init local code

This commit is contained in:
chenxing
2026-09-07 09:11:37 +08:00
commit a3a75c3680
112 changed files with 24837 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
/**
* 站点配置结构化表单纯函数模块(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: "如 /cms/upload/2026/06/logo.png 或 https://…",
tip: "门户顶栏与页脚品牌位展示;建议先在「附件管理」上传,复制路径填到这里。",
},
{
key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500,
placeholder: "如 /cms/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: "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 域子路径形态(/cms/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 由主题参数表单维护) */
export const RESERVED_SITE_CONFIG_KEYS = ["theme_params"];
/** 扩展键名约束(与后端 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);
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 "";
}