Files
YschcmsFront/src/utils/siteConfigForm.ts
T

244 lines
10 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)。
* 已知键与模板/Java 实际消费方对齐(docs/09 §0 键清单表);未知字符串键 → 可编辑扩展行
* (Projman「添加项」模式);未知非字符串键(theme_params 等系统托管键)原样保留无损回写。
* 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";
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,
placeholder: "如 /yschcms/upload/…/favicon.png(留空用主题内置图标)",
},
{
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 号",
},
{
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,
placeholder: "如 /yschcms/upload/…/qr-wechat.png(留空不展示)",
tip: "页脚二维码,悬停放大预览;建议先在「附件管理」上传。",
},
{
key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500,
placeholder: "如 /yschcms/upload/…/qr-weibo.png(留空不展示)",
},
{
key: "social_wechat", label: "微信公众号", group: "footer", kind: "url", maxLength: 500,
placeholder: "微信公众号主页链接,如 https://mp.weixin.qq.com/…(留空不展示图标)",
},
{
key: "social_weibo", label: "微博", group: "footer", kind: "url", maxLength: 500,
placeholder: "微博主页链接 https://weibo.com/…(留空不展示图标)",
},
{
key: "social_douyin", label: "抖音", group: "footer", kind: "url", maxLength: 500,
placeholder: "抖音主页链接 https://www.douyin.com/…(留空不展示图标)",
},
{
key: "social_bilibili", label: "哔哩哔哩", group: "footer", kind: "url", maxLength: 500,
placeholder: "B 站主页链接 https://space.bilibili.com/…(留空不展示图标)",
},
{
key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200,
placeholder: "如:XX学校 · 版权所有 © 2026",
},
{
key: "beian", label: "备案号", group: "footer", kind: "text", maxLength: 100,
placeholder: "如:浙ICP备XXXXXXXX号-1",
},
{
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/{站点编码})。",
},
];
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 SiteConfigExtraRow {
key: string;
value: string;
}
export interface SiteConfigFormModel {
fields: Record<string, string>;
extras: SiteConfigExtraRow[];
/** 系统托管键 + 非字符串未知键,无损保留 */
preserved: Record<string, unknown>;
}
/** configJson → 表单模型:已知键入 fields、未知字符串键入扩展行、其余无损入 preserved */
export function parseSiteConfig(configJson: string | undefined | null): SiteConfigFormModel {
const model: SiteConfigFormModel = { fields: {}, extras: [], 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));
const reserved = new Set([...RESERVED_SITE_CONFIG_KEYS, ...ABSORBED_LEGACY_KEYS]);
Object.keys(parsed).forEach(k => {
const v = parsed![k];
if (typeof v !== "string") {
model.preserved[k] = v;
} else if (known.has(k)) {
model.fields[k] = v;
} else if (reserved.has(k)) {
model.preserved[k] = v;
} else {
model.extras.push({ key: k, value: 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;
}
});
(model.extras || []).forEach(row => {
const k = (row.key || "").trim();
const v = (row.value || "").trim();
if (k && v) {
out[k] = v;
}
});
Object.keys(model.preserved || {}).forEach(k => {
out[k] = model.preserved[k];
});
return JSON.stringify(out);
}
/** 已知字段值校验:返回错误消息,空串=通过(与后端 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} 字` : "";
}
}
/** 扩展行校验:键名格式 / 与内置键或系统键冲突 / 行间重复;返回错误消息,空串=通过(空键行视为待填) */
export function extraRowError(rows: SiteConfigExtraRow[], index: number): string {
const row = rows[index];
const key = (row?.key || "").trim();
if (!key) {
return "";
}
if (!EXTRA_KEY_RE.test(key)) {
return "键名仅限字母/数字/下划线,且以字母或下划线开头";
}
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
if (known.has(key) || RESERVED_SITE_CONFIG_KEYS.includes(key)) {
return "扩展键不能与内置键/系统键重名";
}
const dup = rows.findIndex(r => (r.key || "").trim() === key);
if (dup >= 0 && dup !== index) {
return "扩展键重复";
}
return "";
}
/** 保存前兜底:返回第一条扩展行错误,空串=全部通过 */
export function firstExtraError(rows: SiteConfigExtraRow[]): string {
for (let i = 0; i < (rows || []).length; i++) {
const msg = extraRowError(rows, i);
if (msg) {
return msg;
}
}
return "";
}