feat(theme): S5 学院蓝主题改造(管理端)——homeLayout编辑器/主题参数表单/站点设置/类型 + 单测
build / build (push) Successful in 2m19s
build / build (push) Successful in 2m19s
This commit is contained in:
@@ -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("醒目");
|
||||
});
|
||||
});
|
||||
@@ -246,4 +246,32 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
|
||||
expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留
|
||||
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"); // 其余键仍随事实源保留
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* ① 内置卡按钮为"预览+编辑"(任务四:内置包可改显示名/描述);
|
||||
* ② 被用外部包删除禁用且 tooltip 含站点名;
|
||||
* ③ 上传改为表单弹窗(zip + 命名),提交走手动上传通道;
|
||||
* ④ 内置编辑弹窗无参数区、保存不带 params(后端强校验拒改);
|
||||
* ④ 内置编辑弹窗不开放参数 schema 区、保存不带 params(后端强校验拒改);
|
||||
* ⑤ 添加皮肤包表单标识名为空时拦截。
|
||||
* ⑥ 工作项 T(T-T-06):编辑弹窗内嵌参数微调(内置包亦开放),回显/提交包级 paramValues,
|
||||
* 未触碰不带 paramValues(null=不改),卡片显示「已微调 M 项」。
|
||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -28,6 +30,8 @@ const themes = [
|
||||
description: "",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
|
||||
// 工作项 T:包级参数值(仅含被微调过的 key)
|
||||
paramValues: { primary: "#0a5c36" },
|
||||
source: "builtin",
|
||||
inUseCount: 2,
|
||||
editable: true,
|
||||
@@ -86,7 +90,41 @@ vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["sitePath", "theme", "themeParams", "fill"],
|
||||
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);
|
||||
});
|
||||
|
||||
it("任务四: 内置包编辑弹窗无参数区,保存调用 updateMeta 不带 params", async () => {
|
||||
it("任务四: 内置包编辑弹窗不开放参数 schema 编辑区,保存调用 updateMeta 不带 params", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
@@ -215,7 +253,7 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
|
||||
clickButtonByText(builtin, "编辑");
|
||||
await flush();
|
||||
|
||||
// 内置提示条存在、无 ColorPicker(参数 schema 区不渲染)
|
||||
// 内置提示条存在、无 ColorPicker(参数 schema 编辑区不渲染;包级参数值是另一区,见 T-T-06)
|
||||
const metaModal = modalContaining("内置包内容(模板/资产)不可修改");
|
||||
expect(metaModal).toBeTruthy();
|
||||
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 () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
@@ -261,4 +343,19 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
|
||||
expect(stub).toBeTruthy();
|
||||
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
@@ -106,7 +106,8 @@ class SiteApi {
|
||||
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> }) {
|
||||
return postJSON<SiteVo>(`${SITE}/save`, data);
|
||||
}
|
||||
@@ -230,8 +231,14 @@ class ThemeApi {
|
||||
}
|
||||
|
||||
/** W1 + 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema),
|
||||
* 内置包显示名/描述走 .meta 覆盖层(title/description 全空=恢复默认) */
|
||||
updateMeta(name: string, data: { title?: string; description?: string; params?: unknown[] }) {
|
||||
* 内置包显示名/描述走 .meta 覆盖层(title/description 全空=恢复默认)。
|
||||
* 工作项 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, {
|
||||
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||
});
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { ThemeVo } from "@/types/cms";
|
||||
import ImageAssetField from "@/components/ImageAssetField.vue";
|
||||
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 布局重排(用户反馈"颜色设置竖着太长"):
|
||||
* - color 参数 → 色卡网格(横排自动换行,auto-fill 150px;真实主题 12 个色参数不再拉出 12 行长蛇);
|
||||
* - select/image/text → 两列行式(label + 控件同行);
|
||||
* - 非法值红框 + 行内提示;父级 v-model 绑定 FormModelValue(声明过的 key,值已兜底为默认)。
|
||||
* - select/image/text → 两列行式(label + 控件同行);image 走 ImageAssetField(上传 + 手填双通道);
|
||||
* - 非法值红框 + 行内提示;「已修改」标记 + 单项恢复默认 + 整体重置(对照 params[].defaultValue)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
theme?: ThemeVo;
|
||||
modelValue: FormModelValue;
|
||||
/** image 参数上传归属站点(皮肤包管理借用内容源站点;缺省则仅可手填路径) */
|
||||
siteId?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -26,6 +31,7 @@ const otherParams = computed(() => params.value.filter(p => p.type !== "color"))
|
||||
const badKeys = computed(() =>
|
||||
props.theme ? invalidParamKeys(props.theme, props.modelValue) : []
|
||||
);
|
||||
const modifiedCount = computed(() => params.value.filter(p => isModified(p.key)).length);
|
||||
|
||||
function setValue(key: string, value: string) {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 是否偏离包内声明的默认值(= 会被写进 .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 {
|
||||
return badKeys.value.includes(key);
|
||||
}
|
||||
@@ -48,7 +73,11 @@ function isBad(key: string): boolean {
|
||||
<span v-if="badKeys.length" class="param-warn">
|
||||
{{ badKeys.length }} 个非法值未生效(保存时将回退皮肤默认)
|
||||
</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>
|
||||
|
||||
<!-- color:色卡网格(横排) -->
|
||||
@@ -57,7 +86,7 @@ function isBad(key: string): boolean {
|
||||
v-for="p in colorParams"
|
||||
:key="p.key"
|
||||
class="param-chip"
|
||||
:class="{ bad: isBad(p.key) }"
|
||||
:class="{ bad: isBad(p.key), modified: isModified(p.key) }"
|
||||
:title="p.key"
|
||||
>
|
||||
<ColorPicker
|
||||
@@ -65,15 +94,30 @@ function isBad(key: string): boolean {
|
||||
recommend
|
||||
@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>
|
||||
<Button
|
||||
v-if="isModified(p.key)"
|
||||
size="small"
|
||||
ghost
|
||||
class="chip-reset"
|
||||
@click="resetOne(p.key)"
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- select / image / text:两列行式 -->
|
||||
<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) }">
|
||||
<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">
|
||||
<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>
|
||||
</Select>
|
||||
<Input
|
||||
<ImageAssetField
|
||||
v-else-if="p.type === 'image'"
|
||||
:model-value="displayValue(p.key)"
|
||||
size="small"
|
||||
placeholder="/cms/upload/cms/... (仅接受 /cms/upload/** 路径)"
|
||||
@on-change="(v: string) => setValue(p.key, v)"
|
||||
:site-id="siteId"
|
||||
placeholder="/cms/upload/cms/... (上传或粘贴路径,仅接受 /cms/upload/** 与 /yschcms/upload/**)"
|
||||
@update:model-value="(v: string) => setValue(p.key, v)"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
@@ -98,6 +142,7 @@ function isBad(key: string): boolean {
|
||||
:maxlength="256"
|
||||
@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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,6 +178,9 @@ function isBad(key: string): boolean {
|
||||
color: #e54d42;
|
||||
font-size: 12px;
|
||||
}
|
||||
.reset-all {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* color 色卡网格:横排自动换行(150px 起步,宽屏一行 4~5 张) */
|
||||
@@ -157,6 +205,10 @@ function isBad(key: string): boolean {
|
||||
&.bad {
|
||||
border-color: #e54d42;
|
||||
}
|
||||
&.modified {
|
||||
border-color: #ffd591;
|
||||
background: #fffdf7;
|
||||
}
|
||||
.chip-name {
|
||||
font-size: 12px;
|
||||
color: #1e2635;
|
||||
@@ -169,6 +221,13 @@ function isBad(key: string): boolean {
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
color: #8a93a6;
|
||||
}
|
||||
.chip-reset {
|
||||
align-self: flex-start;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
/* ColorPicker 触发器整条拉宽成色条 */
|
||||
:deep(.ivu-color-picker) {
|
||||
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:两列行式 */
|
||||
.param-rows {
|
||||
display: grid;
|
||||
@@ -217,6 +288,10 @@ function isBad(key: string): boolean {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
:deep(.image-asset-field) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.bad-mark {
|
||||
|
||||
@@ -227,4 +227,46 @@ export interface LinkVo {
|
||||
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)===== */
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as themeParamForm from "./themeParamForm";
|
||||
import {
|
||||
COLOR_RE,
|
||||
formModelFromTheme,
|
||||
effectiveValue,
|
||||
invalidParamKeys,
|
||||
isValidParamValue,
|
||||
mergeThemeParamsIntoConfigJson,
|
||||
themeParamsFromConfigJson,
|
||||
paramValuesOf,
|
||||
} from "./themeParamForm";
|
||||
import { ThemeVo } from "@/types/cms";
|
||||
|
||||
@@ -30,26 +30,69 @@ function theme(): ThemeVo {
|
||||
};
|
||||
}
|
||||
|
||||
describe("themeParamForm(方案 §6.3/§8.4)", () => {
|
||||
it("T28: schema→表单模型:四类型各映射为对应生效值 + 默认值兜底", () => {
|
||||
const model = formModelFromTheme(theme());
|
||||
expect(model.primary).toBe("#b01a2e");
|
||||
expect(model.bg).toBe("#f7f5f2");
|
||||
describe("themeParamForm(方案 §6.3/§8.4 + 工作项 T 包级参数)", () => {
|
||||
it("T-T-08: paramValuesOf 以包 schema + paramValues 组装,缺项回落 defaultValue", () => {
|
||||
const t = theme();
|
||||
t.paramValues = { primary: "#123456" };
|
||||
const model = paramValuesOf(t);
|
||||
expect(model.primary).toBe("#123456"); // 包级微调值
|
||||
expect(model.bg).toBe("#f7f5f2"); // 未微调 → theme.json 默认值
|
||||
expect(model.headerStyle).toBe("banner");
|
||||
expect(model.logoImage).toBe("");
|
||||
});
|
||||
|
||||
it("T28: 已有合法值保留,非法显式值回退默认", () => {
|
||||
const model = formModelFromTheme(theme(), {
|
||||
primary: "#123456", // 合法,保留
|
||||
bg: "red;}", // 非法,回退默认
|
||||
headerStyle: "plain", // select 枚举内合法
|
||||
logoImage: "javascript:alert(1)", // 非法 image
|
||||
it("T-T-08: 覆盖层里的非法值回退默认、未声明 key 不入模型(与后端 filterValidValues 同语义)", () => {
|
||||
const t = theme();
|
||||
t.paramValues = { primary: "red;}", bg: "#000000", zzhack: "zzvalue" };
|
||||
const model = paramValuesOf(t);
|
||||
expect(model.primary).toBe("#b01a2e"); // 非法显式值 → 默认
|
||||
expect(model.bg).toBe("#000000");
|
||||
expect(model.zzhack).toBeUndefined();
|
||||
});
|
||||
expect(model.primary).toBe("#123456");
|
||||
expect(model.bg).toBe("#f7f5f2");
|
||||
expect(model.headerStyle).toBe("plain");
|
||||
expect(model.logoImage).toBe("");
|
||||
|
||||
it("T-T-08: 无 paramValues(新包/未微调)全量回落默认", () => {
|
||||
expect(paramValuesOf(theme())).toEqual({
|
||||
primary: "#b01a2e",
|
||||
bg: "#f7f5f2",
|
||||
headerStyle: "banner",
|
||||
logoImage: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("R5-1 根治:paramValuesOf 只有「一个包」一个入参(无跨包 existing 通道),换包即取新包自身值", () => {
|
||||
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 正则通过/拒绝", () => {
|
||||
@@ -88,45 +131,4 @@ describe("themeParamForm(方案 §6.3/§8.4)", () => {
|
||||
expect(bad).toContain("headerStyle");
|
||||
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
@@ -1,10 +1,11 @@
|
||||
import { ThemeParamVo, ThemeVo } from "@/types/cms";
|
||||
|
||||
/**
|
||||
* themeParams 纯函数模块(方案 §6.3,评审 R10/R6):schema → 表单模型映射、
|
||||
* 值校验(color 正则 / select 枚举 / image 仅 /upload/**)、themeParams ↔ configJson 装拆。
|
||||
* 与后端合并语义(方案 §5.3)保持一致:仅替换 theme_params 键、保留其余键、丢弃未声明键。
|
||||
* vitest 单测对象(§8.4 T28-T30),不依赖组件挂载基建。
|
||||
* 主题参数纯函数模块(方案 §6.3 + 工作项 T):包级 `paramValues` → 表单模型、值校验
|
||||
* (color 正则 / select 枚举 / image 仅 /cms|/yschcms + /upload/** / text 长度)。
|
||||
* 工作项 T 起参数值归属皮肤包(`themes/.meta/{name}.json`),站点级 `config_json.theme_params`
|
||||
* 装拆函数(formModelFromTheme / mergeThemeParamsIntoConfigJson / themeParamsFromConfigJson)随之后端退役一并删除。
|
||||
* vitest 单测对象(§8.4 T28-T30、T-T-07/08),不依赖组件挂载基建。
|
||||
*/
|
||||
|
||||
/** 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 {
|
||||
const raw = model[param.key];
|
||||
if (isValidParamValue(param, raw)) return raw as string;
|
||||
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 = {};
|
||||
theme.params.forEach(p => {
|
||||
const raw = existing[p.key];
|
||||
(theme.params || []).forEach(p => {
|
||||
const raw = stored[p.key];
|
||||
model[p.key] = isValidParamValue(p, raw) ? (raw as string) : (p.defaultValue ?? "");
|
||||
});
|
||||
return model;
|
||||
@@ -65,59 +72,6 @@ export function invalidParamKeys(theme: ThemeVo, model: FormModelValue): string[
|
||||
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 {
|
||||
return !!theme?.params?.some(p => p.type === type);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
const payload = {
|
||||
title: metaTitle.value,
|
||||
description: metaDescription.value,
|
||||
// 内置包参数 schema 不可改(后端强校验),不传即不改动
|
||||
params: builtin ? undefined : metaParams.value,
|
||||
})
|
||||
.send();
|
||||
} 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user