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:
hailin 2026-03-06 04:51:19 -08:00
parent 55b983a950
commit b63341a464
11 changed files with 1021 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -30,6 +30,7 @@
"billingOverview": "Overview",
"billingPlans": "Plans",
"billingInvoices": "Invoices",
"appVersions": "App Versions",
"tenants": "Tenants",
"users": "Users",
"settings": "Settings",

View File

@ -30,6 +30,7 @@
"billingOverview": "总览",
"billingPlans": "套餐",
"billingInvoices": "账单列表",
"appVersions": "App 版本管理",
"tenants": "租户",
"users": "用户",
"settings": "设置",

View File

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

View File

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

View File

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

View File

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

View File

@ -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} /> },