Files
YschcmsFront/src/utils/siteConfigForm.ts
T

234 lines
11 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.
/**
* 站点配置结构化表单纯函数模块(docs/09 §3.1;docs/20 F6 扩展参数分区退役)。
* 已知键与模板/Java 实际消费方对齐(docs/09 §0 键清单表);
* 未知键(字符串与非字符串)一律无损入 preserved 静默保留——不再提供「扩展参数」编辑区
* (后端对未知键的宽容策略不变:不拦截、不渲染、不丢失)。
* vitest 单测对象:siteConfigForm.spec.ts,不依赖组件挂载基建。
*/
export type SiteConfigGroup = "display" | "footer" | "seo" | "domain";
export interface SiteConfigFieldDef {
key: string;
label: string;
group: SiteConfigGroup;
placeholder?: string;
tip?: string;
multiline?: boolean;
/** url: http(s) URL;host: 域名(不带协议);asset: / 开头路径或 http(s) URL;text: 仅长度 */
kind?: "text" | "url" | "host" | "asset";
/** F5(docs/20):asset 类字段的预览/占位框比例(favicon 1:1、二维码 4:3) */
aspect?: "1:1" | "4:3";
maxLength?: number;
}
/** 与后端 SiteService.validateConfigJson 的已知键约束保持一致 */
export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
{
key: "logo", label: "站点 Logo", group: "display", kind: "asset", maxLength: 500,
placeholder: "如 /yschcms/upload/2026/06/logo.png 或 https://…",
tip: "门户顶栏与页脚品牌位展示;建议先在「附件管理」上传,复制路径填到这里。",
},
{
key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500, aspect: "1:1",
placeholder: "如 /yschcms/upload/…/favicon.png(留空用主题内置图标)",
tip: "浏览器标签页与收藏夹的小图标,不显示在页面正文中。",
},
{
key: "slogan", label: "首页标语", group: "display", kind: "text", maxLength: 200,
placeholder: "门户顶栏展示的一句话标语(可选)",
},
{
key: "colleges_label", label: "院系下拉文案", group: "display", kind: "text", maxLength: 50,
placeholder: "留空默认「专业院系」",
tip: "门户右上「专业院系」下拉的文案;仅主站有效(下拉由院系子站生成)。",
},
{
key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true,
placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号",
tip: "显示在页脚品牌区(页脚第一行位置)。",
},
{
key: "footer_addresses", label: "多校区地址", group: "footer", kind: "text", maxLength: 500, multiline: true,
placeholder: "一行一个校区,如:\nXX校区:杭州市 XX 路 100 号 邮编:310000",
tip: "逐行展示在页脚品牌区旁;换行即分行(最多 500 字)。",
},
{
key: "qr_wechat", label: "微信二维码", group: "footer", kind: "asset", maxLength: 500, aspect: "4:3",
placeholder: "如 /yschcms/upload/…/qr-wechat.png(留空不展示)",
tip: "页脚二维码,悬停放大预览;建议先在「附件管理」上传。",
},
{
key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500, aspect: "4:3",
placeholder: "如 /yschcms/upload/…/qr-weibo.png(留空不展示)",
tip: "显示在页脚右侧二维码区,与微信二维码并排,悬停放大预览。",
},
{
key: "social_wechat", label: "微信公众号", group: "footer", kind: "url", maxLength: 500,
placeholder: "微信公众号主页链接,如 https://mp.weixin.qq.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
},
{
key: "social_weibo", label: "微博", group: "footer", kind: "url", maxLength: 500,
placeholder: "微博主页链接 https://weibo.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
},
{
key: "social_douyin", label: "抖音", group: "footer", kind: "url", maxLength: 500,
placeholder: "抖音主页链接 https://www.douyin.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
},
{
key: "social_bilibili", label: "哔哩哔哩", group: "footer", kind: "url", maxLength: 500,
placeholder: "B 站主页链接 https://space.bilibili.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
},
{
key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200,
placeholder: "如:XX学校 · 版权所有 © 2026",
tip: "页脚最底行的版权声明文字。",
},
{
key: "beian", label: "备案号", group: "footer", kind: "text", maxLength: 100,
placeholder: "如:浙ICP备XXXXXXXX号-1",
tip: "页脚最底行版权文字旁的 ICP 备案号(可点击性质由模板决定,通常为纯文字)。",
},
{
key: "seo_description", label: "SEO 描述", group: "seo", kind: "text", maxLength: 500, multiline: true,
placeholder: "搜索引擎摘要(meta description,建议 80~160 字)",
},
{
key: "domain", label: "绑定域名", group: "domain", kind: "host", maxLength: 200,
placeholder: "如 www.xxx.edu.cn(不带 https://)",
tip: "学校独立域名台账;DNS 解析生效后需配合填写对外基地址。",
},
{
key: "public_base_url", label: "对外基地址", group: "domain", kind: "url", maxLength: 500,
placeholder: "如 https://www.xxx.edu.cn",
tip: "绑定独立域名后必填;未填时全站链接走 base 域子路径形态(/yschcms/portal/{站点编码})。",
},
{
key: "svc_hall_base", label: "服务大厅基地址", group: "domain", kind: "url", maxLength: 500,
placeholder: "如 https://svhall.xxx.edu.cn(留空不生成「我要办理」链接)",
tip: "事项指南页「我要办理」按钮:事项单独配置的外链优先;未单独配置时用本地址生成服务大厅发起页(/#/apply?siteCode=…&itemCode=…)。",
},
];
export const SITE_CONFIG_GROUPS: { name: SiteConfigGroup; title: string }[] = [
{ name: "display", title: "基础展示" },
{ name: "footer", title: "页脚信息" },
{ name: "seo", title: "SEO" },
{ name: "domain", title: "域名与访问" },
];
/**
* 系统托管键:不进表单,解析时原样保留、序列化时无损回写(theme_params 由主题参数表单维护;
* custom_css 由站点设置「皮肤与外观」分区维护)。
*/
export const RESERVED_SITE_CONFIG_KEYS = ["theme_params", "custom_css"];
/**
* docs/16 D4 被吸收的旧键:画廊标题/副题、新闻卡背景图改由首页布局区块属性承载(home_layout),
* 分区表单不再展示;存量值静默保留(渲染端仍作区块未设 title/background 时的默认值)。
*/
export const ABSORBED_LEGACY_KEYS = ["gallery_title", "gallery_subtitle", "news_bg_image"];
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致;供后续功能键落位时复用) */
export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
export const HOST_RE = /^(\*\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
export const URL_RE = /^https?:\/\/\S+$/;
export const ASSET_RE = /^(\/.+|https?:\/\/\S+)$/;
export interface SiteConfigFormModel {
fields: Record<string, string>;
/** 非已知键(含系统托管键与未知键)无损保留 */
preserved: Record<string, unknown>;
}
/** configJson → 表单模型:已知键入 fields,其余(系统托管键/旧键/未知键)无损入 preserved */
export function parseSiteConfig(configJson: string | undefined | null): SiteConfigFormModel {
const model: SiteConfigFormModel = { fields: {}, preserved: {} };
if (!configJson || !configJson.trim()) {
return model;
}
let parsed: Record<string, unknown> | null = null;
try {
const obj = JSON.parse(configJson);
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
parsed = obj;
}
} catch {
// 非法 JSON 按空配置处理(与后端 ExtJsonUtils.parse 语义一致)
}
if (!parsed) {
return model;
}
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
Object.keys(parsed).forEach(k => {
const v = parsed![k];
if (known.has(k) && typeof v === "string") {
model.fields[k] = v;
} else {
model.preserved[k] = v;
}
});
return model;
}
/** 表单模型 → configJson:空值剔除;preserved 原样回写(永不丢失) */
export function serializeSiteConfig(model: SiteConfigFormModel): string {
const out: Record<string, unknown> = {};
SITE_CONFIG_FIELD_DEFS.forEach(def => {
const v = (model.fields[def.key] || "").trim();
if (v) {
out[def.key] = v;
}
});
Object.keys(model.preserved || {}).forEach(k => {
out[k] = model.preserved[k];
});
return JSON.stringify(out);
}
/**
* F1(docs/20)Tab 独立保存:服务端事实源 base + 本 Tab 增量 patch 合并——
* patch 值为空串/null/undefined = 删除该键;其余键原样保留。
* 各 Tab 只提交自己的键空间,其他 Tab 未保存的内存修改不会被连带提交。
*/
export function mergeConfigPatch(base: string | undefined | null, patch: Record<string, unknown>): string {
let obj: Record<string, unknown>;
try {
const parsed = base && base.trim() ? JSON.parse(base) : {};
obj = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
} catch {
obj = {};
}
Object.entries(patch).forEach(([k, v]) => {
if (v === undefined || v === null || (typeof v === "string" && !v.trim())) {
delete obj[k];
} else {
obj[k] = v;
}
});
return JSON.stringify(obj);
}
/** 已知字段值校验:返回错误消息,空串=通过(与后端 validateConfigJson 同语义) */
export function validateFieldValue(def: SiteConfigFieldDef, value: string): string {
const v = (value || "").trim();
if (!v) {
return "";
}
switch (def.kind) {
case "url":
return URL_RE.test(v) ? "" : "须为完整的 http(s):// 地址";
case "host":
return HOST_RE.test(v) ? "" : "须为域名形式(如 www.xxx.edu.cn,不带协议)";
case "asset":
return ASSET_RE.test(v) ? "" : "须为站点上传路径(/ 开头)或 http(s) 地址";
default:
return def.maxLength && v.length > def.maxLength ? `不能超过 ${def.maxLength} 字` : "";
}
}