refactor(admin-web): strict Clean Architecture for app-versions feature

Domain → Infrastructure → Application (Use Cases) → Presentation

- Domain: fix AppVersion entity fields; add IVersionRepository interface
- Infrastructure: VersionRepository implements IVersionRepository via HttpClient
- Application: 6 Use Case classes (ListVersions/Parse/Upload/Update/Toggle/Delete)
- Presentation: RTK version.slice (filters/modal state) + Zustand upload.store (form state)
- Page: zero direct apiClient calls; React Query queryFn calls use cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-06 11:11:56 -08:00
parent dca2031a38
commit 3765e8e6b1
8 changed files with 437 additions and 223 deletions

View File

@ -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();

View File

@ -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 ──────────────────────────────────────────────────

View File

@ -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<AppVersion[]>;
parse(file: File): Promise<ParsedPackageInfo>;
upload(input: UploadVersionInput): Promise<AppVersion>;
update(id: string, input: UpdateVersionInput): Promise<AppVersion>;
toggle(id: string, isEnabled: boolean): Promise<void>;
remove(id: string): Promise<void>;
}

View File

@ -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<AppVersion[]> {
const params: Record<string, string> = { appType, includeDisabled: String(includeDisabled) };
if (platform) params.platform = platform;
return httpClient.get<AppVersion[]>('/api/v1/admin/versions', { params });
}
async parse(file: File): Promise<ParsedPackageInfo> {
const fd = new FormData();
fd.append('file', file);
return httpClient.post<ParsedPackageInfo>('/api/v1/admin/versions/parse', fd, { timeout: 300000 });
}
async upload(input: UploadVersionInput): Promise<AppVersion> {
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<AppVersion>('/api/v1/admin/versions/upload', fd, { timeout: 300000 });
}
async update(id: string, input: UpdateVersionInput): Promise<AppVersion> {
return httpClient.put<AppVersion>(`/api/v1/admin/versions/${id}`, input);
}
async toggle(id: string, isEnabled: boolean): Promise<void> {
await httpClient.patch(`/api/v1/admin/versions/${id}/toggle`, { isEnabled });
}
async remove(id: string): Promise<void> {
await httpClient.delete(`/api/v1/admin/versions/${id}`);
}
}
export const versionRepository = new VersionRepository();

View File

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

View File

@ -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<AppType>) => {
state.appType = action.payload;
state.platformFilter = '';
},
setPlatformFilter: (state, action: PayloadAction<PlatformFilter>) => {
state.platformFilter = action.payload;
},
openUploadModal: (state) => {
state.showUpload = true;
},
closeUploadModal: (state) => {
state.showUpload = false;
},
openEditModal: (state, action: PayloadAction<string>) => {
state.editingVersionId = action.payload;
},
closeEditModal: (state) => {
state.editingVersionId = null;
},
},
});
export const {
setAppType,
setPlatformFilter,
openUploadModal,
closeUploadModal,
openEditModal,
closeEditModal,
} = versionSlice.actions;

View File

@ -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<UploadFormState & UploadStoreActions>()((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),
}));

View File

@ -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<AppType>('GENEX_MOBILE');
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('');
const [showUpload, setShowUpload] = useState(false);
const [editingVersion, setEditingVersion] = useState<AppVersion | null>(null);
const dispatch = useAppDispatch();
const { appType, platformFilter, showUpload, editingVersionId } =
useAppSelector((s) => s.versions);
const queryClient = useQueryClient();
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 { data: versions, isLoading, error } = useQuery({
queryKey: versionKeys.list(appType, platformFilter),
queryFn: () => listVersionsUseCase.execute(appType, platformFilter as AppPlatform | undefined),
staleTime: 30_000,
});
const deleteMutation = useApiMutation<void>('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 (
<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)}>
<h1 style={{ margin: 0 }}>{t('app_version_title')}</h1>
<button style={primaryBtn} onClick={() => dispatch(openUploadModal())}>
+ {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>
{/* 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 */}
{/* Platform filter — RTK state */}
<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)}>
{(['', '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>
))}
@ -183,7 +165,7 @@ export const AppVersionManagementPage: React.FC = () => {
{/* Table */}
{error ? (
<div style={loadingBox}>Error: {error.message}</div>
<div style={loadingBox}>Error: {(error as Error).message}</div>
) : isLoading ? (
<div style={loadingBox}>Loading...</div>
) : list.length === 0 ? (
@ -194,7 +176,6 @@ export const AppVersionManagementPage: React.FC = () => {
<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>
@ -205,10 +186,9 @@ export const AppVersionManagementPage: React.FC = () => {
</tr>
</thead>
<tbody>
{list.map(v => (
{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)',
@ -217,14 +197,12 @@ export const AppVersionManagementPage: React.FC = () => {
{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}>{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>
) : '-'}
{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(
@ -234,26 +212,18 @@ export const AppVersionManagementPage: React.FC = () => {
{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}>{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 }}
>
<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', font: 'var(--text-caption)', color: v.isEnabled ? 'var(--color-warning)' : 'var(--color-success)', marginRight: 6 }}
>
<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', font: 'var(--text-caption)', color: 'var(--color-error)' }}
>
<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>
@ -264,69 +234,52 @@ export const AppVersionManagementPage: React.FC = () => {
</div>
)}
{/* Upload Modal */}
{showUpload && (
<UploadModal
appType={appType}
onClose={() => setShowUpload(false)}
onSuccess={() => { setShowUpload(false); refetch(); }}
onClose={() => dispatch(closeUploadModal())}
onSuccess={() => { dispatch(closeUploadModal()); invalidate(); }}
/>
)}
{/* Edit Modal */}
{editingVersion && (
<EditModal
version={editingVersion}
onClose={() => setEditingVersion(null)}
onSuccess={() => { setEditingVersion(null); refetch(); }}
onClose={() => dispatch(closeEditModal())}
onSuccess={() => { dispatch(closeEditModal()); invalidate(); }}
/>
)}
</div>
);
};
/* ── 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<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 [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<HTMLInputElement>) => {
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 (
<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>
<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
ref={fileRef} type="file" accept=".apk,.ipa"
onChange={handleFileChange}
style={{ ...inputStyle, padding: '8px 12px', height: 'auto' }}
/>
<input type="file" accept=".apk,.ipa" onChange={handleFileChange}
style={{ ...inputStyle, padding: '8px 12px', height: 'auto' }} />
{file && (
<div style={{ font: 'var(--text-caption)', color: 'var(--color-text-secondary)', marginTop: 4 }}>
<div style={{ color: 'var(--color-text-secondary)', marginTop: 4 }}>
{file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB)
</div>
)}
{parsing && <div style={{ font: 'var(--text-caption)', color: 'var(--color-info)', marginTop: 4 }}>{t('app_version_parsing')}{file && file.size > 30 * 1024 * 1024 ? '(大文件,请耐心等待...' : ''}</div>}
{parseWarning && <div style={{ font: 'var(--text-caption)', color: 'var(--color-warning, #f90)', marginTop: 4 }}>{parseWarning}</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 const).map(p => (
<label key={p} style={{ font: 'var(--text-body)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
{(['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>
@ -400,44 +347,39 @@ const UploadModal: React.FC<{
<label style={labelStyle}>
{t('app_version_version_name')}
<span style={{ font: 'var(--text-caption)', color: 'var(--color-text-secondary)', fontWeight: 400, marginLeft: 6 }}>
<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" />
<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" />
<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)}
<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')}
/>
<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)} />
<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>}
{uploading && (
<div style={{ font: 'var(--text-caption)', color: 'var(--color-info)', marginTop: 4 }}>
...
</div>
)}
{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={onClose} style={{ padding: '8px 20px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-full)', background: 'transparent', cursor: 'pointer', font: 'var(--text-label-sm)' }}>
<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={uploading || parsing} style={{ ...primaryBtn, opacity: uploading || parsing ? 0.6 : 1 }}>
{uploading ? t('app_version_uploading') : t('confirm')}
<button onClick={handleSubmit} disabled={isUploading || isParsing}
style={{ ...primaryBtn, opacity: isUploading || isParsing ? 0.6 : 1 }}>
{isUploading ? t('app_version_uploading') : t('confirm')}
</button>
</div>
</div>
@ -445,7 +387,7 @@ const UploadModal: React.FC<{
);
};
/* ── Edit Modal ── */
/* ── Edit Modal — local useState (无需持久化,单次操作) ── */
const EditModal: React.FC<{
version: AppVersion;
@ -453,7 +395,7 @@ const EditModal: React.FC<{
onSuccess: () => void;
}> = ({ version, onClose, onSuccess }) => {
const [changelog, setChangelog] = useState(version.changelog);
const [minOsVersion, setMinOsVersion] = useState(version.minOsVersion || '');
const [minOsVersion, setMinOsVersion] = useState(version.minOsVersion ?? '');
const [isForceUpdate, setIsForceUpdate] = useState(version.isForceUpdate);
const [isEnabled, setIsEnabled] = useState(version.isEnabled);
const [saving, setSaving] = useState(false);
@ -463,12 +405,13 @@ const EditModal: React.FC<{
setSaving(true);
setError('');
try {
await apiClient.put(`/api/v1/admin/versions/${version.id}`, {
const input: UpdateVersionInput = {
changelog,
minOsVersion: minOsVersion || null,
isForceUpdate,
isEnabled,
});
};
await updateVersionUseCase.execute(version.id, input);
onSuccess();
} catch (err: any) {
setError(err?.message || 'Save failed');
@ -478,39 +421,39 @@ const EditModal: React.FC<{
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 }}>
<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' }}
/>
<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)}
<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)} />
<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)} />
<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>}
{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', font: 'var(--text-label-sm)' }}>
<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 }}>
<button onClick={handleSave} disabled={saving}
style={{ ...primaryBtn, opacity: saving ? 0.6 : 1 }}>
{saving ? '...' : t('save')}
</button>
</div>