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,
|
||||
} from "lucide-react"
|
||||
|
||||
|
||||
|
||||
export default function Control() {
|
||||
const [config, setConfig] = useState<Config>(defaultConfig)
|
||||
|
||||
const updateConfig = (key: keyof Config, value: any) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}))
|
||||
const updateConfig = (path: string, value: any) => {
|
||||
setConfig(prev => {
|
||||
const newConfig = { ...prev }
|
||||
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[]) => {
|
||||
updateConfig(key, value[0])
|
||||
current[keys[keys.length - 1]] = value
|
||||
return newConfig
|
||||
})
|
||||
}
|
||||
|
||||
const updateNestedConfig = (path: string, value: any) => {
|
||||
updateConfig(path, value)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
<div className="min-h-screen p-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>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Volume2 className="h-5 w-5" />
|
||||
System Controls
|
||||
<Settings className="h-5 w-5" />
|
||||
应用配置
|
||||
</CardTitle>
|
||||
<CardDescription>Audio, display, and hardware settings</CardDescription>
|
||||
<CardDescription>应用基本设置</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="volume">Volume: {config.volume}%</Label>
|
||||
<Slider
|
||||
id="volume"
|
||||
value={[config.volume]}
|
||||
onValueChange={(value) => updateSliderConfig('volume', value)}
|
||||
max={100}
|
||||
step={1}
|
||||
<Label htmlFor="app-name">应用名称</Label>
|
||||
<Input
|
||||
id="app-name"
|
||||
value={config.app.name}
|
||||
onChange={(e) => updateConfig('app.name', e.target.value)}
|
||||
placeholder="输入应用名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="brightness">
|
||||
<Brightness4 className="inline h-4 w-4 mr-1" />
|
||||
Brightness: {config.brightness}%
|
||||
</Label>
|
||||
<Slider
|
||||
id="brightness"
|
||||
value={[config.brightness]}
|
||||
onValueChange={(value) => updateSliderConfig('brightness', value)}
|
||||
max={100}
|
||||
step={1}
|
||||
<Label htmlFor="app-version">版本号</Label>
|
||||
<Input
|
||||
id="app-version"
|
||||
value={config.app.version}
|
||||
onChange={(e) => updateConfig('app.version', e.target.value)}
|
||||
placeholder="输入版本号"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="temperature">Temperature: {config.temperature}°C</Label>
|
||||
<Slider
|
||||
id="temperature"
|
||||
value={[config.temperature]}
|
||||
onValueChange={(value) => updateSliderConfig('temperature', value)}
|
||||
min={16}
|
||||
max={30}
|
||||
step={0.5}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="app-debug">调试模式</Label>
|
||||
<Switch
|
||||
id="app-debug"
|
||||
checked={config.app.debug}
|
||||
onCheckedChange={(checked) => updateConfig('app.debug', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Theme Selection</Label>
|
||||
<Select value={config.theme} onValueChange={(value) => updateConfig('theme', value)}>
|
||||
<Label>时区</Label>
|
||||
<Select value={config.app.timezone} onValueChange={(value) => updateConfig('app.timezone', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light Theme</SelectItem>
|
||||
<SelectItem value="dark">Dark Theme</SelectItem>
|
||||
<SelectItem value="auto">Auto Theme</SelectItem>
|
||||
<SelectItem value="high-contrast">High Contrast</SelectItem>
|
||||
<SelectItem value="UTC">UTC</SelectItem>
|
||||
<SelectItem value="Asia/Shanghai">中国标准时间</SelectItem>
|
||||
<SelectItem value="America/New_York">美国东部时间</SelectItem>
|
||||
<SelectItem value="Europe/London">英国时间</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5" />
|
||||
Server Configuration
|
||||
Kafka配置
|
||||
</CardTitle>
|
||||
<CardDescription>Core server and API settings</CardDescription>
|
||||
<CardDescription>消息队列设置</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-name">Server Name</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>
|
||||
<Label htmlFor="kafka-retries">最大重试次数: {config.kafka.max_retries}</Label>
|
||||
<Slider
|
||||
id="connections"
|
||||
value={[config.maxConnections]}
|
||||
onValueChange={(value) => updateSliderConfig('maxConnections', value)}
|
||||
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)}
|
||||
id="kafka-retries"
|
||||
value={[config.kafka.max_retries]}
|
||||
onValueChange={(value) => updateConfig('kafka.max_retries', value[0])}
|
||||
min={1}
|
||||
max={32}
|
||||
max={10}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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
|
||||
id="memory-limit"
|
||||
value={[config.memoryLimit]}
|
||||
onValueChange={(value) => updateSliderConfig('memoryLimit', value)}
|
||||
min={512}
|
||||
max={8192}
|
||||
step={256}
|
||||
/>
|
||||
</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}
|
||||
id="kafka-delay"
|
||||
value={[config.kafka.retry_delay]}
|
||||
onValueChange={(value) => updateConfig('kafka.retry_delay', value[0])}
|
||||
min={100}
|
||||
max={5000}
|
||||
step={100}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security & Features */}
|
||||
{/* Security Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Security & Features
|
||||
安全配置
|
||||
</CardTitle>
|
||||
<CardDescription>Security settings and feature toggles</CardDescription>
|
||||
<CardDescription>安全相关设置</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="ssl-enabled" className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4" />
|
||||
SSL Enabled
|
||||
</Label>
|
||||
<Switch
|
||||
id="ssl-enabled"
|
||||
checked={config.sslEnabled}
|
||||
onCheckedChange={(checked) => updateConfig('sslEnabled', checked)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="session-timeout">会话超时: {config.security.session_timeout}秒</Label>
|
||||
<Slider
|
||||
id="session-timeout"
|
||||
value={[config.security.session_timeout]}
|
||||
onValueChange={(value) => updateConfig('security.session_timeout', value[0])}
|
||||
min={1800}
|
||||
max={7200}
|
||||
step={300}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="auto-backup">Auto Backup</Label>
|
||||
<Switch
|
||||
id="auto-backup"
|
||||
checked={config.autoBackup}
|
||||
onCheckedChange={(checked) => updateConfig('autoBackup', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 className="space-y-2">
|
||||
<Label htmlFor="login-attempts">最大登录尝试次数: {config.security.max_login_attempts}</Label>
|
||||
<Slider
|
||||
id="login-attempts"
|
||||
value={[config.security.max_login_attempts]}
|
||||
onValueChange={(value) => updateConfig('security.max_login_attempts', value[0])}
|
||||
min={3}
|
||||
max={10}
|
||||
step={1}
|
||||
/>
|
||||
</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">
|
||||
<Label>Log Level</Label>
|
||||
<Select value={config.logLevel} onValueChange={(value) => updateConfig('logLevel', value)}>
|
||||
<Label>日志级别</Label>
|
||||
<Select value={config.logging.level} onValueChange={(value) => updateConfig('logging.level', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@ -318,242 +241,333 @@ export default function Control() {
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="warn">Warning</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="fatal">Fatal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notifications & Alerts
|
||||
通知配置
|
||||
</CardTitle>
|
||||
<CardDescription>Configure notification preferences</CardDescription>
|
||||
<CardDescription>系统通知设置</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="notifications">Push Notifications</Label>
|
||||
<Label htmlFor="banner-enabled">启用横幅通知</Label>
|
||||
<Switch
|
||||
id="notifications"
|
||||
checked={config.notifications}
|
||||
onCheckedChange={(checked) => updateConfig('notifications', checked)}
|
||||
id="banner-enabled"
|
||||
checked={config.notice.banner.enabled}
|
||||
onCheckedChange={(checked) => updateConfig('notice.banner.enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="email-alerts">Email Alerts</Label>
|
||||
<Switch
|
||||
id="email-alerts"
|
||||
checked={config.emailAlerts}
|
||||
onCheckedChange={(checked) => updateConfig('emailAlerts', checked)}
|
||||
{config.notice.banner.enabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="banner-text-zh">中文横幅文本</Label>
|
||||
<Input
|
||||
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 className="flex items-center justify-between">
|
||||
<Label htmlFor="sms-alerts">SMS Alerts</Label>
|
||||
<Switch
|
||||
id="sms-alerts"
|
||||
checked={config.smsAlerts}
|
||||
onCheckedChange={(checked) => updateConfig('smsAlerts', checked)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="banner-text-en">English Banner Text</Label>
|
||||
<Input
|
||||
id="banner-text-en"
|
||||
value={config.notice.banner.text["en"]}
|
||||
onChange={(e) => updateConfig('notice.banner.text.en', e.target.value)}
|
||||
placeholder="Enter English banner text"
|
||||
/>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
{/* Advanced Configuration */}
|
||||
{/* Maintenance Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Advanced Configuration
|
||||
<Cpu className="h-5 w-5" />
|
||||
维护配置
|
||||
</CardTitle>
|
||||
<CardDescription>Detailed system configuration options</CardDescription>
|
||||
<CardDescription>系统维护设置</CardDescription>
|
||||
</CardHeader>
|
||||
<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">
|
||||
<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
|
||||
id="description"
|
||||
value={config.description}
|
||||
onChange={(e) => updateConfig('description', e.target.value)}
|
||||
placeholder="Enter system description..."
|
||||
rows={3}
|
||||
id="maintenance-message-zh"
|
||||
value={config.maintenance.window.message["zh-CN"]}
|
||||
onChange={(e) => updateConfig('maintenance.window.message.zh-CN', e.target.value)}
|
||||
placeholder="输入中文维护消息"
|
||||
rows={2}
|
||||
/>
|
||||
</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">
|
||||
{[
|
||||
{ id: "analytics", label: "Analytics" },
|
||||
{ id: "caching", label: "Caching" },
|
||||
{ id: "cdn", label: "CDN" },
|
||||
{ id: "load-balancing", label: "Load Balancing" },
|
||||
{ id: "auto-scaling", label: "Auto Scaling" },
|
||||
].map((feature) => (
|
||||
<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="maintenance-message-en">English Maintenance Message</Label>
|
||||
<Textarea
|
||||
id="maintenance-message-en"
|
||||
value={config.maintenance.window.message["en"]}
|
||||
onChange={(e) => updateConfig('maintenance.window.message.en', e.target.value)}
|
||||
placeholder="Enter English maintenance message"
|
||||
rows={2}
|
||||
/>
|
||||
<Label htmlFor={feature.id}>{feature.label}</Label>
|
||||
</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 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Status Dashboard */}
|
||||
{/* Action Buttons */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
System Status Dashboard
|
||||
<Settings className="h-5 w-5" />
|
||||
配置操作
|
||||
</CardTitle>
|
||||
<CardDescription>Real-time system metrics and performance indicators</CardDescription>
|
||||
<CardDescription>保存、重置和导出配置</CardDescription>
|
||||
</CardHeader>
|
||||
<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">
|
||||
<Button className="flex items-center gap-2" onClick={() => {
|
||||
// 这里可以添加保存配置到后端的逻辑
|
||||
console.log('Applying configuration:', config)
|
||||
alert('Configuration applied successfully!')
|
||||
alert('配置应用成功!')
|
||||
}}>
|
||||
<Zap className="h-4 w-4" />
|
||||
Apply All Settings
|
||||
应用所有设置
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setConfig(defaultConfig)}>
|
||||
重置为默认值
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setConfig(defaultConfig)}>Reset to Default</Button>
|
||||
<Button variant="outline" onClick={() => {
|
||||
const dataStr = JSON.stringify(config, null, 2)
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
||||
@ -563,7 +577,9 @@ export default function Control() {
|
||||
link.download = 'config.json'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}}>Export Configuration</Button>
|
||||
}}>
|
||||
导出配置
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
@ -578,21 +594,21 @@ export default function Control() {
|
||||
setConfig(importedConfig)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse config file:', error)
|
||||
alert('Invalid configuration file')
|
||||
alert('无效的配置文件')
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}}>Import Configuration</Button>
|
||||
<Button variant="ghost">View Logs</Button>
|
||||
<Button variant="ghost">System Health Check</Button>
|
||||
}}>
|
||||
导入配置
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
import Control from "./control"
|
||||
// 配置管理页面内容组件
|
||||
function AdminPageContent() {
|
||||
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 (
|
||||
<div>
|
||||
<SiteHeader breadcrumbs={[{ label: "Home", href: "/" }, { label: "Settings", href: "/admin/common" }]} />
|
||||
<Control />
|
||||
<div className="space-y-6">
|
||||
{/* 状态信息栏 */}
|
||||
<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 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,
|
||||
} from "@tabler/icons-react"
|
||||
import { cookies } from "next/headers"
|
||||
import { fetchCategories } from "@/lib/admin-fetchers"
|
||||
// import { fetchCategories } from "@/lib/admin-fetchers"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -25,13 +25,13 @@ import {
|
||||
|
||||
export async function NavDocuments() {
|
||||
const jwt = (await cookies()).get('jwt')?.value;
|
||||
const categoriesData = await fetchCategories(jwt);
|
||||
// const categoriesData = await fetchCategories(jwt);
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Categories</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{categoriesData?.settingCategories?.filter((item) => item.page).map((item) => (
|
||||
{/* {categoriesData?.settingCategories?.filter((item) => item.page).map((item) => (
|
||||
<SidebarMenuItem key={item.page.slug}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={`/admin/${item.page.slug}`}>
|
||||
@ -70,7 +70,7 @@ export async function NavDocuments() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
))} */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||
<IconDots className="text-sidebar-foreground/70" />
|
||||
|
||||
@ -14,7 +14,16 @@ export default function Dashboard() {
|
||||
if (!isLoading) {
|
||||
if (!isAuthenticated) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,12 +120,15 @@ import CreateUserForm from "./create-user-form";
|
||||
import { useUser } from "@/app/user-context";
|
||||
|
||||
export const schema = z.object({
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
role: z.string(),
|
||||
groups: z.array(z.string()),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}),
|
||||
groups: z.array(z.string()),
|
||||
})
|
||||
|
||||
// Create a separate component for the drag handle
|
||||
@ -152,7 +155,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
{
|
||||
id: "drag",
|
||||
header: () => null,
|
||||
cell: ({ row }) => <DragHandle id={row.original.id} />,
|
||||
cell: ({ row }) => <DragHandle id={row.original.user.id} />,
|
||||
},
|
||||
{
|
||||
id: "select",
|
||||
@ -194,18 +197,18 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
cell: ({ row }) => (
|
||||
<div className="w-48">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.email}
|
||||
{row.original.user.email}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Role",
|
||||
accessorKey: "groups",
|
||||
header: "Groups",
|
||||
cell: ({ row }) => (
|
||||
<div className="w-32">
|
||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||
{row.original.role}
|
||||
{row.original.groups.join(", ")}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
@ -228,7 +231,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.createdAt);
|
||||
const date = new Date(row.original.user.createdAt);
|
||||
const now = new Date();
|
||||
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,
|
||||
sortingFn: (rowA, rowB, columnId) => {
|
||||
const dateA = new Date(rowA.original.createdAt);
|
||||
const dateB = new Date(rowB.original.createdAt);
|
||||
const dateA = new Date(rowA.original.user.createdAt);
|
||||
const dateB = new Date(rowB.original.user.createdAt);
|
||||
return dateA.getTime() - dateB.getTime();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lastLogin",
|
||||
accessorFn: (row) => row.updatedAt,
|
||||
accessorFn: (row) => row.user.updatedAt,
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@ -293,7 +296,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.updatedAt);
|
||||
const date = new Date(row.original.user.updatedAt);
|
||||
const now = new Date();
|
||||
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60));
|
||||
|
||||
@ -330,8 +333,8 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
},
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB, columnId) => {
|
||||
const dateA = new Date(rowA.original.updatedAt);
|
||||
const dateB = new Date(rowB.original.updatedAt);
|
||||
const dateA = new Date(rowA.original.user.updatedAt);
|
||||
const dateB = new Date(rowB.original.user.updatedAt);
|
||||
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>> }) {
|
||||
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: row.original.id,
|
||||
id: row.original.user.id,
|
||||
})
|
||||
|
||||
return (
|
||||
@ -388,14 +391,16 @@ function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
|
||||
|
||||
const GET_USERS = gql`
|
||||
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
|
||||
username
|
||||
email
|
||||
role
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
groups
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@ -407,13 +412,15 @@ const USERS_INFO = gql`
|
||||
totalAdminUsers
|
||||
totalUserUsers
|
||||
users {
|
||||
user {
|
||||
id
|
||||
username
|
||||
email
|
||||
role
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
groups
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@ -421,10 +428,8 @@ const USERS_INFO = gql`
|
||||
export function UserTable() {
|
||||
|
||||
const { data, loading, error, refetch } = useQuery(USERS_INFO)
|
||||
|
||||
const [localData, setLocalData] = React.useState<any[]>([])
|
||||
|
||||
// 同步外部数据到本地状态
|
||||
React.useEffect(() => {
|
||||
if (data && Array.isArray(data.usersInfo.users)) {
|
||||
setLocalData(data.usersInfo.users)
|
||||
@ -599,11 +604,12 @@ function UserDataTable({
|
||||
fetchPolicy: 'cache-and-network'
|
||||
})
|
||||
|
||||
const data = useInitialData ? propData : queryData?.users
|
||||
const data = useInitialData ? propData : queryData?.userWithGroups
|
||||
const isLoading = useInitialData ? propIsLoading : queryLoading
|
||||
|
||||
// 同步数据到本地状态
|
||||
React.useEffect(() => {
|
||||
debugger
|
||||
if (data && Array.isArray(data)) {
|
||||
setLocalData(data)
|
||||
}
|
||||
@ -662,7 +668,7 @@ function UserDataTable({
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
getRowId: (row) => row.id.toString(),
|
||||
getRowId: (row) => row.user.id.toString(),
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
@ -886,10 +892,10 @@ function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
||||
<DrawerTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="link" className="text-foreground w-fit px-0 text-left">
|
||||
{item.username}
|
||||
{item.user.username}
|
||||
</Button>
|
||||
{
|
||||
item.id === user?.id ? (
|
||||
item.user.id === user?.id ? (
|
||||
<Badge variant="secondary" className="text-[10px] w-fit">
|
||||
Me
|
||||
</Badge>
|
||||
@ -901,7 +907,7 @@ function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
||||
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="gap-1">
|
||||
<DrawerTitle>{item.username}</DrawerTitle>
|
||||
<DrawerTitle>{item.user.username}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
User profile and activity information
|
||||
</DrawerDescription>
|
||||
@ -966,16 +972,16 @@ function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
||||
<form className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" defaultValue={item.username} />
|
||||
<Input id="name" defaultValue={item.user.username} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" defaultValue={item.email} />
|
||||
<Input id="email" defaultValue={item.user.email} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select defaultValue={item.role}>
|
||||
<Label htmlFor="role">Roles</Label>
|
||||
<Select defaultValue={item.user.username}>
|
||||
<SelectTrigger id="role" className="w-full">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
|
||||
@ -15,7 +15,15 @@ const GET_USER_QUERY = gql`
|
||||
id
|
||||
username
|
||||
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);
|
||||
const res = NextResponse.json({ ok: true, token: jwt })
|
||||
await client.request(GET_USER_QUERY);
|
||||
const permissionPairs = await client.request(GET_PERMISSION_PAIRS);
|
||||
const res = NextResponse.json({ ok: true, token: jwt, permissionPairs })
|
||||
|
||||
res.cookies.set('jwt', jwt, {
|
||||
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 { Book, Command, Home, LucideIcon, Plus, User, Settings, Crown, LogOut } from "lucide-react"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
@ -19,12 +19,15 @@ export const metadata: Metadata = {
|
||||
description: "LiDAR for Radar",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<body
|
||||
|
||||
@ -38,7 +38,6 @@ export function LoginForm({
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
debugger
|
||||
await login(values);
|
||||
// clearMap();
|
||||
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 {
|
||||
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 { ThemeToggle } from '@/components/theme-toggle';
|
||||
// import { Timeline } from '@/app/timeline';
|
||||
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 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() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-row h-full">
|
||||
<AppSidebar />
|
||||
@ -43,7 +45,6 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
</WSProvider>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
"use client"
|
||||
import React, { useRef, useEffect, useState, useCallback } from "react";
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import {
|
||||
|
||||
@ -26,7 +26,6 @@ const GET_USER_QUERY = gql`
|
||||
id
|
||||
username
|
||||
email
|
||||
role
|
||||
}
|
||||
}
|
||||
`
|
||||
@ -103,19 +102,7 @@ export function UserProvider({ children }: UserProviderProps) {
|
||||
if (token && isTokenValid(token)) {
|
||||
const payload = parseJWT(token)
|
||||
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', {
|
||||
method: 'POST',
|
||||
@ -126,6 +113,22 @@ export function UserProvider({ children }: UserProviderProps) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -175,11 +178,18 @@ export function UserProvider({ children }: UserProviderProps) {
|
||||
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 = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
role: payload.role
|
||||
permissionPairs,
|
||||
}
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
@ -226,7 +236,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
role: payload.role
|
||||
}
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
@ -299,7 +308,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
role: payload.role
|
||||
}
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
@ -327,7 +335,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
||||
email: userData.currentUser.email,
|
||||
name: userData.currentUser.username,
|
||||
avatar: userData.currentUser.avatar,
|
||||
role: userData.currentUser.role
|
||||
}
|
||||
|
||||
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 { getBaseUrl } from "./gr-client";
|
||||
|
||||
const CategoriesQuery = gql`
|
||||
query Categories {
|
||||
settingCategories {
|
||||
page {
|
||||
id
|
||||
title
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
// const CategoriesQuery = gql`
|
||||
// query Categories {
|
||||
// settingCategories {
|
||||
// page {
|
||||
// id
|
||||
// title
|
||||
// slug
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// `;
|
||||
|
||||
export type CategoryPage = {
|
||||
id: string;
|
||||
@ -27,23 +27,23 @@ export type CategoriesData = {
|
||||
settingCategories: SettingCategory[];
|
||||
};
|
||||
|
||||
export async function fetchCategories(jwt?: string): Promise<CategoriesData | null> {
|
||||
const client = new GraphQLClient(getBaseUrl());
|
||||
// export async function fetchCategories(jwt?: string): Promise<CategoriesData | null> {
|
||||
// const client = new GraphQLClient(getBaseUrl());
|
||||
|
||||
if (jwt) {
|
||||
client.setHeader('Authorization', `Bearer ${jwt}`);
|
||||
}
|
||||
// if (jwt) {
|
||||
// client.setHeader('Authorization', `Bearer ${jwt}`);
|
||||
// }
|
||||
|
||||
try {
|
||||
const response: any = await client.request(CategoriesQuery);
|
||||
// try {
|
||||
// const response: any = await client.request(CategoriesQuery);
|
||||
|
||||
if (response?.settingCategories) {
|
||||
return response;
|
||||
}
|
||||
// if (response?.settingCategories) {
|
||||
// return response;
|
||||
// }
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch categories:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// return null;
|
||||
// } catch (error) {
|
||||
// console.error('Failed to fetch categories:', error);
|
||||
// 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 {
|
||||
// System Controls
|
||||
volume: number
|
||||
brightness: number
|
||||
temperature: number
|
||||
theme: string
|
||||
|
||||
// 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
|
||||
// App Configuration
|
||||
app: {
|
||||
name: string
|
||||
version: string
|
||||
debug: boolean
|
||||
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
|
||||
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 = {
|
||||
// System Controls
|
||||
volume: 75,
|
||||
brightness: 60,
|
||||
temperature: 22,
|
||||
theme: "dark",
|
||||
// App Configuration
|
||||
app: {
|
||||
name: "MMAP System",
|
||||
version: "1.0.0",
|
||||
debug: false,
|
||||
timezone: "UTC"
|
||||
},
|
||||
|
||||
// Server Configuration
|
||||
serverName: "Production Server",
|
||||
apiKey: "",
|
||||
environment: "production",
|
||||
region: "us-east-1",
|
||||
// Database Configuration
|
||||
database: {
|
||||
max_connections: 10,
|
||||
connection_timeout: 30
|
||||
},
|
||||
|
||||
// Performance Settings
|
||||
maxConnections: 100,
|
||||
cacheSize: 512,
|
||||
threadCount: 8,
|
||||
memoryLimit: 4096,
|
||||
diskQuota: 1000,
|
||||
networkBandwidth: 100,
|
||||
// Kafka Configuration
|
||||
kafka: {
|
||||
max_retries: 3,
|
||||
retry_delay: 1000
|
||||
},
|
||||
|
||||
// Security & Features
|
||||
sslEnabled: true,
|
||||
autoBackup: true,
|
||||
compressionEnabled: false,
|
||||
debugMode: false,
|
||||
maintenanceMode: false,
|
||||
logLevel: "info",
|
||||
// Security Configuration
|
||||
security: {
|
||||
session_timeout: 3600,
|
||||
max_login_attempts: 5
|
||||
},
|
||||
|
||||
// Notifications & Alerts
|
||||
notifications: true,
|
||||
emailAlerts: true,
|
||||
smsAlerts: false,
|
||||
monitoringEnabled: true,
|
||||
language: "en",
|
||||
timezone: "UTC",
|
||||
// Logging Configuration
|
||||
logging: {
|
||||
level: "info",
|
||||
max_files: 10
|
||||
},
|
||||
|
||||
// Advanced Configuration
|
||||
description: "",
|
||||
selectedFeatures: ["analytics", "caching"],
|
||||
deploymentStrategy: "rolling"
|
||||
// Cache Configuration
|
||||
cache: {
|
||||
ttl: 300,
|
||||
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
|
||||
name?: string
|
||||
avatar?: string
|
||||
role?: string
|
||||
permissionPairs?: {
|
||||
resource: string
|
||||
action: string
|
||||
}[]
|
||||
// role?: string
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user