feat(mobile-upgrade): 添加移动应用升级管理Web前端

使用Clean Architecture架构实现移动应用版本管理Web前端:
- Domain层: 版本实体、Repository接口定义
- Infrastructure层: API客户端、Repository实现
- Application层: Zustand状态管理、React Hooks
- Presentation层: 版本列表、上传、编辑组件

技术栈: Next.js 14 + React + TypeScript + Zustand + Tailwind CSS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Developer 2025-12-02 22:28:56 -08:00
parent fcd97f26cf
commit 6db948099c
23 changed files with 7799 additions and 0 deletions

35
frontend/mobile-upgrade/.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build
.next/
out/
build/
dist/
# Local env files
.env*.local
# Testing
coverage/
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# TypeScript
*.tsbuildinfo
next-env.d.ts

View File

@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3010',
},
}
module.exports = nextConfig

6365
frontend/mobile-upgrade/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
{
"name": "mobile-upgrade-admin",
"version": "1.0.0",
"description": "Mobile App Upgrade Management Admin Panel",
"private": true,
"scripts": {
"dev": "next dev -p 3020",
"build": "next build",
"start": "next start -p 3020",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"next": "^14.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zustand": "^4.4.7",
"axios": "^1.6.2",
"@tanstack/react-query": "^5.14.0",
"date-fns": "^3.0.6",
"clsx": "^2.0.0",
"react-hot-toast": "^2.4.1",
"react-dropzone": "^14.2.3"
},
"devDependencies": {
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"typescript": "^5.3.3",
"eslint": "^8.56.0",
"eslint-config-next": "^14.0.4",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.32",
"autoprefixer": "^10.4.16"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,47 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-rgb: 249, 250, 251;
}
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
}
@layer components {
.btn {
@apply px-4 py-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-primary {
@apply bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
}
.btn-secondary {
@apply bg-gray-200 text-gray-700 hover:bg-gray-300 focus:ring-gray-500;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
}
.btn-success {
@apply bg-green-600 text-white hover:bg-green-700 focus:ring-green-500;
}
.input {
@apply w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
}
.label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
.card {
@apply bg-white rounded-lg shadow-md p-6;
}
}

View File

@ -0,0 +1,38 @@
import type { Metadata } from 'next'
import { Toaster } from 'react-hot-toast'
import './globals.css'
export const metadata: Metadata = {
title: 'Mobile Upgrade Admin',
description: 'Mobile App Version Management Admin Panel',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body>
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<h1 className="text-xl font-semibold text-gray-900">
Mobile Upgrade Admin
</h1>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</div>
<Toaster position="top-right" />
</body>
</html>
)
}

View File

@ -0,0 +1,176 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useVersions, useVersionActions } from '@/application'
import { Platform } from '@/domain'
import { VersionCard } from '@/presentation/components/version-card'
import { UploadModal } from '@/presentation/components/upload-modal'
import { EditModal } from '@/presentation/components/edit-modal'
import toast from 'react-hot-toast'
export default function HomePage() {
const { versions, isLoading, error, refetch, setFilter } = useVersions()
const { deleteVersion, toggleVersion } = useVersionActions()
const [platformFilter, setPlatformFilter] = useState<Platform | 'all'>('all')
const [showUploadModal, setShowUploadModal] = useState(false)
const [editingVersionId, setEditingVersionId] = useState<string | null>(null)
useEffect(() => {
setFilter({ platform: platformFilter === 'all' ? undefined : platformFilter })
}, [platformFilter, setFilter])
const handleRefresh = useCallback(() => {
refetch()
}, [refetch])
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这个版本吗?此操作不可恢复。')) {
return
}
try {
await deleteVersion(id)
toast.success('版本已删除')
} catch (err) {
toast.error('删除失败')
}
}
const handleToggle = async (id: string, isEnabled: boolean) => {
try {
await toggleVersion(id, isEnabled)
toast.success(isEnabled ? '版本已启用' : '版本已禁用')
} catch (err) {
toast.error('操作失败')
}
}
const handleUploadSuccess = () => {
setShowUploadModal(false)
refetch()
toast.success('版本上传成功')
}
const handleEditSuccess = () => {
setEditingVersionId(null)
refetch()
toast.success('版本更新成功')
}
const filteredVersions = versions
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-900"></h2>
<p className="text-gray-600 mt-1"></p>
</div>
<button
onClick={() => setShowUploadModal(true)}
className="btn btn-primary"
>
</button>
</div>
{/* Filters */}
<div className="card">
<div className="flex items-center gap-4">
<label className="label"></label>
<div className="flex gap-2">
<button
onClick={() => setPlatformFilter('all')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
platformFilter === 'all'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
</button>
<button
onClick={() => setPlatformFilter('android')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
platformFilter === 'android'
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Android
</button>
<button
onClick={() => setPlatformFilter('ios')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
platformFilter === 'ios'
? 'bg-gray-800 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
iOS
</button>
</div>
</div>
</div>
{/* Error State */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
)}
{/* Loading State */}
{isLoading && (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{/* Version List */}
{!isLoading && !error && (
<div className="space-y-4">
{filteredVersions.length === 0 ? (
<div className="card text-center py-12">
<p className="text-gray-500"></p>
<button
onClick={() => setShowUploadModal(true)}
className="btn btn-primary mt-4"
>
</button>
</div>
) : (
filteredVersions.map((version) => (
<VersionCard
key={version.id}
version={version}
onEdit={() => setEditingVersionId(version.id)}
onDelete={() => handleDelete(version.id)}
onToggle={(enabled) => handleToggle(version.id, enabled)}
/>
))
)}
</div>
)}
{/* Upload Modal */}
{showUploadModal && (
<UploadModal
onClose={() => setShowUploadModal(false)}
onSuccess={handleUploadSuccess}
/>
)}
{/* Edit Modal */}
{editingVersionId && (
<EditModal
versionId={editingVersionId}
onClose={() => setEditingVersionId(null)}
onSuccess={handleEditSuccess}
/>
)}
</div>
)
}

View File

@ -0,0 +1,83 @@
import { useEffect } from 'react'
import { useVersionStore } from '../stores/version-store'
export function useVersions() {
const {
versions,
isLoading,
error,
filter,
fetchVersions,
setFilter,
clearError,
} = useVersionStore()
useEffect(() => {
fetchVersions()
}, [filter.platform, filter.includeDisabled])
return {
versions,
isLoading,
error,
filter,
refetch: fetchVersions,
setFilter,
clearError,
}
}
export function useVersion(id: string | null) {
const {
selectedVersion,
isLoading,
error,
fetchVersionById,
setSelectedVersion,
clearError,
} = useVersionStore()
useEffect(() => {
if (id) {
fetchVersionById(id)
} else {
setSelectedVersion(null)
}
}, [id])
return {
version: selectedVersion,
isLoading,
error,
refetch: () => id && fetchVersionById(id),
clearError,
}
}
export function useVersionActions() {
const {
fetchVersions,
setFilter,
createVersion,
updateVersion,
deleteVersion,
toggleVersion,
uploadVersion,
isLoading,
error,
clearError,
} = useVersionStore()
return {
loadVersions: fetchVersions,
setFilter,
createVersion,
updateVersion,
deleteVersion,
toggleVersion,
uploadVersion,
isLoading,
error,
clearError,
}
}

View File

@ -0,0 +1,2 @@
export * from './stores/version-store'
export * from './hooks/use-versions'

View File

@ -0,0 +1,175 @@
import { create } from 'zustand'
import {
AppVersion,
CreateVersionInput,
UpdateVersionInput,
UploadVersionInput,
Platform,
} from '@/domain'
import { versionRepository } from '@/infrastructure'
interface VersionState {
versions: AppVersion[]
selectedVersion: AppVersion | null
isLoading: boolean
error: string | null
filter: {
platform?: Platform
includeDisabled: boolean
}
// Actions
fetchVersions: () => Promise<void>
fetchVersionById: (id: string) => Promise<void>
createVersion: (input: CreateVersionInput) => Promise<AppVersion>
updateVersion: (id: string, input: UpdateVersionInput) => Promise<AppVersion>
deleteVersion: (id: string) => Promise<void>
toggleVersion: (id: string, isEnabled: boolean) => Promise<void>
uploadVersion: (input: UploadVersionInput) => Promise<AppVersion>
setFilter: (filter: Partial<VersionState['filter']>) => void
setSelectedVersion: (version: AppVersion | null) => void
clearError: () => void
}
export const useVersionStore = create<VersionState>((set, get) => ({
versions: [],
selectedVersion: null,
isLoading: false,
error: null,
filter: {
platform: undefined,
includeDisabled: true,
},
fetchVersions: async () => {
set({ isLoading: true, error: null })
try {
const { filter } = get()
const versions = await versionRepository.list(filter)
set({ versions, isLoading: false })
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to fetch versions',
isLoading: false,
})
}
},
fetchVersionById: async (id: string) => {
set({ isLoading: true, error: null })
try {
const version = await versionRepository.getById(id)
set({ selectedVersion: version, isLoading: false })
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to fetch version',
isLoading: false,
})
}
},
createVersion: async (input: CreateVersionInput) => {
set({ isLoading: true, error: null })
try {
const version = await versionRepository.create(input)
const { versions } = get()
set({ versions: [version, ...versions], isLoading: false })
return version
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to create version',
isLoading: false,
})
throw err
}
},
updateVersion: async (id: string, input: UpdateVersionInput) => {
set({ isLoading: true, error: null })
try {
const version = await versionRepository.update(id, input)
const { versions } = get()
set({
versions: versions.map((v) => (v.id === id ? version : v)),
selectedVersion: version,
isLoading: false,
})
return version
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to update version',
isLoading: false,
})
throw err
}
},
deleteVersion: async (id: string) => {
set({ isLoading: true, error: null })
try {
await versionRepository.delete(id)
const { versions, selectedVersion } = get()
set({
versions: versions.filter((v) => v.id !== id),
selectedVersion: selectedVersion?.id === id ? null : selectedVersion,
isLoading: false,
})
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to delete version',
isLoading: false,
})
throw err
}
},
toggleVersion: async (id: string, isEnabled: boolean) => {
set({ isLoading: true, error: null })
try {
await versionRepository.toggle(id, isEnabled)
const { versions } = get()
set({
versions: versions.map((v) =>
v.id === id ? { ...v, isEnabled } : v
),
isLoading: false,
})
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to toggle version',
isLoading: false,
})
throw err
}
},
uploadVersion: async (input: UploadVersionInput) => {
set({ isLoading: true, error: null })
try {
const version = await versionRepository.upload(input)
const { versions } = get()
set({ versions: [version, ...versions], isLoading: false })
return version
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to upload version',
isLoading: false,
})
throw err
}
},
setFilter: (filter) => {
set((state) => ({
filter: { ...state.filter, ...filter },
}))
},
setSelectedVersion: (version) => {
set({ selectedVersion: version })
},
clearError: () => {
set({ error: null })
},
}))

View File

@ -0,0 +1,60 @@
export type Platform = 'android' | 'ios'
export interface AppVersion {
id: string
platform: Platform
versionCode: number
versionName: string
buildNumber: string
downloadUrl: string
fileSize: string
fileSha256: string
changelog: string
isForceUpdate: boolean
isEnabled: boolean
minOsVersion: string | null
releaseDate: string | null
createdAt: string
updatedAt: string
}
export interface CreateVersionInput {
platform: Platform
versionCode: number
versionName: string
buildNumber: string
downloadUrl: string
fileSize: string
fileSha256: string
changelog: string
isForceUpdate: boolean
minOsVersion?: string
releaseDate?: string
}
export interface UpdateVersionInput {
downloadUrl?: string
fileSize?: string
fileSha256?: string
changelog?: string
isForceUpdate?: boolean
isEnabled?: boolean
minOsVersion?: string | null
releaseDate?: string | null
}
export interface UploadVersionInput {
file: File
platform: Platform
versionName: string
buildNumber: string
changelog?: string
isForceUpdate?: boolean
minOsVersion?: string
releaseDate?: string
}
export interface VersionListFilter {
platform?: Platform
includeDisabled?: boolean
}

View File

@ -0,0 +1,2 @@
export * from './entities/version'
export * from './repositories/version-repository'

View File

@ -0,0 +1,17 @@
import {
AppVersion,
CreateVersionInput,
UpdateVersionInput,
UploadVersionInput,
VersionListFilter,
} from '../entities/version'
export interface IVersionRepository {
list(filter?: VersionListFilter): Promise<AppVersion[]>
getById(id: string): Promise<AppVersion>
create(input: CreateVersionInput): Promise<AppVersion>
update(id: string, input: UpdateVersionInput): Promise<AppVersion>
delete(id: string): Promise<void>
toggle(id: string, isEnabled: boolean): Promise<void>
upload(input: UploadVersionInput): Promise<AppVersion>
}

View File

@ -0,0 +1,41 @@
import axios, { AxiosInstance, AxiosError } from 'axios'
export class ApiError extends Error {
constructor(
public statusCode: number,
message: string,
public data?: unknown
) {
super(message)
this.name = 'ApiError'
}
}
export function createApiClient(): AxiosInstance {
const client = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3010',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response) {
const data = error.response.data as { message?: string }
throw new ApiError(
error.response.status,
data?.message || error.message,
data
)
}
throw new ApiError(0, error.message)
}
)
return client
}
export const apiClient = createApiClient()

View File

@ -0,0 +1,2 @@
export * from './http/api-client'
export * from './repositories/version-repository-impl'

View File

@ -0,0 +1,92 @@
import { AxiosInstance } from 'axios'
import {
AppVersion,
CreateVersionInput,
UpdateVersionInput,
UploadVersionInput,
VersionListFilter,
IVersionRepository,
} from '@/domain'
import { apiClient } from '../http/api-client'
export class VersionRepositoryImpl implements IVersionRepository {
private client: AxiosInstance
constructor(client?: AxiosInstance) {
this.client = client || apiClient
}
async list(filter?: VersionListFilter): Promise<AppVersion[]> {
const params = new URLSearchParams()
if (filter?.platform) {
params.append('platform', filter.platform)
}
if (filter?.includeDisabled) {
params.append('includeDisabled', 'true')
}
const response = await this.client.get<AppVersion[]>(
`/api/v1/versions?${params.toString()}`
)
return response.data
}
async getById(id: string): Promise<AppVersion> {
const response = await this.client.get<AppVersion>(`/api/v1/versions/${id}`)
return response.data
}
async create(input: CreateVersionInput): Promise<AppVersion> {
const response = await this.client.post<AppVersion>('/api/v1/versions', input)
return response.data
}
async update(id: string, input: UpdateVersionInput): Promise<AppVersion> {
const response = await this.client.put<AppVersion>(
`/api/v1/versions/${id}`,
input
)
return response.data
}
async delete(id: string): Promise<void> {
await this.client.delete(`/api/v1/versions/${id}`)
}
async toggle(id: string, isEnabled: boolean): Promise<void> {
await this.client.patch(`/api/v1/versions/${id}/toggle`, { isEnabled })
}
async upload(input: UploadVersionInput): Promise<AppVersion> {
const formData = new FormData()
formData.append('file', input.file)
formData.append('platform', input.platform)
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)
}
if (input.releaseDate) {
formData.append('releaseDate', input.releaseDate)
}
const response = await this.client.post<AppVersion>(
'/api/v1/versions/upload',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
timeout: 300000, // 5 minutes for large file uploads
}
)
return response.data
}
}
export const versionRepository = new VersionRepositoryImpl()

View File

@ -0,0 +1,187 @@
'use client'
import { useState, useEffect } from 'react'
import { useVersion, useVersionActions } from '@/application'
interface EditModalProps {
versionId: string
onClose: () => void
onSuccess: () => void
}
export function EditModal({ versionId, onClose, onSuccess }: EditModalProps) {
const { version, isLoading, error: loadError } = useVersion(versionId)
const { updateVersion } = useVersionActions()
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
changelog: '',
isForceUpdate: false,
isEnabled: true,
minOsVersion: '',
})
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (version) {
setFormData({
changelog: version.changelog || '',
isForceUpdate: version.isForceUpdate,
isEnabled: version.isEnabled,
minOsVersion: version.minOsVersion || '',
})
}
}, [version])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
await updateVersion(versionId, {
changelog: formData.changelog || undefined,
isForceUpdate: formData.isForceUpdate,
isEnabled: formData.isEnabled,
minOsVersion: formData.minOsVersion || undefined,
})
onSuccess()
} catch (err) {
setError(err instanceof Error ? err.message : '更新失败')
} finally {
setIsSubmitting(false)
}
}
if (isLoading) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="text-gray-600 mt-4">...</p>
</div>
</div>
)
}
if (loadError || !version) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-8">
<p className="text-red-600"></p>
<button onClick={onClose} className="btn btn-primary mt-4">
</button>
</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-xl font-semibold text-gray-900"></h3>
<p className="text-gray-500 text-sm mt-1">
{version.platform.toUpperCase()} v{version.versionName} (Build {version.buildNumber})
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Min OS Version */}
<div>
<label className="label"></label>
<input
type="text"
value={formData.minOsVersion}
onChange={(e) => setFormData((prev) => ({ ...prev, minOsVersion: e.target.value }))}
placeholder={version.platform === 'android' ? '例如8.0' : '例如14.0'}
className="input"
/>
</div>
{/* Changelog */}
<div>
<label className="label"></label>
<textarea
value={formData.changelog}
onChange={(e) => setFormData((prev) => ({ ...prev, changelog: e.target.value }))}
placeholder="请输入本版本的更新内容..."
rows={4}
className="input"
/>
</div>
{/* Force Update */}
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.isForceUpdate}
onChange={(e) => setFormData((prev) => ({ ...prev, isForceUpdate: e.target.checked }))}
className="mr-2"
/>
<span className="text-gray-700"></span>
</label>
<p className="text-gray-500 text-xs mt-1">
使
</p>
</div>
{/* Enabled */}
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.isEnabled}
onChange={(e) => setFormData((prev) => ({ ...prev, isEnabled: e.target.checked }))}
className="mr-2"
/>
<span className="text-gray-700"></span>
</label>
<p className="text-gray-500 text-xs mt-1">
</p>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn bg-gray-100 text-gray-700 hover:bg-gray-200"
disabled={isSubmitting}
>
</button>
<button
type="submit"
className="btn btn-primary"
disabled={isSubmitting}
>
{isSubmitting ? '保存中...' : '保存'}
</button>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,251 @@
'use client'
import { useState, useRef } from 'react'
import { Platform } from '@/domain'
import { useVersionActions } from '@/application'
interface UploadModalProps {
onClose: () => void
onSuccess: () => void
}
export function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const { uploadVersion } = useVersionActions()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
platform: 'android' as Platform,
versionName: '',
buildNumber: '',
changelog: '',
isForceUpdate: false,
minOsVersion: '',
})
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState<string | null>(null)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) {
setFile(selectedFile)
// Auto-detect platform from file extension
if (selectedFile.name.endsWith('.apk')) {
setFormData((prev) => ({ ...prev, platform: 'android' }))
} else if (selectedFile.name.endsWith('.ipa')) {
setFormData((prev) => ({ ...prev, platform: 'ios' }))
}
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!file) {
setError('请选择安装包文件')
return
}
if (!formData.versionName) {
setError('请输入版本号')
return
}
if (!formData.buildNumber) {
setError('请输入构建号')
return
}
setIsSubmitting(true)
try {
await uploadVersion({
platform: formData.platform,
versionName: formData.versionName,
buildNumber: formData.buildNumber,
changelog: formData.changelog || undefined,
isForceUpdate: formData.isForceUpdate,
minOsVersion: formData.minOsVersion || undefined,
file,
})
onSuccess()
} catch (err) {
setError(err instanceof Error ? err.message : '上传失败')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-semibold text-gray-900"></h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* File Upload */}
<div>
<label className="label"> *</label>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-blue-400 transition-colors"
>
<input
ref={fileInputRef}
type="file"
accept=".apk,.ipa"
onChange={handleFileChange}
className="hidden"
/>
{file ? (
<div>
<p className="text-gray-900 font-medium">{file.name}</p>
<p className="text-gray-500 text-sm">
{(file.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
) : (
<div>
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="mt-2 text-gray-600"> APK IPA </p>
</div>
)}
</div>
</div>
{/* Platform */}
<div>
<label className="label"> *</label>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
name="platform"
value="android"
checked={formData.platform === 'android'}
onChange={(e) => setFormData((prev) => ({ ...prev, platform: e.target.value as Platform }))}
className="mr-2"
/>
Android
</label>
<label className="flex items-center">
<input
type="radio"
name="platform"
value="ios"
checked={formData.platform === 'ios'}
onChange={(e) => setFormData((prev) => ({ ...prev, platform: e.target.value as Platform }))}
className="mr-2"
/>
iOS
</label>
</div>
</div>
{/* Version Name */}
<div>
<label className="label"> *</label>
<input
type="text"
value={formData.versionName}
onChange={(e) => setFormData((prev) => ({ ...prev, versionName: e.target.value }))}
placeholder="例如1.0.0"
className="input"
/>
</div>
{/* Build Number */}
<div>
<label className="label"> *</label>
<input
type="text"
value={formData.buildNumber}
onChange={(e) => setFormData((prev) => ({ ...prev, buildNumber: e.target.value }))}
placeholder="例如100"
className="input"
/>
</div>
{/* Min OS Version */}
<div>
<label className="label"></label>
<input
type="text"
value={formData.minOsVersion}
onChange={(e) => setFormData((prev) => ({ ...prev, minOsVersion: e.target.value }))}
placeholder={formData.platform === 'android' ? '例如8.0' : '例如14.0'}
className="input"
/>
</div>
{/* Changelog */}
<div>
<label className="label"></label>
<textarea
value={formData.changelog}
onChange={(e) => setFormData((prev) => ({ ...prev, changelog: e.target.value }))}
placeholder="请输入本版本的更新内容..."
rows={4}
className="input"
/>
</div>
{/* Force Update */}
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.isForceUpdate}
onChange={(e) => setFormData((prev) => ({ ...prev, isForceUpdate: e.target.checked }))}
className="mr-2"
/>
<span className="text-gray-700"></span>
</label>
<p className="text-gray-500 text-xs mt-1">
使
</p>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn bg-gray-100 text-gray-700 hover:bg-gray-200"
disabled={isSubmitting}
>
</button>
<button
type="submit"
className="btn btn-primary"
disabled={isSubmitting}
>
{isSubmitting ? '上传中...' : '上传'}
</button>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,134 @@
'use client'
import { AppVersion } from '@/domain'
interface VersionCardProps {
version: AppVersion
onEdit: () => void
onDelete: () => void
onToggle: (enabled: boolean) => void
}
export function VersionCard({ version, onEdit, onDelete, onToggle }: VersionCardProps) {
const platformColors = {
android: 'bg-green-100 text-green-800',
ios: 'bg-gray-100 text-gray-800',
}
const formatFileSize = (size: string) => {
const bytes = parseInt(size, 10)
if (isNaN(bytes)) return size
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
return (
<div className="card hover:shadow-md transition-shadow">
<div className="flex justify-between items-start">
{/* Left: Version Info */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className={`px-2 py-1 rounded text-xs font-medium uppercase ${platformColors[version.platform]}`}>
{version.platform}
</span>
<h3 className="text-lg font-semibold text-gray-900">
v{version.versionName}
</h3>
<span className="text-sm text-gray-500">
(Build {version.buildNumber})
</span>
{version.isForceUpdate && (
<span className="px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800">
</span>
)}
<span className={`px-2 py-1 rounded text-xs font-medium ${
version.isEnabled
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-500'
}`}>
{version.isEnabled ? '已启用' : '已禁用'}
</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm text-gray-600 mb-3">
<div>
<span className="text-gray-400"></span>
{version.versionCode}
</div>
<div>
<span className="text-gray-400"></span>
{formatFileSize(version.fileSize)}
</div>
<div>
<span className="text-gray-400"></span>
{version.minOsVersion || '-'}
</div>
<div>
<span className="text-gray-400"></span>
{formatDate(version.releaseDate)}
</div>
</div>
{version.changelog && (
<div className="text-sm">
<span className="text-gray-400"></span>
<p className="text-gray-700 mt-1 whitespace-pre-line bg-gray-50 p-2 rounded">
{version.changelog}
</p>
</div>
)}
<div className="mt-3 text-xs text-gray-400">
{formatDate(version.createdAt)}
{version.updatedAt !== version.createdAt && (
<> · {formatDate(version.updatedAt)}</>
)}
</div>
</div>
{/* Right: Actions */}
<div className="flex flex-col gap-2 ml-4">
<button
onClick={() => onToggle(!version.isEnabled)}
className={`btn text-sm ${
version.isEnabled
? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
}`}
>
{version.isEnabled ? '禁用' : '启用'}
</button>
<button
onClick={onEdit}
className="btn bg-yellow-100 text-yellow-700 hover:bg-yellow-200 text-sm"
>
</button>
<button
onClick={onDelete}
className="btn bg-red-100 text-red-700 hover:bg-red-200 text-sm"
>
</button>
{version.downloadUrl && (
<a
href={version.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="btn bg-green-100 text-green-700 hover:bg-green-200 text-sm text-center"
>
</a>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,3 @@
export * from './components/version-card'
export * from './components/upload-modal'
export * from './components/edit-modal'

View File

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/domain/*": ["src/domain/*"],
"@/application/*": ["src/application/*"],
"@/infrastructure/*": ["src/infrastructure/*"],
"@/presentation/*": ["src/presentation/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}