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; id: string;
appType: AppType; appType: AppType;
platform: AppPlatform; platform: AppPlatform;
versionName: string;
versionCode: number; versionCode: number;
minOsVersion?: string; versionName: string;
fileSize?: number; buildNumber: string;
downloadUrl?: string; downloadUrl: string;
changelog?: string; fileSize: string;
fileSha256: string;
minOsVersion: string | null;
changelog: string;
isForceUpdate: boolean; isForceUpdate: boolean;
isEnabled: boolean; isEnabled: boolean;
releasedAt?: string; releaseDate: string | null;
createdAt: string; createdAt: string;
updatedAt: string;
} }
// ── Common ────────────────────────────────────────────────── // ── 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 { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { uiSlice } from './slices/ui.slice'; import { uiSlice } from './slices/ui.slice';
import { usersSlice } from './slices/users.slice'; import { usersSlice } from './slices/users.slice';
import { versionSlice } from './slices/version.slice';
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
ui: uiSlice.reducer, ui: uiSlice.reducer,
users: usersSlice.reducer, users: usersSlice.reducer,
versions: versionSlice.reducer,
}, },
devTools: process.env.NODE_ENV !== 'production', 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'; '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 { t } from '@/i18n/locales';
import { useApi, useApiMutation } from '@/lib/use-api'; import { useAppDispatch, useAppSelector } from '@/store';
import { apiClient } from '@/lib/api-client'; import {
setAppType, setPlatformFilter,
/* ── Types ── */ openUploadModal, closeUploadModal,
openEditModal, closeEditModal,
interface AppVersion { } from '@/store/slices/version.slice';
id: string; import { useUploadStore } from '@/store/zustand/upload.store';
appType: string; import {
platform: string; listVersionsUseCase,
versionCode: number; parsePackageUseCase,
versionName: string; uploadVersionUseCase,
buildNumber: string; updateVersionUseCase,
downloadUrl: string; toggleVersionUseCase,
fileSize: string; deleteVersionUseCase,
fileSha256: string; } from '@/application/use-cases/version.use-cases';
minOsVersion: string | null; import type { AppVersion, AppType, AppPlatform } from '@/domain/entities';
changelog: string; import type { UpdateVersionInput } from '@/domain/repositories/version.repository.interface';
isForceUpdate: boolean;
isEnabled: boolean;
releaseDate: string | null;
createdAt: string;
updatedAt: string;
}
type AppType = 'GENEX_MOBILE' | 'ADMIN_APP';
type PlatformFilter = '' | 'ANDROID' | 'IOS';
/* ── Styles ── */ /* ── Styles ── */
const loadingBox: React.CSSProperties = { const loadingBox: React.CSSProperties = {
display: 'flex', alignItems: 'center', justifyContent: 'center', 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 => ({ const tabBtn = (active: boolean): React.CSSProperties => ({
padding: '8px 20px', padding: '8px 20px', border: 'none',
border: 'none',
borderBottom: active ? '2px solid var(--color-primary)' : '2px solid transparent', borderBottom: active ? '2px solid var(--color-primary)' : '2px solid transparent',
background: 'transparent', background: 'transparent',
color: active ? 'var(--color-primary)' : 'var(--color-text-secondary)', 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 => ({ const filterBtn = (active: boolean): React.CSSProperties => ({
padding: '4px 14px', padding: '4px 14px',
border: `1px solid ${active ? 'var(--color-primary)' : 'var(--color-border)'}`, border: `1px solid ${active ? 'var(--color-primary)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-full)', borderRadius: 'var(--radius-full)',
background: active ? 'var(--color-primary-surface)' : 'transparent', background: active ? 'var(--color-primary-surface)' : 'transparent',
color: active ? 'var(--color-primary)' : 'var(--color-text-secondary)', color: active ? 'var(--color-primary)' : 'var(--color-text-secondary)',
font: 'var(--text-label-sm)',
cursor: 'pointer', cursor: 'pointer',
}); });
const primaryBtn: React.CSSProperties = { const primaryBtn: React.CSSProperties = {
padding: '8px 20px', border: 'none', borderRadius: 'var(--radius-full)', padding: '8px 20px', border: 'none', borderRadius: 'var(--radius-full)',
background: 'var(--color-primary)', color: 'white', cursor: 'pointer', background: 'var(--color-primary)', color: 'white', cursor: 'pointer',
font: 'var(--text-label-sm)',
}; };
const thStyle: React.CSSProperties = { const thStyle: React.CSSProperties = {
font: 'var(--text-label-sm)', color: 'var(--color-text-tertiary)',
padding: '12px 16px', textAlign: 'left', whiteSpace: 'nowrap', 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 tdStyle: React.CSSProperties = {
font: 'var(--text-body)', padding: '12px 16px', color: 'var(--color-text-primary)',
};
const badge = (bg: string, color: string): React.CSSProperties => ({ const badge = (bg: string, color: string): React.CSSProperties => ({
padding: '2px 10px', borderRadius: 'var(--radius-full)', padding: '2px 10px', borderRadius: 'var(--radius-full)', background: bg, color, fontWeight: 500,
background: bg, color, font: 'var(--text-caption)', fontWeight: 500,
}); });
const overlayStyle: React.CSSProperties = { const overlayStyle: React.CSSProperties = {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
}; };
const modalStyle: React.CSSProperties = { const modalStyle: React.CSSProperties = {
background: 'var(--color-surface)', borderRadius: 'var(--radius-lg)', background: 'var(--color-surface)', borderRadius: 'var(--radius-lg)',
padding: 28, width: 520, maxHeight: '80vh', overflow: 'auto', padding: 28, width: 520, maxHeight: '80vh', overflow: 'auto',
boxShadow: 'var(--shadow-lg)', boxShadow: 'var(--shadow-lg)',
}; };
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', height: 40, border: '1px solid var(--color-border)', 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', background: 'var(--color-gray-50)', outline: 'none', boxSizing: 'border-box',
}; };
const labelStyle: React.CSSProperties = { const labelStyle: React.CSSProperties = {
font: 'var(--text-label-sm)', color: 'var(--color-text-secondary)',
display: 'block', marginBottom: 4, marginTop: 14, display: 'block', marginBottom: 4, marginTop: 14,
color: 'var(--color-text-secondary)',
}; };
/* ── Helpers ── */ /* ── Helpers ── */
function formatFileSize(bytes: string | number): string { function formatFileSize(bytes: string | number | null | undefined): string {
const n = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes; const n = typeof bytes === 'string' ? parseInt(bytes, 10) : (bytes ?? 0);
if (!n || isNaN(n)) return '-'; if (!n || isNaN(n)) return '-';
if (n < 1024) return `${n} B`; if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; 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`; return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
} }
function formatDate(iso: string | null): string { function formatDate(iso: string | null | undefined): string {
if (!iso) return '-'; if (!iso) return '-';
return new Date(iso).toLocaleDateString('zh-CN', { return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit', 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 = () => { export const AppVersionManagementPage: React.FC = () => {
const [appType, setAppType] = useState<AppType>('GENEX_MOBILE'); const dispatch = useAppDispatch();
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>(''); const { appType, platformFilter, showUpload, editingVersionId } =
const [showUpload, setShowUpload] = useState(false); useAppSelector((s) => s.versions);
const [editingVersion, setEditingVersion] = useState<AppVersion | null>(null); const queryClient = useQueryClient();
const { data: versions, isLoading, error, refetch } = useApi<AppVersion[]>( const { data: versions, isLoading, error } = useQuery({
'/api/v1/admin/versions', queryKey: versionKeys.list(appType, platformFilter),
{ params: { appType, platform: platformFilter || undefined, includeDisabled: 'true' } }, queryFn: () => listVersionsUseCase.execute(appType, platformFilter as AppPlatform | undefined),
); staleTime: 30_000,
const toggleMutation = useApiMutation<void>('PATCH', '', {
invalidateKeys: ['/api/v1/admin/versions'],
}); });
const deleteMutation = useApiMutation<void>('DELETE', '', { const invalidate = () =>
invalidateKeys: ['/api/v1/admin/versions'], queryClient.invalidateQueries({ queryKey: ['versions', appType] });
});
const handleToggle = async (v: AppVersion) => { const handleToggle = async (v: AppVersion) => {
await apiClient.patch(`/api/v1/admin/versions/${v.id}/toggle`, { isEnabled: !v.isEnabled }); await toggleVersionUseCase.execute(v.id, !v.isEnabled);
refetch(); invalidate();
}; };
const handleDelete = async (v: AppVersion) => { const handleDelete = async (v: AppVersion) => {
if (!confirm(t('app_version_confirm_delete'))) return; if (!confirm(t('app_version_confirm_delete'))) return;
await apiClient.delete(`/api/v1/admin/versions/${v.id}`); await deleteVersionUseCase.execute(v.id);
refetch(); invalidate();
}; };
console.log('[AppVersions] useApi versions:', versions, 'isArray:', Array.isArray(versions));
const list = Array.isArray(versions) ? versions : []; const list = Array.isArray(versions) ? versions : [];
const editingVersion = editingVersionId ? list.find((v) => v.id === editingVersionId) ?? null : null;
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h1 style={{ font: 'var(--text-h1)', margin: 0 }}>{t('app_version_title')}</h1> <h1 style={{ margin: 0 }}>{t('app_version_title')}</h1>
<button style={primaryBtn} onClick={() => setShowUpload(true)}> <button style={primaryBtn} onClick={() => dispatch(openUploadModal())}>
+ {t('app_version_upload')} + {t('app_version_upload')}
</button> </button>
</div> </div>
{/* App type tabs */} {/* App type tabs — RTK state */}
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--color-border-light)', marginBottom: 16 }}> <div style={{ display: 'flex', borderBottom: '1px solid var(--color-border-light)', marginBottom: 16 }}>
<button style={tabBtn(appType === 'GENEX_MOBILE')} onClick={() => setAppType('GENEX_MOBILE')}> {(['GENEX_MOBILE', 'ADMIN_APP'] as AppType[]).map((at) => (
{t('app_version_genex_mobile')} <button key={at} style={tabBtn(appType === at)} onClick={() => dispatch(setAppType(at))}>
</button> {at === 'GENEX_MOBILE' ? t('app_version_genex_mobile') : t('app_version_admin_app')}
<button style={tabBtn(appType === 'ADMIN_APP')} onClick={() => setAppType('ADMIN_APP')}>
{t('app_version_admin_app')}
</button> </button>
))}
</div> </div>
{/* Platform filter */} {/* Platform filter — RTK state */}
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}> <div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
{(['', 'ANDROID', 'IOS'] as PlatformFilter[]).map(p => ( {(['', 'ANDROID', 'IOS'] as (AppPlatform | '')[]).map((p) => (
<button key={p || 'all'} style={filterBtn(platformFilter === p)} onClick={() => setPlatformFilter(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'} {p === '' ? t('app_version_all_platforms') : p === 'ANDROID' ? 'Android' : 'iOS'}
</button> </button>
))} ))}
@ -183,7 +165,7 @@ export const AppVersionManagementPage: React.FC = () => {
{/* Table */} {/* Table */}
{error ? ( {error ? (
<div style={loadingBox}>Error: {error.message}</div> <div style={loadingBox}>Error: {(error as Error).message}</div>
) : isLoading ? ( ) : isLoading ? (
<div style={loadingBox}>Loading...</div> <div style={loadingBox}>Loading...</div>
) : list.length === 0 ? ( ) : list.length === 0 ? (
@ -194,7 +176,6 @@ export const AppVersionManagementPage: React.FC = () => {
<thead> <thead>
<tr style={{ background: 'var(--color-gray-50)', borderBottom: '1px solid var(--color-border)' }}> <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_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_platform')}</th>
<th style={thStyle}>{t('app_version_build_number')}</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_file_size')}</th>
@ -205,10 +186,9 @@ export const AppVersionManagementPage: React.FC = () => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{list.map(v => ( {list.map((v) => (
<tr key={v.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}> <tr key={v.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ ...tdStyle, fontWeight: 600 }}>{v.versionName}</td> <td style={{ ...tdStyle, fontWeight: 600 }}>{v.versionName}</td>
<td style={tdStyle}>{v.versionCode}</td>
<td style={tdStyle}> <td style={tdStyle}>
<span style={badge( <span style={badge(
v.platform === 'ANDROID' ? 'var(--color-success-light)' : 'var(--color-info-light)', 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'} {v.platform === 'ANDROID' ? 'Android' : 'iOS'}
</span> </span>
</td> </td>
<td style={{ ...tdStyle, font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)' }}> <td style={tdStyle}>{v.buildNumber}</td>
{v.buildNumber}
</td>
<td style={tdStyle}>{formatFileSize(v.fileSize)}</td> <td style={tdStyle}>{formatFileSize(v.fileSize)}</td>
<td style={tdStyle}> <td style={tdStyle}>
{v.isForceUpdate ? ( {v.isForceUpdate
<span style={badge('var(--color-error-light)', 'var(--color-error)')}>{t('app_version_force_update')}</span> ? <span style={badge('var(--color-error-light)', 'var(--color-error)')}>{t('app_version_force_update')}</span>
) : '-'} : '-'}
</td> </td>
<td style={tdStyle}> <td style={tdStyle}>
<span style={badge( <span style={badge(
@ -234,26 +212,18 @@ export const AppVersionManagementPage: React.FC = () => {
{v.isEnabled ? t('app_version_enabled') : t('app_version_disabled')} {v.isEnabled ? t('app_version_enabled') : t('app_version_disabled')}
</span> </span>
</td> </td>
<td style={{ ...tdStyle, font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)' }}> <td style={tdStyle}>{formatDate(v.releaseDate || v.createdAt)}</td>
{formatDate(v.releaseDate || v.createdAt)}
</td>
<td style={{ ...tdStyle, whiteSpace: 'nowrap' }}> <td style={{ ...tdStyle, whiteSpace: 'nowrap' }}>
<button <button onClick={() => dispatch(openEditModal(v.id))}
onClick={() => setEditingVersion(v)} style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', padding: '3px 10px', background: 'transparent', cursor: 'pointer', marginRight: 6 }}>
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')} {t('edit')}
</button> </button>
<button <button onClick={() => handleToggle(v)}
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 }}>
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')} {v.isEnabled ? t('disable') : t('enable')}
</button> </button>
<button <button onClick={() => handleDelete(v)}
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)' }}>
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')} {t('delete')}
</button> </button>
</td> </td>
@ -264,69 +234,52 @@ export const AppVersionManagementPage: React.FC = () => {
</div> </div>
)} )}
{/* Upload Modal */}
{showUpload && ( {showUpload && (
<UploadModal <UploadModal
appType={appType} appType={appType}
onClose={() => setShowUpload(false)} onClose={() => dispatch(closeUploadModal())}
onSuccess={() => { setShowUpload(false); refetch(); }} onSuccess={() => { dispatch(closeUploadModal()); invalidate(); }}
/> />
)} )}
{/* Edit Modal */}
{editingVersion && ( {editingVersion && (
<EditModal <EditModal
version={editingVersion} version={editingVersion}
onClose={() => setEditingVersion(null)} onClose={() => dispatch(closeEditModal())}
onSuccess={() => { setEditingVersion(null); refetch(); }} onSuccess={() => { dispatch(closeEditModal()); invalidate(); }}
/> />
)} )}
</div> </div>
); );
}; };
/* ── Upload Modal ── */ /* ── Upload Modal — reads/writes Zustand upload store ── */
const UploadModal: React.FC<{ const UploadModal: React.FC<{
appType: AppType; appType: AppType;
onClose: () => void; onClose: () => void;
onSuccess: () => void; onSuccess: () => void;
}> = ({ appType, onClose, onSuccess }) => { }> = ({ appType, onClose, onSuccess }) => {
const fileRef = useRef<HTMLInputElement>(null); const {
const [file, setFile] = useState<File | null>(null); file, platform, versionName, buildNumber, changelog, minOsVersion, isForceUpdate,
const [platform, setPlatform] = useState<'ANDROID' | 'IOS'>('ANDROID'); isParsing, parseWarning, isUploading, uploadError,
const [versionName, setVersionName] = useState(''); setFile, setPlatform, setVersionName, setBuildNumber, setChangelog,
const [buildNumber, setBuildNumber] = useState(''); setMinOsVersion, setIsForceUpdate, setIsParsing, setParseWarning,
const [changelog, setChangelog] = useState(''); setIsUploading, setUploadError, reset,
const [minOsVersion, setMinOsVersion] = useState(''); } = useUploadStore();
const [isForceUpdate, setIsForceUpdate] = useState(false);
const [parsing, setParsing] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [parseWarning, setParseWarning] = useState('');
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]; const f = e.target.files?.[0];
if (!f) return; if (!f) return;
setFile(f); setFile(f);
setError('');
setParseWarning(''); setParseWarning('');
// Auto-detect platform from extension
if (f.name.endsWith('.apk')) setPlatform('ANDROID'); if (f.name.endsWith('.apk')) setPlatform('ANDROID');
else if (f.name.endsWith('.ipa')) setPlatform('IOS'); else if (f.name.endsWith('.ipa')) setPlatform('IOS');
// Auto-parse to pre-fill form fields setIsParsing(true);
setParsing(true);
try { try {
console.log('[Upload] Parsing package:', f.name, (f.size / 1024 / 1024).toFixed(1) + 'MB'); console.log('[Upload] Parsing:', f.name, (f.size / 1024 / 1024).toFixed(1) + 'MB');
const formData = new FormData(); const info = await parsePackageUseCase.execute(f);
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] Parse result:', info); console.log('[Upload] Parse result:', info);
if (info?.versionName) setVersionName(info.versionName); if (info?.versionName) setVersionName(info.versionName);
if (info?.versionCode) setBuildNumber(String(info.versionCode)); if (info?.versionCode) setBuildNumber(String(info.versionCode));
@ -335,63 +288,57 @@ const UploadModal: React.FC<{
console.warn('[Upload] Parse failed:', err?.message); console.warn('[Upload] Parse failed:', err?.message);
setParseWarning('无法自动解析安装包信息,请手动填写版本号'); setParseWarning('无法自动解析安装包信息,请手动填写版本号');
} }
setParsing(false); setIsParsing(false);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!file) { if (!file) { setUploadError(t('app_version_upload_file')); return; }
setError(t('app_version_upload_file')); setIsUploading(true);
return; setUploadError('');
}
setUploading(true);
setError('');
try { try {
console.log('[Upload] Starting upload:', file.name, (file.size / 1024 / 1024).toFixed(1) + 'MB'); console.log('[Upload] Uploading:', file.name, (file.size / 1024 / 1024).toFixed(1) + 'MB');
const formData = new FormData(); const result = await uploadVersionUseCase.execute({
formData.append('file', file); file, appType, platform, versionName, buildNumber,
formData.append('appType', appType); changelog, minOsVersion, isForceUpdate,
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] Success:', result); console.log('[Upload] Success:', result);
reset();
onSuccess(); onSuccess();
} catch (err: any) { } catch (err: any) {
console.error('[Upload] Failed:', err); 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 ( return (
<div style={overlayStyle} onClick={onClose}> <div style={overlayStyle} onClick={handleClose}>
<div style={modalStyle} onClick={e => e.stopPropagation()}> <div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<h2 style={{ font: 'var(--text-h2)', margin: '0 0 8px' }}>{t('app_version_upload')}</h2> <h2 style={{ margin: '0 0 8px' }}>{t('app_version_upload')}</h2>
<label style={labelStyle}>{t('app_version_upload_file')}</label> <label style={labelStyle}>{t('app_version_upload_file')}</label>
<input <input type="file" accept=".apk,.ipa" onChange={handleFileChange}
ref={fileRef} type="file" accept=".apk,.ipa" style={{ ...inputStyle, padding: '8px 12px', height: 'auto' }} />
onChange={handleFileChange}
style={{ ...inputStyle, padding: '8px 12px', height: 'auto' }}
/>
{file && ( {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) {file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB)
</div> </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>} {isParsing && (
{parseWarning && <div style={{ font: 'var(--text-caption)', color: 'var(--color-warning, #f90)', marginTop: 4 }}>{parseWarning}</div>} <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> <label style={labelStyle}>{t('app_version_platform')}</label>
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
{(['ANDROID', 'IOS'] as const).map(p => ( {(['ANDROID', 'IOS'] as AppPlatform[]).map((p) => (
<label key={p} style={{ font: 'var(--text-body)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}> <label key={p} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="radio" checked={platform === p} onChange={() => setPlatform(p)} /> <input type="radio" checked={platform === p} onChange={() => setPlatform(p)} />
{p === 'ANDROID' ? 'Android' : 'iOS'} {p === 'ANDROID' ? 'Android' : 'iOS'}
</label> </label>
@ -400,44 +347,39 @@ const UploadModal: React.FC<{
<label style={labelStyle}> <label style={labelStyle}>
{t('app_version_version_name')} {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')}) ({t('optional_auto_from_apk')})
</span> </span>
</label> </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> <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> <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'} /> placeholder={platform === 'ANDROID' ? 'e.g. 8.0' : 'e.g. 14.0'} />
<label style={labelStyle}>{t('app_version_changelog')}</label> <label style={labelStyle}>{t('app_version_changelog')}</label>
<textarea <textarea value={changelog} onChange={(e) => setChangelog(e.target.value)} rows={3}
value={changelog} onChange={e => setChangelog(e.target.value)} rows={3} style={{ ...inputStyle, height: 'auto', padding: '8px 12px', resize: 'vertical' }} />
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 }}> <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')} {t('app_version_force_update')}
</label> </label>
{error && <div style={{ color: 'var(--color-error)', font: 'var(--text-body-sm)', marginTop: 8 }}>{error}</div>} {uploadError && <div style={{ color: 'var(--color-error)', marginTop: 8 }}>{uploadError}</div>}
{uploading && ( {isUploading && <div style={{ color: 'var(--color-info)', marginTop: 4 }}>...</div>}
<div style={{ font: 'var(--text-caption)', color: 'var(--color-info)', marginTop: 4 }}>
...
</div>
)}
<div style={{ display: 'flex', gap: 12, marginTop: 20, justifyContent: 'flex-end' }}> <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')} {t('cancel')}
</button> </button>
<button onClick={handleSubmit} disabled={uploading || parsing} style={{ ...primaryBtn, opacity: uploading || parsing ? 0.6 : 1 }}> <button onClick={handleSubmit} disabled={isUploading || isParsing}
{uploading ? t('app_version_uploading') : t('confirm')} style={{ ...primaryBtn, opacity: isUploading || isParsing ? 0.6 : 1 }}>
{isUploading ? t('app_version_uploading') : t('confirm')}
</button> </button>
</div> </div>
</div> </div>
@ -445,7 +387,7 @@ const UploadModal: React.FC<{
); );
}; };
/* ── Edit Modal ── */ /* ── Edit Modal — local useState (无需持久化,单次操作) ── */
const EditModal: React.FC<{ const EditModal: React.FC<{
version: AppVersion; version: AppVersion;
@ -453,7 +395,7 @@ const EditModal: React.FC<{
onSuccess: () => void; onSuccess: () => void;
}> = ({ version, onClose, onSuccess }) => { }> = ({ version, onClose, onSuccess }) => {
const [changelog, setChangelog] = useState(version.changelog); 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 [isForceUpdate, setIsForceUpdate] = useState(version.isForceUpdate);
const [isEnabled, setIsEnabled] = useState(version.isEnabled); const [isEnabled, setIsEnabled] = useState(version.isEnabled);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -463,12 +405,13 @@ const EditModal: React.FC<{
setSaving(true); setSaving(true);
setError(''); setError('');
try { try {
await apiClient.put(`/api/v1/admin/versions/${version.id}`, { const input: UpdateVersionInput = {
changelog, changelog,
minOsVersion: minOsVersion || null, minOsVersion: minOsVersion || null,
isForceUpdate, isForceUpdate,
isEnabled, isEnabled,
}); };
await updateVersionUseCase.execute(version.id, input);
onSuccess(); onSuccess();
} catch (err: any) { } catch (err: any) {
setError(err?.message || 'Save failed'); setError(err?.message || 'Save failed');
@ -478,39 +421,39 @@ const EditModal: React.FC<{
return ( return (
<div style={overlayStyle} onClick={onClose}> <div style={overlayStyle} onClick={onClose}>
<div style={modalStyle} onClick={e => e.stopPropagation()}> <div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<h2 style={{ font: 'var(--text-h2)', margin: '0 0 4px' }}>{t('app_version_edit')}</h2> <h2 style={{ 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={{ color: 'var(--color-text-tertiary)', marginBottom: 16 }}>
{version.platform === 'ANDROID' ? 'Android' : 'iOS'} · v{version.versionName} · Build {version.buildNumber} {version.platform === 'ANDROID' ? 'Android' : 'iOS'} · v{version.versionName} · Build {version.buildNumber}
</div> </div>
<label style={labelStyle}>{t('app_version_changelog')}</label> <label style={labelStyle}>{t('app_version_changelog')}</label>
<textarea <textarea value={changelog} onChange={(e) => setChangelog(e.target.value)} rows={4}
value={changelog} onChange={e => setChangelog(e.target.value)} rows={4} style={{ ...inputStyle, height: 'auto', padding: '8px 12px', resize: 'vertical' }} />
style={{ ...inputStyle, height: 'auto', padding: '8px 12px', resize: 'vertical' }}
/>
<label style={labelStyle}>{t('app_version_min_os')}</label> <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'} /> placeholder={version.platform === 'ANDROID' ? 'e.g. 8.0' : 'e.g. 14.0'} />
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 8, marginTop: 16 }}> <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')} {t('app_version_force_update')}
</label> </label>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 8 }}> <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')} {t('app_version_enabled')}
</label> </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' }}> <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')} {t('cancel')}
</button> </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')} {saving ? '...' : t('save')}
</button> </button>
</div> </div>