This commit is contained in:
tsuki 2025-08-14 21:34:16 +08:00
parent 6992099298
commit 507746a995
29 changed files with 4596 additions and 600 deletions

View 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
}
]
}
]
}
]
};

View File

@ -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]]
}
current[keys[keys.length - 1]] = value
return newConfig
})
}
const updateSliderConfig = (key: keyof Config, value: number[]) => {
updateConfig(key, value[0])
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="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)}
/>
</div>
{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>
<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>
<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>
</>
)}
</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="space-y-2">
<Label htmlFor="description">System Description</Label>
<Textarea
id="description"
value={config.description}
onChange={(e) => updateConfig('description', e.target.value)}
placeholder="Enter system description..."
rows={3}
<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>
<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>
{config.maintenance.window.enabled && (
<>
<div className="space-y-2">
<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="flex items-center space-x-2">
<RadioGroupItem value="blue-green" id="blue-green" />
<Label htmlFor="blue-green">Blue-Green Deployment</Label>
<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="flex items-center space-x-2">
<RadioGroupItem value="canary" id="canary" />
<Label htmlFor="canary">Canary Deployment</Label>
<div className="space-y-2">
<Label htmlFor="maintenance-message-zh"></Label>
<Textarea
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>
</RadioGroup>
<div className="space-y-2">
<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}
/>
</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="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={feature.id}>{feature.label}</Label>
</div>
))}
</div>
<div className="flex items-center justify-between">
<Label htmlFor="invite-code-required"></Label>
<Switch
id="invite-code-required"
checked={config.ops.features.invite_code_required}
onCheckedChange={(checked) => updateConfig('ops.features.invite_code_required', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="email-verification"></Label>
<Switch
id="email-verification"
checked={config.ops.features.email_verification}
onCheckedChange={(checked) => updateConfig('ops.features.email_verification', checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-users">: {config.ops.limits.max_users}</Label>
<Slider
id="max-users"
value={[config.ops.limits.max_users]}
onValueChange={(value) => updateConfig('ops.limits.max_users', value[0])}
min={100}
max={10000}
step={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-invite-codes">: {config.ops.limits.max_invite_codes_per_user}</Label>
<Slider
id="max-invite-codes"
value={[config.ops.limits.max_invite_codes_per_user]}
onValueChange={(value) => updateConfig('ops.limits.max_invite_codes_per_user', value[0])}
min={1}
max={50}
step={1}
/>
</div>
<div className="space-y-2">
<Label htmlFor="session-timeout">: {config.ops.limits.session_timeout_hours}</Label>
<Slider
id="session-timeout"
value={[config.ops.limits.session_timeout_hours]}
onValueChange={(value) => updateConfig('ops.limits.session_timeout_hours', value[0])}
min={1}
max={168}
step={1}
/>
</div>
</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>
);
}
)
}

View 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);
}
};
}

View File

@ -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>
);
}

View File

@ -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" />

View File

@ -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;
}
}

View File

@ -120,12 +120,15 @@ import CreateUserForm from "./create-user-form";
import { useUser } from "@/app/user-context";
export const schema = z.object({
id: z.string(),
username: z.string(),
email: z.string(),
role: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
user: z.object({
id: z.string(),
username: z.string(),
email: 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,15 +391,17 @@ 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) {
id
username
email
role
createdAt
updatedAt
}
userWithGroups(offset: $offset, limit: $limit, sortBy: $sort_by, sortOrder: $sort_order, filter: $filter) {
user{
id
username
email
createdAt
updatedAt
}
groups
}
}
`
const USERS_INFO = gql`
@ -407,12 +412,14 @@ const USERS_INFO = gql`
totalAdminUsers
totalUserUsers
users {
id
username
email
role
createdAt
updatedAt
user {
id
username
email
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>

View File

@ -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
View 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 });
}
}

View File

@ -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"

View File

@ -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

View File

@ -38,7 +38,6 @@ export function LoginForm({
try {
setIsLoading(true);
setError(null);
debugger
await login(values);
// clearMap();
router.push('/');

View File

@ -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>
)
}

View File

@ -1,3 +1,4 @@
"use client"
import React, { useRef, useEffect, useState, useCallback } from "react";
import { Calendar } from "@/components/ui/calendar"
import {

View File

@ -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 => ({

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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;
}

View File

@ -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
View 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
View 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
View 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
View 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;
}

View File

@ -1,85 +1,277 @@
export interface Config {
// System Controls
volume: number
brightness: number
temperature: number
theme: string
// App Configuration
app: {
name: string
version: string
debug: boolean
timezone: string
}
// Server Configuration
serverName: string
apiKey: string
environment: string
region: string
// Database Configuration
database: {
max_connections: number
connection_timeout: number
}
// Performance Settings
maxConnections: number
cacheSize: number
threadCount: number
memoryLimit: number
diskQuota: number
networkBandwidth: number
// Kafka Configuration
kafka: {
max_retries: number
retry_delay: number
}
// Security & Features
sslEnabled: boolean
autoBackup: boolean
compressionEnabled: boolean
debugMode: boolean
maintenanceMode: boolean
logLevel: string
// Security Configuration
security: {
session_timeout: number
max_login_attempts: number
}
// Notifications & Alerts
notifications: boolean
emailAlerts: boolean
smsAlerts: boolean
monitoringEnabled: boolean
language: string
timezone: string
// Logging Configuration
logging: {
level: string
max_files: number
}
// Advanced Configuration
description: string
selectedFeatures: string[]
deploymentStrategy: string
// 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
}>
}
// 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
View 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;
}

View File

@ -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 {