admin page
This commit is contained in:
parent
507746a995
commit
4bafe4d601
@ -419,7 +419,7 @@ export const defaultAdminPanelConfig: AdminPanelConfig = {
|
||||
autoSave: true,
|
||||
autoSaveDelay: 3000,
|
||||
validateOnChange: true,
|
||||
validateOnSubmit: true,
|
||||
validateOnSubmit: false,
|
||||
|
||||
// 主题设置
|
||||
theme: {
|
||||
|
||||
338
app/admin/common/dynamic-admin-config-form.tsx
Normal file
338
app/admin/common/dynamic-admin-config-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1156
app/admin/common/dynamic-admin-config-with-mutation.tsx
Normal file
1156
app/admin/common/dynamic-admin-config-with-mutation.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,31 +1,49 @@
|
||||
"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 {
|
||||
Settings,
|
||||
Globe,
|
||||
Palette,
|
||||
Bell,
|
||||
Shield,
|
||||
Users,
|
||||
Database,
|
||||
Mail,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Save,
|
||||
Server,
|
||||
HardDrive,
|
||||
Lock,
|
||||
User,
|
||||
ToggleLeft,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Upload,
|
||||
Server,
|
||||
HardDrive
|
||||
Save,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2
|
||||
} 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";
|
||||
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(
|
||||
@ -40,80 +58,8 @@ export function createDynamicAdminConfig(
|
||||
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 配置
|
||||
'site.name': data.site?.info?.name || "MMAP System",
|
||||
'site.description': "",
|
||||
'site.keywords': "",
|
||||
@ -124,7 +70,7 @@ export function createDynamicAdminConfig(
|
||||
'site.icp_url': "",
|
||||
'site.color_style': (data.site?.brand?.dark_mode_default ? 'dark' : 'light'),
|
||||
|
||||
// User
|
||||
// User 配置
|
||||
'user.default_avatar': "/images/avatar.png",
|
||||
'user.default_role': 'user',
|
||||
'user.register_invite_code': data.ops?.features?.invite_code_required ?? false,
|
||||
@ -132,7 +78,7 @@ export function createDynamicAdminConfig(
|
||||
'user.open_login': true,
|
||||
'user.open_reset_password': true,
|
||||
|
||||
// Email
|
||||
// Email 配置
|
||||
'email.smtp_host': "",
|
||||
'email.smtp_port': 465,
|
||||
'email.smtp_user': "",
|
||||
@ -142,22 +88,22 @@ export function createDynamicAdminConfig(
|
||||
'email.smtp_from_email': "",
|
||||
'email.system_template': "default",
|
||||
|
||||
// Blog
|
||||
// Blog 配置
|
||||
'blog.default_author': "",
|
||||
'blog.default_category': "",
|
||||
'blog.default_tag': "",
|
||||
'blog.open_comment': true,
|
||||
|
||||
// Logging
|
||||
// Logging 配置
|
||||
'logging.level': 'info',
|
||||
'logging.max_files': 10,
|
||||
'logging.max_file_size': 10,
|
||||
|
||||
// Cache
|
||||
// Cache 配置
|
||||
'cache.ttl': 3600,
|
||||
'cache.max_size': 1024,
|
||||
|
||||
// Switches
|
||||
// Switch 配置
|
||||
'switch.open_register': data.ops?.features?.registration_enabled ?? true,
|
||||
'switch.open_login': true,
|
||||
'switch.open_reset_password': true,
|
||||
@ -214,27 +160,20 @@ export function createDynamicAdminConfig(
|
||||
},
|
||||
|
||||
tabs: [
|
||||
// 通用配置(基于 configs 动态生成)
|
||||
// 内容配置 (Site + Blog)
|
||||
{
|
||||
id: "common",
|
||||
title: "通用配置",
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
sections: []
|
||||
},
|
||||
// 站点设置
|
||||
{
|
||||
id: "site",
|
||||
title: "站点设置",
|
||||
id: "content",
|
||||
title: "内容配置",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
sections: [
|
||||
{
|
||||
id: "site-info",
|
||||
title: "基本信息",
|
||||
description: "网站基本信息和国际化配置",
|
||||
id: "site-basic",
|
||||
title: "站点信息",
|
||||
description: "网站基本信息和设置",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
fields: [
|
||||
{
|
||||
id: "site.info.name",
|
||||
id: "site.name",
|
||||
label: "网站名称",
|
||||
description: "显示在浏览器标题栏的网站名称",
|
||||
type: "input",
|
||||
@ -247,33 +186,29 @@ export function createDynamicAdminConfig(
|
||||
}
|
||||
},
|
||||
{
|
||||
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.description",
|
||||
label: "网站描述",
|
||||
description: "网站的简要描述信息",
|
||||
type: "textarea",
|
||||
rows: 3,
|
||||
value: "",
|
||||
placeholder: "请输入网站描述"
|
||||
},
|
||||
{
|
||||
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.keywords",
|
||||
label: "网站关键词",
|
||||
description: "SEO相关的关键词,用逗号分隔",
|
||||
type: "input",
|
||||
value: "",
|
||||
placeholder: "请输入关键词,用逗号分隔"
|
||||
},
|
||||
{
|
||||
id: "site.url",
|
||||
label: "网站地址",
|
||||
description: "网站的主域名地址",
|
||||
type: "input",
|
||||
value: "/",
|
||||
placeholder: "请输入网站地址"
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -284,7 +219,7 @@ export function createDynamicAdminConfig(
|
||||
icon: <Palette className="h-5 w-5" />,
|
||||
fields: [
|
||||
{
|
||||
id: "site.brand.logo_url",
|
||||
id: "site.logo",
|
||||
label: "Logo地址",
|
||||
description: "网站Logo图片的URL地址",
|
||||
type: "input",
|
||||
@ -292,133 +227,142 @@ export function createDynamicAdminConfig(
|
||||
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: "site.color_style",
|
||||
label: "颜色风格",
|
||||
description: "网站的颜色主题风格",
|
||||
type: "select",
|
||||
value: (data?.site?.brand?.dark_mode_default ? 'dark' : 'light'),
|
||||
options: [
|
||||
{ label: "浅色主题", value: "light" },
|
||||
{ label: "深色主题", value: "dark" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "maintenance-window",
|
||||
title: "维护窗口",
|
||||
description: "系统维护时间配置",
|
||||
icon: <AlertTriangle className="h-5 w-5" />,
|
||||
id: "site-legal",
|
||||
title: "法律信息",
|
||||
description: "网站的法律相关信息",
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
fields: [
|
||||
{
|
||||
id: "maintenance.window.enabled",
|
||||
label: "启用维护模式",
|
||||
description: "启用后系统将显示维护页面",
|
||||
id: "site.copyright",
|
||||
label: "版权信息",
|
||||
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",
|
||||
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
|
||||
value: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 运营配置
|
||||
// 用户管理 (User + Switch)
|
||||
{
|
||||
id: "operations",
|
||||
title: "运营配置",
|
||||
id: "user",
|
||||
title: "用户管理",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
sections: [
|
||||
{
|
||||
id: "feature-switches",
|
||||
title: "功能开关",
|
||||
description: "控制各项功能的启用状态",
|
||||
id: "user-defaults",
|
||||
title: "默认设置",
|
||||
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" />,
|
||||
fields: [
|
||||
{
|
||||
id: "ops.features.registration_enabled",
|
||||
label: "开放注册",
|
||||
description: "是否允许新用户注册",
|
||||
type: "switch",
|
||||
value: data?.ops?.features?.registration_enabled ?? true
|
||||
},
|
||||
{
|
||||
id: "ops.features.invite_code_required",
|
||||
id: "user.register_invite_code",
|
||||
label: "需要邀请码",
|
||||
description: "注册时是否需要邀请码",
|
||||
type: "switch",
|
||||
value: data?.ops?.features?.invite_code_required ?? false,
|
||||
showWhen: (values) => values["ops.features.registration_enabled"] === true
|
||||
value: data?.ops?.features?.invite_code_required ?? false
|
||||
},
|
||||
{
|
||||
id: "ops.features.email_verification",
|
||||
label: "邮箱验证",
|
||||
id: "user.register_email_verification",
|
||||
label: "需要邮箱验证",
|
||||
description: "注册后是否需要验证邮箱",
|
||||
type: "switch",
|
||||
value: data?.ops?.features?.email_verification ?? false
|
||||
@ -426,121 +370,267 @@ export function createDynamicAdminConfig(
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "limits-config",
|
||||
title: "限制配置",
|
||||
description: "系统资源和使用限制",
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
id: "user-access",
|
||||
title: "访问控制",
|
||||
description: "用户访问和登录相关设置",
|
||||
icon: <Lock 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: "user.open_login",
|
||||
label: "开放登录",
|
||||
description: "是否允许用户登录",
|
||||
type: "switch",
|
||||
value: true
|
||||
},
|
||||
{
|
||||
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: "user.open_reset_password",
|
||||
label: "开放重置密码",
|
||||
description: "是否允许用户重置密码",
|
||||
type: "switch",
|
||||
value: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "notifications-config",
|
||||
title: "通知配置",
|
||||
description: "系统通知和提醒设置",
|
||||
icon: <Mail className="h-5 w-5" />,
|
||||
id: "user-features",
|
||||
title: "功能开关",
|
||||
description: "用户相关功能的开关配置",
|
||||
icon: <ToggleLeft className="h-5 w-5" />,
|
||||
fields: [
|
||||
{
|
||||
id: "ops.notifications.welcome_email",
|
||||
label: "发送欢迎邮件",
|
||||
description: "新用户注册后是否发送欢迎邮件",
|
||||
id: "switch.open_register",
|
||||
label: "开放注册",
|
||||
description: "是否允许新用户注册",
|
||||
type: "switch",
|
||||
value: data?.ops?.notifications?.welcome_email ?? true
|
||||
value: data?.ops?.features?.registration_enabled ?? true
|
||||
},
|
||||
{
|
||||
id: "ops.notifications.system_announcements",
|
||||
label: "系统公告通知",
|
||||
description: "是否发送系统公告通知",
|
||||
id: "switch.open_comment",
|
||||
label: "开放评论",
|
||||
description: "是否允许用户进行评论",
|
||||
type: "switch",
|
||||
value: data?.ops?.notifications?.system_announcements ?? true
|
||||
value: true
|
||||
},
|
||||
{
|
||||
id: "ops.notifications.maintenance_alerts",
|
||||
label: "维护提醒",
|
||||
description: "系统维护前是否发送提醒通知",
|
||||
id: "switch.open_like",
|
||||
label: "开放点赞",
|
||||
description: "是否允许用户进行点赞",
|
||||
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",
|
||||
title: "支持文档",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
id: "email",
|
||||
title: "邮件配置",
|
||||
icon: <Mail className="h-4 w-4" />,
|
||||
sections: [
|
||||
{
|
||||
id: "support-channels",
|
||||
title: "支持渠道",
|
||||
description: "用户支持和服务渠道配置",
|
||||
icon: <MessageSquare className="h-5 w-5" />,
|
||||
id: "email-smtp",
|
||||
title: "SMTP设置",
|
||||
description: "邮件服务器的SMTP配置",
|
||||
icon: <Server className="h-5 w-5" />,
|
||||
fields: [
|
||||
{
|
||||
id: "support.channels.email",
|
||||
label: "支持邮箱",
|
||||
description: "用户联系支持的邮箱地址",
|
||||
type: "email",
|
||||
value: data?.docs_support?.channels?.email || "support@mapp.com",
|
||||
id: "email.smtp_host",
|
||||
label: "SMTP主机",
|
||||
description: "SMTP服务器地址",
|
||||
type: "input",
|
||||
value: "",
|
||||
placeholder: "请输入SMTP主机地址"
|
||||
},
|
||||
{
|
||||
id: "email.smtp_port",
|
||||
label: "SMTP端口",
|
||||
description: "SMTP服务器端口号",
|
||||
type: "number",
|
||||
value: 465,
|
||||
validation: {
|
||||
required: true,
|
||||
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
min: 1,
|
||||
max: 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "support.channels.ticket_system",
|
||||
label: "工单系统地址",
|
||||
description: "用户提交工单的系统地址",
|
||||
id: "email.smtp_user",
|
||||
label: "SMTP用户名",
|
||||
description: "SMTP服务器登录用户名",
|
||||
type: "input",
|
||||
value: data?.docs_support?.channels?.ticket_system || "/support/tickets"
|
||||
value: "",
|
||||
placeholder: "请输入SMTP用户名"
|
||||
},
|
||||
{
|
||||
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)
|
||||
id: "email.smtp_password",
|
||||
label: "SMTP密码",
|
||||
description: "SMTP服务器登录密码",
|
||||
type: "password",
|
||||
value: "",
|
||||
placeholder: "请输入SMTP密码"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -556,4 +646,112 @@ export function createDynamicAdminConfig(
|
||||
return zodErrorsToAdminErrors(result);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 配置更新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>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { ApolloProvider } from '@apollo/client';
|
||||
import { AdminPanel } from "@/components/admin";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { AdminPanel } from "./panel";
|
||||
import { createDynamicAdminConfig } from "./dynamic-admin-config";
|
||||
import {
|
||||
useConfigs,
|
||||
@ -11,19 +10,18 @@ import {
|
||||
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 { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
// 配置管理页面内容组件
|
||||
function AdminPageContent() {
|
||||
export default function AdminPage() {
|
||||
const { configs, loading: loadingConfigs, error: errorConfigs, refetch: refetchConfigs } = useConfigs();
|
||||
const { validation, loading: validationLoading, refetch: refetchValidation } = useConfigValidation();
|
||||
const { updateConfigs, updating } = useConfigUpdater();
|
||||
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
|
||||
// 将 configs 列表转换为初始键值
|
||||
@ -44,23 +42,18 @@ function AdminPageContent() {
|
||||
// 处理配置保存
|
||||
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} 项失败` : ''}`);
|
||||
setLastSaved(new Date());
|
||||
toast.success(`配置保存成功${result.failedKeys?.length ? `,但有 ${result.failedKeys.length} 项失败` : ''}`);
|
||||
|
||||
// 刷新配置数据
|
||||
refetchConfigs();
|
||||
refetchValidation();
|
||||
|
||||
// 刷新配置数据
|
||||
refetchConfigs();
|
||||
refetchValidation();
|
||||
} else {
|
||||
toast.error(result.message || '配置保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save config error:', error);
|
||||
toast.error('配置保存失败,请重试');
|
||||
@ -177,7 +170,7 @@ function AdminPageContent() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
{/* 状态信息栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
@ -221,7 +214,6 @@ function AdminPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 验证警告和错误 */}
|
||||
{validation && !validation.valid && (
|
||||
<Card className="border-destructive bg-destructive/5">
|
||||
<CardHeader className="pb-3">
|
||||
@ -269,15 +261,4 @@ function AdminPageContent() {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 主页面组件(带Apollo Provider)
|
||||
export default function AdminDemoPage() {
|
||||
const apolloClient = createApolloClient();
|
||||
|
||||
return (
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<AdminPageContent />
|
||||
</ApolloProvider>
|
||||
);
|
||||
}
|
||||
258
app/admin/common/panel.tsx
Normal file
258
app/admin/common/panel.tsx
Normal 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>
|
||||
);
|
||||
|
||||
}
|
||||
@ -1,21 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect } 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";
|
||||
import { FormControl, FormField, FormItem, FormLabel } from "../ui/form";
|
||||
import { useForm, UseFormReturn, } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface AdminSectionProps {
|
||||
section: SectionConfig;
|
||||
values: Record<string, any>;
|
||||
errors: Record<string, string>;
|
||||
values?: Record<string, any>;
|
||||
errors?: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
onChange: (fieldId: string, value: any) => void;
|
||||
onBlur?: (fieldId: string) => void;
|
||||
className?: string;
|
||||
form?: any;
|
||||
}
|
||||
|
||||
export function AdminSection({
|
||||
@ -25,7 +29,8 @@ export function AdminSection({
|
||||
disabled = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
className
|
||||
className,
|
||||
form
|
||||
}: AdminSectionProps) {
|
||||
|
||||
|
||||
@ -44,58 +49,92 @@ export function AdminSection({
|
||||
|
||||
// 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
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={field.id}
|
||||
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}
|
||||
render={({ field: formField }: any) => (
|
||||
<FormItem
|
||||
className={cn(
|
||||
"space-y-2",
|
||||
field.grid?.span && `col-span-${field.grid.span}`,
|
||||
field.grid?.offset && `col-start-${field.grid.offset + 1}`
|
||||
)}
|
||||
>
|
||||
<FormLabel
|
||||
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)}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<FieldRenderer
|
||||
field={field}
|
||||
onChange={(newValue) => onChange(field.id, newValue)}
|
||||
onBlur={() => onBlur?.(field.id)}
|
||||
form_field={formField}
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
|
||||
</FormItem>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -14,12 +14,13 @@ import { FieldConfig } from "@/types/admin-panel";
|
||||
|
||||
interface FieldRendererProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
value?: any;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (value: any) => void;
|
||||
onBlur?: () => void;
|
||||
className?: string;
|
||||
form_field?: any;
|
||||
}
|
||||
|
||||
export function FieldRenderer({
|
||||
@ -29,7 +30,9 @@ export function FieldRenderer({
|
||||
disabled = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
className
|
||||
className,
|
||||
form_field
|
||||
|
||||
}: FieldRendererProps) {
|
||||
const isDisabled = disabled || field.disabled;
|
||||
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 = () => {
|
||||
switch (field.type) {
|
||||
@ -63,53 +55,42 @@ export function FieldRenderer({
|
||||
case "tel":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type={field.type === "input" ? "text" : field.type}
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...form_field}
|
||||
/>
|
||||
);
|
||||
|
||||
case "password":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="password"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...form_field}
|
||||
/>
|
||||
);
|
||||
|
||||
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) : "")}
|
||||
{...form_field}
|
||||
/>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Textarea
|
||||
{...commonProps}
|
||||
rows={field.rows || 3}
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...form_field}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={value?.toString() || ""}
|
||||
value={form_field.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);
|
||||
form_field.onChange(option ? option.value : newValue)
|
||||
onChange(option ? option.value : newValue);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
@ -145,8 +126,11 @@ export function FieldRenderer({
|
||||
return (
|
||||
<Switch
|
||||
id={field.id}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={onChange}
|
||||
checked={form_field.value}
|
||||
onCheckedChange={(c) => {
|
||||
form_field.onChange(c)
|
||||
onChange(c);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
error && "border-destructive",
|
||||
@ -159,8 +143,11 @@ export function FieldRenderer({
|
||||
return (
|
||||
<Checkbox
|
||||
id={field.id}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={onChange}
|
||||
checked={form_field.value}
|
||||
onCheckedChange={(c) => {
|
||||
form_field.onChange(c)
|
||||
onChange(c);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
error && "border-destructive",
|
||||
@ -233,10 +220,8 @@ export function FieldRenderer({
|
||||
case "datetime-local":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type={field.type}
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...form_field}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -244,16 +229,13 @@ export function FieldRenderer({
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="color"
|
||||
value={value || "#000000"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...form_field}
|
||||
className="w-12 h-10 p-1 rounded border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...form_field}
|
||||
placeholder="#000000"
|
||||
className={cn(
|
||||
"flex-1",
|
||||
@ -266,24 +248,16 @@ export function FieldRenderer({
|
||||
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);
|
||||
}}
|
||||
{...form_field}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="text"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...form_field}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,6 +9,9 @@ import {
|
||||
FieldConfig,
|
||||
ValidationRule
|
||||
} 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
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
@ -90,20 +93,7 @@ function getAllFields(config: AdminPanelConfig): FieldConfig[] {
|
||||
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 };
|
||||
@ -117,21 +107,37 @@ export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelRetur
|
||||
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
|
||||
// 使用 react-hook-form 作为唯一的数据源
|
||||
const form = useForm<ConfigFormValues>({
|
||||
resolver: zodResolver(configFormSchema),
|
||||
defaultValues: computedInitialValues,
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
if (!config.autoSave || !onSubmit) return;
|
||||
|
||||
@ -141,15 +147,18 @@ export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelRetur
|
||||
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 () => {
|
||||
try {
|
||||
await onSubmit(state.values);
|
||||
lastSavedValues.current = state.values;
|
||||
setState(prev => ({ ...prev, dirty: {} }));
|
||||
const currentValues = form.getValues();
|
||||
await onSubmit(currentValues);
|
||||
lastSavedValues.current = currentValues;
|
||||
// 标记所有字段为干净状态,但不改变值
|
||||
form.reset(currentValues, { keepValues: true });
|
||||
} catch (error) {
|
||||
console.error('Auto-save failed:', error);
|
||||
}
|
||||
@ -161,167 +170,209 @@ export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelRetur
|
||||
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) => {
|
||||
setState(prev => {
|
||||
const newValues = setNestedValue(prev.values, path, value);
|
||||
const newDirty = { ...prev.dirty, [path]: true };
|
||||
// 缓存字段配置以避免循环依赖
|
||||
const fields = React.useMemo(() => getAllFields(config), [config]);
|
||||
const validateOnChange = config.validateOnChange;
|
||||
|
||||
// Clear error for this field
|
||||
const newErrors = { ...prev.errors };
|
||||
delete newErrors[path];
|
||||
// 防抖验证,避免频繁验证
|
||||
const validationTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 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 performValidation = useCallback(() => {
|
||||
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;
|
||||
// 检查条件渲染
|
||||
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);
|
||||
if (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) {
|
||||
const customErrors = config.onValidate(state.values);
|
||||
const customErrors = config.onValidate(currentValues);
|
||||
Object.assign(errors, customErrors);
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, errors }));
|
||||
return Object.keys(errors).length === 0;
|
||||
}, [config, state.values]);
|
||||
}, [fields, config.onValidate, form]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
debugger
|
||||
if (!onSubmit) return;
|
||||
|
||||
// Validate if required
|
||||
if (config.validateOnSubmit !== false) {
|
||||
const isValid = validate();
|
||||
if (!isValid) return;
|
||||
}
|
||||
// 验证(如果需要)
|
||||
// if (config.validateOnSubmit !== false) {
|
||||
// const isValid = validate();
|
||||
// if (!isValid) return;
|
||||
// }
|
||||
|
||||
setState(prev => ({ ...prev, saving: true }));
|
||||
const currentValues = form.getValues();
|
||||
|
||||
try {
|
||||
await onSubmit(state.values);
|
||||
lastSavedValues.current = state.values;
|
||||
await onSubmit(currentValues);
|
||||
lastSavedValues.current = currentValues;
|
||||
// 标记所有字段为干净状态,但不改变值
|
||||
form.reset(currentValues, { keepValues: true });
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
saving: false,
|
||||
dirty: {},
|
||||
errors: {}
|
||||
}));
|
||||
|
||||
if (config.onSave) {
|
||||
await config.onSave(state.values);
|
||||
await config.onSave(currentValues);
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, saving: false }));
|
||||
throw error;
|
||||
}
|
||||
}, [config, state.values, onSubmit, validate]);
|
||||
}, [config.validateOnSubmit, config.onSave, onSubmit, validate, form]);
|
||||
|
||||
const clearErrors = useCallback(() => {
|
||||
setState(prev => ({ ...prev, errors: {} }));
|
||||
}, []);
|
||||
|
||||
// Helpers
|
||||
// Helpers - 直接使用 form.watch() 的数据
|
||||
const getValue = useCallback((path: string) => {
|
||||
return getNestedValue(state.values, path);
|
||||
}, [state.values]);
|
||||
return getNestedValue(values, path);
|
||||
}, [values]);
|
||||
|
||||
const getError = useCallback((path: string) => {
|
||||
return state.errors[path];
|
||||
}, [state.errors]);
|
||||
return state.errors[path] || getNestedValue(form.formState.errors, path)?.message;
|
||||
}, [state.errors, form.formState.errors]);
|
||||
|
||||
const isDirty = useCallback((path?: string) => {
|
||||
if (path) {
|
||||
return Boolean(state.dirty[path]);
|
||||
return Boolean(getNestedValue(dirtyFields, path));
|
||||
}
|
||||
return Object.keys(state.dirty).length > 0;
|
||||
}, [state.dirty]);
|
||||
return Object.keys(dirtyFields).length > 0;
|
||||
}, [dirtyFields]);
|
||||
|
||||
const isValid = useCallback((path?: string) => {
|
||||
if (path) {
|
||||
return !state.errors[path];
|
||||
return !state.errors[path] && !getNestedValue(form.formState.errors, path);
|
||||
}
|
||||
return Object.keys(state.errors).length === 0;
|
||||
}, [state.errors]);
|
||||
return Object.keys(state.errors).length === 0 && form.formState.isValid;
|
||||
}, [state.errors, form.formState.errors, form.formState.isValid]);
|
||||
|
||||
// 构建返回的状态,包含 values
|
||||
const adminPanelState: AdminPanelState = {
|
||||
...state,
|
||||
values,
|
||||
dirty: dirtyFields,
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
form,
|
||||
state: adminPanelState,
|
||||
actions: {
|
||||
setValue,
|
||||
setValues,
|
||||
|
||||
@ -55,7 +55,6 @@ export function useConfigs() {
|
||||
const { data, loading, error, refetch } = useQuery<{ configs: ConfigItemType[] }>(
|
||||
GET_CONFIGS,
|
||||
{
|
||||
errorPolicy: 'all',
|
||||
notifyOnNetworkStatusChange: true
|
||||
}
|
||||
);
|
||||
@ -171,7 +170,9 @@ export function useConfigUpdater() {
|
||||
const updateConfig = useCallback(async (key: string, value: any): Promise<ConfigUpdateResult> => {
|
||||
setUpdating(true);
|
||||
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({
|
||||
variables: { key, value: valueStr }
|
||||
});
|
||||
@ -220,10 +221,21 @@ export function flattenConfigObject(obj: any, prefix = ''): ConfigUpdateInput[]
|
||||
// 递归处理嵌套对象
|
||||
result.push(...flattenConfigObject(value, fullKey));
|
||||
} 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({
|
||||
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 = {};
|
||||
|
||||
for (const config of configs) {
|
||||
const keys = config.key.split('.');
|
||||
const keys = config.key
|
||||
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;
|
||||
result[keys] = config.value;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@ -202,24 +202,24 @@ export const VALIDATE_CONFIG = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// 更新配置设置(假设后端有这样的mutation)
|
||||
export const UPDATE_SETTING = gql`
|
||||
mutation UpdateSetting($key: String!, $value: String!) {
|
||||
updateSetting(key: $key, value: $value) {
|
||||
success
|
||||
message
|
||||
}
|
||||
// 批量更新配置
|
||||
export const UPDATE_CONFIG_BATCH = gql`
|
||||
mutation UpdateConfigBatch($input: [UpdateConfig!]!) {
|
||||
updateConfigBatch(input: $input)
|
||||
}
|
||||
`;
|
||||
|
||||
// 批量更新配置设置
|
||||
// 更新单个配置设置
|
||||
export const UPDATE_SETTING = gql`
|
||||
mutation UpdateSetting($key: String!, $value: String!) {
|
||||
updateConfigBatch(input: [{ key: $key, value: $value }])
|
||||
}
|
||||
`;
|
||||
|
||||
// 批量更新配置设置(兼容旧版本)
|
||||
export const UPDATE_SETTINGS = gql`
|
||||
mutation UpdateSettings($settings: [SettingInput!]!) {
|
||||
updateSettings(settings: $settings) {
|
||||
success
|
||||
message
|
||||
failedKeys
|
||||
}
|
||||
mutation UpdateSettings($settings: [UpdateConfig!]!) {
|
||||
updateConfigBatch(input: $settings)
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { FieldConfig, SectionConfig } from "@/types/admin-panel";
|
||||
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个字符"),
|
||||
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("")),
|
||||
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("")),
|
||||
icp: z.string().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_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("")),
|
||||
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"),
|
||||
}),
|
||||
blog: z.object({
|
||||
@ -41,7 +51,7 @@ export const commonConfigSchema = z.object({
|
||||
open_comment: z.boolean().default(true),
|
||||
}),
|
||||
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_file_size: z.number().int().min(1).max(10240).default(10),
|
||||
}),
|
||||
@ -63,28 +73,28 @@ export const commonConfigSchema = z.object({
|
||||
// 2) 字段元数据定义
|
||||
type Meta = Omit<FieldConfig, "id" | "value"> & { defaultValue?: any };
|
||||
|
||||
const makeField = (id: string, meta: Meta, value?: any): FieldConfig => ({
|
||||
id,
|
||||
...meta,
|
||||
value: value ?? meta.defaultValue
|
||||
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" />,
|
||||
const categoryIcons: Record<string, () => ReactNode> = {
|
||||
site: () => React.createElement(Globe, { className: "h-5 w-5" }),
|
||||
user: () => React.createElement(Users, { className: "h-5 w-5" }),
|
||||
email: () => React.createElement(Mail, { className: "h-5 w-5" }),
|
||||
blog: () => React.createElement(FileText, { className: "h-5 w-5" }),
|
||||
logging: () => React.createElement(Server, { className: "h-5 w-5" }),
|
||||
cache: () => React.createElement(HardDrive, { className: "h-5 w-5" }),
|
||||
switch: () => React.createElement(Shield, { className: "h-5 w-5" }),
|
||||
other: () => React.createElement(Settings, { className: "h-5 w-5" }),
|
||||
};
|
||||
|
||||
// 4) 分组标题映射
|
||||
const categoryTitles: Record<string, string> = {
|
||||
site: "网站信息",
|
||||
user: "用户设置",
|
||||
user: "用户设置",
|
||||
email: "邮件设置",
|
||||
blog: "博客设置",
|
||||
logging: "日志设置",
|
||||
@ -97,31 +107,51 @@ const categoryTitles: Record<string, string> = {
|
||||
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.description": { label: "网站描述", type: "textarea", rows: 3, validation: { maxLength: 200 } },
|
||||
"site.keywords": { label: "关键词", type: "input", description: "逗号分隔,如:blog,tech,ai" },
|
||||
"site.url": { label: "站点URL", type: "url" },
|
||||
"site.logo": { label: "Logo地址", type: "url" },
|
||||
"site.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_url": { label: "备案链接", type: "url" },
|
||||
"site.color_style": {
|
||||
label: "配色风格",
|
||||
type: "select",
|
||||
"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",
|
||||
"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" },
|
||||
@ -132,9 +162,29 @@ const knownFieldsMeta: Record<string, Meta> = {
|
||||
"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": {
|
||||
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_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" },
|
||||
// blog
|
||||
"blog.default_author": { label: "默认作者", type: "input" },
|
||||
@ -142,15 +192,17 @@ const knownFieldsMeta: Record<string, Meta> = {
|
||||
"blog.default_tag": { label: "默认标签", type: "input" },
|
||||
"blog.open_comment": { label: "开启评论", type: "switch" },
|
||||
// logging
|
||||
"logging.level": {
|
||||
label: "日志级别",
|
||||
type: "select",
|
||||
"logging.level": {
|
||||
label: "日志级别",
|
||||
type: "select",
|
||||
validation: { required: true },
|
||||
options: [
|
||||
{ label: "错误", value: "error" },
|
||||
{ label: "警告", value: "warn" },
|
||||
{ label: "跟踪", value: "trace" },
|
||||
{ label: "调试", value: "debug" },
|
||||
{ label: "信息", value: "info" },
|
||||
{ label: "调试", value: "debug" }
|
||||
]
|
||||
{ label: "警告", value: "warn" },
|
||||
{ label: "错误", value: "error" }
|
||||
]
|
||||
},
|
||||
"logging.max_files": { label: "最大文件数", type: "number", min: 1, max: 1000 },
|
||||
"logging.max_file_size": { label: "单文件大小(MB)", type: "number", min: 1, max: 10240 },
|
||||
@ -198,18 +250,18 @@ function parseConfigValue(value: string | null | undefined, valueType: string):
|
||||
// 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) {
|
||||
@ -232,20 +284,20 @@ export function buildSectionsFromConfigs(configs: ConfigItemType[]): SectionConf
|
||||
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,
|
||||
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> {
|
||||
export function zodErrorsToAdminErrors(result: z.ZodSafeParseError<any> | z.ZodSafeParseSuccess<any>): Record<string, string> {
|
||||
if (result.success) return {};
|
||||
const errors: Record<string, string> = {};
|
||||
for (const issue of result.error.issues) {
|
||||
|
||||
@ -9,7 +9,12 @@ export const commonConfigSchema = 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("")),
|
||||
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("")),
|
||||
icp: z.string().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_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("")),
|
||||
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"),
|
||||
}),
|
||||
blog: z.object({
|
||||
@ -40,7 +50,7 @@ export const commonConfigSchema = z.object({
|
||||
open_comment: z.boolean().default(true),
|
||||
}),
|
||||
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_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: {
|
||||
label: "日志级别", type: "select", options: [
|
||||
{ label: "错误", value: "error" },
|
||||
{ label: "警告", value: "warn" },
|
||||
{ label: "跟踪", value: "trace" },
|
||||
{ label: "调试", value: "debug" },
|
||||
{ label: "信息", value: "info" },
|
||||
{ label: "调试", value: "debug" }
|
||||
{ label: "警告", value: "warn" },
|
||||
{ label: "错误", value: "error" }
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -175,7 +186,7 @@ export function buildCommonSectionsFromMeta(): SectionConfig[] {
|
||||
}
|
||||
|
||||
// 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 {};
|
||||
const errors: Record<string, string> = {};
|
||||
for (const issue of result.error.issues) {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ConfigFormValues } from "./config";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
|
||||
export type FieldType =
|
||||
| "input"
|
||||
@ -151,7 +153,7 @@ export interface AdminPanelConfig {
|
||||
export interface AdminPanelState {
|
||||
values: Record<string, any>;
|
||||
errors: Record<string, string>;
|
||||
dirty: Record<string, boolean>;
|
||||
dirty: Record<string, any>; // 兼容 react-hook-form 的 dirtyFields 类型
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
}
|
||||
@ -164,6 +166,7 @@ export interface UseAdminPanelOptions {
|
||||
}
|
||||
|
||||
export interface UseAdminPanelReturn {
|
||||
form: UseFormReturn<ConfigFormValues>;
|
||||
state: AdminPanelState;
|
||||
actions: {
|
||||
setValue: (path: string, value: any) => void;
|
||||
|
||||
335
types/config.ts
335
types/config.ts
@ -1,277 +1,64 @@
|
||||
export interface Config {
|
||||
// App Configuration
|
||||
app: {
|
||||
name: string
|
||||
version: string
|
||||
debug: boolean
|
||||
timezone: string
|
||||
}
|
||||
import { z } from "zod";
|
||||
|
||||
// Database Configuration
|
||||
database: {
|
||||
max_connections: number
|
||||
connection_timeout: number
|
||||
}
|
||||
export const configFormSchema = z.object({
|
||||
'site.name': z.string(),
|
||||
'site.description': z.string(),
|
||||
'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
|
||||
kafka: {
|
||||
max_retries: number
|
||||
retry_delay: number
|
||||
}
|
||||
export type ConfigFormValues = z.infer<typeof configFormSchema>;
|
||||
|
||||
// Security Configuration
|
||||
security: {
|
||||
session_timeout: number
|
||||
max_login_attempts: number
|
||||
}
|
||||
|
||||
// Logging Configuration
|
||||
logging: {
|
||||
level: string
|
||||
max_files: number
|
||||
}
|
||||
|
||||
// Cache Configuration
|
||||
cache: {
|
||||
ttl: number
|
||||
max_size: number
|
||||
}
|
||||
|
||||
// Site Configuration
|
||||
site: {
|
||||
name: string
|
||||
locale_default: string
|
||||
locales_supported: string[]
|
||||
brand: {
|
||||
logo_url: string
|
||||
primary_color: string
|
||||
dark_mode_default: boolean
|
||||
}
|
||||
footer_links: Array<{
|
||||
name: string
|
||||
url: string
|
||||
visible_to_guest: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
// Notice Configuration
|
||||
notice: {
|
||||
banner: {
|
||||
enabled: boolean
|
||||
text: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
// Maintenance Configuration
|
||||
maintenance: {
|
||||
window: {
|
||||
enabled: boolean
|
||||
start_time: string
|
||||
end_time: string
|
||||
message: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
// Modal Announcements
|
||||
modal: {
|
||||
announcements: Array<{
|
||||
id: string
|
||||
title: Record<string, string>
|
||||
content: Record<string, string>
|
||||
start_time: string
|
||||
end_time: string
|
||||
audience: string[]
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
// Documentation Links
|
||||
docs: {
|
||||
links: Array<{
|
||||
name: string
|
||||
url: string
|
||||
description: string
|
||||
}>
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
// GraphQL UpdateConfig 输入类型
|
||||
export interface UpdateConfig {
|
||||
key: string;
|
||||
value: string; // GraphQL schema 要求所有值都是字符串
|
||||
description?: string;
|
||||
category?: string;
|
||||
is_editable?: boolean;
|
||||
}
|
||||
@ -117,7 +117,7 @@ export interface ConfigValidationResultType {
|
||||
// 配置更新相关类型
|
||||
export interface ConfigUpdateInput {
|
||||
key: string;
|
||||
value: string | number | boolean | object;
|
||||
value: string; // 统一使用字符串类型以符合 GraphQL schema
|
||||
}
|
||||
|
||||
export interface ConfigUpdateResult {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user