feat(web-admin): add App Version Management page for IT0 App
Ports the APK/IPA upgrade management UI from rwadurian/mobile-upgrade into it0-web-admin, adapted exclusively for IT0 App's version-service. New files: - src/domain/entities/app-version.ts Domain entity matching version-service response schema: platform returned as ANDROID/IOS (normalized to lowercase), fileSize as number (bigint), no versionCode/fileSha256 fields. - src/infrastructure/repositories/api-app-version.repository.ts CRUD via existing apiClient (→ /api/proxy/api/v1/versions). Upload/parse use dedicated Next.js routes (/api/app-versions/*) because the existing proxy uses request.text() which corrupts binary. - src/app/api/app-versions/upload/route.ts Multipart FormData upload proxy → API_BASE_URL/api/v1/versions/upload maxDuration=300s for large APK files (up to 500 MB). - src/app/api/app-versions/parse/route.ts Multipart proxy → API_BASE_URL/api/v1/versions/parse Forwards APK/IPA file to version-service for auto-parsing. - src/app/(admin)/app-versions/page.tsx Admin page: react-query list, platform filter (all/android/ios), upload button, loading skeleton, delete/toggle with confirm. Single-app (IT0 only) — no multi-app switcher from mobile-upgrade. - src/presentation/components/app-versions/version-card.tsx Version card with enable/disable/edit/delete/download actions. Uses dark-theme CSS variables (bg-card, text-muted-foreground, etc.) - src/presentation/components/app-versions/upload-modal.tsx Upload modal: auto-detects platform from .apk/.ipa extension, auto-parses version info via /parse endpoint, sonner toasts. - src/presentation/components/app-versions/edit-modal.tsx Edit modal: update changelog, force-update flag, enabled state, min OS version. Loads version data on open via getVersionById. Modified: - sidebar.tsx: added Smartphone icon + appVersions nav item → /app-versions - locales/zh/sidebar.json: "appVersions": "App 版本管理" - locales/en/sidebar.json: "appVersions": "App Versions" Backend: IT0 version-service at /api/v1/versions (no auth guard required) Flutter: it0_app/lib/core/updater/version_checker.dart calls GET /api/app/version/check (public) for client-side update check. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
55b983a950
commit
b63341a464
|
|
@ -0,0 +1,195 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { RefreshCw, Upload } from 'lucide-react';
|
||||
import { AppPlatform, AppVersion } from '@/domain/entities/app-version';
|
||||
import {
|
||||
listVersions,
|
||||
deleteVersion,
|
||||
toggleVersion,
|
||||
} from '@/infrastructure/repositories/api-app-version.repository';
|
||||
import { VersionCard } from '@/presentation/components/app-versions/version-card';
|
||||
import { UploadModal } from '@/presentation/components/app-versions/upload-modal';
|
||||
import { EditModal } from '@/presentation/components/app-versions/edit-modal';
|
||||
|
||||
type PlatformFilter = AppPlatform | 'all';
|
||||
|
||||
const PLATFORM_BUTTONS: { value: PlatformFilter; label: string }[] = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'android', label: 'Android' },
|
||||
{ value: 'ios', label: 'iOS' },
|
||||
];
|
||||
|
||||
export default function AppVersionsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [editingVersionId, setEditingVersionId] = useState<string | null>(null);
|
||||
|
||||
const { data: versions = [], isLoading, error, refetch } = useQuery<AppVersion[]>({
|
||||
queryKey: ['app-versions', platformFilter],
|
||||
queryFn: () =>
|
||||
listVersions({
|
||||
platform: platformFilter === 'all' ? undefined : platformFilter,
|
||||
includeDisabled: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这个版本吗?此操作不可恢复。')) return;
|
||||
try {
|
||||
await deleteVersion(id);
|
||||
toast.success('版本已删除');
|
||||
queryClient.invalidateQueries({ queryKey: ['app-versions'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string, isEnabled: boolean) => {
|
||||
try {
|
||||
await toggleVersion(id, isEnabled);
|
||||
toast.success(isEnabled ? '版本已启用' : '版本已禁用');
|
||||
queryClient.invalidateQueries({ queryKey: ['app-versions'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
setShowUploadModal(false);
|
||||
toast.success('版本上传成功');
|
||||
queryClient.invalidateQueries({ queryKey: ['app-versions'] });
|
||||
};
|
||||
|
||||
const handleEditSuccess = () => {
|
||||
setEditingVersionId(null);
|
||||
toast.success('版本更新成功');
|
||||
queryClient.invalidateQueries({ queryKey: ['app-versions'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">App 版本管理</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
管理 IT0 App 的 APK / IPA 版本发布与升级策略
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-md text-sm bg-accent text-foreground hover:bg-accent/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
上传新版本
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground shrink-0">平台筛选:</span>
|
||||
<div className="flex gap-1.5">
|
||||
{PLATFORM_BUTTONS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setPlatformFilter(value)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
platformFilter === value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-accent text-muted-foreground hover:text-foreground hover:bg-accent/80'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 text-red-400 text-sm">
|
||||
加载失败:{error instanceof Error ? error.message : String(error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-card rounded-lg border p-4 animate-pulse">
|
||||
<div className="flex gap-3 mb-3">
|
||||
<div className="h-5 w-16 rounded bg-accent" />
|
||||
<div className="h-5 w-24 rounded bg-accent" />
|
||||
<div className="h-5 w-20 rounded bg-accent" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="h-4 rounded bg-accent" />
|
||||
<div className="h-4 rounded bg-accent" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version list */}
|
||||
{!isLoading && !error && (
|
||||
<div className="space-y-3">
|
||||
{versions.length === 0 ? (
|
||||
<div className="bg-card rounded-lg border p-12 text-center">
|
||||
<p className="text-muted-foreground text-sm mb-4">暂无版本数据</p>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-4 py-2 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
上传第一个版本
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
versions.map((version) => (
|
||||
<VersionCard
|
||||
key={version.id}
|
||||
version={version}
|
||||
onEdit={() => setEditingVersionId(version.id)}
|
||||
onDelete={() => handleDelete(version.id)}
|
||||
onToggle={(enabled) => handleToggle(version.id, enabled)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{showUploadModal && (
|
||||
<UploadModal
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onSuccess={handleUploadSuccess}
|
||||
/>
|
||||
)}
|
||||
{editingVersionId && (
|
||||
<EditModal
|
||||
versionId={editingVersionId}
|
||||
onClose={() => setEditingVersionId(null)}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
return process.env.API_BASE_URL || 'http://localhost:8000';
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const apiBase = getApiBaseUrl();
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (authHeader) headers['authorization'] = authHeader;
|
||||
const tenantHeader = request.headers.get('x-tenant-id');
|
||||
if (tenantHeader) headers['x-tenant-id'] = tenantHeader;
|
||||
|
||||
const response = await fetch(`${apiBase}/api/v1/versions/parse`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.text();
|
||||
return new NextResponse(data, {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[app-versions/parse] proxy error:', error);
|
||||
return NextResponse.json({ error: 'Parse proxy error', detail: String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export const maxDuration = 120; // seconds
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
return process.env.API_BASE_URL || 'http://localhost:8000';
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const apiBase = getApiBaseUrl();
|
||||
|
||||
// Forward auth headers but let fetch set Content-Type with correct boundary for FormData
|
||||
const headers: Record<string, string> = {};
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (authHeader) headers['authorization'] = authHeader;
|
||||
const tenantHeader = request.headers.get('x-tenant-id');
|
||||
if (tenantHeader) headers['x-tenant-id'] = tenantHeader;
|
||||
|
||||
const response = await fetch(`${apiBase}/api/v1/versions/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.text();
|
||||
return new NextResponse(data, {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[app-versions/upload] proxy error:', error);
|
||||
return NextResponse.json({ error: 'Upload proxy error', detail: String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
// Disable body size limit for large APK/IPA uploads (up to 500 MB)
|
||||
export const maxDuration = 300; // seconds
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
export type AppPlatform = 'android' | 'ios';
|
||||
|
||||
export interface AppVersion {
|
||||
id: string;
|
||||
platform: AppPlatform;
|
||||
versionName: string;
|
||||
buildNumber: string;
|
||||
changelog?: string;
|
||||
downloadUrl?: string;
|
||||
fileSize?: number;
|
||||
isForceUpdate: boolean;
|
||||
isEnabled: boolean;
|
||||
minOsVersion?: string;
|
||||
releaseDate?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateAppVersionInput {
|
||||
changelog?: string;
|
||||
isForceUpdate?: boolean;
|
||||
isEnabled?: boolean;
|
||||
minOsVersion?: string | null;
|
||||
}
|
||||
|
||||
export interface UploadAppVersionInput {
|
||||
file: File;
|
||||
platform: AppPlatform;
|
||||
versionName: string;
|
||||
buildNumber: string;
|
||||
changelog?: string;
|
||||
isForceUpdate?: boolean;
|
||||
minOsVersion?: string;
|
||||
}
|
||||
|
||||
export interface AppVersionFilter {
|
||||
platform?: AppPlatform;
|
||||
includeDisabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedPackageInfo {
|
||||
versionName?: string | null;
|
||||
versionCode?: string | null;
|
||||
minSdkVersion?: string | null;
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@
|
|||
"billingOverview": "Overview",
|
||||
"billingPlans": "Plans",
|
||||
"billingInvoices": "Invoices",
|
||||
"appVersions": "App Versions",
|
||||
"tenants": "Tenants",
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"billingOverview": "总览",
|
||||
"billingPlans": "套餐",
|
||||
"billingInvoices": "账单列表",
|
||||
"appVersions": "App 版本管理",
|
||||
"tenants": "租户",
|
||||
"users": "用户",
|
||||
"settings": "设置",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import { apiClient } from '../api/api-client';
|
||||
import {
|
||||
AppVersion,
|
||||
AppVersionFilter,
|
||||
UpdateAppVersionInput,
|
||||
UploadAppVersionInput,
|
||||
ParsedPackageInfo,
|
||||
} from '@/domain/entities/app-version';
|
||||
|
||||
/** Normalize version-service response: platform comes back as ANDROID/IOS, fileSize as bigint string */
|
||||
function normalize(raw: Record<string, unknown>): AppVersion {
|
||||
return {
|
||||
...(raw as AppVersion),
|
||||
platform: (raw.platform as string).toLowerCase() as 'android' | 'ios',
|
||||
fileSize: raw.fileSize != null ? Number(raw.fileSize) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listVersions(filter?: AppVersionFilter): Promise<AppVersion[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.platform) params.append('platform', filter.platform.toUpperCase());
|
||||
if (filter?.includeDisabled) params.append('includeDisabled', 'true');
|
||||
const qs = params.toString();
|
||||
const result = await apiClient<unknown[]>(`/api/v1/versions${qs ? '?' + qs : ''}`);
|
||||
return result.map((v) => normalize(v as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function getVersionById(id: string): Promise<AppVersion> {
|
||||
const result = await apiClient<unknown>(`/api/v1/versions/${id}`);
|
||||
return normalize(result as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export async function updateVersion(id: string, input: UpdateAppVersionInput): Promise<AppVersion> {
|
||||
const result = await apiClient<unknown>(`/api/v1/versions/${id}`, { method: 'PUT', body: input });
|
||||
return normalize(result as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export async function deleteVersion(id: string): Promise<void> {
|
||||
await apiClient<unknown>(`/api/v1/versions/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function toggleVersion(id: string, isEnabled: boolean): Promise<void> {
|
||||
await apiClient<unknown>(`/api/v1/versions/${id}/toggle`, {
|
||||
method: 'PATCH',
|
||||
body: { isEnabled },
|
||||
});
|
||||
}
|
||||
|
||||
function getClientAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (typeof window === 'undefined') return headers;
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const tenantData = localStorage.getItem('current_tenant');
|
||||
if (tenantData) {
|
||||
try {
|
||||
const tenant = JSON.parse(tenantData) as { id: string };
|
||||
headers['X-Tenant-Id'] = tenant.id;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function uploadVersion(input: UploadAppVersionInput): Promise<AppVersion> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', input.file);
|
||||
formData.append('platform', input.platform.toUpperCase());
|
||||
formData.append('versionName', input.versionName);
|
||||
formData.append('buildNumber', input.buildNumber);
|
||||
formData.append('isForceUpdate', String(input.isForceUpdate ?? false));
|
||||
if (input.changelog) formData.append('changelog', input.changelog);
|
||||
if (input.minOsVersion) formData.append('minOsVersion', input.minOsVersion);
|
||||
|
||||
const response = await fetch('/api/app-versions/upload', {
|
||||
method: 'POST',
|
||||
headers: getClientAuthHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => null) as { message?: string } | null;
|
||||
throw new Error(err?.message || `上传失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as Record<string, unknown>;
|
||||
return normalize(result);
|
||||
}
|
||||
|
||||
export async function parsePackage(file: File, platform: 'android' | 'ios'): Promise<ParsedPackageInfo> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('platform', platform.toUpperCase());
|
||||
|
||||
const response = await fetch('/api/app-versions/parse', {
|
||||
method: 'POST',
|
||||
headers: getClientAuthHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`解析失败: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<ParsedPackageInfo>;
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AppVersion, UpdateAppVersionInput } from '@/domain/entities/app-version';
|
||||
import { getVersionById, updateVersion } from '@/infrastructure/repositories/api-app-version.repository';
|
||||
|
||||
interface EditModalProps {
|
||||
versionId: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function EditModal({ versionId, onClose, onSuccess }: EditModalProps) {
|
||||
const [version, setVersion] = useState<AppVersion | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
changelog: '',
|
||||
isForceUpdate: false,
|
||||
isEnabled: true,
|
||||
minOsVersion: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
getVersionById(versionId)
|
||||
.then((v) => {
|
||||
setVersion(v);
|
||||
setFormData({
|
||||
changelog: v.changelog || '',
|
||||
isForceUpdate: v.isForceUpdate,
|
||||
isEnabled: v.isEnabled,
|
||||
minOsVersion: v.minOsVersion || '',
|
||||
});
|
||||
})
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : '加载失败'))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [versionId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const input: UpdateAppVersionInput = {
|
||||
changelog: formData.changelog.trim() || undefined,
|
||||
isForceUpdate: formData.isForceUpdate,
|
||||
isEnabled: formData.isEnabled,
|
||||
minOsVersion: formData.minOsVersion.trim() || null,
|
||||
};
|
||||
await updateVersion(versionId, input);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '更新失败');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (label: string) => (
|
||||
<span className="text-sm font-medium text-foreground">{label}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-xl border shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b shrink-0">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">编辑版本</h3>
|
||||
{version && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{version.platform.toUpperCase()} v{version.versionName} (Build {version.buildNumber})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : loadError || !version ? (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-red-400 text-sm">{loadError || '加载版本信息失败'}</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-4 px-4 py-2 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form id="edit-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Min OS version */}
|
||||
<div className="space-y-1.5">
|
||||
{field('最低系统版本')}
|
||||
<input
|
||||
type="text"
|
||||
value={formData.minOsVersion}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, minOsVersion: e.target.value }))}
|
||||
placeholder={version.platform === 'android' ? '例如:8.0' : '例如:14.0'}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Changelog */}
|
||||
<div className="space-y-1.5">
|
||||
{field('更新日志')}
|
||||
<textarea
|
||||
value={formData.changelog}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, changelog: e.target.value }))}
|
||||
placeholder="请输入本版本的更新内容..."
|
||||
rows={4}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Force update */}
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isForceUpdate}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, isForceUpdate: e.target.checked }))}
|
||||
className="mt-0.5 accent-primary"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">强制更新</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
勾选后,用户必须更新到此版本才能继续使用应用
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Enabled */}
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isEnabled}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, isEnabled: e.target.checked }))}
|
||||
className="mt-0.5 accent-primary"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">启用此版本</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
禁用后,此版本将不会推送给用户
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!isLoading && !loadError && version && (
|
||||
<div className="flex justify-end gap-2.5 p-5 border-t shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-accent text-foreground hover:bg-accent/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="edit-form"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { X, Upload } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AppPlatform, UploadAppVersionInput } from '@/domain/entities/app-version';
|
||||
import { uploadVersion, parsePackage } from '@/infrastructure/repositories/api-app-version.repository';
|
||||
|
||||
interface UploadModalProps {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isParsing, setIsParsing] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parseWarning, setParseWarning] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<{
|
||||
platform: AppPlatform;
|
||||
versionName: string;
|
||||
buildNumber: string;
|
||||
changelog: string;
|
||||
isForceUpdate: boolean;
|
||||
minOsVersion: string;
|
||||
}>({
|
||||
platform: 'android',
|
||||
versionName: '',
|
||||
buildNumber: '',
|
||||
changelog: '',
|
||||
isForceUpdate: false,
|
||||
minOsVersion: '',
|
||||
});
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files?.[0];
|
||||
if (!selected) return;
|
||||
|
||||
setFile(selected);
|
||||
setParseWarning(null);
|
||||
|
||||
const detectedPlatform: AppPlatform = selected.name.endsWith('.ipa') ? 'ios' : 'android';
|
||||
setFormData((prev) => ({ ...prev, platform: detectedPlatform }));
|
||||
|
||||
setIsParsing(true);
|
||||
try {
|
||||
const parsed = await parsePackage(selected, detectedPlatform);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
platform: detectedPlatform,
|
||||
versionName: parsed.versionName || prev.versionName,
|
||||
buildNumber: parsed.versionCode || prev.buildNumber,
|
||||
minOsVersion: parsed.minSdkVersion || prev.minOsVersion,
|
||||
}));
|
||||
} catch {
|
||||
setParseWarning('无法自动解析安装包信息,请手动填写版本号和构建号');
|
||||
} finally {
|
||||
setIsParsing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!file) {
|
||||
toast.error('请选择安装包文件');
|
||||
return;
|
||||
}
|
||||
if (!formData.versionName.trim()) {
|
||||
toast.error('请输入版本号');
|
||||
return;
|
||||
}
|
||||
if (!formData.buildNumber.trim()) {
|
||||
toast.error('请输入构建号');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const input: UploadAppVersionInput = {
|
||||
file,
|
||||
platform: formData.platform,
|
||||
versionName: formData.versionName.trim(),
|
||||
buildNumber: formData.buildNumber.trim(),
|
||||
changelog: formData.changelog.trim() || undefined,
|
||||
isForceUpdate: formData.isForceUpdate,
|
||||
minOsVersion: formData.minOsVersion.trim() || undefined,
|
||||
};
|
||||
await uploadVersion(input);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '上传失败');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (label: string, required = false) => (
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
{required && <span className="text-red-400 ml-0.5">*</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-xl border shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b shrink-0">
|
||||
<h3 className="text-base font-semibold text-foreground">上传新版本</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{parseWarning && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-yellow-400 text-sm">
|
||||
{parseWarning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form id="upload-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* File upload */}
|
||||
<div className="space-y-1.5">
|
||||
{field('安装包文件', true)}
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-border rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".apk,.ipa"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
{file ? (
|
||||
<div>
|
||||
<p className="text-foreground font-medium text-sm">{file.name}</p>
|
||||
<p className="text-muted-foreground text-xs mt-1">
|
||||
{(file.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
{isParsing && (
|
||||
<p className="text-primary text-xs mt-1">正在解析包信息...</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Upload className="mx-auto h-10 w-10 text-muted-foreground mb-2" />
|
||||
<p className="text-muted-foreground text-sm">点击选择 APK 或 IPA 文件</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform */}
|
||||
<div className="space-y-1.5">
|
||||
{field('平台', true)}
|
||||
<div className="flex gap-4">
|
||||
{(['android', 'ios'] as AppPlatform[]).map((p) => (
|
||||
<label key={p} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="platform"
|
||||
value={p}
|
||||
checked={formData.platform === p}
|
||||
onChange={() => setFormData((prev) => ({ ...prev, platform: p }))}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="text-sm text-foreground capitalize">{p}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version name */}
|
||||
<div className="space-y-1.5">
|
||||
{field('版本号', true)}
|
||||
<input
|
||||
type="text"
|
||||
value={formData.versionName}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, versionName: e.target.value }))}
|
||||
placeholder="例如:1.0.0"
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Build number */}
|
||||
<div className="space-y-1.5">
|
||||
{field('构建号', true)}
|
||||
<input
|
||||
type="text"
|
||||
value={formData.buildNumber}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, buildNumber: e.target.value }))}
|
||||
placeholder="例如:100"
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Min OS version */}
|
||||
<div className="space-y-1.5">
|
||||
{field('最低系统版本')}
|
||||
<input
|
||||
type="text"
|
||||
value={formData.minOsVersion}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, minOsVersion: e.target.value }))}
|
||||
placeholder={formData.platform === 'android' ? '例如:8.0' : '例如:14.0'}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Changelog */}
|
||||
<div className="space-y-1.5">
|
||||
{field('更新日志')}
|
||||
<textarea
|
||||
value={formData.changelog}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, changelog: e.target.value }))}
|
||||
placeholder="请输入本版本的更新内容..."
|
||||
rows={4}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Force update */}
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isForceUpdate}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, isForceUpdate: e.target.checked }))}
|
||||
className="mt-0.5 accent-primary"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">强制更新</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
勾选后,用户必须更新到此版本才能继续使用应用
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2.5 p-5 border-t shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-accent text-foreground hover:bg-accent/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="upload-form"
|
||||
disabled={isSubmitting || isParsing}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isParsing ? '解析中...' : isSubmitting ? '上传中...' : '上传'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
'use client';
|
||||
|
||||
import { AppVersion } from '@/domain/entities/app-version';
|
||||
|
||||
interface VersionCardProps {
|
||||
version: AppVersion;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (bytes == null) return '-';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function VersionCard({ version, onEdit, onDelete, onToggle }: VersionCardProps) {
|
||||
const platformBadge =
|
||||
version.platform === 'android'
|
||||
? 'bg-green-500/15 text-green-400 border border-green-500/30'
|
||||
: 'bg-slate-500/15 text-slate-300 border border-slate-500/30';
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-lg border p-4 hover:border-primary/30 transition-colors">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
{/* Left: Version info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title row */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium uppercase ${platformBadge}`}>
|
||||
{version.platform}
|
||||
</span>
|
||||
<span className="text-base font-semibold text-foreground">
|
||||
v{version.versionName}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
(Build {version.buildNumber})
|
||||
</span>
|
||||
{version.isForceUpdate && (
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-destructive/15 text-red-400 border border-destructive/30">
|
||||
强制更新
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
version.isEnabled
|
||||
? 'bg-primary/15 text-primary border border-primary/30'
|
||||
: 'bg-muted text-muted-foreground border border-border'
|
||||
}`}
|
||||
>
|
||||
{version.isEnabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta grid */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm mb-3">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0">文件大小:</span>
|
||||
<span className="text-foreground">{formatFileSize(version.fileSize)}</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0">最低系统版本:</span>
|
||||
<span className="text-foreground">{version.minOsVersion || '-'}</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0">发布时间:</span>
|
||||
<span className="text-foreground">{formatDate(version.releaseDate)}</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0">创建时间:</span>
|
||||
<span className="text-foreground">{formatDate(version.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changelog */}
|
||||
{version.changelog && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">更新日志:</span>
|
||||
<pre className="mt-1 text-foreground bg-accent/50 rounded p-2.5 text-xs whitespace-pre-wrap font-sans">
|
||||
{version.changelog}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex flex-col gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => onToggle(!version.isEnabled)}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
version.isEnabled
|
||||
? 'bg-muted text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
: 'bg-primary/15 text-primary hover:bg-primary/25'
|
||||
}`}
|
||||
>
|
||||
{version.isEnabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-3 py-1.5 rounded text-xs font-medium bg-accent text-foreground hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-3 py-1.5 rounded text-xs font-medium bg-destructive/15 text-red-400 hover:bg-destructive/25 transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
{version.downloadUrl && (
|
||||
<a
|
||||
href={version.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 rounded text-xs font-medium bg-green-500/15 text-green-400 hover:bg-green-500/25 transition-colors text-center"
|
||||
>
|
||||
下载
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
CreditCard,
|
||||
Smartphone,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ---------- Sidebar context for collapse state ---------- */
|
||||
|
|
@ -160,6 +161,7 @@ export function Sidebar() {
|
|||
{ label: t('billingInvoices'), href: '/billing/invoices' },
|
||||
],
|
||||
},
|
||||
{ key: 'appVersions', label: t('appVersions'), href: '/app-versions', icon: <Smartphone className={iconClass} /> },
|
||||
{ key: 'tenants', label: t('tenants'), href: '/tenants', icon: <Building2 className={iconClass} /> },
|
||||
{ key: 'users', label: t('users'), href: '/users', icon: <Users className={iconClass} /> },
|
||||
{ key: 'settings', label: t('settings'), href: '/settings', icon: <Settings className={iconClass} /> },
|
||||
|
|
|
|||
Loading…
Reference in New Issue