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:
parent
fcd97f26cf
commit
6db948099c
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './stores/version-store'
|
||||||
|
export * from './hooks/use-versions'
|
||||||
|
|
@ -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 })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './entities/version'
|
||||||
|
export * from './repositories/version-repository'
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './http/api-client'
|
||||||
|
export * from './repositories/version-repository-impl'
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './components/version-card'
|
||||||
|
export * from './components/upload-modal'
|
||||||
|
export * from './components/edit-modal'
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue