mosaicmap/lib/config-zod.ts
2025-08-15 22:31:51 +08:00

312 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { z } from "zod";
import React, { ReactNode } from "react";
import { Globe, Users, Mail, FileText, Server, HardDrive, Shield, Settings } from "lucide-react";
import { FieldConfig, SectionConfig } from "@/types/admin-panel";
import { ConfigItemType } from "@/hooks/use-site-config";
// 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()
.refine((url) => {
if (!url || url === "") return true; // 允许空值
return url.startsWith("http://") || url.startsWith("https://");
}, "无效的URL格式必须以http://或https://开头")
.optional().or(z.literal("")),
logo: z.string().url("请输入有效的Logo地址").optional().or(z.literal("")),
icp: z.string().optional().or(z.literal("")),
icp_url: z.string().url("请输入有效的备案链接").optional().or(z.literal("")),
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()
.refine((email) => {
if (!email || email === "") return true; // 允许空值
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}, "无效的邮箱格式")
.optional().or(z.literal("")),
system_template: z.string().default("default"),
}),
blog: z.object({
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(["trace", "debug", "info", "warn", "error"]).default("info"),
max_files: z.number().int().min(1).max(1000).default(10),
max_file_size: z.number().int().min(1).max(10240).default(10),
}),
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: () => React.createElement(Globe, { className: "h-5 w-5" }),
user: () => React.createElement(Users, { className: "h-5 w-5" }),
email: () => React.createElement(Mail, { className: "h-5 w-5" }),
blog: () => React.createElement(FileText, { className: "h-5 w-5" }),
logging: () => React.createElement(Server, { className: "h-5 w-5" }),
cache: () => React.createElement(HardDrive, { className: "h-5 w-5" }),
switch: () => React.createElement(Shield, { className: "h-5 w-5" }),
other: () => React.createElement(Settings, { className: "h-5 w-5" }),
};
// 4) 分组标题映射
const categoryTitles: Record<string, string> = {
site: "网站信息",
user: "用户设置",
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, validation: { maxLength: 200 } },
"site.keywords": { label: "关键词", type: "input", description: "逗号分隔blog,tech,ai" },
"site.url": {
label: "站点URL", type: "url", validation: {
custom: (value) => {
if (!value || value === "") return null;
if (!value.startsWith("http://") && !value.startsWith("https://")) {
return "URL必须以http://或https://开头";
}
return null;
}
}
},
"site.logo": {
label: "Logo地址", type: "url", validation: {
custom: (value) => {
if (!value || value === "") return null;
if (!value.startsWith("http://") && !value.startsWith("https://")) {
return "Logo地址必须以http://或https://开头";
}
return null;
}
}
},
"site.icp": { label: "ICP备案号", type: "input" },
"site.icp_url": { label: "备案链接", type: "url" },
"site.color_style": {
label: "配色风格",
type: "select",
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", validation: {
custom: (value) => {
if (!value || value === "") return null;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return "请输入有效的邮箱地址";
}
return null;
}
}
},
"email.smtp_from_name": { label: "发信人名称", type: "input" },
"email.smtp_from_email": {
label: "发信邮箱", type: "email", validation: {
custom: (value) => {
if (!value || value === "") return null;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return "请输入有效的邮箱地址";
}
return null;
}
}
},
"email.system_template": { label: "系统模板", type: "input" },
// blog
"blog.default_author": { label: "默认作者", type: "input" },
"blog.default_category": { label: "默认分类", type: "input" },
"blog.default_tag": { label: "默认标签", type: "input" },
"blog.open_comment": { label: "开启评论", type: "switch" },
// logging
"logging.level": {
label: "日志级别",
type: "select",
validation: { required: true },
options: [
{ label: "跟踪", value: "trace" },
{ label: "调试", value: "debug" },
{ label: "信息", value: "info" },
{ label: "警告", value: "warn" },
{ label: "错误", value: "error" }
]
},
"logging.max_files": { label: "最大文件数", type: "number", min: 1, max: 1000 },
"logging.max_file_size": { label: "单文件大小(MB)", type: "number", min: 1, max: 10240 },
// 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.ZodSafeParseError<any> | z.ZodSafeParseSuccess<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>;