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