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

464 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h1 style={{ margin: 0 }}>{t('app_version_title')}</h1>
<button style={primaryBtn} onClick={() => dispatch(openUploadModal())}>
+ {t('app_version_upload')}
</button>
</div>
{/* App type tabs — RTK state */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border-light)', marginBottom: 16 }}>
{(['GENEX_MOBILE', 'ADMIN_APP'] as AppType[]).map((at) => (
<button key={at} style={tabBtn(appType === at)} onClick={() => dispatch(setAppType(at))}>
{at === 'GENEX_MOBILE' ? t('app_version_genex_mobile') : t('app_version_admin_app')}
</button>
))}
</div>
{/* Platform filter — RTK state */}
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
{(['', 'ANDROID', 'IOS'] as (AppPlatform | '')[]).map((p) => (
<button key={p || 'all'} style={filterBtn(platformFilter === p)}
onClick={() => dispatch(setPlatformFilter(p as AppPlatform | ''))}>
{p === '' ? t('app_version_all_platforms') : p === 'ANDROID' ? 'Android' : 'iOS'}
</button>
))}
</div>
{/* Table */}
{error ? (
<div style={loadingBox}>Error: {(error as 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_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}>
<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}>{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}>{formatDate(v.releaseDate || v.createdAt)}</td>
<td style={{ ...tdStyle, whiteSpace: 'nowrap' }}>
<button onClick={() => dispatch(openEditModal(v.id))}
style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', padding: '3px 10px', background: 'transparent', cursor: 'pointer', 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', 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', color: 'var(--color-error)' }}>
{t('delete')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showUpload && (
<UploadModal
appType={appType}
onClose={() => dispatch(closeUploadModal())}
onSuccess={() => { dispatch(closeUploadModal()); invalidate(); }}
/>
)}
{editingVersion && (
<EditModal
version={editingVersion}
onClose={() => dispatch(closeEditModal())}
onSuccess={() => { dispatch(closeEditModal()); invalidate(); }}
/>
)}
</div>
);
};
/* ── 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<HTMLInputElement>) => {
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 (
<div style={overlayStyle} onClick={handleClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<h2 style={{ margin: '0 0 8px' }}>{t('app_version_upload')}</h2>
<label style={labelStyle}>{t('app_version_upload_file')}</label>
<input type="file" accept=".apk,.ipa" onChange={handleFileChange}
style={{ ...inputStyle, padding: '8px 12px', height: 'auto' }} />
{file && (
<div style={{ color: 'var(--color-text-secondary)', marginTop: 4 }}>
{file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB)
</div>
)}
{isParsing && (
<div style={{ color: 'var(--color-info)', marginTop: 4 }}>
{t('app_version_parsing')}{file && file.size > 30 * 1024 * 1024 ? '(大文件,请耐心等待...' : ''}
</div>
)}
{parseWarning && (
<div style={{ color: 'var(--color-warning, #f90)', marginTop: 4 }}>{parseWarning}</div>
)}
<label style={labelStyle}>{t('app_version_platform')}</label>
<div style={{ display: 'flex', gap: 12 }}>
{(['ANDROID', 'IOS'] as AppPlatform[]).map((p) => (
<label key={p} style={{ 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')}
<span style={{ color: 'var(--color-text-secondary)', fontWeight: 400, marginLeft: 6 }}>
({t('optional_auto_from_apk')})
</span>
</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' }} />
<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>
{uploadError && <div style={{ color: 'var(--color-error)', marginTop: 8 }}>{uploadError}</div>}
{isUploading && <div style={{ color: 'var(--color-info)', marginTop: 4 }}>...</div>}
<div style={{ display: 'flex', gap: 12, marginTop: 20, justifyContent: 'flex-end' }}>
<button onClick={handleClose}
style={{ padding: '8px 20px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-full)', background: 'transparent', cursor: 'pointer' }}>
{t('cancel')}
</button>
<button onClick={handleSubmit} disabled={isUploading || isParsing}
style={{ ...primaryBtn, opacity: isUploading || isParsing ? 0.6 : 1 }}>
{isUploading ? t('app_version_uploading') : t('confirm')}
</button>
</div>
</div>
</div>
);
};
/* ── Edit Modal — local useState (无需持久化,单次操作) ── */
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 {
const input: UpdateVersionInput = {
changelog,
minOsVersion: minOsVersion || null,
isForceUpdate,
isEnabled,
};
await updateVersionUseCase.execute(version.id, input);
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={{ margin: '0 0 4px' }}>{t('app_version_edit')}</h2>
<div style={{ 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)', 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' }}>
{t('cancel')}
</button>
<button onClick={handleSave} disabled={saving}
style={{ ...primaryBtn, opacity: saving ? 0.6 : 1 }}>
{saving ? '...' : t('save')}
</button>
</div>
</div>
</div>
);
};