feat(theme): S5 学院蓝主题改造(管理端)——homeLayout编辑器/主题参数表单/站点设置/类型 + 单测
build / build (push) Successful in 2m19s

This commit is contained in:
chenxing
2026-09-22 18:34:50 +08:00
parent abccca4397
commit d05272b820
14 changed files with 893 additions and 186 deletions
+7 -18
View File
@@ -7,10 +7,8 @@ import config from "@/assets/config/host.config";
import { portalUrlOf, adminPathOf, previewPortalUrl } from "@/utils/sitePath";
import { useCmsStore } from "@/store";
import { SiteVo, ThemeVo } from "@/types/cms";
import { FormModelValue, formModelFromTheme, mergeThemeParamsIntoConfigJson, themeParamsFromConfigJson } from "@/utils/themeParamForm";
import { resolveEditableTheme } from "@/utils/themeEdit";
import ThemePicker from "@/components/theme/ThemePicker.vue";
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
@@ -46,7 +44,7 @@ const themes = ref<ThemeVo[]>([]);
/** 分区清单(锚点导航;locked = 创建模式锁定) */
const sections = [
{ key: "basic", title: "基本信息", desc: "编码 / 名称 / 归属 / 状态" },
{ key: "theme", title: "皮肤与外观", desc: "风格选择与微调" },
{ key: "theme", title: "皮肤与外观", desc: "选皮肤 / 自定义 CSS" },
{ key: "layout", title: "首页布局", desc: "区块序列编排", locked: true },
{ key: "display", title: "基础展示", desc: "品牌标识与图片" },
{ key: "footer", title: "页脚信息", desc: "联系方式与社媒" },
@@ -71,10 +69,9 @@ const basic = reactive({
status: 1,
});
/** ② 皮肤与外观分区 */
/** ② 皮肤与外观分区(工作项 T:参数微调已迁皮肤包管理,站点只负责选哪套包) */
const theme = ref("default");
const themeUnregistered = ref(false);
const themeParams = ref<FormModelValue>({});
const customCss = ref("");
const previewVisible = ref(false);
const editTheme = computed(() => themes.value.find(t => t.name === theme.value));
@@ -187,8 +184,6 @@ function initFromRow(id: number) {
theme.value = resolved.theme;
configJson.value = r.configJson || "";
customCss.value = readCustomCss(configJson.value);
const t = editTheme.value;
themeParams.value = t ? themeParamsFromConfigJson(configJson.value, t) : {};
}
function readCustomCss(configJson: string): string {
@@ -218,8 +213,6 @@ function pickTheme(name: string) {
if (theme.value === name) return;
theme.value = name;
themeUnregistered.value = false;
const t = editTheme.value;
themeParams.value = t ? formModelFromTheme(t, themeParams.value) : {};
}
function openPreview() {
@@ -228,7 +221,7 @@ function openPreview() {
/**
* 统一提交(§3.5 分区独立保存模型):payload = 页面当前事实源 + 基本信息快照;
* patch.configJson 供分区保存前合并自己的键空间(如皮肤分区的 theme_params/custom_css)。
* patch.configJson 供分区保存前合并自己的键空间(如皮肤分区的 custom_css)。
* 创建成功原地 replace 到 site/setting/{id} 并解锁全部分区。
*/
async function submitSection(key: string, patch: { configJson?: string }) {
@@ -259,7 +252,7 @@ async function submitSection(key: string, patch: { configJson?: string }) {
Message.success("保存成功!");
if (res) {
row.value = res;
// 服务端归一后的 configJson 刷新事实源(theme_params 合并等)
// 服务端归一后的 configJson 刷新事实源
if (typeof res.configJson === "string") {
configJson.value = res.configJson;
customCss.value = readCustomCss(configJson.value);
@@ -281,10 +274,7 @@ async function saveBasic() {
}
async function saveTheme() {
const t = editTheme.value;
const baseConfig = withCustomCss(configJson.value, customCss.value);
const merged = t ? mergeThemeParamsIntoConfigJson(baseConfig, t, themeParams.value) : baseConfig;
await submitSection("theme", { configJson: merged });
await submitSection("theme", { configJson: withCustomCss(configJson.value, customCss.value) });
}
async function saveDisplay() {
@@ -466,7 +456,7 @@ watch(() => store.currentSchool?.schId, () => loadSites());
<div id="sec-theme" class="card setting-card" data-section="theme">
<div class="card-header">
<span class="card-title">皮肤与外观</span>
<span class="card-subtitle">风格选择 / 参数微调 / 站点级 CSS 覆盖</span>
<span class="card-subtitle">风格选择 / 站点级 CSS 覆盖(微调请去「皮肤包管理」)</span>
</div>
<div class="card-body">
<div class="field-block">
@@ -479,7 +469,6 @@ watch(() => store.currentSchool?.schId, () => loadSites());
</Alert>
<ThemePicker :themes="themes" :model-value="theme" @update:model-value="pickTheme" />
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openPreview">预览本皮肤</Button>
<ThemeParamForm :theme="editTheme" v-model="themeParams" />
</div>
<!-- B3 自定义样式(config_json.custom_css,服务端黑名单强校验) -->
<Collapse class="css-collapse">
@@ -594,7 +583,7 @@ watch(() => store.currentSchool?.schId, () => loadSites());
v-if="previewVisible && editTheme && row"
:site-path="row.path || row.code"
:theme="theme"
:theme-params="themeParams"
:theme-params="editTheme?.paramValues || {}"
/>
</Modal>
</div>
+87 -20
View File
@@ -5,14 +5,18 @@ import { Message } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import WorkbenchHeader from "@/components/WorkbenchHeader.vue";
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
import { ThemeParamVo, ThemeVo, SiteVo } from "@/types/cms";
import { themeApi, siteApi, assetUrl } from "@/api/module/cms";
import { isValidParamValue, paramValuesOf, FormModelValue } from "@/utils/themeParamForm";
import { useCmsStore } from "@/store";
/**
* 皮肤包管理(W1 + 任务二/四,2026-09-08):平台级全屏页(/theme-manage,超管专属,
* 工作台按钮进入、不再挂左侧菜单)。内置包内容只读但显示名/描述可改(.meta 覆盖层)、
* 外部包可管(上传/派生/编辑/删除)。标识名(name,即主题路径/URL 段)仅创建时可定,创建后不可改。
* 工作项 T:参数值改挂皮肤包(themes/.meta/{name}.json → paramValues),编辑弹窗内嵌参数微调,
* 内置包与外部包同权(值只落覆盖层,不碰包内 theme.json);卡片显示「已微调 M 项」。
*/
const router = useRouter();
@@ -41,6 +45,9 @@ const metaTheme = ref<ThemeVo | null>(null);
const metaTitle = ref("");
const metaDescription = ref("");
const metaParams = ref<ThemeParamVo[]>([]);
/** 包级参数值模型(paramValuesOf 回显);paramTouched 决定保存时是否携带 paramValues */
const metaParamModel = ref<FormModelValue>({});
const paramTouched = ref(false);
// 非超管深链兜底(引导期守卫放行,user 装载后弹回工作台)
watch(
@@ -64,9 +71,8 @@ async function run() {
onMounted(run);
/** 预览内容源站点(W1:与向导同语义,取启用站点按门户 path 预览) */
async function openPreview(theme: ThemeVo) {
previewTheme.value = theme;
/** 预览内容源站点(W1:与向导同语义,取启用站点按门户 path 预览);亦借给参数微调的 image 上传归属 */
async function ensureContentSources(): Promise<SiteVo[]> {
if (!contentSources.value.length) {
try {
contentSources.value = ((await siteApi.list().send()) || []).filter(s => s.status === 1);
@@ -74,7 +80,16 @@ async function openPreview(theme: ThemeVo) {
contentSources.value = [];
}
}
const first = contentSources.value[0];
return contentSources.value;
}
/** image 型参数上传需归属站点(后端 siteId 必填);无可用站点时控件退化为仅手填路径 */
const metaSiteId = computed(() => contentSources.value[0]?.id);
async function openPreview(theme: ThemeVo) {
previewTheme.value = theme;
const sources = await ensureContentSources();
const first = sources[0];
previewSitePath.value = first ? first.path || first.code : "";
previewVisible.value = true;
}
@@ -145,36 +160,55 @@ async function doAdd() {
}
}
/* ===== 编辑元数据(任务四:内置包亦可改显示名/描述,走覆盖层) ===== */
function openMeta(theme: ThemeVo) {
/* ===== 编辑元数据(任务四:内置包亦可改显示名/描述,走覆盖层;工作项 T:包级参数值同区编辑) ===== */
async function openMeta(theme: ThemeVo) {
metaTheme.value = theme;
metaTitle.value = theme.title;
metaDescription.value = theme.description || "";
metaParams.value = (theme.params || []).map(p => ({ ...p }));
metaParamModel.value = paramValuesOf(theme);
paramTouched.value = false;
metaVisible.value = true;
if ((theme.params || []).some(p => p.type === "image")) await ensureContentSources();
}
function onParamModelChange(v: FormModelValue) {
metaParamModel.value = v;
paramTouched.value = true;
}
/** 待提交的包级参数值:只带「合法且偏离包内默认」的 key(= 卡片「已微调」口径,与后端整体替换语义一致) */
function currentParamValues(): Record<string, string> {
const result: Record<string, string> = {};
(metaTheme.value?.params || []).forEach(p => {
const v = metaParamModel.value[p.key];
if (isValidParamValue(p, v) && v !== (p.defaultValue ?? "")) result[p.key] = v;
});
return result;
}
async function doSaveMeta() {
const builtin = metaTheme.value?.source === "builtin";
await themeApi
.updateMeta(metaTheme.value!.name, {
title: metaTitle.value,
description: metaDescription.value,
// 内置包参数 schema 不可改(后端强校验),不传即不改动
params: builtin ? undefined : metaParams.value,
})
.send();
const payload = {
title: metaTitle.value,
description: metaDescription.value,
// 内置包参数 schema 不可改(后端强校验),不传即不改动
params: builtin ? undefined : metaParams.value,
} as { title: string; description: string; params?: unknown[]; paramValues?: Record<string, string> };
// 未触碰参数区则不带 paramValues 键(后端 null=不改),避免改名顺带清空微调
if (paramTouched.value) payload.paramValues = currentParamValues();
await themeApi.updateMeta(metaTheme.value!.name, payload).send();
Message.success("已保存,引用该皮肤的站点门户与预览立即生效");
metaVisible.value = false;
run();
}
/** 内置包恢复默认显示名/描述(清空 .meta 覆盖层) */
/** 内置包恢复默认(清空 .meta 覆盖层:改名 + 描述 + 包级参数微调一并回到包内声明) */
async function doResetBuiltinMeta() {
MyConfirm({
title: "恢复默认名称",
msg: `确定恢复「${metaTheme.value?.name}」的默认显示名称与描述吗?`,
info: "将清除改名覆盖层,恢复包内声明的原始名称",
info: "将清除改名覆盖层,恢复包内声明的原始名称;包级参数微调一并清空",
async onOk() {
await themeApi.updateMeta(metaTheme.value!.name, { title: "", description: "" }).send();
Message.success("已恢复默认名称");
@@ -206,6 +240,20 @@ function askRemove(theme: ThemeVo) {
}
const builtinCount = computed(() => themes.value.filter(t => t.source === "builtin").length);
/** 卡片「已微调 M 项」口径:包级 paramValues 里合法且偏离 theme.json 默认值的 key 数 */
function tweakedCount(t: ThemeVo): number {
const stored = t.paramValues || {};
return (t.params || []).filter(p => {
const v = stored[p.key];
return isValidParamValue(p, v) && v !== (p.defaultValue ?? "");
}).length;
}
function tweakSummary(t: ThemeVo): string {
const m = tweakedCount(t);
return m ? `已微调 ${m} 项` : "未微调";
}
</script>
<template>
@@ -234,13 +282,13 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
<div class="meta">
<p class="title">{{ t.title }}<span class="name">({{ t.name }})</span></p>
<p class="desc">{{ t.description || "—" }}</p>
<p class="stats">参数 {{ (t.params || []).length }} 个 · 被用 {{ t.inUseCount || 0 }} 站点</p>
<p class="stats">参数 {{ (t.params || []).length }} 个 · {{ tweakSummary(t) }} · 被用 {{ t.inUseCount || 0 }} 站点</p>
</div>
<div class="actions">
<Button size="small" @click="openPreview(t)">预览</Button>
<Tooltip
:content="t.source === 'builtin'
? '内置包内容只读,可改显示名称与描述'
? '内置包内容只读,可改显示名称与描述、微调包级参数值'
: (t.inUseCount || 0) > 0
? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
: '外部包可编辑参数 schema'"
@@ -334,14 +382,20 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
</Option>
</Select>
</div>
<ThemePreviewFrame fill :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" />
<ThemePreviewFrame
fill
:site-path="previewSitePath"
:theme="previewTheme?.name"
:theme-params="previewTheme?.paramValues || {}"
/>
</div>
</Modal>
<!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema) -->
<!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema。工作项 T:包级参数值两态同权编辑) -->
<Modal v-model="metaVisible" :title="`编辑皮肤:${metaTheme?.name || ''}`" :mask-closable="false" width="560px">
<Alert v-if="metaTheme?.source === 'builtin'" type="info" show-icon style="margin-bottom: 12px">
内置包内容(模板/资产)不可修改,仅显示名称与描述生效;标识名({{ metaTheme?.name }})不可改。
下方的参数微调只写包级覆盖层,不改包内 theme.json。
</Alert>
<Form :label-width="110">
<FormItem label="显示名称">
@@ -357,6 +411,14 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
</FormItem>
</template>
</Form>
<div class="meta-params">
<ThemeParamForm
:theme="metaTheme || undefined"
:model-value="metaParamModel"
:site-id="metaSiteId"
@update:model-value="onParamModelChange"
/>
</div>
<template #footer>
<Button v-if="metaTheme?.source === 'builtin'" style="float: left" @click="doResetBuiltinMeta">
恢复默认名称
@@ -462,6 +524,11 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
gap: 8px;
}
}
.meta-params {
margin-top: 4px;
padding-top: 12px;
border-top: 1px dashed #e9edf5;
}
.form-tip {
color: #999;
font-size: 12px;
+75 -1
View File
@@ -7,6 +7,8 @@ import {
BlockType,
HOME_LAYOUT_VERSION,
LayoutBlock,
LINK_NAME_MAX,
QUICK_LINK_MAX,
flattenListCategories,
genBlockId,
genFromCategories,
@@ -176,6 +178,24 @@ function onFieldChange() {
emitUp();
}
/** quick_links 入口行:编辑期内存允许留空行,提交时由 cleanBlock 剔除 */
type LinkItems = { name: string; url: string }[];
function itemsOf(block: LayoutBlock): LinkItems {
return block.items || (block.items = []);
}
function addLink(block: LayoutBlock) {
const items = itemsOf(block);
if (items.length >= QUICK_LINK_MAX) return;
items.push({ name: "", url: "" });
emitUp();
}
function removeLink(block: LayoutBlock, index: number) {
itemsOf(block).splice(index, 1);
emitUp();
}
/** 保存前校验(父分区调用):返回 null=通过,否则错误消息串 */
function validate(): string | null {
const errors = validateHomeLayout({ version: HOME_LAYOUT_VERSION, blocks: blocks.value });
@@ -239,7 +259,7 @@ defineExpose({ validate });
<Input
v-if="f.kind === 'text'"
v-model="selected[f.key]"
:maxlength="100"
:maxlength="f.maxlength || 100"
:placeholder="f.placeholder"
@on-change="onFieldChange"
/>
@@ -277,6 +297,35 @@ defineExpose({ validate });
<p v-if="!flat.length" class="hle-cats-empty">暂无列表型栏目</p>
</template>
</div>
<div v-else-if="f.kind === 'links'" class="hle-links">
<div v-for="(l, li) in itemsOf(selected!)" :key="li" class="hle-link-row">
<Input
v-model="l.name"
class="hle-link-name"
:maxlength="LINK_NAME_MAX"
placeholder="入口名称"
@on-change="onFieldChange"
/>
<Input v-model="l.url" placeholder="https://… 或 / 站内路径" @on-change="onFieldChange" />
<Button
size="small"
type="text"
icon="md-trash"
class="hle-link-del"
@click="removeLink(selected!, li)"
/>
</div>
<p v-if="!itemsOf(selected!).length" class="hle-links-empty">还没有入口,点下方按钮添加</p>
<Button
class="hle-link-add"
long
icon="md-add"
:disabled="itemsOf(selected!).length >= QUICK_LINK_MAX"
@click="addLink(selected!)"
>
添加入口
</Button>
</div>
<p v-if="f.tip" class="hle-field-tip">{{ f.tip }}</p>
</div>
</template>
@@ -459,6 +508,31 @@ defineExpose({ validate });
margin: 8px 0;
}
}
.hle-links {
display: flex;
flex-direction: column;
gap: 6px;
.hle-link-row {
display: flex;
align-items: center;
gap: 6px;
.hle-link-name {
flex: none;
width: 100px;
}
}
.hle-link-del:hover {
color: #ed4014;
}
.hle-links-empty {
color: #999;
font-size: 12px;
text-align: center;
}
.hle-link-add {
border-style: dashed;
}
}
}
.hle-type-card {
display: flex;
+49 -3
View File
@@ -4,16 +4,17 @@
* 编辑器属性面板/添加抽屉按本表 schema 驱动自动出现。
* propFields 顺序即属性面板渲染顺序;showIf 控制条件显示(如 background 仅 news_grid)。
*/
import { LayoutBlock } from "./homeLayout";
import { LIST_STYLES, styleLabel } from "./homeLayout";
import { LayoutBlock, LINK_NAME_MAX, LIST_STYLES, NOTICE_TEXT_MAX, QUICK_LINK_MAX, styleLabel } from "./homeLayout";
export interface BlockPropField {
key: string;
label: string;
kind: "text" | "number" | "select" | "switch" | "categories";
kind: "text" | "number" | "select" | "switch" | "categories" | "links";
options?: { value: string; label: string }[];
min?: number;
max?: number;
/** text 输入框字数上限(缺省 100) */
maxlength?: number;
placeholder?: string;
tip?: string;
/** 条件显示(返回 false 时隐藏且不校验) */
@@ -99,6 +100,42 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
{ key: "group", label: "链接分组", kind: "text", placeholder: "留空显示全部分组" },
],
},
{
type: "quick_links",
title: "快捷通道",
icon: "md-flash",
description: "入口墙:自行填写 1~12 个跳转入口(名称 + 地址),不依赖栏目与友情链接数据",
defaultProps: () => ({ items: [{ name: "", url: "" }] }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「快捷通道」" },
{
key: "items", label: "入口列表", kind: "links",
tip: `每个入口 = 名称(${LINK_NAME_MAX} 字内)+ 地址(http(s):// 或站内 / 路径),最多 ${QUICK_LINK_MAX} 个`,
},
],
},
{
type: "notice_bar",
title: "公告条",
icon: "md-volume-up",
description: "通栏提示条:一条全站公告(放假/停网/招生等),可选跳转与醒目配色",
defaultProps: () => ({ text: "", level: "info" }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「公告」,显示为左侧标签" },
{
key: "text", label: "公告正文", kind: "text", maxlength: NOTICE_TEXT_MAX,
placeholder: "如:国庆放假安排通知如下……", tip: `通栏一行展示,建议一句话(${NOTICE_TEXT_MAX} 字内)`,
},
{ key: "url", label: "跳转地址", kind: "text", placeholder: "可选:http(s):// 或站内 / 路径" },
{
key: "level", label: "提示级别", kind: "select",
options: [
{ value: "info", label: "一般(主题色)" },
{ value: "warn", label: "醒目(警示红)" },
],
},
],
},
];
export function blockDefOf(type: string): BlockTypeDef | undefined {
@@ -120,6 +157,15 @@ export function blockSummary(block: LayoutBlock, namesOf: (id: number) => string
parts.push("线上展厅图集");
} else if (block.type === "links") {
parts.push(block.group ? `分组:${block.group}` : "全部分组");
} else if (block.type === "quick_links") {
const filled = (block.items || []).filter(i => (i.name || "").trim() || (i.url || "").trim());
parts.push(`${filled.length} 个入口`);
} else if (block.type === "notice_bar") {
parts.push(block.level === "warn" ? "醒目提示" : "普通提示");
const text = (block.text || "").trim();
if (text) {
parts.push(text.length > 18 ? `${text.slice(0, 18)}…` : text);
}
}
const ids = block.categoryIds || [];
if (ids.length) {
@@ -126,6 +126,84 @@ describe("validateHomeLayout:与后端 SiteService 同语义", () => {
});
});
describe("quick_links / notice_bar(v2 手册 §2.8):装拆与校验须与后端 S3 同语义", () => {
const link = (name: string, url: string) => ({ name, url });
const quick = (items: LayoutBlock["items"]): LayoutBlock => ({ id: "q", type: "quick_links", visible: true, items });
const notice = (extra: Partial<LayoutBlock>): LayoutBlock => ({ id: "n", type: "notice_bar", visible: true, ...extra });
it("T-B-06 parseHomeLayout 接受两个新类型;normalizeBlock 还原 items/text/url/level 并剔除脏行", () => {
const json = JSON.stringify({
home_layout: {
version: 1,
blocks: [
{
id: "q1", type: "quick_links", title: "服务入口", items: [
link("办事大厅", "https://bsch.edu.cn/hall"), link("图书馆", "/library"),
"脏项", { name: 1, url: 2 },
],
},
{ id: "n1", type: "notice_bar", text: "国庆放假", url: "/notice/gq", level: "warn" },
{ id: "n2", type: "notice_bar", text: "常规提示" },
],
},
});
const layout = parseHomeLayout(json)!;
expect(layout.blocks.length).toBe(3);
expect(layout.blocks[0]).toMatchObject({
id: "q1", type: "quick_links", title: "服务入口", visible: true,
items: [link("办事大厅", "https://bsch.edu.cn/hall"), link("图书馆", "/library")],
});
expect(layout.blocks[1]).toMatchObject({ text: "国庆放假", url: "/notice/gq", level: "warn" });
expect(layout.blocks[2].level).toBeUndefined();
// 未知类型仍被跳过(两个新类型入白名单不等于放开任意类型)
expect(parseHomeLayout(JSON.stringify({ home_layout: { blocks: [{ type: "quicklink" }] } }))!.blocks).toEqual([]);
});
it("T-B-07 cleanBlock:两个新类型只写业务键、缺省值不落键、空入口行剔除", () => {
expect(cleanBlock(quick([link("办事大厅", "https://x.edu.cn"), link("", "")]))).toEqual({
id: "q", type: "quick_links", items: [link("办事大厅", "https://x.edu.cn")],
});
expect(cleanBlock(quick([]))).toEqual({ id: "q", type: "quick_links" });
expect(cleanBlock({ ...quick([link("图", "/lib")]), visible: false })).toEqual({
id: "q", type: "quick_links", visible: false, items: [link("图", "/lib")],
});
expect(cleanBlock(notice({ text: " 放假通知 ", url: "/a", level: "warn" }))).toEqual({
id: "n", type: "notice_bar", text: "放假通知", url: "/a", level: "warn",
});
// level=info 是缺省值 → 不写;url 空 → 不写
expect(cleanBlock(notice({ text: "提示", level: "info", url: "" }))).toEqual({ id: "n", type: "notice_bar", text: "提示" });
});
it("T-B-07b parse→serialize 往返稳定(新区块不被改写)", () => {
const json = JSON.stringify({
home_layout: {
version: 1,
blocks: [
{ id: "q1", type: "quick_links", items: [link("办事大厅", "https://x.edu.cn")] },
{ id: "n1", type: "notice_bar", text: "放假通知", url: "/notice", level: "warn" },
],
},
});
expect(JSON.parse(serializeHomeLayout(json, parseHomeLayout(json)))).toEqual(JSON.parse(json));
});
it("T-B-08 validateHomeLayout 与后端同语义:必填 / 12 项 / 20 字 / 200 字 / url 前缀 / level 白名单", () => {
expect(validateHomeLayout({ version: 1, blocks: [quick([link("办事大厅", "https://x.edu.cn")]), notice({ text: "通知" })] })).toEqual([]);
const joined = (blocks: LayoutBlock[]) => validateHomeLayout({ version: 1, blocks }).join(" ");
expect(joined([quick([])])).toContain("入口");
expect(joined([quick(Array.from({ length: 13 }, (_, i) => link(`入口${i}`, `/e${i}`)))])).toContain("12");
expect(joined([quick([link("长".repeat(21), "/x")])])).toContain("20");
expect(joined([quick([link("", "/x")])])).toContain("名称");
expect(joined([quick([link("办事大厅", "")])])).toContain("http(s)://");
expect(joined([quick([link("办事大厅", "javascript:alert(1)")])])).toContain("http(s)://");
expect(joined([quick([link("办事大厅", "lib.edu.cn")])])).toContain("http(s)://");
expect(joined([notice({})])).toContain("公告正文");
expect(joined([notice({ text: "告".repeat(201) })])).toContain("200");
expect(joined([notice({ text: "x", url: "javascript:alert(1)" })])).toContain("http(s)://");
expect(joined([notice({ text: "x", level: "danger" })])).toContain("info");
});
});
describe("genFromCategories:一键生成与现状渲染等价", () => {
const tree: CategoryTreeNode[] = [
cat(1, "校园时讯", 1, "news_grid", [cat(11, "院系动态", 1), cat(12, "单页栏目", 2), cat(13, "外链", 3)]),
+111 -5
View File
@@ -1,14 +1,22 @@
/**
* 首页布局纯函数模块(docs/16 主线B §4.1/§4.5)。
* 布局存 config_json.home_layout(零 DDL):{version:1, blocks:[…]};无该键 = 回落现状渲染(老站零迁移)。
* 与后端契约对齐:type 白名单(article_list/ticker/gallery/links)、size 1~20、title/subtitle ≤100 字;
* 与后端契约对齐:type 白名单(article_list/ticker/gallery/links/quick_links/notice_bar)、size 1~20、
* title/subtitle ≤100 字、quick_links items ≤12 项(name ≤20 字、url 须 http(s):// 或 / 开头)、
* notice_bar text 必填 ≤200 字、level ∈ {info,warn};
* 服务端 SiteService.validateHomeLayout 强校验,本模块做编辑端同语义校验与往返装拆。
* vitest 单测对象:homeLayout.spec.ts,不依赖组件挂载基建。
*/
import { CategoryTreeNode } from "@/types/cms";
/** v1 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */
export type BlockType = "article_list" | "ticker" | "gallery" | "links";
/** 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */
export type BlockType = "article_list" | "ticker" | "gallery" | "links" | "quick_links" | "notice_bar";
/** quick_links 单个入口(v2 手册 §2.8) */
export interface QuickLinkItem {
name: string;
url: string;
}
/** article_list 展示形态(与后端渲染端 style 分支及主题 fragment 对应) */
export const LIST_STYLES: { value: string; label: string }[] = [
@@ -44,6 +52,14 @@ export interface LayoutBlock {
source?: string;
/** links 分组名(空=全部分组) */
group?: string;
/** quick_links 入口列表(≤12 项;name ≤20 字、url 为 http(s):// 或站内 / 路径) */
items?: QuickLinkItem[];
/** notice_bar 公告正文(必填,≤200 字) */
text?: string;
/** notice_bar 跳转地址(可选) */
url?: string;
/** notice_bar 提示级别:info=一般(缺省)| warn=醒目 */
level?: string;
}
export interface HomeLayout {
@@ -53,12 +69,20 @@ export interface HomeLayout {
export const HOME_LAYOUT_VERSION = 1;
/** 服务端同款约束(SiteService §4.6) */
export const BLOCK_TYPES_ALLOWED: BlockType[] = ["article_list", "ticker", "gallery", "links"];
/** 服务端同款约束(SiteService §4.6 + v2 手册 §2.8) */
export const BLOCK_TYPES_ALLOWED: BlockType[] = [
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar",
];
export const SIZE_MIN = 1;
export const SIZE_MAX = 20;
export const TITLE_MAX = 100;
export const MAX_BLOCKS = 30;
export const QUICK_LINK_MAX = 12;
export const LINK_NAME_MAX = 20;
export const NOTICE_TEXT_MAX = 200;
export const NOTICE_LEVELS = ["info", "warn"];
/** 入口/跳转地址:http(s):// 绝对地址或站内 / 路径(与后端 HOME_LAYOUT_LINK_PATTERN 同语义) */
const LINK_URL_PATTERN = /^(https?:\/\/\S+|\/.+)$/;
export function genBlockId(): string {
return "b_" + Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4);
@@ -109,6 +133,24 @@ function toIdList(v: unknown): number[] | undefined {
return ids.length ? ids : undefined;
}
/** 入口列表容错还原:非对象项(字符串/数字/null)与非字符串 name/url 的脏行整行丢弃 */
function toLinkItems(v: unknown): QuickLinkItem[] | undefined {
if (!Array.isArray(v)) {
return undefined;
}
const items: QuickLinkItem[] = [];
for (const raw of v as unknown[]) {
const obj = asObject(raw);
const name = typeof obj?.["name"] === "string" ? (obj["name"] as string).trim() : null;
const url = typeof obj?.["url"] === "string" ? (obj["url"] as string).trim() : null;
if (name === null || url === null) {
continue;
}
items.push({ name, url });
}
return items.length ? items : undefined;
}
function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBlock {
const size = typeof obj["size"] === "number" ? Math.round(obj["size"]) : undefined;
const block: LayoutBlock = {
@@ -123,6 +165,15 @@ function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBl
if (typeof obj["background"] === "boolean") block.background = obj["background"];
if (typeof obj["source"] === "string") block.source = obj["source"];
if (typeof obj["group"] === "string") block.group = obj["group"];
if (type === "quick_links") {
const items = toLinkItems(obj["items"]);
if (items) block.items = items;
}
if (type === "notice_bar") {
if (typeof obj["text"] === "string") block.text = obj["text"];
if (typeof obj["url"] === "string") block.url = obj["url"];
if (typeof obj["level"] === "string") block.level = obj["level"];
}
const ids = toIdList(obj["categoryIds"]);
if (ids) block.categoryIds = ids;
return block;
@@ -171,6 +222,26 @@ export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
if (group) {
out["group"] = group;
}
} else if (block.type === "quick_links") {
// 编辑期允许存在未填写的空行,提交前整行剔除(后端要求 name/url 齐备)
const items = (block.items || [])
.map(i => ({ name: (i.name || "").trim(), url: (i.url || "").trim() }))
.filter(i => i.name || i.url);
if (items.length) {
out["items"] = items;
}
} else if (block.type === "notice_bar") {
const text = (block.text || "").trim();
if (text) {
out["text"] = text;
}
const url = (block.url || "").trim();
if (url) {
out["url"] = url;
}
if (block.level === "warn") {
out["level"] = "warn";
}
}
return out;
}
@@ -228,6 +299,41 @@ export function validateHomeLayout(layout: HomeLayout | null): string[] {
if (b.type === "ticker" && b.source === "category" && !(b.categoryIds && b.categoryIds.length)) {
errors.push(`${no}:指定栏目模式请选择栏目`);
}
if (b.type === "quick_links") {
const items = b.items || [];
if (!items.length) {
errors.push(`${no}:请至少填写 1 个入口`);
} else if (items.length > QUICK_LINK_MAX) {
errors.push(`${no}:入口数量不能超过 ${QUICK_LINK_MAX} 个`);
}
items.forEach(item => {
const name = (item.name || "").trim();
const linkUrl = (item.url || "").trim();
if (!name) {
errors.push(`${no}:入口名称不能为空`);
} else if (name.length > LINK_NAME_MAX) {
errors.push(`${no}:入口名称不能超过 ${LINK_NAME_MAX} 字`);
}
if (!LINK_URL_PATTERN.test(linkUrl)) {
errors.push(`${no}:入口「${name || linkUrl}」地址须为 http(s):// 开头或站内路径(/ 开头)`);
}
});
}
if (b.type === "notice_bar") {
const text = (b.text || "").trim();
if (!text) {
errors.push(`${no}:请填写公告正文`);
} else if (text.length > NOTICE_TEXT_MAX) {
errors.push(`${no}:公告正文不能超过 ${NOTICE_TEXT_MAX} 字`);
}
const linkUrl = (b.url || "").trim();
if (linkUrl && !LINK_URL_PATTERN.test(linkUrl)) {
errors.push(`${no}:跳转地址须为 http(s):// 开头或站内路径(/ 开头)`);
}
if (b.level != null && !NOTICE_LEVELS.includes(b.level)) {
errors.push(`${no}:提示级别只能是 info 或 warn`);
}
}
});
return errors;
}