feat(admin): App 版本管理 — 多应用支持 + 管理后台页面
为 admin-service 添加 appType 维度,支持管理 genex-mobile (用户端)
和 admin-app (发行方管理端) 两个 Flutter 应用的版本发布。
同时在 admin-web 新增完整的版本管理页面。
### 后端改动 (admin-service)
数据模型:
- 新增 AppType 枚举: GENEX_MOBILE | ADMIN_APP
- app_versions 表添加 app_type 列 (VARCHAR(20), 默认 GENEX_MOBILE)
- 重建唯一索引: (app_type, platform, version_code)
- Migration 046: ALTER TABLE + 索引重建
DDD 各层更新:
- Repository 接口/实现: 所有查询方法增加 appType 参数
- Service: checkUpdate/listVersions/createVersion 支持按 appType 过滤
重复检测范围: 同一 appType + platform 内的 versionCode 唯一
- AdminVersionController:
- GET /admin/versions 增加 ?appType= 查询参数
- POST /admin/versions body 增加 appType 字段
- POST /admin/versions/upload body 增加 appType 字段
- AppVersionController (移动端):
- GET /app/version/check 增加 ?app_type= 参数 (默认 GENEX_MOBILE)
### 前端改动 (admin-web)
新增页面 /app-versions:
- App 选择器 Tab: Genex 用户端 / 发行方管理端
- 平台过滤器: 全部 / Android / iOS
- 版本列表表格: 版本号、代码、平台、构建号、文件大小、强制更新、状态、发布日期
- 操作列: 编辑 / 启用|禁用 / 删除
- 上传对话框: 文件选择 → 自动解析包信息 → 填写表单 → 上传到 MinIO
- 编辑对话框: 更新日志、最低系统版本、强制更新、启用状态
- i18n: zh-CN / en-US / ja-JP 各 28 个新翻译键
- 侧边栏: 在「系统管理」前增加「📱 应用版本」菜单项
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
41b8a8fcfb
commit
c1681085b8
|
|
@ -0,0 +1,12 @@
|
|||
-- Add app_type column to distinguish between genex-mobile and admin-app
|
||||
ALTER TABLE app_versions ADD COLUMN IF NOT EXISTS app_type VARCHAR(20) NOT NULL DEFAULT 'GENEX_MOBILE';
|
||||
|
||||
-- Drop old unique index (platform, version_code) and recreate with app_type
|
||||
DROP INDEX IF EXISTS idx_app_versions_platform_code;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_app_versions_app_platform_code
|
||||
ON app_versions(app_type, platform, version_code);
|
||||
|
||||
-- Update composite index for queries
|
||||
DROP INDEX IF EXISTS idx_app_versions_platform;
|
||||
CREATE INDEX IF NOT EXISTS idx_app_versions_app_platform
|
||||
ON app_versions(app_type, platform, is_enabled);
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Injectable, NotFoundException, ConflictException, Inject, Logger } from '@nestjs/common';
|
||||
import { APP_VERSION_REPOSITORY, IAppVersionRepository } from '../../domain/repositories/app-version.repository.interface';
|
||||
import { Platform } from '../../domain/enums/platform.enum';
|
||||
import { AppType } from '../../domain/enums/app-type.enum';
|
||||
|
||||
@Injectable()
|
||||
export class AppVersionService {
|
||||
|
|
@ -11,8 +12,8 @@ export class AppVersionService {
|
|||
) {}
|
||||
|
||||
/** Check for update - mobile client API */
|
||||
async checkUpdate(platform: Platform, currentVersionCode: number) {
|
||||
const latest = await this.versionRepo.findLatestEnabled(platform);
|
||||
async checkUpdate(appType: AppType, platform: Platform, currentVersionCode: number) {
|
||||
const latest = await this.versionRepo.findLatestEnabled(appType, platform);
|
||||
|
||||
if (!latest || latest.versionCode <= currentVersionCode) {
|
||||
return { needUpdate: false };
|
||||
|
|
@ -34,8 +35,8 @@ export class AppVersionService {
|
|||
}
|
||||
|
||||
/** List versions (admin) */
|
||||
async listVersions(platform?: Platform, includeDisabled = false) {
|
||||
return this.versionRepo.findByFilters(platform, includeDisabled);
|
||||
async listVersions(appType?: AppType, platform?: Platform, includeDisabled = false) {
|
||||
return this.versionRepo.findByFilters(appType, platform, includeDisabled);
|
||||
}
|
||||
|
||||
/** Get version detail */
|
||||
|
|
@ -47,6 +48,7 @@ export class AppVersionService {
|
|||
|
||||
/** Create version (admin) */
|
||||
async createVersion(data: {
|
||||
appType: AppType;
|
||||
platform: Platform;
|
||||
versionCode: number;
|
||||
versionName: string;
|
||||
|
|
@ -60,11 +62,13 @@ export class AppVersionService {
|
|||
releaseDate?: Date;
|
||||
createdBy?: string;
|
||||
}) {
|
||||
// Check duplicate
|
||||
const existing = await this.versionRepo.findByPlatformAndCode(data.platform, data.versionCode);
|
||||
// Check duplicate within same appType + platform
|
||||
const existing = await this.versionRepo.findByPlatformAndCode(
|
||||
data.appType, data.platform, data.versionCode,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Version code ${data.versionCode} already exists for ${data.platform}`,
|
||||
`Version code ${data.versionCode} already exists for ${data.appType}/${data.platform}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,17 @@ import {
|
|||
UpdateDateColumn, VersionColumn, Index,
|
||||
} from 'typeorm';
|
||||
import { Platform } from '../enums/platform.enum';
|
||||
import { AppType } from '../enums/app-type.enum';
|
||||
|
||||
@Entity('app_versions')
|
||||
@Index('idx_app_versions_platform', ['platform', 'isEnabled'])
|
||||
@Index('idx_app_versions_code', ['platform', 'versionCode'])
|
||||
@Index('idx_app_versions_app_platform', ['appType', 'platform', 'isEnabled'])
|
||||
@Index('idx_app_versions_code', ['appType', 'platform', 'versionCode'])
|
||||
export class AppVersion {
|
||||
@PrimaryGeneratedColumn('uuid') id: string;
|
||||
|
||||
@Column({ name: 'app_type', type: 'varchar', length: 20, default: 'GENEX_MOBILE' })
|
||||
appType: AppType;
|
||||
|
||||
@Column({ type: 'varchar', length: 10 })
|
||||
platform: Platform;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
export enum AppType {
|
||||
GENEX_MOBILE = 'GENEX_MOBILE',
|
||||
ADMIN_APP = 'ADMIN_APP',
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
import { AppVersion } from '../entities/app-version.entity';
|
||||
import { Platform } from '../enums/platform.enum';
|
||||
import { AppType } from '../enums/app-type.enum';
|
||||
|
||||
export const APP_VERSION_REPOSITORY = Symbol('APP_VERSION_REPOSITORY');
|
||||
|
||||
export interface IAppVersionRepository {
|
||||
findById(id: string): Promise<AppVersion | null>;
|
||||
findLatestEnabled(platform: Platform): Promise<AppVersion | null>;
|
||||
findByPlatformAndCode(platform: Platform, versionCode: number): Promise<AppVersion | null>;
|
||||
findByFilters(platform?: Platform, includeDisabled?: boolean): Promise<AppVersion[]>;
|
||||
findLatestEnabled(appType: AppType, platform: Platform): Promise<AppVersion | null>;
|
||||
findByPlatformAndCode(appType: AppType, platform: Platform, versionCode: number): Promise<AppVersion | null>;
|
||||
findByFilters(appType?: AppType, platform?: Platform, includeDisabled?: boolean): Promise<AppVersion[]>;
|
||||
create(data: Partial<AppVersion>): AppVersion;
|
||||
save(entity: AppVersion): Promise<AppVersion>;
|
||||
updatePartial(id: string, data: Partial<AppVersion>): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||
import { Repository } from 'typeorm';
|
||||
import { AppVersion } from '../../domain/entities/app-version.entity';
|
||||
import { Platform } from '../../domain/enums/platform.enum';
|
||||
import { AppType } from '../../domain/enums/app-type.enum';
|
||||
import { IAppVersionRepository } from '../../domain/repositories/app-version.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -15,21 +16,22 @@ export class AppVersionRepository implements IAppVersionRepository {
|
|||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findLatestEnabled(platform: Platform): Promise<AppVersion | null> {
|
||||
async findLatestEnabled(appType: AppType, platform: Platform): Promise<AppVersion | null> {
|
||||
return this.repo.findOne({
|
||||
where: { platform, isEnabled: true },
|
||||
where: { appType, platform, isEnabled: true },
|
||||
order: { versionCode: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByPlatformAndCode(platform: Platform, versionCode: number): Promise<AppVersion | null> {
|
||||
async findByPlatformAndCode(appType: AppType, platform: Platform, versionCode: number): Promise<AppVersion | null> {
|
||||
return this.repo.findOne({
|
||||
where: { platform, versionCode },
|
||||
where: { appType, platform, versionCode },
|
||||
});
|
||||
}
|
||||
|
||||
async findByFilters(platform?: Platform, includeDisabled = false): Promise<AppVersion[]> {
|
||||
async findByFilters(appType?: AppType, platform?: Platform, includeDisabled = false): Promise<AppVersion[]> {
|
||||
const where: any = {};
|
||||
if (appType) where.appType = appType;
|
||||
if (platform) where.platform = platform;
|
||||
if (!includeDisabled) where.isEnabled = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import {
|
|||
Param, Query, Body, UseGuards, UseInterceptors, UploadedFile, Req,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes, ApiQuery } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common';
|
||||
import { AppVersionService } from '../../../application/services/app-version.service';
|
||||
import { FileStorageService } from '../../../application/services/file-storage.service';
|
||||
import { PACKAGE_PARSER, IPackageParser } from '../../../domain/ports/package-parser.interface';
|
||||
import { Platform } from '../../../domain/enums/platform.enum';
|
||||
import { AppType } from '../../../domain/enums/app-type.enum';
|
||||
|
||||
@ApiTags('admin-versions')
|
||||
@Controller('admin/versions')
|
||||
|
|
@ -24,14 +25,21 @@ export class AdminVersionController {
|
|||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List app versions' })
|
||||
@ApiQuery({ name: 'appType', required: false, enum: AppType })
|
||||
@ApiQuery({ name: 'platform', required: false, enum: Platform })
|
||||
async listVersions(
|
||||
@Query('appType') appType?: string,
|
||||
@Query('platform') platform?: string,
|
||||
@Query('includeDisabled') includeDisabled?: string,
|
||||
) {
|
||||
const appTypeEnum = appType
|
||||
? (appType.toUpperCase() as AppType)
|
||||
: undefined;
|
||||
const platformEnum = platform
|
||||
? (platform.toUpperCase() as Platform)
|
||||
: undefined;
|
||||
const versions = await this.versionService.listVersions(
|
||||
appTypeEnum,
|
||||
platformEnum,
|
||||
includeDisabled === 'true',
|
||||
);
|
||||
|
|
@ -49,6 +57,7 @@ export class AdminVersionController {
|
|||
@ApiOperation({ summary: 'Create version manually' })
|
||||
async createVersion(
|
||||
@Body() body: {
|
||||
appType: string;
|
||||
platform: string;
|
||||
versionCode: number;
|
||||
versionName: string;
|
||||
|
|
@ -65,6 +74,7 @@ export class AdminVersionController {
|
|||
) {
|
||||
const version = await this.versionService.createVersion({
|
||||
...body,
|
||||
appType: (body.appType || 'GENEX_MOBILE').toUpperCase() as AppType,
|
||||
platform: body.platform.toUpperCase() as Platform,
|
||||
releaseDate: body.releaseDate ? new Date(body.releaseDate) : undefined,
|
||||
createdBy: req.user?.sub,
|
||||
|
|
@ -79,6 +89,7 @@ export class AdminVersionController {
|
|||
async uploadVersion(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() body: {
|
||||
appType?: string;
|
||||
platform: string;
|
||||
versionCode?: string;
|
||||
versionName?: string;
|
||||
|
|
@ -93,6 +104,7 @@ export class AdminVersionController {
|
|||
// Parse package to extract metadata (auto-fill when not provided)
|
||||
const parsedInfo = await this.packageParser.parse(file.buffer, file.originalname);
|
||||
|
||||
const appType: AppType = (body.appType || 'GENEX_MOBILE').toUpperCase() as AppType;
|
||||
const platform: Platform = body.platform
|
||||
? (body.platform.toUpperCase() as Platform)
|
||||
: (parsedInfo.platform as Platform);
|
||||
|
|
@ -111,6 +123,7 @@ export class AdminVersionController {
|
|||
);
|
||||
|
||||
const version = await this.versionService.createVersion({
|
||||
appType,
|
||||
platform,
|
||||
versionCode,
|
||||
versionName,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Response } from 'express';
|
|||
import { AppVersionService } from '../../../application/services/app-version.service';
|
||||
import { FileStorageService } from '../../../application/services/file-storage.service';
|
||||
import { Platform } from '../../../domain/enums/platform.enum';
|
||||
import { AppType } from '../../../domain/enums/app-type.enum';
|
||||
|
||||
@ApiTags('app-version')
|
||||
@Controller('app/version')
|
||||
|
|
@ -15,14 +16,18 @@ export class AppVersionController {
|
|||
|
||||
@Get('check')
|
||||
@ApiOperation({ summary: 'Check for app update (mobile client)' })
|
||||
@ApiQuery({ name: 'app_type', required: false, enum: AppType, description: 'Default: GENEX_MOBILE' })
|
||||
@ApiQuery({ name: 'platform', enum: ['android', 'ios', 'ANDROID', 'IOS'] })
|
||||
@ApiQuery({ name: 'current_version_code', type: Number })
|
||||
async checkUpdate(
|
||||
@Query('app_type') appType: string,
|
||||
@Query('platform') platform: string,
|
||||
@Query('current_version_code') currentVersionCode: string,
|
||||
) {
|
||||
const appTypeEnum = (appType || 'GENEX_MOBILE').toUpperCase() as AppType;
|
||||
const platformEnum = platform.toUpperCase() as Platform;
|
||||
const result = await this.versionService.checkUpdate(
|
||||
appTypeEnum,
|
||||
platformEnum,
|
||||
parseInt(currentVersionCode, 10),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { AppVersionManagementPage } from '@/views/app-versions/AppVersionManagementPage';
|
||||
|
||||
export default function AppVersions() {
|
||||
return <AppVersionManagementPage />;
|
||||
}
|
||||
|
|
@ -130,6 +130,32 @@ const translations: Record<Locale, Record<string, string>> = {
|
|||
'nav_agent': '代理面板',
|
||||
'nav_market_maker': '做市商',
|
||||
'nav_insurance': '保险管理',
|
||||
'nav_app_versions': '应用版本',
|
||||
|
||||
// ── App Version Management ──
|
||||
'app_version_title': '应用版本管理',
|
||||
'app_version_genex_mobile': 'Genex 用户端',
|
||||
'app_version_admin_app': '发行方管理端',
|
||||
'app_version_all_platforms': '全部平台',
|
||||
'app_version_upload': '上传新版本',
|
||||
'app_version_version_name': '版本号',
|
||||
'app_version_version_code': '版本代码',
|
||||
'app_version_build_number': '构建号',
|
||||
'app_version_platform': '平台',
|
||||
'app_version_file_size': '文件大小',
|
||||
'app_version_force_update': '强制更新',
|
||||
'app_version_enabled': '已启用',
|
||||
'app_version_disabled': '已禁用',
|
||||
'app_version_changelog': '更新日志',
|
||||
'app_version_min_os': '最低系统版本',
|
||||
'app_version_release_date': '发布日期',
|
||||
'app_version_upload_file': '选择 APK/IPA 文件',
|
||||
'app_version_parsing': '解析中...',
|
||||
'app_version_uploading': '上传中...',
|
||||
'app_version_confirm_delete': '确定删除此版本?此操作不可撤销。',
|
||||
'app_version_no_versions': '暂无版本记录',
|
||||
'app_version_edit': '编辑版本',
|
||||
'app_version_created_at': '创建时间',
|
||||
|
||||
// ── Header ──
|
||||
'header_search_placeholder': '搜索用户/订单/交易...',
|
||||
|
|
@ -863,6 +889,32 @@ const translations: Record<Locale, Record<string, string>> = {
|
|||
'nav_agent': 'Agent Panel',
|
||||
'nav_market_maker': 'Market Maker',
|
||||
'nav_insurance': 'Insurance',
|
||||
'nav_app_versions': 'App Versions',
|
||||
|
||||
// ── App Version Management ──
|
||||
'app_version_title': 'App Version Management',
|
||||
'app_version_genex_mobile': 'Genex Mobile',
|
||||
'app_version_admin_app': 'Admin App',
|
||||
'app_version_all_platforms': 'All Platforms',
|
||||
'app_version_upload': 'Upload Version',
|
||||
'app_version_version_name': 'Version',
|
||||
'app_version_version_code': 'Version Code',
|
||||
'app_version_build_number': 'Build Number',
|
||||
'app_version_platform': 'Platform',
|
||||
'app_version_file_size': 'File Size',
|
||||
'app_version_force_update': 'Force Update',
|
||||
'app_version_enabled': 'Enabled',
|
||||
'app_version_disabled': 'Disabled',
|
||||
'app_version_changelog': 'Changelog',
|
||||
'app_version_min_os': 'Min OS Version',
|
||||
'app_version_release_date': 'Release Date',
|
||||
'app_version_upload_file': 'Select APK/IPA File',
|
||||
'app_version_parsing': 'Parsing...',
|
||||
'app_version_uploading': 'Uploading...',
|
||||
'app_version_confirm_delete': 'Delete this version? This action cannot be undone.',
|
||||
'app_version_no_versions': 'No versions found',
|
||||
'app_version_edit': 'Edit Version',
|
||||
'app_version_created_at': 'Created At',
|
||||
|
||||
// ── Header ──
|
||||
'header_search_placeholder': 'Search users/orders/trades...',
|
||||
|
|
@ -1596,6 +1648,32 @@ const translations: Record<Locale, Record<string, string>> = {
|
|||
'nav_agent': 'エージェントパネル',
|
||||
'nav_market_maker': 'マーケットメーカー',
|
||||
'nav_insurance': '保険管理',
|
||||
'nav_app_versions': 'アプリバージョン',
|
||||
|
||||
// ── App Version Management ──
|
||||
'app_version_title': 'アプリバージョン管理',
|
||||
'app_version_genex_mobile': 'Genex モバイル',
|
||||
'app_version_admin_app': '管理アプリ',
|
||||
'app_version_all_platforms': '全プラットフォーム',
|
||||
'app_version_upload': 'バージョンをアップロード',
|
||||
'app_version_version_name': 'バージョン',
|
||||
'app_version_version_code': 'バージョンコード',
|
||||
'app_version_build_number': 'ビルド番号',
|
||||
'app_version_platform': 'プラットフォーム',
|
||||
'app_version_file_size': 'ファイルサイズ',
|
||||
'app_version_force_update': '強制更新',
|
||||
'app_version_enabled': '有効',
|
||||
'app_version_disabled': '無効',
|
||||
'app_version_changelog': '更新ログ',
|
||||
'app_version_min_os': '最低OSバージョン',
|
||||
'app_version_release_date': 'リリース日',
|
||||
'app_version_upload_file': 'APK/IPAファイルを選択',
|
||||
'app_version_parsing': '解析中...',
|
||||
'app_version_uploading': 'アップロード中...',
|
||||
'app_version_confirm_delete': 'このバージョンを削除しますか?この操作は元に戻せません。',
|
||||
'app_version_no_versions': 'バージョンが見つかりません',
|
||||
'app_version_edit': 'バージョンを編集',
|
||||
'app_version_created_at': '作成日時',
|
||||
|
||||
// ── Header ──
|
||||
'header_search_placeholder': 'ユーザー/注文/取引を検索...',
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const navItems: NavItem[] = [
|
|||
{ key: 'compliance/reports', icon: '', label: t('nav_compliance_reports') },
|
||||
],
|
||||
},
|
||||
{ key: 'app-versions', icon: '📱', label: t('nav_app_versions') },
|
||||
{
|
||||
key: 'system', icon: '⚙️', label: t('nav_system'),
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,496 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { t } from '@/i18n/locales';
|
||||
import { useApi, useApiMutation } from '@/lib/use-api';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
interface AppVersion {
|
||||
id: string;
|
||||
appType: string;
|
||||
platform: string;
|
||||
versionCode: number;
|
||||
versionName: string;
|
||||
buildNumber: string;
|
||||
downloadUrl: string;
|
||||
fileSize: string;
|
||||
fileSha256: string;
|
||||
minOsVersion: string | null;
|
||||
changelog: string;
|
||||
isForceUpdate: boolean;
|
||||
isEnabled: boolean;
|
||||
releaseDate: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
type AppType = 'GENEX_MOBILE' | 'ADMIN_APP';
|
||||
type PlatformFilter = '' | 'ANDROID' | 'IOS';
|
||||
|
||||
/* ── Styles ── */
|
||||
|
||||
const loadingBox: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||
};
|
||||
|
||||
const tabBtn = (active: boolean): React.CSSProperties => ({
|
||||
padding: '8px 20px',
|
||||
border: 'none',
|
||||
borderBottom: active ? '2px solid var(--color-primary)' : '2px solid transparent',
|
||||
background: 'transparent',
|
||||
color: active ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||
font: 'var(--text-label)',
|
||||
cursor: 'pointer',
|
||||
fontWeight: active ? 600 : 400,
|
||||
});
|
||||
|
||||
const filterBtn = (active: boolean): React.CSSProperties => ({
|
||||
padding: '4px 14px',
|
||||
border: `1px solid ${active ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-full)',
|
||||
background: active ? 'var(--color-primary-surface)' : 'transparent',
|
||||
color: active ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||
font: 'var(--text-label-sm)',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
padding: '8px 20px', border: 'none', borderRadius: 'var(--radius-full)',
|
||||
background: 'var(--color-primary)', color: 'white', cursor: 'pointer',
|
||||
font: 'var(--text-label-sm)',
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
font: 'var(--text-label-sm)', color: 'var(--color-text-tertiary)',
|
||||
padding: '12px 16px', textAlign: 'left', whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
font: 'var(--text-body)', padding: '12px 16px', color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const badge = (bg: string, color: string): React.CSSProperties => ({
|
||||
padding: '2px 10px', borderRadius: 'var(--radius-full)',
|
||||
background: bg, color, font: 'var(--text-caption)', fontWeight: 500,
|
||||
});
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: 'var(--color-surface)', borderRadius: 'var(--radius-lg)',
|
||||
padding: 28, width: 520, maxHeight: '80vh', overflow: 'auto',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', height: 40, border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '0 12px', font: 'var(--text-body)',
|
||||
background: 'var(--color-gray-50)', outline: 'none', boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
font: 'var(--text-label-sm)', color: 'var(--color-text-secondary)',
|
||||
display: 'block', marginBottom: 4, marginTop: 14,
|
||||
};
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
function formatFileSize(bytes: string | number): string {
|
||||
const n = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
|
||||
if (!n || isNaN(n)) return '-';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '-';
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
export const AppVersionManagementPage: React.FC = () => {
|
||||
const [appType, setAppType] = useState<AppType>('GENEX_MOBILE');
|
||||
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('');
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [editingVersion, setEditingVersion] = useState<AppVersion | null>(null);
|
||||
|
||||
const { data: versions, isLoading, error, refetch } = useApi<AppVersion[]>(
|
||||
'/api/v1/admin/versions',
|
||||
{ params: { appType, platform: platformFilter || undefined, includeDisabled: 'true' } },
|
||||
);
|
||||
|
||||
const toggleMutation = useApiMutation<void>('PATCH', '', {
|
||||
invalidateKeys: ['/api/v1/admin/versions'],
|
||||
});
|
||||
|
||||
const deleteMutation = useApiMutation<void>('DELETE', '', {
|
||||
invalidateKeys: ['/api/v1/admin/versions'],
|
||||
});
|
||||
|
||||
const handleToggle = async (v: AppVersion) => {
|
||||
await apiClient.patch(`/api/v1/admin/versions/${v.id}/toggle`, { isEnabled: !v.isEnabled });
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleDelete = async (v: AppVersion) => {
|
||||
if (!confirm(t('app_version_confirm_delete'))) return;
|
||||
await apiClient.delete(`/api/v1/admin/versions/${v.id}`);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const list = versions ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h1 style={{ font: 'var(--text-h1)', margin: 0 }}>{t('app_version_title')}</h1>
|
||||
<button style={primaryBtn} onClick={() => setShowUpload(true)}>
|
||||
+ {t('app_version_upload')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* App type tabs */}
|
||||
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--color-border-light)', marginBottom: 16 }}>
|
||||
<button style={tabBtn(appType === 'GENEX_MOBILE')} onClick={() => setAppType('GENEX_MOBILE')}>
|
||||
{t('app_version_genex_mobile')}
|
||||
</button>
|
||||
<button style={tabBtn(appType === 'ADMIN_APP')} onClick={() => setAppType('ADMIN_APP')}>
|
||||
{t('app_version_admin_app')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Platform filter */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||
{(['', 'ANDROID', 'IOS'] as PlatformFilter[]).map(p => (
|
||||
<button key={p || 'all'} style={filterBtn(platformFilter === p)} onClick={() => setPlatformFilter(p)}>
|
||||
{p === '' ? t('app_version_all_platforms') : p === 'ANDROID' ? 'Android' : 'iOS'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{error ? (
|
||||
<div style={loadingBox}>Error: {error.message}</div>
|
||||
) : isLoading ? (
|
||||
<div style={loadingBox}>Loading...</div>
|
||||
) : list.length === 0 ? (
|
||||
<div style={loadingBox}>{t('app_version_no_versions')}</div>
|
||||
) : (
|
||||
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-light)', overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--color-gray-50)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<th style={thStyle}>{t('app_version_version_name')}</th>
|
||||
<th style={thStyle}>{t('app_version_version_code')}</th>
|
||||
<th style={thStyle}>{t('app_version_platform')}</th>
|
||||
<th style={thStyle}>{t('app_version_build_number')}</th>
|
||||
<th style={thStyle}>{t('app_version_file_size')}</th>
|
||||
<th style={thStyle}>{t('app_version_force_update')}</th>
|
||||
<th style={thStyle}>{t('status')}</th>
|
||||
<th style={thStyle}>{t('app_version_release_date')}</th>
|
||||
<th style={thStyle}>{t('actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map(v => (
|
||||
<tr key={v.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<td style={{ ...tdStyle, fontWeight: 600 }}>{v.versionName}</td>
|
||||
<td style={tdStyle}>{v.versionCode}</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={badge(
|
||||
v.platform === 'ANDROID' ? 'var(--color-success-light)' : 'var(--color-info-light)',
|
||||
v.platform === 'ANDROID' ? 'var(--color-success)' : 'var(--color-info)',
|
||||
)}>
|
||||
{v.platform === 'ANDROID' ? 'Android' : 'iOS'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)' }}>
|
||||
{v.buildNumber}
|
||||
</td>
|
||||
<td style={tdStyle}>{formatFileSize(v.fileSize)}</td>
|
||||
<td style={tdStyle}>
|
||||
{v.isForceUpdate ? (
|
||||
<span style={badge('var(--color-error-light)', 'var(--color-error)')}>{t('app_version_force_update')}</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={badge(
|
||||
v.isEnabled ? 'var(--color-success-light)' : 'var(--color-gray-100)',
|
||||
v.isEnabled ? 'var(--color-success)' : 'var(--color-text-tertiary)',
|
||||
)}>
|
||||
{v.isEnabled ? t('app_version_enabled') : t('app_version_disabled')}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)' }}>
|
||||
{formatDate(v.releaseDate || v.createdAt)}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
onClick={() => setEditingVersion(v)}
|
||||
style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', padding: '3px 10px', background: 'transparent', cursor: 'pointer', font: 'var(--text-caption)', marginRight: 6 }}
|
||||
>
|
||||
{t('edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(v)}
|
||||
style={{ border: `1px solid ${v.isEnabled ? 'var(--color-warning)' : 'var(--color-success)'}`, borderRadius: 'var(--radius-sm)', padding: '3px 10px', background: 'transparent', cursor: 'pointer', font: 'var(--text-caption)', color: v.isEnabled ? 'var(--color-warning)' : 'var(--color-success)', marginRight: 6 }}
|
||||
>
|
||||
{v.isEnabled ? t('disable') : t('enable')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(v)}
|
||||
style={{ border: '1px solid var(--color-error)', borderRadius: 'var(--radius-sm)', padding: '3px 10px', background: 'transparent', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-error)' }}
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUpload && (
|
||||
<UploadModal
|
||||
appType={appType}
|
||||
onClose={() => setShowUpload(false)}
|
||||
onSuccess={() => { setShowUpload(false); refetch(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingVersion && (
|
||||
<EditModal
|
||||
version={editingVersion}
|
||||
onClose={() => setEditingVersion(null)}
|
||||
onSuccess={() => { setEditingVersion(null); refetch(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Upload Modal ── */
|
||||
|
||||
const UploadModal: React.FC<{
|
||||
appType: AppType;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}> = ({ appType, onClose, onSuccess }) => {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [platform, setPlatform] = useState<'ANDROID' | 'IOS'>('ANDROID');
|
||||
const [versionName, setVersionName] = useState('');
|
||||
const [buildNumber, setBuildNumber] = useState('');
|
||||
const [changelog, setChangelog] = useState('');
|
||||
const [minOsVersion, setMinOsVersion] = useState('');
|
||||
const [isForceUpdate, setIsForceUpdate] = useState(false);
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
setFile(f);
|
||||
|
||||
// Auto-detect platform
|
||||
if (f.name.endsWith('.apk')) setPlatform('ANDROID');
|
||||
else if (f.name.endsWith('.ipa')) setPlatform('IOS');
|
||||
|
||||
// Auto-parse
|
||||
setParsing(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', f);
|
||||
const info = await apiClient.post<{
|
||||
versionCode?: number; versionName?: string; minSdkVersion?: string;
|
||||
}>('/api/v1/admin/versions/parse', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000,
|
||||
});
|
||||
if (info?.versionName) setVersionName(info.versionName);
|
||||
if (info?.versionCode) setBuildNumber(String(info.versionCode));
|
||||
if (info?.minSdkVersion) setMinOsVersion(info.minSdkVersion);
|
||||
} catch {
|
||||
// Parsing failed, allow manual entry
|
||||
}
|
||||
setParsing(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file || !versionName) {
|
||||
setError(t('app_version_upload_file'));
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('appType', appType);
|
||||
formData.append('platform', platform);
|
||||
formData.append('versionName', versionName);
|
||||
formData.append('buildNumber', buildNumber || '1');
|
||||
formData.append('changelog', changelog);
|
||||
formData.append('minOsVersion', minOsVersion);
|
||||
formData.append('isForceUpdate', String(isForceUpdate));
|
||||
|
||||
await apiClient.post('/api/v1/admin/versions/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 300000,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Upload failed');
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={e => e.stopPropagation()}>
|
||||
<h2 style={{ font: 'var(--text-h2)', margin: '0 0 8px' }}>{t('app_version_upload')}</h2>
|
||||
|
||||
<label style={labelStyle}>{t('app_version_upload_file')}</label>
|
||||
<input
|
||||
ref={fileRef} type="file" accept=".apk,.ipa"
|
||||
onChange={handleFileChange}
|
||||
style={{ ...inputStyle, padding: '8px 12px', height: 'auto' }}
|
||||
/>
|
||||
{parsing && <div style={{ font: 'var(--text-caption)', color: 'var(--color-info)', marginTop: 4 }}>{t('app_version_parsing')}</div>}
|
||||
|
||||
<label style={labelStyle}>{t('app_version_platform')}</label>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{(['ANDROID', 'IOS'] as const).map(p => (
|
||||
<label key={p} style={{ font: 'var(--text-body)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input type="radio" checked={platform === p} onChange={() => setPlatform(p)} />
|
||||
{p === 'ANDROID' ? 'Android' : 'iOS'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label style={labelStyle}>{t('app_version_version_name')} *</label>
|
||||
<input style={inputStyle} value={versionName} onChange={e => setVersionName(e.target.value)} placeholder="1.0.0" />
|
||||
|
||||
<label style={labelStyle}>{t('app_version_build_number')}</label>
|
||||
<input style={inputStyle} value={buildNumber} onChange={e => setBuildNumber(e.target.value)} placeholder="100" />
|
||||
|
||||
<label style={labelStyle}>{t('app_version_min_os')}</label>
|
||||
<input style={inputStyle} value={minOsVersion} onChange={e => setMinOsVersion(e.target.value)}
|
||||
placeholder={platform === 'ANDROID' ? 'e.g. 8.0' : 'e.g. 14.0'} />
|
||||
|
||||
<label style={labelStyle}>{t('app_version_changelog')}</label>
|
||||
<textarea
|
||||
value={changelog} onChange={e => setChangelog(e.target.value)} rows={3}
|
||||
style={{ ...inputStyle, height: 'auto', padding: '8px 12px', resize: 'vertical' }}
|
||||
placeholder={t('app_version_changelog')}
|
||||
/>
|
||||
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 8, marginTop: 16 }}>
|
||||
<input type="checkbox" checked={isForceUpdate} onChange={e => setIsForceUpdate(e.target.checked)} />
|
||||
{t('app_version_force_update')}
|
||||
</label>
|
||||
|
||||
{error && <div style={{ color: 'var(--color-error)', font: 'var(--text-body-sm)', marginTop: 8 }}>{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 20, justifyContent: 'flex-end' }}>
|
||||
<button onClick={onClose} style={{ padding: '8px 20px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-full)', background: 'transparent', cursor: 'pointer', font: 'var(--text-label-sm)' }}>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={uploading || parsing} style={{ ...primaryBtn, opacity: uploading || parsing ? 0.6 : 1 }}>
|
||||
{uploading ? t('app_version_uploading') : t('confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Edit Modal ── */
|
||||
|
||||
const EditModal: React.FC<{
|
||||
version: AppVersion;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}> = ({ version, onClose, onSuccess }) => {
|
||||
const [changelog, setChangelog] = useState(version.changelog);
|
||||
const [minOsVersion, setMinOsVersion] = useState(version.minOsVersion || '');
|
||||
const [isForceUpdate, setIsForceUpdate] = useState(version.isForceUpdate);
|
||||
const [isEnabled, setIsEnabled] = useState(version.isEnabled);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await apiClient.put(`/api/v1/admin/versions/${version.id}`, {
|
||||
changelog,
|
||||
minOsVersion: minOsVersion || null,
|
||||
isForceUpdate,
|
||||
isEnabled,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Save failed');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={e => e.stopPropagation()}>
|
||||
<h2 style={{ font: 'var(--text-h2)', margin: '0 0 4px' }}>{t('app_version_edit')}</h2>
|
||||
<div style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', marginBottom: 16 }}>
|
||||
{version.platform === 'ANDROID' ? 'Android' : 'iOS'} · v{version.versionName} · Build {version.buildNumber}
|
||||
</div>
|
||||
|
||||
<label style={labelStyle}>{t('app_version_changelog')}</label>
|
||||
<textarea
|
||||
value={changelog} onChange={e => setChangelog(e.target.value)} rows={4}
|
||||
style={{ ...inputStyle, height: 'auto', padding: '8px 12px', resize: 'vertical' }}
|
||||
/>
|
||||
|
||||
<label style={labelStyle}>{t('app_version_min_os')}</label>
|
||||
<input style={inputStyle} value={minOsVersion} onChange={e => setMinOsVersion(e.target.value)}
|
||||
placeholder={version.platform === 'ANDROID' ? 'e.g. 8.0' : 'e.g. 14.0'} />
|
||||
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 8, marginTop: 16 }}>
|
||||
<input type="checkbox" checked={isForceUpdate} onChange={e => setIsForceUpdate(e.target.checked)} />
|
||||
{t('app_version_force_update')}
|
||||
</label>
|
||||
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={isEnabled} onChange={e => setIsEnabled(e.target.checked)} />
|
||||
{t('app_version_enabled')}
|
||||
</label>
|
||||
|
||||
{error && <div style={{ color: 'var(--color-error)', font: 'var(--text-body-sm)', marginTop: 8 }}>{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 20, justifyContent: 'flex-end' }}>
|
||||
<button onClick={onClose} style={{ padding: '8px 20px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-full)', background: 'transparent', cursor: 'pointer', font: 'var(--text-label-sm)' }}>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving} style={{ ...primaryBtn, opacity: saving ? 0.6 : 1 }}>
|
||||
{saving ? '...' : t('save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue