admin page

This commit is contained in:
tsuki 2025-08-15 22:31:51 +08:00
parent 507746a995
commit 4bafe4d601
16 changed files with 2782 additions and 931 deletions

View File

@ -419,7 +419,7 @@ export const defaultAdminPanelConfig: AdminPanelConfig = {
autoSave: true, autoSave: true,
autoSaveDelay: 3000, autoSaveDelay: 3000,
validateOnChange: true, validateOnChange: true,
validateOnSubmit: true, validateOnSubmit: false,
// 主题设置 // 主题设置
theme: { theme: {

View File

@ -0,0 +1,338 @@
"use client";
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Settings,
Globe,
Palette,
Shield,
Users,
Database,
Mail,
FileText,
Server,
HardDrive,
Lock,
User,
ToggleLeft,
RefreshCw,
Download,
Upload,
Save,
CheckCircle,
AlertCircle
} from "lucide-react";
import { SiteOpsConfigType } from "@/types/site-config";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// 表单验证模式
const configFormSchema = z.object({
'site.name': z.string().min(2, "网站名称至少需要2个字符").max(50, "网站名称不能超过50个字符"),
'site.description': z.string().optional(),
'site.keywords': z.string().optional(),
'site.url': z.string()
.refine((url) => {
if (!url || url === "") return true; // 允许空值
return url.startsWith("http://") || url.startsWith("https://");
}, "无效的URL格式必须以http://或https://开头")
.optional().or(z.literal("")),
'site.logo': z.string().optional(),
'site.color_style': z.enum(["light", "dark"]),
'user.default_role': z.enum(["user", "vip", "admin"]),
'user.register_invite_code': z.boolean(),
'user.register_email_verification': z.boolean(),
'switch.open_register': z.boolean(),
'switch.open_comment': z.boolean(),
});
type ConfigFormValues = z.infer<typeof configFormSchema>;
// 配置管理表单组件
function DynamicAdminConfigForm({
data,
onSave,
onExport,
onImport,
loading = false
}: {
data?: SiteOpsConfigType;
onSave: (values: ConfigFormValues) => Promise<void>;
onExport?: () => Promise<void>;
onImport?: (file: File) => Promise<void>;
loading?: boolean;
}) {
const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string>('');
// 初始化表单
const form = useForm<ConfigFormValues>({
resolver: zodResolver(configFormSchema),
defaultValues: {
'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.color_style': (data?.site?.brand?.dark_mode_default ? 'dark' : 'light') as "light" | "dark",
'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,
'switch.open_register': data?.ops?.features?.registration_enabled ?? true,
'switch.open_comment': true,
}
});
// 处理表单提交
const onSubmit = async (values: ConfigFormValues) => {
setSaveStatus('loading');
setErrorMessage('');
try {
await onSave(values);
setSaveStatus('success');
toast.success("配置保存成功!");
setTimeout(() => setSaveStatus('idle'), 3000);
} catch (error) {
setSaveStatus('error');
setErrorMessage('保存失败,请重试');
toast.error("配置保存失败,请重试");
}
};
return (
<div className="min-h-screen bg-gray-50">
{/* 头部 */}
<div className="bg-white border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600"></p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={() => window.location.reload()}>
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={onExport}>
<Download className="h-4 w-4 mr-2" />
</Button>
<Button 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();
}}>
<Upload className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</div>
{/* 主要内容 */}
<div className="flex-1 px-6 py-6">
<div className="max-w-7xl mx-auto">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Tabs defaultValue="content" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="content"></TabsTrigger>
<TabsTrigger value="user"></TabsTrigger>
<TabsTrigger value="email"></TabsTrigger>
<TabsTrigger value="system"></TabsTrigger>
</TabsList>
{/* 内容配置标签页 */}
<TabsContent value="content" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />
</CardTitle>
<p className="text-sm text-gray-500"></p>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="site.name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入网站名称" {...field} />
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="site.description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea placeholder="请输入网站描述" rows={3} {...field} />
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</TabsContent>
{/* 用户管理标签页 */}
<TabsContent value="user" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="user.default_role"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择默认角色" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="user"></SelectItem>
<SelectItem value="vip">VIP用户</SelectItem>
<SelectItem value="admin"></SelectItem>
</SelectContent>
</Select>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="switch.open_register"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base"></FormLabel>
<FormDescription></FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
</TabsContent>
{/* 其他标签页内容可以继续添加 */}
<TabsContent value="email" className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500">...</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="system" className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500">...</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 保存按钮和状态 */}
<div className="flex items-center justify-between pt-6 border-t">
<div className="flex items-center gap-2">
{saveStatus === 'loading' && (
<>
<RefreshCw className="h-4 w-4 animate-spin text-blue-500" />
<span className="text-blue-600">...</span>
</>
)}
{saveStatus === 'success' && (
<>
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-green-600"></span>
</>
)}
{saveStatus === 'error' && (
<>
<AlertCircle className="h-4 w-4 text-red-500" />
<span className="text-red-600">{errorMessage || '保存失败'}</span>
</>
)}
</div>
<Button
type="submit"
disabled={loading || saveStatus === 'loading'}
className="flex items-center gap-2"
>
{saveStatus === 'loading' ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</Form>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +1,49 @@
"use client"; "use client";
import React from "react"; import React, { useState } from "react";
import { gql, useMutation } from "@apollo/client";
import { useForm } from "react-hook-form"
import { z } from "zod"
import { import {
Settings, Settings,
Globe, Globe,
Palette, Palette,
Bell,
Shield, Shield,
Users, Users,
Database, Database,
Mail, Mail,
FileText, FileText,
MessageSquare, Server,
Clock, HardDrive,
AlertTriangle, Lock,
Save, User,
ToggleLeft,
RefreshCw, RefreshCw,
Download, Download,
Upload, Upload,
Server, Save,
HardDrive CheckCircle,
AlertCircle,
Loader2
} from "lucide-react"; } from "lucide-react";
import { AdminPanelConfig } from "@/types/admin-panel"; import { AdminPanelConfig } from "@/types/admin-panel";
import { commonConfigSchema, zodErrorsToAdminErrors } from "@/lib/config-zod"; import { commonConfigSchema, zodErrorsToAdminErrors } from "@/lib/config-zod";
// import { SiteOpsConfigType } from "@/types/site-config";
import { SiteOpsConfigType } from "@/types/site-config"; import { SiteOpsConfigType } from "@/types/site-config";
import { ConfigItemType } from "@/hooks/use-site-config"; import { ConfigItemType } from "@/hooks/use-site-config";
import { UpdateConfig } from "@/types/config";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { zodResolver } from "@hookform/resolvers/zod";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
// GraphQL Mutation
const UPDATE_CONFIG_BATCH = gql`
mutation UpdateConfigBatch($input: [UpdateConfig!]!) {
updateConfigBatch(input: $input)
}
`;
// 创建基于后端数据的动态管理面板配置 // 创建基于后端数据的动态管理面板配置
export function createDynamicAdminConfig( export function createDynamicAdminConfig(
@ -40,80 +58,8 @@ export function createDynamicAdminConfig(
const getInitialValues = () => { const getInitialValues = () => {
if (!data) return {}; 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 { return {
// 站点信息 // Site 配置
'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.name': data.site?.info?.name || "MMAP System",
'site.description': "", 'site.description': "",
'site.keywords': "", 'site.keywords': "",
@ -124,7 +70,7 @@ export function createDynamicAdminConfig(
'site.icp_url': "", 'site.icp_url': "",
'site.color_style': (data.site?.brand?.dark_mode_default ? 'dark' : 'light'), 'site.color_style': (data.site?.brand?.dark_mode_default ? 'dark' : 'light'),
// User // User 配置
'user.default_avatar': "/images/avatar.png", 'user.default_avatar': "/images/avatar.png",
'user.default_role': 'user', 'user.default_role': 'user',
'user.register_invite_code': data.ops?.features?.invite_code_required ?? false, 'user.register_invite_code': data.ops?.features?.invite_code_required ?? false,
@ -132,7 +78,7 @@ export function createDynamicAdminConfig(
'user.open_login': true, 'user.open_login': true,
'user.open_reset_password': true, 'user.open_reset_password': true,
// Email // Email 配置
'email.smtp_host': "", 'email.smtp_host': "",
'email.smtp_port': 465, 'email.smtp_port': 465,
'email.smtp_user': "", 'email.smtp_user': "",
@ -142,22 +88,22 @@ export function createDynamicAdminConfig(
'email.smtp_from_email': "", 'email.smtp_from_email': "",
'email.system_template': "default", 'email.system_template': "default",
// Blog // Blog 配置
'blog.default_author': "", 'blog.default_author': "",
'blog.default_category': "", 'blog.default_category': "",
'blog.default_tag': "", 'blog.default_tag': "",
'blog.open_comment': true, 'blog.open_comment': true,
// Logging // Logging 配置
'logging.level': 'info', 'logging.level': 'info',
'logging.max_files': 10, 'logging.max_files': 10,
'logging.max_file_size': 10, 'logging.max_file_size': 10,
// Cache // Cache 配置
'cache.ttl': 3600, 'cache.ttl': 3600,
'cache.max_size': 1024, 'cache.max_size': 1024,
// Switches // Switch 配置
'switch.open_register': data.ops?.features?.registration_enabled ?? true, 'switch.open_register': data.ops?.features?.registration_enabled ?? true,
'switch.open_login': true, 'switch.open_login': true,
'switch.open_reset_password': true, 'switch.open_reset_password': true,
@ -214,27 +160,20 @@ export function createDynamicAdminConfig(
}, },
tabs: [ tabs: [
// 通用配置(基于 configs 动态生成) // 内容配置 (Site + Blog)
{ {
id: "common", id: "content",
title: "通用配置", title: "内容配置",
icon: <Settings className="h-4 w-4" />,
sections: []
},
// 站点设置
{
id: "site",
title: "站点设置",
icon: <Globe className="h-4 w-4" />, icon: <Globe className="h-4 w-4" />,
sections: [ sections: [
{ {
id: "site-info", id: "site-basic",
title: "基本信息", title: "站点信息",
description: "网站基本信息和国际化配置", description: "网站基本信息和设置",
icon: <Settings className="h-5 w-5" />, icon: <Settings className="h-5 w-5" />,
fields: [ fields: [
{ {
id: "site.info.name", id: "site.name",
label: "网站名称", label: "网站名称",
description: "显示在浏览器标题栏的网站名称", description: "显示在浏览器标题栏的网站名称",
type: "input", type: "input",
@ -247,33 +186,29 @@ export function createDynamicAdminConfig(
} }
}, },
{ {
id: "site.info.locale_default", id: "site.description",
label: "默认语言", label: "网站描述",
description: "网站的默认显示语言", description: "网站的简要描述信息",
type: "select", type: "textarea",
value: data?.site?.info?.locale_default || "zh-CN", rows: 3,
options: [ value: "",
{ label: "简体中文", value: "zh-CN" }, placeholder: "请输入网站描述"
{ label: "English", value: "en" }
],
validation: {
required: true
}
}, },
{ {
id: "site.info.locales_supported", id: "site.keywords",
label: "支持的语言", label: "网站关键词",
description: "用户可以选择的所有语言选项", description: "SEO相关的关键词用逗号分隔",
type: "select", type: "input",
value: data?.site?.info?.locales_supported || ["zh-CN", "en"], value: "",
multiple: true, placeholder: "请输入关键词,用逗号分隔"
options: [ },
{ label: "简体中文", value: "zh-CN" }, {
{ label: "English", value: "en" }, id: "site.url",
{ label: "繁體中文", value: "zh-TW" }, label: "网站地址",
{ label: "日本語", value: "ja" }, description: "网站的主域名地址",
{ label: "한국어", value: "ko" } type: "input",
] value: "/",
placeholder: "请输入网站地址"
} }
] ]
}, },
@ -284,7 +219,7 @@ export function createDynamicAdminConfig(
icon: <Palette className="h-5 w-5" />, icon: <Palette className="h-5 w-5" />,
fields: [ fields: [
{ {
id: "site.brand.logo_url", id: "site.logo",
label: "Logo地址", label: "Logo地址",
description: "网站Logo图片的URL地址", description: "网站Logo图片的URL地址",
type: "input", type: "input",
@ -292,133 +227,142 @@ export function createDynamicAdminConfig(
placeholder: "请输入Logo URL" placeholder: "请输入Logo URL"
}, },
{ {
id: "site.brand.primary_color", id: "site.color_style",
label: "主题色", label: "颜色风格",
description: "网站的主要色彩,用于按钮、链接等", description: "网站的颜色主题风格",
type: "color", type: "select",
value: data?.site?.brand?.primary_color || "#3B82F6" value: (data?.site?.brand?.dark_mode_default ? 'dark' : 'light'),
}, options: [
{ { label: "浅色主题", value: "light" },
id: "site.brand.dark_mode_default", { label: "深色主题", value: "dark" }
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", id: "site-legal",
title: "维护窗口", title: "法律信息",
description: "系统维护时间配置", description: "网站的法律相关信息",
icon: <AlertTriangle className="h-5 w-5" />, icon: <Shield className="h-5 w-5" />,
fields: [ fields: [
{ {
id: "maintenance.window.enabled", id: "site.copyright",
label: "启用维护模式", label: "版权信息",
description: "启用后系统将显示维护页面", description: "网站的版权声明",
type: "input",
value: "",
placeholder: "请输入版权信息"
},
{
id: "site.icp",
label: "ICP备案号",
description: "网站的ICP备案号码",
type: "input",
value: "",
placeholder: "请输入ICP备案号"
},
{
id: "site.icp_url",
label: "ICP备案链接",
description: "ICP备案查询链接",
type: "input",
value: "",
placeholder: "请输入ICP备案链接"
}
]
},
{
id: "blog-defaults",
title: "博客设置",
description: "博客功能的默认配置",
icon: <FileText className="h-5 w-5" />,
fields: [
{
id: "blog.default_author",
label: "默认作者",
description: "博客文章的默认作者",
type: "input",
value: "",
placeholder: "请输入默认作者"
},
{
id: "blog.default_category",
label: "默认分类",
description: "博客文章的默认分类",
type: "input",
value: "",
placeholder: "请输入默认分类"
},
{
id: "blog.default_tag",
label: "默认标签",
description: "博客文章的默认标签",
type: "input",
value: "",
placeholder: "请输入默认标签"
},
{
id: "blog.open_comment",
label: "开放评论",
description: "是否允许用户对博客文章进行评论",
type: "switch", type: "switch",
value: data?.notice_maintenance?.maintenance_window?.enabled || false value: true
},
{
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
} }
] ]
} }
] ]
}, },
// 运营配置 // 用户管理 (User + Switch)
{ {
id: "operations", id: "user",
title: "运营配置", title: "用户管理",
icon: <Users className="h-4 w-4" />, icon: <Users className="h-4 w-4" />,
sections: [ sections: [
{ {
id: "feature-switches", id: "user-defaults",
title: "功能开关", title: "默认设置",
description: "控制各项功能的启用状态", description: "用户相关的默认配置",
icon: <Settings className="h-5 w-5" />,
fields: [
{
id: "user.default_avatar",
label: "默认头像",
description: "新用户的默认头像图片",
type: "input",
value: "/images/avatar.png",
placeholder: "请输入默认头像URL"
},
{
id: "user.default_role",
label: "默认角色",
description: "新用户注册后的默认角色",
type: "select",
value: 'user',
options: [
{ label: "普通用户", value: "user" },
{ label: "VIP用户", value: "vip" },
{ label: "管理员", value: "admin" }
]
}
]
},
{
id: "user-registration",
title: "注册设置",
description: "用户注册相关的配置",
icon: <Shield className="h-5 w-5" />, icon: <Shield className="h-5 w-5" />,
fields: [ fields: [
{ {
id: "ops.features.registration_enabled", id: "user.register_invite_code",
label: "开放注册",
description: "是否允许新用户注册",
type: "switch",
value: data?.ops?.features?.registration_enabled ?? true
},
{
id: "ops.features.invite_code_required",
label: "需要邀请码", label: "需要邀请码",
description: "注册时是否需要邀请码", description: "注册时是否需要邀请码",
type: "switch", type: "switch",
value: data?.ops?.features?.invite_code_required ?? false, value: data?.ops?.features?.invite_code_required ?? false
showWhen: (values) => values["ops.features.registration_enabled"] === true
}, },
{ {
id: "ops.features.email_verification", id: "user.register_email_verification",
label: "邮箱验证", label: "需要邮箱验证",
description: "注册后是否需要验证邮箱", description: "注册后是否需要验证邮箱",
type: "switch", type: "switch",
value: data?.ops?.features?.email_verification ?? false value: data?.ops?.features?.email_verification ?? false
@ -426,121 +370,267 @@ export function createDynamicAdminConfig(
] ]
}, },
{ {
id: "limits-config", id: "user-access",
title: "限制配置", title: "访问控制",
description: "系统资源和使用限制", description: "用户访问和登录相关设置",
icon: <Database className="h-5 w-5" />, icon: <Lock className="h-5 w-5" />,
fields: [ fields: [
{ {
id: "ops.limits.max_users", id: "user.open_login",
label: "最大用户数", label: "开放登录",
description: "系统允许的最大用户数量", description: "是否允许用户登录",
type: "number", type: "switch",
value: data?.ops?.limits?.max_users || 1000, value: true
validation: {
required: true,
min: 1,
max: 100000
}
}, },
{ {
id: "ops.limits.max_invite_codes_per_user", id: "user.open_reset_password",
label: "用户最大邀请码数", label: "开放重置密码",
description: "每个用户最多可以生成的邀请码数量", description: "是否允许用户重置密码",
type: "number", type: "switch",
value: data?.ops?.limits?.max_invite_codes_per_user || 10, value: true
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", id: "user-features",
title: "通知配置", title: "功能开关",
description: "系统通知和提醒设置", description: "用户相关功能的开关配置",
icon: <Mail className="h-5 w-5" />, icon: <ToggleLeft className="h-5 w-5" />,
fields: [ fields: [
{ {
id: "ops.notifications.welcome_email", id: "switch.open_register",
label: "发送欢迎邮件", label: "开放注册",
description: "新用户注册后是否发送欢迎邮件", description: "是否允许新用户注册",
type: "switch", type: "switch",
value: data?.ops?.notifications?.welcome_email ?? true value: data?.ops?.features?.registration_enabled ?? true
}, },
{ {
id: "ops.notifications.system_announcements", id: "switch.open_comment",
label: "系统公告通知", label: "开放评论",
description: "是否发送系统公告通知", description: "是否允许用户进行评论",
type: "switch", type: "switch",
value: data?.ops?.notifications?.system_announcements ?? true value: true
}, },
{ {
id: "ops.notifications.maintenance_alerts", id: "switch.open_like",
label: "维护提醒", label: "开放点赞",
description: "系统维护前是否发送提醒通知", description: "是否允许用户进行点赞",
type: "switch", type: "switch",
value: data?.ops?.notifications?.maintenance_alerts ?? true value: true
},
{
id: "switch.open_share",
label: "开放分享",
description: "是否允许用户分享内容",
type: "switch",
value: true
},
{
id: "switch.open_view",
label: "开放查看",
description: "是否允许用户查看内容",
type: "switch",
value: true
} }
] ]
} }
] ]
}, },
// 支持文档 // 邮件配置
{ {
id: "support", id: "email",
title: "支持文档", title: "邮件配置",
icon: <FileText className="h-4 w-4" />, icon: <Mail className="h-4 w-4" />,
sections: [ sections: [
{ {
id: "support-channels", id: "email-smtp",
title: "支持渠道", title: "SMTP设置",
description: "用户支持和服务渠道配置", description: "邮件服务器的SMTP配置",
icon: <MessageSquare className="h-5 w-5" />, icon: <Server className="h-5 w-5" />,
fields: [ fields: [
{ {
id: "support.channels.email", id: "email.smtp_host",
label: "支持邮箱", label: "SMTP主机",
description: "用户联系支持的邮箱地址", description: "SMTP服务器地址",
type: "email", type: "input",
value: data?.docs_support?.channels?.email || "support@mapp.com", value: "",
placeholder: "请输入SMTP主机地址"
},
{
id: "email.smtp_port",
label: "SMTP端口",
description: "SMTP服务器端口号",
type: "number",
value: 465,
validation: { validation: {
required: true, required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ min: 1,
max: 65535
} }
}, },
{ {
id: "support.channels.ticket_system", id: "email.smtp_user",
label: "工单系统地址", label: "SMTP用户名",
description: "用户提交工单的系统地址", description: "SMTP服务器登录用户名",
type: "input", type: "input",
value: data?.docs_support?.channels?.ticket_system || "/support/tickets" value: "",
placeholder: "请输入SMTP用户名"
}, },
{ {
id: "support.channels.working_hours", id: "email.smtp_password",
label: "工作时间", label: "SMTP密码",
description: "多语言的工作时间说明", description: "SMTP服务器登录密码",
type: "textarea", type: "password",
rows: 6, value: "",
value: JSON.stringify(data?.docs_support?.channels?.working_hours || { placeholder: "请输入SMTP密码"
"zh-CN": "周一至周五 9:00-18:00", }
"en": "Mon-Fri 9:00-18:00" ]
}, null, 2) },
{
id: "email-sender",
title: "发件人设置",
description: "邮件发件人相关信息",
icon: <User className="h-5 w-5" />,
fields: [
{
id: "email.smtp_from",
label: "发件人地址",
description: "系统发送邮件的发件人地址",
type: "email",
value: "",
placeholder: "请输入发件人邮箱"
},
{
id: "email.smtp_from_name",
label: "发件人姓名",
description: "系统发送邮件的发件人姓名",
type: "input",
value: "",
placeholder: "请输入发件人姓名"
},
{
id: "email.smtp_from_email",
label: "发件人邮箱",
description: "系统发送邮件的发件人邮箱",
type: "email",
value: "",
placeholder: "请输入发件人邮箱"
}
]
},
{
id: "email-templates",
title: "邮件模板",
description: "邮件模板相关配置",
icon: <FileText className="h-5 w-5" />,
fields: [
{
id: "email.system_template",
label: "系统模板",
description: "系统邮件的默认模板",
type: "select",
value: "default",
options: [
{ label: "默认模板", value: "default" },
{ label: "简洁模板", value: "simple" },
{ label: "企业模板", value: "enterprise" }
]
}
]
}
]
},
// 系统配置 (Logging + Cache)
{
id: "system",
title: "系统配置",
icon: <Database className="h-4 w-4" />,
sections: [
{
id: "logging-level",
title: "日志级别",
description: "系统日志的级别设置",
icon: <Settings className="h-5 w-5" />,
fields: [
{
id: "logging.level",
label: "日志级别",
description: "系统记录日志的最低级别",
type: "select",
value: 'info',
options: [
{ label: "调试", value: "debug" },
{ label: "信息", value: "info" },
{ label: "警告", value: "warn" },
{ label: "错误", value: "error" },
{ label: "致命", value: "fatal" }
]
}
]
},
{
id: "logging-files",
title: "日志文件管理",
description: "日志文件的管理配置",
icon: <HardDrive className="h-5 w-5" />,
fields: [
{
id: "logging.max_files",
label: "最大文件数",
description: "保留的日志文件最大数量",
type: "number",
value: 10,
validation: {
required: true,
min: 1,
max: 100
}
},
{
id: "logging.max_file_size",
label: "最大文件大小(MB)",
description: "单个日志文件的最大大小",
type: "number",
value: 10,
validation: {
required: true,
min: 1,
max: 10240
}
}
]
},
{
id: "cache-settings",
title: "缓存设置",
description: "系统缓存的配置参数",
icon: <Settings className="h-5 w-5" />,
fields: [
{
id: "cache.ttl",
label: "缓存TTL(秒)",
description: "缓存的生存时间,单位为秒",
type: "number",
value: 3600,
validation: {
required: true,
min: 1,
max: 86400
}
},
{
id: "cache.max_size",
label: "最大缓存大小(MB)",
description: "缓存的最大内存占用",
type: "number",
value: 1024,
validation: {
required: true,
min: 1,
max: 10000
}
} }
] ]
} }
@ -557,3 +647,111 @@ export function createDynamicAdminConfig(
} }
}; };
} }
// 配置更新Hook
export function useConfigUpdate() {
const [updateConfigBatch, { loading, error, data }] = useMutation(UPDATE_CONFIG_BATCH);
const updateConfigs = async (configs: Record<string, any>) => {
try {
// 将配置对象转换为UpdateConfig数组
const updateConfigs: UpdateConfig[] = Object.entries(configs).map(([key, value]) => ({
key,
value: String(value), // 直接转换为字符串避免JSON.stringify添加双引号
description: undefined,
category: undefined,
is_editable: true
}));
const result = await updateConfigBatch({
variables: {
input: updateConfigs
}
});
if (result.data?.updateConfigBatch === "successed") {
toast.success("配置更新成功!");
return true;
} else {
toast.error("配置更新失败");
return false;
}
} catch (err) {
console.error("更新配置失败:", err);
toast.error("配置更新失败,请检查网络连接");
return false;
}
};
return {
updateConfigs,
loading,
error,
data
};
}
// 配置保存按钮组件
export function ConfigSaveButton({
onSave,
loading = false,
disabled = false
}: {
onSave: () => void;
loading?: boolean;
disabled?: boolean;
}) {
return (
<Button
onClick={onSave}
disabled={disabled || loading}
className="flex items-center gap-2"
>
{loading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="h-4 w-4" />
</>
)}
</Button>
);
}
// 配置状态指示器
export function ConfigStatusIndicator({
status,
message
}: {
status: 'idle' | 'loading' | 'success' | 'error';
message?: string;
}) {
if (status === 'idle') return null;
return (
<div className="flex items-center gap-2 p-2 rounded-md">
{status === 'loading' && (
<>
<RefreshCw className="h-4 w-4 animate-spin text-blue-500" />
<span className="text-blue-600">...</span>
</>
)}
{status === 'success' && (
<>
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-green-600"></span>
</>
)}
{status === 'error' && (
<>
<AlertCircle className="h-4 w-4 text-red-500" />
<span className="text-red-600">{message || '保存失败'}</span>
</>
)}
</div>
);
}

View File

@ -1,8 +1,7 @@
"use client"; "use client";
import React, { useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { ApolloProvider } from '@apollo/client'; import { AdminPanel } from "./panel";
import { AdminPanel } from "@/components/admin";
import { createDynamicAdminConfig } from "./dynamic-admin-config"; import { createDynamicAdminConfig } from "./dynamic-admin-config";
import { import {
useConfigs, useConfigs,
@ -11,19 +10,18 @@ import {
flattenConfigObject, flattenConfigObject,
unflattenConfigObject unflattenConfigObject
} from "@/hooks/use-site-config"; } from "@/hooks/use-site-config";
import { createApolloClient } from "@/lib/apollo-client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loader2, AlertCircle, CheckCircle, RefreshCw } from "lucide-react"; import { Loader2, AlertCircle, CheckCircle, RefreshCw } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ScrollArea } from "@/components/ui/scroll-area";
// 配置管理页面内容组件 // 配置管理页面内容组件
function AdminPageContent() { export default function AdminPage() {
const { configs, loading: loadingConfigs, error: errorConfigs, refetch: refetchConfigs } = useConfigs(); const { configs, loading: loadingConfigs, error: errorConfigs, refetch: refetchConfigs } = useConfigs();
const { validation, loading: validationLoading, refetch: refetchValidation } = useConfigValidation(); const { validation, loading: validationLoading, refetch: refetchValidation } = useConfigValidation();
const { updateConfigs, updating } = useConfigUpdater(); const { updateConfigs, updating } = useConfigUpdater();
const [lastSaved, setLastSaved] = useState<Date | null>(null); const [lastSaved, setLastSaved] = useState<Date | null>(null);
// 将 configs 列表转换为初始键值 // 将 configs 列表转换为初始键值
@ -44,23 +42,18 @@ function AdminPageContent() {
// 处理配置保存 // 处理配置保存
const handleSave = async (values: Record<string, any>) => { const handleSave = async (values: Record<string, any>) => {
try { try {
console.log("保存数据:", values);
// 将表单值转换为配置更新格式 // 将表单值转换为配置更新格式
const configUpdates = flattenConfigObject(values); const configUpdates = flattenConfigObject(values);
const result = await updateConfigs(configUpdates); const result = await updateConfigs(configUpdates);
if (result.success) { setLastSaved(new Date());
setLastSaved(new Date()); toast.success(`配置保存成功${result.failedKeys?.length ? `,但有 ${result.failedKeys.length} 项失败` : ''}`);
toast.success(`配置保存成功${result.failedKeys?.length ? `,但有 ${result.failedKeys.length} 项失败` : ''}`);
// 刷新配置数据
refetchConfigs();
refetchValidation();
// 刷新配置数据
refetchConfigs();
refetchValidation();
} else {
toast.error(result.message || '配置保存失败');
}
} catch (error) { } catch (error) {
console.error('Save config error:', error); console.error('Save config error:', error);
toast.error('配置保存失败,请重试'); toast.error('配置保存失败,请重试');
@ -177,7 +170,7 @@ function AdminPageContent() {
); );
return ( return (
<div className="space-y-6"> <div>
{/* 状态信息栏 */} {/* 状态信息栏 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -221,7 +214,6 @@ function AdminPageContent() {
</div> </div>
</div> </div>
{/* 验证警告和错误 */}
{validation && !validation.valid && ( {validation && !validation.valid && (
<Card className="border-destructive bg-destructive/5"> <Card className="border-destructive bg-destructive/5">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@ -270,14 +262,3 @@ function AdminPageContent() {
</div> </div>
); );
} }
// 主页面组件带Apollo Provider
export default function AdminDemoPage() {
const apolloClient = createApolloClient();
return (
<ApolloProvider client={apolloClient}>
<AdminPageContent />
</ApolloProvider>
);
}

258
app/admin/common/panel.tsx Normal file
View File

@ -0,0 +1,258 @@
import React, { act, useEffect } from "react";
import { AdminPanelConfig, TabConfig } from "@/types/admin-panel";
import { cn } from "@/lib/utils";
import { SiteHeader } from "../site-header";
import { Button } from "@/components/ui/button";
import { AlertCircle, CheckCircle, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent
} from "@/components/ui/tabs";
import { useAdminPanel } from "@/hooks/use-admin-panel";
import { AdminSection } from "@/components/admin";
import { configFormSchema, ConfigFormValues } from "@/types/config"
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Form } from "@/components/ui/form";
import { toast } from "sonner";
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 { state, actions, helpers, form } = useAdminPanel({
config,
initialValues,
onSubmit
});
const visibleTabs = config.tabs.filter(tab =>
!tab.permissions || tab.permissions.some(p => hasPermission(p))
);
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 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;
};
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";
const getVisibleSections = (tab: TabConfig) => {
return tab.sections.filter(section =>
!section.permissions || section.permissions.some(p => hasPermission(p))
);
};
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={async () => await actions.save()}
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">
<Form {...form}>
<form>
<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>
{visibleTabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="space-y-6">
{getVisibleSections(tab).map((section) => (
<AdminSection
key={section.id}
section={section}
disabled={state.loading}
onChange={actions.setValue}
onBlur={() => { }} // Could implement field-level validation
form={form}
/>
))}
</TabsContent>
))}
</Tabs>
</form>
</Form>
</div>
</div>
);
}

View File

@ -1,21 +1,25 @@
"use client"; "use client";
import React from "react"; import React, { useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { SectionConfig, FieldConfig } from "@/types/admin-panel"; import { SectionConfig, FieldConfig } from "@/types/admin-panel";
import { FieldRenderer } from "./field-renderer"; import { FieldRenderer } from "./field-renderer";
import { FormControl, FormField, FormItem, FormLabel } from "../ui/form";
import { useForm, UseFormReturn, } from "react-hook-form";
import { toast } from "sonner";
interface AdminSectionProps { interface AdminSectionProps {
section: SectionConfig; section: SectionConfig;
values: Record<string, any>; values?: Record<string, any>;
errors: Record<string, string>; errors?: Record<string, string>;
disabled?: boolean; disabled?: boolean;
onChange: (fieldId: string, value: any) => void; onChange: (fieldId: string, value: any) => void;
onBlur?: (fieldId: string) => void; onBlur?: (fieldId: string) => void;
className?: string; className?: string;
form?: any;
} }
export function AdminSection({ export function AdminSection({
@ -25,7 +29,8 @@ export function AdminSection({
disabled = false, disabled = false,
onChange, onChange,
onBlur, onBlur,
className className,
form
}: AdminSectionProps) { }: AdminSectionProps) {
@ -44,58 +49,92 @@ export function AdminSection({
// Render field with label and description // Render field with label and description
const renderFieldWithLabel = (field: FieldConfig) => { const renderFieldWithLabel = (field: FieldConfig) => {
const value = getFieldValue(field);
const error = errors[field.id];
const fieldDisabled = disabled || field.disabled;
return ( return (
<div
<FormField
control={form.control}
name={field.id}
key={field.id} key={field.id}
className={cn( render={({ field: formField }: any) => (
"space-y-2", <FormItem
field.grid?.span && `col-span-${field.grid.span}`, className={cn(
field.grid?.offset && `col-start-${field.grid.offset + 1}` "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 <FormLabel
htmlFor={field.id}
className={cn( className={cn(
"text-sm font-medium", "text-sm font-medium",
field.validation?.required && "after:content-['*'] after:ml-0.5 after:text-destructive" field.validation?.required && "after:content-['*'] after:ml-0.5 after:text-destructive"
)} )}
> >
{field.label} {field.label}
</Label> </FormLabel>
{field.description && (
<p className="text-xs text-muted-foreground"> <FormControl>
{field.description} <FieldRenderer
</p> field={field}
)} onChange={(newValue) => onChange(field.id, newValue)}
</div> onBlur={() => onBlur?.(field.id)}
{field.type === "switch" && ( form_field={formField}
<FieldRenderer />
field={field}
value={value} </FormControl>
error={error}
disabled={fieldDisabled} </FormItem>
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> />
// <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>
); );
}; };

View File

@ -14,12 +14,13 @@ import { FieldConfig } from "@/types/admin-panel";
interface FieldRendererProps { interface FieldRendererProps {
field: FieldConfig; field: FieldConfig;
value: any; value?: any;
error?: string; error?: string;
disabled?: boolean; disabled?: boolean;
onChange: (value: any) => void; onChange: (value: any) => void;
onBlur?: () => void; onBlur?: () => void;
className?: string; className?: string;
form_field?: any;
} }
export function FieldRenderer({ export function FieldRenderer({
@ -29,7 +30,9 @@ export function FieldRenderer({
disabled = false, disabled = false,
onChange, onChange,
onBlur, onBlur,
className className,
form_field
}: FieldRendererProps) { }: FieldRendererProps) {
const isDisabled = disabled || field.disabled; const isDisabled = disabled || field.disabled;
const isReadOnly = field.readOnly; const isReadOnly = field.readOnly;
@ -43,17 +46,6 @@ export function FieldRenderer({
); );
} }
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 = () => { const renderField = () => {
switch (field.type) { switch (field.type) {
@ -63,53 +55,42 @@ export function FieldRenderer({
case "tel": case "tel":
return ( return (
<Input <Input
{...commonProps} {...form_field}
type={field.type === "input" ? "text" : field.type}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
/> />
); );
case "password": case "password":
return ( return (
<Input <Input
{...commonProps}
type="password" type="password"
value={value || ""} {...form_field}
onChange={(e) => onChange(e.target.value)}
/> />
); );
case "number": case "number":
return ( return (
<Input <Input
{...commonProps}
type="number" type="number"
min={field.min} {...form_field}
max={field.max}
step={field.step}
value={value || ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : "")}
/> />
); );
case "textarea": case "textarea":
return ( return (
<Textarea <Textarea
{...commonProps}
rows={field.rows || 3} rows={field.rows || 3}
value={value || ""} {...form_field}
onChange={(e) => onChange(e.target.value)}
/> />
); );
case "select": case "select":
return ( return (
<Select <Select
value={value?.toString() || ""} value={form_field.value?.toString() || ""}
onValueChange={(newValue) => { onValueChange={(newValue) => {
// Convert back to number if the original value was a number // Convert back to number if the original value was a number
const option = field.options?.find(opt => opt.value?.toString() === newValue); const option = field.options?.find(opt => opt.value?.toString() === newValue);
form_field.onChange(option ? option.value : newValue)
onChange(option ? option.value : newValue); onChange(option ? option.value : newValue);
}} }}
disabled={isDisabled} disabled={isDisabled}
@ -145,8 +126,11 @@ export function FieldRenderer({
return ( return (
<Switch <Switch
id={field.id} id={field.id}
checked={Boolean(value)} checked={form_field.value}
onCheckedChange={onChange} onCheckedChange={(c) => {
form_field.onChange(c)
onChange(c);
}}
disabled={isDisabled} disabled={isDisabled}
className={cn( className={cn(
error && "border-destructive", error && "border-destructive",
@ -159,8 +143,11 @@ export function FieldRenderer({
return ( return (
<Checkbox <Checkbox
id={field.id} id={field.id}
checked={Boolean(value)} checked={form_field.value}
onCheckedChange={onChange} onCheckedChange={(c) => {
form_field.onChange(c)
onChange(c);
}}
disabled={isDisabled} disabled={isDisabled}
className={cn( className={cn(
error && "border-destructive", error && "border-destructive",
@ -233,10 +220,8 @@ export function FieldRenderer({
case "datetime-local": case "datetime-local":
return ( return (
<Input <Input
{...commonProps}
type={field.type} type={field.type}
value={value || ""} {...form_field}
onChange={(e) => onChange(e.target.value)}
/> />
); );
@ -244,16 +229,13 @@ export function FieldRenderer({
return ( return (
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Input <Input
{...commonProps}
type="color" type="color"
value={value || "#000000"} {...form_field}
onChange={(e) => onChange(e.target.value)}
className="w-12 h-10 p-1 rounded border cursor-pointer" className="w-12 h-10 p-1 rounded border cursor-pointer"
/> />
<Input <Input
type="text" type="text"
value={value || ""} {...form_field}
onChange={(e) => onChange(e.target.value)}
placeholder="#000000" placeholder="#000000"
className={cn( className={cn(
"flex-1", "flex-1",
@ -266,24 +248,16 @@ export function FieldRenderer({
case "file": case "file":
return ( return (
<Input <Input
{...commonProps}
type="file" type="file"
accept={field.accept} {...form_field}
multiple={field.multiple}
onChange={(e) => {
const files = e.target.files;
onChange(field.multiple ? Array.from(files || []) : files?.[0] || null);
}}
/> />
); );
default: default:
return ( return (
<Input <Input
{...commonProps}
type="text" type="text"
value={value || ""} {...form_field}
onChange={(e) => onChange(e.target.value)}
/> />
); );
} }

View File

@ -9,6 +9,9 @@ import {
FieldConfig, FieldConfig,
ValidationRule ValidationRule
} from "@/types/admin-panel"; } from "@/types/admin-panel";
import { useForm } from "react-hook-form";
import { configFormSchema, ConfigFormValues } from "@/types/config";
import { zodResolver } from "@hookform/resolvers/zod";
// Helper function to get nested value // Helper function to get nested value
function getNestedValue(obj: any, path: string): any { function getNestedValue(obj: any, path: string): any {
@ -90,20 +93,7 @@ function getAllFields(config: AdminPanelConfig): FieldConfig[] {
export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelReturn { export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelReturn {
const { config, initialValues = {}, onSubmit } = options; 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 computedInitialValues = React.useMemo(() => {
const fields = getAllFields(config); const fields = getAllFields(config);
const values: Record<string, any> = { ...initialValues }; const values: Record<string, any> = { ...initialValues };
@ -117,21 +107,37 @@ export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelRetur
return values; return values;
}, [config, initialValues]); }, [config, initialValues]);
// Initialize values only when computed initial values change // 使用 react-hook-form 作为唯一的数据源
useEffect(() => { const form = useForm<ConfigFormValues>({
setState(prev => { resolver: zodResolver(configFormSchema),
// Only update if values are actually different to prevent loops defaultValues: computedInitialValues,
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 // 通过 form.watch() 监听所有表单数据变化
const values = form.watch();
// 简化的状态,只保留非表单数据
const [state, setState] = useState<Omit<AdminPanelState, 'values' | 'dirty'>>({
errors: {},
loading: false,
saving: false,
});
// Auto-save timer 和上次保存的值
const autoSaveTimer = useRef<NodeJS.Timeout | null>(null);
const lastSavedValues = useRef<Record<string, any>>(computedInitialValues);
// 使用 form.formState.dirtyFields 来判断字段是否已修改
const { formState } = form;
const { dirtyFields } = formState;
// 重新设置初始值(当配置或初始值变化时)
useEffect(() => {
form.reset(computedInitialValues);
lastSavedValues.current = computedInitialValues;
}, [computedInitialValues]); // 移除 form 依赖
// Auto-save 功能
useEffect(() => { useEffect(() => {
if (!config.autoSave || !onSubmit) return; if (!config.autoSave || !onSubmit) return;
@ -141,15 +147,18 @@ export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelRetur
clearTimeout(autoSaveTimer.current); clearTimeout(autoSaveTimer.current);
} }
// Check if values have changed // 检查是否有变化
const hasChanges = JSON.stringify(state.values) !== JSON.stringify(lastSavedValues.current); const hasChanges = JSON.stringify(values) !== JSON.stringify(lastSavedValues.current);
const hasDirtyFields = Object.keys(dirtyFields).length > 0;
if (hasChanges && Object.keys(state.dirty).length > 0) { if (hasChanges && hasDirtyFields) {
autoSaveTimer.current = setTimeout(async () => { autoSaveTimer.current = setTimeout(async () => {
try { try {
await onSubmit(state.values); const currentValues = form.getValues();
lastSavedValues.current = state.values; await onSubmit(currentValues);
setState(prev => ({ ...prev, dirty: {} })); lastSavedValues.current = currentValues;
// 标记所有字段为干净状态,但不改变值
form.reset(currentValues, { keepValues: true });
} catch (error) { } catch (error) {
console.error('Auto-save failed:', error); console.error('Auto-save failed:', error);
} }
@ -161,167 +170,209 @@ export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelRetur
clearTimeout(autoSaveTimer.current); clearTimeout(autoSaveTimer.current);
} }
}; };
}, [state.values, state.dirty, config.autoSave, config.autoSaveDelay, onSubmit]); }, [values, dirtyFields, config.autoSave, config.autoSaveDelay, onSubmit]); // 移除 form 依赖
// Actions // 缓存字段配置以避免循环依赖
const setValue = useCallback((path: string, value: any) => { const fields = React.useMemo(() => getAllFields(config), [config]);
setState(prev => { const validateOnChange = config.validateOnChange;
const newValues = setNestedValue(prev.values, path, value);
const newDirty = { ...prev.dirty, [path]: true };
// Clear error for this field // 防抖验证,避免频繁验证
const newErrors = { ...prev.errors }; const validationTimer = useRef<NodeJS.Timeout | null>(null);
delete newErrors[path];
// Validate on change if enabled // 执行字段验证的函数
let validationErrors = newErrors; const performValidation = useCallback(() => {
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> = {}; const errors: Record<string, string> = {};
fields.forEach(field => { fields.forEach(field => {
// Skip validation for disabled or readOnly fields // 跳过禁用或只读字段的验证
if (field.disabled || field.readOnly) return; if (field.disabled || field.readOnly) return;
// Check conditional rendering // 检查条件渲染
if (field.showWhen && !field.showWhen(state.values)) return; if (field.showWhen && !field.showWhen(values)) return;
const value = getNestedValue(state.values, field.id); const value = getNestedValue(values, field.id);
const error = validateField(field, value); const error = validateField(field, value);
if (error) { if (error) {
errors[field.id] = error; errors[field.id] = error;
} }
}); });
// Custom validation setState(prev => ({ ...prev, errors }));
}, [fields, values]);
// 初始验证 - 组件挂载时和配置变化时执行一次
useEffect(() => {
performValidation();
}, [fields, computedInitialValues]); // 当字段配置或初始值变化时重新验证
// 监听值变化进行实时验证
useEffect(() => {
if (!validateOnChange) return;
// 清除之前的验证定时器
if (validationTimer.current) {
clearTimeout(validationTimer.current);
}
// 延迟验证,避免频繁触发
validationTimer.current = setTimeout(() => {
performValidation();
}, 300); // 300ms 防抖
return () => {
if (validationTimer.current) {
clearTimeout(validationTimer.current);
}
};
}, [values, validateOnChange, performValidation]);
// Actions - 使用 form 的方法来更新值
const setValue = useCallback((path: string, value: any) => {
(form.setValue as any)(path, value, { shouldDirty: true, shouldValidate: validateOnChange });
// 清除该字段的错误
setState(prev => {
const newErrors = { ...prev.errors };
delete newErrors[path];
return { ...prev, errors: newErrors };
});
// 调用 onChange 回调
if (config.onValueChange) {
const currentValues = form.getValues();
const newValues = setNestedValue(currentValues, path, value);
config.onValueChange(path, value, newValues);
}
}, [form, config.onValueChange, validateOnChange]); // 移除 values 依赖
const setValues = useCallback((newValues: Record<string, any>) => {
Object.entries(newValues).forEach(([path, value]) => {
(form.setValue as any)(path, value, { shouldDirty: true });
});
}, [form]);
const resetValue = useCallback((path: string) => {
const field = fields.find(f => f.id === path);
if (field) {
(form.setValue as any)(path, field.value, { shouldDirty: false });
// 清除该字段的错误
setState(prev => {
const newErrors = { ...prev.errors };
delete newErrors[path];
return { ...prev, errors: newErrors };
});
}
}, [fields, form]);
const resetAll = useCallback(() => {
form.reset(computedInitialValues);
setState(prev => ({ ...prev, errors: {} }));
lastSavedValues.current = computedInitialValues;
if (config.onReset) {
config.onReset();
}
}, [computedInitialValues, config.onReset, form]);
const validate = useCallback((): boolean => {
const currentValues = form.getValues();
const errors: Record<string, string> = {};
fields.forEach(field => {
// 跳过禁用或只读字段的验证
if (field.disabled || field.readOnly) return;
// 检查条件渲染
if (field.showWhen && !field.showWhen(currentValues)) return;
const value = getNestedValue(currentValues, field.id);
const error = validateField(field, value);
if (error) {
errors[field.id] = error;
}
});
// 自定义验证
if (config.onValidate) { if (config.onValidate) {
const customErrors = config.onValidate(state.values); const customErrors = config.onValidate(currentValues);
Object.assign(errors, customErrors); Object.assign(errors, customErrors);
} }
setState(prev => ({ ...prev, errors })); setState(prev => ({ ...prev, errors }));
return Object.keys(errors).length === 0; return Object.keys(errors).length === 0;
}, [config, state.values]); }, [fields, config.onValidate, form]);
const save = useCallback(async () => { const save = useCallback(async () => {
debugger
if (!onSubmit) return; if (!onSubmit) return;
// Validate if required // 验证(如果需要)
if (config.validateOnSubmit !== false) { // if (config.validateOnSubmit !== false) {
const isValid = validate(); // const isValid = validate();
if (!isValid) return; // if (!isValid) return;
} // }
setState(prev => ({ ...prev, saving: true })); setState(prev => ({ ...prev, saving: true }));
const currentValues = form.getValues();
try { try {
await onSubmit(state.values); await onSubmit(currentValues);
lastSavedValues.current = state.values; lastSavedValues.current = currentValues;
// 标记所有字段为干净状态,但不改变值
form.reset(currentValues, { keepValues: true });
setState(prev => ({ setState(prev => ({
...prev, ...prev,
saving: false, saving: false,
dirty: {},
errors: {} errors: {}
})); }));
if (config.onSave) { if (config.onSave) {
await config.onSave(state.values); await config.onSave(currentValues);
} }
} catch (error) { } catch (error) {
setState(prev => ({ ...prev, saving: false })); setState(prev => ({ ...prev, saving: false }));
throw error; throw error;
} }
}, [config, state.values, onSubmit, validate]); }, [config.validateOnSubmit, config.onSave, onSubmit, validate, form]);
const clearErrors = useCallback(() => { const clearErrors = useCallback(() => {
setState(prev => ({ ...prev, errors: {} })); setState(prev => ({ ...prev, errors: {} }));
}, []); }, []);
// Helpers // Helpers - 直接使用 form.watch() 的数据
const getValue = useCallback((path: string) => { const getValue = useCallback((path: string) => {
return getNestedValue(state.values, path); return getNestedValue(values, path);
}, [state.values]); }, [values]);
const getError = useCallback((path: string) => { const getError = useCallback((path: string) => {
return state.errors[path]; return state.errors[path] || getNestedValue(form.formState.errors, path)?.message;
}, [state.errors]); }, [state.errors, form.formState.errors]);
const isDirty = useCallback((path?: string) => { const isDirty = useCallback((path?: string) => {
if (path) { if (path) {
return Boolean(state.dirty[path]); return Boolean(getNestedValue(dirtyFields, path));
} }
return Object.keys(state.dirty).length > 0; return Object.keys(dirtyFields).length > 0;
}, [state.dirty]); }, [dirtyFields]);
const isValid = useCallback((path?: string) => { const isValid = useCallback((path?: string) => {
if (path) { if (path) {
return !state.errors[path]; return !state.errors[path] && !getNestedValue(form.formState.errors, path);
} }
return Object.keys(state.errors).length === 0; return Object.keys(state.errors).length === 0 && form.formState.isValid;
}, [state.errors]); }, [state.errors, form.formState.errors, form.formState.isValid]);
// 构建返回的状态,包含 values
const adminPanelState: AdminPanelState = {
...state,
values,
dirty: dirtyFields,
};
return { return {
state, form,
state: adminPanelState,
actions: { actions: {
setValue, setValue,
setValues, setValues,

View File

@ -55,7 +55,6 @@ export function useConfigs() {
const { data, loading, error, refetch } = useQuery<{ configs: ConfigItemType[] }>( const { data, loading, error, refetch } = useQuery<{ configs: ConfigItemType[] }>(
GET_CONFIGS, GET_CONFIGS,
{ {
errorPolicy: 'all',
notifyOnNetworkStatusChange: true notifyOnNetworkStatusChange: true
} }
); );
@ -171,7 +170,9 @@ export function useConfigUpdater() {
const updateConfig = useCallback(async (key: string, value: any): Promise<ConfigUpdateResult> => { const updateConfig = useCallback(async (key: string, value: any): Promise<ConfigUpdateResult> => {
setUpdating(true); setUpdating(true);
try { try {
const valueStr = typeof value === 'object' ? JSON.stringify(value) : String(value); const valueStr = (value === null || value === undefined) ? '' :
(typeof value === 'object' && !Array.isArray(value)) ? JSON.stringify(value) :
String(value);
const result = await updateSetting({ const result = await updateSetting({
variables: { key, value: valueStr } variables: { key, value: valueStr }
}); });
@ -220,10 +221,21 @@ export function flattenConfigObject(obj: any, prefix = ''): ConfigUpdateInput[]
// 递归处理嵌套对象 // 递归处理嵌套对象
result.push(...flattenConfigObject(value, fullKey)); result.push(...flattenConfigObject(value, fullKey));
} else { } else {
// 处理基本类型、数组和日期 // 处理基本类型、数组和日期,统一转换为字符串
let stringValue: string;
if (value === null || value === undefined) {
stringValue = '';
} else if (Array.isArray(value) || (typeof value === 'object' && value instanceof Date)) {
// 数组或日期对象转换为JSON字符串
stringValue = JSON.stringify(value);
} else {
// 布尔值、数字、字符串等都直接转换为字符串,避免双引号
stringValue = String(value);
}
result.push({ result.push({
key: fullKey, key: fullKey,
value: value as string | number | boolean | object value: stringValue
}); });
} }
} }
@ -236,18 +248,9 @@ export function unflattenConfigObject(configs: { key: string; value: any }[]): a
const result: any = {}; const result: any = {};
for (const config of configs) { for (const config of configs) {
const keys = config.key.split('.'); const keys = config.key
let current = result; let current = result;
result[keys] = config.value;
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; return result;

View File

@ -202,24 +202,24 @@ export const VALIDATE_CONFIG = gql`
} }
`; `;
// 更新配置设置假设后端有这样的mutation // 批量更新配置
export const UPDATE_SETTING = gql` export const UPDATE_CONFIG_BATCH = gql`
mutation UpdateSetting($key: String!, $value: String!) { mutation UpdateConfigBatch($input: [UpdateConfig!]!) {
updateSetting(key: $key, value: $value) { updateConfigBatch(input: $input)
success
message
}
} }
`; `;
// 批量更新配置设置 // 更新单个配置设置
export const UPDATE_SETTING = gql`
mutation UpdateSetting($key: String!, $value: String!) {
updateConfigBatch(input: [{ key: $key, value: $value }])
}
`;
// 批量更新配置设置(兼容旧版本)
export const UPDATE_SETTINGS = gql` export const UPDATE_SETTINGS = gql`
mutation UpdateSettings($settings: [SettingInput!]!) { mutation UpdateSettings($settings: [UpdateConfig!]!) {
updateSettings(settings: $settings) { updateConfigBatch(input: $settings)
success
message
failedKeys
}
} }
`; `;

View File

@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { ReactNode } from "react"; import React, { ReactNode } from "react";
import { Globe, Users, Mail, FileText, Server, HardDrive, Shield, Settings } from "lucide-react"; import { Globe, Users, Mail, FileText, Server, HardDrive, Shield, Settings } from "lucide-react";
import { FieldConfig, SectionConfig } from "@/types/admin-panel"; import { FieldConfig, SectionConfig } from "@/types/admin-panel";
import { ConfigItemType } from "@/hooks/use-site-config"; import { ConfigItemType } from "@/hooks/use-site-config";
@ -10,7 +10,12 @@ export const commonConfigSchema = z.object({
name: z.string().min(2, "网站名称至少2个字符").max(50, "网站名称最多50个字符"), name: z.string().min(2, "网站名称至少2个字符").max(50, "网站名称最多50个字符"),
description: z.string().max(200, "网站描述最多200个字符").optional().or(z.literal("")), description: z.string().max(200, "网站描述最多200个字符").optional().or(z.literal("")),
keywords: z.string().optional().or(z.literal("")), keywords: z.string().optional().or(z.literal("")),
url: z.string().url("请输入有效的站点URL").optional().or(z.literal("")), url: z.string()
.refine((url) => {
if (!url || url === "") return true; // 允许空值
return url.startsWith("http://") || url.startsWith("https://");
}, "无效的URL格式必须以http://或https://开头")
.optional().or(z.literal("")),
logo: z.string().url("请输入有效的Logo地址").optional().or(z.literal("")), logo: z.string().url("请输入有效的Logo地址").optional().or(z.literal("")),
icp: z.string().optional().or(z.literal("")), icp: z.string().optional().or(z.literal("")),
icp_url: z.string().url("请输入有效的备案链接").optional().or(z.literal("")), icp_url: z.string().url("请输入有效的备案链接").optional().or(z.literal("")),
@ -31,7 +36,12 @@ export const commonConfigSchema = z.object({
smtp_password: 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: z.string().email("请输入有效的发信地址").optional().or(z.literal("")),
smtp_from_name: z.string().optional().or(z.literal("")), smtp_from_name: z.string().optional().or(z.literal("")),
smtp_from_email: z.string().email("请输入有效的发信邮箱").optional().or(z.literal("")), smtp_from_email: z.string()
.refine((email) => {
if (!email || email === "") return true; // 允许空值
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}, "无效的邮箱格式")
.optional().or(z.literal("")),
system_template: z.string().default("default"), system_template: z.string().default("default"),
}), }),
blog: z.object({ blog: z.object({
@ -41,7 +51,7 @@ export const commonConfigSchema = z.object({
open_comment: z.boolean().default(true), open_comment: z.boolean().default(true),
}), }),
logging: z.object({ logging: z.object({
level: z.enum(["error", "warn", "info", "debug"]).default("info"), level: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
max_files: z.number().int().min(1).max(1000).default(10), max_files: z.number().int().min(1).max(1000).default(10),
max_file_size: z.number().int().min(1).max(10240).default(10), max_file_size: z.number().int().min(1).max(10240).default(10),
}), }),
@ -70,15 +80,15 @@ const makeField = (id: string, meta: Meta, value?: any): FieldConfig => ({
}); });
// 3) 分组图标映射 // 3) 分组图标映射
const categoryIcons: Record<string, ReactNode> = { const categoryIcons: Record<string, () => ReactNode> = {
site: <Globe className="h-5 w-5" />, site: () => React.createElement(Globe, { className: "h-5 w-5" }),
user: <Users className="h-5 w-5" />, user: () => React.createElement(Users, { className: "h-5 w-5" }),
email: <Mail className="h-5 w-5" />, email: () => React.createElement(Mail, { className: "h-5 w-5" }),
blog: <FileText className="h-5 w-5" />, blog: () => React.createElement(FileText, { className: "h-5 w-5" }),
logging: <Server className="h-5 w-5" />, logging: () => React.createElement(Server, { className: "h-5 w-5" }),
cache: <HardDrive className="h-5 w-5" />, cache: () => React.createElement(HardDrive, { className: "h-5 w-5" }),
switch: <Shield className="h-5 w-5" />, switch: () => React.createElement(Shield, { className: "h-5 w-5" }),
other: <Settings className="h-5 w-5" />, other: () => React.createElement(Settings, { className: "h-5 w-5" }),
}; };
// 4) 分组标题映射 // 4) 分组标题映射
@ -97,10 +107,30 @@ const categoryTitles: Record<string, string> = {
const knownFieldsMeta: Record<string, Meta> = { const knownFieldsMeta: Record<string, Meta> = {
// site // site
"site.name": { label: "网站名称", type: "input", validation: { required: true, minLength: 2, maxLength: 50 } }, "site.name": { label: "网站名称", type: "input", validation: { required: true, minLength: 2, maxLength: 50 } },
"site.description": { label: "网站描述", type: "textarea", rows: 3 }, "site.description": { label: "网站描述", type: "textarea", rows: 3, validation: { maxLength: 200 } },
"site.keywords": { label: "关键词", type: "input", description: "逗号分隔blog,tech,ai" }, "site.keywords": { label: "关键词", type: "input", description: "逗号分隔blog,tech,ai" },
"site.url": { label: "站点URL", type: "url" }, "site.url": {
"site.logo": { label: "Logo地址", type: "url" }, label: "站点URL", type: "url", validation: {
custom: (value) => {
if (!value || value === "") return null;
if (!value.startsWith("http://") && !value.startsWith("https://")) {
return "URL必须以http://或https://开头";
}
return null;
}
}
},
"site.logo": {
label: "Logo地址", type: "url", validation: {
custom: (value) => {
if (!value || value === "") return null;
if (!value.startsWith("http://") && !value.startsWith("https://")) {
return "Logo地址必须以http://或https://开头";
}
return null;
}
}
},
"site.icp": { label: "ICP备案号", type: "input" }, "site.icp": { label: "ICP备案号", type: "input" },
"site.icp_url": { label: "备案链接", type: "url" }, "site.icp_url": { label: "备案链接", type: "url" },
"site.color_style": { "site.color_style": {
@ -132,9 +162,29 @@ const knownFieldsMeta: Record<string, Meta> = {
"email.smtp_port": { label: "SMTP 端口", type: "number", min: 1, max: 65535 }, "email.smtp_port": { label: "SMTP 端口", type: "number", min: 1, max: 65535 },
"email.smtp_user": { label: "SMTP 用户名", type: "input" }, "email.smtp_user": { label: "SMTP 用户名", type: "input" },
"email.smtp_password": { label: "SMTP 密码", type: "password" }, "email.smtp_password": { label: "SMTP 密码", type: "password" },
"email.smtp_from": { label: "发信地址", type: "email" }, "email.smtp_from": {
label: "发信地址", type: "email", validation: {
custom: (value) => {
if (!value || value === "") return null;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return "请输入有效的邮箱地址";
}
return null;
}
}
},
"email.smtp_from_name": { label: "发信人名称", type: "input" }, "email.smtp_from_name": { label: "发信人名称", type: "input" },
"email.smtp_from_email": { label: "发信邮箱", type: "email" }, "email.smtp_from_email": {
label: "发信邮箱", type: "email", validation: {
custom: (value) => {
if (!value || value === "") return null;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return "请输入有效的邮箱地址";
}
return null;
}
}
},
"email.system_template": { label: "系统模板", type: "input" }, "email.system_template": { label: "系统模板", type: "input" },
// blog // blog
"blog.default_author": { label: "默认作者", type: "input" }, "blog.default_author": { label: "默认作者", type: "input" },
@ -145,11 +195,13 @@ const knownFieldsMeta: Record<string, Meta> = {
"logging.level": { "logging.level": {
label: "日志级别", label: "日志级别",
type: "select", type: "select",
validation: { required: true },
options: [ options: [
{ label: "错误", value: "error" }, { label: "跟踪", value: "trace" },
{ label: "警告", value: "warn" }, { label: "调试", value: "debug" },
{ label: "信息", value: "info" }, { label: "信息", value: "info" },
{ label: "调试", value: "debug" } { label: "警告", value: "warn" },
{ label: "错误", value: "error" }
] ]
}, },
"logging.max_files": { label: "最大文件数", type: "number", min: 1, max: 1000 }, "logging.max_files": { label: "最大文件数", type: "number", min: 1, max: 1000 },
@ -239,13 +291,13 @@ export function buildSectionsFromConfigs(configs: ConfigItemType[]): SectionConf
.map<SectionConfig>(([group, items]) => ({ .map<SectionConfig>(([group, items]) => ({
id: `common-${group}`, id: `common-${group}`,
title: categoryTitles[group] || `${group} 配置`, title: categoryTitles[group] || `${group} 配置`,
icon: categoryIcons[group] || categoryIcons.other, icon: (categoryIcons[group] || categoryIcons.other)(),
fields: items.map(item => item.field), fields: items.map(item => item.field),
})); }));
} }
// 9) 将 zod 校验错误转换为 AdminPanel 的错误映射 // 9) 将 zod 校验错误转换为 AdminPanel 的错误映射
export function zodErrorsToAdminErrors(result: z.SafeParseReturnType<any, any>): Record<string, string> { export function zodErrorsToAdminErrors(result: z.ZodSafeParseError<any> | z.ZodSafeParseSuccess<any>): Record<string, string> {
if (result.success) return {}; if (result.success) return {};
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
for (const issue of result.error.issues) { for (const issue of result.error.issues) {

View File

@ -9,7 +9,12 @@ export const commonConfigSchema = z.object({
name: z.string().min(2, "网站名称至少2个字符").max(50, "网站名称最多50个字符"), name: z.string().min(2, "网站名称至少2个字符").max(50, "网站名称最多50个字符"),
description: z.string().max(200, "网站描述最多200个字符").optional().or(z.literal("")), description: z.string().max(200, "网站描述最多200个字符").optional().or(z.literal("")),
keywords: z.string().optional().or(z.literal("")), keywords: z.string().optional().or(z.literal("")),
url: z.string().url("请输入有效的站点URL").optional().or(z.literal("")), url: z.string()
.refine((url) => {
if (!url || url === "") return true; // 允许空值
return url.startsWith("http://") || url.startsWith("https://");
}, "无效的URL格式必须以http://或https://开头")
.optional().or(z.literal("")),
logo: z.string().url("请输入有效的Logo地址").optional().or(z.literal("")), logo: z.string().url("请输入有效的Logo地址").optional().or(z.literal("")),
icp: z.string().optional().or(z.literal("")), icp: z.string().optional().or(z.literal("")),
icp_url: z.string().url("请输入有效的备案链接").optional().or(z.literal("")), icp_url: z.string().url("请输入有效的备案链接").optional().or(z.literal("")),
@ -30,7 +35,12 @@ export const commonConfigSchema = z.object({
smtp_password: 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: z.string().email("请输入有效的发信地址").optional().or(z.literal("")),
smtp_from_name: z.string().optional().or(z.literal("")), smtp_from_name: z.string().optional().or(z.literal("")),
smtp_from_email: z.string().email("请输入有效的发信邮箱").optional().or(z.literal("")), smtp_from_email: z.string()
.refine((email) => {
if (!email || email === "") return true; // 允许空值
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}, "无效的邮箱格式")
.optional().or(z.literal("")),
system_template: z.string().default("default"), system_template: z.string().default("default"),
}), }),
blog: z.object({ blog: z.object({
@ -40,7 +50,7 @@ export const commonConfigSchema = z.object({
open_comment: z.boolean().default(true), open_comment: z.boolean().default(true),
}), }),
logging: z.object({ logging: z.object({
level: z.enum(["error", "warn", "info", "debug"]).default("info"), level: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
max_files: z.number().int().min(1).max(1000).default(10), max_files: z.number().int().min(1).max(1000).default(10),
max_file_size: z.number().int().min(1).max(10240).default(10), max_file_size: z.number().int().min(1).max(10240).default(10),
}), }),
@ -136,10 +146,11 @@ export const commonFieldsMeta: Array<{ id: string; meta: Meta }> = [
{ {
id: "logging.level", meta: { id: "logging.level", meta: {
label: "日志级别", type: "select", options: [ label: "日志级别", type: "select", options: [
{ label: "错误", value: "error" }, { label: "跟踪", value: "trace" },
{ label: "警告", value: "warn" }, { label: "调试", value: "debug" },
{ label: "信息", value: "info" }, { label: "信息", value: "info" },
{ label: "调试", value: "debug" } { label: "警告", value: "warn" },
{ label: "错误", value: "error" }
] ]
} }
}, },
@ -175,7 +186,7 @@ export function buildCommonSectionsFromMeta(): SectionConfig[] {
} }
// 5) 将 zod 校验错误转换为 AdminPanel 的错误映射 // 5) 将 zod 校验错误转换为 AdminPanel 的错误映射
export function zodErrorsToAdminErrors(result: z.SafeParseReturnType<any, any>): Record<string, string> { export function zodErrorsToAdminErrors(result: z.ZodSafeParseError<any> | z.ZodSafeParseSuccess<any>): Record<string, string> {
if (result.success) return {}; if (result.success) return {};
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
for (const issue of result.error.issues) { for (const issue of result.error.issues) {

View File

@ -1,4 +1,6 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { ConfigFormValues } from "./config";
import { UseFormReturn } from "react-hook-form";
export type FieldType = export type FieldType =
| "input" | "input"
@ -151,7 +153,7 @@ export interface AdminPanelConfig {
export interface AdminPanelState { export interface AdminPanelState {
values: Record<string, any>; values: Record<string, any>;
errors: Record<string, string>; errors: Record<string, string>;
dirty: Record<string, boolean>; dirty: Record<string, any>; // 兼容 react-hook-form 的 dirtyFields 类型
loading: boolean; loading: boolean;
saving: boolean; saving: boolean;
} }
@ -164,6 +166,7 @@ export interface UseAdminPanelOptions {
} }
export interface UseAdminPanelReturn { export interface UseAdminPanelReturn {
form: UseFormReturn<ConfigFormValues>;
state: AdminPanelState; state: AdminPanelState;
actions: { actions: {
setValue: (path: string, value: any) => void; setValue: (path: string, value: any) => void;

View File

@ -1,277 +1,64 @@
export interface Config { import { z } from "zod";
// App Configuration
app: {
name: string
version: string
debug: boolean
timezone: string
}
// Database Configuration export const configFormSchema = z.object({
database: { 'site.name': z.string(),
max_connections: number 'site.description': z.string(),
connection_timeout: number 'site.keywords': z.string(),
} 'site.url': z.string()
.refine((url) => {
if (!url || url === "") return true; // 允许空值
return url.startsWith("http://") || url.startsWith("https://");
}, "无效的URL格式必须以http://或https://开头"),
'site.logo': z.string(),
'site.copyright': z.string(),
'site.icp': z.string(),
'site.icp_url': z.string(),
'site.color_style': z.string(),
'user.default_avatar': z.string(),
'user.default_role': z.string(),
'user.register_invite_code': z.boolean(),
'user.register_email_verification': z.boolean(),
'user.open_login': z.boolean(),
'user.open_reset_password': z.boolean(),
'email.smtp_host': z.string(),
'email.smtp_port': z.number().int().min(1).max(65535),
'email.smtp_user': z.string(),
'email.smtp_password': z.string(),
'email.smtp_from': z.string(),
'email.smtp_from_name': z.string(),
'email.smtp_from_email': z.string()
.refine((email) => {
if (!email || email === "") return true; // 允许空值
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}, "无效的邮箱格式"),
'email.system_template': z.string(),
'blog.default_author': z.string(),
'blog.default_category': z.string(),
'blog.default_tag': z.string(),
'blog.open_comment': z.boolean(),
'logging.level': z.enum(["trace", "debug", "info", "warn", "error"], {
message: "日志级别必须是以下之一: trace, debug, info, warn, error"
}),
'logging.max_files': z.number().int().min(1).max(1000),
'logging.max_file_size': z.number().int().min(1).max(10240),
'cache.ttl': z.number().int().min(1).max(31536000),
'cache.max_size': z.number().int().min(1).max(1048576),
'switch.open_register': z.boolean(),
'switch.open_login': z.boolean(),
'switch.open_reset_password': z.boolean(),
'switch.open_comment': z.boolean(),
'switch.open_like': z.boolean(),
'switch.open_share': z.boolean(),
'switch.open_view': z.boolean(),
})
// Kafka Configuration export type ConfigFormValues = z.infer<typeof configFormSchema>;
kafka: {
max_retries: number
retry_delay: number
}
// Security Configuration // GraphQL UpdateConfig 输入类型
security: { export interface UpdateConfig {
session_timeout: number key: string;
max_login_attempts: number value: string; // GraphQL schema 要求所有值都是字符串
} description?: string;
category?: string;
// Logging Configuration is_editable?: boolean;
logging: {
level: string
max_files: number
}
// Cache Configuration
cache: {
ttl: number
max_size: number
}
// Site Configuration
site: {
name: string
locale_default: string
locales_supported: string[]
brand: {
logo_url: string
primary_color: string
dark_mode_default: boolean
}
footer_links: Array<{
name: string
url: string
visible_to_guest: boolean
}>
}
// Notice Configuration
notice: {
banner: {
enabled: boolean
text: Record<string, string>
}
}
// Maintenance Configuration
maintenance: {
window: {
enabled: boolean
start_time: string
end_time: string
message: Record<string, string>
}
}
// Modal Announcements
modal: {
announcements: Array<{
id: string
title: Record<string, string>
content: Record<string, string>
start_time: string
end_time: string
audience: string[]
priority: string
}>
}
// Documentation Links
docs: {
links: Array<{
name: string
url: string
description: string
}>
}
// 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 = {
// App Configuration
app: {
name: "MMAP System",
version: "1.0.0",
debug: false,
timezone: "UTC"
},
// Database Configuration
database: {
max_connections: 10,
connection_timeout: 30
},
// Kafka Configuration
kafka: {
max_retries: 3,
retry_delay: 1000
},
// Security Configuration
security: {
session_timeout: 3600,
max_login_attempts: 5
},
// Logging Configuration
logging: {
level: "info",
max_files: 10
},
// 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
}
}
} }

View File

@ -117,7 +117,7 @@ export interface ConfigValidationResultType {
// 配置更新相关类型 // 配置更新相关类型
export interface ConfigUpdateInput { export interface ConfigUpdateInput {
key: string; key: string;
value: string | number | boolean | object; value: string; // 统一使用字符串类型以符合 GraphQL schema
} }
export interface ConfigUpdateResult { export interface ConfigUpdateResult {