'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('GENEX_MOBILE'); const [platformFilter, setPlatformFilter] = useState(''); const [showUpload, setShowUpload] = useState(false); const [editingVersion, setEditingVersion] = useState(null); const { data: versions, isLoading, error, refetch } = useApi( '/api/v1/admin/versions', { params: { appType, platform: platformFilter || undefined, includeDisabled: 'true' } }, ); const toggleMutation = useApiMutation('PATCH', '', { invalidateKeys: ['/api/v1/admin/versions'], }); const deleteMutation = useApiMutation('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 (

{t('app_version_title')}

{/* App type tabs */}
{/* Platform filter */}
{(['', 'ANDROID', 'IOS'] as PlatformFilter[]).map(p => ( ))}
{/* Table */} {error ? (
Error: {error.message}
) : isLoading ? (
Loading...
) : list.length === 0 ? (
{t('app_version_no_versions')}
) : (
{list.map(v => ( ))}
{t('app_version_version_name')} {t('app_version_version_code')} {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.versionCode} {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)}
)} {/* Upload Modal */} {showUpload && ( setShowUpload(false)} onSuccess={() => { setShowUpload(false); refetch(); }} /> )} {/* Edit Modal */} {editingVersion && ( setEditingVersion(null)} onSuccess={() => { setEditingVersion(null); refetch(); }} /> )}
); }; /* ── Upload Modal ── */ const UploadModal: React.FC<{ appType: AppType; onClose: () => void; onSuccess: () => void; }> = ({ appType, onClose, onSuccess }) => { const fileRef = useRef(null); const [file, setFile] = useState(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) => { 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, { 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, { timeout: 300000, }); onSuccess(); } catch (err: any) { setError(err?.message || 'Upload failed'); } setUploading(false); }; return (
e.stopPropagation()}>

{t('app_version_upload')}

{parsing &&
{t('app_version_parsing')}
}
{(['ANDROID', 'IOS'] as const).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'} />