260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
import { z } from "zod";
|
||
import { ReactNode } from "react";
|
||
import { Globe, Users, Mail, FileText, Server, HardDrive, Shield, Settings } from "lucide-react";
|
||
import { FieldConfig, SectionConfig } from "@/types/admin-panel";
|
||
import { ConfigItemType } from "@/hooks/use-site-config";
|
||
|
||
// 1) 使用 zod 定义“通用配置”结构与规则
|
||
export const commonConfigSchema = z.object({
|
||
site: z.object({
|
||
name: z.string().min(2, "网站名称至少2个字符").max(50, "网站名称最多50个字符"),
|
||
description: z.string().max(200, "网站描述最多200个字符").optional().or(z.literal("")),
|
||
keywords: z.string().optional().or(z.literal("")),
|
||
url: z.string().url("请输入有效的站点URL").optional().or(z.literal("")),
|
||
logo: z.string().url("请输入有效的Logo地址").optional().or(z.literal("")),
|
||
icp: z.string().optional().or(z.literal("")),
|
||
icp_url: z.string().url("请输入有效的备案链接").optional().or(z.literal("")),
|
||
color_style: z.enum(["light", "dark", "auto"]).default("light"),
|
||
}),
|
||
user: z.object({
|
||
default_avatar: z.string().url("请输入有效的头像URL").optional().or(z.literal("")),
|
||
default_role: z.enum(["user", "editor", "admin"]).default("user"),
|
||
register_invite_code: z.boolean().default(false),
|
||
register_email_verification: z.boolean().default(false),
|
||
open_login: z.boolean().default(true),
|
||
open_reset_password: z.boolean().default(true),
|
||
}),
|
||
email: z.object({
|
||
smtp_host: z.string().optional().or(z.literal("")),
|
||
smtp_port: z.number().int().min(1).max(65535).default(465),
|
||
smtp_user: z.string().optional().or(z.literal("")),
|
||
smtp_password: z.string().optional().or(z.literal("")),
|
||
smtp_from: z.string().email("请输入有效的发信地址").optional().or(z.literal("")),
|
||
smtp_from_name: z.string().optional().or(z.literal("")),
|
||
smtp_from_email: z.string().email("请输入有效的发信邮箱").optional().or(z.literal("")),
|
||
system_template: z.string().default("default"),
|
||
}),
|
||
blog: z.object({
|
||
default_author: z.string().optional().or(z.literal("")),
|
||
default_category: z.string().optional().or(z.literal("")),
|
||
default_tag: z.string().optional().or(z.literal("")),
|
||
open_comment: z.boolean().default(true),
|
||
}),
|
||
logging: z.object({
|
||
level: z.enum(["error", "warn", "info", "debug"]).default("info"),
|
||
max_files: z.number().int().min(1).max(1000).default(10),
|
||
max_file_size: z.number().int().min(1).max(10240).default(10),
|
||
}),
|
||
cache: z.object({
|
||
ttl: z.number().int().min(1).max(31_536_000).default(3600),
|
||
max_size: z.number().int().min(1).max(1_048_576).default(1024),
|
||
}),
|
||
switch: z.object({
|
||
open_register: z.boolean().default(true),
|
||
open_login: z.boolean().default(true),
|
||
open_reset_password: z.boolean().default(true),
|
||
open_comment: z.boolean().default(true),
|
||
open_like: z.boolean().default(true),
|
||
open_share: z.boolean().default(true),
|
||
open_view: z.boolean().default(true),
|
||
})
|
||
});
|
||
|
||
// 2) 字段元数据定义
|
||
type Meta = Omit<FieldConfig, "id" | "value"> & { defaultValue?: any };
|
||
|
||
const makeField = (id: string, meta: Meta, value?: any): FieldConfig => ({
|
||
id,
|
||
...meta,
|
||
value: value ?? meta.defaultValue
|
||
});
|
||
|
||
// 3) 分组图标映射
|
||
const categoryIcons: Record<string, ReactNode> = {
|
||
site: <Globe className="h-5 w-5" />,
|
||
user: <Users className="h-5 w-5" />,
|
||
email: <Mail className="h-5 w-5" />,
|
||
blog: <FileText className="h-5 w-5" />,
|
||
logging: <Server className="h-5 w-5" />,
|
||
cache: <HardDrive className="h-5 w-5" />,
|
||
switch: <Shield className="h-5 w-5" />,
|
||
other: <Settings className="h-5 w-5" />,
|
||
};
|
||
|
||
// 4) 分组标题映射
|
||
const categoryTitles: Record<string, string> = {
|
||
site: "网站信息",
|
||
user: "用户设置",
|
||
email: "邮件设置",
|
||
blog: "博客设置",
|
||
logging: "日志设置",
|
||
cache: "缓存设置",
|
||
switch: "功能开关",
|
||
other: "其他配置",
|
||
};
|
||
|
||
// 5) 已知字段的元数据(用于生成表单)
|
||
const knownFieldsMeta: Record<string, Meta> = {
|
||
// site
|
||
"site.name": { label: "网站名称", type: "input", validation: { required: true, minLength: 2, maxLength: 50 } },
|
||
"site.description": { label: "网站描述", type: "textarea", rows: 3 },
|
||
"site.keywords": { label: "关键词", type: "input", description: "逗号分隔,如:blog,tech,ai" },
|
||
"site.url": { label: "站点URL", type: "url" },
|
||
"site.logo": { label: "Logo地址", type: "url" },
|
||
"site.icp": { label: "ICP备案号", type: "input" },
|
||
"site.icp_url": { label: "备案链接", type: "url" },
|
||
"site.color_style": {
|
||
label: "配色风格",
|
||
type: "select",
|
||
options: [
|
||
{ label: "浅色", value: "light" },
|
||
{ label: "深色", value: "dark" },
|
||
{ label: "自动", value: "auto" }
|
||
]
|
||
},
|
||
// user
|
||
"user.default_avatar": { label: "默认头像URL", type: "url" },
|
||
"user.default_role": {
|
||
label: "默认角色",
|
||
type: "select",
|
||
options: [
|
||
{ label: "用户", value: "user" },
|
||
{ label: "编辑", value: "editor" },
|
||
{ label: "管理员", value: "admin" }
|
||
]
|
||
},
|
||
"user.register_invite_code": { label: "注册需邀请码", type: "switch" },
|
||
"user.register_email_verification": { label: "注册需邮箱验证", type: "switch" },
|
||
"user.open_login": { label: "开启登录", type: "switch" },
|
||
"user.open_reset_password": { label: "开启重置密码", type: "switch" },
|
||
// email
|
||
"email.smtp_host": { label: "SMTP 主机", type: "input" },
|
||
"email.smtp_port": { label: "SMTP 端口", type: "number", min: 1, max: 65535 },
|
||
"email.smtp_user": { label: "SMTP 用户名", type: "input" },
|
||
"email.smtp_password": { label: "SMTP 密码", type: "password" },
|
||
"email.smtp_from": { label: "发信地址", type: "email" },
|
||
"email.smtp_from_name": { label: "发信人名称", type: "input" },
|
||
"email.smtp_from_email": { label: "发信邮箱", type: "email" },
|
||
"email.system_template": { label: "系统模板", type: "input" },
|
||
// blog
|
||
"blog.default_author": { label: "默认作者", type: "input" },
|
||
"blog.default_category": { label: "默认分类", type: "input" },
|
||
"blog.default_tag": { label: "默认标签", type: "input" },
|
||
"blog.open_comment": { label: "开启评论", type: "switch" },
|
||
// logging
|
||
"logging.level": {
|
||
label: "日志级别",
|
||
type: "select",
|
||
options: [
|
||
{ label: "错误", value: "error" },
|
||
{ label: "警告", value: "warn" },
|
||
{ label: "信息", value: "info" },
|
||
{ label: "调试", value: "debug" }
|
||
]
|
||
},
|
||
"logging.max_files": { label: "最大文件数", type: "number", min: 1, max: 1000 },
|
||
"logging.max_file_size": { label: "单文件大小(MB)", type: "number", min: 1, max: 10240 },
|
||
// cache
|
||
"cache.ttl": { label: "TTL(秒)", type: "number", min: 1, max: 31536000 },
|
||
"cache.max_size": { label: "最大容量(MB)", type: "number", min: 1, max: 1048576 },
|
||
// switch
|
||
"switch.open_register": { label: "开放注册", type: "switch" },
|
||
"switch.open_login": { label: "开放登录", type: "switch" },
|
||
"switch.open_reset_password": { label: "开放重置密码", type: "switch" },
|
||
"switch.open_comment": { label: "开放评论", type: "switch" },
|
||
"switch.open_like": { label: "开放点赞", type: "switch" },
|
||
"switch.open_share": { label: "开放分享", type: "switch" },
|
||
"switch.open_view": { label: "开放浏览", type: "switch" },
|
||
};
|
||
|
||
// 6) 根据 valueType 推断字段类型
|
||
function inferFieldType(valueType: string, value: any): FieldConfig["type"] {
|
||
const vt = valueType.toLowerCase();
|
||
if (vt === "boolean" || vt === "bool") return "switch";
|
||
if (vt === "number" || vt === "int" || vt === "integer" || vt === "float" || vt === "double") return "number";
|
||
if (vt === "email") return "email";
|
||
if (vt === "url") return "url";
|
||
if (vt === "password") return "password";
|
||
if (vt === "json" || vt === "object" || vt === "array") return "textarea";
|
||
if (typeof value === "string" && value.length > 100) return "textarea";
|
||
return "input";
|
||
}
|
||
|
||
// 7) 解析配置值
|
||
function parseConfigValue(value: string | null | undefined, valueType: string): any {
|
||
if (value == null) return "";
|
||
const vt = valueType.toLowerCase();
|
||
try {
|
||
if (vt === "number" || vt === "int" || vt === "integer") return Number(value);
|
||
if (vt === "float" || vt === "double") return parseFloat(value);
|
||
if (vt === "bool" || vt === "boolean") return value === "true" || value === "1";
|
||
if (vt === "json" || vt === "object" || vt === "array") return value; // 保持字符串用于 textarea
|
||
return value;
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
// 8) 根据 configs 动态生成分组
|
||
export function buildSectionsFromConfigs(configs: ConfigItemType[]): SectionConfig[] {
|
||
const groupMap: Record<string, Array<{ config: ConfigItemType; field: FieldConfig }>> = {};
|
||
|
||
for (const config of configs) {
|
||
const [category] = config.key.split(".");
|
||
const group = category || "other";
|
||
|
||
if (!groupMap[group]) {
|
||
groupMap[group] = [];
|
||
}
|
||
|
||
// 解析值
|
||
const parsedValue = parseConfigValue(config.value, config.valueType);
|
||
|
||
// 检查是否有预定义的元数据
|
||
const knownMeta = knownFieldsMeta[config.key];
|
||
if (knownMeta) {
|
||
// 使用预定义的元数据
|
||
groupMap[group].push({
|
||
config,
|
||
field: makeField(config.key, knownMeta, parsedValue)
|
||
});
|
||
} else {
|
||
// 动态推断字段类型
|
||
const inferredType = inferFieldType(config.valueType, parsedValue);
|
||
const field: FieldConfig = {
|
||
id: config.key,
|
||
label: config.description || config.key,
|
||
type: inferredType,
|
||
value: parsedValue,
|
||
description: config.description ? undefined : `配置键: ${config.key}`,
|
||
rows: inferredType === "textarea" ? 3 : undefined,
|
||
};
|
||
groupMap[group].push({ config, field });
|
||
}
|
||
}
|
||
|
||
// 转换为 SectionConfig[]
|
||
return Object.entries(groupMap)
|
||
.filter(([_, items]) => items.length > 0)
|
||
.map<SectionConfig>(([group, items]) => ({
|
||
id: `common-${group}`,
|
||
title: categoryTitles[group] || `${group} 配置`,
|
||
icon: categoryIcons[group] || categoryIcons.other,
|
||
fields: items.map(item => item.field),
|
||
}));
|
||
}
|
||
|
||
// 9) 将 zod 校验错误转换为 AdminPanel 的错误映射
|
||
export function zodErrorsToAdminErrors(result: z.SafeParseReturnType<any, any>): Record<string, string> {
|
||
if (result.success) return {};
|
||
const errors: Record<string, string> = {};
|
||
for (const issue of result.error.issues) {
|
||
const path = issue.path.join(".");
|
||
if (path) {
|
||
errors[path] = issue.message;
|
||
}
|
||
}
|
||
return errors;
|
||
}
|
||
|
||
export type CommonConfig = z.infer<typeof commonConfigSchema>;
|