sync
This commit is contained in:
parent
6992099298
commit
507746a995
496
app/admin/common/admin-panel-config.tsx
Normal file
496
app/admin/common/admin-panel-config.tsx
Normal file
@ -0,0 +1,496 @@
|
|||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
Database,
|
||||||
|
Shield,
|
||||||
|
Bell,
|
||||||
|
Mail,
|
||||||
|
Globe,
|
||||||
|
Palette,
|
||||||
|
Monitor,
|
||||||
|
Smartphone,
|
||||||
|
Save,
|
||||||
|
RefreshCw,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Zap,
|
||||||
|
HardDrive,
|
||||||
|
Lock,
|
||||||
|
Server
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AdminPanelConfig } from "@/types/admin-panel";
|
||||||
|
|
||||||
|
// 示例配置:完整的后台管理面板配置
|
||||||
|
export const defaultAdminPanelConfig: AdminPanelConfig = {
|
||||||
|
header: {
|
||||||
|
title: "后台管理面板",
|
||||||
|
description: "系统设置和配置管理",
|
||||||
|
breadcrumbs: [
|
||||||
|
{ label: "首页", href: "/" },
|
||||||
|
{ label: "管理", href: "/admin" },
|
||||||
|
{ label: "系统设置" }
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "refresh",
|
||||||
|
label: "刷新",
|
||||||
|
icon: <RefreshCw className="h-4 w-4" />,
|
||||||
|
variant: "outline",
|
||||||
|
onClick: () => window.location.reload()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "export",
|
||||||
|
label: "导出配置",
|
||||||
|
icon: <Save className="h-4 w-4" />,
|
||||||
|
variant: "outline",
|
||||||
|
onClick: () => console.log("导出配置")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
title: "常规设置",
|
||||||
|
icon: <Settings className="h-4 w-4" />,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "site-info",
|
||||||
|
title: "网站信息",
|
||||||
|
description: "网站基本信息和配置",
|
||||||
|
icon: <Globe className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "siteName",
|
||||||
|
label: "网站名称",
|
||||||
|
description: "显示在浏览器标题栏的网站名称",
|
||||||
|
type: "input",
|
||||||
|
value: "我的网站",
|
||||||
|
placeholder: "请输入网站名称",
|
||||||
|
validation: {
|
||||||
|
required: true,
|
||||||
|
minLength: 2,
|
||||||
|
maxLength: 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "siteDescription",
|
||||||
|
label: "网站描述",
|
||||||
|
description: "网站的简短描述,用于SEO",
|
||||||
|
type: "textarea",
|
||||||
|
value: "这是一个很棒的网站",
|
||||||
|
rows: 3,
|
||||||
|
validation: {
|
||||||
|
maxLength: 200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "adminEmail",
|
||||||
|
label: "管理员邮箱",
|
||||||
|
description: "接收系统通知的邮箱地址",
|
||||||
|
type: "email",
|
||||||
|
value: "admin@example.com",
|
||||||
|
validation: {
|
||||||
|
required: true,
|
||||||
|
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
label: "默认语言",
|
||||||
|
description: "网站的默认显示语言",
|
||||||
|
type: "select",
|
||||||
|
value: "zh-CN",
|
||||||
|
options: [
|
||||||
|
{ label: "简体中文", value: "zh-CN" },
|
||||||
|
{ label: "English", value: "en-US" },
|
||||||
|
{ label: "日本語", value: "ja-JP" },
|
||||||
|
{ label: "한국어", value: "ko-KR" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "timezone",
|
||||||
|
label: "时区",
|
||||||
|
description: "服务器时区设置",
|
||||||
|
type: "select",
|
||||||
|
value: "Asia/Shanghai",
|
||||||
|
options: [
|
||||||
|
{ label: "北京时间 (UTC+8)", value: "Asia/Shanghai" },
|
||||||
|
{ label: "东京时间 (UTC+9)", value: "Asia/Tokyo" },
|
||||||
|
{ label: "纽约时间 (UTC-5)", value: "America/New_York" },
|
||||||
|
{ label: "伦敦时间 (UTC+0)", value: "Europe/London" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system",
|
||||||
|
title: "系统设置",
|
||||||
|
icon: <Database className="h-4 w-4" />,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "performance",
|
||||||
|
title: "性能设置",
|
||||||
|
description: "系统性能和资源配置",
|
||||||
|
icon: <Zap className="h-5 w-5" />,
|
||||||
|
columns: 2,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "enableMaintenance",
|
||||||
|
label: "维护模式",
|
||||||
|
description: "启用后网站将显示维护页面",
|
||||||
|
type: "switch",
|
||||||
|
value: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cacheEnabled",
|
||||||
|
label: "启用缓存",
|
||||||
|
description: "开启页面缓存以提高性能",
|
||||||
|
type: "switch",
|
||||||
|
value: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maxUsers",
|
||||||
|
label: "最大用户数",
|
||||||
|
description: "系统允许的最大注册用户数量",
|
||||||
|
type: "slider",
|
||||||
|
value: 1000,
|
||||||
|
min: 100,
|
||||||
|
max: 10000,
|
||||||
|
step: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sessionTimeout",
|
||||||
|
label: "会话超时时间(分钟)",
|
||||||
|
description: "用户登录会话的超时时间",
|
||||||
|
type: "slider",
|
||||||
|
value: 30,
|
||||||
|
min: 5,
|
||||||
|
max: 120,
|
||||||
|
step: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "backupFrequency",
|
||||||
|
label: "备份频率",
|
||||||
|
description: "自动备份数据的频率",
|
||||||
|
type: "select",
|
||||||
|
value: "daily",
|
||||||
|
options: [
|
||||||
|
{ label: "每小时", value: "hourly", description: "适合高频更新的网站" },
|
||||||
|
{ label: "每天", value: "daily", description: "推荐设置" },
|
||||||
|
{ label: "每周", value: "weekly", description: "适合低频更新的网站" },
|
||||||
|
{ label: "每月", value: "monthly", description: "仅适合静态网站" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "logLevel",
|
||||||
|
label: "日志级别",
|
||||||
|
description: "系统日志记录的详细程度",
|
||||||
|
type: "select",
|
||||||
|
value: "info",
|
||||||
|
options: [
|
||||||
|
{ label: "错误", value: "error" },
|
||||||
|
{ label: "警告", value: "warn" },
|
||||||
|
{ label: "信息", value: "info" },
|
||||||
|
{ label: "调试", value: "debug" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "security",
|
||||||
|
title: "安全设置",
|
||||||
|
icon: <Shield className="h-4 w-4" />,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "auth-settings",
|
||||||
|
title: "认证设置",
|
||||||
|
description: "用户认证和访问控制",
|
||||||
|
icon: <Lock className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "enableRegistration",
|
||||||
|
label: "允许用户注册",
|
||||||
|
description: "是否允许新用户注册账户",
|
||||||
|
type: "switch",
|
||||||
|
value: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enableSSL",
|
||||||
|
label: "强制HTTPS",
|
||||||
|
description: "强制所有连接使用HTTPS协议",
|
||||||
|
type: "switch",
|
||||||
|
value: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "securityLevel",
|
||||||
|
label: "安全级别",
|
||||||
|
description: "系统安全防护级别 (1-10)",
|
||||||
|
type: "slider",
|
||||||
|
value: 8,
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
step: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maxLoginAttempts",
|
||||||
|
label: "最大登录尝试次数",
|
||||||
|
description: "账户锁定前允许的最大失败登录次数",
|
||||||
|
type: "number",
|
||||||
|
value: 5,
|
||||||
|
min: 1,
|
||||||
|
max: 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "passwordPolicy",
|
||||||
|
label: "密码策略",
|
||||||
|
description: "密码复杂度要求",
|
||||||
|
type: "select",
|
||||||
|
value: "medium",
|
||||||
|
options: [
|
||||||
|
{ label: "简单", value: "simple", description: "至少6位字符" },
|
||||||
|
{ label: "中等", value: "medium", description: "至少8位,包含字母和数字" },
|
||||||
|
{ label: "复杂", value: "complex", description: "至少12位,包含大小写字母、数字和特殊字符" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "appearance",
|
||||||
|
title: "外观设置",
|
||||||
|
icon: <Palette className="h-4 w-4" />,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "theme-settings",
|
||||||
|
title: "主题设置",
|
||||||
|
description: "界面主题和视觉配置",
|
||||||
|
icon: <Monitor className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "theme",
|
||||||
|
label: "主题模式",
|
||||||
|
description: "选择网站的主题外观",
|
||||||
|
type: "radio",
|
||||||
|
value: "light",
|
||||||
|
options: [
|
||||||
|
{ label: "浅色主题", value: "light" },
|
||||||
|
{ label: "深色主题", value: "dark" },
|
||||||
|
{ label: "自动切换", value: "auto" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "primaryColor",
|
||||||
|
label: "主色调",
|
||||||
|
description: "网站的主要颜色",
|
||||||
|
type: "color",
|
||||||
|
value: "#3b82f6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enableComments",
|
||||||
|
label: "启用评论",
|
||||||
|
description: "是否在文章页面显示评论功能",
|
||||||
|
type: "switch",
|
||||||
|
value: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "performanceScore",
|
||||||
|
label: "性能优化级别",
|
||||||
|
description: "网站性能优化程度 (0-100)",
|
||||||
|
type: "slider",
|
||||||
|
value: 75,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "customCSS",
|
||||||
|
label: "自定义样式",
|
||||||
|
description: "添加自定义CSS代码",
|
||||||
|
type: "textarea",
|
||||||
|
value: "",
|
||||||
|
rows: 6,
|
||||||
|
placeholder: "/* 在这里添加自定义CSS */",
|
||||||
|
showWhen: (values) => values.theme === "dark"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notifications",
|
||||||
|
title: "通知设置",
|
||||||
|
icon: <Bell className="h-4 w-4" />,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "notification-settings",
|
||||||
|
title: "通知配置",
|
||||||
|
description: "系统通知和邮件设置",
|
||||||
|
icon: <Mail className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "enableNotifications",
|
||||||
|
label: "启用通知",
|
||||||
|
description: "是否接收系统通知",
|
||||||
|
type: "switch",
|
||||||
|
value: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "emailNotifications",
|
||||||
|
label: "邮件通知类型",
|
||||||
|
description: "选择要接收的邮件通知类型",
|
||||||
|
type: "checkbox",
|
||||||
|
value: true,
|
||||||
|
// Note: For multiple checkboxes, you'd typically use a different approach
|
||||||
|
// This is a simplified example
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maxFileSize",
|
||||||
|
label: "最大文件大小 (MB)",
|
||||||
|
description: "允许上传的最大文件大小",
|
||||||
|
type: "slider",
|
||||||
|
value: 10,
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
step: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enableCDN",
|
||||||
|
label: "启用CDN",
|
||||||
|
description: "使用内容分发网络加速",
|
||||||
|
type: "switch",
|
||||||
|
value: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notificationSound",
|
||||||
|
label: "通知声音",
|
||||||
|
description: "上传自定义通知声音文件",
|
||||||
|
type: "file",
|
||||||
|
value: null,
|
||||||
|
accept: "audio/*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "users",
|
||||||
|
title: "用户管理",
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
badge: "4",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "user-list",
|
||||||
|
title: "用户列表",
|
||||||
|
description: "管理系统用户账户和权限",
|
||||||
|
icon: <Users className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "userSearch",
|
||||||
|
label: "搜索用户",
|
||||||
|
description: "输入用户名或邮箱进行搜索",
|
||||||
|
type: "input",
|
||||||
|
value: "",
|
||||||
|
placeholder: "搜索用户..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "userRole",
|
||||||
|
label: "默认用户角色",
|
||||||
|
description: "新注册用户的默认角色",
|
||||||
|
type: "select",
|
||||||
|
value: "user",
|
||||||
|
options: [
|
||||||
|
{ label: "用户", value: "user" },
|
||||||
|
{ label: "编辑", value: "editor" },
|
||||||
|
{ label: "管理员", value: "admin" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// 配置选项
|
||||||
|
autoSave: true,
|
||||||
|
autoSaveDelay: 3000,
|
||||||
|
validateOnChange: true,
|
||||||
|
validateOnSubmit: true,
|
||||||
|
|
||||||
|
// 主题设置
|
||||||
|
theme: {
|
||||||
|
spacing: "normal",
|
||||||
|
layout: "tabs"
|
||||||
|
},
|
||||||
|
|
||||||
|
// 回调函数
|
||||||
|
onValueChange: (path, value, allValues) => {
|
||||||
|
console.log(`配置项 ${path} 已更改为:`, value);
|
||||||
|
},
|
||||||
|
|
||||||
|
onSave: async (values) => {
|
||||||
|
console.log("保存配置:", values);
|
||||||
|
// 这里可以添加保存到服务器的逻辑
|
||||||
|
},
|
||||||
|
|
||||||
|
onReset: () => {
|
||||||
|
console.log("重置配置");
|
||||||
|
},
|
||||||
|
|
||||||
|
onValidate: (values) => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
// 自定义验证逻辑
|
||||||
|
if (values.siteName && values.siteName.includes("测试")) {
|
||||||
|
errors.siteName = "网站名称不能包含'测试'字样";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.maxUsers && values.sessionTimeout &&
|
||||||
|
values.maxUsers > 5000 && values.sessionTimeout < 15) {
|
||||||
|
errors.sessionTimeout = "当最大用户数超过5000时,会话超时时间不能少于15分钟";
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简化版配置示例
|
||||||
|
export const simpleAdminPanelConfig: AdminPanelConfig = {
|
||||||
|
header: {
|
||||||
|
title: "快速设置",
|
||||||
|
description: "基本配置选项"
|
||||||
|
},
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: "basic",
|
||||||
|
title: "基本设置",
|
||||||
|
icon: <Settings className="h-4 w-4" />,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "basic-info",
|
||||||
|
title: "基本信息",
|
||||||
|
icon: <Globe className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
label: "名称",
|
||||||
|
type: "input",
|
||||||
|
value: "",
|
||||||
|
validation: { required: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
label: "启用",
|
||||||
|
type: "switch",
|
||||||
|
value: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
@ -29,287 +29,210 @@ import {
|
|||||||
Cpu,
|
Cpu,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function Control() {
|
export default function Control() {
|
||||||
const [config, setConfig] = useState<Config>(defaultConfig)
|
const [config, setConfig] = useState<Config>(defaultConfig)
|
||||||
|
|
||||||
const updateConfig = (key: keyof Config, value: any) => {
|
const updateConfig = (path: string, value: any) => {
|
||||||
setConfig(prev => ({
|
setConfig(prev => {
|
||||||
...prev,
|
const newConfig = { ...prev }
|
||||||
[key]: value
|
const keys = path.split('.')
|
||||||
}))
|
let current: any = newConfig
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
current = current[keys[i]]
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSliderConfig = (key: keyof Config, value: number[]) => {
|
current[keys[keys.length - 1]] = value
|
||||||
updateConfig(key, value[0])
|
return newConfig
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNestedConfig = (path: string, value: any) => {
|
||||||
|
updateConfig(path, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="flex-1 h-full">
|
<ScrollArea className="flex-1 h-full">
|
||||||
<div className="min-h-screen p-6">
|
<div className="min-h-screen p-6">
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* App Configuration */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Volume2 className="h-5 w-5" />
|
<Settings className="h-5 w-5" />
|
||||||
System Controls
|
应用配置
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Audio, display, and hardware settings</CardDescription>
|
<CardDescription>应用基本设置</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="volume">Volume: {config.volume}%</Label>
|
<Label htmlFor="app-name">应用名称</Label>
|
||||||
<Slider
|
<Input
|
||||||
id="volume"
|
id="app-name"
|
||||||
value={[config.volume]}
|
value={config.app.name}
|
||||||
onValueChange={(value) => updateSliderConfig('volume', value)}
|
onChange={(e) => updateConfig('app.name', e.target.value)}
|
||||||
max={100}
|
placeholder="输入应用名称"
|
||||||
step={1}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="brightness">
|
<Label htmlFor="app-version">版本号</Label>
|
||||||
<Brightness4 className="inline h-4 w-4 mr-1" />
|
<Input
|
||||||
Brightness: {config.brightness}%
|
id="app-version"
|
||||||
</Label>
|
value={config.app.version}
|
||||||
<Slider
|
onChange={(e) => updateConfig('app.version', e.target.value)}
|
||||||
id="brightness"
|
placeholder="输入版本号"
|
||||||
value={[config.brightness]}
|
|
||||||
onValueChange={(value) => updateSliderConfig('brightness', value)}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="temperature">Temperature: {config.temperature}°C</Label>
|
<Label htmlFor="app-debug">调试模式</Label>
|
||||||
<Slider
|
<Switch
|
||||||
id="temperature"
|
id="app-debug"
|
||||||
value={[config.temperature]}
|
checked={config.app.debug}
|
||||||
onValueChange={(value) => updateSliderConfig('temperature', value)}
|
onCheckedChange={(checked) => updateConfig('app.debug', checked)}
|
||||||
min={16}
|
|
||||||
max={30}
|
|
||||||
step={0.5}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Theme Selection</Label>
|
<Label>时区</Label>
|
||||||
<Select value={config.theme} onValueChange={(value) => updateConfig('theme', value)}>
|
<Select value={config.app.timezone} onValueChange={(value) => updateConfig('app.timezone', value)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="light">Light Theme</SelectItem>
|
<SelectItem value="UTC">UTC</SelectItem>
|
||||||
<SelectItem value="dark">Dark Theme</SelectItem>
|
<SelectItem value="Asia/Shanghai">中国标准时间</SelectItem>
|
||||||
<SelectItem value="auto">Auto Theme</SelectItem>
|
<SelectItem value="America/New_York">美国东部时间</SelectItem>
|
||||||
<SelectItem value="high-contrast">High Contrast</SelectItem>
|
<SelectItem value="Europe/London">英国时间</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Server Configuration */}
|
{/* Database Configuration */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
数据库配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>数据库连接设置</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="db-connections">最大连接数: {config.database.max_connections}</Label>
|
||||||
|
<Slider
|
||||||
|
id="db-connections"
|
||||||
|
value={[config.database.max_connections]}
|
||||||
|
onValueChange={(value) => updateConfig('database.max_connections', value[0])}
|
||||||
|
min={5}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="db-timeout">连接超时: {config.database.connection_timeout}秒</Label>
|
||||||
|
<Slider
|
||||||
|
id="db-timeout"
|
||||||
|
value={[config.database.connection_timeout]}
|
||||||
|
onValueChange={(value) => updateConfig('database.connection_timeout', value[0])}
|
||||||
|
min={10}
|
||||||
|
max={120}
|
||||||
|
step={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Kafka Configuration */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Server className="h-5 w-5" />
|
<Server className="h-5 w-5" />
|
||||||
Server Configuration
|
Kafka配置
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Core server and API settings</CardDescription>
|
<CardDescription>消息队列设置</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="server-name">Server Name</Label>
|
<Label htmlFor="kafka-retries">最大重试次数: {config.kafka.max_retries}</Label>
|
||||||
<Input
|
|
||||||
id="server-name"
|
|
||||||
value={config.serverName}
|
|
||||||
onChange={(e) => updateConfig('serverName', e.target.value)}
|
|
||||||
placeholder="Enter server name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="api-key">API Key</Label>
|
|
||||||
<Input
|
|
||||||
id="api-key"
|
|
||||||
type="password"
|
|
||||||
value={config.apiKey}
|
|
||||||
onChange={(e) => updateConfig('apiKey', e.target.value)}
|
|
||||||
placeholder="Enter API key"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>Environment</Label>
|
|
||||||
<Select value={config.environment} onValueChange={(value) => updateConfig('environment', value)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="development">Development</SelectItem>
|
|
||||||
<SelectItem value="staging">Staging</SelectItem>
|
|
||||||
<SelectItem value="production">Production</SelectItem>
|
|
||||||
<SelectItem value="testing">Testing</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>Region</Label>
|
|
||||||
<Select value={config.region} onValueChange={(value) => updateConfig('region', value)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
|
|
||||||
<SelectItem value="us-west-2">US West (Oregon)</SelectItem>
|
|
||||||
<SelectItem value="eu-west-1">Europe (Ireland)</SelectItem>
|
|
||||||
<SelectItem value="ap-southeast-1">Asia Pacific (Singapore)</SelectItem>
|
|
||||||
<SelectItem value="ap-northeast-1">Asia Pacific (Tokyo)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Performance Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Cpu className="h-5 w-5" />
|
|
||||||
Performance Settings
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Resource allocation and optimization</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="connections">Max Connections: {config.maxConnections}</Label>
|
|
||||||
<Slider
|
<Slider
|
||||||
id="connections"
|
id="kafka-retries"
|
||||||
value={[config.maxConnections]}
|
value={[config.kafka.max_retries]}
|
||||||
onValueChange={(value) => updateSliderConfig('maxConnections', value)}
|
onValueChange={(value) => updateConfig('kafka.max_retries', value[0])}
|
||||||
min={10}
|
|
||||||
max={500}
|
|
||||||
step={10}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cache-size">Cache Size: {config.cacheSize}MB</Label>
|
|
||||||
<Slider
|
|
||||||
id="cache-size"
|
|
||||||
value={[config.cacheSize]}
|
|
||||||
onValueChange={(value) => updateSliderConfig('cacheSize', value)}
|
|
||||||
min={64}
|
|
||||||
max={2048}
|
|
||||||
step={64}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="thread-count">Thread Count: {config.threadCount}</Label>
|
|
||||||
<Slider
|
|
||||||
id="thread-count"
|
|
||||||
value={[config.threadCount]}
|
|
||||||
onValueChange={(value) => updateSliderConfig('threadCount', value)}
|
|
||||||
min={1}
|
min={1}
|
||||||
max={32}
|
max={10}
|
||||||
step={1}
|
step={1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="memory-limit">Memory Limit: {config.memoryLimit}MB</Label>
|
<Label htmlFor="kafka-delay">重试延迟: {config.kafka.retry_delay}毫秒</Label>
|
||||||
<Slider
|
<Slider
|
||||||
id="memory-limit"
|
id="kafka-delay"
|
||||||
value={[config.memoryLimit]}
|
value={[config.kafka.retry_delay]}
|
||||||
onValueChange={(value) => updateSliderConfig('memoryLimit', value)}
|
onValueChange={(value) => updateConfig('kafka.retry_delay', value[0])}
|
||||||
min={512}
|
min={100}
|
||||||
max={8192}
|
max={5000}
|
||||||
step={256}
|
step={100}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="bandwidth">Network Bandwidth: {config.networkBandwidth}Mbps</Label>
|
|
||||||
<Slider
|
|
||||||
id="bandwidth"
|
|
||||||
value={[config.networkBandwidth]}
|
|
||||||
onValueChange={(value) => updateSliderConfig('networkBandwidth', value)}
|
|
||||||
min={10}
|
|
||||||
max={1000}
|
|
||||||
step={10}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Security & Features */}
|
{/* Security Configuration */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield className="h-5 w-5" />
|
<Shield className="h-5 w-5" />
|
||||||
Security & Features
|
安全配置
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Security settings and feature toggles</CardDescription>
|
<CardDescription>安全相关设置</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="ssl-enabled" className="flex items-center gap-2">
|
<Label htmlFor="session-timeout">会话超时: {config.security.session_timeout}秒</Label>
|
||||||
<Lock className="h-4 w-4" />
|
<Slider
|
||||||
SSL Enabled
|
id="session-timeout"
|
||||||
</Label>
|
value={[config.security.session_timeout]}
|
||||||
<Switch
|
onValueChange={(value) => updateConfig('security.session_timeout', value[0])}
|
||||||
id="ssl-enabled"
|
min={1800}
|
||||||
checked={config.sslEnabled}
|
max={7200}
|
||||||
onCheckedChange={(checked) => updateConfig('sslEnabled', checked)}
|
step={300}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="auto-backup">Auto Backup</Label>
|
<Label htmlFor="login-attempts">最大登录尝试次数: {config.security.max_login_attempts}</Label>
|
||||||
<Switch
|
<Slider
|
||||||
id="auto-backup"
|
id="login-attempts"
|
||||||
checked={config.autoBackup}
|
value={[config.security.max_login_attempts]}
|
||||||
onCheckedChange={(checked) => updateConfig('autoBackup', checked)}
|
onValueChange={(value) => updateConfig('security.max_login_attempts', value[0])}
|
||||||
/>
|
min={3}
|
||||||
</div>
|
max={10}
|
||||||
|
step={1}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="compression">Compression</Label>
|
|
||||||
<Switch
|
|
||||||
id="compression"
|
|
||||||
checked={config.compressionEnabled}
|
|
||||||
onCheckedChange={(checked) => updateConfig('compressionEnabled', checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="debug-mode">Debug Mode</Label>
|
|
||||||
<Switch
|
|
||||||
id="debug-mode"
|
|
||||||
checked={config.debugMode}
|
|
||||||
onCheckedChange={(checked) => updateConfig('debugMode', checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="maintenance">Maintenance Mode</Label>
|
|
||||||
<Switch
|
|
||||||
id="maintenance"
|
|
||||||
checked={config.maintenanceMode}
|
|
||||||
onCheckedChange={(checked) => updateConfig('maintenanceMode', checked)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Logging Configuration */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Monitor className="h-5 w-5" />
|
||||||
|
日志配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>日志记录设置</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Log Level</Label>
|
<Label>日志级别</Label>
|
||||||
<Select value={config.logLevel} onValueChange={(value) => updateConfig('logLevel', value)}>
|
<Select value={config.logging.level} onValueChange={(value) => updateConfig('logging.level', value)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -318,242 +241,333 @@ export default function Control() {
|
|||||||
<SelectItem value="info">Info</SelectItem>
|
<SelectItem value="info">Info</SelectItem>
|
||||||
<SelectItem value="warn">Warning</SelectItem>
|
<SelectItem value="warn">Warning</SelectItem>
|
||||||
<SelectItem value="error">Error</SelectItem>
|
<SelectItem value="error">Error</SelectItem>
|
||||||
<SelectItem value="fatal">Fatal</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="log-files">最大日志文件数: {config.logging.max_files}</Label>
|
||||||
|
<Slider
|
||||||
|
id="log-files"
|
||||||
|
value={[config.logging.max_files]}
|
||||||
|
onValueChange={(value) => updateConfig('logging.max_files', value[0])}
|
||||||
|
min={5}
|
||||||
|
max={50}
|
||||||
|
step={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Notifications & Alerts */}
|
{/* Cache Configuration */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<HardDrive className="h-5 w-5" />
|
||||||
|
缓存配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>缓存系统设置</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cache-ttl">缓存TTL: {config.cache.ttl}秒</Label>
|
||||||
|
<Slider
|
||||||
|
id="cache-ttl"
|
||||||
|
value={[config.cache.ttl]}
|
||||||
|
onValueChange={(value) => updateConfig('cache.ttl', value[0])}
|
||||||
|
min={60}
|
||||||
|
max={3600}
|
||||||
|
step={60}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cache-size">最大缓存大小: {config.cache.max_size}</Label>
|
||||||
|
<Slider
|
||||||
|
id="cache-size"
|
||||||
|
value={[config.cache.max_size]}
|
||||||
|
onValueChange={(value) => updateConfig('cache.max_size', value[0])}
|
||||||
|
min={100}
|
||||||
|
max={10000}
|
||||||
|
step={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Site Configuration */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Wifi className="h-5 w-5" />
|
||||||
|
站点配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>网站基本设置</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="site-name">站点名称</Label>
|
||||||
|
<Input
|
||||||
|
id="site-name"
|
||||||
|
value={config.site.name}
|
||||||
|
onChange={(e) => updateConfig('site.name', e.target.value)}
|
||||||
|
placeholder="输入站点名称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>默认语言</Label>
|
||||||
|
<Select value={config.site.locale_default} onValueChange={(value) => updateConfig('site.locale_default', value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="zh-CN">中文</SelectItem>
|
||||||
|
<SelectItem value="en">English</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="logo-url">Logo URL</Label>
|
||||||
|
<Input
|
||||||
|
id="logo-url"
|
||||||
|
value={config.site.brand.logo_url}
|
||||||
|
onChange={(e) => updateConfig('site.brand.logo_url', e.target.value)}
|
||||||
|
placeholder="输入Logo URL"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="primary-color">主色调</Label>
|
||||||
|
<Input
|
||||||
|
id="primary-color"
|
||||||
|
type="color"
|
||||||
|
value={config.site.brand.primary_color}
|
||||||
|
onChange={(e) => updateConfig('site.brand.primary_color', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="dark-mode">默认深色模式</Label>
|
||||||
|
<Switch
|
||||||
|
id="dark-mode"
|
||||||
|
checked={config.site.brand.dark_mode_default}
|
||||||
|
onCheckedChange={(checked) => updateConfig('site.brand.dark_mode_default', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notice Configuration */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
Notifications & Alerts
|
通知配置
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Configure notification preferences</CardDescription>
|
<CardDescription>系统通知设置</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="notifications">Push Notifications</Label>
|
<Label htmlFor="banner-enabled">启用横幅通知</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id="notifications"
|
id="banner-enabled"
|
||||||
checked={config.notifications}
|
checked={config.notice.banner.enabled}
|
||||||
onCheckedChange={(checked) => updateConfig('notifications', checked)}
|
onCheckedChange={(checked) => updateConfig('notice.banner.enabled', checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{config.notice.banner.enabled && (
|
||||||
<Label htmlFor="email-alerts">Email Alerts</Label>
|
<>
|
||||||
<Switch
|
<div className="space-y-2">
|
||||||
id="email-alerts"
|
<Label htmlFor="banner-text-zh">中文横幅文本</Label>
|
||||||
checked={config.emailAlerts}
|
<Input
|
||||||
onCheckedChange={(checked) => updateConfig('emailAlerts', checked)}
|
id="banner-text-zh"
|
||||||
|
value={config.notice.banner.text["zh-CN"]}
|
||||||
|
onChange={(e) => updateConfig('notice.banner.text.zh-CN', e.target.value)}
|
||||||
|
placeholder="输入中文横幅文本"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="sms-alerts">SMS Alerts</Label>
|
<Label htmlFor="banner-text-en">English Banner Text</Label>
|
||||||
<Switch
|
<Input
|
||||||
id="sms-alerts"
|
id="banner-text-en"
|
||||||
checked={config.smsAlerts}
|
value={config.notice.banner.text["en"]}
|
||||||
onCheckedChange={(checked) => updateConfig('smsAlerts', checked)}
|
onChange={(e) => updateConfig('notice.banner.text.en', e.target.value)}
|
||||||
|
placeholder="Enter English banner text"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<div className="flex items-center justify-between">
|
)}
|
||||||
<Label htmlFor="monitoring">System Monitoring</Label>
|
|
||||||
<Switch
|
|
||||||
id="monitoring"
|
|
||||||
checked={config.monitoringEnabled}
|
|
||||||
onCheckedChange={(checked) => updateConfig('monitoringEnabled', checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>Language</Label>
|
|
||||||
<Select value={config.language} onValueChange={(value) => updateConfig('language', value)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="en">English</SelectItem>
|
|
||||||
<SelectItem value="es">Español</SelectItem>
|
|
||||||
<SelectItem value="fr">Français</SelectItem>
|
|
||||||
<SelectItem value="de">Deutsch</SelectItem>
|
|
||||||
<SelectItem value="zh">中文</SelectItem>
|
|
||||||
<SelectItem value="ja">日本語</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>Timezone</Label>
|
|
||||||
<Select value={config.timezone} onValueChange={(value) => updateConfig('timezone', value)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="UTC">UTC</SelectItem>
|
|
||||||
<SelectItem value="EST">Eastern Time</SelectItem>
|
|
||||||
<SelectItem value="PST">Pacific Time</SelectItem>
|
|
||||||
<SelectItem value="GMT">Greenwich Mean Time</SelectItem>
|
|
||||||
<SelectItem value="JST">Japan Standard Time</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Advanced Configuration */}
|
{/* Maintenance Configuration */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Database className="h-5 w-5" />
|
<Cpu className="h-5 w-5" />
|
||||||
Advanced Configuration
|
维护配置
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Detailed system configuration options</CardDescription>
|
<CardDescription>系统维护设置</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="maintenance-enabled">启用维护模式</Label>
|
||||||
|
<Switch
|
||||||
|
id="maintenance-enabled"
|
||||||
|
checked={config.maintenance.window.enabled}
|
||||||
|
onCheckedChange={(checked) => updateConfig('maintenance.window.enabled', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.maintenance.window.enabled && (
|
||||||
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">System Description</Label>
|
<Label htmlFor="maintenance-start">维护开始时间</Label>
|
||||||
|
<Input
|
||||||
|
id="maintenance-start"
|
||||||
|
type="datetime-local"
|
||||||
|
value={config.maintenance.window.start_time.replace('Z', '')}
|
||||||
|
onChange={(e) => updateConfig('maintenance.window.start_time', e.target.value + 'Z')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="maintenance-end">维护结束时间</Label>
|
||||||
|
<Input
|
||||||
|
id="maintenance-end"
|
||||||
|
type="datetime-local"
|
||||||
|
value={config.maintenance.window.end_time.replace('Z', '')}
|
||||||
|
onChange={(e) => updateConfig('maintenance.window.end_time', e.target.value + 'Z')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="maintenance-message-zh">中文维护消息</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="maintenance-message-zh"
|
||||||
value={config.description}
|
value={config.maintenance.window.message["zh-CN"]}
|
||||||
onChange={(e) => updateConfig('description', e.target.value)}
|
onChange={(e) => updateConfig('maintenance.window.message.zh-CN', e.target.value)}
|
||||||
placeholder="Enter system description..."
|
placeholder="输入中文维护消息"
|
||||||
rows={3}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>Deployment Strategy</Label>
|
|
||||||
<RadioGroup value={config.deploymentStrategy} onValueChange={(value) => updateConfig('deploymentStrategy', value)}>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="rolling" id="rolling" />
|
|
||||||
<Label htmlFor="rolling">Rolling Deployment</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="blue-green" id="blue-green" />
|
|
||||||
<Label htmlFor="blue-green">Blue-Green Deployment</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="canary" id="canary" />
|
|
||||||
<Label htmlFor="canary">Canary Deployment</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>Enabled Features</Label>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[
|
<Label htmlFor="maintenance-message-en">English Maintenance Message</Label>
|
||||||
{ id: "analytics", label: "Analytics" },
|
<Textarea
|
||||||
{ id: "caching", label: "Caching" },
|
id="maintenance-message-en"
|
||||||
{ id: "cdn", label: "CDN" },
|
value={config.maintenance.window.message["en"]}
|
||||||
{ id: "load-balancing", label: "Load Balancing" },
|
onChange={(e) => updateConfig('maintenance.window.message.en', e.target.value)}
|
||||||
{ id: "auto-scaling", label: "Auto Scaling" },
|
placeholder="Enter English maintenance message"
|
||||||
].map((feature) => (
|
rows={2}
|
||||||
<div key={feature.id} className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id={feature.id}
|
|
||||||
checked={config.selectedFeatures.includes(feature.id)}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
if (checked) {
|
|
||||||
updateConfig('selectedFeatures', [...config.selectedFeatures, feature.id])
|
|
||||||
} else {
|
|
||||||
updateConfig('selectedFeatures', config.selectedFeatures.filter((f) => f !== feature.id))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={feature.id}>{feature.label}</Label>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Operations Configuration */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Zap className="h-5 w-5" />
|
||||||
|
运营配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>运营功能设置</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="registration-enabled">启用用户注册</Label>
|
||||||
|
<Switch
|
||||||
|
id="registration-enabled"
|
||||||
|
checked={config.ops.features.registration_enabled}
|
||||||
|
onCheckedChange={(checked) => updateConfig('ops.features.registration_enabled', checked)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="invite-code-required">需要邀请码</Label>
|
||||||
|
<Switch
|
||||||
|
id="invite-code-required"
|
||||||
|
checked={config.ops.features.invite_code_required}
|
||||||
|
onCheckedChange={(checked) => updateConfig('ops.features.invite_code_required', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="email-verification">邮箱验证</Label>
|
||||||
|
<Switch
|
||||||
|
id="email-verification"
|
||||||
|
checked={config.ops.features.email_verification}
|
||||||
|
onCheckedChange={(checked) => updateConfig('ops.features.email_verification', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max-users">最大用户数: {config.ops.limits.max_users}</Label>
|
||||||
|
<Slider
|
||||||
|
id="max-users"
|
||||||
|
value={[config.ops.limits.max_users]}
|
||||||
|
onValueChange={(value) => updateConfig('ops.limits.max_users', value[0])}
|
||||||
|
min={100}
|
||||||
|
max={10000}
|
||||||
|
step={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max-invite-codes">每用户最大邀请码数: {config.ops.limits.max_invite_codes_per_user}</Label>
|
||||||
|
<Slider
|
||||||
|
id="max-invite-codes"
|
||||||
|
value={[config.ops.limits.max_invite_codes_per_user]}
|
||||||
|
onValueChange={(value) => updateConfig('ops.limits.max_invite_codes_per_user', value[0])}
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="session-timeout">会话超时: {config.ops.limits.session_timeout_hours}小时</Label>
|
||||||
|
<Slider
|
||||||
|
id="session-timeout"
|
||||||
|
value={[config.ops.limits.session_timeout_hours]}
|
||||||
|
onValueChange={(value) => updateConfig('ops.limits.session_timeout_hours', value[0])}
|
||||||
|
min={1}
|
||||||
|
max={168}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Dashboard */}
|
{/* Action Buttons */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Monitor className="h-5 w-5" />
|
<Settings className="h-5 w-5" />
|
||||||
System Status Dashboard
|
配置操作
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Real-time system metrics and performance indicators</CardDescription>
|
<CardDescription>保存、重置和导出配置</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<Cpu className="h-4 w-4" />
|
|
||||||
CPU Usage
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 bg-slate-200 rounded-full h-2">
|
|
||||||
<div className="bg-blue-500 h-2 rounded-full w-3/4"></div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary">75%</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<HardDrive className="h-4 w-4" />
|
|
||||||
Memory
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 bg-slate-200 rounded-full h-2">
|
|
||||||
<div className="bg-green-500 h-2 rounded-full w-1/2"></div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary">8.2GB</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<Database className="h-4 w-4" />
|
|
||||||
Storage
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 bg-slate-200 rounded-full h-2">
|
|
||||||
<div className="bg-orange-500 h-2 rounded-full w-5/6"></div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary">456GB</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<Wifi className="h-4 w-4" />
|
|
||||||
Network
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 bg-slate-200 rounded-full h-2">
|
|
||||||
<div className="bg-purple-500 h-2 rounded-full w-1/3"></div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-green-600 border-green-600">
|
|
||||||
Online
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="my-6" />
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button className="flex items-center gap-2" onClick={() => {
|
<Button className="flex items-center gap-2" onClick={() => {
|
||||||
// 这里可以添加保存配置到后端的逻辑
|
|
||||||
console.log('Applying configuration:', config)
|
console.log('Applying configuration:', config)
|
||||||
alert('Configuration applied successfully!')
|
alert('配置应用成功!')
|
||||||
}}>
|
}}>
|
||||||
<Zap className="h-4 w-4" />
|
<Zap className="h-4 w-4" />
|
||||||
Apply All Settings
|
应用所有设置
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setConfig(defaultConfig)}>
|
||||||
|
重置为默认值
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setConfig(defaultConfig)}>Reset to Default</Button>
|
|
||||||
<Button variant="outline" onClick={() => {
|
<Button variant="outline" onClick={() => {
|
||||||
const dataStr = JSON.stringify(config, null, 2)
|
const dataStr = JSON.stringify(config, null, 2)
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
||||||
@ -563,7 +577,9 @@ export default function Control() {
|
|||||||
link.download = 'config.json'
|
link.download = 'config.json'
|
||||||
link.click()
|
link.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}}>Export Configuration</Button>
|
}}>
|
||||||
|
导出配置
|
||||||
|
</Button>
|
||||||
<Button variant="outline" onClick={() => {
|
<Button variant="outline" onClick={() => {
|
||||||
const input = document.createElement('input')
|
const input = document.createElement('input')
|
||||||
input.type = 'file'
|
input.type = 'file'
|
||||||
@ -578,21 +594,21 @@ export default function Control() {
|
|||||||
setConfig(importedConfig)
|
setConfig(importedConfig)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse config file:', error)
|
console.error('Failed to parse config file:', error)
|
||||||
alert('Invalid configuration file')
|
alert('无效的配置文件')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader.readAsText(file)
|
reader.readAsText(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
input.click()
|
input.click()
|
||||||
}}>Import Configuration</Button>
|
}}>
|
||||||
<Button variant="ghost">View Logs</Button>
|
导入配置
|
||||||
<Button variant="ghost">System Health Check</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
559
app/admin/common/dynamic-admin-config.tsx
Normal file
559
app/admin/common/dynamic-admin-config.tsx
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
Globe,
|
||||||
|
Palette,
|
||||||
|
Bell,
|
||||||
|
Shield,
|
||||||
|
Users,
|
||||||
|
Database,
|
||||||
|
Mail,
|
||||||
|
FileText,
|
||||||
|
MessageSquare,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
Save,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
Server,
|
||||||
|
HardDrive
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AdminPanelConfig } from "@/types/admin-panel";
|
||||||
|
import { commonConfigSchema, zodErrorsToAdminErrors } from "@/lib/config-zod";
|
||||||
|
// import { SiteOpsConfigType } from "@/types/site-config";
|
||||||
|
import { SiteOpsConfigType } from "@/types/site-config";
|
||||||
|
import { ConfigItemType } from "@/hooks/use-site-config";
|
||||||
|
|
||||||
|
// 创建基于后端数据的动态管理面板配置
|
||||||
|
export function createDynamicAdminConfig(
|
||||||
|
data?: SiteOpsConfigType,
|
||||||
|
onSave?: (values: Record<string, any>) => Promise<void>,
|
||||||
|
onExport?: () => Promise<void>,
|
||||||
|
onImport?: (file: File) => Promise<void>,
|
||||||
|
configs?: ConfigItemType[]
|
||||||
|
): AdminPanelConfig {
|
||||||
|
|
||||||
|
// 从后端数据获取初始值(安全访问嵌套属性)
|
||||||
|
const getInitialValues = () => {
|
||||||
|
if (!data) return {};
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
const toDateTimeLocal = (value?: any) => {
|
||||||
|
if (!value) return "";
|
||||||
|
const d = new Date(value);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
return d.toISOString().slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bannerText = data.notice_maintenance?.banner?.text || {
|
||||||
|
"zh-CN": "欢迎使用MMAP系统",
|
||||||
|
"en": "Welcome to MMAP System"
|
||||||
|
};
|
||||||
|
const maintenanceMsg = data.notice_maintenance?.maintenance_window?.message || {
|
||||||
|
"zh-CN": "系统维护中,请稍后再试",
|
||||||
|
"en": "System maintenance in progress"
|
||||||
|
};
|
||||||
|
const workingHours = data.docs_support?.channels?.working_hours || {
|
||||||
|
"zh-CN": "周一至周五 9:00-18:00",
|
||||||
|
"en": "Mon-Fri 9:00-18:00"
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 站点信息
|
||||||
|
'site.info.name': data.site?.info?.name || "MMAP System",
|
||||||
|
'site.info.locale_default': data.site?.info?.locale_default || "zh-CN",
|
||||||
|
'site.info.locales_supported': data.site?.info?.locales_supported || ["zh-CN", "en"],
|
||||||
|
|
||||||
|
// 品牌配置
|
||||||
|
'site.brand.logo_url': data.site?.brand?.logo_url || "/images/logo.png",
|
||||||
|
'site.brand.primary_color': data.site?.brand?.primary_color || "#3B82F6",
|
||||||
|
'site.brand.dark_mode_default': data.site?.brand?.dark_mode_default || false,
|
||||||
|
|
||||||
|
// 页脚链接
|
||||||
|
'site.footer_links': data.site?.footer_links || [],
|
||||||
|
|
||||||
|
// 横幅公告
|
||||||
|
'notice.banner.enabled': data.notice_maintenance?.banner?.enabled || false,
|
||||||
|
'notice.banner.text': JSON.stringify(bannerText, null, 2),
|
||||||
|
|
||||||
|
// 维护窗口
|
||||||
|
'maintenance.window.enabled': data.notice_maintenance?.maintenance_window?.enabled || false,
|
||||||
|
'maintenance.window.start_time': toDateTimeLocal(data.notice_maintenance?.maintenance_window?.start_time),
|
||||||
|
'maintenance.window.end_time': toDateTimeLocal(data.notice_maintenance?.maintenance_window?.end_time),
|
||||||
|
'maintenance.window.message': JSON.stringify(maintenanceMsg, null, 2),
|
||||||
|
|
||||||
|
// 弹窗公告
|
||||||
|
'modal.announcements': data.notice_maintenance?.modal_announcements || [],
|
||||||
|
|
||||||
|
// 文档链接
|
||||||
|
'docs.links': data.docs_support?.links || [],
|
||||||
|
|
||||||
|
// 支持渠道
|
||||||
|
'support.channels.email': data.docs_support?.channels?.email || "support@mapp.com",
|
||||||
|
'support.channels.ticket_system': data.docs_support?.channels?.ticket_system || "/support/tickets",
|
||||||
|
'support.channels.chat_groups': data.docs_support?.channels?.chat_groups || [],
|
||||||
|
'support.channels.working_hours': JSON.stringify(workingHours, null, 2),
|
||||||
|
|
||||||
|
// 运营功能开关
|
||||||
|
'ops.features.registration_enabled': data.ops?.features?.registration_enabled ?? true,
|
||||||
|
'ops.features.invite_code_required': data.ops?.features?.invite_code_required ?? false,
|
||||||
|
'ops.features.email_verification': data.ops?.features?.email_verification ?? false,
|
||||||
|
|
||||||
|
// 运营限制
|
||||||
|
'ops.limits.max_users': data.ops?.limits?.max_users || 1000,
|
||||||
|
'ops.limits.max_invite_codes_per_user': data.ops?.limits?.max_invite_codes_per_user || 10,
|
||||||
|
'ops.limits.session_timeout_hours': data.ops?.limits?.session_timeout_hours || 24,
|
||||||
|
|
||||||
|
// 通知配置
|
||||||
|
'ops.notifications.welcome_email': data.ops?.notifications?.welcome_email ?? true,
|
||||||
|
'ops.notifications.system_announcements': data.ops?.notifications?.system_announcements ?? true,
|
||||||
|
'ops.notifications.maintenance_alerts': data.ops?.notifications?.maintenance_alerts ?? true,
|
||||||
|
|
||||||
|
// —— 通用配置(映射/默认)——
|
||||||
|
// Site
|
||||||
|
'site.name': data.site?.info?.name || "MMAP System",
|
||||||
|
'site.description': "",
|
||||||
|
'site.keywords': "",
|
||||||
|
'site.url': "/",
|
||||||
|
'site.logo': data.site?.brand?.logo_url || "/images/logo.png",
|
||||||
|
'site.copyright': "",
|
||||||
|
'site.icp': "",
|
||||||
|
'site.icp_url': "",
|
||||||
|
'site.color_style': (data.site?.brand?.dark_mode_default ? 'dark' : 'light'),
|
||||||
|
|
||||||
|
// User
|
||||||
|
'user.default_avatar': "/images/avatar.png",
|
||||||
|
'user.default_role': 'user',
|
||||||
|
'user.register_invite_code': data.ops?.features?.invite_code_required ?? false,
|
||||||
|
'user.register_email_verification': data.ops?.features?.email_verification ?? false,
|
||||||
|
'user.open_login': true,
|
||||||
|
'user.open_reset_password': true,
|
||||||
|
|
||||||
|
// Email
|
||||||
|
'email.smtp_host': "",
|
||||||
|
'email.smtp_port': 465,
|
||||||
|
'email.smtp_user': "",
|
||||||
|
'email.smtp_password': "",
|
||||||
|
'email.smtp_from': "",
|
||||||
|
'email.smtp_from_name': "",
|
||||||
|
'email.smtp_from_email': "",
|
||||||
|
'email.system_template': "default",
|
||||||
|
|
||||||
|
// Blog
|
||||||
|
'blog.default_author': "",
|
||||||
|
'blog.default_category': "",
|
||||||
|
'blog.default_tag': "",
|
||||||
|
'blog.open_comment': true,
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
'logging.level': 'info',
|
||||||
|
'logging.max_files': 10,
|
||||||
|
'logging.max_file_size': 10,
|
||||||
|
|
||||||
|
// Cache
|
||||||
|
'cache.ttl': 3600,
|
||||||
|
'cache.max_size': 1024,
|
||||||
|
|
||||||
|
// Switches
|
||||||
|
'switch.open_register': data.ops?.features?.registration_enabled ?? true,
|
||||||
|
'switch.open_login': true,
|
||||||
|
'switch.open_reset_password': true,
|
||||||
|
'switch.open_comment': true,
|
||||||
|
'switch.open_like': true,
|
||||||
|
'switch.open_share': true,
|
||||||
|
'switch.open_view': true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: {
|
||||||
|
title: "系统配置管理",
|
||||||
|
description: "管理站点配置、运营设置和系统参数",
|
||||||
|
breadcrumbs: [
|
||||||
|
{ label: "首页", href: "/" },
|
||||||
|
{ label: "管理中心", href: "/admin" },
|
||||||
|
{ label: "系统配置" }
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "refresh",
|
||||||
|
label: "刷新数据",
|
||||||
|
icon: <RefreshCw className="h-4 w-4" />,
|
||||||
|
variant: "outline",
|
||||||
|
onClick: () => window.location.reload()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "export",
|
||||||
|
label: "导出配置",
|
||||||
|
icon: <Download className="h-4 w-4" />,
|
||||||
|
variant: "outline",
|
||||||
|
onClick: onExport || (() => console.log("导出配置"))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "import",
|
||||||
|
label: "导入配置",
|
||||||
|
icon: <Upload className="h-4 w-4" />,
|
||||||
|
variant: "outline",
|
||||||
|
onClick: () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.json';
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (file && onImport) {
|
||||||
|
onImport(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
tabs: [
|
||||||
|
// 通用配置(基于 configs 动态生成)
|
||||||
|
{
|
||||||
|
id: "common",
|
||||||
|
title: "通用配置",
|
||||||
|
icon: <Settings className="h-4 w-4" />,
|
||||||
|
sections: []
|
||||||
|
},
|
||||||
|
// 站点设置
|
||||||
|
{
|
||||||
|
id: "site",
|
||||||
|
title: "站点设置",
|
||||||
|
icon: <Globe className="h-4 w-4" />,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "site-info",
|
||||||
|
title: "基本信息",
|
||||||
|
description: "网站基本信息和国际化配置",
|
||||||
|
icon: <Settings className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "site.info.name",
|
||||||
|
label: "网站名称",
|
||||||
|
description: "显示在浏览器标题栏的网站名称",
|
||||||
|
type: "input",
|
||||||
|
value: data?.site?.info?.name || "MMAP System",
|
||||||
|
placeholder: "请输入网站名称",
|
||||||
|
validation: {
|
||||||
|
required: true,
|
||||||
|
minLength: 2,
|
||||||
|
maxLength: 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "site.info.locale_default",
|
||||||
|
label: "默认语言",
|
||||||
|
description: "网站的默认显示语言",
|
||||||
|
type: "select",
|
||||||
|
value: data?.site?.info?.locale_default || "zh-CN",
|
||||||
|
options: [
|
||||||
|
{ label: "简体中文", value: "zh-CN" },
|
||||||
|
{ label: "English", value: "en" }
|
||||||
|
],
|
||||||
|
validation: {
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "site.info.locales_supported",
|
||||||
|
label: "支持的语言",
|
||||||
|
description: "用户可以选择的所有语言选项",
|
||||||
|
type: "select",
|
||||||
|
value: data?.site?.info?.locales_supported || ["zh-CN", "en"],
|
||||||
|
multiple: true,
|
||||||
|
options: [
|
||||||
|
{ label: "简体中文", value: "zh-CN" },
|
||||||
|
{ label: "English", value: "en" },
|
||||||
|
{ label: "繁體中文", value: "zh-TW" },
|
||||||
|
{ label: "日本語", value: "ja" },
|
||||||
|
{ label: "한국어", value: "ko" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "site-brand",
|
||||||
|
title: "品牌设置",
|
||||||
|
description: "网站品牌形象和主题配置",
|
||||||
|
icon: <Palette className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "site.brand.logo_url",
|
||||||
|
label: "Logo地址",
|
||||||
|
description: "网站Logo图片的URL地址",
|
||||||
|
type: "input",
|
||||||
|
value: data?.site?.brand?.logo_url || "/images/logo.png",
|
||||||
|
placeholder: "请输入Logo URL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "site.brand.primary_color",
|
||||||
|
label: "主题色",
|
||||||
|
description: "网站的主要色彩,用于按钮、链接等",
|
||||||
|
type: "color",
|
||||||
|
value: data?.site?.brand?.primary_color || "#3B82F6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "site.brand.dark_mode_default",
|
||||||
|
label: "默认深色模式",
|
||||||
|
description: "新用户访问时是否默认使用深色模式",
|
||||||
|
type: "switch",
|
||||||
|
value: data?.site?.brand?.dark_mode_default || false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// 公告维护
|
||||||
|
{
|
||||||
|
id: "notice",
|
||||||
|
title: "公告维护",
|
||||||
|
icon: <Bell className="h-4 w-4" />,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "banner-notice",
|
||||||
|
title: "横幅公告",
|
||||||
|
description: "网站顶部横幅公告设置",
|
||||||
|
icon: <Bell className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "notice.banner.enabled",
|
||||||
|
label: "启用横幅公告",
|
||||||
|
description: "是否在网站顶部显示横幅公告",
|
||||||
|
type: "switch",
|
||||||
|
value: data?.notice_maintenance?.banner?.enabled || false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notice.banner.text",
|
||||||
|
label: "公告内容",
|
||||||
|
description: "多语言公告文本",
|
||||||
|
type: "textarea",
|
||||||
|
rows: 6,
|
||||||
|
value: JSON.stringify(data?.notice_maintenance?.banner?.text || {
|
||||||
|
"zh-CN": "欢迎使用MMAP系统",
|
||||||
|
"en": "Welcome to MMAP System"
|
||||||
|
}, null, 2),
|
||||||
|
showWhen: (values) => values["notice.banner.enabled"] === true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maintenance-window",
|
||||||
|
title: "维护窗口",
|
||||||
|
description: "系统维护时间配置",
|
||||||
|
icon: <AlertTriangle className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "maintenance.window.enabled",
|
||||||
|
label: "启用维护模式",
|
||||||
|
description: "启用后系统将显示维护页面",
|
||||||
|
type: "switch",
|
||||||
|
value: data?.notice_maintenance?.maintenance_window?.enabled || false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maintenance.window.start_time",
|
||||||
|
label: "维护开始时间",
|
||||||
|
description: "维护窗口的开始时间",
|
||||||
|
type: "datetime-local",
|
||||||
|
value: (data?.notice_maintenance?.maintenance_window?.start_time ? new Date(data.notice_maintenance.maintenance_window.start_time).toISOString().slice(0, 16) : ""),
|
||||||
|
showWhen: (values) => values["maintenance.window.enabled"] === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maintenance.window.end_time",
|
||||||
|
label: "维护结束时间",
|
||||||
|
description: "维护窗口的结束时间",
|
||||||
|
type: "datetime-local",
|
||||||
|
value: (data?.notice_maintenance?.maintenance_window?.end_time ? new Date(data.notice_maintenance.maintenance_window.end_time).toISOString().slice(0, 16) : ""),
|
||||||
|
showWhen: (values) => values["maintenance.window.enabled"] === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maintenance.window.message",
|
||||||
|
label: "维护提示信息",
|
||||||
|
description: "维护期间显示给用户的多语言提示信息",
|
||||||
|
type: "textarea",
|
||||||
|
rows: 6,
|
||||||
|
value: JSON.stringify(data?.notice_maintenance?.maintenance_window?.message || {
|
||||||
|
"zh-CN": "系统维护中,请稍后再试",
|
||||||
|
"en": "System maintenance in progress"
|
||||||
|
}, null, 2),
|
||||||
|
showWhen: (values) => values["maintenance.window.enabled"] === true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// 运营配置
|
||||||
|
{
|
||||||
|
id: "operations",
|
||||||
|
title: "运营配置",
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "feature-switches",
|
||||||
|
title: "功能开关",
|
||||||
|
description: "控制各项功能的启用状态",
|
||||||
|
icon: <Shield className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "ops.features.registration_enabled",
|
||||||
|
label: "开放注册",
|
||||||
|
description: "是否允许新用户注册",
|
||||||
|
type: "switch",
|
||||||
|
value: data?.ops?.features?.registration_enabled ?? true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ops.features.invite_code_required",
|
||||||
|
label: "需要邀请码",
|
||||||
|
description: "注册时是否需要邀请码",
|
||||||
|
type: "switch",
|
||||||
|
value: data?.ops?.features?.invite_code_required ?? false,
|
||||||
|
showWhen: (values) => values["ops.features.registration_enabled"] === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ops.features.email_verification",
|
||||||
|
label: "邮箱验证",
|
||||||
|
description: "注册后是否需要验证邮箱",
|
||||||
|
type: "switch",
|
||||||
|
value: data?.ops?.features?.email_verification ?? false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "limits-config",
|
||||||
|
title: "限制配置",
|
||||||
|
description: "系统资源和使用限制",
|
||||||
|
icon: <Database className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "ops.limits.max_users",
|
||||||
|
label: "最大用户数",
|
||||||
|
description: "系统允许的最大用户数量",
|
||||||
|
type: "number",
|
||||||
|
value: data?.ops?.limits?.max_users || 1000,
|
||||||
|
validation: {
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 100000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ops.limits.max_invite_codes_per_user",
|
||||||
|
label: "用户最大邀请码数",
|
||||||
|
description: "每个用户最多可以生成的邀请码数量",
|
||||||
|
type: "number",
|
||||||
|
value: data?.ops?.limits?.max_invite_codes_per_user || 10,
|
||||||
|
validation: {
|
||||||
|
required: true,
|
||||||
|
min: 0,
|
||||||
|
max: 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ops.limits.session_timeout_hours",
|
||||||
|
label: "会话超时时间(小时)",
|
||||||
|
description: "用户会话的超时时间",
|
||||||
|
type: "number",
|
||||||
|
value: data?.ops?.limits?.session_timeout_hours || 24,
|
||||||
|
validation: {
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 720
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notifications-config",
|
||||||
|
title: "通知配置",
|
||||||
|
description: "系统通知和提醒设置",
|
||||||
|
icon: <Mail className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "ops.notifications.welcome_email",
|
||||||
|
label: "发送欢迎邮件",
|
||||||
|
description: "新用户注册后是否发送欢迎邮件",
|
||||||
|
type: "switch",
|
||||||
|
value: data?.ops?.notifications?.welcome_email ?? true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ops.notifications.system_announcements",
|
||||||
|
label: "系统公告通知",
|
||||||
|
description: "是否发送系统公告通知",
|
||||||
|
type: "switch",
|
||||||
|
value: data?.ops?.notifications?.system_announcements ?? true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ops.notifications.maintenance_alerts",
|
||||||
|
label: "维护提醒",
|
||||||
|
description: "系统维护前是否发送提醒通知",
|
||||||
|
type: "switch",
|
||||||
|
value: data?.ops?.notifications?.maintenance_alerts ?? true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// 支持文档
|
||||||
|
{
|
||||||
|
id: "support",
|
||||||
|
title: "支持文档",
|
||||||
|
icon: <FileText className="h-4 w-4" />,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "support-channels",
|
||||||
|
title: "支持渠道",
|
||||||
|
description: "用户支持和服务渠道配置",
|
||||||
|
icon: <MessageSquare className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "support.channels.email",
|
||||||
|
label: "支持邮箱",
|
||||||
|
description: "用户联系支持的邮箱地址",
|
||||||
|
type: "email",
|
||||||
|
value: data?.docs_support?.channels?.email || "support@mapp.com",
|
||||||
|
validation: {
|
||||||
|
required: true,
|
||||||
|
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "support.channels.ticket_system",
|
||||||
|
label: "工单系统地址",
|
||||||
|
description: "用户提交工单的系统地址",
|
||||||
|
type: "input",
|
||||||
|
value: data?.docs_support?.channels?.ticket_system || "/support/tickets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "support.channels.working_hours",
|
||||||
|
label: "工作时间",
|
||||||
|
description: "多语言的工作时间说明",
|
||||||
|
type: "textarea",
|
||||||
|
rows: 6,
|
||||||
|
value: JSON.stringify(data?.docs_support?.channels?.working_hours || {
|
||||||
|
"zh-CN": "周一至周五 9:00-18:00",
|
||||||
|
"en": "Mon-Fri 9:00-18:00"
|
||||||
|
}, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// 配置选项
|
||||||
|
autoSave: false, // 禁用自动保存,使用手动保存
|
||||||
|
validateOnChange: true,
|
||||||
|
onValidate: (values) => {
|
||||||
|
const result = commonConfigSchema.safeParse(values);
|
||||||
|
return zodErrorsToAdminErrors(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,18 +1,283 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react"
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { ApolloProvider } from '@apollo/client';
|
||||||
|
import { AdminPanel } from "@/components/admin";
|
||||||
|
import { createDynamicAdminConfig } from "./dynamic-admin-config";
|
||||||
|
import {
|
||||||
|
useConfigs,
|
||||||
|
useConfigUpdater,
|
||||||
|
useConfigValidation,
|
||||||
|
flattenConfigObject,
|
||||||
|
unflattenConfigObject
|
||||||
|
} from "@/hooks/use-site-config";
|
||||||
|
import { createApolloClient } from "@/lib/apollo-client";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2, AlertCircle, CheckCircle, RefreshCw } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { SiteHeader } from "../site-header"
|
// 配置管理页面内容组件
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
function AdminPageContent() {
|
||||||
import Control from "./control"
|
const { configs, loading: loadingConfigs, error: errorConfigs, refetch: refetchConfigs } = useConfigs();
|
||||||
|
const { validation, loading: validationLoading, refetch: refetchValidation } = useConfigValidation();
|
||||||
|
const { updateConfigs, updating } = useConfigUpdater();
|
||||||
|
|
||||||
export default function ControlPanel() {
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// 将 configs 列表转换为初始键值
|
||||||
|
const initialValuesFromConfigs = useMemo(() => {
|
||||||
|
const parseValue = (value: string | null | undefined, valueType: string) => {
|
||||||
|
if (value == null) return "";
|
||||||
|
const vt = (valueType || '').toLowerCase();
|
||||||
|
if (vt === 'number' || vt === 'int' || vt === 'integer') return Number(value);
|
||||||
|
if (vt === 'float' || vt === 'double') return parseFloat(value);
|
||||||
|
if (vt === 'bool' || vt === 'boolean') return value === 'true' || value === '1';
|
||||||
|
// 对于 json/object/array,保留原字符串,便于在 textarea 中编辑
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const entries = configs.map((item) => ({ key: item.key, value: parseValue(item.value ?? undefined, item.valueType) }));
|
||||||
|
return unflattenConfigObject(entries);
|
||||||
|
}, [configs]);
|
||||||
|
|
||||||
|
// 处理配置保存
|
||||||
|
const handleSave = async (values: Record<string, any>) => {
|
||||||
|
try {
|
||||||
|
console.log("保存数据:", values);
|
||||||
|
|
||||||
|
// 将表单值转换为配置更新格式
|
||||||
|
const configUpdates = flattenConfigObject(values);
|
||||||
|
|
||||||
|
const result = await updateConfigs(configUpdates);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setLastSaved(new Date());
|
||||||
|
toast.success(`配置保存成功${result.failedKeys?.length ? `,但有 ${result.failedKeys.length} 项失败` : ''}`);
|
||||||
|
|
||||||
|
// 刷新配置数据
|
||||||
|
refetchConfigs();
|
||||||
|
refetchValidation();
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || '配置保存失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save config error:', error);
|
||||||
|
toast.error('配置保存失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理配置导出
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const exportData = {
|
||||||
|
config: initialValuesFromConfigs,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: '1.0'
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||||
|
type: 'application/json'
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `config-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast.success('配置导出成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export error:', error);
|
||||||
|
toast.error('配置导出失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理配置导入
|
||||||
|
const handleImport = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const importData = JSON.parse(text);
|
||||||
|
|
||||||
|
if (importData.config) {
|
||||||
|
const configUpdates = flattenConfigObject(importData.config);
|
||||||
|
const result = await updateConfigs(configUpdates);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('配置导入成功');
|
||||||
|
refetchConfigs();
|
||||||
|
refetchValidation();
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || '配置导入失败');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('无效的配置文件格式');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Import error:', error);
|
||||||
|
toast.error('配置导入失败,请检查文件格式');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 权限检查函数
|
||||||
|
const hasPermission = (permission: string) => {
|
||||||
|
// 这里应该实现实际的权限检查逻辑
|
||||||
|
const userPermissions = ["admin", "settings.read", "settings.write"];
|
||||||
|
return userPermissions.includes(permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingConfigs) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
<span>加载配置数据中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorConfigs) {
|
||||||
|
return (
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
配置加载失败
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{(errorConfigs?.message) || '无法加载配置数据,请检查网络连接或联系管理员'}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => { refetchConfigs(); }}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建动态配置
|
||||||
|
const adminConfig = createDynamicAdminConfig(
|
||||||
|
undefined,
|
||||||
|
handleSave,
|
||||||
|
handleExport,
|
||||||
|
handleImport,
|
||||||
|
configs
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<SiteHeader breadcrumbs={[{ label: "Home", href: "/" }, { label: "Settings", href: "/admin/common" }]} />
|
{/* 状态信息栏 */}
|
||||||
<Control />
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* 保存状态 */}
|
||||||
|
{lastSaved && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
最后保存: {lastSaved.toLocaleTimeString()}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 更新状态 */}
|
||||||
|
{updating && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
保存中...
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
|
{/* 验证状态 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{validationLoading ? (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
验证中...
|
||||||
|
</Badge>
|
||||||
|
) : validation ? (
|
||||||
|
<Badge
|
||||||
|
variant={validation.valid ? "default" : "destructive"}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{validation.valid ? (
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{validation.valid ? '配置有效' : `${validation.errors.length} 个错误`}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 验证警告和错误 */}
|
||||||
|
{validation && !validation.valid && (
|
||||||
|
<Card className="border-destructive bg-destructive/5">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-destructive text-sm">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
配置验证失败
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<ul className="list-disc list-inside text-sm space-y-1">
|
||||||
|
{validation.errors.map((error, index) => (
|
||||||
|
<li key={index}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 验证警告 */}
|
||||||
|
{validation && validation.warnings.length > 0 && (
|
||||||
|
<Card className="border-yellow-500/50 bg-yellow-50/50">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-yellow-700 text-sm">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
配置警告
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<ul className="list-disc list-inside text-sm space-y-1">
|
||||||
|
{validation.warnings.map((warning, index) => (
|
||||||
|
<li key={index}>{warning}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 管理面板 */}
|
||||||
|
<AdminPanel
|
||||||
|
config={adminConfig}
|
||||||
|
initialValues={initialValuesFromConfigs}
|
||||||
|
onSubmit={handleSave}
|
||||||
|
hasPermission={hasPermission}
|
||||||
|
className="min-h-screen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主页面组件(带Apollo Provider)
|
||||||
|
export default function AdminDemoPage() {
|
||||||
|
const apolloClient = createApolloClient();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApolloProvider client={apolloClient}>
|
||||||
|
<AdminPageContent />
|
||||||
|
</ApolloProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@ -5,7 +5,7 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
import { cookies } from "next/headers"
|
import { cookies } from "next/headers"
|
||||||
import { fetchCategories } from "@/lib/admin-fetchers"
|
// import { fetchCategories } from "@/lib/admin-fetchers"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -25,13 +25,13 @@ import {
|
|||||||
|
|
||||||
export async function NavDocuments() {
|
export async function NavDocuments() {
|
||||||
const jwt = (await cookies()).get('jwt')?.value;
|
const jwt = (await cookies()).get('jwt')?.value;
|
||||||
const categoriesData = await fetchCategories(jwt);
|
// const categoriesData = await fetchCategories(jwt);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||||
<SidebarGroupLabel>Categories</SidebarGroupLabel>
|
<SidebarGroupLabel>Categories</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{categoriesData?.settingCategories?.filter((item) => item.page).map((item) => (
|
{/* {categoriesData?.settingCategories?.filter((item) => item.page).map((item) => (
|
||||||
<SidebarMenuItem key={item.page.slug}>
|
<SidebarMenuItem key={item.page.slug}>
|
||||||
<SidebarMenuButton asChild>
|
<SidebarMenuButton asChild>
|
||||||
<a href={`/admin/${item.page.slug}`}>
|
<a href={`/admin/${item.page.slug}`}>
|
||||||
@ -70,7 +70,7 @@ export async function NavDocuments() {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))} */}
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||||
<IconDots className="text-sidebar-foreground/70" />
|
<IconDots className="text-sidebar-foreground/70" />
|
||||||
|
|||||||
@ -14,7 +14,16 @@ export default function Dashboard() {
|
|||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
console.log(user?.role)
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
router.push("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user?.permissionPairs?.some(pair => pair.resource === "admin" && pair.action === "write")) {
|
||||||
|
router.push("/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -120,12 +120,15 @@ import CreateUserForm from "./create-user-form";
|
|||||||
import { useUser } from "@/app/user-context";
|
import { useUser } from "@/app/user-context";
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schema = z.object({
|
||||||
|
user: z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
role: z.string(),
|
groups: z.array(z.string()),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
|
}),
|
||||||
|
groups: z.array(z.string()),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create a separate component for the drag handle
|
// Create a separate component for the drag handle
|
||||||
@ -152,7 +155,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||||||
{
|
{
|
||||||
id: "drag",
|
id: "drag",
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) => <DragHandle id={row.original.id} />,
|
cell: ({ row }) => <DragHandle id={row.original.user.id} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
@ -194,18 +197,18 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="w-48">
|
<div className="w-48">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{row.original.email}
|
{row.original.user.email}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "role",
|
accessorKey: "groups",
|
||||||
header: "Role",
|
header: "Groups",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="w-32">
|
<div className="w-32">
|
||||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||||
{row.original.role}
|
{row.original.groups.join(", ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -228,7 +231,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const date = new Date(row.original.createdAt);
|
const date = new Date(row.original.user.createdAt);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
@ -269,14 +272,14 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||||||
},
|
},
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
sortingFn: (rowA, rowB, columnId) => {
|
sortingFn: (rowA, rowB, columnId) => {
|
||||||
const dateA = new Date(rowA.original.createdAt);
|
const dateA = new Date(rowA.original.user.createdAt);
|
||||||
const dateB = new Date(rowB.original.createdAt);
|
const dateB = new Date(rowB.original.user.createdAt);
|
||||||
return dateA.getTime() - dateB.getTime();
|
return dateA.getTime() - dateB.getTime();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "lastLogin",
|
id: "lastLogin",
|
||||||
accessorFn: (row) => row.updatedAt,
|
accessorFn: (row) => row.user.updatedAt,
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@ -293,7 +296,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const date = new Date(row.original.updatedAt);
|
const date = new Date(row.original.user.updatedAt);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60));
|
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60));
|
||||||
|
|
||||||
@ -330,8 +333,8 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||||||
},
|
},
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
sortingFn: (rowA, rowB, columnId) => {
|
sortingFn: (rowA, rowB, columnId) => {
|
||||||
const dateA = new Date(rowA.original.updatedAt);
|
const dateA = new Date(rowA.original.user.updatedAt);
|
||||||
const dateB = new Date(rowB.original.updatedAt);
|
const dateB = new Date(rowB.original.user.updatedAt);
|
||||||
return dateA.getTime() - dateB.getTime();
|
return dateA.getTime() - dateB.getTime();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -363,7 +366,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||||||
|
|
||||||
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
|
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
|
||||||
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
||||||
id: row.original.id,
|
id: row.original.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -388,14 +391,16 @@ function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
|
|||||||
|
|
||||||
const GET_USERS = gql`
|
const GET_USERS = gql`
|
||||||
query GetUsers($offset: Int, $limit: Int, $sort_by: String, $sort_order: String, $filter: String) {
|
query GetUsers($offset: Int, $limit: Int, $sort_by: String, $sort_order: String, $filter: String) {
|
||||||
users(offset: $offset, limit: $limit, sortBy: $sort_by, sortOrder: $sort_order, filter: $filter) {
|
userWithGroups(offset: $offset, limit: $limit, sortBy: $sort_by, sortOrder: $sort_order, filter: $filter) {
|
||||||
|
user{
|
||||||
id
|
id
|
||||||
username
|
username
|
||||||
email
|
email
|
||||||
role
|
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
}
|
}
|
||||||
|
groups
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
@ -407,13 +412,15 @@ const USERS_INFO = gql`
|
|||||||
totalAdminUsers
|
totalAdminUsers
|
||||||
totalUserUsers
|
totalUserUsers
|
||||||
users {
|
users {
|
||||||
|
user {
|
||||||
id
|
id
|
||||||
username
|
username
|
||||||
email
|
email
|
||||||
role
|
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
}
|
}
|
||||||
|
groups
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
@ -421,10 +428,8 @@ const USERS_INFO = gql`
|
|||||||
export function UserTable() {
|
export function UserTable() {
|
||||||
|
|
||||||
const { data, loading, error, refetch } = useQuery(USERS_INFO)
|
const { data, loading, error, refetch } = useQuery(USERS_INFO)
|
||||||
|
|
||||||
const [localData, setLocalData] = React.useState<any[]>([])
|
const [localData, setLocalData] = React.useState<any[]>([])
|
||||||
|
|
||||||
// 同步外部数据到本地状态
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (data && Array.isArray(data.usersInfo.users)) {
|
if (data && Array.isArray(data.usersInfo.users)) {
|
||||||
setLocalData(data.usersInfo.users)
|
setLocalData(data.usersInfo.users)
|
||||||
@ -599,11 +604,12 @@ function UserDataTable({
|
|||||||
fetchPolicy: 'cache-and-network'
|
fetchPolicy: 'cache-and-network'
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = useInitialData ? propData : queryData?.users
|
const data = useInitialData ? propData : queryData?.userWithGroups
|
||||||
const isLoading = useInitialData ? propIsLoading : queryLoading
|
const isLoading = useInitialData ? propIsLoading : queryLoading
|
||||||
|
|
||||||
// 同步数据到本地状态
|
// 同步数据到本地状态
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
debugger
|
||||||
if (data && Array.isArray(data)) {
|
if (data && Array.isArray(data)) {
|
||||||
setLocalData(data)
|
setLocalData(data)
|
||||||
}
|
}
|
||||||
@ -662,7 +668,7 @@ function UserDataTable({
|
|||||||
columnFilters,
|
columnFilters,
|
||||||
pagination,
|
pagination,
|
||||||
},
|
},
|
||||||
getRowId: (row) => row.id.toString(),
|
getRowId: (row) => row.user.id.toString(),
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
@ -886,10 +892,10 @@ function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
|||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="link" className="text-foreground w-fit px-0 text-left">
|
<Button variant="link" className="text-foreground w-fit px-0 text-left">
|
||||||
{item.username}
|
{item.user.username}
|
||||||
</Button>
|
</Button>
|
||||||
{
|
{
|
||||||
item.id === user?.id ? (
|
item.user.id === user?.id ? (
|
||||||
<Badge variant="secondary" className="text-[10px] w-fit">
|
<Badge variant="secondary" className="text-[10px] w-fit">
|
||||||
Me
|
Me
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -901,7 +907,7 @@ function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
|||||||
|
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<DrawerHeader className="gap-1">
|
<DrawerHeader className="gap-1">
|
||||||
<DrawerTitle>{item.username}</DrawerTitle>
|
<DrawerTitle>{item.user.username}</DrawerTitle>
|
||||||
<DrawerDescription>
|
<DrawerDescription>
|
||||||
User profile and activity information
|
User profile and activity information
|
||||||
</DrawerDescription>
|
</DrawerDescription>
|
||||||
@ -966,16 +972,16 @@ function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
|||||||
<form className="flex flex-col gap-4">
|
<form className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">Name</Label>
|
||||||
<Input id="name" defaultValue={item.username} />
|
<Input id="name" defaultValue={item.user.username} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input id="email" defaultValue={item.email} />
|
<Input id="email" defaultValue={item.user.email} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Label htmlFor="role">Role</Label>
|
<Label htmlFor="role">Roles</Label>
|
||||||
<Select defaultValue={item.role}>
|
<Select defaultValue={item.user.username}>
|
||||||
<SelectTrigger id="role" className="w-full">
|
<SelectTrigger id="role" className="w-full">
|
||||||
<SelectValue placeholder="Select a role" />
|
<SelectValue placeholder="Select a role" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@ -15,7 +15,15 @@ const GET_USER_QUERY = gql`
|
|||||||
id
|
id
|
||||||
username
|
username
|
||||||
email
|
email
|
||||||
role
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const GET_PERMISSION_PAIRS = gql`
|
||||||
|
query GetPermissionPairs {
|
||||||
|
getUserPermissions {
|
||||||
|
resource
|
||||||
|
action
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
@ -32,8 +40,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: any = await client.request(GET_USER_QUERY);
|
await client.request(GET_USER_QUERY);
|
||||||
const res = NextResponse.json({ ok: true, token: jwt })
|
const permissionPairs = await client.request(GET_PERMISSION_PAIRS);
|
||||||
|
const res = NextResponse.json({ ok: true, token: jwt, permissionPairs })
|
||||||
|
|
||||||
res.cookies.set('jwt', jwt, {
|
res.cookies.set('jwt', jwt, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
|||||||
23
app/api/site/route.ts
Normal file
23
app/api/site/route.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { gql, GraphQLClient } from 'graphql-request';
|
||||||
|
|
||||||
|
|
||||||
|
const GET_CONFIGS = gql`
|
||||||
|
query GetConfigs {
|
||||||
|
siteConfigs {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const client = new GraphQLClient(process.env.GRAPHQL_URL || 'http://localhost:3050/graphql');
|
||||||
|
try {
|
||||||
|
const data: any = await client.request(GET_CONFIGS);
|
||||||
|
return NextResponse.json(data.siteConfigs);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'No site configs found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
"use client"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Book, Command, Home, LucideIcon, Plus, User, Settings, Crown, LogOut } from "lucide-react"
|
import { Book, Command, Home, LucideIcon, Plus, User, Settings, Crown, LogOut } from "lucide-react"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
|
|||||||
@ -19,12 +19,15 @@ export const metadata: Metadata = {
|
|||||||
description: "LiDAR for Radar",
|
description: "LiDAR for Radar",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark" suppressHydrationWarning>
|
<html lang="en" className="dark" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
|
|||||||
@ -38,7 +38,6 @@ export function LoginForm({
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
debugger
|
|
||||||
await login(values);
|
await login(values);
|
||||||
// clearMap();
|
// clearMap();
|
||||||
router.push('/');
|
router.push('/');
|
||||||
|
|||||||
47
app/page.tsx
47
app/page.tsx
@ -1,34 +1,36 @@
|
|||||||
'use client'
|
import { Metadata, ResolvingMetadata } from 'next'
|
||||||
|
|
||||||
import { AppSidebar } from '@/app/app-sidebar'
|
import { AppSidebar } from '@/app/app-sidebar'
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
} from '@/components/ui/breadcrumb'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import {
|
|
||||||
SidebarInset,
|
|
||||||
SidebarProvider,
|
|
||||||
SidebarTrigger,
|
|
||||||
} from '@/components/ui/sidebar'
|
|
||||||
import { MapComponent } from '@/components/map-component';
|
import { MapComponent } from '@/components/map-component';
|
||||||
import { ThemeToggle } from '@/components/theme-toggle';
|
|
||||||
// import { Timeline } from '@/app/timeline';
|
|
||||||
import { Timeline } from '@/app/tl';
|
import { Timeline } from '@/app/tl';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { useMap } from './map-context'
|
|
||||||
import { format } from 'date-fns'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Navigation } from './nav'
|
|
||||||
import { WSProvider } from './ws-context'
|
import { WSProvider } from './ws-context'
|
||||||
import StatusBar from './status-bar'
|
import StatusBar from './status-bar'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSiteConfigs() {
|
||||||
|
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||||
|
const siteConfigs = await fetch(`${baseUrl}/api/site`);
|
||||||
|
const data = await siteConfigs.json();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(
|
||||||
|
{ params, searchParams }: Props,
|
||||||
|
parent: ResolvingMetadata
|
||||||
|
): Promise<Metadata> {
|
||||||
|
const siteConfigs = await getSiteConfigs();
|
||||||
|
return {
|
||||||
|
title: siteConfigs.find((config: any) => config.key === 'site.name')?.value ?? "LiDAR",
|
||||||
|
description: siteConfigs.find((config: any) => config.key === 'site.description')?.value ?? "LiDAR for Radar",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row h-full">
|
<div className="flex flex-row h-full">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
@ -43,7 +45,6 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</WSProvider>
|
</WSProvider>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
"use client"
|
||||||
import React, { useRef, useEffect, useState, useCallback } from "react";
|
import React, { useRef, useEffect, useState, useCallback } from "react";
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
import { Calendar } from "@/components/ui/calendar"
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -26,7 +26,6 @@ const GET_USER_QUERY = gql`
|
|||||||
id
|
id
|
||||||
username
|
username
|
||||||
email
|
email
|
||||||
role
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
@ -103,19 +102,7 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
if (token && isTokenValid(token)) {
|
if (token && isTokenValid(token)) {
|
||||||
const payload = parseJWT(token)
|
const payload = parseJWT(token)
|
||||||
if (payload) {
|
if (payload) {
|
||||||
const user: User = {
|
|
||||||
id: payload.sub,
|
|
||||||
email: payload.email,
|
|
||||||
name: payload.name,
|
|
||||||
role: payload.role
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthState({
|
|
||||||
user,
|
|
||||||
token,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await fetch('/api/session/sync', {
|
const res = await fetch('/api/session/sync', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -126,6 +113,22 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
throw new Error('Failed to sync session')
|
throw new Error('Failed to sync session')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { permissionPairs } = await res.json()
|
||||||
|
|
||||||
|
const user: User = {
|
||||||
|
id: payload.sub,
|
||||||
|
email: payload.email,
|
||||||
|
name: payload.name,
|
||||||
|
permissionPairs,
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthState({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false
|
||||||
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -175,11 +178,18 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
throw new Error('Failed to parse token')
|
throw new Error('Failed to parse token')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/session/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ jwt: token })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { permissionPairs } = await res.json()
|
||||||
|
|
||||||
const user: User = {
|
const user: User = {
|
||||||
id: payload.sub,
|
id: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
role: payload.role
|
permissionPairs,
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
@ -226,7 +236,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
id: payload.sub,
|
id: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
role: payload.role
|
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
@ -299,7 +308,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
id: payload.sub,
|
id: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
role: payload.role
|
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
@ -327,7 +335,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
email: userData.currentUser.email,
|
email: userData.currentUser.email,
|
||||||
name: userData.currentUser.username,
|
name: userData.currentUser.username,
|
||||||
avatar: userData.currentUser.avatar,
|
avatar: userData.currentUser.avatar,
|
||||||
role: userData.currentUser.role
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthState(prev => ({
|
setAuthState(prev => ({
|
||||||
|
|||||||
300
components/admin/admin-panel.tsx
Normal file
300
components/admin/admin-panel.tsx
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Loader2, AlertCircle, CheckCircle } from "lucide-react";
|
||||||
|
import { AdminPanelConfig, TabConfig } from "@/types/admin-panel";
|
||||||
|
import { useAdminPanel } from "@/hooks/use-admin-panel";
|
||||||
|
import { AdminSection } from "./admin-section";
|
||||||
|
import { SiteHeader } from "@/app/admin/site-header";
|
||||||
|
|
||||||
|
interface AdminPanelProps {
|
||||||
|
config: AdminPanelConfig;
|
||||||
|
initialValues?: Record<string, any>;
|
||||||
|
onSubmit?: (values: Record<string, any>) => Promise<void>;
|
||||||
|
className?: string;
|
||||||
|
// Permission checker function
|
||||||
|
hasPermission?: (permission: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPanel({
|
||||||
|
config,
|
||||||
|
initialValues,
|
||||||
|
onSubmit,
|
||||||
|
className,
|
||||||
|
hasPermission = () => true
|
||||||
|
}: AdminPanelProps) {
|
||||||
|
const [activeTab, setActiveTab] = React.useState(() => {
|
||||||
|
// Find first accessible tab
|
||||||
|
const firstAccessibleTab = config.tabs.find(tab =>
|
||||||
|
!tab.disabled && (!tab.permissions || tab.permissions.some(p => hasPermission(p)))
|
||||||
|
);
|
||||||
|
return firstAccessibleTab?.id || config.tabs[0]?.id || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const { state, actions, helpers } = useAdminPanel({
|
||||||
|
config,
|
||||||
|
initialValues,
|
||||||
|
onSubmit
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter tabs based on permissions
|
||||||
|
const visibleTabs = config.tabs.filter(tab =>
|
||||||
|
!tab.permissions || tab.permissions.some(p => hasPermission(p))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get current tab
|
||||||
|
const currentTab = visibleTabs.find(tab => tab.id === activeTab);
|
||||||
|
|
||||||
|
// Filter sections based on permissions
|
||||||
|
const getVisibleSections = (tab: TabConfig) => {
|
||||||
|
return tab.sections.filter(section =>
|
||||||
|
!section.permissions || section.permissions.some(p => hasPermission(p))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle save with loading state
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await actions.save();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
// You might want to show a toast notification here
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render header actions
|
||||||
|
const renderHeaderActions = () => {
|
||||||
|
const actions = config.header.actions || [];
|
||||||
|
|
||||||
|
return actions
|
||||||
|
.filter(action => !action.permissions || action.permissions.some(p => hasPermission(p)))
|
||||||
|
.map(action => (
|
||||||
|
<Button
|
||||||
|
key={action.id}
|
||||||
|
variant={action.variant || "default"}
|
||||||
|
size={action.size || "default"}
|
||||||
|
disabled={action.disabled || state.loading}
|
||||||
|
onClick={action.onClick}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{action.loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{action.icon}
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render breadcrumbs
|
||||||
|
const renderBreadcrumbs = () => {
|
||||||
|
if (!config.header.breadcrumbs) return null;
|
||||||
|
|
||||||
|
return <SiteHeader breadcrumbs={config.header.breadcrumbs.map(crumb => ({
|
||||||
|
label: crumb.label,
|
||||||
|
href: crumb.href || ""
|
||||||
|
}))} />
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render status indicators
|
||||||
|
const renderStatusIndicators = () => {
|
||||||
|
const indicators = [];
|
||||||
|
|
||||||
|
// Loading indicator
|
||||||
|
if (state.loading) {
|
||||||
|
indicators.push(
|
||||||
|
<Badge key="loading" variant="outline" className="gap-1">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
加载中
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saving indicator
|
||||||
|
if (state.saving) {
|
||||||
|
indicators.push(
|
||||||
|
<Badge key="saving" variant="outline" className="gap-1">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
保存中
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dirty state indicator
|
||||||
|
if (helpers.isDirty() && !state.saving) {
|
||||||
|
indicators.push(
|
||||||
|
<Badge key="dirty" variant="destructive" className="gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
有未保存的更改
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid state indicator
|
||||||
|
if (!helpers.isDirty() && helpers.isValid()) {
|
||||||
|
indicators.push(
|
||||||
|
<Badge key="saved" variant="default" className="gap-1">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
已保存
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation errors indicator
|
||||||
|
if (Object.keys(state.errors).length > 0) {
|
||||||
|
indicators.push(
|
||||||
|
<Badge key="errors" variant="destructive" className="gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{Object.keys(state.errors).length} 个错误
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return indicators;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute stable grid columns class to avoid Tailwind purge of dynamic classes
|
||||||
|
const gridColsMap: Record<number, string> = {
|
||||||
|
1: "grid-cols-1",
|
||||||
|
2: "grid-cols-2",
|
||||||
|
3: "grid-cols-3",
|
||||||
|
4: "grid-cols-4",
|
||||||
|
5: "grid-cols-5",
|
||||||
|
6: "grid-cols-6",
|
||||||
|
};
|
||||||
|
const gridColsClass = gridColsMap[Math.min(visibleTabs.length, 6)] || "grid-cols-6";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("min-h-screen bg-background", className)}>
|
||||||
|
{renderBreadcrumbs()}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-8 px-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">
|
||||||
|
{config.header.title}
|
||||||
|
</h1>
|
||||||
|
{config.header.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{config.header.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Status indicators */}
|
||||||
|
{renderStatusIndicators()}
|
||||||
|
|
||||||
|
{/* Header actions */}
|
||||||
|
{renderHeaderActions()}
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
{onSubmit && (
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={state.saving || (!helpers.isDirty() && helpers.isValid())}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{state.saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
保存设置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container mx-auto px-6 py-6">
|
||||||
|
{config.theme?.layout === "sidebar" ? (
|
||||||
|
// Sidebar layout
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="w-64 space-y-2">
|
||||||
|
{visibleTabs.map((tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab.id}
|
||||||
|
variant={activeTab === tab.id ? "default" : "ghost"}
|
||||||
|
className="w-full justify-start gap-2"
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
disabled={tab.disabled}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.title}
|
||||||
|
{tab.badge && (
|
||||||
|
<Badge variant="secondary" className="ml-auto">
|
||||||
|
{tab.badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 space-y-6">
|
||||||
|
{currentTab && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{getVisibleSections(currentTab).map((section) => (
|
||||||
|
<AdminSection
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
values={state.values}
|
||||||
|
errors={state.errors}
|
||||||
|
disabled={state.loading}
|
||||||
|
onChange={actions.setValue}
|
||||||
|
onBlur={() => { }} // Could implement field-level validation
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Tabs layout (default)
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<TabsList className={cn(
|
||||||
|
"grid w-full gap-2",
|
||||||
|
gridColsClass
|
||||||
|
)}>
|
||||||
|
{visibleTabs.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.id}
|
||||||
|
value={tab.id}
|
||||||
|
disabled={tab.disabled}
|
||||||
|
className="flex items-center gap-2 text-xs lg:text-sm w-full justify-center"
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="hidden sm:inline truncate">{tab.title}</span>
|
||||||
|
{tab.badge && (
|
||||||
|
<Badge variant="secondary" className="ml-1 text-xs">
|
||||||
|
{tab.badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{visibleTabs.map((tab) => (
|
||||||
|
<TabsContent key={tab.id} value={tab.id} className="space-y-6">
|
||||||
|
{getVisibleSections(tab).map((section) => (
|
||||||
|
<AdminSection
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
values={state.values}
|
||||||
|
errors={state.errors}
|
||||||
|
disabled={state.loading}
|
||||||
|
onChange={actions.setValue}
|
||||||
|
onBlur={() => { }} // Could implement field-level validation
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
components/admin/admin-section.tsx
Normal file
149
components/admin/admin-section.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
import { SectionConfig, FieldConfig } from "@/types/admin-panel";
|
||||||
|
import { FieldRenderer } from "./field-renderer";
|
||||||
|
|
||||||
|
interface AdminSectionProps {
|
||||||
|
section: SectionConfig;
|
||||||
|
values: Record<string, any>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (fieldId: string, value: any) => void;
|
||||||
|
onBlur?: (fieldId: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSection({
|
||||||
|
section,
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
className
|
||||||
|
}: AdminSectionProps) {
|
||||||
|
|
||||||
|
|
||||||
|
// Filter fields based on conditional rendering
|
||||||
|
const visibleFields = section.fields.filter(field => {
|
||||||
|
if (field.showWhen) {
|
||||||
|
return field.showWhen(values);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get field value helper
|
||||||
|
const getFieldValue = (field: FieldConfig) => {
|
||||||
|
return values[field.id] ?? field.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render field with label and description
|
||||||
|
const renderFieldWithLabel = (field: FieldConfig) => {
|
||||||
|
const value = getFieldValue(field);
|
||||||
|
const error = errors[field.id];
|
||||||
|
const fieldDisabled = disabled || field.disabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={field.id}
|
||||||
|
className={cn(
|
||||||
|
"space-y-2",
|
||||||
|
field.grid?.span && `col-span-${field.grid.span}`,
|
||||||
|
field.grid?.offset && `col-start-${field.grid.offset + 1}`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label
|
||||||
|
htmlFor={field.id}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
field.validation?.required && "after:content-['*'] after:ml-0.5 after:text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
{field.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{field.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{field.type === "switch" && (
|
||||||
|
<FieldRenderer
|
||||||
|
field={field}
|
||||||
|
value={value}
|
||||||
|
error={error}
|
||||||
|
disabled={fieldDisabled}
|
||||||
|
onChange={(newValue) => onChange(field.id, newValue)}
|
||||||
|
onBlur={() => onBlur?.(field.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{field.type !== "switch" && (
|
||||||
|
<FieldRenderer
|
||||||
|
field={field}
|
||||||
|
value={value}
|
||||||
|
error={error}
|
||||||
|
disabled={fieldDisabled}
|
||||||
|
onChange={(newValue) => onChange(field.id, newValue)}
|
||||||
|
onBlur={() => onBlur?.(field.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use custom render function if provided
|
||||||
|
if (section.render) {
|
||||||
|
const children = (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid gap-6",
|
||||||
|
section.columns ? `grid-cols-${section.columns}` : "grid-cols-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{visibleFields.map(renderFieldWithLabel)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return section.render(section, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default card layout
|
||||||
|
const content = (
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid gap-6",
|
||||||
|
section.columns ? `grid-cols-${section.columns}` : "grid-cols-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{visibleFields.map(renderFieldWithLabel)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{section.icon}
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{section.title}</CardTitle>
|
||||||
|
{section.description && (
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
{section.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{content}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
components/admin/field-renderer.tsx
Normal file
300
components/admin/field-renderer.tsx
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { FieldConfig } from "@/types/admin-panel";
|
||||||
|
|
||||||
|
interface FieldRendererProps {
|
||||||
|
field: FieldConfig;
|
||||||
|
value: any;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (value: any) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldRenderer({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
error,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
className
|
||||||
|
}: FieldRendererProps) {
|
||||||
|
const isDisabled = disabled || field.disabled;
|
||||||
|
const isReadOnly = field.readOnly;
|
||||||
|
|
||||||
|
// Use custom render function if provided
|
||||||
|
if (field.render) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{field.render(field, value, onChange)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
id: field.id,
|
||||||
|
disabled: isDisabled,
|
||||||
|
readOnly: isReadOnly,
|
||||||
|
placeholder: field.placeholder,
|
||||||
|
onBlur,
|
||||||
|
className: cn(
|
||||||
|
error && "border-destructive focus-visible:ring-destructive",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderField = () => {
|
||||||
|
switch (field.type) {
|
||||||
|
case "input":
|
||||||
|
case "email":
|
||||||
|
case "url":
|
||||||
|
case "tel":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type={field.type === "input" ? "text" : field.type}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "password":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type="password"
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type="number"
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={field.step}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : "")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "textarea":
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
{...commonProps}
|
||||||
|
rows={field.rows || 3}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value?.toString() || ""}
|
||||||
|
onValueChange={(newValue) => {
|
||||||
|
// Convert back to number if the original value was a number
|
||||||
|
const option = field.options?.find(opt => opt.value?.toString() === newValue);
|
||||||
|
onChange(option ? option.value : newValue);
|
||||||
|
}}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={cn(
|
||||||
|
error && "border-destructive focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<SelectValue placeholder={field.placeholder || "请选择..."} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{field.options?.map((option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value.toString()}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div>{option.label}</div>
|
||||||
|
{option.description && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{option.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "switch":
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
id={field.id}
|
||||||
|
checked={Boolean(value)}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={cn(
|
||||||
|
error && "border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "checkbox":
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
id={field.id}
|
||||||
|
checked={Boolean(value)}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={cn(
|
||||||
|
error && "border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "radio":
|
||||||
|
return (
|
||||||
|
<RadioGroup
|
||||||
|
value={value?.toString() || ""}
|
||||||
|
onValueChange={(newValue) => {
|
||||||
|
const option = field.options?.find(opt => opt.value?.toString() === newValue);
|
||||||
|
onChange(option ? option.value : newValue);
|
||||||
|
}}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{field.options?.map((option) => (
|
||||||
|
<div key={option.value} className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={option.value.toString()}
|
||||||
|
id={`${field.id}-${option.value}`}
|
||||||
|
disabled={option.disabled}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`${field.id}-${option.value}`}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Label>
|
||||||
|
{option.description && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "slider":
|
||||||
|
case "range":
|
||||||
|
const sliderValue = Array.isArray(value) ? value : [value || field.min || 0];
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Slider
|
||||||
|
value={sliderValue}
|
||||||
|
onValueChange={(newValue) => onChange(newValue[0])}
|
||||||
|
min={field.min || 0}
|
||||||
|
max={field.max || 100}
|
||||||
|
step={field.step || 1}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={cn(
|
||||||
|
error && "accent-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{field.min || 0}</span>
|
||||||
|
<span className="font-medium">{sliderValue[0]}</span>
|
||||||
|
<span>{field.max || 100}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "date":
|
||||||
|
case "time":
|
||||||
|
case "datetime-local":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type={field.type}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "color":
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type="color"
|
||||||
|
value={value || "#000000"}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-12 h-10 p-1 rounded border cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="#000000"
|
||||||
|
className={cn(
|
||||||
|
"flex-1",
|
||||||
|
error && "border-destructive focus-visible:ring-destructive"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "file":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type="file"
|
||||||
|
accept={field.accept}
|
||||||
|
multiple={field.multiple}
|
||||||
|
onChange={(e) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
onChange(field.multiple ? Array.from(files || []) : files?.[0] || null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type="text"
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderField()}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm font-medium text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
components/admin/index.ts
Normal file
31
components/admin/index.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// Admin Panel Components
|
||||||
|
export { AdminPanel } from "./admin-panel";
|
||||||
|
export { AdminSection } from "./admin-section";
|
||||||
|
export { FieldRenderer } from "./field-renderer";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export { useAdminPanel } from "@/hooks/use-admin-panel";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
AdminPanelConfig,
|
||||||
|
TabConfig,
|
||||||
|
SectionConfig,
|
||||||
|
FieldConfig,
|
||||||
|
FieldType,
|
||||||
|
SelectOption,
|
||||||
|
ValidationRule,
|
||||||
|
ActionConfig,
|
||||||
|
HeaderConfig,
|
||||||
|
AdminPanelState,
|
||||||
|
UseAdminPanelOptions,
|
||||||
|
UseAdminPanelReturn,
|
||||||
|
AdminDataProvider,
|
||||||
|
PermissionChecker
|
||||||
|
} from "@/types/admin-panel";
|
||||||
|
|
||||||
|
// Configurations
|
||||||
|
export {
|
||||||
|
defaultAdminPanelConfig,
|
||||||
|
simpleAdminPanelConfig
|
||||||
|
} from "@/app/admin/common/admin-panel-config";
|
||||||
341
hooks/use-admin-panel.ts
Normal file
341
hooks/use-admin-panel.ts
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
AdminPanelConfig,
|
||||||
|
AdminPanelState,
|
||||||
|
UseAdminPanelOptions,
|
||||||
|
UseAdminPanelReturn,
|
||||||
|
FieldConfig,
|
||||||
|
ValidationRule
|
||||||
|
} from "@/types/admin-panel";
|
||||||
|
|
||||||
|
// Helper function to get nested value
|
||||||
|
function getNestedValue(obj: any, path: string): any {
|
||||||
|
return path.split('.').reduce((current, key) => current?.[key], obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to set nested value
|
||||||
|
function setNestedValue(obj: any, path: string, value: any): any {
|
||||||
|
const keys = path.split('.');
|
||||||
|
const result = { ...obj };
|
||||||
|
let current = result;
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
current[key] = current[key] ? { ...current[key] } : {};
|
||||||
|
current = current[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
current[keys[keys.length - 1]] = value;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to validate a field
|
||||||
|
function validateField(field: FieldConfig, value: any): string | null {
|
||||||
|
if (!field.validation) return null;
|
||||||
|
|
||||||
|
const { validation } = field;
|
||||||
|
|
||||||
|
// Required validation
|
||||||
|
if (validation.required && (value === undefined || value === null || value === '')) {
|
||||||
|
return `${field.label}是必填项`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip other validations if value is empty and not required
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Min/Max validation for numbers
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (validation.min !== undefined && value < validation.min) {
|
||||||
|
return `${field.label}不能小于${validation.min}`;
|
||||||
|
}
|
||||||
|
if (validation.max !== undefined && value > validation.max) {
|
||||||
|
return `${field.label}不能大于${validation.max}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length validation for strings
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (validation.minLength !== undefined && value.length < validation.minLength) {
|
||||||
|
return `${field.label}长度不能少于${validation.minLength}个字符`;
|
||||||
|
}
|
||||||
|
if (validation.maxLength !== undefined && value.length > validation.maxLength) {
|
||||||
|
return `${field.label}长度不能超过${validation.maxLength}个字符`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern validation
|
||||||
|
if (validation.pattern && typeof value === 'string' && !validation.pattern.test(value)) {
|
||||||
|
return `${field.label}格式不正确`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom validation
|
||||||
|
if (validation.custom) {
|
||||||
|
return validation.custom(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all fields from config
|
||||||
|
function getAllFields(config: AdminPanelConfig): FieldConfig[] {
|
||||||
|
return config.tabs.flatMap(tab =>
|
||||||
|
tab.sections.flatMap(section => section.fields)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelReturn {
|
||||||
|
const { config, initialValues = {}, onSubmit } = options;
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [state, setState] = useState<AdminPanelState>({
|
||||||
|
values: initialValues,
|
||||||
|
errors: {},
|
||||||
|
dirty: {},
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-save timer
|
||||||
|
const autoSaveTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const lastSavedValues = useRef<Record<string, any>>(initialValues);
|
||||||
|
|
||||||
|
// Calculate initial values with memoization to prevent unnecessary recalculations
|
||||||
|
const computedInitialValues = React.useMemo(() => {
|
||||||
|
const fields = getAllFields(config);
|
||||||
|
const values: Record<string, any> = { ...initialValues };
|
||||||
|
|
||||||
|
fields.forEach(field => {
|
||||||
|
if (getNestedValue(values, field.id) === undefined) {
|
||||||
|
setNestedValue(values, field.id, field.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}, [config, initialValues]);
|
||||||
|
|
||||||
|
// Initialize values only when computed initial values change
|
||||||
|
useEffect(() => {
|
||||||
|
setState(prev => {
|
||||||
|
// Only update if values are actually different to prevent loops
|
||||||
|
const currentJson = JSON.stringify(prev.values);
|
||||||
|
const newJson = JSON.stringify(computedInitialValues);
|
||||||
|
if (currentJson !== newJson) {
|
||||||
|
lastSavedValues.current = computedInitialValues;
|
||||||
|
return { ...prev, values: computedInitialValues };
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [computedInitialValues]); // Depend directly on memoized values
|
||||||
|
|
||||||
|
// Auto-save functionality
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config.autoSave || !onSubmit) return;
|
||||||
|
|
||||||
|
const delay = config.autoSaveDelay || 2000;
|
||||||
|
|
||||||
|
if (autoSaveTimer.current) {
|
||||||
|
clearTimeout(autoSaveTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if values have changed
|
||||||
|
const hasChanges = JSON.stringify(state.values) !== JSON.stringify(lastSavedValues.current);
|
||||||
|
|
||||||
|
if (hasChanges && Object.keys(state.dirty).length > 0) {
|
||||||
|
autoSaveTimer.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await onSubmit(state.values);
|
||||||
|
lastSavedValues.current = state.values;
|
||||||
|
setState(prev => ({ ...prev, dirty: {} }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-save failed:', error);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (autoSaveTimer.current) {
|
||||||
|
clearTimeout(autoSaveTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state.values, state.dirty, config.autoSave, config.autoSaveDelay, onSubmit]);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const setValue = useCallback((path: string, value: any) => {
|
||||||
|
setState(prev => {
|
||||||
|
const newValues = setNestedValue(prev.values, path, value);
|
||||||
|
const newDirty = { ...prev.dirty, [path]: true };
|
||||||
|
|
||||||
|
// Clear error for this field
|
||||||
|
const newErrors = { ...prev.errors };
|
||||||
|
delete newErrors[path];
|
||||||
|
|
||||||
|
// Validate on change if enabled
|
||||||
|
let validationErrors = newErrors;
|
||||||
|
if (config.validateOnChange) {
|
||||||
|
const fields = getAllFields(config);
|
||||||
|
const field = fields.find(f => f.id === path);
|
||||||
|
if (field) {
|
||||||
|
const error = validateField(field, value);
|
||||||
|
if (error) {
|
||||||
|
validationErrors = { ...validationErrors, [path]: error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call onChange callback
|
||||||
|
if (config.onValueChange) {
|
||||||
|
config.onValueChange(path, value, newValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
values: newValues,
|
||||||
|
dirty: newDirty,
|
||||||
|
errors: validationErrors,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const setValues = useCallback((values: Record<string, any>) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
values: { ...prev.values, ...values },
|
||||||
|
dirty: Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), prev.dirty),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetValue = useCallback((path: string) => {
|
||||||
|
const fields = getAllFields(config);
|
||||||
|
const field = fields.find(f => f.id === path);
|
||||||
|
if (field) {
|
||||||
|
setValue(path, field.value);
|
||||||
|
setState(prev => {
|
||||||
|
const newDirty = { ...prev.dirty };
|
||||||
|
delete newDirty[path];
|
||||||
|
const newErrors = { ...prev.errors };
|
||||||
|
delete newErrors[path];
|
||||||
|
return { ...prev, dirty: newDirty, errors: newErrors };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [config, setValue]);
|
||||||
|
|
||||||
|
const resetAll = useCallback(() => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
values: computedInitialValues,
|
||||||
|
dirty: {},
|
||||||
|
errors: {},
|
||||||
|
}));
|
||||||
|
if (config.onReset) {
|
||||||
|
config.onReset();
|
||||||
|
}
|
||||||
|
}, [computedInitialValues, config]);
|
||||||
|
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const fields = getAllFields(config);
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
fields.forEach(field => {
|
||||||
|
// Skip validation for disabled or readOnly fields
|
||||||
|
if (field.disabled || field.readOnly) return;
|
||||||
|
|
||||||
|
// Check conditional rendering
|
||||||
|
if (field.showWhen && !field.showWhen(state.values)) return;
|
||||||
|
|
||||||
|
const value = getNestedValue(state.values, field.id);
|
||||||
|
const error = validateField(field, value);
|
||||||
|
if (error) {
|
||||||
|
errors[field.id] = error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom validation
|
||||||
|
if (config.onValidate) {
|
||||||
|
const customErrors = config.onValidate(state.values);
|
||||||
|
Object.assign(errors, customErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, errors }));
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}, [config, state.values]);
|
||||||
|
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
if (!onSubmit) return;
|
||||||
|
|
||||||
|
// Validate if required
|
||||||
|
if (config.validateOnSubmit !== false) {
|
||||||
|
const isValid = validate();
|
||||||
|
if (!isValid) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, saving: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(state.values);
|
||||||
|
lastSavedValues.current = state.values;
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
saving: false,
|
||||||
|
dirty: {},
|
||||||
|
errors: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (config.onSave) {
|
||||||
|
await config.onSave(state.values);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setState(prev => ({ ...prev, saving: false }));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [config, state.values, onSubmit, validate]);
|
||||||
|
|
||||||
|
const clearErrors = useCallback(() => {
|
||||||
|
setState(prev => ({ ...prev, errors: {} }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
const getValue = useCallback((path: string) => {
|
||||||
|
return getNestedValue(state.values, path);
|
||||||
|
}, [state.values]);
|
||||||
|
|
||||||
|
const getError = useCallback((path: string) => {
|
||||||
|
return state.errors[path];
|
||||||
|
}, [state.errors]);
|
||||||
|
|
||||||
|
const isDirty = useCallback((path?: string) => {
|
||||||
|
if (path) {
|
||||||
|
return Boolean(state.dirty[path]);
|
||||||
|
}
|
||||||
|
return Object.keys(state.dirty).length > 0;
|
||||||
|
}, [state.dirty]);
|
||||||
|
|
||||||
|
const isValid = useCallback((path?: string) => {
|
||||||
|
if (path) {
|
||||||
|
return !state.errors[path];
|
||||||
|
}
|
||||||
|
return Object.keys(state.errors).length === 0;
|
||||||
|
}, [state.errors]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
actions: {
|
||||||
|
setValue,
|
||||||
|
setValues,
|
||||||
|
resetValue,
|
||||||
|
resetAll,
|
||||||
|
save,
|
||||||
|
validate,
|
||||||
|
clearErrors,
|
||||||
|
},
|
||||||
|
helpers: {
|
||||||
|
getValue,
|
||||||
|
getError,
|
||||||
|
isDirty,
|
||||||
|
isValid,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
254
hooks/use-site-config.ts
Normal file
254
hooks/use-site-config.ts
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation } from '@apollo/client';
|
||||||
|
import {
|
||||||
|
GET_SITE_OPS_CONFIG,
|
||||||
|
GET_CONFIGS,
|
||||||
|
GET_SITE_CONFIG,
|
||||||
|
GET_NOTICE_MAINTENANCE_CONFIG,
|
||||||
|
GET_DOCS_SUPPORT_CONFIG,
|
||||||
|
GET_OPS_CONFIG,
|
||||||
|
VALIDATE_CONFIG,
|
||||||
|
UPDATE_SETTING,
|
||||||
|
UPDATE_SETTINGS
|
||||||
|
} from '@/lib/config-queries';
|
||||||
|
import {
|
||||||
|
SiteOpsConfigType,
|
||||||
|
SiteConfigType,
|
||||||
|
NoticeMaintenanceType,
|
||||||
|
DocsSupportType,
|
||||||
|
OpsConfigType,
|
||||||
|
ConfigValidationResultType,
|
||||||
|
ConfigUpdateInput,
|
||||||
|
ConfigUpdateResult
|
||||||
|
} from '@/types/site-config';
|
||||||
|
|
||||||
|
// 使用完整的站点运营配置
|
||||||
|
export function useSiteOpsConfig() {
|
||||||
|
const { data, loading, error, refetch } = useQuery<{ siteOpsConfig: SiteOpsConfigType }>(
|
||||||
|
GET_SITE_OPS_CONFIG,
|
||||||
|
{
|
||||||
|
errorPolicy: 'all',
|
||||||
|
notifyOnNetworkStatusChange: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: data?.siteOpsConfig,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用通用配置列表(KV)
|
||||||
|
export interface ConfigItemType {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
value?: string | null;
|
||||||
|
valueType: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfigs() {
|
||||||
|
const { data, loading, error, refetch } = useQuery<{ configs: ConfigItemType[] }>(
|
||||||
|
GET_CONFIGS,
|
||||||
|
{
|
||||||
|
errorPolicy: 'all',
|
||||||
|
notifyOnNetworkStatusChange: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
configs: data?.configs || [],
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用站点配置
|
||||||
|
export function useSiteConfig() {
|
||||||
|
const { data, loading, error, refetch } = useQuery<{ siteConfig: SiteConfigType }>(
|
||||||
|
GET_SITE_CONFIG,
|
||||||
|
{
|
||||||
|
errorPolicy: 'all'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: data?.siteConfig,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用公告维护配置
|
||||||
|
export function useNoticeMaintenanceConfig() {
|
||||||
|
const { data, loading, error, refetch } = useQuery<{ noticeMaintenanceConfig: NoticeMaintenanceType }>(
|
||||||
|
GET_NOTICE_MAINTENANCE_CONFIG,
|
||||||
|
{
|
||||||
|
errorPolicy: 'all'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: data?.noticeMaintenanceConfig,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用文档支持配置
|
||||||
|
export function useDocsSupportConfig() {
|
||||||
|
const { data, loading, error, refetch } = useQuery<{ docsSupportConfig: DocsSupportType }>(
|
||||||
|
GET_DOCS_SUPPORT_CONFIG,
|
||||||
|
{
|
||||||
|
errorPolicy: 'all'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: data?.docsSupportConfig,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用运营配置
|
||||||
|
export function useOpsConfig() {
|
||||||
|
const { data, loading, error, refetch } = useQuery<{ opsConfig: OpsConfigType }>(
|
||||||
|
GET_OPS_CONFIG,
|
||||||
|
{
|
||||||
|
errorPolicy: 'all'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: data?.opsConfig,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用配置验证
|
||||||
|
export function useConfigValidation() {
|
||||||
|
const { data, loading, error, refetch } = useQuery<{ validateConfig: ConfigValidationResultType }>(
|
||||||
|
VALIDATE_CONFIG,
|
||||||
|
{
|
||||||
|
errorPolicy: 'all'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validation: data?.validateConfig,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置更新hook
|
||||||
|
export function useConfigUpdater() {
|
||||||
|
const [updateSetting] = useMutation<
|
||||||
|
{ updateSetting: ConfigUpdateResult },
|
||||||
|
{ key: string; value: string }
|
||||||
|
>(UPDATE_SETTING);
|
||||||
|
|
||||||
|
const [updateSettings] = useMutation<
|
||||||
|
{ updateSettings: ConfigUpdateResult & { failedKeys?: string[] } },
|
||||||
|
{ settings: ConfigUpdateInput[] }
|
||||||
|
>(UPDATE_SETTINGS);
|
||||||
|
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
|
||||||
|
// 更新单个配置
|
||||||
|
const updateConfig = useCallback(async (key: string, value: any): Promise<ConfigUpdateResult> => {
|
||||||
|
setUpdating(true);
|
||||||
|
try {
|
||||||
|
const valueStr = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||||
|
const result = await updateSetting({
|
||||||
|
variables: { key, value: valueStr }
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.data?.updateSetting || { success: false, message: '更新失败' };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update config error:', error);
|
||||||
|
return { success: false, message: error instanceof Error ? error.message : '更新失败' };
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
}, [updateSetting]);
|
||||||
|
|
||||||
|
// 批量更新配置
|
||||||
|
const updateConfigs = useCallback(async (configs: ConfigUpdateInput[]): Promise<ConfigUpdateResult & { failedKeys?: string[] }> => {
|
||||||
|
setUpdating(true);
|
||||||
|
try {
|
||||||
|
const result = await updateSettings({
|
||||||
|
variables: { settings: configs }
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.data?.updateSettings || { success: false, message: '批量更新失败' };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update configs error:', error);
|
||||||
|
return { success: false, message: error instanceof Error ? error.message : '批量更新失败' };
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
}, [updateSettings]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateConfig,
|
||||||
|
updateConfigs,
|
||||||
|
updating
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将嵌套配置对象转换为平铺的键值对
|
||||||
|
export function flattenConfigObject(obj: any, prefix = ''): ConfigUpdateInput[] {
|
||||||
|
const result: ConfigUpdateInput[] = [];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
||||||
|
// 递归处理嵌套对象
|
||||||
|
result.push(...flattenConfigObject(value, fullKey));
|
||||||
|
} else {
|
||||||
|
// 处理基本类型、数组和日期
|
||||||
|
result.push({
|
||||||
|
key: fullKey,
|
||||||
|
value: value as string | number | boolean | object
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从平铺的配置重建嵌套对象
|
||||||
|
export function unflattenConfigObject(configs: { key: string; value: any }[]): any {
|
||||||
|
const result: any = {};
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const keys = config.key.split('.');
|
||||||
|
let current = result;
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
if (!(key in current)) {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
current = current[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
current[keys[keys.length - 1]] = config.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@ -1,17 +1,17 @@
|
|||||||
import { gql, GraphQLClient } from "graphql-request";
|
import { gql, GraphQLClient } from "graphql-request";
|
||||||
import { getBaseUrl } from "./gr-client";
|
import { getBaseUrl } from "./gr-client";
|
||||||
|
|
||||||
const CategoriesQuery = gql`
|
// const CategoriesQuery = gql`
|
||||||
query Categories {
|
// query Categories {
|
||||||
settingCategories {
|
// settingCategories {
|
||||||
page {
|
// page {
|
||||||
id
|
// id
|
||||||
title
|
// title
|
||||||
slug
|
// slug
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
`;
|
// `;
|
||||||
|
|
||||||
export type CategoryPage = {
|
export type CategoryPage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -27,23 +27,23 @@ export type CategoriesData = {
|
|||||||
settingCategories: SettingCategory[];
|
settingCategories: SettingCategory[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchCategories(jwt?: string): Promise<CategoriesData | null> {
|
// export async function fetchCategories(jwt?: string): Promise<CategoriesData | null> {
|
||||||
const client = new GraphQLClient(getBaseUrl());
|
// const client = new GraphQLClient(getBaseUrl());
|
||||||
|
|
||||||
if (jwt) {
|
// if (jwt) {
|
||||||
client.setHeader('Authorization', `Bearer ${jwt}`);
|
// client.setHeader('Authorization', `Bearer ${jwt}`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
try {
|
// try {
|
||||||
const response: any = await client.request(CategoriesQuery);
|
// const response: any = await client.request(CategoriesQuery);
|
||||||
|
|
||||||
if (response?.settingCategories) {
|
// if (response?.settingCategories) {
|
||||||
return response;
|
// return response;
|
||||||
}
|
// }
|
||||||
|
|
||||||
return null;
|
// return null;
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
console.error('Failed to fetch categories:', error);
|
// console.error('Failed to fetch categories:', error);
|
||||||
return null;
|
// return null;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
256
lib/config-queries.ts
Normal file
256
lib/config-queries.ts
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
// 获取完整的站点和运营配置
|
||||||
|
export const GET_CONFIGS = gql`
|
||||||
|
query GetConfigs {
|
||||||
|
configs {
|
||||||
|
id
|
||||||
|
key
|
||||||
|
value
|
||||||
|
valueType
|
||||||
|
description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 获取完整的站点和运营配置
|
||||||
|
export const GET_SITE_OPS_CONFIG = gql`
|
||||||
|
query GetSiteOpsConfig {
|
||||||
|
siteOpsConfig {
|
||||||
|
site {
|
||||||
|
info {
|
||||||
|
name
|
||||||
|
localeDefault
|
||||||
|
localesSupported
|
||||||
|
}
|
||||||
|
brand {
|
||||||
|
logoUrl
|
||||||
|
primaryColor
|
||||||
|
darkModeDefault
|
||||||
|
}
|
||||||
|
footerLinks {
|
||||||
|
name
|
||||||
|
url
|
||||||
|
visibleToGuest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
noticeMaintenance {
|
||||||
|
banner {
|
||||||
|
enabled
|
||||||
|
text
|
||||||
|
}
|
||||||
|
maintenanceWindow {
|
||||||
|
enabled
|
||||||
|
startTime
|
||||||
|
endTime
|
||||||
|
message
|
||||||
|
}
|
||||||
|
modalAnnouncements {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
content
|
||||||
|
startTime
|
||||||
|
endTime
|
||||||
|
audience
|
||||||
|
priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
docsSupport {
|
||||||
|
links {
|
||||||
|
name
|
||||||
|
url
|
||||||
|
description
|
||||||
|
}
|
||||||
|
channels {
|
||||||
|
email
|
||||||
|
ticketSystem
|
||||||
|
chatGroups {
|
||||||
|
name
|
||||||
|
url
|
||||||
|
qrCode
|
||||||
|
description
|
||||||
|
}
|
||||||
|
workingHours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ops {
|
||||||
|
features {
|
||||||
|
registrationEnabled
|
||||||
|
inviteCodeRequired
|
||||||
|
emailVerification
|
||||||
|
}
|
||||||
|
limits {
|
||||||
|
maxUsers
|
||||||
|
maxInviteCodesPerUser
|
||||||
|
sessionTimeoutHours
|
||||||
|
}
|
||||||
|
notifications {
|
||||||
|
welcomeEmail
|
||||||
|
systemAnnouncements
|
||||||
|
maintenanceAlerts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 获取站点配置
|
||||||
|
export const GET_SITE_CONFIG = gql`
|
||||||
|
query GetSiteConfig {
|
||||||
|
siteConfig {
|
||||||
|
info {
|
||||||
|
name
|
||||||
|
localeDefault
|
||||||
|
localesSupported
|
||||||
|
}
|
||||||
|
brand {
|
||||||
|
logoUrl
|
||||||
|
primaryColor
|
||||||
|
darkModeDefault
|
||||||
|
}
|
||||||
|
footerLinks {
|
||||||
|
name
|
||||||
|
url
|
||||||
|
visibleToGuest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 获取公告维护配置
|
||||||
|
export const GET_NOTICE_MAINTENANCE_CONFIG = gql`
|
||||||
|
query GetNoticeMaintenanceConfig {
|
||||||
|
noticeMaintenanceConfig {
|
||||||
|
banner {
|
||||||
|
enabled
|
||||||
|
text
|
||||||
|
}
|
||||||
|
maintenanceWindow {
|
||||||
|
enabled
|
||||||
|
startTime
|
||||||
|
endTime
|
||||||
|
message
|
||||||
|
}
|
||||||
|
modalAnnouncements {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
content
|
||||||
|
startTime
|
||||||
|
endTime
|
||||||
|
audience
|
||||||
|
priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 获取文档支持配置
|
||||||
|
export const GET_DOCS_SUPPORT_CONFIG = gql`
|
||||||
|
query GetDocsSupportConfig {
|
||||||
|
docsSupportConfig {
|
||||||
|
links {
|
||||||
|
name
|
||||||
|
url
|
||||||
|
description
|
||||||
|
}
|
||||||
|
channels {
|
||||||
|
email
|
||||||
|
ticketSystem
|
||||||
|
chatGroups {
|
||||||
|
name
|
||||||
|
url
|
||||||
|
qrCode
|
||||||
|
description
|
||||||
|
}
|
||||||
|
workingHours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 获取运营配置
|
||||||
|
export const GET_OPS_CONFIG = gql`
|
||||||
|
query GetOpsConfig {
|
||||||
|
opsConfig {
|
||||||
|
features {
|
||||||
|
registrationEnabled
|
||||||
|
inviteCodeRequired
|
||||||
|
emailVerification
|
||||||
|
}
|
||||||
|
limits {
|
||||||
|
maxUsers
|
||||||
|
maxInviteCodesPerUser
|
||||||
|
sessionTimeoutHours
|
||||||
|
}
|
||||||
|
notifications {
|
||||||
|
welcomeEmail
|
||||||
|
systemAnnouncements
|
||||||
|
maintenanceAlerts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 验证配置
|
||||||
|
export const VALIDATE_CONFIG = gql`
|
||||||
|
query ValidateConfig {
|
||||||
|
validateConfig {
|
||||||
|
valid
|
||||||
|
errors
|
||||||
|
warnings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 更新配置设置(假设后端有这样的mutation)
|
||||||
|
export const UPDATE_SETTING = gql`
|
||||||
|
mutation UpdateSetting($key: String!, $value: String!) {
|
||||||
|
updateSetting(key: $key, value: $value) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 批量更新配置设置
|
||||||
|
export const UPDATE_SETTINGS = gql`
|
||||||
|
mutation UpdateSettings($settings: [SettingInput!]!) {
|
||||||
|
updateSettings(settings: $settings) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
failedKeys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 重置配置到默认值
|
||||||
|
export const RESET_SETTINGS = gql`
|
||||||
|
mutation ResetSettings($keys: [String!]!) {
|
||||||
|
resetSettings(keys: $keys) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 导出配置
|
||||||
|
export const EXPORT_CONFIG = gql`
|
||||||
|
query ExportConfig {
|
||||||
|
exportConfig {
|
||||||
|
data
|
||||||
|
timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 导入配置
|
||||||
|
export const IMPORT_CONFIG = gql`
|
||||||
|
mutation ImportConfig($configData: String!) {
|
||||||
|
importConfig(configData: $configData) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
importedCount
|
||||||
|
skippedCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
260
lib/config-zod.ts
Normal file
260
lib/config-zod.ts
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { Globe, Users, Mail, FileText, Server, HardDrive, Shield, Settings } from "lucide-react";
|
||||||
|
import { FieldConfig, SectionConfig } from "@/types/admin-panel";
|
||||||
|
import { ConfigItemType } from "@/hooks/use-site-config";
|
||||||
|
|
||||||
|
// 1) 使用 zod 定义“通用配置”结构与规则
|
||||||
|
export const commonConfigSchema = z.object({
|
||||||
|
site: z.object({
|
||||||
|
name: z.string().min(2, "网站名称至少2个字符").max(50, "网站名称最多50个字符"),
|
||||||
|
description: z.string().max(200, "网站描述最多200个字符").optional().or(z.literal("")),
|
||||||
|
keywords: z.string().optional().or(z.literal("")),
|
||||||
|
url: z.string().url("请输入有效的站点URL").optional().or(z.literal("")),
|
||||||
|
logo: z.string().url("请输入有效的Logo地址").optional().or(z.literal("")),
|
||||||
|
icp: z.string().optional().or(z.literal("")),
|
||||||
|
icp_url: z.string().url("请输入有效的备案链接").optional().or(z.literal("")),
|
||||||
|
color_style: z.enum(["light", "dark", "auto"]).default("light"),
|
||||||
|
}),
|
||||||
|
user: z.object({
|
||||||
|
default_avatar: z.string().url("请输入有效的头像URL").optional().or(z.literal("")),
|
||||||
|
default_role: z.enum(["user", "editor", "admin"]).default("user"),
|
||||||
|
register_invite_code: z.boolean().default(false),
|
||||||
|
register_email_verification: z.boolean().default(false),
|
||||||
|
open_login: z.boolean().default(true),
|
||||||
|
open_reset_password: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
email: z.object({
|
||||||
|
smtp_host: z.string().optional().or(z.literal("")),
|
||||||
|
smtp_port: z.number().int().min(1).max(65535).default(465),
|
||||||
|
smtp_user: z.string().optional().or(z.literal("")),
|
||||||
|
smtp_password: z.string().optional().or(z.literal("")),
|
||||||
|
smtp_from: z.string().email("请输入有效的发信地址").optional().or(z.literal("")),
|
||||||
|
smtp_from_name: z.string().optional().or(z.literal("")),
|
||||||
|
smtp_from_email: z.string().email("请输入有效的发信邮箱").optional().or(z.literal("")),
|
||||||
|
system_template: z.string().default("default"),
|
||||||
|
}),
|
||||||
|
blog: z.object({
|
||||||
|
default_author: z.string().optional().or(z.literal("")),
|
||||||
|
default_category: z.string().optional().or(z.literal("")),
|
||||||
|
default_tag: z.string().optional().or(z.literal("")),
|
||||||
|
open_comment: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
logging: z.object({
|
||||||
|
level: z.enum(["error", "warn", "info", "debug"]).default("info"),
|
||||||
|
max_files: z.number().int().min(1).max(1000).default(10),
|
||||||
|
max_file_size: z.number().int().min(1).max(10240).default(10),
|
||||||
|
}),
|
||||||
|
cache: z.object({
|
||||||
|
ttl: z.number().int().min(1).max(31_536_000).default(3600),
|
||||||
|
max_size: z.number().int().min(1).max(1_048_576).default(1024),
|
||||||
|
}),
|
||||||
|
switch: z.object({
|
||||||
|
open_register: z.boolean().default(true),
|
||||||
|
open_login: z.boolean().default(true),
|
||||||
|
open_reset_password: z.boolean().default(true),
|
||||||
|
open_comment: z.boolean().default(true),
|
||||||
|
open_like: z.boolean().default(true),
|
||||||
|
open_share: z.boolean().default(true),
|
||||||
|
open_view: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) 字段元数据定义
|
||||||
|
type Meta = Omit<FieldConfig, "id" | "value"> & { defaultValue?: any };
|
||||||
|
|
||||||
|
const makeField = (id: string, meta: Meta, value?: any): FieldConfig => ({
|
||||||
|
id,
|
||||||
|
...meta,
|
||||||
|
value: value ?? meta.defaultValue
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) 分组图标映射
|
||||||
|
const categoryIcons: Record<string, ReactNode> = {
|
||||||
|
site: <Globe className="h-5 w-5" />,
|
||||||
|
user: <Users className="h-5 w-5" />,
|
||||||
|
email: <Mail className="h-5 w-5" />,
|
||||||
|
blog: <FileText className="h-5 w-5" />,
|
||||||
|
logging: <Server className="h-5 w-5" />,
|
||||||
|
cache: <HardDrive className="h-5 w-5" />,
|
||||||
|
switch: <Shield className="h-5 w-5" />,
|
||||||
|
other: <Settings className="h-5 w-5" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4) 分组标题映射
|
||||||
|
const categoryTitles: Record<string, string> = {
|
||||||
|
site: "网站信息",
|
||||||
|
user: "用户设置",
|
||||||
|
email: "邮件设置",
|
||||||
|
blog: "博客设置",
|
||||||
|
logging: "日志设置",
|
||||||
|
cache: "缓存设置",
|
||||||
|
switch: "功能开关",
|
||||||
|
other: "其他配置",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5) 已知字段的元数据(用于生成表单)
|
||||||
|
const knownFieldsMeta: Record<string, Meta> = {
|
||||||
|
// site
|
||||||
|
"site.name": { label: "网站名称", type: "input", validation: { required: true, minLength: 2, maxLength: 50 } },
|
||||||
|
"site.description": { label: "网站描述", type: "textarea", rows: 3 },
|
||||||
|
"site.keywords": { label: "关键词", type: "input", description: "逗号分隔,如:blog,tech,ai" },
|
||||||
|
"site.url": { label: "站点URL", type: "url" },
|
||||||
|
"site.logo": { label: "Logo地址", type: "url" },
|
||||||
|
"site.icp": { label: "ICP备案号", type: "input" },
|
||||||
|
"site.icp_url": { label: "备案链接", type: "url" },
|
||||||
|
"site.color_style": {
|
||||||
|
label: "配色风格",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "浅色", value: "light" },
|
||||||
|
{ label: "深色", value: "dark" },
|
||||||
|
{ label: "自动", value: "auto" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// user
|
||||||
|
"user.default_avatar": { label: "默认头像URL", type: "url" },
|
||||||
|
"user.default_role": {
|
||||||
|
label: "默认角色",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "用户", value: "user" },
|
||||||
|
{ label: "编辑", value: "editor" },
|
||||||
|
{ label: "管理员", value: "admin" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user.register_invite_code": { label: "注册需邀请码", type: "switch" },
|
||||||
|
"user.register_email_verification": { label: "注册需邮箱验证", type: "switch" },
|
||||||
|
"user.open_login": { label: "开启登录", type: "switch" },
|
||||||
|
"user.open_reset_password": { label: "开启重置密码", type: "switch" },
|
||||||
|
// email
|
||||||
|
"email.smtp_host": { label: "SMTP 主机", type: "input" },
|
||||||
|
"email.smtp_port": { label: "SMTP 端口", type: "number", min: 1, max: 65535 },
|
||||||
|
"email.smtp_user": { label: "SMTP 用户名", type: "input" },
|
||||||
|
"email.smtp_password": { label: "SMTP 密码", type: "password" },
|
||||||
|
"email.smtp_from": { label: "发信地址", type: "email" },
|
||||||
|
"email.smtp_from_name": { label: "发信人名称", type: "input" },
|
||||||
|
"email.smtp_from_email": { label: "发信邮箱", type: "email" },
|
||||||
|
"email.system_template": { label: "系统模板", type: "input" },
|
||||||
|
// blog
|
||||||
|
"blog.default_author": { label: "默认作者", type: "input" },
|
||||||
|
"blog.default_category": { label: "默认分类", type: "input" },
|
||||||
|
"blog.default_tag": { label: "默认标签", type: "input" },
|
||||||
|
"blog.open_comment": { label: "开启评论", type: "switch" },
|
||||||
|
// logging
|
||||||
|
"logging.level": {
|
||||||
|
label: "日志级别",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "错误", value: "error" },
|
||||||
|
{ label: "警告", value: "warn" },
|
||||||
|
{ label: "信息", value: "info" },
|
||||||
|
{ label: "调试", value: "debug" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"logging.max_files": { label: "最大文件数", type: "number", min: 1, max: 1000 },
|
||||||
|
"logging.max_file_size": { label: "单文件大小(MB)", type: "number", min: 1, max: 10240 },
|
||||||
|
// cache
|
||||||
|
"cache.ttl": { label: "TTL(秒)", type: "number", min: 1, max: 31536000 },
|
||||||
|
"cache.max_size": { label: "最大容量(MB)", type: "number", min: 1, max: 1048576 },
|
||||||
|
// switch
|
||||||
|
"switch.open_register": { label: "开放注册", type: "switch" },
|
||||||
|
"switch.open_login": { label: "开放登录", type: "switch" },
|
||||||
|
"switch.open_reset_password": { label: "开放重置密码", type: "switch" },
|
||||||
|
"switch.open_comment": { label: "开放评论", type: "switch" },
|
||||||
|
"switch.open_like": { label: "开放点赞", type: "switch" },
|
||||||
|
"switch.open_share": { label: "开放分享", type: "switch" },
|
||||||
|
"switch.open_view": { label: "开放浏览", type: "switch" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6) 根据 valueType 推断字段类型
|
||||||
|
function inferFieldType(valueType: string, value: any): FieldConfig["type"] {
|
||||||
|
const vt = valueType.toLowerCase();
|
||||||
|
if (vt === "boolean" || vt === "bool") return "switch";
|
||||||
|
if (vt === "number" || vt === "int" || vt === "integer" || vt === "float" || vt === "double") return "number";
|
||||||
|
if (vt === "email") return "email";
|
||||||
|
if (vt === "url") return "url";
|
||||||
|
if (vt === "password") return "password";
|
||||||
|
if (vt === "json" || vt === "object" || vt === "array") return "textarea";
|
||||||
|
if (typeof value === "string" && value.length > 100) return "textarea";
|
||||||
|
return "input";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) 解析配置值
|
||||||
|
function parseConfigValue(value: string | null | undefined, valueType: string): any {
|
||||||
|
if (value == null) return "";
|
||||||
|
const vt = valueType.toLowerCase();
|
||||||
|
try {
|
||||||
|
if (vt === "number" || vt === "int" || vt === "integer") return Number(value);
|
||||||
|
if (vt === "float" || vt === "double") return parseFloat(value);
|
||||||
|
if (vt === "bool" || vt === "boolean") return value === "true" || value === "1";
|
||||||
|
if (vt === "json" || vt === "object" || vt === "array") return value; // 保持字符串用于 textarea
|
||||||
|
return value;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8) 根据 configs 动态生成分组
|
||||||
|
export function buildSectionsFromConfigs(configs: ConfigItemType[]): SectionConfig[] {
|
||||||
|
const groupMap: Record<string, Array<{ config: ConfigItemType; field: FieldConfig }>> = {};
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const [category] = config.key.split(".");
|
||||||
|
const group = category || "other";
|
||||||
|
|
||||||
|
if (!groupMap[group]) {
|
||||||
|
groupMap[group] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析值
|
||||||
|
const parsedValue = parseConfigValue(config.value, config.valueType);
|
||||||
|
|
||||||
|
// 检查是否有预定义的元数据
|
||||||
|
const knownMeta = knownFieldsMeta[config.key];
|
||||||
|
if (knownMeta) {
|
||||||
|
// 使用预定义的元数据
|
||||||
|
groupMap[group].push({
|
||||||
|
config,
|
||||||
|
field: makeField(config.key, knownMeta, parsedValue)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 动态推断字段类型
|
||||||
|
const inferredType = inferFieldType(config.valueType, parsedValue);
|
||||||
|
const field: FieldConfig = {
|
||||||
|
id: config.key,
|
||||||
|
label: config.description || config.key,
|
||||||
|
type: inferredType,
|
||||||
|
value: parsedValue,
|
||||||
|
description: config.description ? undefined : `配置键: ${config.key}`,
|
||||||
|
rows: inferredType === "textarea" ? 3 : undefined,
|
||||||
|
};
|
||||||
|
groupMap[group].push({ config, field });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 SectionConfig[]
|
||||||
|
return Object.entries(groupMap)
|
||||||
|
.filter(([_, items]) => items.length > 0)
|
||||||
|
.map<SectionConfig>(([group, items]) => ({
|
||||||
|
id: `common-${group}`,
|
||||||
|
title: categoryTitles[group] || `${group} 配置`,
|
||||||
|
icon: categoryIcons[group] || categoryIcons.other,
|
||||||
|
fields: items.map(item => item.field),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9) 将 zod 校验错误转换为 AdminPanel 的错误映射
|
||||||
|
export function zodErrorsToAdminErrors(result: z.SafeParseReturnType<any, any>): Record<string, string> {
|
||||||
|
if (result.success) return {};
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
for (const issue of result.error.issues) {
|
||||||
|
const path = issue.path.join(".");
|
||||||
|
if (path) {
|
||||||
|
errors[path] = issue.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommonConfig = z.infer<typeof commonConfigSchema>;
|
||||||
191
lib/config-zod.tsx
Normal file
191
lib/config-zod.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { Globe, Users, Mail, FileText, Server, HardDrive, Shield } from "lucide-react";
|
||||||
|
import { FieldConfig, SectionConfig } from "@/types/admin-panel";
|
||||||
|
|
||||||
|
// 1) 使用 zod 定义“通用配置”结构与规则(与表单值结构一致,使用嵌套对象)
|
||||||
|
export const commonConfigSchema = z.object({
|
||||||
|
site: z.object({
|
||||||
|
name: z.string().min(2, "网站名称至少2个字符").max(50, "网站名称最多50个字符"),
|
||||||
|
description: z.string().max(200, "网站描述最多200个字符").optional().or(z.literal("")),
|
||||||
|
keywords: z.string().optional().or(z.literal("")),
|
||||||
|
url: z.string().url("请输入有效的站点URL").optional().or(z.literal("")),
|
||||||
|
logo: z.string().url("请输入有效的Logo地址").optional().or(z.literal("")),
|
||||||
|
icp: z.string().optional().or(z.literal("")),
|
||||||
|
icp_url: z.string().url("请输入有效的备案链接").optional().or(z.literal("")),
|
||||||
|
color_style: z.enum(["light", "dark", "auto"]).default("light"),
|
||||||
|
}),
|
||||||
|
user: z.object({
|
||||||
|
default_avatar: z.string().url("请输入有效的头像URL").optional().or(z.literal("")),
|
||||||
|
default_role: z.enum(["user", "editor", "admin"]).default("user"),
|
||||||
|
register_invite_code: z.boolean().default(false),
|
||||||
|
register_email_verification: z.boolean().default(false),
|
||||||
|
open_login: z.boolean().default(true),
|
||||||
|
open_reset_password: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
email: z.object({
|
||||||
|
smtp_host: z.string().optional().or(z.literal("")),
|
||||||
|
smtp_port: z.number().int().min(1).max(65535).default(465),
|
||||||
|
smtp_user: z.string().optional().or(z.literal("")),
|
||||||
|
smtp_password: z.string().optional().or(z.literal("")),
|
||||||
|
smtp_from: z.string().email("请输入有效的发信地址").optional().or(z.literal("")),
|
||||||
|
smtp_from_name: z.string().optional().or(z.literal("")),
|
||||||
|
smtp_from_email: z.string().email("请输入有效的发信邮箱").optional().or(z.literal("")),
|
||||||
|
system_template: z.string().default("default"),
|
||||||
|
}),
|
||||||
|
blog: z.object({
|
||||||
|
default_author: z.string().optional().or(z.literal("")),
|
||||||
|
default_category: z.string().optional().or(z.literal("")),
|
||||||
|
default_tag: z.string().optional().or(z.literal("")),
|
||||||
|
open_comment: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
logging: z.object({
|
||||||
|
level: z.enum(["error", "warn", "info", "debug"]).default("info"),
|
||||||
|
max_files: z.number().int().min(1).max(1000).default(10),
|
||||||
|
max_file_size: z.number().int().min(1).max(10240).default(10),
|
||||||
|
}),
|
||||||
|
cache: z.object({
|
||||||
|
ttl: z.number().int().min(1).max(31_536_000).default(3600),
|
||||||
|
max_size: z.number().int().min(1).max(1_048_576).default(1024),
|
||||||
|
}),
|
||||||
|
switch: z.object({
|
||||||
|
open_register: z.boolean().default(true),
|
||||||
|
open_login: z.boolean().default(true),
|
||||||
|
open_reset_password: z.boolean().default(true),
|
||||||
|
open_comment: z.boolean().default(true),
|
||||||
|
open_like: z.boolean().default(true),
|
||||||
|
open_share: z.boolean().default(true),
|
||||||
|
open_view: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) 最小化配置,用于生成 FieldConfig(避免重复手写 FieldConfig)
|
||||||
|
type Meta = Omit<FieldConfig, "id" | "value"> & { defaultValue?: any };
|
||||||
|
|
||||||
|
const makeField = (id: string, meta: Meta): FieldConfig => ({ id, ...meta, value: meta.defaultValue });
|
||||||
|
|
||||||
|
const sectionIcons: Record<string, ReactNode> = {
|
||||||
|
site: <Globe className="h-5 w-5" />,
|
||||||
|
user: <Users className="h-5 w-5" />,
|
||||||
|
email: <Mail className="h-5 w-5" />,
|
||||||
|
blog: <FileText className="h-5 w-5" />,
|
||||||
|
logging: <Server className="h-5 w-5" />,
|
||||||
|
cache: <HardDrive className="h-5 w-5" />,
|
||||||
|
switch: <Shield className="h-5 w-5" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionTitles: Record<string, string> = {
|
||||||
|
site: "网站信息",
|
||||||
|
user: "用户设置",
|
||||||
|
email: "邮件设置",
|
||||||
|
blog: "博客设置",
|
||||||
|
logging: "日志设置",
|
||||||
|
cache: "缓存设置",
|
||||||
|
switch: "功能开关",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3) 通用配置的字段元数据(用于生成表单)
|
||||||
|
export const commonFieldsMeta: Array<{ id: string; meta: Meta }> = [
|
||||||
|
// site
|
||||||
|
{ id: "site.name", meta: { label: "网站名称", type: "input", validation: { required: true, minLength: 2, maxLength: 50 } } },
|
||||||
|
{ id: "site.description", meta: { label: "网站描述", type: "textarea", rows: 3 } },
|
||||||
|
{ id: "site.keywords", meta: { label: "关键词", type: "input", description: "逗号分隔,如:blog,tech,ai" } },
|
||||||
|
{ id: "site.url", meta: { label: "站点URL", type: "url" } },
|
||||||
|
{ id: "site.logo", meta: { label: "Logo地址", type: "url" } },
|
||||||
|
{ id: "site.icp", meta: { label: "ICP备案号", type: "input" } },
|
||||||
|
{ id: "site.icp_url", meta: { label: "备案链接", type: "url" } },
|
||||||
|
{
|
||||||
|
id: "site.color_style", meta: {
|
||||||
|
label: "配色风格", type: "select", options: [
|
||||||
|
{ label: "浅色", value: "light" },
|
||||||
|
{ label: "深色", value: "dark" },
|
||||||
|
{ label: "自动", value: "auto" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// user
|
||||||
|
{ id: "user.default_avatar", meta: { label: "默认头像URL", type: "url" } },
|
||||||
|
{
|
||||||
|
id: "user.default_role", meta: {
|
||||||
|
label: "默认角色", type: "select", options: [
|
||||||
|
{ label: "用户", value: "user" },
|
||||||
|
{ label: "编辑", value: "editor" },
|
||||||
|
{ label: "管理员", value: "admin" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ id: "user.register_invite_code", meta: { label: "注册需邀请码", type: "switch" } },
|
||||||
|
{ id: "user.register_email_verification", meta: { label: "注册需邮箱验证", type: "switch" } },
|
||||||
|
{ id: "user.open_login", meta: { label: "开启登录", type: "switch" } },
|
||||||
|
{ id: "user.open_reset_password", meta: { label: "开启重置密码", type: "switch" } },
|
||||||
|
// email
|
||||||
|
{ id: "email.smtp_host", meta: { label: "SMTP 主机", type: "input" } },
|
||||||
|
{ id: "email.smtp_port", meta: { label: "SMTP 端口", type: "number", min: 1, max: 65535 } },
|
||||||
|
{ id: "email.smtp_user", meta: { label: "SMTP 用户名", type: "input" } },
|
||||||
|
{ id: "email.smtp_password", meta: { label: "SMTP 密码", type: "password" } },
|
||||||
|
{ id: "email.smtp_from", meta: { label: "发信地址", type: "email" } },
|
||||||
|
{ id: "email.smtp_from_name", meta: { label: "发信人名称", type: "input" } },
|
||||||
|
{ id: "email.smtp_from_email", meta: { label: "发信邮箱", type: "email" } },
|
||||||
|
{ id: "email.system_template", meta: { label: "系统模板", type: "input" } },
|
||||||
|
// blog
|
||||||
|
{ id: "blog.default_author", meta: { label: "默认作者", type: "input" } },
|
||||||
|
{ id: "blog.default_category", meta: { label: "默认分类", type: "input" } },
|
||||||
|
{ id: "blog.default_tag", meta: { label: "默认标签", type: "input" } },
|
||||||
|
{ id: "blog.open_comment", meta: { label: "开启评论", type: "switch" } },
|
||||||
|
// logging
|
||||||
|
{
|
||||||
|
id: "logging.level", meta: {
|
||||||
|
label: "日志级别", type: "select", options: [
|
||||||
|
{ label: "错误", value: "error" },
|
||||||
|
{ label: "警告", value: "warn" },
|
||||||
|
{ label: "信息", value: "info" },
|
||||||
|
{ label: "调试", value: "debug" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ id: "logging.max_files", meta: { label: "最大文件数", type: "number", min: 1, max: 1000 } },
|
||||||
|
{ id: "logging.max_file_size", meta: { label: "单文件大小(MB)", type: "number", min: 1, max: 10240 } },
|
||||||
|
// cache
|
||||||
|
{ id: "cache.ttl", meta: { label: "TTL(秒)", type: "number", min: 1, max: 31536000 } },
|
||||||
|
{ id: "cache.max_size", meta: { label: "最大容量(MB)", type: "number", min: 1, max: 1048576 } },
|
||||||
|
// switch
|
||||||
|
{ id: "switch.open_register", meta: { label: "开放注册", type: "switch" } },
|
||||||
|
{ id: "switch.open_login", meta: { label: "开放登录", type: "switch" } },
|
||||||
|
{ id: "switch.open_reset_password", meta: { label: "开放重置密码", type: "switch" } },
|
||||||
|
{ id: "switch.open_comment", meta: { label: "开放评论", type: "switch" } },
|
||||||
|
{ id: "switch.open_like", meta: { label: "开放点赞", type: "switch" } },
|
||||||
|
{ id: "switch.open_share", meta: { label: "开放分享", type: "switch" } },
|
||||||
|
{ id: "switch.open_view", meta: { label: "开放浏览", type: "switch" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 4) 根据元数据分组生成 Section 列表(供 dynamic-admin-config 使用)
|
||||||
|
export function buildCommonSectionsFromMeta(): SectionConfig[] {
|
||||||
|
const groupMap: Record<string, FieldConfig[]> = {};
|
||||||
|
for (const { id, meta } of commonFieldsMeta) {
|
||||||
|
const [group] = id.split(".");
|
||||||
|
if (!groupMap[group]) groupMap[group] = [];
|
||||||
|
groupMap[group].push(makeField(id, meta));
|
||||||
|
}
|
||||||
|
return Object.entries(groupMap).map<SectionConfig>(([group, fields]) => ({
|
||||||
|
id: `common-${group}`,
|
||||||
|
title: sectionTitles[group] || group,
|
||||||
|
icon: sectionIcons[group],
|
||||||
|
fields,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 将 zod 校验错误转换为 AdminPanel 的错误映射
|
||||||
|
export function zodErrorsToAdminErrors(result: z.SafeParseReturnType<any, any>): Record<string, string> {
|
||||||
|
if (result.success) return {};
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
for (const issue of result.error.issues) {
|
||||||
|
const path = issue.path.join(".");
|
||||||
|
if (path) {
|
||||||
|
errors[path] = issue.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommonConfig = z.infer<typeof commonConfigSchema>;
|
||||||
|
|
||||||
197
types/admin-panel.ts
Normal file
197
types/admin-panel.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type FieldType =
|
||||||
|
| "input"
|
||||||
|
| "textarea"
|
||||||
|
| "select"
|
||||||
|
| "switch"
|
||||||
|
| "slider"
|
||||||
|
| "checkbox"
|
||||||
|
| "radio"
|
||||||
|
| "number"
|
||||||
|
| "password"
|
||||||
|
| "email"
|
||||||
|
| "url"
|
||||||
|
| "date"
|
||||||
|
| "time"
|
||||||
|
| "datetime-local"
|
||||||
|
| "color"
|
||||||
|
| "file"
|
||||||
|
| "range"
|
||||||
|
| "tel";
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
disabled?: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationRule {
|
||||||
|
required?: boolean;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
pattern?: RegExp;
|
||||||
|
custom?: (value: any) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldConfig {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
type: FieldType;
|
||||||
|
value: any;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
validation?: ValidationRule;
|
||||||
|
|
||||||
|
// Type-specific props
|
||||||
|
options?: SelectOption[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
accept?: string; // for file input
|
||||||
|
multiple?: boolean;
|
||||||
|
rows?: number; // for textarea
|
||||||
|
|
||||||
|
// Layout props
|
||||||
|
grid?: {
|
||||||
|
span?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Conditional rendering
|
||||||
|
dependsOn?: string;
|
||||||
|
showWhen?: (values: Record<string, any>) => boolean;
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
render?: (field: FieldConfig, value: any, onChange: (value: any) => void) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionConfig {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
collapsed?: boolean;
|
||||||
|
fields: FieldConfig[];
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
columns?: number;
|
||||||
|
|
||||||
|
// Access control
|
||||||
|
permissions?: string[];
|
||||||
|
|
||||||
|
// Custom render
|
||||||
|
render?: (section: SectionConfig, children: ReactNode) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabConfig {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
badge?: string | number;
|
||||||
|
disabled?: boolean;
|
||||||
|
sections: SectionConfig[];
|
||||||
|
|
||||||
|
// Access control
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionConfig {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||||
|
size?: "default" | "sm" | "lg" | "icon";
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
permissions?: string[];
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeaderConfig {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ActionConfig[];
|
||||||
|
breadcrumbs?: Array<{
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminPanelConfig {
|
||||||
|
header: HeaderConfig;
|
||||||
|
tabs: TabConfig[];
|
||||||
|
|
||||||
|
// Global settings
|
||||||
|
autoSave?: boolean;
|
||||||
|
autoSaveDelay?: number;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
validateOnChange?: boolean;
|
||||||
|
validateOnSubmit?: boolean;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
onValueChange?: (path: string, value: any, allValues: Record<string, any>) => void;
|
||||||
|
onSave?: (values: Record<string, any>) => Promise<void>;
|
||||||
|
onReset?: () => void;
|
||||||
|
onValidate?: (values: Record<string, any>) => Record<string, string>;
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
theme?: {
|
||||||
|
spacing?: "compact" | "normal" | "relaxed";
|
||||||
|
layout?: "sidebar" | "tabs";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminPanelState {
|
||||||
|
values: Record<string, any>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
dirty: Record<string, boolean>;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook types
|
||||||
|
export interface UseAdminPanelOptions {
|
||||||
|
config: AdminPanelConfig;
|
||||||
|
initialValues?: Record<string, any>;
|
||||||
|
onSubmit?: (values: Record<string, any>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAdminPanelReturn {
|
||||||
|
state: AdminPanelState;
|
||||||
|
actions: {
|
||||||
|
setValue: (path: string, value: any) => void;
|
||||||
|
setValues: (values: Record<string, any>) => void;
|
||||||
|
resetValue: (path: string) => void;
|
||||||
|
resetAll: () => void;
|
||||||
|
save: () => Promise<void>;
|
||||||
|
validate: () => boolean;
|
||||||
|
clearErrors: () => void;
|
||||||
|
};
|
||||||
|
helpers: {
|
||||||
|
getValue: (path: string) => any;
|
||||||
|
getError: (path: string) => string | undefined;
|
||||||
|
isDirty: (path?: string) => boolean;
|
||||||
|
isValid: (path?: string) => boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data providers
|
||||||
|
export interface AdminDataProvider {
|
||||||
|
load: () => Promise<Record<string, any>>;
|
||||||
|
save: (values: Record<string, any>) => Promise<void>;
|
||||||
|
validate?: (values: Record<string, any>) => Promise<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission system
|
||||||
|
export interface PermissionChecker {
|
||||||
|
hasPermission: (permission: string) => boolean;
|
||||||
|
hasAnyPermission: (permissions: string[]) => boolean;
|
||||||
|
hasAllPermissions: (permissions: string[]) => boolean;
|
||||||
|
}
|
||||||
336
types/config.ts
336
types/config.ts
@ -1,85 +1,277 @@
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
// System Controls
|
// App Configuration
|
||||||
volume: number
|
app: {
|
||||||
brightness: number
|
name: string
|
||||||
temperature: number
|
version: string
|
||||||
theme: string
|
debug: boolean
|
||||||
|
|
||||||
// Server Configuration
|
|
||||||
serverName: string
|
|
||||||
apiKey: string
|
|
||||||
environment: string
|
|
||||||
region: string
|
|
||||||
|
|
||||||
// Performance Settings
|
|
||||||
maxConnections: number
|
|
||||||
cacheSize: number
|
|
||||||
threadCount: number
|
|
||||||
memoryLimit: number
|
|
||||||
diskQuota: number
|
|
||||||
networkBandwidth: number
|
|
||||||
|
|
||||||
// Security & Features
|
|
||||||
sslEnabled: boolean
|
|
||||||
autoBackup: boolean
|
|
||||||
compressionEnabled: boolean
|
|
||||||
debugMode: boolean
|
|
||||||
maintenanceMode: boolean
|
|
||||||
logLevel: string
|
|
||||||
|
|
||||||
// Notifications & Alerts
|
|
||||||
notifications: boolean
|
|
||||||
emailAlerts: boolean
|
|
||||||
smsAlerts: boolean
|
|
||||||
monitoringEnabled: boolean
|
|
||||||
language: string
|
|
||||||
timezone: string
|
timezone: string
|
||||||
|
}
|
||||||
|
|
||||||
// Advanced Configuration
|
// Database Configuration
|
||||||
|
database: {
|
||||||
|
max_connections: number
|
||||||
|
connection_timeout: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kafka Configuration
|
||||||
|
kafka: {
|
||||||
|
max_retries: number
|
||||||
|
retry_delay: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security Configuration
|
||||||
|
security: {
|
||||||
|
session_timeout: number
|
||||||
|
max_login_attempts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging Configuration
|
||||||
|
logging: {
|
||||||
|
level: string
|
||||||
|
max_files: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache Configuration
|
||||||
|
cache: {
|
||||||
|
ttl: number
|
||||||
|
max_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site Configuration
|
||||||
|
site: {
|
||||||
|
name: string
|
||||||
|
locale_default: string
|
||||||
|
locales_supported: string[]
|
||||||
|
brand: {
|
||||||
|
logo_url: string
|
||||||
|
primary_color: string
|
||||||
|
dark_mode_default: boolean
|
||||||
|
}
|
||||||
|
footer_links: Array<{
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
visible_to_guest: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notice Configuration
|
||||||
|
notice: {
|
||||||
|
banner: {
|
||||||
|
enabled: boolean
|
||||||
|
text: Record<string, string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenance Configuration
|
||||||
|
maintenance: {
|
||||||
|
window: {
|
||||||
|
enabled: boolean
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
message: Record<string, string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal Announcements
|
||||||
|
modal: {
|
||||||
|
announcements: Array<{
|
||||||
|
id: string
|
||||||
|
title: Record<string, string>
|
||||||
|
content: Record<string, string>
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
audience: string[]
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Documentation Links
|
||||||
|
docs: {
|
||||||
|
links: Array<{
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
description: string
|
description: string
|
||||||
selectedFeatures: string[]
|
}>
|
||||||
deploymentStrategy: string
|
}
|
||||||
|
|
||||||
|
// Support Channels
|
||||||
|
support: {
|
||||||
|
channels: {
|
||||||
|
email: string
|
||||||
|
ticket_system: string
|
||||||
|
chat_groups: Array<{
|
||||||
|
name: string
|
||||||
|
url?: string
|
||||||
|
qr_code?: string
|
||||||
|
description: string
|
||||||
|
}>
|
||||||
|
working_hours: Record<string, string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operations Configuration
|
||||||
|
ops: {
|
||||||
|
features: {
|
||||||
|
registration_enabled: boolean
|
||||||
|
invite_code_required: boolean
|
||||||
|
email_verification: boolean
|
||||||
|
}
|
||||||
|
limits: {
|
||||||
|
max_users: number
|
||||||
|
max_invite_codes_per_user: number
|
||||||
|
session_timeout_hours: number
|
||||||
|
}
|
||||||
|
notifications: {
|
||||||
|
welcome_email: boolean
|
||||||
|
system_announcements: boolean
|
||||||
|
maintenance_alerts: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultConfig: Config = {
|
export const defaultConfig: Config = {
|
||||||
// System Controls
|
// App Configuration
|
||||||
volume: 75,
|
app: {
|
||||||
brightness: 60,
|
name: "MMAP System",
|
||||||
temperature: 22,
|
version: "1.0.0",
|
||||||
theme: "dark",
|
debug: false,
|
||||||
|
timezone: "UTC"
|
||||||
|
},
|
||||||
|
|
||||||
// Server Configuration
|
// Database Configuration
|
||||||
serverName: "Production Server",
|
database: {
|
||||||
apiKey: "",
|
max_connections: 10,
|
||||||
environment: "production",
|
connection_timeout: 30
|
||||||
region: "us-east-1",
|
},
|
||||||
|
|
||||||
// Performance Settings
|
// Kafka Configuration
|
||||||
maxConnections: 100,
|
kafka: {
|
||||||
cacheSize: 512,
|
max_retries: 3,
|
||||||
threadCount: 8,
|
retry_delay: 1000
|
||||||
memoryLimit: 4096,
|
},
|
||||||
diskQuota: 1000,
|
|
||||||
networkBandwidth: 100,
|
|
||||||
|
|
||||||
// Security & Features
|
// Security Configuration
|
||||||
sslEnabled: true,
|
security: {
|
||||||
autoBackup: true,
|
session_timeout: 3600,
|
||||||
compressionEnabled: false,
|
max_login_attempts: 5
|
||||||
debugMode: false,
|
},
|
||||||
maintenanceMode: false,
|
|
||||||
logLevel: "info",
|
|
||||||
|
|
||||||
// Notifications & Alerts
|
// Logging Configuration
|
||||||
notifications: true,
|
logging: {
|
||||||
emailAlerts: true,
|
level: "info",
|
||||||
smsAlerts: false,
|
max_files: 10
|
||||||
monitoringEnabled: true,
|
},
|
||||||
language: "en",
|
|
||||||
timezone: "UTC",
|
|
||||||
|
|
||||||
// Advanced Configuration
|
// Cache Configuration
|
||||||
description: "",
|
cache: {
|
||||||
selectedFeatures: ["analytics", "caching"],
|
ttl: 300,
|
||||||
deploymentStrategy: "rolling"
|
max_size: 1000
|
||||||
|
},
|
||||||
|
|
||||||
|
// Site Configuration
|
||||||
|
site: {
|
||||||
|
name: "MMAP System",
|
||||||
|
locale_default: "zh-CN",
|
||||||
|
locales_supported: ["zh-CN", "en"],
|
||||||
|
brand: {
|
||||||
|
logo_url: "/images/logo.png",
|
||||||
|
primary_color: "#3B82F6",
|
||||||
|
dark_mode_default: false
|
||||||
|
},
|
||||||
|
footer_links: [
|
||||||
|
{ name: "关于我们", url: "/about", visible_to_guest: true },
|
||||||
|
{ name: "联系我们", url: "/contact", visible_to_guest: true },
|
||||||
|
{ name: "用户中心", url: "/dashboard", visible_to_guest: false }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Notice Configuration
|
||||||
|
notice: {
|
||||||
|
banner: {
|
||||||
|
enabled: false,
|
||||||
|
text: {
|
||||||
|
"zh-CN": "欢迎使用MMAP系统",
|
||||||
|
"en": "Welcome to MMAP System"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Maintenance Configuration
|
||||||
|
maintenance: {
|
||||||
|
window: {
|
||||||
|
enabled: false,
|
||||||
|
start_time: "2024-01-01T02:00:00Z",
|
||||||
|
end_time: "2024-01-01T06:00:00Z",
|
||||||
|
message: {
|
||||||
|
"zh-CN": "系统维护中,请稍后再试",
|
||||||
|
"en": "System maintenance in progress"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Modal Announcements
|
||||||
|
modal: {
|
||||||
|
announcements: [
|
||||||
|
{
|
||||||
|
id: "welcome_2024",
|
||||||
|
title: {
|
||||||
|
"zh-CN": "2024新年快乐",
|
||||||
|
"en": "Happy New Year 2024"
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
"zh-CN": "感谢您在过去一年的支持",
|
||||||
|
"en": "Thank you for your support in the past year"
|
||||||
|
},
|
||||||
|
start_time: "2024-01-01T00:00:00Z",
|
||||||
|
end_time: "2024-01-31T23:59:59Z",
|
||||||
|
audience: ["all"],
|
||||||
|
priority: "high"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Documentation Links
|
||||||
|
docs: {
|
||||||
|
links: [
|
||||||
|
{ name: "API文档", url: "/docs/api", description: "完整的API接口文档" },
|
||||||
|
{ name: "图例说明", url: "/docs/legend", description: "系统图例和符号说明" },
|
||||||
|
{ name: "计费说明", url: "/docs/billing", description: "详细的计费规则和说明" },
|
||||||
|
{ name: "用户手册", url: "/docs/user-guide", description: "用户操作指南" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Support Channels
|
||||||
|
support: {
|
||||||
|
channels: {
|
||||||
|
email: "support@mapp.com",
|
||||||
|
ticket_system: "/support/tickets",
|
||||||
|
chat_groups: [
|
||||||
|
{ name: "官方QQ群", url: "https://qm.qq.com/xxx", description: "技术交流群" },
|
||||||
|
{ name: "微信群", qr_code: "/images/wechat-qr.png", description: "扫码加入微信群" }
|
||||||
|
],
|
||||||
|
working_hours: {
|
||||||
|
"zh-CN": "周一至周五 9:00-18:00",
|
||||||
|
"en": "Mon-Fri 9:00-18:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Operations Configuration
|
||||||
|
ops: {
|
||||||
|
features: {
|
||||||
|
registration_enabled: true,
|
||||||
|
invite_code_required: true,
|
||||||
|
email_verification: false
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
max_users: 1000,
|
||||||
|
max_invite_codes_per_user: 10,
|
||||||
|
session_timeout_hours: 24
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
welcome_email: true,
|
||||||
|
system_announcements: true,
|
||||||
|
maintenance_alerts: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
126
types/site-config.ts
Normal file
126
types/site-config.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
// 与后端GraphQL接口对应的配置类型定义
|
||||||
|
|
||||||
|
export interface SiteInfoType {
|
||||||
|
name: string;
|
||||||
|
locale_default: string;
|
||||||
|
locales_supported: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandConfigType {
|
||||||
|
logo_url: string;
|
||||||
|
primary_color: string;
|
||||||
|
dark_mode_default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FooterLinkType {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
visible_to_guest: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteConfigType {
|
||||||
|
info: SiteInfoType;
|
||||||
|
brand: BrandConfigType;
|
||||||
|
footer_links: FooterLinkType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BannerNoticeType {
|
||||||
|
enabled: boolean;
|
||||||
|
text: Record<string, string>; // locale -> text
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaintenanceWindowType {
|
||||||
|
enabled: boolean;
|
||||||
|
start_time?: Date;
|
||||||
|
end_time?: Date;
|
||||||
|
message: Record<string, string>; // locale -> message
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModalAnnouncementType {
|
||||||
|
id: string;
|
||||||
|
title: Record<string, string>; // locale -> title
|
||||||
|
content: Record<string, string>; // locale -> content
|
||||||
|
start_time: Date;
|
||||||
|
end_time: Date;
|
||||||
|
audience: string[];
|
||||||
|
priority: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeMaintenanceType {
|
||||||
|
banner: BannerNoticeType;
|
||||||
|
maintenance_window: MaintenanceWindowType;
|
||||||
|
modal_announcements: ModalAnnouncementType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocLinkType {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatGroupType {
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
qr_code?: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportChannelsType {
|
||||||
|
email: string;
|
||||||
|
ticket_system: string;
|
||||||
|
chat_groups: ChatGroupType[];
|
||||||
|
working_hours: Record<string, string>; // locale -> hours
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocsSupportType {
|
||||||
|
links: DocLinkType[];
|
||||||
|
channels: SupportChannelsType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureSwitchesType {
|
||||||
|
registration_enabled: boolean;
|
||||||
|
invite_code_required: boolean;
|
||||||
|
email_verification: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LimitsConfigType {
|
||||||
|
max_users: number;
|
||||||
|
max_invite_codes_per_user: number;
|
||||||
|
session_timeout_hours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationConfigType {
|
||||||
|
welcome_email: boolean;
|
||||||
|
system_announcements: boolean;
|
||||||
|
maintenance_alerts: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsConfigType {
|
||||||
|
features: FeatureSwitchesType;
|
||||||
|
limits: LimitsConfigType;
|
||||||
|
notifications: NotificationConfigType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteOpsConfigType {
|
||||||
|
site: SiteConfigType;
|
||||||
|
notice_maintenance: NoticeMaintenanceType;
|
||||||
|
docs_support: DocsSupportType;
|
||||||
|
ops: OpsConfigType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigValidationResultType {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置更新相关类型
|
||||||
|
export interface ConfigUpdateInput {
|
||||||
|
key: string;
|
||||||
|
value: string | number | boolean | object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigUpdateResult {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
@ -3,7 +3,11 @@ export interface User {
|
|||||||
email: string
|
email: string
|
||||||
name?: string
|
name?: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
role?: string
|
permissionPairs?: {
|
||||||
|
resource: string
|
||||||
|
action: string
|
||||||
|
}[]
|
||||||
|
// role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user