gcx/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx

495 lines
20 KiB
TypeScript

'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, {
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 (
<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>
);
};