'use client'; // ============================================================ // Presentation — App Version Management Page // Clean Architecture: 纯表现层,只读 Redux/Zustand 状态,调用 Use Cases // 不直接使用 HttpClient / apiClient / fetch // ============================================================ import React, { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { t } from '@/i18n/locales'; import { useAppDispatch, useAppSelector } from '@/store'; import { setAppType, setPlatformFilter, openUploadModal, closeUploadModal, openEditModal, closeEditModal, } from '@/store/slices/version.slice'; import { useUploadStore } from '@/store/zustand/upload.store'; import { listVersionsUseCase, parsePackageUseCase, uploadVersionUseCase, updateVersionUseCase, toggleVersionUseCase, deleteVersionUseCase, } from '@/application/use-cases/version.use-cases'; import type { AppVersion, AppType, AppPlatform } from '@/domain/entities'; import type { UpdateVersionInput } from '@/domain/repositories/version.repository.interface'; /* ── Styles ── */ const loadingBox: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40, color: 'var(--color-text-tertiary)', }; 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)', 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)', cursor: 'pointer', }); const primaryBtn: React.CSSProperties = { padding: '8px 20px', border: 'none', borderRadius: 'var(--radius-full)', background: 'var(--color-primary)', color: 'white', cursor: 'pointer', }; const thStyle: React.CSSProperties = { padding: '12px 16px', textAlign: 'left', whiteSpace: 'nowrap', color: 'var(--color-text-tertiary)', }; const tdStyle: React.CSSProperties = { 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, 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', background: 'var(--color-gray-50)', outline: 'none', boxSizing: 'border-box', }; const labelStyle: React.CSSProperties = { display: 'block', marginBottom: 4, marginTop: 14, color: 'var(--color-text-secondary)', }; /* ── Helpers ── */ function formatFileSize(bytes: string | number | null | undefined): string { const n = typeof bytes === 'string' ? parseInt(bytes, 10) : (bytes ?? 0); 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 | undefined): 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', }); } /* ── Query key factory ── */ const versionKeys = { list: (appType: AppType, platform: AppPlatform | '') => ['versions', appType, platform] as const, }; /* ── Main Page ── */ export const AppVersionManagementPage: React.FC = () => { const dispatch = useAppDispatch(); const { appType, platformFilter, showUpload, editingVersionId } = useAppSelector((s) => s.versions); const queryClient = useQueryClient(); const { data: versions, isLoading, error } = useQuery({ queryKey: versionKeys.list(appType, platformFilter), queryFn: () => listVersionsUseCase.execute(appType, platformFilter as AppPlatform | undefined), staleTime: 30_000, }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['versions', appType] }); const handleToggle = async (v: AppVersion) => { await toggleVersionUseCase.execute(v.id, !v.isEnabled); invalidate(); }; const handleDelete = async (v: AppVersion) => { if (!confirm(t('app_version_confirm_delete'))) return; await deleteVersionUseCase.execute(v.id); invalidate(); }; const list = Array.isArray(versions) ? versions : []; const editingVersion = editingVersionId ? list.find((v) => v.id === editingVersionId) ?? null : null; return (

{t('app_version_title')}

{/* App type tabs — RTK state */}
{(['GENEX_MOBILE', 'ADMIN_APP'] as AppType[]).map((at) => ( ))}
{/* Platform filter — RTK state */}
{(['', 'ANDROID', 'IOS'] as (AppPlatform | '')[]).map((p) => ( ))}
{/* Table */} {error ? (
Error: {(error as Error).message}
) : isLoading ? (
Loading...
) : list.length === 0 ? (
{t('app_version_no_versions')}
) : (
{list.map((v) => ( ))}
{t('app_version_version_name')} {t('app_version_platform')} {t('app_version_build_number')} {t('app_version_file_size')} {t('app_version_force_update')} {t('status')} {t('app_version_release_date')} {t('actions')}
{v.versionName} {v.platform === 'ANDROID' ? 'Android' : 'iOS'} {v.buildNumber} {formatFileSize(v.fileSize)} {v.isForceUpdate ? {t('app_version_force_update')} : '-'} {v.isEnabled ? t('app_version_enabled') : t('app_version_disabled')} {formatDate(v.releaseDate || v.createdAt)}
)} {showUpload && ( dispatch(closeUploadModal())} onSuccess={() => { dispatch(closeUploadModal()); invalidate(); }} /> )} {editingVersion && ( dispatch(closeEditModal())} onSuccess={() => { dispatch(closeEditModal()); invalidate(); }} /> )}
); }; /* ── Upload Modal — reads/writes Zustand upload store ── */ const UploadModal: React.FC<{ appType: AppType; onClose: () => void; onSuccess: () => void; }> = ({ appType, onClose, onSuccess }) => { const { file, platform, versionName, buildNumber, changelog, minOsVersion, isForceUpdate, isParsing, parseWarning, isUploading, uploadError, setFile, setPlatform, setVersionName, setBuildNumber, setChangelog, setMinOsVersion, setIsForceUpdate, setIsParsing, setParseWarning, setIsUploading, setUploadError, reset, } = useUploadStore(); const handleFileChange = async (e: React.ChangeEvent) => { const f = e.target.files?.[0]; if (!f) return; setFile(f); setParseWarning(''); if (f.name.endsWith('.apk')) setPlatform('ANDROID'); else if (f.name.endsWith('.ipa')) setPlatform('IOS'); setIsParsing(true); try { console.log('[Upload] Parsing:', f.name, (f.size / 1024 / 1024).toFixed(1) + 'MB'); const info = await parsePackageUseCase.execute(f); console.log('[Upload] Parse result:', info); if (info?.versionName) setVersionName(info.versionName); if (info?.versionCode) setBuildNumber(String(info.versionCode)); if (info?.minSdkVersion) setMinOsVersion(info.minSdkVersion); } catch (err: any) { console.warn('[Upload] Parse failed:', err?.message); setParseWarning('无法自动解析安装包信息,请手动填写版本号'); } setIsParsing(false); }; const handleSubmit = async () => { if (!file) { setUploadError(t('app_version_upload_file')); return; } setIsUploading(true); setUploadError(''); try { console.log('[Upload] Uploading:', file.name, (file.size / 1024 / 1024).toFixed(1) + 'MB'); const result = await uploadVersionUseCase.execute({ file, appType, platform, versionName, buildNumber, changelog, minOsVersion, isForceUpdate, }); console.log('[Upload] Success:', result); reset(); onSuccess(); } catch (err: any) { console.error('[Upload] Failed:', err); setUploadError(err?.message || 'Upload failed'); } setIsUploading(false); }; const handleClose = () => { reset(); onClose(); }; return (
e.stopPropagation()}>

{t('app_version_upload')}

{file && (
{file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB)
)} {isParsing && (
{t('app_version_parsing')}{file && file.size > 30 * 1024 * 1024 ? '(大文件,请耐心等待...)' : ''}
)} {parseWarning && (
{parseWarning}
)}
{(['ANDROID', 'IOS'] as AppPlatform[]).map((p) => ( ))}
setVersionName(e.target.value)} placeholder="1.0.0" /> setBuildNumber(e.target.value)} placeholder="100" /> setMinOsVersion(e.target.value)} placeholder={platform === 'ANDROID' ? 'e.g. 8.0' : 'e.g. 14.0'} />