diff --git a/frontend/admin-web/src/application/use-cases/version.use-cases.ts b/frontend/admin-web/src/application/use-cases/version.use-cases.ts new file mode 100644 index 0000000..ff856f7 --- /dev/null +++ b/frontend/admin-web/src/application/use-cases/version.use-cases.ts @@ -0,0 +1,55 @@ +import type { IVersionRepository, UploadVersionInput, UpdateVersionInput } from '@/domain/repositories/version.repository.interface'; +import type { AppType, AppPlatform } from '@/domain/entities'; +import { versionRepository } from '@/infrastructure/repositories/version.repository'; + +// ── Use Case classes — testable via constructor DI ────────── + +export class ListVersionsUseCase { + constructor(private readonly repo: IVersionRepository = versionRepository) {} + execute(appType: AppType, platform?: AppPlatform) { + return this.repo.list(appType, platform, true); + } +} + +export class ParsePackageUseCase { + constructor(private readonly repo: IVersionRepository = versionRepository) {} + execute(file: File) { + return this.repo.parse(file); + } +} + +export class UploadVersionUseCase { + constructor(private readonly repo: IVersionRepository = versionRepository) {} + execute(input: UploadVersionInput) { + return this.repo.upload(input); + } +} + +export class UpdateVersionUseCase { + constructor(private readonly repo: IVersionRepository = versionRepository) {} + execute(id: string, input: UpdateVersionInput) { + return this.repo.update(id, input); + } +} + +export class ToggleVersionUseCase { + constructor(private readonly repo: IVersionRepository = versionRepository) {} + execute(id: string, isEnabled: boolean) { + return this.repo.toggle(id, isEnabled); + } +} + +export class DeleteVersionUseCase { + constructor(private readonly repo: IVersionRepository = versionRepository) {} + execute(id: string) { + return this.repo.remove(id); + } +} + +// ── Singleton instances (frontend DI) ─────────────────────── +export const listVersionsUseCase = new ListVersionsUseCase(); +export const parsePackageUseCase = new ParsePackageUseCase(); +export const uploadVersionUseCase = new UploadVersionUseCase(); +export const updateVersionUseCase = new UpdateVersionUseCase(); +export const toggleVersionUseCase = new ToggleVersionUseCase(); +export const deleteVersionUseCase = new DeleteVersionUseCase(); diff --git a/frontend/admin-web/src/domain/entities/index.ts b/frontend/admin-web/src/domain/entities/index.ts index a0592fe..aa87ac1 100644 --- a/frontend/admin-web/src/domain/entities/index.ts +++ b/frontend/admin-web/src/domain/entities/index.ts @@ -50,16 +50,19 @@ export interface AppVersion { id: string; appType: AppType; platform: AppPlatform; - versionName: string; versionCode: number; - minOsVersion?: string; - fileSize?: number; - downloadUrl?: string; - changelog?: string; + versionName: string; + buildNumber: string; + downloadUrl: string; + fileSize: string; + fileSha256: string; + minOsVersion: string | null; + changelog: string; isForceUpdate: boolean; isEnabled: boolean; - releasedAt?: string; + releaseDate: string | null; createdAt: string; + updatedAt: string; } // ── Common ────────────────────────────────────────────────── diff --git a/frontend/admin-web/src/domain/repositories/version.repository.interface.ts b/frontend/admin-web/src/domain/repositories/version.repository.interface.ts new file mode 100644 index 0000000..291b37e --- /dev/null +++ b/frontend/admin-web/src/domain/repositories/version.repository.interface.ts @@ -0,0 +1,34 @@ +import type { AppVersion, AppType, AppPlatform } from '../entities'; + +export interface ParsedPackageInfo { + versionCode?: number; + versionName?: string; + minSdkVersion?: string; +} + +export interface UploadVersionInput { + file: File; + appType: AppType; + platform: AppPlatform; + versionName?: string; + buildNumber?: string; + changelog?: string; + minOsVersion?: string; + isForceUpdate: boolean; +} + +export interface UpdateVersionInput { + changelog?: string; + minOsVersion?: string | null; + isForceUpdate?: boolean; + isEnabled?: boolean; +} + +export interface IVersionRepository { + list(appType: AppType, platform?: AppPlatform, includeDisabled?: boolean): Promise; + parse(file: File): Promise; + upload(input: UploadVersionInput): Promise; + update(id: string, input: UpdateVersionInput): Promise; + toggle(id: string, isEnabled: boolean): Promise; + remove(id: string): Promise; +} diff --git a/frontend/admin-web/src/infrastructure/repositories/version.repository.ts b/frontend/admin-web/src/infrastructure/repositories/version.repository.ts new file mode 100644 index 0000000..964d021 --- /dev/null +++ b/frontend/admin-web/src/infrastructure/repositories/version.repository.ts @@ -0,0 +1,49 @@ +import type { + IVersionRepository, + ParsedPackageInfo, + UploadVersionInput, + UpdateVersionInput, +} from '@/domain/repositories/version.repository.interface'; +import type { AppVersion, AppType, AppPlatform } from '@/domain/entities'; +import { httpClient } from '../http/http.client'; + +class VersionRepository implements IVersionRepository { + async list(appType: AppType, platform?: AppPlatform, includeDisabled = true): Promise { + const params: Record = { appType, includeDisabled: String(includeDisabled) }; + if (platform) params.platform = platform; + return httpClient.get('/api/v1/admin/versions', { params }); + } + + async parse(file: File): Promise { + const fd = new FormData(); + fd.append('file', file); + return httpClient.post('/api/v1/admin/versions/parse', fd, { timeout: 300000 }); + } + + async upload(input: UploadVersionInput): Promise { + const fd = new FormData(); + fd.append('file', input.file); + fd.append('appType', input.appType); + fd.append('platform', input.platform); + if (input.versionName) fd.append('versionName', input.versionName); + if (input.buildNumber) fd.append('buildNumber', input.buildNumber); + if (input.changelog) fd.append('changelog', input.changelog); + if (input.minOsVersion) fd.append('minOsVersion', input.minOsVersion); + fd.append('isForceUpdate', String(input.isForceUpdate)); + return httpClient.post('/api/v1/admin/versions/upload', fd, { timeout: 300000 }); + } + + async update(id: string, input: UpdateVersionInput): Promise { + return httpClient.put(`/api/v1/admin/versions/${id}`, input); + } + + async toggle(id: string, isEnabled: boolean): Promise { + await httpClient.patch(`/api/v1/admin/versions/${id}/toggle`, { isEnabled }); + } + + async remove(id: string): Promise { + await httpClient.delete(`/api/v1/admin/versions/${id}`); + } +} + +export const versionRepository = new VersionRepository(); diff --git a/frontend/admin-web/src/store/index.ts b/frontend/admin-web/src/store/index.ts index 5556c4c..d318dda 100644 --- a/frontend/admin-web/src/store/index.ts +++ b/frontend/admin-web/src/store/index.ts @@ -7,11 +7,13 @@ import { configureStore } from '@reduxjs/toolkit'; import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import { uiSlice } from './slices/ui.slice'; import { usersSlice } from './slices/users.slice'; +import { versionSlice } from './slices/version.slice'; export const store = configureStore({ reducer: { ui: uiSlice.reducer, users: usersSlice.reducer, + versions: versionSlice.reducer, }, devTools: process.env.NODE_ENV !== 'production', }); diff --git a/frontend/admin-web/src/store/slices/version.slice.ts b/frontend/admin-web/src/store/slices/version.slice.ts new file mode 100644 index 0000000..82e4529 --- /dev/null +++ b/frontend/admin-web/src/store/slices/version.slice.ts @@ -0,0 +1,59 @@ +// ============================================================ +// Redux Toolkit — Version Slice +// 大厂模式:RTK 管理 version 页的客户端 UI 状态(筛选器、modal 开关) +// 服务端数据获取由 React Query 负责(queryKey 依赖此 slice 的筛选值) +// ============================================================ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import type { AppType, AppPlatform } from '@/domain/entities'; + +type PlatformFilter = AppPlatform | ''; + +interface VersionSliceState { + appType: AppType; + platformFilter: PlatformFilter; + showUpload: boolean; + editingVersionId: string | null; +} + +const initialState: VersionSliceState = { + appType: 'GENEX_MOBILE', + platformFilter: '', + showUpload: false, + editingVersionId: null, +}; + +export const versionSlice = createSlice({ + name: 'versions', + initialState, + reducers: { + setAppType: (state, action: PayloadAction) => { + state.appType = action.payload; + state.platformFilter = ''; + }, + setPlatformFilter: (state, action: PayloadAction) => { + state.platformFilter = action.payload; + }, + openUploadModal: (state) => { + state.showUpload = true; + }, + closeUploadModal: (state) => { + state.showUpload = false; + }, + openEditModal: (state, action: PayloadAction) => { + state.editingVersionId = action.payload; + }, + closeEditModal: (state) => { + state.editingVersionId = null; + }, + }, +}); + +export const { + setAppType, + setPlatformFilter, + openUploadModal, + closeUploadModal, + openEditModal, + closeEditModal, +} = versionSlice.actions; diff --git a/frontend/admin-web/src/store/zustand/upload.store.ts b/frontend/admin-web/src/store/zustand/upload.store.ts new file mode 100644 index 0000000..9dfaaaf --- /dev/null +++ b/frontend/admin-web/src/store/zustand/upload.store.ts @@ -0,0 +1,69 @@ +'use client'; + +// ============================================================ +// Zustand — Upload Store +// 大厂模式:Zustand 管理上传 modal 的临时表单状态 +// 生命周期与 modal 绑定,关闭时 reset +// ============================================================ + +import { create } from 'zustand'; +import type { AppPlatform } from '@/domain/entities'; + +interface UploadFormState { + file: File | null; + platform: AppPlatform; + versionName: string; + buildNumber: string; + changelog: string; + minOsVersion: string; + isForceUpdate: boolean; + isParsing: boolean; + parseWarning: string; + isUploading: boolean; + uploadError: string; +} + +interface UploadStoreActions { + setFile: (file: File | null) => void; + setPlatform: (p: AppPlatform) => void; + setVersionName: (v: string) => void; + setBuildNumber: (v: string) => void; + setChangelog: (v: string) => void; + setMinOsVersion: (v: string) => void; + setIsForceUpdate: (v: boolean) => void; + setIsParsing: (v: boolean) => void; + setParseWarning: (v: string) => void; + setIsUploading: (v: boolean) => void; + setUploadError: (v: string) => void; + reset: () => void; +} + +const initialState: UploadFormState = { + file: null, + platform: 'ANDROID', + versionName: '', + buildNumber: '', + changelog: '', + minOsVersion: '', + isForceUpdate: false, + isParsing: false, + parseWarning: '', + isUploading: false, + uploadError: '', +}; + +export const useUploadStore = create()((set) => ({ + ...initialState, + setFile: (file) => set({ file }), + setPlatform: (platform) => set({ platform }), + setVersionName: (versionName) => set({ versionName }), + setBuildNumber: (buildNumber) => set({ buildNumber }), + setChangelog: (changelog) => set({ changelog }), + setMinOsVersion: (minOsVersion) => set({ minOsVersion }), + setIsForceUpdate: (isForceUpdate) => set({ isForceUpdate }), + setIsParsing: (isParsing) => set({ isParsing }), + setParseWarning: (parseWarning) => set({ parseWarning }), + setIsUploading: (isUploading) => set({ isUploading }), + setUploadError: (uploadError) => set({ uploadError }), + reset: () => set(initialState), +})); diff --git a/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx b/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx index 5123a57..5d72ac1 100644 --- a/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx +++ b/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx @@ -1,108 +1,88 @@ 'use client'; -import React, { useState, useRef } from 'react'; +// ============================================================ +// 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 { 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'; +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)', font: 'var(--text-body)', + padding: 40, color: 'var(--color-text-tertiary)', }; - const tabBtn = (active: boolean): React.CSSProperties => ({ - padding: '8px 20px', - border: 'none', + 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, + 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', + color: 'var(--color-text-tertiary)', }; - -const tdStyle: React.CSSProperties = { - font: 'var(--text-body)', padding: '12px 16px', color: 'var(--color-text-primary)', -}; - +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, font: 'var(--text-caption)', fontWeight: 500, + 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', font: 'var(--text-body)', + borderRadius: 'var(--radius-sm)', padding: '0 12px', 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, + color: 'var(--color-text-secondary)', }; /* ── Helpers ── */ -function formatFileSize(bytes: string | number): string { - const n = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes; +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`; @@ -110,7 +90,7 @@ function formatFileSize(bytes: string | number): string { return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; } -function formatDate(iso: string | null): string { +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', @@ -118,64 +98,66 @@ function formatDate(iso: string | null): string { }); } -/* ── Component ── */ +/* ── Query key factory ── */ +const versionKeys = { + list: (appType: AppType, platform: AppPlatform | '') => + ['versions', appType, platform] as const, +}; + +/* ── Main Page ── */ 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 dispatch = useAppDispatch(); + const { appType, platformFilter, showUpload, editingVersionId } = + useAppSelector((s) => s.versions); + const queryClient = useQueryClient(); - 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 { data: versions, isLoading, error } = useQuery({ + queryKey: versionKeys.list(appType, platformFilter), + queryFn: () => listVersionsUseCase.execute(appType, platformFilter as AppPlatform | undefined), + staleTime: 30_000, }); - const deleteMutation = useApiMutation('DELETE', '', { - invalidateKeys: ['/api/v1/admin/versions'], - }); + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ['versions', appType] }); const handleToggle = async (v: AppVersion) => { - await apiClient.patch(`/api/v1/admin/versions/${v.id}/toggle`, { isEnabled: !v.isEnabled }); - refetch(); + await toggleVersionUseCase.execute(v.id, !v.isEnabled); + invalidate(); }; const handleDelete = async (v: AppVersion) => { if (!confirm(t('app_version_confirm_delete'))) return; - await apiClient.delete(`/api/v1/admin/versions/${v.id}`); - refetch(); + await deleteVersionUseCase.execute(v.id); + invalidate(); }; - console.log('[AppVersions] useApi versions:', versions, 'isArray:', Array.isArray(versions)); 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 */} -
- - + {/* App type tabs — RTK state */} +
+ {(['GENEX_MOBILE', 'ADMIN_APP'] as AppType[]).map((at) => ( + + ))}
- {/* Platform filter */} + {/* Platform filter — RTK state */}
- {(['', 'ANDROID', 'IOS'] as PlatformFilter[]).map(p => ( - ))} @@ -183,7 +165,7 @@ export const AppVersionManagementPage: React.FC = () => { {/* Table */} {error ? ( -
Error: {error.message}
+
Error: {(error as Error).message}
) : isLoading ? (
Loading...
) : list.length === 0 ? ( @@ -194,7 +176,6 @@ export const AppVersionManagementPage: React.FC = () => { {t('app_version_version_name')} - {t('app_version_version_code')} {t('app_version_platform')} {t('app_version_build_number')} {t('app_version_file_size')} @@ -205,10 +186,9 @@ export const AppVersionManagementPage: React.FC = () => { - {list.map(v => ( + {list.map((v) => ( {v.versionName} - {v.versionCode} { {v.platform === 'ANDROID' ? 'Android' : 'iOS'} - - {v.buildNumber} - + {v.buildNumber} {formatFileSize(v.fileSize)} - {v.isForceUpdate ? ( - {t('app_version_force_update')} - ) : '-'} + {v.isForceUpdate + ? {t('app_version_force_update')} + : '-'} { {v.isEnabled ? t('app_version_enabled') : t('app_version_disabled')} - - {formatDate(v.releaseDate || v.createdAt)} - + {formatDate(v.releaseDate || v.createdAt)} - - - @@ -264,69 +234,52 @@ export const AppVersionManagementPage: React.FC = () => {
)} - {/* Upload Modal */} {showUpload && ( setShowUpload(false)} - onSuccess={() => { setShowUpload(false); refetch(); }} + onClose={() => dispatch(closeUploadModal())} + onSuccess={() => { dispatch(closeUploadModal()); invalidate(); }} /> )} - {/* Edit Modal */} {editingVersion && ( setEditingVersion(null)} - onSuccess={() => { setEditingVersion(null); refetch(); }} + onClose={() => dispatch(closeEditModal())} + onSuccess={() => { dispatch(closeEditModal()); invalidate(); }} /> )}
); }; -/* ── Upload Modal ── */ +/* ── Upload Modal — reads/writes Zustand upload store ── */ 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 [parseWarning, setParseWarning] = useState(''); + 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); - setError(''); setParseWarning(''); - - // Auto-detect platform from extension if (f.name.endsWith('.apk')) setPlatform('ANDROID'); else if (f.name.endsWith('.ipa')) setPlatform('IOS'); - // Auto-parse to pre-fill form fields - setParsing(true); + setIsParsing(true); try { - console.log('[Upload] Parsing package:', f.name, (f.size / 1024 / 1024).toFixed(1) + 'MB'); - 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: 300000, - }); + 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)); @@ -335,63 +288,57 @@ const UploadModal: React.FC<{ console.warn('[Upload] Parse failed:', err?.message); setParseWarning('无法自动解析安装包信息,请手动填写版本号'); } - setParsing(false); + setIsParsing(false); }; const handleSubmit = async () => { - if (!file) { - setError(t('app_version_upload_file')); - return; - } - setUploading(true); - setError(''); + if (!file) { setUploadError(t('app_version_upload_file')); return; } + setIsUploading(true); + setUploadError(''); try { - console.log('[Upload] Starting upload:', file.name, (file.size / 1024 / 1024).toFixed(1) + 'MB'); - const formData = new FormData(); - formData.append('file', file); - formData.append('appType', appType); - formData.append('platform', platform); - if (versionName) formData.append('versionName', versionName); - if (buildNumber) formData.append('buildNumber', buildNumber); - if (changelog) formData.append('changelog', changelog); - if (minOsVersion) formData.append('minOsVersion', minOsVersion); - formData.append('isForceUpdate', String(isForceUpdate)); - - const result = await apiClient.post('/api/v1/admin/versions/upload', formData, { - timeout: 300000, + 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); - setError(err?.response?.data?.message || err?.message || 'Upload failed'); + setUploadError(err?.message || 'Upload failed'); } - setUploading(false); + setIsUploading(false); }; + const handleClose = () => { reset(); onClose(); }; + return ( -
-
e.stopPropagation()}> -

{t('app_version_upload')}

+
+
e.stopPropagation()}> +

{t('app_version_upload')}

- + {file && ( -
+
{file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB)
)} - {parsing &&
{t('app_version_parsing')}{file && file.size > 30 * 1024 * 1024 ? '(大文件,请耐心等待...)' : ''}
} - {parseWarning &&
{parseWarning}
} + {isParsing && ( +
+ {t('app_version_parsing')}{file && file.size > 30 * 1024 * 1024 ? '(大文件,请耐心等待...)' : ''} +
+ )} + {parseWarning && ( +
{parseWarning}
+ )}
- {(['ANDROID', 'IOS'] as const).map(p => ( -