This commit is contained in:
eric
2025-11-13 05:05:53 +08:00
parent cbdc65f56e
commit 891af61146
691 changed files with 83952 additions and 0 deletions

704
app/admin/data/page.tsx Normal file
View File

@@ -0,0 +1,704 @@
'use client'
export const runtime = 'edge'
import { useState, useEffect } from 'react'
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { JsonEditor } from "@/components/ui/json-editor"
import { useToast } from "@/components/ui/use-toast"
import {
Download,
RefreshCw,
Save,
AlertTriangle,
Database,
FileText,
CheckCircle,
XCircle,
Info,
Upload
} from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
export default function DataManagementPage() {
const [navigationData, setNavigationData] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isRestoreDialogOpen, setIsRestoreDialogOpen] = useState(false)
const [isJsonValid, setIsJsonValid] = useState(true)
const [jsonError, setJsonError] = useState('')
const [dataStats, setDataStats] = useState({ categories: 0, items: 0, size: 0 })
const [editorErrors, setEditorErrors] = useState<string[]>([])
const [defaultFileStatus, setDefaultFileStatus] = useState({ exists: false, valid: false, itemCount: 0, checked: false })
const { toast } = useToast()
// 检查默认文件状态
const checkDefaultFile = async () => {
try {
const response = await fetch('/api/navigation/check-default')
if (response.ok) {
const status = await response.json()
setDefaultFileStatus({ ...status, checked: true })
} else {
setDefaultFileStatus({ exists: false, valid: false, itemCount: 0, checked: true })
}
} catch (error) {
console.error('Failed to check default file:', error)
setDefaultFileStatus({ exists: false, valid: false, itemCount: 0, checked: true })
}
}
// 验证JSON格式并更新统计信息
const validateJson = (jsonString: string) => {
try {
const parsed = JSON.parse(jsonString)
setIsJsonValid(true)
setJsonError('')
// 计算统计信息
if (parsed.navigationItems) {
const categories = parsed.navigationItems.length
let items = 0
parsed.navigationItems.forEach((category: any) => {
if (category.items) items += category.items.length
if (category.subCategories) {
category.subCategories.forEach((sub: any) => {
if (sub.items) items += sub.items.length
})
}
})
const size = new Blob([jsonString]).size
setDataStats({ categories, items, size })
}
return true
} catch (error) {
setIsJsonValid(false)
setJsonError((error as Error).message)
return false
}
}
// 加载当前导航数据
const loadNavigationData = async () => {
setIsLoading(true)
try {
const response = await fetch('/api/navigation')
if (response.ok) {
const data = await response.json()
const jsonString = JSON.stringify(data, null, 2)
setNavigationData(jsonString)
validateJson(jsonString)
} else {
throw new Error('加载数据失败')
}
} catch (error) {
toast({
title: "错误",
description: "加载导航数据失败",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
// 恢复初始化数据
const restoreDefaultData = async () => {
// 检查默认文件是否存在
if (!defaultFileStatus.exists) {
toast({
title: "错误",
description: "navigation-default.json 文件不存在,无法恢复默认数据",
variant: "destructive",
})
return
}
if (!defaultFileStatus.valid) {
toast({
title: "错误",
description: "navigation-default.json 文件格式无效,无法恢复默认数据",
variant: "destructive",
})
return
}
setIsLoading(true)
try {
const response = await fetch('/api/navigation/restore', {
method: 'POST',
})
if (response.ok) {
const data = await response.json()
const jsonString = JSON.stringify(data, null, 2)
setNavigationData(jsonString)
validateJson(jsonString)
setIsRestoreDialogOpen(false)
toast({
title: "成功",
description: `已恢复为初始化数据(${defaultFileStatus.itemCount} 个分类)`,
})
} else {
const errorData = await response.json()
throw new Error(errorData.details || '恢复数据失败')
}
} catch (error) {
toast({
title: "错误",
description: (error as Error).message || "恢复初始化数据失败",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
// 保存数据
const saveData = async () => {
if (!validateJson(navigationData)) {
toast({
title: "错误",
description: "JSON格式不正确请检查语法",
variant: "destructive",
})
return
}
setIsSaving(true)
try {
const response = await fetch('/api/navigation', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: navigationData,
})
if (response.ok) {
toast({
title: "成功",
description: "数据保存成功",
})
} else {
throw new Error('保存失败')
}
} catch (error) {
toast({
title: "错误",
description: "保存数据失败",
variant: "destructive",
})
} finally {
setIsSaving(false)
}
}
// 下载数据到本地
const downloadData = () => {
if (!validateJson(navigationData)) {
toast({
title: "错误",
description: "JSON格式不正确无法下载",
variant: "destructive",
})
return
}
try {
const parsedData = JSON.parse(navigationData)
const blob = new Blob([JSON.stringify(parsedData, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `navigation-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast({
title: "成功",
description: "数据已下载到本地",
})
} catch (error) {
toast({
title: "错误",
description: "下载失败",
variant: "destructive",
})
}
}
// 上传JSON文件
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
// 检查文件类型
if (!file.name.endsWith('.json') && file.type !== 'application/json') {
toast({
title: "错误",
description: "请选择JSON文件",
variant: "destructive",
})
return
}
// 检查文件大小 (限制为5MB)
if (file.size > 5 * 1024 * 1024) {
toast({
title: "错误",
description: "文件大小不能超过5MB",
variant: "destructive",
})
return
}
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
// 验证JSON格式
JSON.parse(content)
// 格式化JSON并设置到编辑器
const formatted = JSON.stringify(JSON.parse(content), null, 2)
setNavigationData(formatted)
validateJson(formatted)
toast({
title: "成功",
description: `已上传文件: ${file.name}`,
})
} catch (error) {
toast({
title: "错误",
description: "文件内容不是有效的JSON格式",
variant: "destructive",
})
}
}
reader.onerror = () => {
toast({
title: "错误",
description: "文件读取失败",
variant: "destructive",
})
}
reader.readAsText(file)
// 清空input值允许重复上传同一文件
event.target.value = ''
}
// 触发文件选择
const triggerFileUpload = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json,application/json'
input.onchange = (e) => handleFileUpload(e as any)
input.click()
}
// 处理编辑器变化
const handleEditorChange = (value: string) => {
setNavigationData(value)
validateJson(value)
}
// 处理编辑器验证
const handleEditorValidate = (isValid: boolean, errors: string[]) => {
setIsJsonValid(isValid)
setEditorErrors(errors)
if (!isValid && errors.length > 0) {
setJsonError(errors[0])
} else {
setJsonError('')
}
}
useEffect(() => {
loadNavigationData()
checkDefaultFile()
}, [])
// Monaco Editor 事件监听
useEffect(() => {
const handleMonacoSave = () => {
if (!isSaving && !isLoading && isJsonValid) {
saveData()
}
}
const handleMonacoRefresh = () => {
if (!isLoading) {
loadNavigationData()
}
}
const handleMonacoDownload = () => {
if (!isLoading && isJsonValid) {
downloadData()
}
}
window.addEventListener('monaco-save', handleMonacoSave)
window.addEventListener('monaco-refresh', handleMonacoRefresh)
window.addEventListener('monaco-download', handleMonacoDownload)
return () => {
window.removeEventListener('monaco-save', handleMonacoSave)
window.removeEventListener('monaco-refresh', handleMonacoRefresh)
window.removeEventListener('monaco-download', handleMonacoDownload)
}
}, [isSaving, isLoading, isJsonValid, navigationData])
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight"></h1>
</div>
<p className="text-muted-foreground">
线
</p>
</div>
<div className="flex items-center gap-2">
</div>
</div>
{/* 操作面板 */}
<Card>
<CardContent className="pt-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Button
onClick={loadNavigationData}
disabled={isLoading}
variant="outline"
className="h-12"
>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Dialog open={isRestoreDialogOpen} onOpenChange={setIsRestoreDialogOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
disabled={isLoading || !defaultFileStatus.exists || !defaultFileStatus.valid}
className="h-12 relative"
>
<AlertTriangle className="mr-2 h-4 w-4 text-orange-500" />
{defaultFileStatus.checked && !defaultFileStatus.exists && (
<Badge variant="destructive" className="absolute -top-1 -right-1 h-4 w-4 p-0 text-xs">
!
</Badge>
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-500" />
</DialogTitle>
<DialogDescription className="space-y-3">
<div>
使 <code className="bg-muted px-1 py-0.5 rounded">navigation-default.json</code> <code className="bg-muted px-1 py-0.5 rounded">navigation.json</code>
</div>
{/* 默认文件状态 */}
<div className="p-3 bg-muted/50 rounded-lg">
<div className="text-sm font-medium mb-2"></div>
<div className="space-y-1 text-sm">
<div className="flex items-center justify-between">
<span></span>
<Badge variant={defaultFileStatus.exists ? "default" : "destructive"}>
{defaultFileStatus.exists ? "是" : "否"}
</Badge>
</div>
<div className="flex items-center justify-between">
<span></span>
<Badge variant={defaultFileStatus.valid ? "default" : "destructive"}>
{defaultFileStatus.valid ? "是" : "否"}
</Badge>
</div>
{defaultFileStatus.exists && defaultFileStatus.valid && (
<div className="flex items-center justify-between">
<span></span>
<Badge variant="outline">{defaultFileStatus.itemCount}</Badge>
</div>
)}
</div>
</div>
<div className="text-destructive font-medium">
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRestoreDialogOpen(false)}>
</Button>
<Button
onClick={restoreDefaultData}
disabled={isLoading || !defaultFileStatus.exists || !defaultFileStatus.valid}
variant="destructive"
>
{isLoading && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
onClick={saveData}
disabled={isSaving || isLoading || !isJsonValid}
className="h-12"
>
<Save className={`mr-2 h-4 w-4 ${isSaving ? 'animate-spin' : ''}`} />
</Button>
<Button
onClick={downloadData}
disabled={isLoading || !isJsonValid}
variant="outline"
className="h-12"
>
<Download className="mr-2 h-4 w-4" />
</Button>
<Button
onClick={triggerFileUpload}
disabled={isLoading}
variant="outline"
className="h-12"
>
<Upload className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* JSON 编辑器 */}
<Card>
<CardContent className="p-0">
{/* 错误提示 */}
{!isJsonValid && jsonError && (
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<div className="flex items-start gap-3">
<XCircle className="h-5 w-5 text-destructive mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="font-medium text-destructive mb-1">JSON </div>
<p className="text-sm text-destructive/80 mb-2">{jsonError}</p>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
try {
const lines = navigationData.split('\n')
const errorMatch = jsonError.match(/position (\d+)/)
if (errorMatch) {
const position = parseInt(errorMatch[1])
let currentPos = 0
let lineNum = 0
for (let i = 0; i < lines.length; i++) {
if (currentPos + lines[i].length >= position) {
lineNum = i + 1
break
}
currentPos += lines[i].length + 1
}
toast({
title: "错误位置",
description: `错误可能在第 ${lineNum} 行附近`,
})
}
} catch {
toast({
title: "无法定位错误",
description: "请手动检查JSON语法",
variant: "destructive",
})
}
}}
className="h-7 text-xs"
>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
// 尝试自动修复常见错误
let fixed = navigationData
// 修复常见的JSON错误
fixed = fixed.replace(/,(\s*[}\]])/g, '$1') // 移除多余的逗号
fixed = fixed.replace(/([{,]\s*)(\w+):/g, '$1"$2":') // 给属性名加引号
setNavigationData(fixed)
validateJson(fixed)
}}
className="h-7 text-xs"
>
</Button>
</div>
</div>
</div>
</div>
)}
<div className="relative">
<JsonEditor
value={navigationData}
onChange={handleEditorChange}
onValidate={handleEditorValidate}
disabled={isLoading}
height="500px"
isValid={isJsonValid}
stats={dataStats}
/>
{/* 加载遮罩 */}
{isLoading && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-10">
<div className="flex items-center gap-3 bg-background border rounded-lg px-4 py-3 shadow-lg">
<RefreshCw className="h-5 w-5 animate-spin text-primary" />
<span className="text-sm font-medium">...</span>
</div>
</div>
)}
</div>
{/* 数据预览和统计 */}
<div className="mt-4 space-y-4">
{/* 快速统计 */}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-4">
<span>: {navigationData.length.toLocaleString()}</span>
<span>: {navigationData.split('\n').length}</span>
<span>: {(new Blob([navigationData]).size / 1024).toFixed(2)} KB</span>
</div>
<div className="flex items-center gap-2">
{isJsonValid ? (
<div className="flex items-center gap-1 text-green-600">
<CheckCircle className="h-3 w-3" />
<span></span>
</div>
) : (
<div className="flex items-center gap-1 text-destructive">
<XCircle className="h-3 w-3" />
<span></span>
</div>
)}
</div>
</div>
{/* 数据结构预览 */}
{isJsonValid && navigationData && (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2 text-sm">
{(() => {
try {
const data = JSON.parse(navigationData)
return (
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-mono">{data.navigationItems?.length || 0}</span>
</div>
{data.navigationItems?.map((item: any, index: number) => (
<div key={index} className="ml-4 text-xs text-muted-foreground">
<div className="flex justify-between">
<span> {item.title}</span>
<span>
{(item.items?.length || 0) + (item.subCategories?.reduce((acc: number, sub: any) => acc + (sub.items?.length || 0), 0) || 0)}
</span>
</div>
</div>
))}
</div>
)
} catch {
return <span className="text-muted-foreground"></span>
}
})()}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-mono text-xs"></span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-mono text-xs"></span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<Badge variant="outline" className="text-xs">
</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</div>
</CardContent>
</Card>
</div>
)
}