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