234 lines
11 KiB
TypeScript
234 lines
11 KiB
TypeScript
/**
|
||
* 站点配置结构化表单纯函数模块(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} 字` : "";
|
||
}
|
||
}
|