From b63341a4641357e1ab23096d1aa44d4727a237ae Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 6 Mar 2026 04:51:19 -0800 Subject: [PATCH] feat(web-admin): add App Version Management page for IT0 App MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/app/(admin)/app-versions/page.tsx | 195 +++++++++++++ .../src/app/api/app-versions/parse/route.ts | 35 +++ .../src/app/api/app-versions/upload/route.ts | 37 +++ .../src/domain/entities/app-version.ts | 45 +++ .../src/i18n/locales/en/sidebar.json | 1 + .../src/i18n/locales/zh/sidebar.json | 1 + .../api-app-version.repository.ts | 107 +++++++ .../components/app-versions/edit-modal.tsx | 188 ++++++++++++ .../components/app-versions/upload-modal.tsx | 271 ++++++++++++++++++ .../components/app-versions/version-card.tsx | 139 +++++++++ .../components/layout/sidebar.tsx | 2 + 11 files changed, 1021 insertions(+) create mode 100644 it0-web-admin/src/app/(admin)/app-versions/page.tsx create mode 100644 it0-web-admin/src/app/api/app-versions/parse/route.ts create mode 100644 it0-web-admin/src/app/api/app-versions/upload/route.ts create mode 100644 it0-web-admin/src/domain/entities/app-version.ts create mode 100644 it0-web-admin/src/infrastructure/repositories/api-app-version.repository.ts create mode 100644 it0-web-admin/src/presentation/components/app-versions/edit-modal.tsx create mode 100644 it0-web-admin/src/presentation/components/app-versions/upload-modal.tsx create mode 100644 it0-web-admin/src/presentation/components/app-versions/version-card.tsx diff --git a/it0-web-admin/src/app/(admin)/app-versions/page.tsx b/it0-web-admin/src/app/(admin)/app-versions/page.tsx new file mode 100644 index 0000000..c2a8780 --- /dev/null +++ b/it0-web-admin/src/app/(admin)/app-versions/page.tsx @@ -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('all'); + const [showUploadModal, setShowUploadModal] = useState(false); + const [editingVersionId, setEditingVersionId] = useState(null); + + const { data: versions = [], isLoading, error, refetch } = useQuery({ + 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 ( +
+ {/* Page header */} +
+
+

App 版本管理

+

+ 管理 IT0 App 的 APK / IPA 版本发布与升级策略 +

+
+
+ + +
+
+ + {/* Platform filter */} +
+ 平台筛选: +
+ {PLATFORM_BUTTONS.map(({ value, label }) => ( + + ))} +
+
+ + {/* Error state */} + {error && ( +
+ 加载失败:{error instanceof Error ? error.message : String(error)} +
+ )} + + {/* Loading skeleton */} + {isLoading && ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Version list */} + {!isLoading && !error && ( +
+ {versions.length === 0 ? ( +
+

暂无版本数据

+ +
+ ) : ( + versions.map((version) => ( + setEditingVersionId(version.id)} + onDelete={() => handleDelete(version.id)} + onToggle={(enabled) => handleToggle(version.id, enabled)} + /> + )) + )} +
+ )} + + {/* Modals */} + {showUploadModal && ( + setShowUploadModal(false)} + onSuccess={handleUploadSuccess} + /> + )} + {editingVersionId && ( + setEditingVersionId(null)} + onSuccess={handleEditSuccess} + /> + )} +
+ ); +} diff --git a/it0-web-admin/src/app/api/app-versions/parse/route.ts b/it0-web-admin/src/app/api/app-versions/parse/route.ts new file mode 100644 index 0000000..e28708a --- /dev/null +++ b/it0-web-admin/src/app/api/app-versions/parse/route.ts @@ -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 = {}; + 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 diff --git a/it0-web-admin/src/app/api/app-versions/upload/route.ts b/it0-web-admin/src/app/api/app-versions/upload/route.ts new file mode 100644 index 0000000..232ca6f --- /dev/null +++ b/it0-web-admin/src/app/api/app-versions/upload/route.ts @@ -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 = {}; + 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 diff --git a/it0-web-admin/src/domain/entities/app-version.ts b/it0-web-admin/src/domain/entities/app-version.ts new file mode 100644 index 0000000..d424005 --- /dev/null +++ b/it0-web-admin/src/domain/entities/app-version.ts @@ -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; +} diff --git a/it0-web-admin/src/i18n/locales/en/sidebar.json b/it0-web-admin/src/i18n/locales/en/sidebar.json index 3042149..bd01836 100644 --- a/it0-web-admin/src/i18n/locales/en/sidebar.json +++ b/it0-web-admin/src/i18n/locales/en/sidebar.json @@ -30,6 +30,7 @@ "billingOverview": "Overview", "billingPlans": "Plans", "billingInvoices": "Invoices", + "appVersions": "App Versions", "tenants": "Tenants", "users": "Users", "settings": "Settings", diff --git a/it0-web-admin/src/i18n/locales/zh/sidebar.json b/it0-web-admin/src/i18n/locales/zh/sidebar.json index a06c1b9..b6c8ce8 100644 --- a/it0-web-admin/src/i18n/locales/zh/sidebar.json +++ b/it0-web-admin/src/i18n/locales/zh/sidebar.json @@ -30,6 +30,7 @@ "billingOverview": "总览", "billingPlans": "套餐", "billingInvoices": "账单列表", + "appVersions": "App 版本管理", "tenants": "租户", "users": "用户", "settings": "设置", diff --git a/it0-web-admin/src/infrastructure/repositories/api-app-version.repository.ts b/it0-web-admin/src/infrastructure/repositories/api-app-version.repository.ts new file mode 100644 index 0000000..ad23d08 --- /dev/null +++ b/it0-web-admin/src/infrastructure/repositories/api-app-version.repository.ts @@ -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): 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 { + 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(`/api/v1/versions${qs ? '?' + qs : ''}`); + return result.map((v) => normalize(v as Record)); +} + +export async function getVersionById(id: string): Promise { + const result = await apiClient(`/api/v1/versions/${id}`); + return normalize(result as Record); +} + +export async function updateVersion(id: string, input: UpdateAppVersionInput): Promise { + const result = await apiClient(`/api/v1/versions/${id}`, { method: 'PUT', body: input }); + return normalize(result as Record); +} + +export async function deleteVersion(id: string): Promise { + await apiClient(`/api/v1/versions/${id}`, { method: 'DELETE' }); +} + +export async function toggleVersion(id: string, isEnabled: boolean): Promise { + await apiClient(`/api/v1/versions/${id}/toggle`, { + method: 'PATCH', + body: { isEnabled }, + }); +} + +function getClientAuthHeaders(): Record { + const headers: Record = {}; + 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 { + 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; + return normalize(result); +} + +export async function parsePackage(file: File, platform: 'android' | 'ios'): Promise { + 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; +} diff --git a/it0-web-admin/src/presentation/components/app-versions/edit-modal.tsx b/it0-web-admin/src/presentation/components/app-versions/edit-modal.tsx new file mode 100644 index 0000000..6a04565 --- /dev/null +++ b/it0-web-admin/src/presentation/components/app-versions/edit-modal.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [loadError, setLoadError] = useState(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) => ( + {label} + ); + + return ( +
+
+ {/* Header */} +
+
+

编辑版本

+ {version && ( +

+ {version.platform.toUpperCase()} v{version.versionName} (Build {version.buildNumber}) +

+ )} +
+ +
+ + {/* Body */} +
+ {isLoading ? ( +
+
+
+ ) : loadError || !version ? ( +
+

{loadError || '加载版本信息失败'}

+ +
+ ) : ( +
+ {/* Min OS version */} +
+ {field('最低系统版本')} + 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" + /> +
+ + {/* Changelog */} +
+ {field('更新日志')} +