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
+142
View File
@@ -0,0 +1,142 @@
// @vitest-environment jsdom
/**
* 首页布局编辑器(v2 工作项 B / 手册 §2.8)挂载回归:T-B-09——
* quick_links 的属性面板必须是「数组编辑器」(每行 name + url,可增删、受 12 项上限约束),
* notice_bar 必须是正文 + 可选跳转 + level 选择;序列行摘要走 blockSummary 新增分支。
* 沿用 siteSetting.spec.ts 的手写 createApp + vi.mock 桩风格。
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import ViewUIPlus from "view-ui-plus";
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
vi.mock("@/api/module/cms", () => ({
categoryApi: { tree: () => ({ send: async () => [] }) },
}));
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
const cleanups: (() => void)[] = [];
/** 排空微任务 + 一个宏任务(view-ui-plus 的 Select 标签回填走 nextTick/setTimeout) */
async function settle(frames = 4) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
for (let i = 0; i < frames; i++) {
await nextTick();
}
}
const CONFIG = JSON.stringify({
logo: "/a.png",
home_layout: {
version: 1,
blocks: [
{ id: "q1", type: "quick_links", items: [{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }] },
{ id: "n1", type: "notice_bar", text: "国庆放假安排", url: "/notice/gq", level: "warn" },
],
},
});
async function mountEditor(configJson: string) {
const el = document.createElement("div");
document.body.appendChild(el);
const model = ref(configJson);
const app = createApp(
defineComponent({
render: () => h(HomeLayoutEditor, { modelValue: model.value, "onUpdate:modelValue": (v: string) => (model.value = v) }),
}),
);
app.use(ViewUIPlus);
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
});
await settle(6);
return { el, model };
}
/** 取属性面板中指定 label 的字段容器 */
function fieldOf(el: HTMLElement, label: string): HTMLElement | null {
return (
Array.from(el.querySelectorAll<HTMLElement>(".hle-field")).find(
f => f.querySelector(".hle-field-label")?.textContent?.trim() === label,
) || null
);
}
/** 第 index 行的删除按钮 */
function del(field: HTMLElement, index: number): HTMLElement {
return field.querySelectorAll<HTMLElement>(".hle-link-del")[index];
}
afterEach(() => {
cleanups.splice(0).forEach(fn => fn());
});
describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09)", () => {
it("quick_links:入口行编辑器渲染 name+url 双输入,添加/删除可用且回写 items", async () => {
const { el, model } = await mountEditor(CONFIG);
// 序列行摘要走 blockSummary 新增分支
expect(el.textContent).toContain("1 个入口");
expect(el.textContent).toContain("醒目提示");
// 选中 quick_links 行 → 属性面板出现入口列表编辑器
(el.querySelectorAll(".hle-row")[0] as HTMLElement).click();
await settle();
const field = fieldOf(el as HTMLElement, "入口列表");
expect(field).not.toBeNull();
expect(field!.querySelectorAll(".hle-link-row").length).toBe(1);
expect(field!.querySelectorAll(".hle-link-row input").length).toBe(2);
const add = field!.querySelector(".hle-link-add") as HTMLElement;
expect(add.textContent).toContain("添加入口");
add.click();
await settle();
expect(field!.querySelectorAll(".hle-link-row").length).toBe(2);
// 新增行是空行:回写布局时空行由 cleanBlock 剔除,故提交形态仍只 1 项
const blocks = JSON.parse(model.value).home_layout.blocks;
expect(blocks[0].items).toEqual([{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }]);
// 其余键无损
expect(JSON.parse(model.value).logo).toBe("/a.png");
// 删掉尾部的空行 → 回到 1 行,提交形态不变
del(field!, 1).click();
await settle();
expect(field!.querySelectorAll(".hle-link-row").length).toBe(1);
expect(JSON.parse(model.value).home_layout.blocks[0].items).toEqual([{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }]);
// 再删唯一入口行 → 空列表(提交时 items 不落键,由后端校验拦下「至少 1 个入口」)
del(field!, 0).click();
await settle();
expect(field!.querySelectorAll(".hle-link-row").length).toBe(0);
expect(JSON.parse(model.value).home_layout.blocks[0].items).toBeUndefined();
});
it("quick_links:12 项上限时添加按钮禁用", async () => {
const items = Array.from({ length: 12 }, (_, i) => ({ name: `入口${i}`, url: `/e${i}` }));
const config = JSON.stringify({ home_layout: { version: 1, blocks: [{ id: "q1", type: "quick_links", items }] } });
const { el } = await mountEditor(config);
(el.querySelectorAll(".hle-row")[0] as HTMLElement).click();
await settle();
const field = fieldOf(el as HTMLElement, "入口列表")!;
expect(field.querySelectorAll(".hle-link-row").length).toBe(12);
expect((field.querySelector(".hle-link-add") as HTMLButtonElement).disabled).toBe(true);
});
it("notice_bar:正文输入放宽到 200 字、跳转可选、level 为 info/warn 选择器", async () => {
const { el } = await mountEditor(CONFIG);
(el.querySelectorAll(".hle-row")[1] as HTMLElement).click();
await settle();
const text = fieldOf(el as HTMLElement, "公告正文")!.querySelector("input")!;
expect(text.getAttribute("maxlength")).toBe("200");
expect(fieldOf(el as HTMLElement, "跳转地址")).not.toBeNull();
const levelField = fieldOf(el as HTMLElement, "提示级别")!;
expect(levelField.querySelector(".ivu-select")).not.toBeNull();
expect(levelField.textContent).toContain("醒目");
});
});
+28
View File
@@ -246,4 +246,32 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留 expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留
expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局分区未动 expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局分区未动
}); });
it("T-T-05: 皮肤分区不再有参数微调区(只留选皮肤 + 站点级 CSS),保存不写 theme_params", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
const themeCard = el.querySelector('[data-section="theme"]')!;
expect(themeCard).toBeTruthy();
// ThemeParamForm 已被桩化为 theme-param-stub:站点设置页不再出现该桩
expect(themeCard.textContent).not.toContain("theme-param-stub");
expect(themeCard.textContent).not.toContain("参数微调");
// 站点级 CSS 覆盖区保留(副标题文案 + textarea 编辑框)
expect(themeCard.textContent).toContain("站点级 CSS 覆盖");
expect(themeCard.querySelector("textarea")).toBeTruthy();
// v1 §三:站点只负责选哪套包(选皮肤仍在)
expect(themeCard.textContent).toContain("theme-picker-stub");
const saveBtn = [...themeCard.querySelectorAll("button")].find(b =>
b.textContent?.includes("仅保存皮肤与外观")
)!;
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
const config = JSON.parse(mocks.siteSave.mock.calls[0][0].configJson);
expect(config).not.toHaveProperty("theme_params"); // 参数值不再由站点提交
expect(config.logo).toBe("/cms/upload/logo.png"); // 其余键仍随事实源保留
});
}); });
+101 -4
View File
@@ -4,8 +4,10 @@
* ① 内置卡按钮为"预览+编辑"(任务四:内置包可改显示名/描述); * ① 内置卡按钮为"预览+编辑"(任务四:内置包可改显示名/描述);
* ② 被用外部包删除禁用且 tooltip 含站点名; * ② 被用外部包删除禁用且 tooltip 含站点名;
* ③ 上传改为表单弹窗(zip + 命名),提交走手动上传通道; * ③ 上传改为表单弹窗(zip + 命名),提交走手动上传通道;
* ④ 内置编辑弹窗无参数区、保存不带 params(后端强校验拒改); * ④ 内置编辑弹窗不开放参数 schema 区、保存不带 params(后端强校验拒改);
* ⑤ 添加皮肤包表单标识名为空时拦截。 * ⑤ 添加皮肤包表单标识名为空时拦截。
* ⑥ 工作项 T(T-T-06):编辑弹窗内嵌参数微调(内置包亦开放),回显/提交包级 paramValues,
* 未触碰不带 paramValues(null=不改),卡片显示「已微调 M 项」。
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。 * 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -28,6 +30,8 @@ const themes = [
description: "", description: "",
screenshotUrl: "/api/theme/default/asset/screenshot.png", screenshotUrl: "/api/theme/default/asset/screenshot.png",
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }], params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
// 工作项 T:包级参数值(仅含被微调过的 key)
paramValues: { primary: "#0a5c36" },
source: "builtin", source: "builtin",
inUseCount: 2, inUseCount: 2,
editable: true, editable: true,
@@ -86,7 +90,41 @@ vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
default: defineComponent({ default: defineComponent({
props: ["sitePath", "theme", "themeParams", "fill"], props: ["sitePath", "theme", "themeParams", "fill"],
setup: (props: any) => () => setup: (props: any) => () =>
h("div", { "data-preview-stub": "true", "data-fill": String(props.fill !== undefined && props.fill !== false) }, "preview-stub"), h(
"div",
{
"data-preview-stub": "true",
"data-fill": String(props.fill !== undefined && props.fill !== false),
"data-params": JSON.stringify(props.themeParams || {}),
},
"preview-stub"
),
}),
}));
/**
* T-T-06:参数微调迁皮肤包级后,编辑弹窗内嵌 ThemeParamForm。
* 桩件把当前表单模型回显在 data-model 上,并给一个「改主色」按钮模拟用户改动。
*/
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
default: defineComponent({
props: ["theme", "modelValue"],
emits: ["update:modelValue"],
setup: (props: any, { emit }: any) => () =>
h(
"div",
{ "data-param-stub": "true", "data-model": JSON.stringify(props.modelValue || {}) },
[
h(
"button",
{
class: "stub-tweak",
onClick: () => emit("update:modelValue", { ...(props.modelValue || {}), primary: "#111111" }),
},
"改主色"
),
]
),
}), }),
})); }));
@@ -207,7 +245,7 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2); expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2);
}); });
it("任务四: 内置包编辑弹窗无参数区,保存调用 updateMeta 不带 params", async () => { it("任务四: 内置包编辑弹窗不开放参数 schema 编辑区,保存调用 updateMeta 不带 params", async () => {
const el = mountPage(); const el = mountPage();
await flush(); await flush();
@@ -215,7 +253,7 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
clickButtonByText(builtin, "编辑"); clickButtonByText(builtin, "编辑");
await flush(); await flush();
// 内置提示条存在、无 ColorPicker(参数 schema 区不渲染) // 内置提示条存在、无 ColorPicker(参数 schema 编辑区不渲染;包级参数值是另一区,见 T-T-06)
const metaModal = modalContaining("内置包内容(模板/资产)不可修改"); const metaModal = modalContaining("内置包内容(模板/资产)不可修改");
expect(metaModal).toBeTruthy(); expect(metaModal).toBeTruthy();
expect(document.body.textContent).toContain("内置包内容(模板/资产)不可修改"); expect(document.body.textContent).toContain("内置包内容(模板/资产)不可修改");
@@ -229,6 +267,50 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
); );
}); });
it("T-T-06: 编辑弹窗内嵌参数微调,回显包级 paramValues(缺项回落默认),保存提交 paramValues", async () => {
const el = mountPage();
await flush();
clickButtonByText([...el.querySelectorAll(".card")][0], "编辑");
await flush();
const metaModal = modalContaining("内置包内容(模板/资产)不可修改");
const stub = metaModal!.querySelector('[data-param-stub]');
expect(stub).toBeTruthy();
// paramValuesOf:已微调的 primary 取包值,未声明微调的走 theme.json 默认
expect(JSON.parse(stub!.getAttribute("data-model")!)).toEqual({ primary: "#0a5c36" });
// 改主色 → 保存:只提交「与默认值不同」的 key(=「已微调」口径,与后端整体替换语义对齐)
clickButtonByText(metaModal!, "改主色");
await flush();
clickButtonByText(metaModal!, "保存");
await flush();
expect(updateMetaMock).toHaveBeenCalledWith("default", expect.objectContaining({ paramValues: { primary: "#111111" } }));
});
it("T-T-06: 未触碰参数区时保存不带 paramValues(后端 null=不改,防止改名顺带清空微调)", async () => {
const el = mountPage();
await flush();
clickButtonByText([...el.querySelectorAll(".card")][2], "编辑"); // free-skin(外部包,无微调)
await flush();
const metaModal = modalContaining("编辑皮肤:free-skin");
expect(metaModal).toBeTruthy();
expect(JSON.parse(metaModal!.querySelector('[data-param-stub]')!.getAttribute("data-model")!)).toEqual({});
clickButtonByText(metaModal!, "保存");
await flush();
const payload = updateMetaMock.mock.calls[0][1] as Record<string, unknown>;
expect("paramValues" in payload).toBe(false);
});
it("T-T-06: 卡片显示「已微调 M 项」,无微调的包显示未微调", async () => {
const el = mountPage();
await flush();
const cards = [...el.querySelectorAll(".card")];
expect(cards[0].textContent).toContain("参数 1 个 · 已微调 1 项");
expect(cards[1].textContent).toContain("未微调"); // my-skin 无 paramValues
expect(cards[2].textContent).toContain("参数 0 个");
});
it("任务四: 添加皮肤包表单——标识名为空被拦截,不调用 derive", async () => { it("任务四: 添加皮肤包表单——标识名为空被拦截,不调用 derive", async () => {
const el = mountPage(); const el = mountPage();
await flush(); await flush();
@@ -261,4 +343,19 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
expect(stub).toBeTruthy(); expect(stub).toBeTruthy();
expect(stub!.getAttribute("data-fill")).toBe("true"); expect(stub!.getAttribute("data-fill")).toBe("true");
}); });
it("T-T-06 佐证: 预览转发包级 paramValues(后端 /api/theme/preview 只认 p_*,不转发则预览丢包色)", async () => {
const el = mountPage();
await flush();
clickButtonByText([...el.querySelectorAll(".card")][0], "预览"); // default:paramValues={primary:#0a5c36}
await flush();
const stub = modalContaining("内容源站点")!.querySelector("[data-preview-stub]")!;
expect(JSON.parse(stub.getAttribute("data-params")!)).toEqual({ primary: "#0a5c36" });
clickButtonByText([...el.querySelectorAll(".card")][2], "预览"); // free-skin:未微调
await flush();
const free = modalContaining("内容源站点")!.querySelector("[data-preview-stub]")!;
expect(JSON.parse(free.getAttribute("data-params")!)).toEqual({});
});
}); });
+10 -3
View File
@@ -106,7 +106,8 @@ class SiteApi {
return get<SiteVo[]>(`${SITE}/list`); return get<SiteVo[]>(`${SITE}/list`);
} }
/** 入参对齐 SiteSaveRequest(themeParams 服务端合并进 config_json.theme_params,方案 §5.3) */ /** 入参对齐 SiteSaveRequest。themeParams 已随工作项 T 退役(参数值改存皮肤包级 paramValues),
* 服务端忽略该字段——保留仅为与 @Deprecated 的后端 DTO 同形,新代码不要再传 */
save(data: Partial<SiteVo> & { themeParams?: Record<string, string> }) { save(data: Partial<SiteVo> & { themeParams?: Record<string, string> }) {
return postJSON<SiteVo>(`${SITE}/save`, data); return postJSON<SiteVo>(`${SITE}/save`, data);
} }
@@ -230,8 +231,14 @@ class ThemeApi {
} }
/** W1 + 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema), /** W1 + 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema),
* 内置包显示名/描述走 .meta 覆盖层(title/description 全空=恢复默认) */ * 内置包显示名/描述走 .meta 覆盖层(title/description 全空=恢复默认)。
updateMeta(name: string, data: { title?: string; description?: string; params?: unknown[] }) { * 工作项 T:paramValues=包级参数微调值,省略=不改,{}=清空恢复 theme.json 默认值 */
updateMeta(name: string, data: {
title?: string;
description?: string;
params?: unknown[];
paramValues?: Record<string, string>;
}) {
return alovaInstance.Put<ThemeVo>(`${THEME}/${name}/meta`, data, { return alovaInstance.Put<ThemeVo>(`${THEME}/${name}/meta`, data, {
headers: { "Content-Type": "application/json;charset=UTF-8" }, headers: { "Content-Type": "application/json;charset=UTF-8" },
}); });
+86 -11
View File
@@ -1,18 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import { ThemeVo } from "@/types/cms"; import { ThemeVo } from "@/types/cms";
import ImageAssetField from "@/components/ImageAssetField.vue";
import { effectiveValue, invalidParamKeys, FormModelValue } from "@/utils/themeParamForm"; import { effectiveValue, invalidParamKeys, FormModelValue } from "@/utils/themeParamForm";
/** /**
* 主题参数微调表单(方案 §6.3):按 theme.json params schema 动态生成。 * 主题参数微调表单(方案 §6.3 + 工作项 T):按 theme.json params schema 动态生成,
* 值语义为「皮肤包级参数值」(`ThemeVo.paramValues`,落 themes/.meta/{name}.json)——
* 一包一份值,引用同包的站点共享;需要差异走「复制皮肤包」。
* 2026-09-20 布局重排(用户反馈"颜色设置竖着太长"): * 2026-09-20 布局重排(用户反馈"颜色设置竖着太长"):
* - color 参数 → 色卡网格(横排自动换行,auto-fill 150px;真实主题 12 个色参数不再拉出 12 行长蛇); * - color 参数 → 色卡网格(横排自动换行,auto-fill 150px;真实主题 12 个色参数不再拉出 12 行长蛇);
* - select/image/text → 两列行式(label + 控件同行); * - select/image/text → 两列行式(label + 控件同行);image 走 ImageAssetField(上传 + 手填双通道);
* - 非法值红框 + 行内提示;父级 v-model 绑定 FormModelValue(声明过的 key,值已兜底为默认)。 * - 非法值红框 + 行内提示;「已修改」标记 + 单项恢复默认 + 整体重置(对照 params[].defaultValue)。
*/ */
const props = defineProps<{ const props = defineProps<{
theme?: ThemeVo; theme?: ThemeVo;
modelValue: FormModelValue; modelValue: FormModelValue;
/** image 参数上传归属站点(皮肤包管理借用内容源站点;缺省则仅可手填路径) */
siteId?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -26,6 +31,7 @@ const otherParams = computed(() => params.value.filter(p => p.type !== "color"))
const badKeys = computed(() => const badKeys = computed(() =>
props.theme ? invalidParamKeys(props.theme, props.modelValue) : [] props.theme ? invalidParamKeys(props.theme, props.modelValue) : []
); );
const modifiedCount = computed(() => params.value.filter(p => isModified(p.key)).length);
function setValue(key: string, value: string) { function setValue(key: string, value: string) {
emit("update:modelValue", { ...props.modelValue, [key]: value }); emit("update:modelValue", { ...props.modelValue, [key]: value });
@@ -35,6 +41,25 @@ function displayValue(key: string): string {
return effectiveValue(props.theme?.params.find(p => p.key === key), props.modelValue); return effectiveValue(props.theme?.params.find(p => p.key === key), props.modelValue);
} }
/** 是否偏离包内声明的默认值(= 会被写进 .meta paramValues 的那些项) */
function isModified(key: string): boolean {
const p = params.value.find(x => x.key === key);
if (!p) return false;
return displayValue(key) !== (p.defaultValue ?? "");
}
function resetOne(key: string) {
const p = params.value.find(x => x.key === key);
setValue(key, p?.defaultValue ?? "");
}
/** 整体重置 = 提交全默认值模型(父级据此给后端空 Map,恢复 theme.json 默认) */
function resetAll() {
const next: FormModelValue = {};
params.value.forEach(p => (next[p.key] = p.defaultValue ?? ""));
emit("update:modelValue", next);
}
function isBad(key: string): boolean { function isBad(key: string): boolean {
return badKeys.value.includes(key); return badKeys.value.includes(key);
} }
@@ -48,7 +73,11 @@ function isBad(key: string): boolean {
<span v-if="badKeys.length" class="param-warn"> <span v-if="badKeys.length" class="param-warn">
{{ badKeys.length }} 个非法值未生效(保存时将回退皮肤默认) {{ badKeys.length }} 个非法值未生效(保存时将回退皮肤默认)
</span> </span>
<span class="param-head-tip">点击色卡可修改;保存后门户立即生效</span> <span class="param-head-tip">
{{ modifiedCount ? `已修改 ${modifiedCount} 项` : "未修改(全部为包内默认值)" }}
· 保存后引用该包的站点门户立即生效
</span>
<Button v-if="modifiedCount" size="small" ghost class="reset-all" @click="resetAll">整体恢复默认</Button>
</div> </div>
<!-- color:色卡网格(横排) --> <!-- color:色卡网格(横排) -->
@@ -57,7 +86,7 @@ function isBad(key: string): boolean {
v-for="p in colorParams" v-for="p in colorParams"
:key="p.key" :key="p.key"
class="param-chip" class="param-chip"
:class="{ bad: isBad(p.key) }" :class="{ bad: isBad(p.key), modified: isModified(p.key) }"
:title="p.key" :title="p.key"
> >
<ColorPicker <ColorPicker
@@ -65,15 +94,30 @@ function isBad(key: string): boolean {
recommend recommend
@on-change="(c: string) => setValue(p.key, c)" @on-change="(c: string) => setValue(p.key, c)"
/> />
<span class="chip-name">{{ p.label }}</span> <span class="chip-name">
{{ p.label }}
<span v-if="isModified(p.key)" class="chip-flag">已修改</span>
</span>
<span class="chip-hex">{{ displayValue(p.key) }}</span> <span class="chip-hex">{{ displayValue(p.key) }}</span>
<Button
v-if="isModified(p.key)"
size="small"
ghost
class="chip-reset"
@click="resetOne(p.key)"
>
恢复默认
</Button>
</div> </div>
</div> </div>
<!-- select / image / text:两列行式 --> <!-- select / image / text:两列行式 -->
<div v-if="otherParams.length" class="param-rows"> <div v-if="otherParams.length" class="param-rows">
<div v-for="p in otherParams" :key="p.key" class="param-row" :class="{ bad: isBad(p.key) }"> <div v-for="p in otherParams" :key="p.key" class="param-row" :class="{ bad: isBad(p.key) }">
<label class="row-label">{{ p.label }}</label> <label class="row-label">
{{ p.label }}
<span v-if="isModified(p.key)" class="chip-flag">已修改</span>
</label>
<div class="row-control"> <div class="row-control">
<Select <Select
v-if="p.type === 'select'" v-if="p.type === 'select'"
@@ -84,12 +128,12 @@ function isBad(key: string): boolean {
> >
<Option v-for="o in p.options" :key="o.value" :value="o.value">{{ o.label }}</Option> <Option v-for="o in p.options" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select> </Select>
<Input <ImageAssetField
v-else-if="p.type === 'image'" v-else-if="p.type === 'image'"
:model-value="displayValue(p.key)" :model-value="displayValue(p.key)"
size="small" :site-id="siteId"
placeholder="/cms/upload/cms/... (仅接受 /cms/upload/** 路径)" placeholder="/cms/upload/cms/... (上传或粘贴路径,仅接受 /cms/upload/** 与 /yschcms/upload/**)"
@on-change="(v: string) => setValue(p.key, v)" @update:model-value="(v: string) => setValue(p.key, v)"
/> />
<Input <Input
v-else v-else
@@ -98,6 +142,7 @@ function isBad(key: string): boolean {
:maxlength="256" :maxlength="256"
@on-change="(v: string) => setValue(p.key, v)" @on-change="(v: string) => setValue(p.key, v)"
/> />
<Button v-if="isModified(p.key)" size="small" ghost @click="resetOne(p.key)">恢复默认</Button>
<span v-if="isBad(p.key)" class="bad-mark">非法值({{ p.type }} 校验失败)</span> <span v-if="isBad(p.key)" class="bad-mark">非法值({{ p.type }} 校验失败)</span>
</div> </div>
</div> </div>
@@ -133,6 +178,9 @@ function isBad(key: string): boolean {
color: #e54d42; color: #e54d42;
font-size: 12px; font-size: 12px;
} }
.reset-all {
margin-left: auto;
}
} }
/* color 色卡网格:横排自动换行(150px 起步,宽屏一行 4~5 张) */ /* color 色卡网格:横排自动换行(150px 起步,宽屏一行 4~5 张) */
@@ -157,6 +205,10 @@ function isBad(key: string): boolean {
&.bad { &.bad {
border-color: #e54d42; border-color: #e54d42;
} }
&.modified {
border-color: #ffd591;
background: #fffdf7;
}
.chip-name { .chip-name {
font-size: 12px; font-size: 12px;
color: #1e2635; color: #1e2635;
@@ -169,6 +221,13 @@ function isBad(key: string): boolean {
font-family: Consolas, Menlo, monospace; font-family: Consolas, Menlo, monospace;
color: #8a93a6; color: #8a93a6;
} }
.chip-reset {
align-self: flex-start;
height: 20px;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
}
/* ColorPicker 触发器整条拉宽成色条 */ /* ColorPicker 触发器整条拉宽成色条 */
:deep(.ivu-color-picker) { :deep(.ivu-color-picker) {
width: 100%; width: 100%;
@@ -185,6 +244,18 @@ function isBad(key: string): boolean {
} }
} }
/* 「已修改」小徽标:色卡与行式 label 共用 */
.chip-flag {
display: inline-block;
margin-left: 4px;
padding: 0 5px;
border-radius: 7px;
background: #fff7e6;
color: #d46b08;
font-size: 10px;
line-height: 14px;
}
/* select/image/text:两列行式 */ /* select/image/text:两列行式 */
.param-rows { .param-rows {
display: grid; display: grid;
@@ -217,6 +288,10 @@ function isBad(key: string): boolean {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
:deep(.image-asset-field) {
flex: 1;
min-width: 0;
}
} }
} }
.bad-mark { .bad-mark {
+42
View File
@@ -227,4 +227,46 @@ export interface LinkVo {
status: number; status: number;
} }
/* ===== 皮肤包(ThemeVo,S1 交接契约:参数值为包级)===== */
/** select 型参数的候选项 */
export interface ThemeParamOption {
value: string;
label: string;
}
/** 参数 schema 项(后端 ThemeVo.ThemeParamVo):schema 只来自包内 theme.json */
export interface ThemeParamVo {
key: string;
/** color 型才有,CSS 变量名(如 --primary) */
cssVar?: string;
label: string;
/** color | select | image | text */
type: string;
/** 包内默认值:前端「已修改」标记与恢复默认的基准 */
defaultValue?: string;
options?: ThemeParamOption[];
}
/** 皮肤包(GET/PUT /api/theme/**) */
export interface ThemeVo {
/** 标识名 = 主题路径段,创建后不可改 */
name: string;
title: string;
description?: string;
version?: string;
author?: string;
bannerSize?: string;
/** 预览图(管理端资产代理 /api/theme/{name}/asset/screenshot.png) */
screenshotUrl?: string;
params: ThemeParamVo[];
/** 包级参数值(工作项 T):只含被微调过的 key,存 themes/.meta/{name}.json;缺项按 defaultValue 回落 */
paramValues?: Record<string, string>;
/** builtin=内置(模板/资产只读)/ external=外部可管 */
source?: string;
inUseCount?: number;
editable?: boolean;
usedBySiteNames?: string[];
}
/* ===== 事项库(svc,事项库详细设计 §7.4)===== */ /* ===== 事项库(svc,事项库详细设计 §7.4)===== */
+61 -59
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import * as themeParamForm from "./themeParamForm";
import { import {
COLOR_RE, COLOR_RE,
formModelFromTheme, effectiveValue,
invalidParamKeys, invalidParamKeys,
isValidParamValue, isValidParamValue,
mergeThemeParamsIntoConfigJson, paramValuesOf,
themeParamsFromConfigJson,
} from "./themeParamForm"; } from "./themeParamForm";
import { ThemeVo } from "@/types/cms"; import { ThemeVo } from "@/types/cms";
@@ -30,26 +30,69 @@ function theme(): ThemeVo {
}; };
} }
describe("themeParamForm(方案 §6.3/§8.4)", () => { describe("themeParamForm(方案 §6.3/§8.4 + 工作项 T 包级参数)", () => {
it("T28: schema→表单模型:四类型各映射为对应生效值 + 默认值兜底", () => { it("T-T-08: paramValuesOf 以包 schema + paramValues 组装,缺项回落 defaultValue", () => {
const model = formModelFromTheme(theme()); const t = theme();
expect(model.primary).toBe("#b01a2e"); t.paramValues = { primary: "#123456" };
expect(model.bg).toBe("#f7f5f2"); const model = paramValuesOf(t);
expect(model.primary).toBe("#123456"); // 包级微调值
expect(model.bg).toBe("#f7f5f2"); // 未微调 → theme.json 默认值
expect(model.headerStyle).toBe("banner"); expect(model.headerStyle).toBe("banner");
expect(model.logoImage).toBe(""); expect(model.logoImage).toBe("");
}); });
it("T28: 已有合法值保留,非法显式值回退默认", () => { it("T-T-08: 覆盖层里的非法值回退默认、未声明 key 不入模型(与后端 filterValidValues 同语义)", () => {
const model = formModelFromTheme(theme(), { const t = theme();
primary: "#123456", // 合法,保留 t.paramValues = { primary: "red;}", bg: "#000000", zzhack: "zzvalue" };
bg: "red;}", // 非法,回退默认 const model = paramValuesOf(t);
headerStyle: "plain", // select 枚举内合法 expect(model.primary).toBe("#b01a2e"); // 非法显式值 → 默认
logoImage: "javascript:alert(1)", // 非法 image expect(model.bg).toBe("#000000");
expect(model.zzhack).toBeUndefined();
});
it("T-T-08: 无 paramValues(新包/未微调)全量回落默认", () => {
expect(paramValuesOf(theme())).toEqual({
primary: "#b01a2e",
bg: "#f7f5f2",
headerStyle: "banner",
logoImage: "",
}); });
expect(model.primary).toBe("#123456"); });
expect(model.bg).toBe("#f7f5f2");
expect(model.headerStyle).toBe("plain"); it("R5-1 根治:paramValuesOf 只有「一个包」一个入参(无跨包 existing 通道),换包即取新包自身值", () => {
expect(model.logoImage).toBe(""); expect(paramValuesOf.length).toBe(1);
const nextPack = theme();
nextPack.name = "another-pack";
nextPack.params = nextPack.params.map(p => ({ ...p, defaultValue: p.type === "color" ? "#0a5c36" : p.defaultValue }));
// 上一个包微调成 #123456 后切到未微调的新包:只能是新包默认值,旧包值无处带入
expect(paramValuesOf(nextPack).primary).toBe("#0a5c36");
expect(paramValuesOf(nextPack).primary).not.toBe("#123456");
});
it("T-T-07: 站点级装拆随参数迁包退役(formModelFromTheme / merge…IntoConfigJson / …FromConfigJson 不再导出)", () => {
const mod = themeParamForm as unknown as Record<string, unknown>;
expect(mod.formModelFromTheme).toBeUndefined();
expect(mod.mergeThemeParamsIntoConfigJson).toBeUndefined();
expect(mod.themeParamsFromConfigJson).toBeUndefined();
// 模块出口收敛为「纯 schema + 值校验」:不再感知 configJson
expect(Object.keys(mod).sort()).toEqual(
[
"COLOR_RE",
"KEY_RE",
"effectiveValue",
"hasParamType",
"invalidParamKeys",
"isValidParamValue",
"paramValuesOf",
].sort()
);
});
it("T28: 生效值兜底 —— 合法显式值优先,非法/缺失回退默认", () => {
const primary = theme().params.find(p => p.key === "primary")!;
expect(effectiveValue(primary, { primary: "#123456" })).toBe("#123456");
expect(effectiveValue(primary, { primary: "red;}" })).toBe("#b01a2e");
expect(effectiveValue(primary, {})).toBe("#b01a2e");
}); });
it("T29: 值校验 —— color 正则通过/拒绝", () => { it("T29: 值校验 —— color 正则通过/拒绝", () => {
@@ -88,45 +131,4 @@ describe("themeParamForm(方案 §6.3/§8.4)", () => {
expect(bad).toContain("headerStyle"); expect(bad).toContain("headerStyle");
expect(bad).not.toContain("primary"); expect(bad).not.toContain("primary");
}); });
it("T30: themeParams↔configJson 装拆 —— 合并保留其余键 + 丢弃未声明键", () => {
const t = theme();
const merged = mergeThemeParamsIntoConfigJson(
'{"logo":"/upload/cms/logo.png","contact":"0571-88888888"}',
t,
{ primary: "#ff0000", headerStyle: "plain", zzhack: "zzvalue" },
);
const parsed = JSON.parse(merged);
expect(parsed.logo).toBe("/upload/cms/logo.png");
expect(parsed.contact).toBe("0571-88888888");
expect(parsed.theme_params.primary).toBe("#ff0000");
expect(parsed.theme_params.headerStyle).toBe("plain");
expect(parsed.theme_params).not.toHaveProperty("zzhack"); // 未声明 key 丢弃(后端同语义)
});
it("T30: 空/非法 configJson 兼容;无有效参数不写入 theme_params 键", () => {
const t = theme();
const merged = mergeThemeParamsIntoConfigJson("{bad json", t, {});
expect(JSON.parse(merged)).toEqual({});
const merged2 = mergeThemeParamsIntoConfigJson(
'{"theme_params":{"primary":"#b01a2e"},"logo":"x"}',
t,
{ headerStyle: "banner" },
);
const parsed2 = JSON.parse(merged2);
expect(parsed2.logo).toBe("x");
expect(parsed2.theme_params).toEqual({ headerStyle: "banner" }); // 旧的非法/未更新值被替换
});
it("T30: configJson → themeParams 编辑回显(仅声明 key + 非法值丢弃)", () => {
const t = theme();
const model = themeParamsFromConfigJson(
'{"theme_params":{"primary":"#b01a2e","zzhack":"x","headerStyle":"plain"}}',
t,
);
expect(model.primary).toBe("#b01a2e");
expect(model.headerStyle).toBe("plain");
expect(model.zzhack).toBeUndefined();
});
}); });
+16 -62
View File
@@ -1,10 +1,11 @@
import { ThemeParamVo, ThemeVo } from "@/types/cms"; import { ThemeParamVo, ThemeVo } from "@/types/cms";
/** /**
* themeParams 纯函数模块(方案 §6.3,评审 R10/R6):schema → 表单模型映射、 * 主题参数纯函数模块(方案 §6.3 + 工作项 T):包级 `paramValues` → 表单模型、值校验
* 值校验(color 正则 / select 枚举 / image 仅 /upload/**)、themeParams ↔ configJson 装拆。 * (color 正则 / select 枚举 / image 仅 /cms|/yschcms + /upload/** / text 长度)。
* 与后端合并语义(方案 §5.3)保持一致:仅替换 theme_params 键、保留其余键、丢弃未声明键。 * 工作项 T 起参数值归属皮肤包(`themes/.meta/{name}.json`),站点级 `config_json.theme_params`
* vitest 单测对象(§8.4 T28-T30),不依赖组件挂载基建。 * 装拆函数(formModelFromTheme / mergeThemeParamsIntoConfigJson / themeParamsFromConfigJson)随之后端退役一并删除。
* vitest 单测对象(§8.4 T28-T30、T-T-07/08),不依赖组件挂载基建。
*/ */
/** color 合法值:#RGB/#RRGGBB/#RRGGBBAA(与后端 ThemeParam.COLOR_PATTERN 一致) */ /** color 合法值:#RGB/#RRGGBB/#RRGGBBAA(与后端 ThemeParam.COLOR_PATTERN 一致) */
@@ -36,18 +37,24 @@ export function isValidParamValue(param: ThemeParamVo, value: string | undefined
} }
} }
/** 取参数生效值:显式值合法用之;否则回退 defaultValue(缺省也视为合法默认) */ /** T28: 生效值兜底 —— 合法显式值优先,否则回退 defaultValue(缺省也视为合法默认) */
export function effectiveValue(param: ThemeParamVo, model: FormModelValue): string { export function effectiveValue(param: ThemeParamVo, model: FormModelValue): string {
const raw = model[param.key]; const raw = model[param.key];
if (isValidParamValue(param, raw)) return raw as string; if (isValidParamValue(param, raw)) return raw as string;
return param.defaultValue ?? ""; return param.defaultValue ?? "";
} }
/** T28: schema → 表单模型(声明过的 key 全部带上生效值) */ /**
export function formModelFromTheme(theme: ThemeVo, existing: FormModelValue = {}): FormModelValue { * T-T-08: 包级参数值 → 表单模型(工作项 T,方案 v1 §三)。
* 值来源只有一个:该包自己的 `paramValues`(`.meta` 覆盖层);未微调的 key 回落 `params[].defaultValue`,
* 非法值与未声明 key 丢弃(与后端 `ThemeInfo.filterValidValues` 同语义)。
* 入参只有「一个包」——不存在传入上一个包值的通道,R5-1(换肤颜色继承)在函数签名层面即不成立。
*/
export function paramValuesOf(theme: ThemeVo): FormModelValue {
const stored = theme.paramValues || {};
const model: FormModelValue = {}; const model: FormModelValue = {};
theme.params.forEach(p => { (theme.params || []).forEach(p => {
const raw = existing[p.key]; const raw = stored[p.key];
model[p.key] = isValidParamValue(p, raw) ? (raw as string) : (p.defaultValue ?? ""); model[p.key] = isValidParamValue(p, raw) ? (raw as string) : (p.defaultValue ?? "");
}); });
return model; return model;
@@ -65,59 +72,6 @@ export function invalidParamKeys(theme: ThemeVo, model: FormModelValue): string[
return result; return result;
} }
/** T30a: 服务端合并语义(与后端 §5.3 一致)—— 仅替换 theme_params、保留其余键、丢弃未声明/非法值 */
export function mergeThemeParamsIntoConfigJson(configJson: string | undefined | null,
theme: ThemeVo,
model: FormModelValue): string {
let config: Record<string, unknown> = {};
if (configJson && configJson.trim()) {
try {
const parsed = JSON.parse(configJson);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
config = { ...parsed };
}
} catch {
// 非合法 JSON 按空配置处理(与后端 ExtJsonUtils.parse 语义一致)
}
}
const filtered: Record<string, string> = {};
theme.params.forEach(p => {
const v = model[p.key];
if (isValidParamValue(p, v)) {
filtered[p.key] = v as string;
}
});
if (Object.keys(filtered).length === 0) {
delete config.theme_params;
} else {
config.theme_params = filtered;
}
return JSON.stringify(config);
}
/** T30b: configJson → themeParams(编辑回显:仅取声明过的 key,非法值丢弃) */
export function themeParamsFromConfigJson(configJson: string | undefined | null,
theme: ThemeVo): FormModelValue {
const model: FormModelValue = {};
if (!configJson) return model;
try {
const parsed = JSON.parse(configJson);
const tp = parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>).theme_params
: undefined;
if (tp && typeof tp === "object") {
theme.params.forEach(p => {
const raw = (tp as Record<string, unknown>)[p.key];
const v = typeof raw === "string" ? raw : "";
if (isValidParamValue(p, v)) model[p.key] = v;
});
}
} catch {
// 非合法 JSON 按无参数处理
}
return model;
}
/** 主题是否声明了某类型参数(组件渲染分支用) */ /** 主题是否声明了某类型参数(组件渲染分支用) */
export function hasParamType(theme: ThemeVo | undefined | null, type: string): boolean { export function hasParamType(theme: ThemeVo | undefined | null, type: string): boolean {
return !!theme?.params?.some(p => p.type === type); return !!theme?.params?.some(p => p.type === type);
+7 -18
View File
@@ -7,10 +7,8 @@ import config from "@/assets/config/host.config";
import { portalUrlOf, adminPathOf, previewPortalUrl } from "@/utils/sitePath"; import { portalUrlOf, adminPathOf, previewPortalUrl } from "@/utils/sitePath";
import { useCmsStore } from "@/store"; import { useCmsStore } from "@/store";
import { SiteVo, ThemeVo } from "@/types/cms"; import { SiteVo, ThemeVo } from "@/types/cms";
import { FormModelValue, formModelFromTheme, mergeThemeParamsIntoConfigJson, themeParamsFromConfigJson } from "@/utils/themeParamForm";
import { resolveEditableTheme } from "@/utils/themeEdit"; import { resolveEditableTheme } from "@/utils/themeEdit";
import ThemePicker from "@/components/theme/ThemePicker.vue"; import ThemePicker from "@/components/theme/ThemePicker.vue";
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue"; import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue"; import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue"; import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
@@ -46,7 +44,7 @@ const themes = ref<ThemeVo[]>([]);
/** 分区清单(锚点导航;locked = 创建模式锁定) */ /** 分区清单(锚点导航;locked = 创建模式锁定) */
const sections = [ const sections = [
{ key: "basic", title: "基本信息", desc: "编码 / 名称 / 归属 / 状态" }, { key: "basic", title: "基本信息", desc: "编码 / 名称 / 归属 / 状态" },
{ key: "theme", title: "皮肤与外观", desc: "风格选择与微调" }, { key: "theme", title: "皮肤与外观", desc: "选皮肤 / 自定义 CSS" },
{ key: "layout", title: "首页布局", desc: "区块序列编排", locked: true }, { key: "layout", title: "首页布局", desc: "区块序列编排", locked: true },
{ key: "display", title: "基础展示", desc: "品牌标识与图片" }, { key: "display", title: "基础展示", desc: "品牌标识与图片" },
{ key: "footer", title: "页脚信息", desc: "联系方式与社媒" }, { key: "footer", title: "页脚信息", desc: "联系方式与社媒" },
@@ -71,10 +69,9 @@ const basic = reactive({
status: 1, status: 1,
}); });
/** ② 皮肤与外观分区 */ /** ② 皮肤与外观分区(工作项 T:参数微调已迁皮肤包管理,站点只负责选哪套包) */
const theme = ref("default"); const theme = ref("default");
const themeUnregistered = ref(false); const themeUnregistered = ref(false);
const themeParams = ref<FormModelValue>({});
const customCss = ref(""); const customCss = ref("");
const previewVisible = ref(false); const previewVisible = ref(false);
const editTheme = computed(() => themes.value.find(t => t.name === theme.value)); const editTheme = computed(() => themes.value.find(t => t.name === theme.value));
@@ -187,8 +184,6 @@ function initFromRow(id: number) {
theme.value = resolved.theme; theme.value = resolved.theme;
configJson.value = r.configJson || ""; configJson.value = r.configJson || "";
customCss.value = readCustomCss(configJson.value); customCss.value = readCustomCss(configJson.value);
const t = editTheme.value;
themeParams.value = t ? themeParamsFromConfigJson(configJson.value, t) : {};
} }
function readCustomCss(configJson: string): string { function readCustomCss(configJson: string): string {
@@ -218,8 +213,6 @@ function pickTheme(name: string) {
if (theme.value === name) return; if (theme.value === name) return;
theme.value = name; theme.value = name;
themeUnregistered.value = false; themeUnregistered.value = false;
const t = editTheme.value;
themeParams.value = t ? formModelFromTheme(t, themeParams.value) : {};
} }
function openPreview() { function openPreview() {
@@ -228,7 +221,7 @@ function openPreview() {
/** /**
* 统一提交(§3.5 分区独立保存模型):payload = 页面当前事实源 + 基本信息快照; * 统一提交(§3.5 分区独立保存模型):payload = 页面当前事实源 + 基本信息快照;
* patch.configJson 供分区保存前合并自己的键空间(如皮肤分区的 theme_params/custom_css)。 * patch.configJson 供分区保存前合并自己的键空间(如皮肤分区的 custom_css)。
* 创建成功原地 replace 到 site/setting/{id} 并解锁全部分区。 * 创建成功原地 replace 到 site/setting/{id} 并解锁全部分区。
*/ */
async function submitSection(key: string, patch: { configJson?: string }) { async function submitSection(key: string, patch: { configJson?: string }) {
@@ -259,7 +252,7 @@ async function submitSection(key: string, patch: { configJson?: string }) {
Message.success("保存成功!"); Message.success("保存成功!");
if (res) { if (res) {
row.value = res; row.value = res;
// 服务端归一后的 configJson 刷新事实源(theme_params 合并等) // 服务端归一后的 configJson 刷新事实源
if (typeof res.configJson === "string") { if (typeof res.configJson === "string") {
configJson.value = res.configJson; configJson.value = res.configJson;
customCss.value = readCustomCss(configJson.value); customCss.value = readCustomCss(configJson.value);
@@ -281,10 +274,7 @@ async function saveBasic() {
} }
async function saveTheme() { async function saveTheme() {
const t = editTheme.value; await submitSection("theme", { configJson: withCustomCss(configJson.value, customCss.value) });
const baseConfig = withCustomCss(configJson.value, customCss.value);
const merged = t ? mergeThemeParamsIntoConfigJson(baseConfig, t, themeParams.value) : baseConfig;
await submitSection("theme", { configJson: merged });
} }
async function saveDisplay() { async function saveDisplay() {
@@ -466,7 +456,7 @@ watch(() => store.currentSchool?.schId, () => loadSites());
<div id="sec-theme" class="card setting-card" data-section="theme"> <div id="sec-theme" class="card setting-card" data-section="theme">
<div class="card-header"> <div class="card-header">
<span class="card-title">皮肤与外观</span> <span class="card-title">皮肤与外观</span>
<span class="card-subtitle">风格选择 / 参数微调 / 站点级 CSS 覆盖</span> <span class="card-subtitle">风格选择 / 站点级 CSS 覆盖(微调请去「皮肤包管理」)</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="field-block"> <div class="field-block">
@@ -479,7 +469,6 @@ watch(() => store.currentSchool?.schId, () => loadSites());
</Alert> </Alert>
<ThemePicker :themes="themes" :model-value="theme" @update:model-value="pickTheme" /> <ThemePicker :themes="themes" :model-value="theme" @update:model-value="pickTheme" />
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openPreview">预览本皮肤</Button> <Button size="small" class="preview-btn" :disabled="!editTheme" @click="openPreview">预览本皮肤</Button>
<ThemeParamForm :theme="editTheme" v-model="themeParams" />
</div> </div>
<!-- B3 自定义样式(config_json.custom_css,服务端黑名单强校验) --> <!-- B3 自定义样式(config_json.custom_css,服务端黑名单强校验) -->
<Collapse class="css-collapse"> <Collapse class="css-collapse">
@@ -594,7 +583,7 @@ watch(() => store.currentSchool?.schId, () => loadSites());
v-if="previewVisible && editTheme && row" v-if="previewVisible && editTheme && row"
:site-path="row.path || row.code" :site-path="row.path || row.code"
:theme="theme" :theme="theme"
:theme-params="themeParams" :theme-params="editTheme?.paramValues || {}"
/> />
</Modal> </Modal>
</div> </div>
+87 -20
View File
@@ -5,14 +5,18 @@ import { Message } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import WorkbenchHeader from "@/components/WorkbenchHeader.vue"; import WorkbenchHeader from "@/components/WorkbenchHeader.vue";
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue"; import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
import { ThemeParamVo, ThemeVo, SiteVo } from "@/types/cms"; import { ThemeParamVo, ThemeVo, SiteVo } from "@/types/cms";
import { themeApi, siteApi, assetUrl } from "@/api/module/cms"; import { themeApi, siteApi, assetUrl } from "@/api/module/cms";
import { isValidParamValue, paramValuesOf, FormModelValue } from "@/utils/themeParamForm";
import { useCmsStore } from "@/store"; import { useCmsStore } from "@/store";
/** /**
* 皮肤包管理(W1 + 任务二/四,2026-09-08):平台级全屏页(/theme-manage,超管专属, * 皮肤包管理(W1 + 任务二/四,2026-09-08):平台级全屏页(/theme-manage,超管专属,
* 工作台按钮进入、不再挂左侧菜单)。内置包内容只读但显示名/描述可改(.meta 覆盖层)、 * 工作台按钮进入、不再挂左侧菜单)。内置包内容只读但显示名/描述可改(.meta 覆盖层)、
* 外部包可管(上传/派生/编辑/删除)。标识名(name,即主题路径/URL 段)仅创建时可定,创建后不可改。 * 外部包可管(上传/派生/编辑/删除)。标识名(name,即主题路径/URL 段)仅创建时可定,创建后不可改。
* 工作项 T:参数值改挂皮肤包(themes/.meta/{name}.json → paramValues),编辑弹窗内嵌参数微调,
* 内置包与外部包同权(值只落覆盖层,不碰包内 theme.json);卡片显示「已微调 M 项」。
*/ */
const router = useRouter(); const router = useRouter();
@@ -41,6 +45,9 @@ const metaTheme = ref<ThemeVo | null>(null);
const metaTitle = ref(""); const metaTitle = ref("");
const metaDescription = ref(""); const metaDescription = ref("");
const metaParams = ref<ThemeParamVo[]>([]); const metaParams = ref<ThemeParamVo[]>([]);
/** 包级参数值模型(paramValuesOf 回显);paramTouched 决定保存时是否携带 paramValues */
const metaParamModel = ref<FormModelValue>({});
const paramTouched = ref(false);
// 非超管深链兜底(引导期守卫放行,user 装载后弹回工作台) // 非超管深链兜底(引导期守卫放行,user 装载后弹回工作台)
watch( watch(
@@ -64,9 +71,8 @@ async function run() {
onMounted(run); onMounted(run);
/** 预览内容源站点(W1:与向导同语义,取启用站点按门户 path 预览) */ /** 预览内容源站点(W1:与向导同语义,取启用站点按门户 path 预览);亦借给参数微调的 image 上传归属 */
async function openPreview(theme: ThemeVo) { async function ensureContentSources(): Promise<SiteVo[]> {
previewTheme.value = theme;
if (!contentSources.value.length) { if (!contentSources.value.length) {
try { try {
contentSources.value = ((await siteApi.list().send()) || []).filter(s => s.status === 1); contentSources.value = ((await siteApi.list().send()) || []).filter(s => s.status === 1);
@@ -74,7 +80,16 @@ async function openPreview(theme: ThemeVo) {
contentSources.value = []; 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 : ""; previewSitePath.value = first ? first.path || first.code : "";
previewVisible.value = true; previewVisible.value = true;
} }
@@ -145,36 +160,55 @@ async function doAdd() {
} }
} }
/* ===== 编辑元数据(任务四:内置包亦可改显示名/描述,走覆盖层) ===== */ /* ===== 编辑元数据(任务四:内置包亦可改显示名/描述,走覆盖层;工作项 T:包级参数值同区编辑) ===== */
function openMeta(theme: ThemeVo) { async function openMeta(theme: ThemeVo) {
metaTheme.value = theme; metaTheme.value = theme;
metaTitle.value = theme.title; metaTitle.value = theme.title;
metaDescription.value = theme.description || ""; metaDescription.value = theme.description || "";
metaParams.value = (theme.params || []).map(p => ({ ...p })); metaParams.value = (theme.params || []).map(p => ({ ...p }));
metaParamModel.value = paramValuesOf(theme);
paramTouched.value = false;
metaVisible.value = true; 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() { async function doSaveMeta() {
const builtin = metaTheme.value?.source === "builtin"; const builtin = metaTheme.value?.source === "builtin";
await themeApi const payload = {
.updateMeta(metaTheme.value!.name, { title: metaTitle.value,
title: metaTitle.value, description: metaDescription.value,
description: metaDescription.value, // 内置包参数 schema 不可改(后端强校验),不传即不改动
// 内置包参数 schema 不可改(后端强校验),不传即不改动 params: builtin ? undefined : metaParams.value,
params: builtin ? undefined : metaParams.value, } as { title: string; description: string; params?: unknown[]; paramValues?: Record<string, string> };
}) // 未触碰参数区则不带 paramValues 键(后端 null=不改),避免改名顺带清空微调
.send(); if (paramTouched.value) payload.paramValues = currentParamValues();
await themeApi.updateMeta(metaTheme.value!.name, payload).send();
Message.success("已保存,引用该皮肤的站点门户与预览立即生效"); Message.success("已保存,引用该皮肤的站点门户与预览立即生效");
metaVisible.value = false; metaVisible.value = false;
run(); run();
} }
/** 内置包恢复默认显示名/描述(清空 .meta 覆盖层) */ /** 内置包恢复默认(清空 .meta 覆盖层:改名 + 描述 + 包级参数微调一并回到包内声明) */
async function doResetBuiltinMeta() { async function doResetBuiltinMeta() {
MyConfirm({ MyConfirm({
title: "恢复默认名称", title: "恢复默认名称",
msg: `确定恢复「${metaTheme.value?.name}」的默认显示名称与描述吗?`, msg: `确定恢复「${metaTheme.value?.name}」的默认显示名称与描述吗?`,
info: "将清除改名覆盖层,恢复包内声明的原始名称", info: "将清除改名覆盖层,恢复包内声明的原始名称;包级参数微调一并清空",
async onOk() { async onOk() {
await themeApi.updateMeta(metaTheme.value!.name, { title: "", description: "" }).send(); await themeApi.updateMeta(metaTheme.value!.name, { title: "", description: "" }).send();
Message.success("已恢复默认名称"); Message.success("已恢复默认名称");
@@ -206,6 +240,20 @@ function askRemove(theme: ThemeVo) {
} }
const builtinCount = computed(() => themes.value.filter(t => t.source === "builtin").length); 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> </script>
<template> <template>
@@ -234,13 +282,13 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
<div class="meta"> <div class="meta">
<p class="title">{{ t.title }}<span class="name">({{ t.name }})</span></p> <p class="title">{{ t.title }}<span class="name">({{ t.name }})</span></p>
<p class="desc">{{ t.description || "—" }}</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>
<div class="actions"> <div class="actions">
<Button size="small" @click="openPreview(t)">预览</Button> <Button size="small" @click="openPreview(t)">预览</Button>
<Tooltip <Tooltip
:content="t.source === 'builtin' :content="t.source === 'builtin'
? '内置包内容只读,可改显示名称与描述' ? '内置包内容只读,可改显示名称与描述、微调包级参数值'
: (t.inUseCount || 0) > 0 : (t.inUseCount || 0) > 0
? `被站点引用:${(t.usedBySiteNames || []).join('、')}` ? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
: '外部包可编辑参数 schema'" : '外部包可编辑参数 schema'"
@@ -334,14 +382,20 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
</Option> </Option>
</Select> </Select>
</div> </div>
<ThemePreviewFrame fill :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" /> <ThemePreviewFrame
fill
:site-path="previewSitePath"
:theme="previewTheme?.name"
:theme-params="previewTheme?.paramValues || {}"
/>
</div> </div>
</Modal> </Modal>
<!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema) --> <!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema。工作项 T:包级参数值两态同权编辑) -->
<Modal v-model="metaVisible" :title="`编辑皮肤:${metaTheme?.name || ''}`" :mask-closable="false" width="560px"> <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"> <Alert v-if="metaTheme?.source === 'builtin'" type="info" show-icon style="margin-bottom: 12px">
内置包内容(模板/资产)不可修改,仅显示名称与描述生效;标识名({{ metaTheme?.name }})不可改。 内置包内容(模板/资产)不可修改,仅显示名称与描述生效;标识名({{ metaTheme?.name }})不可改。
下方的参数微调只写包级覆盖层,不改包内 theme.json。
</Alert> </Alert>
<Form :label-width="110"> <Form :label-width="110">
<FormItem label="显示名称"> <FormItem label="显示名称">
@@ -357,6 +411,14 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
</FormItem> </FormItem>
</template> </template>
</Form> </Form>
<div class="meta-params">
<ThemeParamForm
:theme="metaTheme || undefined"
:model-value="metaParamModel"
:site-id="metaSiteId"
@update:model-value="onParamModelChange"
/>
</div>
<template #footer> <template #footer>
<Button v-if="metaTheme?.source === 'builtin'" style="float: left" @click="doResetBuiltinMeta"> <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; gap: 8px;
} }
} }
.meta-params {
margin-top: 4px;
padding-top: 12px;
border-top: 1px dashed #e9edf5;
}
.form-tip { .form-tip {
color: #999; color: #999;
font-size: 12px; font-size: 12px;
+75 -1
View File
@@ -7,6 +7,8 @@ import {
BlockType, BlockType,
HOME_LAYOUT_VERSION, HOME_LAYOUT_VERSION,
LayoutBlock, LayoutBlock,
LINK_NAME_MAX,
QUICK_LINK_MAX,
flattenListCategories, flattenListCategories,
genBlockId, genBlockId,
genFromCategories, genFromCategories,
@@ -176,6 +178,24 @@ function onFieldChange() {
emitUp(); 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=通过,否则错误消息串 */ /** 保存前校验(父分区调用):返回 null=通过,否则错误消息串 */
function validate(): string | null { function validate(): string | null {
const errors = validateHomeLayout({ version: HOME_LAYOUT_VERSION, blocks: blocks.value }); const errors = validateHomeLayout({ version: HOME_LAYOUT_VERSION, blocks: blocks.value });
@@ -239,7 +259,7 @@ defineExpose({ validate });
<Input <Input
v-if="f.kind === 'text'" v-if="f.kind === 'text'"
v-model="selected[f.key]" v-model="selected[f.key]"
:maxlength="100" :maxlength="f.maxlength || 100"
:placeholder="f.placeholder" :placeholder="f.placeholder"
@on-change="onFieldChange" @on-change="onFieldChange"
/> />
@@ -277,6 +297,35 @@ defineExpose({ validate });
<p v-if="!flat.length" class="hle-cats-empty">暂无列表型栏目</p> <p v-if="!flat.length" class="hle-cats-empty">暂无列表型栏目</p>
</template> </template>
</div> </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> <p v-if="f.tip" class="hle-field-tip">{{ f.tip }}</p>
</div> </div>
</template> </template>
@@ -459,6 +508,31 @@ defineExpose({ validate });
margin: 8px 0; 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 { .hle-type-card {
display: flex; display: flex;
+49 -3
View File
@@ -4,16 +4,17 @@
* 编辑器属性面板/添加抽屉按本表 schema 驱动自动出现。 * 编辑器属性面板/添加抽屉按本表 schema 驱动自动出现。
* propFields 顺序即属性面板渲染顺序;showIf 控制条件显示(如 background 仅 news_grid)。 * propFields 顺序即属性面板渲染顺序;showIf 控制条件显示(如 background 仅 news_grid)。
*/ */
import { LayoutBlock } from "./homeLayout"; import { LayoutBlock, LINK_NAME_MAX, LIST_STYLES, NOTICE_TEXT_MAX, QUICK_LINK_MAX, styleLabel } from "./homeLayout";
import { LIST_STYLES, styleLabel } from "./homeLayout";
export interface BlockPropField { export interface BlockPropField {
key: string; key: string;
label: string; label: string;
kind: "text" | "number" | "select" | "switch" | "categories"; kind: "text" | "number" | "select" | "switch" | "categories" | "links";
options?: { value: string; label: string }[]; options?: { value: string; label: string }[];
min?: number; min?: number;
max?: number; max?: number;
/** text 输入框字数上限(缺省 100) */
maxlength?: number;
placeholder?: string; placeholder?: string;
tip?: string; tip?: string;
/** 条件显示(返回 false 时隐藏且不校验) */ /** 条件显示(返回 false 时隐藏且不校验) */
@@ -99,6 +100,42 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
{ key: "group", label: "链接分组", kind: "text", placeholder: "留空显示全部分组" }, { 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 { export function blockDefOf(type: string): BlockTypeDef | undefined {
@@ -120,6 +157,15 @@ export function blockSummary(block: LayoutBlock, namesOf: (id: number) => string
parts.push("线上展厅图集"); parts.push("线上展厅图集");
} else if (block.type === "links") { } else if (block.type === "links") {
parts.push(block.group ? `分组:${block.group}` : "全部分组"); 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 || []; const ids = block.categoryIds || [];
if (ids.length) { 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:一键生成与现状渲染等价", () => { describe("genFromCategories:一键生成与现状渲染等价", () => {
const tree: CategoryTreeNode[] = [ const tree: CategoryTreeNode[] = [
cat(1, "校园时讯", 1, "news_grid", [cat(11, "院系动态", 1), cat(12, "单页栏目", 2), cat(13, "外链", 3)]), 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)。 * 首页布局纯函数模块(docs/16 主线B §4.1/§4.5)。
* 布局存 config_json.home_layout(零 DDL):{version:1, blocks:[…]};无该键 = 回落现状渲染(老站零迁移)。 * 布局存 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 强校验,本模块做编辑端同语义校验与往返装拆。 * 服务端 SiteService.validateHomeLayout 强校验,本模块做编辑端同语义校验与往返装拆。
* vitest 单测对象:homeLayout.spec.ts,不依赖组件挂载基建。 * vitest 单测对象:homeLayout.spec.ts,不依赖组件挂载基建。
*/ */
import { CategoryTreeNode } from "@/types/cms"; import { CategoryTreeNode } from "@/types/cms";
/** v1 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */ /** 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */
export type BlockType = "article_list" | "ticker" | "gallery" | "links"; 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 对应) */ /** article_list 展示形态(与后端渲染端 style 分支及主题 fragment 对应) */
export const LIST_STYLES: { value: string; label: string }[] = [ export const LIST_STYLES: { value: string; label: string }[] = [
@@ -44,6 +52,14 @@ export interface LayoutBlock {
source?: string; source?: string;
/** links 分组名(空=全部分组) */ /** links 分组名(空=全部分组) */
group?: string; 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 { export interface HomeLayout {
@@ -53,12 +69,20 @@ export interface HomeLayout {
export const HOME_LAYOUT_VERSION = 1; export const HOME_LAYOUT_VERSION = 1;
/** 服务端同款约束(SiteService §4.6) */ /** 服务端同款约束(SiteService §4.6 + v2 手册 §2.8) */
export const BLOCK_TYPES_ALLOWED: BlockType[] = ["article_list", "ticker", "gallery", "links"]; export const BLOCK_TYPES_ALLOWED: BlockType[] = [
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar",
];
export const SIZE_MIN = 1; export const SIZE_MIN = 1;
export const SIZE_MAX = 20; export const SIZE_MAX = 20;
export const TITLE_MAX = 100; export const TITLE_MAX = 100;
export const MAX_BLOCKS = 30; 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 { export function genBlockId(): string {
return "b_" + Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4); 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; 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 { function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBlock {
const size = typeof obj["size"] === "number" ? Math.round(obj["size"]) : undefined; const size = typeof obj["size"] === "number" ? Math.round(obj["size"]) : undefined;
const block: LayoutBlock = { 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["background"] === "boolean") block.background = obj["background"];
if (typeof obj["source"] === "string") block.source = obj["source"]; if (typeof obj["source"] === "string") block.source = obj["source"];
if (typeof obj["group"] === "string") block.group = obj["group"]; 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"]); const ids = toIdList(obj["categoryIds"]);
if (ids) block.categoryIds = ids; if (ids) block.categoryIds = ids;
return block; return block;
@@ -171,6 +222,26 @@ export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
if (group) { if (group) {
out["group"] = 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; 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)) { if (b.type === "ticker" && b.source === "category" && !(b.categoryIds && b.categoryIds.length)) {
errors.push(`${no}:指定栏目模式请选择栏目`); 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; return errors;
} }