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:
hailin 2026-03-03 06:09:55 -08:00
parent 41b8a8fcfb
commit c1681085b8
12 changed files with 643 additions and 18 deletions

View File

@ -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);

View File

@ -1,6 +1,7 @@
import { Injectable, NotFoundException, ConflictException, Inject, Logger } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException, Inject, Logger } from '@nestjs/common';
import { APP_VERSION_REPOSITORY, IAppVersionRepository } from '../../domain/repositories/app-version.repository.interface'; import { APP_VERSION_REPOSITORY, IAppVersionRepository } from '../../domain/repositories/app-version.repository.interface';
import { Platform } from '../../domain/enums/platform.enum'; import { Platform } from '../../domain/enums/platform.enum';
import { AppType } from '../../domain/enums/app-type.enum';
@Injectable() @Injectable()
export class AppVersionService { export class AppVersionService {
@ -11,8 +12,8 @@ export class AppVersionService {
) {} ) {}
/** Check for update - mobile client API */ /** Check for update - mobile client API */
async checkUpdate(platform: Platform, currentVersionCode: number) { async checkUpdate(appType: AppType, platform: Platform, currentVersionCode: number) {
const latest = await this.versionRepo.findLatestEnabled(platform); const latest = await this.versionRepo.findLatestEnabled(appType, platform);
if (!latest || latest.versionCode <= currentVersionCode) { if (!latest || latest.versionCode <= currentVersionCode) {
return { needUpdate: false }; return { needUpdate: false };
@ -34,8 +35,8 @@ export class AppVersionService {
} }
/** List versions (admin) */ /** List versions (admin) */
async listVersions(platform?: Platform, includeDisabled = false) { async listVersions(appType?: AppType, platform?: Platform, includeDisabled = false) {
return this.versionRepo.findByFilters(platform, includeDisabled); return this.versionRepo.findByFilters(appType, platform, includeDisabled);
} }
/** Get version detail */ /** Get version detail */
@ -47,6 +48,7 @@ export class AppVersionService {
/** Create version (admin) */ /** Create version (admin) */
async createVersion(data: { async createVersion(data: {
appType: AppType;
platform: Platform; platform: Platform;
versionCode: number; versionCode: number;
versionName: string; versionName: string;
@ -60,11 +62,13 @@ export class AppVersionService {
releaseDate?: Date; releaseDate?: Date;
createdBy?: string; createdBy?: string;
}) { }) {
// Check duplicate // Check duplicate within same appType + platform
const existing = await this.versionRepo.findByPlatformAndCode(data.platform, data.versionCode); const existing = await this.versionRepo.findByPlatformAndCode(
data.appType, data.platform, data.versionCode,
);
if (existing) { if (existing) {
throw new ConflictException( throw new ConflictException(
`Version code ${data.versionCode} already exists for ${data.platform}`, `Version code ${data.versionCode} already exists for ${data.appType}/${data.platform}`,
); );
} }

View File

@ -3,13 +3,17 @@ import {
UpdateDateColumn, VersionColumn, Index, UpdateDateColumn, VersionColumn, Index,
} from 'typeorm'; } from 'typeorm';
import { Platform } from '../enums/platform.enum'; import { Platform } from '../enums/platform.enum';
import { AppType } from '../enums/app-type.enum';
@Entity('app_versions') @Entity('app_versions')
@Index('idx_app_versions_platform', ['platform', 'isEnabled']) @Index('idx_app_versions_app_platform', ['appType', 'platform', 'isEnabled'])
@Index('idx_app_versions_code', ['platform', 'versionCode']) @Index('idx_app_versions_code', ['appType', 'platform', 'versionCode'])
export class AppVersion { export class AppVersion {
@PrimaryGeneratedColumn('uuid') id: string; @PrimaryGeneratedColumn('uuid') id: string;
@Column({ name: 'app_type', type: 'varchar', length: 20, default: 'GENEX_MOBILE' })
appType: AppType;
@Column({ type: 'varchar', length: 10 }) @Column({ type: 'varchar', length: 10 })
platform: Platform; platform: Platform;

View File

@ -0,0 +1,4 @@
export enum AppType {
GENEX_MOBILE = 'GENEX_MOBILE',
ADMIN_APP = 'ADMIN_APP',
}

View File

@ -1,13 +1,14 @@
import { AppVersion } from '../entities/app-version.entity'; import { AppVersion } from '../entities/app-version.entity';
import { Platform } from '../enums/platform.enum'; import { Platform } from '../enums/platform.enum';
import { AppType } from '../enums/app-type.enum';
export const APP_VERSION_REPOSITORY = Symbol('APP_VERSION_REPOSITORY'); export const APP_VERSION_REPOSITORY = Symbol('APP_VERSION_REPOSITORY');
export interface IAppVersionRepository { export interface IAppVersionRepository {
findById(id: string): Promise<AppVersion | null>; findById(id: string): Promise<AppVersion | null>;
findLatestEnabled(platform: Platform): Promise<AppVersion | null>; findLatestEnabled(appType: AppType, platform: Platform): Promise<AppVersion | null>;
findByPlatformAndCode(platform: Platform, versionCode: number): Promise<AppVersion | null>; findByPlatformAndCode(appType: AppType, platform: Platform, versionCode: number): Promise<AppVersion | null>;
findByFilters(platform?: Platform, includeDisabled?: boolean): Promise<AppVersion[]>; findByFilters(appType?: AppType, platform?: Platform, includeDisabled?: boolean): Promise<AppVersion[]>;
create(data: Partial<AppVersion>): AppVersion; create(data: Partial<AppVersion>): AppVersion;
save(entity: AppVersion): Promise<AppVersion>; save(entity: AppVersion): Promise<AppVersion>;
updatePartial(id: string, data: Partial<AppVersion>): Promise<void>; updatePartial(id: string, data: Partial<AppVersion>): Promise<void>;

View File

@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AppVersion } from '../../domain/entities/app-version.entity'; import { AppVersion } from '../../domain/entities/app-version.entity';
import { Platform } from '../../domain/enums/platform.enum'; import { Platform } from '../../domain/enums/platform.enum';
import { AppType } from '../../domain/enums/app-type.enum';
import { IAppVersionRepository } from '../../domain/repositories/app-version.repository.interface'; import { IAppVersionRepository } from '../../domain/repositories/app-version.repository.interface';
@Injectable() @Injectable()
@ -15,21 +16,22 @@ export class AppVersionRepository implements IAppVersionRepository {
return this.repo.findOne({ where: { id } }); 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({ return this.repo.findOne({
where: { platform, isEnabled: true }, where: { appType, platform, isEnabled: true },
order: { versionCode: 'DESC' }, 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({ 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 = {}; const where: any = {};
if (appType) where.appType = appType;
if (platform) where.platform = platform; if (platform) where.platform = platform;
if (!includeDisabled) where.isEnabled = true; if (!includeDisabled) where.isEnabled = true;

View File

@ -3,12 +3,13 @@ import {
Param, Query, Body, UseGuards, UseInterceptors, UploadedFile, Req, Param, Query, Body, UseGuards, UseInterceptors, UploadedFile, Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; 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 { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common';
import { AppVersionService } from '../../../application/services/app-version.service'; import { AppVersionService } from '../../../application/services/app-version.service';
import { FileStorageService } from '../../../application/services/file-storage.service'; import { FileStorageService } from '../../../application/services/file-storage.service';
import { PACKAGE_PARSER, IPackageParser } from '../../../domain/ports/package-parser.interface'; import { PACKAGE_PARSER, IPackageParser } from '../../../domain/ports/package-parser.interface';
import { Platform } from '../../../domain/enums/platform.enum'; import { Platform } from '../../../domain/enums/platform.enum';
import { AppType } from '../../../domain/enums/app-type.enum';
@ApiTags('admin-versions') @ApiTags('admin-versions')
@Controller('admin/versions') @Controller('admin/versions')
@ -24,14 +25,21 @@ export class AdminVersionController {
@Get() @Get()
@ApiOperation({ summary: 'List app versions' }) @ApiOperation({ summary: 'List app versions' })
@ApiQuery({ name: 'appType', required: false, enum: AppType })
@ApiQuery({ name: 'platform', required: false, enum: Platform })
async listVersions( async listVersions(
@Query('appType') appType?: string,
@Query('platform') platform?: string, @Query('platform') platform?: string,
@Query('includeDisabled') includeDisabled?: string, @Query('includeDisabled') includeDisabled?: string,
) { ) {
const appTypeEnum = appType
? (appType.toUpperCase() as AppType)
: undefined;
const platformEnum = platform const platformEnum = platform
? (platform.toUpperCase() as Platform) ? (platform.toUpperCase() as Platform)
: undefined; : undefined;
const versions = await this.versionService.listVersions( const versions = await this.versionService.listVersions(
appTypeEnum,
platformEnum, platformEnum,
includeDisabled === 'true', includeDisabled === 'true',
); );
@ -49,6 +57,7 @@ export class AdminVersionController {
@ApiOperation({ summary: 'Create version manually' }) @ApiOperation({ summary: 'Create version manually' })
async createVersion( async createVersion(
@Body() body: { @Body() body: {
appType: string;
platform: string; platform: string;
versionCode: number; versionCode: number;
versionName: string; versionName: string;
@ -65,6 +74,7 @@ export class AdminVersionController {
) { ) {
const version = await this.versionService.createVersion({ const version = await this.versionService.createVersion({
...body, ...body,
appType: (body.appType || 'GENEX_MOBILE').toUpperCase() as AppType,
platform: body.platform.toUpperCase() as Platform, platform: body.platform.toUpperCase() as Platform,
releaseDate: body.releaseDate ? new Date(body.releaseDate) : undefined, releaseDate: body.releaseDate ? new Date(body.releaseDate) : undefined,
createdBy: req.user?.sub, createdBy: req.user?.sub,
@ -79,6 +89,7 @@ export class AdminVersionController {
async uploadVersion( async uploadVersion(
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Body() body: { @Body() body: {
appType?: string;
platform: string; platform: string;
versionCode?: string; versionCode?: string;
versionName?: string; versionName?: string;
@ -93,6 +104,7 @@ export class AdminVersionController {
// Parse package to extract metadata (auto-fill when not provided) // Parse package to extract metadata (auto-fill when not provided)
const parsedInfo = await this.packageParser.parse(file.buffer, file.originalname); 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 const platform: Platform = body.platform
? (body.platform.toUpperCase() as Platform) ? (body.platform.toUpperCase() as Platform)
: (parsedInfo.platform as Platform); : (parsedInfo.platform as Platform);
@ -111,6 +123,7 @@ export class AdminVersionController {
); );
const version = await this.versionService.createVersion({ const version = await this.versionService.createVersion({
appType,
platform, platform,
versionCode, versionCode,
versionName, versionName,

View File

@ -4,6 +4,7 @@ import { Response } from 'express';
import { AppVersionService } from '../../../application/services/app-version.service'; import { AppVersionService } from '../../../application/services/app-version.service';
import { FileStorageService } from '../../../application/services/file-storage.service'; import { FileStorageService } from '../../../application/services/file-storage.service';
import { Platform } from '../../../domain/enums/platform.enum'; import { Platform } from '../../../domain/enums/platform.enum';
import { AppType } from '../../../domain/enums/app-type.enum';
@ApiTags('app-version') @ApiTags('app-version')
@Controller('app/version') @Controller('app/version')
@ -15,14 +16,18 @@ export class AppVersionController {
@Get('check') @Get('check')
@ApiOperation({ summary: 'Check for app update (mobile client)' }) @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: 'platform', enum: ['android', 'ios', 'ANDROID', 'IOS'] })
@ApiQuery({ name: 'current_version_code', type: Number }) @ApiQuery({ name: 'current_version_code', type: Number })
async checkUpdate( async checkUpdate(
@Query('app_type') appType: string,
@Query('platform') platform: string, @Query('platform') platform: string,
@Query('current_version_code') currentVersionCode: string, @Query('current_version_code') currentVersionCode: string,
) { ) {
const appTypeEnum = (appType || 'GENEX_MOBILE').toUpperCase() as AppType;
const platformEnum = platform.toUpperCase() as Platform; const platformEnum = platform.toUpperCase() as Platform;
const result = await this.versionService.checkUpdate( const result = await this.versionService.checkUpdate(
appTypeEnum,
platformEnum, platformEnum,
parseInt(currentVersionCode, 10), parseInt(currentVersionCode, 10),
); );

View File

@ -0,0 +1,5 @@
import { AppVersionManagementPage } from '@/views/app-versions/AppVersionManagementPage';
export default function AppVersions() {
return <AppVersionManagementPage />;
}

View File

@ -130,6 +130,32 @@ const translations: Record<Locale, Record<string, string>> = {
'nav_agent': '代理面板', 'nav_agent': '代理面板',
'nav_market_maker': '做市商', 'nav_market_maker': '做市商',
'nav_insurance': '保险管理', '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 ──
'header_search_placeholder': '搜索用户/订单/交易...', 'header_search_placeholder': '搜索用户/订单/交易...',
@ -863,6 +889,32 @@ const translations: Record<Locale, Record<string, string>> = {
'nav_agent': 'Agent Panel', 'nav_agent': 'Agent Panel',
'nav_market_maker': 'Market Maker', 'nav_market_maker': 'Market Maker',
'nav_insurance': 'Insurance', '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 ──
'header_search_placeholder': 'Search users/orders/trades...', 'header_search_placeholder': 'Search users/orders/trades...',
@ -1596,6 +1648,32 @@ const translations: Record<Locale, Record<string, string>> = {
'nav_agent': 'エージェントパネル', 'nav_agent': 'エージェントパネル',
'nav_market_maker': 'マーケットメーカー', 'nav_market_maker': 'マーケットメーカー',
'nav_insurance': '保険管理', '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 ──
'header_search_placeholder': 'ユーザー/注文/取引を検索...', 'header_search_placeholder': 'ユーザー/注文/取引を検索...',

View File

@ -65,6 +65,7 @@ const navItems: NavItem[] = [
{ key: 'compliance/reports', icon: '', label: t('nav_compliance_reports') }, { 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'), key: 'system', icon: '⚙️', label: t('nav_system'),
children: [ children: [

View File

@ -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>
);
};