admin page
This commit is contained in:
parent
507746a995
commit
4bafe4d601
@ -419,7 +419,7 @@ export const defaultAdminPanelConfig: AdminPanelConfig = {
|
|||||||
autoSave: true,
|
autoSave: true,
|
||||||
autoSaveDelay: 3000,
|
autoSaveDelay: 3000,
|
||||||
validateOnChange: true,
|
validateOnChange: true,
|
||||||
validateOnSubmit: true,
|
validateOnSubmit: false,
|
||||||
|
|
||||||
// 主题设置
|
// 主题设置
|
||||||
theme: {
|
theme: {
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
import { gql, useMutation } from "@apollo/client";
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
Globe,
|
Globe,
|
||||||
Palette,
|
Palette,
|
||||||
Bell,
|
|
||||||
Shield,
|
Shield,
|
||||||
Users,
|
Users,
|
||||||
Database,
|
Database,
|
||||||
Mail,
|
Mail,
|
||||||
FileText,
|
FileText,
|
||||||
MessageSquare,
|
Server,
|
||||||
Clock,
|
HardDrive,
|
||||||
AlertTriangle,
|
Lock,
|
||||||
Save,
|
User,
|
||||||
|
ToggleLeft,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Download,
|
Download,
|
||||||
Upload,
|
Upload,
|
||||||
Server,
|
Save,
|
||||||
HardDrive
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AdminPanelConfig } from "@/types/admin-panel";
|
import { AdminPanelConfig } from "@/types/admin-panel";
|
||||||
import { commonConfigSchema, zodErrorsToAdminErrors } from "@/lib/config-zod";
|
import { commonConfigSchema, zodErrorsToAdminErrors } from "@/lib/config-zod";
|
||||||
// import { SiteOpsConfigType } from "@/types/site-config";
|
|
||||||
import { SiteOpsConfigType } from "@/types/site-config";
|
import { SiteOpsConfigType } from "@/types/site-config";
|
||||||
import { ConfigItemType } from "@/hooks/use-site-config";
|
import { ConfigItemType } from "@/hooks/use-site-config";
|
||||||
|
import { UpdateConfig } from "@/types/config";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
||||||
|
// GraphQL Mutation
|
||||||
|
const UPDATE_CONFIG_BATCH = gql`
|
||||||
|
mutation UpdateConfigBatch($input: [UpdateConfig!]!) {
|
||||||
|
updateConfigBatch(input: $input)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
// 创建基于后端数据的动态管理面板配置
|
// 创建基于后端数据的动态管理面板配置
|
||||||
export function createDynamicAdminConfig(
|
export function createDynamicAdminConfig(
|
||||||
@ -40,80 +58,8 @@ export function createDynamicAdminConfig(
|
|||||||
const getInitialValues = () => {
|
const getInitialValues = () => {
|
||||||
if (!data) return {};
|
if (!data) return {};
|
||||||
|
|
||||||
// helpers
|
|
||||||
const toDateTimeLocal = (value?: any) => {
|
|
||||||
if (!value) return "";
|
|
||||||
const d = new Date(value);
|
|
||||||
if (isNaN(d.getTime())) return "";
|
|
||||||
return d.toISOString().slice(0, 16);
|
|
||||||
};
|
|
||||||
|
|
||||||
const bannerText = data.notice_maintenance?.banner?.text || {
|
|
||||||
"zh-CN": "欢迎使用MMAP系统",
|
|
||||||
"en": "Welcome to MMAP System"
|
|
||||||
};
|
|
||||||
const maintenanceMsg = data.notice_maintenance?.maintenance_window?.message || {
|
|
||||||
"zh-CN": "系统维护中,请稍后再试",
|
|
||||||
"en": "System maintenance in progress"
|
|
||||||
};
|
|
||||||
const workingHours = data.docs_support?.channels?.working_hours || {
|
|
||||||
"zh-CN": "周一至周五 9:00-18:00",
|
|
||||||
"en": "Mon-Fri 9:00-18:00"
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 站点信息
|
// Site 配置
|
||||||
'site.info.name': data.site?.info?.name || "MMAP System",
|
|
||||||
'site.info.locale_default': data.site?.info?.locale_default || "zh-CN",
|
|
||||||
'site.info.locales_supported': data.site?.info?.locales_supported || ["zh-CN", "en"],
|
|
||||||
|
|
||||||
// 品牌配置
|
|
||||||
'site.brand.logo_url': data.site?.brand?.logo_url || "/images/logo.png",
|
|
||||||
'site.brand.primary_color': data.site?.brand?.primary_color || "#3B82F6",
|
|
||||||
'site.brand.dark_mode_default': data.site?.brand?.dark_mode_default || false,
|
|
||||||
|
|
||||||
// 页脚链接
|
|
||||||
'site.footer_links': data.site?.footer_links || [],
|
|
||||||
|
|
||||||
// 横幅公告
|
|
||||||
'notice.banner.enabled': data.notice_maintenance?.banner?.enabled || false,
|
|
||||||
'notice.banner.text': JSON.stringify(bannerText, null, 2),
|
|
||||||
|
|
||||||
// 维护窗口
|
|
||||||
'maintenance.window.enabled': data.notice_maintenance?.maintenance_window?.enabled || false,
|
|
||||||
'maintenance.window.start_time': toDateTimeLocal(data.notice_maintenance?.maintenance_window?.start_time),
|
|
||||||
'maintenance.window.end_time': toDateTimeLocal(data.notice_maintenance?.maintenance_window?.end_time),
|
|
||||||
'maintenance.window.message': JSON.stringify(maintenanceMsg, null, 2),
|
|
||||||
|
|
||||||
// 弹窗公告
|
|
||||||
'modal.announcements': data.notice_maintenance?.modal_announcements || [],
|
|
||||||
|
|
||||||
// 文档链接
|
|
||||||
'docs.links': data.docs_support?.links || [],
|
|
||||||
|
|
||||||
// 支持渠道
|
|
||||||
'support.channels.email': data.docs_support?.channels?.email || "support@mapp.com",
|
|
||||||
'support.channels.ticket_system': data.docs_support?.channels?.ticket_system || "/support/tickets",
|
|
||||||
'support.channels.chat_groups': data.docs_support?.channels?.chat_groups || [],
|
|
||||||
'support.channels.working_hours': JSON.stringify(workingHours, null, 2),
|
|
||||||
|
|
||||||
// 运营功能开关
|
|
||||||
'ops.features.registration_enabled': data.ops?.features?.registration_enabled ?? true,
|
|
||||||
'ops.features.invite_code_required': data.ops?.features?.invite_code_required ?? false,
|
|
||||||
'ops.features.email_verification': data.ops?.features?.email_verification ?? false,
|
|
||||||
|
|
||||||
// 运营限制
|
|
||||||
'ops.limits.max_users': data.ops?.limits?.max_users || 1000,
|
|
||||||
'ops.limits.max_invite_codes_per_user': data.ops?.limits?.max_invite_codes_per_user || 10,
|
|
||||||
'ops.limits.session_timeout_hours': data.ops?.limits?.session_timeout_hours || 24,
|
|
||||||
|
|
||||||
// 通知配置
|
|
||||||
'ops.notifications.welcome_email': data.ops?.notifications?.welcome_email ?? true,
|
|
||||||
'ops.notifications.system_announcements': data.ops?.notifications?.system_announcements ?? true,
|
|
||||||
'ops.notifications.maintenance_alerts': data.ops?.notifications?.maintenance_alerts ?? true,
|
|
||||||
|
|
||||||
// —— 通用配置(映射/默认)——
|
|
||||||
// Site
|
|
||||||
'site.name': data.site?.info?.name || "MMAP System",
|
'site.name': data.site?.info?.name || "MMAP System",
|
||||||
'site.description': "",
|
'site.description': "",
|
||||||
'site.keywords': "",
|
'site.keywords': "",
|
||||||
@ -124,7 +70,7 @@ export function createDynamicAdminConfig(
|
|||||||
'site.icp_url': "",
|
'site.icp_url': "",
|
||||||
'site.color_style': (data.site?.brand?.dark_mode_default ? 'dark' : 'light'),
|
'site.color_style': (data.site?.brand?.dark_mode_default ? 'dark' : 'light'),
|
||||||
|
|
||||||
// User
|
// User 配置
|
||||||
'user.default_avatar': "/images/avatar.png",
|
'user.default_avatar': "/images/avatar.png",
|
||||||
'user.default_role': 'user',
|
'user.default_role': 'user',
|
||||||
'user.register_invite_code': data.ops?.features?.invite_code_required ?? false,
|
'user.register_invite_code': data.ops?.features?.invite_code_required ?? false,
|
||||||
@ -132,7 +78,7 @@ export function createDynamicAdminConfig(
|
|||||||
'user.open_login': true,
|
'user.open_login': true,
|
||||||
'user.open_reset_password': true,
|
'user.open_reset_password': true,
|
||||||
|
|
||||||
// Email
|
// Email 配置
|
||||||
'email.smtp_host': "",
|
'email.smtp_host': "",
|
||||||
'email.smtp_port': 465,
|
'email.smtp_port': 465,
|
||||||
'email.smtp_user': "",
|
'email.smtp_user': "",
|
||||||
@ -142,22 +88,22 @@ export function createDynamicAdminConfig(
|
|||||||
'email.smtp_from_email': "",
|
'email.smtp_from_email': "",
|
||||||
'email.system_template': "default",
|
'email.system_template': "default",
|
||||||
|
|
||||||
// Blog
|
// Blog 配置
|
||||||
'blog.default_author': "",
|
'blog.default_author': "",
|
||||||
'blog.default_category': "",
|
'blog.default_category': "",
|
||||||
'blog.default_tag': "",
|
'blog.default_tag': "",
|
||||||
'blog.open_comment': true,
|
'blog.open_comment': true,
|
||||||
|
|
||||||
// Logging
|
// Logging 配置
|
||||||
'logging.level': 'info',
|
'logging.level': 'info',
|
||||||
'logging.max_files': 10,
|
'logging.max_files': 10,
|
||||||
'logging.max_file_size': 10,
|
'logging.max_file_size': 10,
|
||||||
|
|
||||||
// Cache
|
// Cache 配置
|
||||||
'cache.ttl': 3600,
|
'cache.ttl': 3600,
|
||||||
'cache.max_size': 1024,
|
'cache.max_size': 1024,
|
||||||
|
|
||||||
// Switches
|
// Switch 配置
|
||||||
'switch.open_register': data.ops?.features?.registration_enabled ?? true,
|
'switch.open_register': data.ops?.features?.registration_enabled ?? true,
|
||||||
'switch.open_login': true,
|
'switch.open_login': true,
|
||||||
'switch.open_reset_password': true,
|
'switch.open_reset_password': true,
|
||||||
@ -214,27 +160,20 @@ export function createDynamicAdminConfig(
|
|||||||
},
|
},
|
||||||
|
|
||||||
tabs: [
|
tabs: [
|
||||||
// 通用配置(基于 configs 动态生成)
|
// 内容配置 (Site + Blog)
|
||||||
{
|
{
|
||||||
id: "common",
|
id: "content",
|
||||||
title: "通用配置",
|
title: "内容配置",
|
||||||
icon: <Settings className="h-4 w-4" />,
|
|
||||||
sections: []
|
|
||||||
},
|
|
||||||
// 站点设置
|
|
||||||
{
|
|
||||||
id: "site",
|
|
||||||
title: "站点设置",
|
|
||||||
icon: <Globe className="h-4 w-4" />,
|
icon: <Globe className="h-4 w-4" />,
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "site-info",
|
id: "site-basic",
|
||||||
title: "基本信息",
|
title: "站点信息",
|
||||||
description: "网站基本信息和国际化配置",
|
description: "网站基本信息和设置",
|
||||||
icon: <Settings className="h-5 w-5" />,
|
icon: <Settings className="h-5 w-5" />,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
id: "site.info.name",
|
id: "site.name",
|
||||||
label: "网站名称",
|
label: "网站名称",
|
||||||
description: "显示在浏览器标题栏的网站名称",
|
description: "显示在浏览器标题栏的网站名称",
|
||||||
type: "input",
|
type: "input",
|
||||||
@ -247,33 +186,29 @@ export function createDynamicAdminConfig(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "site.info.locale_default",
|
id: "site.description",
|
||||||
label: "默认语言",
|
label: "网站描述",
|
||||||
description: "网站的默认显示语言",
|
description: "网站的简要描述信息",
|
||||||
type: "select",
|
type: "textarea",
|
||||||
value: data?.site?.info?.locale_default || "zh-CN",
|
rows: 3,
|
||||||
options: [
|
value: "",
|
||||||
{ label: "简体中文", value: "zh-CN" },
|
placeholder: "请输入网站描述"
|
||||||
{ label: "English", value: "en" }
|
|
||||||
],
|
|
||||||
validation: {
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "site.info.locales_supported",
|
id: "site.keywords",
|
||||||
label: "支持的语言",
|
label: "网站关键词",
|
||||||
description: "用户可以选择的所有语言选项",
|
description: "SEO相关的关键词,用逗号分隔",
|
||||||
type: "select",
|
type: "input",
|
||||||
value: data?.site?.info?.locales_supported || ["zh-CN", "en"],
|
value: "",
|
||||||
multiple: true,
|
placeholder: "请输入关键词,用逗号分隔"
|
||||||
options: [
|
},
|
||||||
{ label: "简体中文", value: "zh-CN" },
|
{
|
||||||
{ label: "English", value: "en" },
|
id: "site.url",
|
||||||
{ label: "繁體中文", value: "zh-TW" },
|
label: "网站地址",
|
||||||
{ label: "日本語", value: "ja" },
|
description: "网站的主域名地址",
|
||||||
{ label: "한국어", value: "ko" }
|
type: "input",
|
||||||
]
|
value: "/",
|
||||||
|
placeholder: "请输入网站地址"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -284,7 +219,7 @@ export function createDynamicAdminConfig(
|
|||||||
icon: <Palette className="h-5 w-5" />,
|
icon: <Palette className="h-5 w-5" />,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
id: "site.brand.logo_url",
|
id: "site.logo",
|
||||||
label: "Logo地址",
|
label: "Logo地址",
|
||||||
description: "网站Logo图片的URL地址",
|
description: "网站Logo图片的URL地址",
|
||||||
type: "input",
|
type: "input",
|
||||||
@ -292,133 +227,142 @@ export function createDynamicAdminConfig(
|
|||||||
placeholder: "请输入Logo URL"
|
placeholder: "请输入Logo URL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "site.brand.primary_color",
|
id: "site.color_style",
|
||||||
label: "主题色",
|
label: "颜色风格",
|
||||||
description: "网站的主要色彩,用于按钮、链接等",
|
description: "网站的颜色主题风格",
|
||||||
type: "color",
|
type: "select",
|
||||||
value: data?.site?.brand?.primary_color || "#3B82F6"
|
value: (data?.site?.brand?.dark_mode_default ? 'dark' : 'light'),
|
||||||
},
|
options: [
|
||||||
{
|
{ label: "浅色主题", value: "light" },
|
||||||
id: "site.brand.dark_mode_default",
|
{ label: "深色主题", value: "dark" }
|
||||||
label: "默认深色模式",
|
]
|
||||||
description: "新用户访问时是否默认使用深色模式",
|
|
||||||
type: "switch",
|
|
||||||
value: data?.site?.brand?.dark_mode_default || false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
// 公告维护
|
|
||||||
{
|
|
||||||
id: "notice",
|
|
||||||
title: "公告维护",
|
|
||||||
icon: <Bell className="h-4 w-4" />,
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "banner-notice",
|
|
||||||
title: "横幅公告",
|
|
||||||
description: "网站顶部横幅公告设置",
|
|
||||||
icon: <Bell className="h-5 w-5" />,
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
id: "notice.banner.enabled",
|
|
||||||
label: "启用横幅公告",
|
|
||||||
description: "是否在网站顶部显示横幅公告",
|
|
||||||
type: "switch",
|
|
||||||
value: data?.notice_maintenance?.banner?.enabled || false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "notice.banner.text",
|
|
||||||
label: "公告内容",
|
|
||||||
description: "多语言公告文本",
|
|
||||||
type: "textarea",
|
|
||||||
rows: 6,
|
|
||||||
value: JSON.stringify(data?.notice_maintenance?.banner?.text || {
|
|
||||||
"zh-CN": "欢迎使用MMAP系统",
|
|
||||||
"en": "Welcome to MMAP System"
|
|
||||||
}, null, 2),
|
|
||||||
showWhen: (values) => values["notice.banner.enabled"] === true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "maintenance-window",
|
id: "site-legal",
|
||||||
title: "维护窗口",
|
title: "法律信息",
|
||||||
description: "系统维护时间配置",
|
description: "网站的法律相关信息",
|
||||||
icon: <AlertTriangle className="h-5 w-5" />,
|
icon: <Shield className="h-5 w-5" />,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
id: "maintenance.window.enabled",
|
id: "site.copyright",
|
||||||
label: "启用维护模式",
|
label: "版权信息",
|
||||||
description: "启用后系统将显示维护页面",
|
description: "网站的版权声明",
|
||||||
|
type: "input",
|
||||||
|
value: "",
|
||||||
|
placeholder: "请输入版权信息"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "site.icp",
|
||||||
|
label: "ICP备案号",
|
||||||
|
description: "网站的ICP备案号码",
|
||||||
|
type: "input",
|
||||||
|
value: "",
|
||||||
|
placeholder: "请输入ICP备案号"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "site.icp_url",
|
||||||
|
label: "ICP备案链接",
|
||||||
|
description: "ICP备案查询链接",
|
||||||
|
type: "input",
|
||||||
|
value: "",
|
||||||
|
placeholder: "请输入ICP备案链接"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "blog-defaults",
|
||||||
|
title: "博客设置",
|
||||||
|
description: "博客功能的默认配置",
|
||||||
|
icon: <FileText className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "blog.default_author",
|
||||||
|
label: "默认作者",
|
||||||
|
description: "博客文章的默认作者",
|
||||||
|
type: "input",
|
||||||
|
value: "",
|
||||||
|
placeholder: "请输入默认作者"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "blog.default_category",
|
||||||
|
label: "默认分类",
|
||||||
|
description: "博客文章的默认分类",
|
||||||
|
type: "input",
|
||||||
|
value: "",
|
||||||
|
placeholder: "请输入默认分类"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "blog.default_tag",
|
||||||
|
label: "默认标签",
|
||||||
|
description: "博客文章的默认标签",
|
||||||
|
type: "input",
|
||||||
|
value: "",
|
||||||
|
placeholder: "请输入默认标签"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "blog.open_comment",
|
||||||
|
label: "开放评论",
|
||||||
|
description: "是否允许用户对博客文章进行评论",
|
||||||
type: "switch",
|
type: "switch",
|
||||||
value: data?.notice_maintenance?.maintenance_window?.enabled || false
|
value: true
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "maintenance.window.start_time",
|
|
||||||
label: "维护开始时间",
|
|
||||||
description: "维护窗口的开始时间",
|
|
||||||
type: "datetime-local",
|
|
||||||
value: (data?.notice_maintenance?.maintenance_window?.start_time ? new Date(data.notice_maintenance.maintenance_window.start_time).toISOString().slice(0, 16) : ""),
|
|
||||||
showWhen: (values) => values["maintenance.window.enabled"] === true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "maintenance.window.end_time",
|
|
||||||
label: "维护结束时间",
|
|
||||||
description: "维护窗口的结束时间",
|
|
||||||
type: "datetime-local",
|
|
||||||
value: (data?.notice_maintenance?.maintenance_window?.end_time ? new Date(data.notice_maintenance.maintenance_window.end_time).toISOString().slice(0, 16) : ""),
|
|
||||||
showWhen: (values) => values["maintenance.window.enabled"] === true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "maintenance.window.message",
|
|
||||||
label: "维护提示信息",
|
|
||||||
description: "维护期间显示给用户的多语言提示信息",
|
|
||||||
type: "textarea",
|
|
||||||
rows: 6,
|
|
||||||
value: JSON.stringify(data?.notice_maintenance?.maintenance_window?.message || {
|
|
||||||
"zh-CN": "系统维护中,请稍后再试",
|
|
||||||
"en": "System maintenance in progress"
|
|
||||||
}, null, 2),
|
|
||||||
showWhen: (values) => values["maintenance.window.enabled"] === true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
// 运营配置
|
// 用户管理 (User + Switch)
|
||||||
{
|
{
|
||||||
id: "operations",
|
id: "user",
|
||||||
title: "运营配置",
|
title: "用户管理",
|
||||||
icon: <Users className="h-4 w-4" />,
|
icon: <Users className="h-4 w-4" />,
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "feature-switches",
|
id: "user-defaults",
|
||||||
title: "功能开关",
|
title: "默认设置",
|
||||||
description: "控制各项功能的启用状态",
|
description: "用户相关的默认配置",
|
||||||
|
icon: <Settings className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "user.default_avatar",
|
||||||
|
label: "默认头像",
|
||||||
|
description: "新用户的默认头像图片",
|
||||||
|
type: "input",
|
||||||
|
value: "/images/avatar.png",
|
||||||
|
placeholder: "请输入默认头像URL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user.default_role",
|
||||||
|
label: "默认角色",
|
||||||
|
description: "新用户注册后的默认角色",
|
||||||
|
type: "select",
|
||||||
|
value: 'user',
|
||||||
|
options: [
|
||||||
|
{ label: "普通用户", value: "user" },
|
||||||
|
{ label: "VIP用户", value: "vip" },
|
||||||
|
{ label: "管理员", value: "admin" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-registration",
|
||||||
|
title: "注册设置",
|
||||||
|
description: "用户注册相关的配置",
|
||||||
icon: <Shield className="h-5 w-5" />,
|
icon: <Shield className="h-5 w-5" />,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
id: "ops.features.registration_enabled",
|
id: "user.register_invite_code",
|
||||||
label: "开放注册",
|
|
||||||
description: "是否允许新用户注册",
|
|
||||||
type: "switch",
|
|
||||||
value: data?.ops?.features?.registration_enabled ?? true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ops.features.invite_code_required",
|
|
||||||
label: "需要邀请码",
|
label: "需要邀请码",
|
||||||
description: "注册时是否需要邀请码",
|
description: "注册时是否需要邀请码",
|
||||||
type: "switch",
|
type: "switch",
|
||||||
value: data?.ops?.features?.invite_code_required ?? false,
|
value: data?.ops?.features?.invite_code_required ?? false
|
||||||
showWhen: (values) => values["ops.features.registration_enabled"] === true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ops.features.email_verification",
|
id: "user.register_email_verification",
|
||||||
label: "邮箱验证",
|
label: "需要邮箱验证",
|
||||||
description: "注册后是否需要验证邮箱",
|
description: "注册后是否需要验证邮箱",
|
||||||
type: "switch",
|
type: "switch",
|
||||||
value: data?.ops?.features?.email_verification ?? false
|
value: data?.ops?.features?.email_verification ?? false
|
||||||
@ -426,121 +370,267 @@ export function createDynamicAdminConfig(
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "limits-config",
|
id: "user-access",
|
||||||
title: "限制配置",
|
title: "访问控制",
|
||||||
description: "系统资源和使用限制",
|
description: "用户访问和登录相关设置",
|
||||||
icon: <Database className="h-5 w-5" />,
|
icon: <Lock className="h-5 w-5" />,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
id: "ops.limits.max_users",
|
id: "user.open_login",
|
||||||
label: "最大用户数",
|
label: "开放登录",
|
||||||
description: "系统允许的最大用户数量",
|
description: "是否允许用户登录",
|
||||||
type: "number",
|
type: "switch",
|
||||||
value: data?.ops?.limits?.max_users || 1000,
|
value: true
|
||||||
validation: {
|
|
||||||
required: true,
|
|
||||||
min: 1,
|
|
||||||
max: 100000
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ops.limits.max_invite_codes_per_user",
|
id: "user.open_reset_password",
|
||||||
label: "用户最大邀请码数",
|
label: "开放重置密码",
|
||||||
description: "每个用户最多可以生成的邀请码数量",
|
description: "是否允许用户重置密码",
|
||||||
type: "number",
|
type: "switch",
|
||||||
value: data?.ops?.limits?.max_invite_codes_per_user || 10,
|
value: true
|
||||||
validation: {
|
|
||||||
required: true,
|
|
||||||
min: 0,
|
|
||||||
max: 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ops.limits.session_timeout_hours",
|
|
||||||
label: "会话超时时间(小时)",
|
|
||||||
description: "用户会话的超时时间",
|
|
||||||
type: "number",
|
|
||||||
value: data?.ops?.limits?.session_timeout_hours || 24,
|
|
||||||
validation: {
|
|
||||||
required: true,
|
|
||||||
min: 1,
|
|
||||||
max: 720
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "notifications-config",
|
id: "user-features",
|
||||||
title: "通知配置",
|
title: "功能开关",
|
||||||
description: "系统通知和提醒设置",
|
description: "用户相关功能的开关配置",
|
||||||
icon: <Mail className="h-5 w-5" />,
|
icon: <ToggleLeft className="h-5 w-5" />,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
id: "ops.notifications.welcome_email",
|
id: "switch.open_register",
|
||||||
label: "发送欢迎邮件",
|
label: "开放注册",
|
||||||
description: "新用户注册后是否发送欢迎邮件",
|
description: "是否允许新用户注册",
|
||||||
type: "switch",
|
type: "switch",
|
||||||
value: data?.ops?.notifications?.welcome_email ?? true
|
value: data?.ops?.features?.registration_enabled ?? true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ops.notifications.system_announcements",
|
id: "switch.open_comment",
|
||||||
label: "系统公告通知",
|
label: "开放评论",
|
||||||
description: "是否发送系统公告通知",
|
description: "是否允许用户进行评论",
|
||||||
type: "switch",
|
type: "switch",
|
||||||
value: data?.ops?.notifications?.system_announcements ?? true
|
value: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ops.notifications.maintenance_alerts",
|
id: "switch.open_like",
|
||||||
label: "维护提醒",
|
label: "开放点赞",
|
||||||
description: "系统维护前是否发送提醒通知",
|
description: "是否允许用户进行点赞",
|
||||||
type: "switch",
|
type: "switch",
|
||||||
value: data?.ops?.notifications?.maintenance_alerts ?? true
|
value: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "switch.open_share",
|
||||||
|
label: "开放分享",
|
||||||
|
description: "是否允许用户分享内容",
|
||||||
|
type: "switch",
|
||||||
|
value: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "switch.open_view",
|
||||||
|
label: "开放查看",
|
||||||
|
description: "是否允许用户查看内容",
|
||||||
|
type: "switch",
|
||||||
|
value: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
// 支持文档
|
// 邮件配置
|
||||||
{
|
{
|
||||||
id: "support",
|
id: "email",
|
||||||
title: "支持文档",
|
title: "邮件配置",
|
||||||
icon: <FileText className="h-4 w-4" />,
|
icon: <Mail className="h-4 w-4" />,
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "support-channels",
|
id: "email-smtp",
|
||||||
title: "支持渠道",
|
title: "SMTP设置",
|
||||||
description: "用户支持和服务渠道配置",
|
description: "邮件服务器的SMTP配置",
|
||||||
icon: <MessageSquare className="h-5 w-5" />,
|
icon: <Server className="h-5 w-5" />,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
id: "support.channels.email",
|
id: "email.smtp_host",
|
||||||
label: "支持邮箱",
|
label: "SMTP主机",
|
||||||
description: "用户联系支持的邮箱地址",
|
description: "SMTP服务器地址",
|
||||||
type: "email",
|
type: "input",
|
||||||
value: data?.docs_support?.channels?.email || "support@mapp.com",
|
value: "",
|
||||||
|
placeholder: "请输入SMTP主机地址"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email.smtp_port",
|
||||||
|
label: "SMTP端口",
|
||||||
|
description: "SMTP服务器端口号",
|
||||||
|
type: "number",
|
||||||
|
value: 465,
|
||||||
validation: {
|
validation: {
|
||||||
required: true,
|
required: true,
|
||||||
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
min: 1,
|
||||||
|
max: 65535
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "support.channels.ticket_system",
|
id: "email.smtp_user",
|
||||||
label: "工单系统地址",
|
label: "SMTP用户名",
|
||||||
description: "用户提交工单的系统地址",
|
description: "SMTP服务器登录用户名",
|
||||||
type: "input",
|
type: "input",
|
||||||
value: data?.docs_support?.channels?.ticket_system || "/support/tickets"
|
value: "",
|
||||||
|
placeholder: "请输入SMTP用户名"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "support.channels.working_hours",
|
id: "email.smtp_password",
|
||||||
label: "工作时间",
|
label: "SMTP密码",
|
||||||
description: "多语言的工作时间说明",
|
description: "SMTP服务器登录密码",
|
||||||
type: "textarea",
|
type: "password",
|
||||||
rows: 6,
|
value: "",
|
||||||
value: JSON.stringify(data?.docs_support?.channels?.working_hours || {
|
placeholder: "请输入SMTP密码"
|
||||||
"zh-CN": "周一至周五 9:00-18:00",
|
}
|
||||||
"en": "Mon-Fri 9:00-18:00"
|
]
|
||||||
}, null, 2)
|
},
|
||||||
|
{
|
||||||
|
id: "email-sender",
|
||||||
|
title: "发件人设置",
|
||||||
|
description: "邮件发件人相关信息",
|
||||||
|
icon: <User className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "email.smtp_from",
|
||||||
|
label: "发件人地址",
|
||||||
|
description: "系统发送邮件的发件人地址",
|
||||||
|
type: "email",
|
||||||
|
value: "",
|
||||||
|
placeholder: "请输入发件人邮箱"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email.smtp_from_name",
|
||||||
|
label: "发件人姓名",
|
||||||
|
description: "系统发送邮件的发件人姓名",
|
||||||
|
type: "input",
|
||||||
|
value: "",
|
||||||
|
placeholder: "请输入发件人姓名"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email.smtp_from_email",
|
||||||
|
label: "发件人邮箱",
|
||||||
|
description: "系统发送邮件的发件人邮箱",
|
||||||
|
type: "email",
|
||||||
|
value: "",
|
||||||
|
placeholder: "请输入发件人邮箱"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email-templates",
|
||||||
|
title: "邮件模板",
|
||||||
|
description: "邮件模板相关配置",
|
||||||
|
icon: <FileText className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "email.system_template",
|
||||||
|
label: "系统模板",
|
||||||
|
description: "系统邮件的默认模板",
|
||||||
|
type: "select",
|
||||||
|
value: "default",
|
||||||
|
options: [
|
||||||
|
{ label: "默认模板", value: "default" },
|
||||||
|
{ label: "简洁模板", value: "simple" },
|
||||||
|
{ label: "企业模板", value: "enterprise" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// 系统配置 (Logging + Cache)
|
||||||
|
{
|
||||||
|
id: "system",
|
||||||
|
title: "系统配置",
|
||||||
|
icon: <Database className="h-4 w-4" />,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "logging-level",
|
||||||
|
title: "日志级别",
|
||||||
|
description: "系统日志的级别设置",
|
||||||
|
icon: <Settings className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "logging.level",
|
||||||
|
label: "日志级别",
|
||||||
|
description: "系统记录日志的最低级别",
|
||||||
|
type: "select",
|
||||||
|
value: 'info',
|
||||||
|
options: [
|
||||||
|
{ label: "调试", value: "debug" },
|
||||||
|
{ label: "信息", value: "info" },
|
||||||
|
{ label: "警告", value: "warn" },
|
||||||
|
{ label: "错误", value: "error" },
|
||||||
|
{ label: "致命", value: "fatal" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "logging-files",
|
||||||
|
title: "日志文件管理",
|
||||||
|
description: "日志文件的管理配置",
|
||||||
|
icon: <HardDrive className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "logging.max_files",
|
||||||
|
label: "最大文件数",
|
||||||
|
description: "保留的日志文件最大数量",
|
||||||
|
type: "number",
|
||||||
|
value: 10,
|
||||||
|
validation: {
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "logging.max_file_size",
|
||||||
|
label: "最大文件大小(MB)",
|
||||||
|
description: "单个日志文件的最大大小",
|
||||||
|
type: "number",
|
||||||
|
value: 10,
|
||||||
|
validation: {
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 10240
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cache-settings",
|
||||||
|
title: "缓存设置",
|
||||||
|
description: "系统缓存的配置参数",
|
||||||
|
icon: <Settings className="h-5 w-5" />,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "cache.ttl",
|
||||||
|
label: "缓存TTL(秒)",
|
||||||
|
description: "缓存的生存时间,单位为秒",
|
||||||
|
type: "number",
|
||||||
|
value: 3600,
|
||||||
|
validation: {
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 86400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cache.max_size",
|
||||||
|
label: "最大缓存大小(MB)",
|
||||||
|
description: "缓存的最大内存占用",
|
||||||
|
type: "number",
|
||||||
|
value: 1024,
|
||||||
|
validation: {
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 10000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -557,3 +647,111 @@ export function createDynamicAdminConfig(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 配置更新Hook
|
||||||
|
export function useConfigUpdate() {
|
||||||
|
const [updateConfigBatch, { loading, error, data }] = useMutation(UPDATE_CONFIG_BATCH);
|
||||||
|
|
||||||
|
const updateConfigs = async (configs: Record<string, any>) => {
|
||||||
|
try {
|
||||||
|
// 将配置对象转换为UpdateConfig数组
|
||||||
|
const updateConfigs: UpdateConfig[] = Object.entries(configs).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: String(value), // 直接转换为字符串,避免JSON.stringify添加双引号
|
||||||
|
description: undefined,
|
||||||
|
category: undefined,
|
||||||
|
is_editable: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await updateConfigBatch({
|
||||||
|
variables: {
|
||||||
|
input: updateConfigs
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.data?.updateConfigBatch === "successed") {
|
||||||
|
toast.success("配置更新成功!");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error("配置更新失败");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("更新配置失败:", err);
|
||||||
|
toast.error("配置更新失败,请检查网络连接");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateConfigs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置保存按钮组件
|
||||||
|
export function ConfigSaveButton({
|
||||||
|
onSave,
|
||||||
|
loading = false,
|
||||||
|
disabled = false
|
||||||
|
}: {
|
||||||
|
onSave: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
保存中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
保存配置
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置状态指示器
|
||||||
|
export function ConfigStatusIndicator({
|
||||||
|
status,
|
||||||
|
message
|
||||||
|
}: {
|
||||||
|
status: 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
message?: string;
|
||||||
|
}) {
|
||||||
|
if (status === 'idle') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-2 rounded-md">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin text-blue-500" />
|
||||||
|
<span className="text-blue-600">保存中...</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
<span className="text-green-600">保存成功</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||||
|
<span className="text-red-600">{message || '保存失败'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { ApolloProvider } from '@apollo/client';
|
import { AdminPanel } from "./panel";
|
||||||
import { AdminPanel } from "@/components/admin";
|
|
||||||
import { createDynamicAdminConfig } from "./dynamic-admin-config";
|
import { createDynamicAdminConfig } from "./dynamic-admin-config";
|
||||||
import {
|
import {
|
||||||
useConfigs,
|
useConfigs,
|
||||||
@ -11,19 +10,18 @@ import {
|
|||||||
flattenConfigObject,
|
flattenConfigObject,
|
||||||
unflattenConfigObject
|
unflattenConfigObject
|
||||||
} from "@/hooks/use-site-config";
|
} from "@/hooks/use-site-config";
|
||||||
import { createApolloClient } from "@/lib/apollo-client";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, AlertCircle, CheckCircle, RefreshCw } from "lucide-react";
|
import { Loader2, AlertCircle, CheckCircle, RefreshCw } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
// 配置管理页面内容组件
|
// 配置管理页面内容组件
|
||||||
function AdminPageContent() {
|
export default function AdminPage() {
|
||||||
const { configs, loading: loadingConfigs, error: errorConfigs, refetch: refetchConfigs } = useConfigs();
|
const { configs, loading: loadingConfigs, error: errorConfigs, refetch: refetchConfigs } = useConfigs();
|
||||||
const { validation, loading: validationLoading, refetch: refetchValidation } = useConfigValidation();
|
const { validation, loading: validationLoading, refetch: refetchValidation } = useConfigValidation();
|
||||||
const { updateConfigs, updating } = useConfigUpdater();
|
const { updateConfigs, updating } = useConfigUpdater();
|
||||||
|
|
||||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||||
|
|
||||||
// 将 configs 列表转换为初始键值
|
// 将 configs 列表转换为初始键值
|
||||||
@ -44,23 +42,18 @@ function AdminPageContent() {
|
|||||||
// 处理配置保存
|
// 处理配置保存
|
||||||
const handleSave = async (values: Record<string, any>) => {
|
const handleSave = async (values: Record<string, any>) => {
|
||||||
try {
|
try {
|
||||||
console.log("保存数据:", values);
|
|
||||||
|
|
||||||
// 将表单值转换为配置更新格式
|
// 将表单值转换为配置更新格式
|
||||||
const configUpdates = flattenConfigObject(values);
|
const configUpdates = flattenConfigObject(values);
|
||||||
|
|
||||||
const result = await updateConfigs(configUpdates);
|
const result = await updateConfigs(configUpdates);
|
||||||
|
|
||||||
if (result.success) {
|
setLastSaved(new Date());
|
||||||
setLastSaved(new Date());
|
toast.success(`配置保存成功${result.failedKeys?.length ? `,但有 ${result.failedKeys.length} 项失败` : ''}`);
|
||||||
toast.success(`配置保存成功${result.failedKeys?.length ? `,但有 ${result.failedKeys.length} 项失败` : ''}`);
|
|
||||||
|
// 刷新配置数据
|
||||||
|
refetchConfigs();
|
||||||
|
refetchValidation();
|
||||||
|
|
||||||
// 刷新配置数据
|
|
||||||
refetchConfigs();
|
|
||||||
refetchValidation();
|
|
||||||
} else {
|
|
||||||
toast.error(result.message || '配置保存失败');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save config error:', error);
|
console.error('Save config error:', error);
|
||||||
toast.error('配置保存失败,请重试');
|
toast.error('配置保存失败,请重试');
|
||||||
@ -177,7 +170,7 @@ function AdminPageContent() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div>
|
||||||
{/* 状态信息栏 */}
|
{/* 状态信息栏 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@ -221,7 +214,6 @@ function AdminPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 验证警告和错误 */}
|
|
||||||
{validation && !validation.valid && (
|
{validation && !validation.valid && (
|
||||||
<Card className="border-destructive bg-destructive/5">
|
<Card className="border-destructive bg-destructive/5">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@ -270,14 +262,3 @@ function AdminPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主页面组件(带Apollo Provider)
|
|
||||||
export default function AdminDemoPage() {
|
|
||||||
const apolloClient = createApolloClient();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ApolloProvider client={apolloClient}>
|
|
||||||
<AdminPageContent />
|
|
||||||
</ApolloProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
258
app/admin/common/panel.tsx
Normal file
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";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
import { SectionConfig, FieldConfig } from "@/types/admin-panel";
|
import { SectionConfig, FieldConfig } from "@/types/admin-panel";
|
||||||
import { FieldRenderer } from "./field-renderer";
|
import { FieldRenderer } from "./field-renderer";
|
||||||
|
import { FormControl, FormField, FormItem, FormLabel } from "../ui/form";
|
||||||
|
import { useForm, UseFormReturn, } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface AdminSectionProps {
|
interface AdminSectionProps {
|
||||||
section: SectionConfig;
|
section: SectionConfig;
|
||||||
values: Record<string, any>;
|
values?: Record<string, any>;
|
||||||
errors: Record<string, string>;
|
errors?: Record<string, string>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (fieldId: string, value: any) => void;
|
onChange: (fieldId: string, value: any) => void;
|
||||||
onBlur?: (fieldId: string) => void;
|
onBlur?: (fieldId: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
form?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSection({
|
export function AdminSection({
|
||||||
@ -25,7 +29,8 @@ export function AdminSection({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
className
|
className,
|
||||||
|
form
|
||||||
}: AdminSectionProps) {
|
}: AdminSectionProps) {
|
||||||
|
|
||||||
|
|
||||||
@ -44,58 +49,92 @@ export function AdminSection({
|
|||||||
|
|
||||||
// Render field with label and description
|
// Render field with label and description
|
||||||
const renderFieldWithLabel = (field: FieldConfig) => {
|
const renderFieldWithLabel = (field: FieldConfig) => {
|
||||||
const value = getFieldValue(field);
|
|
||||||
const error = errors[field.id];
|
|
||||||
const fieldDisabled = disabled || field.disabled;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={field.id}
|
||||||
key={field.id}
|
key={field.id}
|
||||||
className={cn(
|
render={({ field: formField }: any) => (
|
||||||
"space-y-2",
|
<FormItem
|
||||||
field.grid?.span && `col-span-${field.grid.span}`,
|
className={cn(
|
||||||
field.grid?.offset && `col-start-${field.grid.offset + 1}`
|
"space-y-2",
|
||||||
)}
|
field.grid?.span && `col-span-${field.grid.span}`,
|
||||||
>
|
field.grid?.offset && `col-start-${field.grid.offset + 1}`
|
||||||
<div className="flex items-center justify-between">
|
)}
|
||||||
<div className="space-y-1">
|
>
|
||||||
<Label
|
<FormLabel
|
||||||
htmlFor={field.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-medium",
|
"text-sm font-medium",
|
||||||
field.validation?.required && "after:content-['*'] after:ml-0.5 after:text-destructive"
|
field.validation?.required && "after:content-['*'] after:ml-0.5 after:text-destructive"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.label}
|
{field.label}
|
||||||
</Label>
|
</FormLabel>
|
||||||
{field.description && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<FormControl>
|
||||||
{field.description}
|
<FieldRenderer
|
||||||
</p>
|
field={field}
|
||||||
)}
|
onChange={(newValue) => onChange(field.id, newValue)}
|
||||||
</div>
|
onBlur={() => onBlur?.(field.id)}
|
||||||
{field.type === "switch" && (
|
form_field={formField}
|
||||||
<FieldRenderer
|
/>
|
||||||
field={field}
|
|
||||||
value={value}
|
</FormControl>
|
||||||
error={error}
|
|
||||||
disabled={fieldDisabled}
|
</FormItem>
|
||||||
onChange={(newValue) => onChange(field.id, newValue)}
|
|
||||||
onBlur={() => onBlur?.(field.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{field.type !== "switch" && (
|
|
||||||
<FieldRenderer
|
|
||||||
field={field}
|
|
||||||
value={value}
|
|
||||||
error={error}
|
|
||||||
disabled={fieldDisabled}
|
|
||||||
onChange={(newValue) => onChange(field.id, newValue)}
|
|
||||||
onBlur={() => onBlur?.(field.id)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
// <div
|
||||||
|
// key={field.id}
|
||||||
|
// className={cn(
|
||||||
|
// "space-y-2",
|
||||||
|
// field.grid?.span && `col-span-${field.grid.span}`,
|
||||||
|
// field.grid?.offset && `col-start-${field.grid.offset + 1}`
|
||||||
|
// )}
|
||||||
|
// >
|
||||||
|
// <div className="flex items-center justify-between">
|
||||||
|
// <div className="space-y-1">
|
||||||
|
// <Label
|
||||||
|
// htmlFor={field.id}
|
||||||
|
// className={cn(
|
||||||
|
// "text-sm font-medium",
|
||||||
|
// field.validation?.required && "after:content-['*'] after:ml-0.5 after:text-destructive"
|
||||||
|
// )}
|
||||||
|
// >
|
||||||
|
// {field.label}
|
||||||
|
// </Label>
|
||||||
|
// {field.description && (
|
||||||
|
// <p className="text-xs text-muted-foreground">
|
||||||
|
// {field.description}
|
||||||
|
// </p>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// {field.type === "switch" && (
|
||||||
|
// <FieldRenderer
|
||||||
|
// field={field}
|
||||||
|
// value={value}
|
||||||
|
// error={error}
|
||||||
|
// disabled={fieldDisabled}
|
||||||
|
// onChange={(newValue) => onChange(field.id, newValue)}
|
||||||
|
// onBlur={() => onBlur?.(field.id)}
|
||||||
|
// />
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// {field.type !== "switch" && (
|
||||||
|
// <FieldRenderer
|
||||||
|
// field={field}
|
||||||
|
// value={value}
|
||||||
|
// error={error}
|
||||||
|
// disabled={fieldDisabled}
|
||||||
|
// onChange={(newValue) => onChange(field.id, newValue)}
|
||||||
|
// onBlur={() => onBlur?.(field.id)}
|
||||||
|
// />
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -14,12 +14,13 @@ import { FieldConfig } from "@/types/admin-panel";
|
|||||||
|
|
||||||
interface FieldRendererProps {
|
interface FieldRendererProps {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
value: any;
|
value?: any;
|
||||||
error?: string;
|
error?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (value: any) => void;
|
onChange: (value: any) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
form_field?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FieldRenderer({
|
export function FieldRenderer({
|
||||||
@ -29,7 +30,9 @@ export function FieldRenderer({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
className
|
className,
|
||||||
|
form_field
|
||||||
|
|
||||||
}: FieldRendererProps) {
|
}: FieldRendererProps) {
|
||||||
const isDisabled = disabled || field.disabled;
|
const isDisabled = disabled || field.disabled;
|
||||||
const isReadOnly = field.readOnly;
|
const isReadOnly = field.readOnly;
|
||||||
@ -43,17 +46,6 @@ export function FieldRenderer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const commonProps = {
|
|
||||||
id: field.id,
|
|
||||||
disabled: isDisabled,
|
|
||||||
readOnly: isReadOnly,
|
|
||||||
placeholder: field.placeholder,
|
|
||||||
onBlur,
|
|
||||||
className: cn(
|
|
||||||
error && "border-destructive focus-visible:ring-destructive",
|
|
||||||
className
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderField = () => {
|
const renderField = () => {
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
@ -63,53 +55,42 @@ export function FieldRenderer({
|
|||||||
case "tel":
|
case "tel":
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
{...form_field}
|
||||||
type={field.type === "input" ? "text" : field.type}
|
|
||||||
value={value || ""}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "password":
|
case "password":
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
|
||||||
type="password"
|
type="password"
|
||||||
value={value || ""}
|
{...form_field}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "number":
|
case "number":
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
|
||||||
type="number"
|
type="number"
|
||||||
min={field.min}
|
{...form_field}
|
||||||
max={field.max}
|
|
||||||
step={field.step}
|
|
||||||
value={value || ""}
|
|
||||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : "")}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "textarea":
|
case "textarea":
|
||||||
return (
|
return (
|
||||||
<Textarea
|
<Textarea
|
||||||
{...commonProps}
|
|
||||||
rows={field.rows || 3}
|
rows={field.rows || 3}
|
||||||
value={value || ""}
|
{...form_field}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "select":
|
case "select":
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={value?.toString() || ""}
|
value={form_field.value?.toString() || ""}
|
||||||
onValueChange={(newValue) => {
|
onValueChange={(newValue) => {
|
||||||
// Convert back to number if the original value was a number
|
// Convert back to number if the original value was a number
|
||||||
const option = field.options?.find(opt => opt.value?.toString() === newValue);
|
const option = field.options?.find(opt => opt.value?.toString() === newValue);
|
||||||
|
form_field.onChange(option ? option.value : newValue)
|
||||||
onChange(option ? option.value : newValue);
|
onChange(option ? option.value : newValue);
|
||||||
}}
|
}}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
@ -145,8 +126,11 @@ export function FieldRenderer({
|
|||||||
return (
|
return (
|
||||||
<Switch
|
<Switch
|
||||||
id={field.id}
|
id={field.id}
|
||||||
checked={Boolean(value)}
|
checked={form_field.value}
|
||||||
onCheckedChange={onChange}
|
onCheckedChange={(c) => {
|
||||||
|
form_field.onChange(c)
|
||||||
|
onChange(c);
|
||||||
|
}}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
error && "border-destructive",
|
error && "border-destructive",
|
||||||
@ -159,8 +143,11 @@ export function FieldRenderer({
|
|||||||
return (
|
return (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={field.id}
|
id={field.id}
|
||||||
checked={Boolean(value)}
|
checked={form_field.value}
|
||||||
onCheckedChange={onChange}
|
onCheckedChange={(c) => {
|
||||||
|
form_field.onChange(c)
|
||||||
|
onChange(c);
|
||||||
|
}}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
error && "border-destructive",
|
error && "border-destructive",
|
||||||
@ -233,10 +220,8 @@ export function FieldRenderer({
|
|||||||
case "datetime-local":
|
case "datetime-local":
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
|
||||||
type={field.type}
|
type={field.type}
|
||||||
value={value || ""}
|
{...form_field}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -244,16 +229,13 @@ export function FieldRenderer({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
|
||||||
type="color"
|
type="color"
|
||||||
value={value || "#000000"}
|
{...form_field}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="w-12 h-10 p-1 rounded border cursor-pointer"
|
className="w-12 h-10 p-1 rounded border cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={value || ""}
|
{...form_field}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder="#000000"
|
placeholder="#000000"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1",
|
"flex-1",
|
||||||
@ -266,24 +248,16 @@ export function FieldRenderer({
|
|||||||
case "file":
|
case "file":
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
|
||||||
type="file"
|
type="file"
|
||||||
accept={field.accept}
|
{...form_field}
|
||||||
multiple={field.multiple}
|
|
||||||
onChange={(e) => {
|
|
||||||
const files = e.target.files;
|
|
||||||
onChange(field.multiple ? Array.from(files || []) : files?.[0] || null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
|
||||||
type="text"
|
type="text"
|
||||||
value={value || ""}
|
{...form_field}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,9 @@ import {
|
|||||||
FieldConfig,
|
FieldConfig,
|
||||||
ValidationRule
|
ValidationRule
|
||||||
} from "@/types/admin-panel";
|
} from "@/types/admin-panel";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { configFormSchema, ConfigFormValues } from "@/types/config";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
// Helper function to get nested value
|
// Helper function to get nested value
|
||||||
function getNestedValue(obj: any, path: string): any {
|
function getNestedValue(obj: any, path: string): any {
|
||||||
@ -90,20 +93,7 @@ function getAllFields(config: AdminPanelConfig): FieldConfig[] {
|
|||||||
export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelReturn {
|
export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelReturn {
|
||||||
const { config, initialValues = {}, onSubmit } = options;
|
const { config, initialValues = {}, onSubmit } = options;
|
||||||
|
|
||||||
// State
|
// 计算初始值,包含字段默认值
|
||||||
const [state, setState] = useState<AdminPanelState>({
|
|
||||||
values: initialValues,
|
|
||||||
errors: {},
|
|
||||||
dirty: {},
|
|
||||||
loading: false,
|
|
||||||
saving: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-save timer
|
|
||||||
const autoSaveTimer = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const lastSavedValues = useRef<Record<string, any>>(initialValues);
|
|
||||||
|
|
||||||
// Calculate initial values with memoization to prevent unnecessary recalculations
|
|
||||||
const computedInitialValues = React.useMemo(() => {
|
const computedInitialValues = React.useMemo(() => {
|
||||||
const fields = getAllFields(config);
|
const fields = getAllFields(config);
|
||||||
const values: Record<string, any> = { ...initialValues };
|
const values: Record<string, any> = { ...initialValues };
|
||||||
@ -117,21 +107,37 @@ export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelRetur
|
|||||||
return values;
|
return values;
|
||||||
}, [config, initialValues]);
|
}, [config, initialValues]);
|
||||||
|
|
||||||
// Initialize values only when computed initial values change
|
// 使用 react-hook-form 作为唯一的数据源
|
||||||
useEffect(() => {
|
const form = useForm<ConfigFormValues>({
|
||||||
setState(prev => {
|
resolver: zodResolver(configFormSchema),
|
||||||
// Only update if values are actually different to prevent loops
|
defaultValues: computedInitialValues,
|
||||||
const currentJson = JSON.stringify(prev.values);
|
});
|
||||||
const newJson = JSON.stringify(computedInitialValues);
|
|
||||||
if (currentJson !== newJson) {
|
|
||||||
lastSavedValues.current = computedInitialValues;
|
|
||||||
return { ...prev, values: computedInitialValues };
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}, [computedInitialValues]); // Depend directly on memoized values
|
|
||||||
|
|
||||||
// Auto-save functionality
|
// 通过 form.watch() 监听所有表单数据变化
|
||||||
|
const values = form.watch();
|
||||||
|
|
||||||
|
// 简化的状态,只保留非表单数据
|
||||||
|
const [state, setState] = useState<Omit<AdminPanelState, 'values' | 'dirty'>>({
|
||||||
|
errors: {},
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-save timer 和上次保存的值
|
||||||
|
const autoSaveTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const lastSavedValues = useRef<Record<string, any>>(computedInitialValues);
|
||||||
|
|
||||||
|
// 使用 form.formState.dirtyFields 来判断字段是否已修改
|
||||||
|
const { formState } = form;
|
||||||
|
const { dirtyFields } = formState;
|
||||||
|
|
||||||
|
// 重新设置初始值(当配置或初始值变化时)
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset(computedInitialValues);
|
||||||
|
lastSavedValues.current = computedInitialValues;
|
||||||
|
}, [computedInitialValues]); // 移除 form 依赖
|
||||||
|
|
||||||
|
// Auto-save 功能
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config.autoSave || !onSubmit) return;
|
if (!config.autoSave || !onSubmit) return;
|
||||||
|
|
||||||
@ -141,15 +147,18 @@ export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelRetur
|
|||||||
clearTimeout(autoSaveTimer.current);
|
clearTimeout(autoSaveTimer.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if values have changed
|
// 检查是否有变化
|
||||||
const hasChanges = JSON.stringify(state.values) !== JSON.stringify(lastSavedValues.current);
|
const hasChanges = JSON.stringify(values) !== JSON.stringify(lastSavedValues.current);
|
||||||
|
const hasDirtyFields = Object.keys(dirtyFields).length > 0;
|
||||||
|
|
||||||
if (hasChanges && Object.keys(state.dirty).length > 0) {
|
if (hasChanges && hasDirtyFields) {
|
||||||
autoSaveTimer.current = setTimeout(async () => {
|
autoSaveTimer.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await onSubmit(state.values);
|
const currentValues = form.getValues();
|
||||||
lastSavedValues.current = state.values;
|
await onSubmit(currentValues);
|
||||||
setState(prev => ({ ...prev, dirty: {} }));
|
lastSavedValues.current = currentValues;
|
||||||
|
// 标记所有字段为干净状态,但不改变值
|
||||||
|
form.reset(currentValues, { keepValues: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auto-save failed:', error);
|
console.error('Auto-save failed:', error);
|
||||||
}
|
}
|
||||||
@ -161,167 +170,209 @@ export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelRetur
|
|||||||
clearTimeout(autoSaveTimer.current);
|
clearTimeout(autoSaveTimer.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [state.values, state.dirty, config.autoSave, config.autoSaveDelay, onSubmit]);
|
}, [values, dirtyFields, config.autoSave, config.autoSaveDelay, onSubmit]); // 移除 form 依赖
|
||||||
|
|
||||||
// Actions
|
// 缓存字段配置以避免循环依赖
|
||||||
const setValue = useCallback((path: string, value: any) => {
|
const fields = React.useMemo(() => getAllFields(config), [config]);
|
||||||
setState(prev => {
|
const validateOnChange = config.validateOnChange;
|
||||||
const newValues = setNestedValue(prev.values, path, value);
|
|
||||||
const newDirty = { ...prev.dirty, [path]: true };
|
|
||||||
|
|
||||||
// Clear error for this field
|
// 防抖验证,避免频繁验证
|
||||||
const newErrors = { ...prev.errors };
|
const validationTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
delete newErrors[path];
|
|
||||||
|
|
||||||
// Validate on change if enabled
|
// 执行字段验证的函数
|
||||||
let validationErrors = newErrors;
|
const performValidation = useCallback(() => {
|
||||||
if (config.validateOnChange) {
|
|
||||||
const fields = getAllFields(config);
|
|
||||||
const field = fields.find(f => f.id === path);
|
|
||||||
if (field) {
|
|
||||||
const error = validateField(field, value);
|
|
||||||
if (error) {
|
|
||||||
validationErrors = { ...validationErrors, [path]: error };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call onChange callback
|
|
||||||
if (config.onValueChange) {
|
|
||||||
config.onValueChange(path, value, newValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
values: newValues,
|
|
||||||
dirty: newDirty,
|
|
||||||
errors: validationErrors,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const setValues = useCallback((values: Record<string, any>) => {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
values: { ...prev.values, ...values },
|
|
||||||
dirty: Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), prev.dirty),
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetValue = useCallback((path: string) => {
|
|
||||||
const fields = getAllFields(config);
|
|
||||||
const field = fields.find(f => f.id === path);
|
|
||||||
if (field) {
|
|
||||||
setValue(path, field.value);
|
|
||||||
setState(prev => {
|
|
||||||
const newDirty = { ...prev.dirty };
|
|
||||||
delete newDirty[path];
|
|
||||||
const newErrors = { ...prev.errors };
|
|
||||||
delete newErrors[path];
|
|
||||||
return { ...prev, dirty: newDirty, errors: newErrors };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [config, setValue]);
|
|
||||||
|
|
||||||
const resetAll = useCallback(() => {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
values: computedInitialValues,
|
|
||||||
dirty: {},
|
|
||||||
errors: {},
|
|
||||||
}));
|
|
||||||
if (config.onReset) {
|
|
||||||
config.onReset();
|
|
||||||
}
|
|
||||||
}, [computedInitialValues, config]);
|
|
||||||
|
|
||||||
const validate = useCallback((): boolean => {
|
|
||||||
const fields = getAllFields(config);
|
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
fields.forEach(field => {
|
fields.forEach(field => {
|
||||||
// Skip validation for disabled or readOnly fields
|
// 跳过禁用或只读字段的验证
|
||||||
if (field.disabled || field.readOnly) return;
|
if (field.disabled || field.readOnly) return;
|
||||||
|
|
||||||
// Check conditional rendering
|
// 检查条件渲染
|
||||||
if (field.showWhen && !field.showWhen(state.values)) return;
|
if (field.showWhen && !field.showWhen(values)) return;
|
||||||
|
|
||||||
const value = getNestedValue(state.values, field.id);
|
const value = getNestedValue(values, field.id);
|
||||||
const error = validateField(field, value);
|
const error = validateField(field, value);
|
||||||
if (error) {
|
if (error) {
|
||||||
errors[field.id] = error;
|
errors[field.id] = error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom validation
|
setState(prev => ({ ...prev, errors }));
|
||||||
|
}, [fields, values]);
|
||||||
|
|
||||||
|
// 初始验证 - 组件挂载时和配置变化时执行一次
|
||||||
|
useEffect(() => {
|
||||||
|
performValidation();
|
||||||
|
}, [fields, computedInitialValues]); // 当字段配置或初始值变化时重新验证
|
||||||
|
|
||||||
|
// 监听值变化进行实时验证
|
||||||
|
useEffect(() => {
|
||||||
|
if (!validateOnChange) return;
|
||||||
|
|
||||||
|
// 清除之前的验证定时器
|
||||||
|
if (validationTimer.current) {
|
||||||
|
clearTimeout(validationTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟验证,避免频繁触发
|
||||||
|
validationTimer.current = setTimeout(() => {
|
||||||
|
performValidation();
|
||||||
|
}, 300); // 300ms 防抖
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (validationTimer.current) {
|
||||||
|
clearTimeout(validationTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [values, validateOnChange, performValidation]);
|
||||||
|
|
||||||
|
// Actions - 使用 form 的方法来更新值
|
||||||
|
const setValue = useCallback((path: string, value: any) => {
|
||||||
|
(form.setValue as any)(path, value, { shouldDirty: true, shouldValidate: validateOnChange });
|
||||||
|
|
||||||
|
// 清除该字段的错误
|
||||||
|
setState(prev => {
|
||||||
|
const newErrors = { ...prev.errors };
|
||||||
|
delete newErrors[path];
|
||||||
|
return { ...prev, errors: newErrors };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 调用 onChange 回调
|
||||||
|
if (config.onValueChange) {
|
||||||
|
const currentValues = form.getValues();
|
||||||
|
const newValues = setNestedValue(currentValues, path, value);
|
||||||
|
config.onValueChange(path, value, newValues);
|
||||||
|
}
|
||||||
|
}, [form, config.onValueChange, validateOnChange]); // 移除 values 依赖
|
||||||
|
|
||||||
|
const setValues = useCallback((newValues: Record<string, any>) => {
|
||||||
|
Object.entries(newValues).forEach(([path, value]) => {
|
||||||
|
(form.setValue as any)(path, value, { shouldDirty: true });
|
||||||
|
});
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const resetValue = useCallback((path: string) => {
|
||||||
|
const field = fields.find(f => f.id === path);
|
||||||
|
if (field) {
|
||||||
|
(form.setValue as any)(path, field.value, { shouldDirty: false });
|
||||||
|
|
||||||
|
// 清除该字段的错误
|
||||||
|
setState(prev => {
|
||||||
|
const newErrors = { ...prev.errors };
|
||||||
|
delete newErrors[path];
|
||||||
|
return { ...prev, errors: newErrors };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [fields, form]);
|
||||||
|
|
||||||
|
const resetAll = useCallback(() => {
|
||||||
|
form.reset(computedInitialValues);
|
||||||
|
setState(prev => ({ ...prev, errors: {} }));
|
||||||
|
lastSavedValues.current = computedInitialValues;
|
||||||
|
|
||||||
|
if (config.onReset) {
|
||||||
|
config.onReset();
|
||||||
|
}
|
||||||
|
}, [computedInitialValues, config.onReset, form]);
|
||||||
|
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const currentValues = form.getValues();
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
fields.forEach(field => {
|
||||||
|
// 跳过禁用或只读字段的验证
|
||||||
|
if (field.disabled || field.readOnly) return;
|
||||||
|
|
||||||
|
// 检查条件渲染
|
||||||
|
if (field.showWhen && !field.showWhen(currentValues)) return;
|
||||||
|
|
||||||
|
const value = getNestedValue(currentValues, field.id);
|
||||||
|
const error = validateField(field, value);
|
||||||
|
if (error) {
|
||||||
|
errors[field.id] = error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自定义验证
|
||||||
if (config.onValidate) {
|
if (config.onValidate) {
|
||||||
const customErrors = config.onValidate(state.values);
|
const customErrors = config.onValidate(currentValues);
|
||||||
Object.assign(errors, customErrors);
|
Object.assign(errors, customErrors);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(prev => ({ ...prev, errors }));
|
setState(prev => ({ ...prev, errors }));
|
||||||
return Object.keys(errors).length === 0;
|
return Object.keys(errors).length === 0;
|
||||||
}, [config, state.values]);
|
}, [fields, config.onValidate, form]);
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
|
debugger
|
||||||
if (!onSubmit) return;
|
if (!onSubmit) return;
|
||||||
|
|
||||||
// Validate if required
|
// 验证(如果需要)
|
||||||
if (config.validateOnSubmit !== false) {
|
// if (config.validateOnSubmit !== false) {
|
||||||
const isValid = validate();
|
// const isValid = validate();
|
||||||
if (!isValid) return;
|
// if (!isValid) return;
|
||||||
}
|
// }
|
||||||
|
|
||||||
setState(prev => ({ ...prev, saving: true }));
|
setState(prev => ({ ...prev, saving: true }));
|
||||||
|
const currentValues = form.getValues();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit(state.values);
|
await onSubmit(currentValues);
|
||||||
lastSavedValues.current = state.values;
|
lastSavedValues.current = currentValues;
|
||||||
|
// 标记所有字段为干净状态,但不改变值
|
||||||
|
form.reset(currentValues, { keepValues: true });
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
saving: false,
|
saving: false,
|
||||||
dirty: {},
|
|
||||||
errors: {}
|
errors: {}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (config.onSave) {
|
if (config.onSave) {
|
||||||
await config.onSave(state.values);
|
await config.onSave(currentValues);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setState(prev => ({ ...prev, saving: false }));
|
setState(prev => ({ ...prev, saving: false }));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [config, state.values, onSubmit, validate]);
|
}, [config.validateOnSubmit, config.onSave, onSubmit, validate, form]);
|
||||||
|
|
||||||
const clearErrors = useCallback(() => {
|
const clearErrors = useCallback(() => {
|
||||||
setState(prev => ({ ...prev, errors: {} }));
|
setState(prev => ({ ...prev, errors: {} }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Helpers
|
// Helpers - 直接使用 form.watch() 的数据
|
||||||
const getValue = useCallback((path: string) => {
|
const getValue = useCallback((path: string) => {
|
||||||
return getNestedValue(state.values, path);
|
return getNestedValue(values, path);
|
||||||
}, [state.values]);
|
}, [values]);
|
||||||
|
|
||||||
const getError = useCallback((path: string) => {
|
const getError = useCallback((path: string) => {
|
||||||
return state.errors[path];
|
return state.errors[path] || getNestedValue(form.formState.errors, path)?.message;
|
||||||
}, [state.errors]);
|
}, [state.errors, form.formState.errors]);
|
||||||
|
|
||||||
const isDirty = useCallback((path?: string) => {
|
const isDirty = useCallback((path?: string) => {
|
||||||
if (path) {
|
if (path) {
|
||||||
return Boolean(state.dirty[path]);
|
return Boolean(getNestedValue(dirtyFields, path));
|
||||||
}
|
}
|
||||||
return Object.keys(state.dirty).length > 0;
|
return Object.keys(dirtyFields).length > 0;
|
||||||
}, [state.dirty]);
|
}, [dirtyFields]);
|
||||||
|
|
||||||
const isValid = useCallback((path?: string) => {
|
const isValid = useCallback((path?: string) => {
|
||||||
if (path) {
|
if (path) {
|
||||||
return !state.errors[path];
|
return !state.errors[path] && !getNestedValue(form.formState.errors, path);
|
||||||
}
|
}
|
||||||
return Object.keys(state.errors).length === 0;
|
return Object.keys(state.errors).length === 0 && form.formState.isValid;
|
||||||
}, [state.errors]);
|
}, [state.errors, form.formState.errors, form.formState.isValid]);
|
||||||
|
|
||||||
|
// 构建返回的状态,包含 values
|
||||||
|
const adminPanelState: AdminPanelState = {
|
||||||
|
...state,
|
||||||
|
values,
|
||||||
|
dirty: dirtyFields,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
form,
|
||||||
|
state: adminPanelState,
|
||||||
actions: {
|
actions: {
|
||||||
setValue,
|
setValue,
|
||||||
setValues,
|
setValues,
|
||||||
|
|||||||
@ -55,7 +55,6 @@ export function useConfigs() {
|
|||||||
const { data, loading, error, refetch } = useQuery<{ configs: ConfigItemType[] }>(
|
const { data, loading, error, refetch } = useQuery<{ configs: ConfigItemType[] }>(
|
||||||
GET_CONFIGS,
|
GET_CONFIGS,
|
||||||
{
|
{
|
||||||
errorPolicy: 'all',
|
|
||||||
notifyOnNetworkStatusChange: true
|
notifyOnNetworkStatusChange: true
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -171,7 +170,9 @@ export function useConfigUpdater() {
|
|||||||
const updateConfig = useCallback(async (key: string, value: any): Promise<ConfigUpdateResult> => {
|
const updateConfig = useCallback(async (key: string, value: any): Promise<ConfigUpdateResult> => {
|
||||||
setUpdating(true);
|
setUpdating(true);
|
||||||
try {
|
try {
|
||||||
const valueStr = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
const valueStr = (value === null || value === undefined) ? '' :
|
||||||
|
(typeof value === 'object' && !Array.isArray(value)) ? JSON.stringify(value) :
|
||||||
|
String(value);
|
||||||
const result = await updateSetting({
|
const result = await updateSetting({
|
||||||
variables: { key, value: valueStr }
|
variables: { key, value: valueStr }
|
||||||
});
|
});
|
||||||
@ -220,10 +221,21 @@ export function flattenConfigObject(obj: any, prefix = ''): ConfigUpdateInput[]
|
|||||||
// 递归处理嵌套对象
|
// 递归处理嵌套对象
|
||||||
result.push(...flattenConfigObject(value, fullKey));
|
result.push(...flattenConfigObject(value, fullKey));
|
||||||
} else {
|
} else {
|
||||||
// 处理基本类型、数组和日期
|
// 处理基本类型、数组和日期,统一转换为字符串
|
||||||
|
let stringValue: string;
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
stringValue = '';
|
||||||
|
} else if (Array.isArray(value) || (typeof value === 'object' && value instanceof Date)) {
|
||||||
|
// 数组或日期对象转换为JSON字符串
|
||||||
|
stringValue = JSON.stringify(value);
|
||||||
|
} else {
|
||||||
|
// 布尔值、数字、字符串等都直接转换为字符串,避免双引号
|
||||||
|
stringValue = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
key: fullKey,
|
key: fullKey,
|
||||||
value: value as string | number | boolean | object
|
value: stringValue
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -236,18 +248,9 @@ export function unflattenConfigObject(configs: { key: string; value: any }[]): a
|
|||||||
const result: any = {};
|
const result: any = {};
|
||||||
|
|
||||||
for (const config of configs) {
|
for (const config of configs) {
|
||||||
const keys = config.key.split('.');
|
const keys = config.key
|
||||||
let current = result;
|
let current = result;
|
||||||
|
result[keys] = config.value;
|
||||||
for (let i = 0; i < keys.length - 1; i++) {
|
|
||||||
const key = keys[i];
|
|
||||||
if (!(key in current)) {
|
|
||||||
current[key] = {};
|
|
||||||
}
|
|
||||||
current = current[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
current[keys[keys.length - 1]] = config.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@ -202,24 +202,24 @@ export const VALIDATE_CONFIG = gql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 更新配置设置(假设后端有这样的mutation)
|
// 批量更新配置
|
||||||
export const UPDATE_SETTING = gql`
|
export const UPDATE_CONFIG_BATCH = gql`
|
||||||
mutation UpdateSetting($key: String!, $value: String!) {
|
mutation UpdateConfigBatch($input: [UpdateConfig!]!) {
|
||||||
updateSetting(key: $key, value: $value) {
|
updateConfigBatch(input: $input)
|
||||||
success
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 批量更新配置设置
|
// 更新单个配置设置
|
||||||
|
export const UPDATE_SETTING = gql`
|
||||||
|
mutation UpdateSetting($key: String!, $value: String!) {
|
||||||
|
updateConfigBatch(input: [{ key: $key, value: $value }])
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 批量更新配置设置(兼容旧版本)
|
||||||
export const UPDATE_SETTINGS = gql`
|
export const UPDATE_SETTINGS = gql`
|
||||||
mutation UpdateSettings($settings: [SettingInput!]!) {
|
mutation UpdateSettings($settings: [UpdateConfig!]!) {
|
||||||
updateSettings(settings: $settings) {
|
updateConfigBatch(input: $settings)
|
||||||
success
|
|
||||||
message
|
|
||||||
failedKeys
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
import { Globe, Users, Mail, FileText, Server, HardDrive, Shield, Settings } from "lucide-react";
|
import { Globe, Users, Mail, FileText, Server, HardDrive, Shield, Settings } from "lucide-react";
|
||||||
import { FieldConfig, SectionConfig } from "@/types/admin-panel";
|
import { FieldConfig, SectionConfig } from "@/types/admin-panel";
|
||||||
import { ConfigItemType } from "@/hooks/use-site-config";
|
import { ConfigItemType } from "@/hooks/use-site-config";
|
||||||
@ -10,7 +10,12 @@ export const commonConfigSchema = z.object({
|
|||||||
name: z.string().min(2, "网站名称至少2个字符").max(50, "网站名称最多50个字符"),
|
name: z.string().min(2, "网站名称至少2个字符").max(50, "网站名称最多50个字符"),
|
||||||
description: z.string().max(200, "网站描述最多200个字符").optional().or(z.literal("")),
|
description: z.string().max(200, "网站描述最多200个字符").optional().or(z.literal("")),
|
||||||
keywords: z.string().optional().or(z.literal("")),
|
keywords: z.string().optional().or(z.literal("")),
|
||||||
url: z.string().url("请输入有效的站点URL").optional().or(z.literal("")),
|
url: z.string()
|
||||||
|
.refine((url) => {
|
||||||
|
if (!url || url === "") return true; // 允许空值
|
||||||
|
return url.startsWith("http://") || url.startsWith("https://");
|
||||||
|
}, "无效的URL格式,必须以http://或https://开头")
|
||||||
|
.optional().or(z.literal("")),
|
||||||
logo: z.string().url("请输入有效的Logo地址").optional().or(z.literal("")),
|
logo: z.string().url("请输入有效的Logo地址").optional().or(z.literal("")),
|
||||||
icp: z.string().optional().or(z.literal("")),
|
icp: z.string().optional().or(z.literal("")),
|
||||||
icp_url: z.string().url("请输入有效的备案链接").optional().or(z.literal("")),
|
icp_url: z.string().url("请输入有效的备案链接").optional().or(z.literal("")),
|
||||||
@ -31,7 +36,12 @@ export const commonConfigSchema = z.object({
|
|||||||
smtp_password: z.string().optional().or(z.literal("")),
|
smtp_password: z.string().optional().or(z.literal("")),
|
||||||
smtp_from: z.string().email("请输入有效的发信地址").optional().or(z.literal("")),
|
smtp_from: z.string().email("请输入有效的发信地址").optional().or(z.literal("")),
|
||||||
smtp_from_name: z.string().optional().or(z.literal("")),
|
smtp_from_name: z.string().optional().or(z.literal("")),
|
||||||
smtp_from_email: z.string().email("请输入有效的发信邮箱").optional().or(z.literal("")),
|
smtp_from_email: z.string()
|
||||||
|
.refine((email) => {
|
||||||
|
if (!email || email === "") return true; // 允许空值
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
}, "无效的邮箱格式")
|
||||||
|
.optional().or(z.literal("")),
|
||||||
system_template: z.string().default("default"),
|
system_template: z.string().default("default"),
|
||||||
}),
|
}),
|
||||||
blog: z.object({
|
blog: z.object({
|
||||||
@ -41,7 +51,7 @@ export const commonConfigSchema = z.object({
|
|||||||
open_comment: z.boolean().default(true),
|
open_comment: z.boolean().default(true),
|
||||||
}),
|
}),
|
||||||
logging: z.object({
|
logging: z.object({
|
||||||
level: z.enum(["error", "warn", "info", "debug"]).default("info"),
|
level: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
|
||||||
max_files: z.number().int().min(1).max(1000).default(10),
|
max_files: z.number().int().min(1).max(1000).default(10),
|
||||||
max_file_size: z.number().int().min(1).max(10240).default(10),
|
max_file_size: z.number().int().min(1).max(10240).default(10),
|
||||||
}),
|
}),
|
||||||
@ -70,15 +80,15 @@ const makeField = (id: string, meta: Meta, value?: any): FieldConfig => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 3) 分组图标映射
|
// 3) 分组图标映射
|
||||||
const categoryIcons: Record<string, ReactNode> = {
|
const categoryIcons: Record<string, () => ReactNode> = {
|
||||||
site: <Globe className="h-5 w-5" />,
|
site: () => React.createElement(Globe, { className: "h-5 w-5" }),
|
||||||
user: <Users className="h-5 w-5" />,
|
user: () => React.createElement(Users, { className: "h-5 w-5" }),
|
||||||
email: <Mail className="h-5 w-5" />,
|
email: () => React.createElement(Mail, { className: "h-5 w-5" }),
|
||||||
blog: <FileText className="h-5 w-5" />,
|
blog: () => React.createElement(FileText, { className: "h-5 w-5" }),
|
||||||
logging: <Server className="h-5 w-5" />,
|
logging: () => React.createElement(Server, { className: "h-5 w-5" }),
|
||||||
cache: <HardDrive className="h-5 w-5" />,
|
cache: () => React.createElement(HardDrive, { className: "h-5 w-5" }),
|
||||||
switch: <Shield className="h-5 w-5" />,
|
switch: () => React.createElement(Shield, { className: "h-5 w-5" }),
|
||||||
other: <Settings className="h-5 w-5" />,
|
other: () => React.createElement(Settings, { className: "h-5 w-5" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4) 分组标题映射
|
// 4) 分组标题映射
|
||||||
@ -97,10 +107,30 @@ const categoryTitles: Record<string, string> = {
|
|||||||
const knownFieldsMeta: Record<string, Meta> = {
|
const knownFieldsMeta: Record<string, Meta> = {
|
||||||
// site
|
// site
|
||||||
"site.name": { label: "网站名称", type: "input", validation: { required: true, minLength: 2, maxLength: 50 } },
|
"site.name": { label: "网站名称", type: "input", validation: { required: true, minLength: 2, maxLength: 50 } },
|
||||||
"site.description": { label: "网站描述", type: "textarea", rows: 3 },
|
"site.description": { label: "网站描述", type: "textarea", rows: 3, validation: { maxLength: 200 } },
|
||||||
"site.keywords": { label: "关键词", type: "input", description: "逗号分隔,如:blog,tech,ai" },
|
"site.keywords": { label: "关键词", type: "input", description: "逗号分隔,如:blog,tech,ai" },
|
||||||
"site.url": { label: "站点URL", type: "url" },
|
"site.url": {
|
||||||
"site.logo": { label: "Logo地址", type: "url" },
|
label: "站点URL", type: "url", validation: {
|
||||||
|
custom: (value) => {
|
||||||
|
if (!value || value === "") return null;
|
||||||
|
if (!value.startsWith("http://") && !value.startsWith("https://")) {
|
||||||
|
return "URL必须以http://或https://开头";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"site.logo": {
|
||||||
|
label: "Logo地址", type: "url", validation: {
|
||||||
|
custom: (value) => {
|
||||||
|
if (!value || value === "") return null;
|
||||||
|
if (!value.startsWith("http://") && !value.startsWith("https://")) {
|
||||||
|
return "Logo地址必须以http://或https://开头";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"site.icp": { label: "ICP备案号", type: "input" },
|
"site.icp": { label: "ICP备案号", type: "input" },
|
||||||
"site.icp_url": { label: "备案链接", type: "url" },
|
"site.icp_url": { label: "备案链接", type: "url" },
|
||||||
"site.color_style": {
|
"site.color_style": {
|
||||||
@ -132,9 +162,29 @@ const knownFieldsMeta: Record<string, Meta> = {
|
|||||||
"email.smtp_port": { label: "SMTP 端口", type: "number", min: 1, max: 65535 },
|
"email.smtp_port": { label: "SMTP 端口", type: "number", min: 1, max: 65535 },
|
||||||
"email.smtp_user": { label: "SMTP 用户名", type: "input" },
|
"email.smtp_user": { label: "SMTP 用户名", type: "input" },
|
||||||
"email.smtp_password": { label: "SMTP 密码", type: "password" },
|
"email.smtp_password": { label: "SMTP 密码", type: "password" },
|
||||||
"email.smtp_from": { label: "发信地址", type: "email" },
|
"email.smtp_from": {
|
||||||
|
label: "发信地址", type: "email", validation: {
|
||||||
|
custom: (value) => {
|
||||||
|
if (!value || value === "") return null;
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||||
|
return "请输入有效的邮箱地址";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"email.smtp_from_name": { label: "发信人名称", type: "input" },
|
"email.smtp_from_name": { label: "发信人名称", type: "input" },
|
||||||
"email.smtp_from_email": { label: "发信邮箱", type: "email" },
|
"email.smtp_from_email": {
|
||||||
|
label: "发信邮箱", type: "email", validation: {
|
||||||
|
custom: (value) => {
|
||||||
|
if (!value || value === "") return null;
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||||
|
return "请输入有效的邮箱地址";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"email.system_template": { label: "系统模板", type: "input" },
|
"email.system_template": { label: "系统模板", type: "input" },
|
||||||
// blog
|
// blog
|
||||||
"blog.default_author": { label: "默认作者", type: "input" },
|
"blog.default_author": { label: "默认作者", type: "input" },
|
||||||
@ -145,11 +195,13 @@ const knownFieldsMeta: Record<string, Meta> = {
|
|||||||
"logging.level": {
|
"logging.level": {
|
||||||
label: "日志级别",
|
label: "日志级别",
|
||||||
type: "select",
|
type: "select",
|
||||||
|
validation: { required: true },
|
||||||
options: [
|
options: [
|
||||||
{ label: "错误", value: "error" },
|
{ label: "跟踪", value: "trace" },
|
||||||
{ label: "警告", value: "warn" },
|
{ label: "调试", value: "debug" },
|
||||||
{ label: "信息", value: "info" },
|
{ label: "信息", value: "info" },
|
||||||
{ label: "调试", value: "debug" }
|
{ label: "警告", value: "warn" },
|
||||||
|
{ label: "错误", value: "error" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"logging.max_files": { label: "最大文件数", type: "number", min: 1, max: 1000 },
|
"logging.max_files": { label: "最大文件数", type: "number", min: 1, max: 1000 },
|
||||||
@ -239,13 +291,13 @@ export function buildSectionsFromConfigs(configs: ConfigItemType[]): SectionConf
|
|||||||
.map<SectionConfig>(([group, items]) => ({
|
.map<SectionConfig>(([group, items]) => ({
|
||||||
id: `common-${group}`,
|
id: `common-${group}`,
|
||||||
title: categoryTitles[group] || `${group} 配置`,
|
title: categoryTitles[group] || `${group} 配置`,
|
||||||
icon: categoryIcons[group] || categoryIcons.other,
|
icon: (categoryIcons[group] || categoryIcons.other)(),
|
||||||
fields: items.map(item => item.field),
|
fields: items.map(item => item.field),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9) 将 zod 校验错误转换为 AdminPanel 的错误映射
|
// 9) 将 zod 校验错误转换为 AdminPanel 的错误映射
|
||||||
export function zodErrorsToAdminErrors(result: z.SafeParseReturnType<any, any>): Record<string, string> {
|
export function zodErrorsToAdminErrors(result: z.ZodSafeParseError<any> | z.ZodSafeParseSuccess<any>): Record<string, string> {
|
||||||
if (result.success) return {};
|
if (result.success) return {};
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
for (const issue of result.error.issues) {
|
for (const issue of result.error.issues) {
|
||||||
|
|||||||
@ -9,7 +9,12 @@ export const commonConfigSchema = z.object({
|
|||||||
name: z.string().min(2, "网站名称至少2个字符").max(50, "网站名称最多50个字符"),
|
name: z.string().min(2, "网站名称至少2个字符").max(50, "网站名称最多50个字符"),
|
||||||
description: z.string().max(200, "网站描述最多200个字符").optional().or(z.literal("")),
|
description: z.string().max(200, "网站描述最多200个字符").optional().or(z.literal("")),
|
||||||
keywords: z.string().optional().or(z.literal("")),
|
keywords: z.string().optional().or(z.literal("")),
|
||||||
url: z.string().url("请输入有效的站点URL").optional().or(z.literal("")),
|
url: z.string()
|
||||||
|
.refine((url) => {
|
||||||
|
if (!url || url === "") return true; // 允许空值
|
||||||
|
return url.startsWith("http://") || url.startsWith("https://");
|
||||||
|
}, "无效的URL格式,必须以http://或https://开头")
|
||||||
|
.optional().or(z.literal("")),
|
||||||
logo: z.string().url("请输入有效的Logo地址").optional().or(z.literal("")),
|
logo: z.string().url("请输入有效的Logo地址").optional().or(z.literal("")),
|
||||||
icp: z.string().optional().or(z.literal("")),
|
icp: z.string().optional().or(z.literal("")),
|
||||||
icp_url: z.string().url("请输入有效的备案链接").optional().or(z.literal("")),
|
icp_url: z.string().url("请输入有效的备案链接").optional().or(z.literal("")),
|
||||||
@ -30,7 +35,12 @@ export const commonConfigSchema = z.object({
|
|||||||
smtp_password: z.string().optional().or(z.literal("")),
|
smtp_password: z.string().optional().or(z.literal("")),
|
||||||
smtp_from: z.string().email("请输入有效的发信地址").optional().or(z.literal("")),
|
smtp_from: z.string().email("请输入有效的发信地址").optional().or(z.literal("")),
|
||||||
smtp_from_name: z.string().optional().or(z.literal("")),
|
smtp_from_name: z.string().optional().or(z.literal("")),
|
||||||
smtp_from_email: z.string().email("请输入有效的发信邮箱").optional().or(z.literal("")),
|
smtp_from_email: z.string()
|
||||||
|
.refine((email) => {
|
||||||
|
if (!email || email === "") return true; // 允许空值
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
}, "无效的邮箱格式")
|
||||||
|
.optional().or(z.literal("")),
|
||||||
system_template: z.string().default("default"),
|
system_template: z.string().default("default"),
|
||||||
}),
|
}),
|
||||||
blog: z.object({
|
blog: z.object({
|
||||||
@ -40,7 +50,7 @@ export const commonConfigSchema = z.object({
|
|||||||
open_comment: z.boolean().default(true),
|
open_comment: z.boolean().default(true),
|
||||||
}),
|
}),
|
||||||
logging: z.object({
|
logging: z.object({
|
||||||
level: z.enum(["error", "warn", "info", "debug"]).default("info"),
|
level: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
|
||||||
max_files: z.number().int().min(1).max(1000).default(10),
|
max_files: z.number().int().min(1).max(1000).default(10),
|
||||||
max_file_size: z.number().int().min(1).max(10240).default(10),
|
max_file_size: z.number().int().min(1).max(10240).default(10),
|
||||||
}),
|
}),
|
||||||
@ -136,10 +146,11 @@ export const commonFieldsMeta: Array<{ id: string; meta: Meta }> = [
|
|||||||
{
|
{
|
||||||
id: "logging.level", meta: {
|
id: "logging.level", meta: {
|
||||||
label: "日志级别", type: "select", options: [
|
label: "日志级别", type: "select", options: [
|
||||||
{ label: "错误", value: "error" },
|
{ label: "跟踪", value: "trace" },
|
||||||
{ label: "警告", value: "warn" },
|
{ label: "调试", value: "debug" },
|
||||||
{ label: "信息", value: "info" },
|
{ label: "信息", value: "info" },
|
||||||
{ label: "调试", value: "debug" }
|
{ label: "警告", value: "warn" },
|
||||||
|
{ label: "错误", value: "error" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -175,7 +186,7 @@ export function buildCommonSectionsFromMeta(): SectionConfig[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5) 将 zod 校验错误转换为 AdminPanel 的错误映射
|
// 5) 将 zod 校验错误转换为 AdminPanel 的错误映射
|
||||||
export function zodErrorsToAdminErrors(result: z.SafeParseReturnType<any, any>): Record<string, string> {
|
export function zodErrorsToAdminErrors(result: z.ZodSafeParseError<any> | z.ZodSafeParseSuccess<any>): Record<string, string> {
|
||||||
if (result.success) return {};
|
if (result.success) return {};
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
for (const issue of result.error.issues) {
|
for (const issue of result.error.issues) {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
import { ConfigFormValues } from "./config";
|
||||||
|
import { UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
export type FieldType =
|
export type FieldType =
|
||||||
| "input"
|
| "input"
|
||||||
@ -151,7 +153,7 @@ export interface AdminPanelConfig {
|
|||||||
export interface AdminPanelState {
|
export interface AdminPanelState {
|
||||||
values: Record<string, any>;
|
values: Record<string, any>;
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
dirty: Record<string, boolean>;
|
dirty: Record<string, any>; // 兼容 react-hook-form 的 dirtyFields 类型
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
}
|
}
|
||||||
@ -164,6 +166,7 @@ export interface UseAdminPanelOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UseAdminPanelReturn {
|
export interface UseAdminPanelReturn {
|
||||||
|
form: UseFormReturn<ConfigFormValues>;
|
||||||
state: AdminPanelState;
|
state: AdminPanelState;
|
||||||
actions: {
|
actions: {
|
||||||
setValue: (path: string, value: any) => void;
|
setValue: (path: string, value: any) => void;
|
||||||
|
|||||||
333
types/config.ts
333
types/config.ts
@ -1,277 +1,64 @@
|
|||||||
export interface Config {
|
import { z } from "zod";
|
||||||
// App Configuration
|
|
||||||
app: {
|
|
||||||
name: string
|
|
||||||
version: string
|
|
||||||
debug: boolean
|
|
||||||
timezone: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Database Configuration
|
export const configFormSchema = z.object({
|
||||||
database: {
|
'site.name': z.string(),
|
||||||
max_connections: number
|
'site.description': z.string(),
|
||||||
connection_timeout: number
|
'site.keywords': z.string(),
|
||||||
}
|
'site.url': z.string()
|
||||||
|
.refine((url) => {
|
||||||
|
if (!url || url === "") return true; // 允许空值
|
||||||
|
return url.startsWith("http://") || url.startsWith("https://");
|
||||||
|
}, "无效的URL格式,必须以http://或https://开头"),
|
||||||
|
'site.logo': z.string(),
|
||||||
|
'site.copyright': z.string(),
|
||||||
|
'site.icp': z.string(),
|
||||||
|
'site.icp_url': z.string(),
|
||||||
|
'site.color_style': z.string(),
|
||||||
|
'user.default_avatar': z.string(),
|
||||||
|
'user.default_role': z.string(),
|
||||||
|
'user.register_invite_code': z.boolean(),
|
||||||
|
'user.register_email_verification': z.boolean(),
|
||||||
|
'user.open_login': z.boolean(),
|
||||||
|
'user.open_reset_password': z.boolean(),
|
||||||
|
'email.smtp_host': z.string(),
|
||||||
|
'email.smtp_port': z.number().int().min(1).max(65535),
|
||||||
|
'email.smtp_user': z.string(),
|
||||||
|
'email.smtp_password': z.string(),
|
||||||
|
'email.smtp_from': z.string(),
|
||||||
|
'email.smtp_from_name': z.string(),
|
||||||
|
'email.smtp_from_email': z.string()
|
||||||
|
.refine((email) => {
|
||||||
|
if (!email || email === "") return true; // 允许空值
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
}, "无效的邮箱格式"),
|
||||||
|
'email.system_template': z.string(),
|
||||||
|
'blog.default_author': z.string(),
|
||||||
|
'blog.default_category': z.string(),
|
||||||
|
'blog.default_tag': z.string(),
|
||||||
|
'blog.open_comment': z.boolean(),
|
||||||
|
'logging.level': z.enum(["trace", "debug", "info", "warn", "error"], {
|
||||||
|
message: "日志级别必须是以下之一: trace, debug, info, warn, error"
|
||||||
|
}),
|
||||||
|
'logging.max_files': z.number().int().min(1).max(1000),
|
||||||
|
'logging.max_file_size': z.number().int().min(1).max(10240),
|
||||||
|
'cache.ttl': z.number().int().min(1).max(31536000),
|
||||||
|
'cache.max_size': z.number().int().min(1).max(1048576),
|
||||||
|
'switch.open_register': z.boolean(),
|
||||||
|
'switch.open_login': z.boolean(),
|
||||||
|
'switch.open_reset_password': z.boolean(),
|
||||||
|
'switch.open_comment': z.boolean(),
|
||||||
|
'switch.open_like': z.boolean(),
|
||||||
|
'switch.open_share': z.boolean(),
|
||||||
|
'switch.open_view': z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
// Kafka Configuration
|
export type ConfigFormValues = z.infer<typeof configFormSchema>;
|
||||||
kafka: {
|
|
||||||
max_retries: number
|
|
||||||
retry_delay: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Security Configuration
|
// GraphQL UpdateConfig 输入类型
|
||||||
security: {
|
export interface UpdateConfig {
|
||||||
session_timeout: number
|
key: string;
|
||||||
max_login_attempts: number
|
value: string; // GraphQL schema 要求所有值都是字符串
|
||||||
}
|
description?: string;
|
||||||
|
category?: string;
|
||||||
// Logging Configuration
|
is_editable?: boolean;
|
||||||
logging: {
|
|
||||||
level: string
|
|
||||||
max_files: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache Configuration
|
|
||||||
cache: {
|
|
||||||
ttl: number
|
|
||||||
max_size: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Site Configuration
|
|
||||||
site: {
|
|
||||||
name: string
|
|
||||||
locale_default: string
|
|
||||||
locales_supported: string[]
|
|
||||||
brand: {
|
|
||||||
logo_url: string
|
|
||||||
primary_color: string
|
|
||||||
dark_mode_default: boolean
|
|
||||||
}
|
|
||||||
footer_links: Array<{
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
visible_to_guest: boolean
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notice Configuration
|
|
||||||
notice: {
|
|
||||||
banner: {
|
|
||||||
enabled: boolean
|
|
||||||
text: Record<string, string>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maintenance Configuration
|
|
||||||
maintenance: {
|
|
||||||
window: {
|
|
||||||
enabled: boolean
|
|
||||||
start_time: string
|
|
||||||
end_time: string
|
|
||||||
message: Record<string, string>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal Announcements
|
|
||||||
modal: {
|
|
||||||
announcements: Array<{
|
|
||||||
id: string
|
|
||||||
title: Record<string, string>
|
|
||||||
content: Record<string, string>
|
|
||||||
start_time: string
|
|
||||||
end_time: string
|
|
||||||
audience: string[]
|
|
||||||
priority: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Documentation Links
|
|
||||||
docs: {
|
|
||||||
links: Array<{
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
description: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Support Channels
|
|
||||||
support: {
|
|
||||||
channels: {
|
|
||||||
email: string
|
|
||||||
ticket_system: string
|
|
||||||
chat_groups: Array<{
|
|
||||||
name: string
|
|
||||||
url?: string
|
|
||||||
qr_code?: string
|
|
||||||
description: string
|
|
||||||
}>
|
|
||||||
working_hours: Record<string, string>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Operations Configuration
|
|
||||||
ops: {
|
|
||||||
features: {
|
|
||||||
registration_enabled: boolean
|
|
||||||
invite_code_required: boolean
|
|
||||||
email_verification: boolean
|
|
||||||
}
|
|
||||||
limits: {
|
|
||||||
max_users: number
|
|
||||||
max_invite_codes_per_user: number
|
|
||||||
session_timeout_hours: number
|
|
||||||
}
|
|
||||||
notifications: {
|
|
||||||
welcome_email: boolean
|
|
||||||
system_announcements: boolean
|
|
||||||
maintenance_alerts: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultConfig: Config = {
|
|
||||||
// App Configuration
|
|
||||||
app: {
|
|
||||||
name: "MMAP System",
|
|
||||||
version: "1.0.0",
|
|
||||||
debug: false,
|
|
||||||
timezone: "UTC"
|
|
||||||
},
|
|
||||||
|
|
||||||
// Database Configuration
|
|
||||||
database: {
|
|
||||||
max_connections: 10,
|
|
||||||
connection_timeout: 30
|
|
||||||
},
|
|
||||||
|
|
||||||
// Kafka Configuration
|
|
||||||
kafka: {
|
|
||||||
max_retries: 3,
|
|
||||||
retry_delay: 1000
|
|
||||||
},
|
|
||||||
|
|
||||||
// Security Configuration
|
|
||||||
security: {
|
|
||||||
session_timeout: 3600,
|
|
||||||
max_login_attempts: 5
|
|
||||||
},
|
|
||||||
|
|
||||||
// Logging Configuration
|
|
||||||
logging: {
|
|
||||||
level: "info",
|
|
||||||
max_files: 10
|
|
||||||
},
|
|
||||||
|
|
||||||
// Cache Configuration
|
|
||||||
cache: {
|
|
||||||
ttl: 300,
|
|
||||||
max_size: 1000
|
|
||||||
},
|
|
||||||
|
|
||||||
// Site Configuration
|
|
||||||
site: {
|
|
||||||
name: "MMAP System",
|
|
||||||
locale_default: "zh-CN",
|
|
||||||
locales_supported: ["zh-CN", "en"],
|
|
||||||
brand: {
|
|
||||||
logo_url: "/images/logo.png",
|
|
||||||
primary_color: "#3B82F6",
|
|
||||||
dark_mode_default: false
|
|
||||||
},
|
|
||||||
footer_links: [
|
|
||||||
{ name: "关于我们", url: "/about", visible_to_guest: true },
|
|
||||||
{ name: "联系我们", url: "/contact", visible_to_guest: true },
|
|
||||||
{ name: "用户中心", url: "/dashboard", visible_to_guest: false }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
// Notice Configuration
|
|
||||||
notice: {
|
|
||||||
banner: {
|
|
||||||
enabled: false,
|
|
||||||
text: {
|
|
||||||
"zh-CN": "欢迎使用MMAP系统",
|
|
||||||
"en": "Welcome to MMAP System"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Maintenance Configuration
|
|
||||||
maintenance: {
|
|
||||||
window: {
|
|
||||||
enabled: false,
|
|
||||||
start_time: "2024-01-01T02:00:00Z",
|
|
||||||
end_time: "2024-01-01T06:00:00Z",
|
|
||||||
message: {
|
|
||||||
"zh-CN": "系统维护中,请稍后再试",
|
|
||||||
"en": "System maintenance in progress"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Modal Announcements
|
|
||||||
modal: {
|
|
||||||
announcements: [
|
|
||||||
{
|
|
||||||
id: "welcome_2024",
|
|
||||||
title: {
|
|
||||||
"zh-CN": "2024新年快乐",
|
|
||||||
"en": "Happy New Year 2024"
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
"zh-CN": "感谢您在过去一年的支持",
|
|
||||||
"en": "Thank you for your support in the past year"
|
|
||||||
},
|
|
||||||
start_time: "2024-01-01T00:00:00Z",
|
|
||||||
end_time: "2024-01-31T23:59:59Z",
|
|
||||||
audience: ["all"],
|
|
||||||
priority: "high"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
// Documentation Links
|
|
||||||
docs: {
|
|
||||||
links: [
|
|
||||||
{ name: "API文档", url: "/docs/api", description: "完整的API接口文档" },
|
|
||||||
{ name: "图例说明", url: "/docs/legend", description: "系统图例和符号说明" },
|
|
||||||
{ name: "计费说明", url: "/docs/billing", description: "详细的计费规则和说明" },
|
|
||||||
{ name: "用户手册", url: "/docs/user-guide", description: "用户操作指南" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
// Support Channels
|
|
||||||
support: {
|
|
||||||
channels: {
|
|
||||||
email: "support@mapp.com",
|
|
||||||
ticket_system: "/support/tickets",
|
|
||||||
chat_groups: [
|
|
||||||
{ name: "官方QQ群", url: "https://qm.qq.com/xxx", description: "技术交流群" },
|
|
||||||
{ name: "微信群", qr_code: "/images/wechat-qr.png", description: "扫码加入微信群" }
|
|
||||||
],
|
|
||||||
working_hours: {
|
|
||||||
"zh-CN": "周一至周五 9:00-18:00",
|
|
||||||
"en": "Mon-Fri 9:00-18:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Operations Configuration
|
|
||||||
ops: {
|
|
||||||
features: {
|
|
||||||
registration_enabled: true,
|
|
||||||
invite_code_required: true,
|
|
||||||
email_verification: false
|
|
||||||
},
|
|
||||||
limits: {
|
|
||||||
max_users: 1000,
|
|
||||||
max_invite_codes_per_user: 10,
|
|
||||||
session_timeout_hours: 24
|
|
||||||
},
|
|
||||||
notifications: {
|
|
||||||
welcome_email: true,
|
|
||||||
system_announcements: true,
|
|
||||||
maintenance_alerts: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -117,7 +117,7 @@ export interface ConfigValidationResultType {
|
|||||||
// 配置更新相关类型
|
// 配置更新相关类型
|
||||||
export interface ConfigUpdateInput {
|
export interface ConfigUpdateInput {
|
||||||
key: string;
|
key: string;
|
||||||
value: string | number | boolean | object;
|
value: string; // 统一使用字符串类型以符合 GraphQL schema
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigUpdateResult {
|
export interface ConfigUpdateResult {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user