feat(cms): docs/16 站点设置整页化+首页布局编辑器- SiteSetting.vue整页7分区(基本信息/皮肤与外观/首页布局/基础展示/页脚信息/SEO与域名/扩展参数)锚点导航sticky+IntersectionObserver+分区卡片三段式每分区独立保存(configJson单一事实源+submitSection统一提交);创建模式仅①②可编辑其余锁定,创建成功原地replace到site/setting/:id;SiteWizard三步向导退役删除(门户预览收编portalPreviewUrl);SiteManage瘦身表格+行内编辑跳整页(adminPathOf子站带站点段);ImageAssetField图片双通道(上传+路径粘贴折叠);SiteConfigForm支持groups/showExtras/siteId+asset字段接ImageAssetField+三旧键表单不再展示(ABSORBED_LEGACY_KEYS静默保留);homeLayout三件套(homeLayout.ts模型parse/serialize/validate/genFromCategories一键生成+blockTypes.ts注册表showIf条件字段+HomeLayoutEditor.vue序列编辑增删显隐上移下移);routes site/new+site/setting/:id+legacy redirect;CategoryManage home_style提示改口指向首页布局;新增siteSetting.spec+homeLayout.spec,重写siteManage.spec,117用例全绿
build / build (push) Successful in 2m19s

This commit is contained in:
chenxing
2026-09-10 15:09:10 +08:00
parent 7c925cd4f2
commit 34733ec280
16 changed files with 2399 additions and 840 deletions
+36 -53
View File
@@ -1,10 +1,8 @@
// @vitest-environment jsdom
/**
* 站点管理页回归(2026-09-10 修复批次):
* ① 行「编辑」打开编辑弹窗——修复 themeUnregistered ref 声明缺失导致 openEdit 抛
* ReferenceError、弹窗无法打开(编辑功能整体失效)的回归;
* ② 已注册皮肤不出现"未注册"告警;未注册皮肤保留原值并出现告警(任务三行为)。
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 themeManage.spec.ts 一致)。
* 站点管理列表页回归(docs/16 主线A 整页化后):
* 行「编辑」→ 跳站点设置中心 site/setting/:id(弹窗已退役);「新建站点」→ site/new 创建模式;
* 子站行带 URL 站点段(docs/13 adminPathOf 口径)。沿用手写 createApp 挂载桩风格。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick } from "vue";
@@ -15,9 +13,10 @@ import ViewUIPlus, { Tag } from "view-ui-plus";
// vitest 管线不走该插件)——这里挂到全局作用域供其解析
(globalThis as any).Tag = Tag;
const { siteListMock, themeListMock } = vi.hoisted(() => ({
const { siteListMock, themeListMock, routerPushMock } = vi.hoisted(() => ({
siteListMock: vi.fn(),
themeListMock: vi.fn(),
routerPushMock: vi.fn(),
}));
const siteRow = {
@@ -38,7 +37,7 @@ const themeRow = {
title: "高校蓝(默认)",
description: "",
screenshotUrl: "/api/theme/default/asset/screenshot.png",
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
params: [],
source: "builtin",
inUseCount: 1,
editable: true,
@@ -52,27 +51,12 @@ vi.mock("@/api/module/cms", () => ({
}));
vi.mock("vue-router", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useRouter: () => ({ push: routerPushMock, replace: vi.fn() }),
useRoute: () => ({ params: {}, query: {} }),
}));
vi.mock("@/components/theme/ThemePicker.vue", () => ({
default: defineComponent({ props: ["themes", "modelValue"], render: () => h("div", "theme-picker-stub") }),
}));
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
default: defineComponent({ props: ["theme", "modelValue"], render: () => h("div", "theme-param-stub") }),
}));
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }),
}));
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
default: defineComponent({ props: ["modelValue"], render: () => h("div", "config-form-stub") }),
}));
vi.mock("@/views/cms/SiteWizard.vue", () => ({
default: defineComponent({ props: ["visible", "sites", "lockParentId"], emits: ["created"], render: () => h("div", "wizard-stub") }),
}));
import SiteManage from "@/views/cms/SiteManage.vue";
import { useCmsStore } from "@/store";
let cleanups: (() => void)[] = [];
@@ -83,11 +67,15 @@ async function flush(frames = 6) {
await new Promise(r => setTimeout(r, 0));
}
function mountPage() {
function mountPage(storeSetup?: (store: any) => void) {
const el = document.createElement("div");
document.body.appendChild(el);
const pinia = createPinia();
if (storeSetup) {
storeSetup(useCmsStore(pinia));
}
const app = createApp(defineComponent({ render: () => h(SiteManage) }));
app.use(createPinia());
app.use(pinia);
app.use(ViewUIPlus);
app.mount(el);
cleanups.push(() => {
@@ -98,17 +86,13 @@ function mountPage() {
return el;
}
/** 操作列图标按钮(iView 固定列克隆两份,取第一个命中的) */
function clickRowAction(el: ParentNode, iconClass: string) {
const btn = [...el.querySelectorAll(`.operating-btn.${iconClass}`)][0];
/** 操作列图标按钮(iView 固定列克隆两份;按行序取第 rowIndex+1 个命中) */
function clickRowAction(el: ParentNode, iconClass: string, rowIndex = 0) {
const btn = [...el.querySelectorAll(`.operating-btn.${iconClass}`)][rowIndex];
btn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
return btn;
}
function modalByTitle(title: string): HTMLElement | undefined {
return [...document.querySelectorAll(".ivu-modal")].find(m => m.querySelector(".ivu-modal-header-inner")?.textContent === title);
}
beforeEach(() => {
vi.clearAllMocks();
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([siteRow])));
@@ -120,8 +104,8 @@ afterEach(() => {
cleanups = [];
});
describe("SiteManage 站点管理(2026-09-10 修复回归)", () => {
it("① 行「编辑」打开编辑弹窗并回填数据(不再因 themeUnregistered 未声明而失效)", async () => {
describe("SiteManage 列表页(docs/16 整页化后)", () => {
it("① 行「编辑」跳站点设置中心(主站形态无站点段)", async () => {
const el = mountPage();
await flush();
@@ -129,34 +113,33 @@ describe("SiteManage 站点管理(2026-09-10 修复回归)", () => {
expect(btn).toBeTruthy();
await flush();
const modal = modalByTitle("编辑站点");
expect(modal).toBeTruthy();
const codeInput = modal!.querySelector<HTMLInputElement>(".ivu-input");
expect(codeInput?.value).toBe("demo");
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/setting/1");
});
it("② 已注册皮肤无告警", async () => {
it("② 子站行「编辑」带 URL 站点段(/sch/{main}/site/{sub}/site/setting/{id})", async () => {
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([
siteRow,
{ ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机械学院" },
])));
const el = mountPage();
await flush();
// 已注册:default 在皮肤列表中 → 不出现告警
clickRowAction(el, "icon-edit");
clickRowAction(el, "icon-edit", 1);
await flush();
const modal = modalByTitle("编辑站点");
expect(modal?.textContent).not.toContain("未在注册表中");
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/jdxy/site/setting/2");
});
it("③ 未注册皮肤保留原值且弹出告警(不静默归一 default)", async () => {
siteListMock.mockResolvedValue(
JSON.parse(JSON.stringify([{ ...siteRow, theme: "skin-x" }]))
);
const el = mountPage();
it("③「新建站点」按钮跳创建模式(site/new;超管无学校上下文回落首主站编码)", async () => {
const el = mountPage(store => {
store.user = { superAdmin: true } as any;
});
await flush();
clickRowAction(el, "icon-edit");
const btn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("新建站点"));
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
const modal = modalByTitle("编辑站点");
expect(modal?.textContent).toContain("未在注册表中");
expect(modal?.textContent).toContain("skin-x");
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/new");
});
});
+249
View File
@@ -0,0 +1,249 @@
// @vitest-environment jsdom
/**
* 站点设置中心(docs/16 主线A)挂载回归:
* ① 创建模式:③~⑦锁定("创建站点后开放"×5)、主按钮=「创建站点」、
* 校级锁定本校主站时门户地址预览随编码联动(原向导 T-W4-18 能力收编);
* ② 编辑模式:基本信息回填、未注册皮肤告警保留原值(任务三行为迁入②分区);
* ③ 分区保存:保存基本信息 = 当前 configJson 事实源全量提交(其他分区键不丢)。
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 siteManage.spec.ts 一致)。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick } from "vue";
import { createPinia } from "pinia";
import ViewUIPlus from "view-ui-plus";
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
const routeMock = vi.hoisted(() => ({ params: {} as Record<string, string>, query: {} as Record<string, string> }));
const routerMock = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn() }));
const mocks = vi.hoisted(() => ({
siteList: vi.fn(),
siteSave: vi.fn(),
themeList: vi.fn(),
}));
const siteRow = {
id: 1,
code: "demo",
path: "demo",
parentSiteId: 0,
schId: 1001,
schName: "云海大学",
name: "云海大学门户网站",
theme: "default",
configJson: '{"logo":"/cms/upload/logo.png","gallery_title":"线上展厅","home_layout":{"version":1,"blocks":[{"id":"b1","type":"links"}]}}',
status: 1,
};
const themeRow = {
name: "default",
title: "高校蓝(默认)",
screenshotUrl: "/api/theme/default/asset/screenshot.png",
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
};
vi.mock("@/api/module/cms", () => ({
siteApi: {
list: () => ({ send: mocks.siteList }),
save: (data: any) => ({ send: () => mocks.siteSave(data) }),
},
themeApi: { list: () => ({ send: mocks.themeList }) },
assetUrl: (u: string) => u,
uploadUrl: () => "/api/attachment/upload",
}));
vi.mock("vue-router", () => ({
useRouter: () => routerMock,
useRoute: () => routeMock,
}));
vi.mock("@/components/theme/ThemePicker.vue", () => ({
default: defineComponent({ props: ["themes", "modelValue"], emits: ["update:modelValue"], render: () => h("div", "theme-picker-stub") }),
}));
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
default: defineComponent({ props: ["theme", "modelValue"], emits: ["update:modelValue"], render: () => h("div", "theme-param-stub") }),
}));
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }),
}));
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
default: defineComponent({
props: ["modelValue", "groups", "showExtras", "siteId"],
methods: { validate: async () => true },
render: () => h("div", "config-form-stub"),
}),
}));
vi.mock("@/views/cms/homeLayout/HomeLayoutEditor.vue", () => ({
default: defineComponent({
props: ["modelValue", "siteId"],
emits: ["update:modelValue", "save"],
methods: { validate: () => null },
render: () => h("div", "layout-editor-stub"),
}),
}));
import SiteSetting from "@/views/cms/SiteSetting.vue";
import { useCmsStore } from "@/store";
let cleanups: (() => void)[] = [];
async function flush(frames = 6) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
}
function mountPage(storeSetup?: (store: any) => void) {
const el = document.createElement("div");
document.body.appendChild(el);
const pinia = createPinia();
if (storeSetup) {
// 挂载前把上下文注入同一 pinia(defineStore 幂等,组件内拿到同一实例)
storeSetup(useCmsStore(pinia));
}
const app = createApp(defineComponent({ render: () => h(SiteSetting) }));
app.use(pinia);
app.use(ViewUIPlus);
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
});
return el;
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
beforeEach(() => {
vi.clearAllMocks();
routeMock.params = {};
routeMock.query = {};
mocks.siteList.mockResolvedValue(JSON.parse(JSON.stringify([siteRow])));
mocks.themeList.mockResolvedValue(JSON.parse(JSON.stringify([themeRow])));
mocks.siteSave.mockImplementation(async (data: any) => ({ ...siteRow, ...data, id: data.id ?? 99 }));
});
afterEach(() => {
cleanups.forEach(fn => fn());
cleanups = [];
});
describe("SiteSetting 创建模式(site/new,向导退役收编)", () => {
it("③~⑦分区锁定(创建站点后开放×5),主按钮=创建站点", async () => {
const el = mountPage();
await flush();
const tips = (el.textContent.match(/创建站点后开放/g) || []).length;
expect(tips).toBe(5);
expect(el.textContent).toContain("新建站点");
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点"));
expect(createBtn).toBeTruthy();
});
it("无上级时门户地址预览随编码联动(/portal/{code}/)", async () => {
const el = mountPage();
await flush();
const input = el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!;
expect(input).toBeTruthy();
setInputValue(input, "jdxy");
await flush();
expect(el.textContent).toContain("/portal/jdxy/");
});
it("校级建子站:上级锁定本校主站,预览=上级地址/编码(原向导 lockParent 语义)", async () => {
const el = mountPage(store => {
store.user = { superAdmin: false } as any;
store.enterSchool(
{ schId: 1001, schName: "云海大学", mainSiteId: 1, mainSiteCode: "demo", mainSiteName: "云海大学门户网站" },
[{ id: 1, code: "demo", path: "demo", name: "云海大学门户网站" }]
);
});
await flush();
const input = el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!;
setInputValue(input, "jdxy");
await flush();
expect(el.textContent).toContain("/portal/demo/jdxy/");
// 上级锁定为本校主站(Select disabled)
const selectEl = el.querySelector(".ivu-select-disabled");
expect(selectEl).toBeTruthy();
});
it("创建成功:replace 到站点设置页并解锁分区", async () => {
const el = mountPage();
await flush();
const input = el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!;
setInputValue(input, "jdxy");
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="如 XX学校门户网站"]')!;
setInputValue(nameInput, "机械学院门户");
await flush();
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点"))!;
createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
const payload = mocks.siteSave.mock.calls[0][0];
expect(payload.code).toBe("jdxy");
expect(payload.name).toBe("机械学院门户");
expect(routerMock.replace).toHaveBeenCalledWith("/sch/jdxy/site/setting/99");
});
});
describe("SiteSetting 编辑模式(site/setting/:id)", () => {
it("基本信息回填 + 已注册皮肤无告警", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
expect(el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!.value).toBe("demo");
expect((el.textContent || "")).not.toContain("未在注册表中");
expect(el.textContent).toContain("云海大学门户网站");
expect(el.textContent).not.toContain("创建站点后开放");
});
it("未注册皮肤保留原值并弹出告警(不静默归一 default)", async () => {
routeMock.params = { id: "1" };
mocks.siteList.mockResolvedValue(JSON.parse(JSON.stringify([{ ...siteRow, theme: "skin-x" }])));
const el = mountPage();
await flush();
expect(el.textContent).toContain("未在注册表中");
expect(el.textContent).toContain("skin-x");
});
it("仅保存基本信息:以当前 configJson 事实源全量提交(home_layout 等其他分区键随行)", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="如 XX学校门户网站"]')!;
setInputValue(nameInput, "改名后的门户");
await flush();
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("仅保存基本信息"))!;
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
const payload = mocks.siteSave.mock.calls[0][0];
expect(payload.id).toBe(1);
expect(payload.name).toBe("改名后的门户");
expect(payload.theme).toBe("default");
const config = JSON.parse(payload.configJson);
expect(config.logo).toBe("/cms/upload/logo.png");
expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留
expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局分区未动
});
});
-160
View File
@@ -1,160 +0,0 @@
// @vitest-environment jsdom
/**
* W4 向导地址预览联动(方案 §8.3 T-W4-18):
* 第 3 步「门户地址预览」随上级与编码输入联动(校级 lockParent 模式预置上级=选上级)。
* 挂载 SiteWizard(Modal transfer 到 body),桩掉主题三件套与配置表单。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import { createPinia } from "pinia";
import ViewUIPlus from "view-ui-plus";
const themesMock = vi.hoisted(() => ({
list: vi.fn(async () => [
{
name: "default",
title: "云端默认",
screenshotUrl: "/api/theme/default/asset/screenshot.png",
params: [],
},
]),
}));
vi.mock("@/api/module/cms", () => ({
themeApi: { list: () => ({ send: themesMock.list }) },
siteApi: { save: () => ({ send: async () => ({}) }) },
}));
vi.mock("@/components/theme/ThemePicker.vue", () => ({
default: defineComponent({
props: ["themes", "modelValue"],
emits: ["update:modelValue", "pick"],
setup(_, { emit }) {
(globalThis as any).__wizardPickTheme = (name: string) => emit("pick", name);
return () => h("div", { class: "theme-picker-stub" }, "themes");
},
}),
}));
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
default: defineComponent({ props: ["modelValue"], emits: ["update:modelValue"], render: () => null }),
}));
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
default: defineComponent({ props: ["url", "tick"], render: () => h("div", "preview-stub") }),
}));
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
default: defineComponent({
props: ["modelValue"],
methods: { validate: async () => true },
render: () => null,
}),
}));
import SiteWizard from "@/views/cms/SiteWizard.vue";
let cleanups: (() => void)[] = [];
async function flush(frames = 6) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
}
function mountWizard(lockParentId?: number, secondMain = false) {
const sites = [
{ id: 1, code: "sch-3", path: "sch-3", parentSiteId: 0, schId: 1, schName: "第一中学", name: "第一中学门户", status: 1 },
...(secondMain
? [{ id: 2, code: "sch-8", path: "sch-8", parentSiteId: 0, schId: 2, schName: "第八中学", name: "第八中学门户", status: 1 }]
: []),
];
const visible = ref(false); // 挂载后翻转以触发 SiteWizard 的 visible watch(真实打开路径)
const pinia = createPinia();
const el = document.createElement("div");
document.body.appendChild(el);
const app = createApp(defineComponent({
setup() {
return () => h(SiteWizard, {
visible: visible.value,
sites,
lockParentId,
"onUpdate:visible": (v: boolean) => { visible.value = v; },
});
},
}));
app.use(pinia);
app.use(ViewUIPlus);
// SiteWizard 第 2 步的 FormItem 不在 Form 内(现状如此),jsdom 卸载时 view-ui-plus
// 内部 removeField/colon 读取 undefined 崩溃 → 本测试用展示桩覆盖 FormItem
app.component("FormItem", defineComponent({
props: ["label", "prop", "labelWidth"],
setup(props, { slots }) {
return () => h("div", { class: "form-item-stub" }, [
props.label ? h("label", props.label) : null,
slots.default?.(),
]);
},
}));
app.mount(el);
visible.value = true;
cleanups.push(() => {
app.unmount();
el.remove();
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
});
return el;
}
async function gotoStep3() {
// step1 → step2 → step3(footer 按钮 transfer 到 body)
const clickNext = async () => {
await flush();
const btn = [...document.querySelectorAll(".ivu-modal-footer button")]
.find(b => b.textContent?.includes("下一步"));
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
};
await clickNext();
await clickNext();
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanups.forEach(fn => fn());
cleanups = [];
});
describe("SiteWizard 门户地址预览(T-W4-18)", () => {
it("选上级(lockParent 预置 sch-3)+ 输入 jdxy → 预览 /portal/sch-3/jdxy/", async () => {
mountWizard(1);
await gotoStep3();
const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement;
expect(input).toBeTruthy();
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(input, "jdxy");
input.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
expect(document.body.textContent).toContain("门户地址预览");
expect(document.body.textContent).toContain("/portal/sch-3/jdxy/");
});
it("改上级即变:换到另一主站 sch-8 → 预览 /portal/sch-8/jdxy/", async () => {
mountWizard(2, true);
await gotoStep3();
const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(input, "jdxy");
input.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
expect(document.body.textContent).toContain("/portal/sch-8/jdxy/");
expect(document.body.textContent).not.toContain("/portal/sch-3/jdxy/");
});
});
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { Message } from "view-ui-plus";
import { assetUrl, uploadUrl } from "@/api/module/cms";
/**
* 图片类配置字段控件(docs/16 D7):上传 + 手动粘贴路径双通道。
* v-model 绑定资产路径(/ 开头相对路径或 http(s) URL);上传成功自动回填(服务端返回相对 url)。
* 缩略预览 + 清空;格式校验由外层 FormItem 规则(ASSET_RE)承担,控件只负责取值。
*/
const props = defineProps<{
modelValue: string;
/** 上传归属站点(附件场景引用;缺省不携带) */
siteId?: number;
placeholder?: string;
}>();
const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>();
const manualOpen = ref(false);
const manualPath = ref("");
const uploading = ref(false);
const previewSrc = computed(() => (props.modelValue ? assetUrl(props.modelValue) : ""));
const action = computed(() => {
const base = uploadUrl();
return props.siteId ? `${base}?siteId=${props.siteId}` : base;
});
function onUploaded(res: any) {
uploading.value = false;
if (res && res.code === 0 && res.data && res.data.url) {
emit("update:modelValue", res.data.url);
manualOpen.value = false;
} else {
Message.error(res?.msg || "图片上传失败");
}
}
function onUploadError() {
uploading.value = false;
Message.error("图片上传失败");
}
function confirmManual() {
const v = manualPath.value.trim();
if (!v) {
Message.warning("请先粘贴图片路径");
return;
}
emit("update:modelValue", v);
manualPath.value = "";
manualOpen.value = false;
}
function clearValue() {
emit("update:modelValue", "");
}
</script>
<template>
<div class="image-asset-field">
<div class="iaf-row">
<Upload
v-if="!modelValue"
:action="action"
:show-upload-list="false"
:on-success="onUploaded"
:on-error="onUploadError"
accept="image/*"
>
<Button size="small" icon="ios-cloud-upload-outline" :loading="uploading">上传图片</Button>
</Upload>
<span v-else class="iaf-thumb">
<img :src="previewSrc" alt="" />
<span class="iaf-thumb-del" title="清除" @click="clearValue">
<Icon type="ios-close" />
</span>
</span>
<Button size="small" type="text" class="iaf-toggle" @click="manualOpen = !manualOpen">
{{ manualOpen ? "收起" : "手动粘贴路径" }}
</Button>
</div>
<Input
v-if="manualOpen"
v-model="manualPath"
size="small"
class="iaf-manual"
:placeholder="placeholder || '粘贴已上传图片的路径,如 /yschcms/upload/…/logo.png'"
>
<Button slot="append" size="small" type="primary" @click="confirmManual">填入</Button>
</Input>
<p v-else-if="modelValue" class="iaf-path">{{ modelValue }}</p>
</div>
</template>
<style scoped lang="scss">
.image-asset-field {
.iaf-row {
display: flex;
align-items: center;
gap: 8px;
}
.iaf-toggle {
color: #999;
&:hover {
color: #2d8cf0;
}
}
.iaf-thumb {
position: relative;
display: inline-flex;
img {
width: 64px;
height: 40px;
object-fit: cover;
border-radius: 4px;
border: 1px solid #dcdee2;
background: #f8f8f9;
}
.iaf-thumb-del {
position: absolute;
top: -7px;
right: -7px;
width: 16px;
height: 16px;
line-height: 14px;
text-align: center;
border-radius: 50%;
background: #ed4014;
color: #fff;
cursor: pointer;
font-size: 12px;
display: none;
}
&:hover .iaf-thumb-del {
display: block;
}
}
.iaf-manual {
max-width: 420px;
margin-top: 6px;
}
.iaf-path {
color: #999;
font-size: 12px;
margin-top: 2px;
word-break: break-all;
line-height: 1.5;
}
}
</style>
+16
View File
@@ -85,6 +85,20 @@ const cmsChildren: RouteRecordRaw[] = [
component: () => import("@/views/cms/SiteManage.vue"),
meta: { title: "站点管理", modId: 6 },
},
{
// 站点设置中心(docs/16 主线A):新建 = 创建模式(仅基本信息/皮肤可编辑),编辑 = 完整分区页。
// 列表子页面形态(subOf),面包屑/侧栏高亮归属「站点管理」
path: "site/new",
name: "SiteSettingNew",
component: () => import("@/views/cms/SiteSetting.vue"),
meta: { title: "新建站点", modId: 6, subOf: "/site" },
},
{
path: "site/setting/:id",
name: "SiteSetting",
component: () => import("@/views/cms/SiteSetting.vue"),
meta: { title: "站点设置", modId: 6, subOf: "/site" },
},
{
// 门户预览(SPA 内嵌简版门户;真实门户由后端 Thymeleaf 提供)
path: "portal-preview",
@@ -142,6 +156,8 @@ export const routes: RouteRecordRaw[] = [
{ path: "/link", redirect: legacyRedirect("link") },
{ path: "/attachment", redirect: legacyRedirect("attachment") },
{ path: "/site", redirect: legacyRedirect("site") },
{ path: "/site/new", redirect: legacyRedirect("site/new") },
{ path: "/site/setting/:id", redirect: legacyRedirect("site/setting") },
{ path: "/theme", redirect: "/theme-manage" },
{ path: "/portal-preview", redirect: legacyRedirect("portal-preview") },
{
+11 -5
View File
@@ -107,10 +107,9 @@ describe("siteConfigForm", () => {
expect(firstExtraError([])).toBe("");
});
it("已知键清单与 docs/09 §0 键盘点表一致(防漂移)", () => {
it("已知键清单与 docs/09 §0 键盘点表一致(防漂移;docs/16 D4 吸收三旧键后收敛)", () => {
expect(SITE_CONFIG_FIELD_DEFS.map(d => d.key)).toEqual([
"logo", "favicon", "slogan", "colleges_label",
"news_bg_image", "gallery_title", "gallery_subtitle",
"contact", "footer_addresses", "qr_wechat", "qr_weibo",
"social_wechat", "social_weibo", "social_douyin", "social_bilibili",
"copyright", "beian",
@@ -118,13 +117,12 @@ describe("siteConfigForm", () => {
"domain", "public_base_url",
]);
});
it("docs/12:页脚增强与首页视觉新键 parse/serialize/校验(qr_* asset、social_* url)", () => {
it("docs/12:页脚增强新键 parse/serialize/校验(qr_* asset、social_* url)", () => {
const json = JSON.stringify({
footer_addresses: "潭山校区:南湖路 237 号 邮编:464000\n淮河校区:大学路 1 号 邮编:464300",
qr_wechat: "/cms/upload/qr-wechat.png",
social_weibo: "https://weibo.com/xxx",
social_douyin: "not-a-url",
gallery_title: "线上展厅",
});
const m = parseSiteConfig(json);
expect(m.fields.footer_addresses).toContain("淮河校区");
@@ -140,7 +138,15 @@ describe("siteConfigForm", () => {
const out = JSON.parse(serializeSiteConfig(m));
expect(out.footer_addresses).toContain("邮编:464300");
expect(out).not.toHaveProperty("social_bilibili");
expect(out.gallery_title).toBe("线上展厅");
});
it("docs/16 D4:gallery_title/gallery_subtitle/news_bg_image 被吸收为静默保留键(不落扩展行、无损回写)", () => {
const json = JSON.stringify({ gallery_title: "线上展厅", news_bg_image: "/cms/upload/campus.jpg" });
const m = parseSiteConfig(json);
expect(m.fields).toEqual({}); // 不进任何分区表单
expect(m.extras).toEqual([]); // 也不落扩展行
expect(m.preserved.gallery_title).toBe("线上展厅");
expect(m.preserved.news_bg_image).toBe("/cms/upload/campus.jpg");
expect(JSON.parse(serializeSiteConfig(m))).toEqual(JSON.parse(json)); // 存量值无损保留
});
it("B3:custom_css 为保留键,不入扩展行,parse/serialize 无损往返", () => {
const json = JSON.stringify({ custom_css: ".site-header{padding:16px 0}", my_ext: "v" });
+11 -17
View File
@@ -39,21 +39,6 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
placeholder: "留空默认「专业院系」",
tip: "门户右上「专业院系」下拉的文案;仅主站有效(下拉由院系子站生成)。",
},
{
key: "news_bg_image", label: "新闻卡背景图", group: "display", kind: "asset", maxLength: 500,
placeholder: "如 /yschcms/upload/…/campus.jpg(留空用主题渐变)",
tip: "首页「双栏新闻卡」板块的全幅背景大图(建议 1920×700);仅展示型皮肤(campus-glow)消费。",
},
{
key: "gallery_title", label: "展厅标题", group: "display", kind: "text", maxLength: 50,
placeholder: "留空默认「线上展厅」",
tip: "首页图片画廊文区头主标题。",
},
{
key: "gallery_subtitle", label: "展厅英文副题", group: "display", kind: "text", maxLength: 100,
placeholder: "留空默认 Online Exhibition Hall",
tip: "首页图片画廊文区头英文副题。",
},
{
key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true,
placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号",
@@ -119,9 +104,18 @@ export const SITE_CONFIG_GROUPS: { name: SiteConfigGroup; title: string }[] = [
{ name: "domain", title: "域名与访问" },
];
/** 系统托管键:不进表单,解析时原样保留、序列化时无损回写(theme_params 由主题参数表单维护) */
/**
* 系统托管键:不进表单,解析时原样保留、序列化时无损回写(theme_params 由主题参数表单维护;
* custom_css 由站点设置「皮肤与外观」分区维护)。
*/
export const RESERVED_SITE_CONFIG_KEYS = ["theme_params", "custom_css"];
/**
* docs/16 D4 被吸收的旧键:画廊标题/副题、新闻卡背景图改由首页布局区块属性承载(home_layout),
* 分区表单不再展示;存量值静默保留(渲染端仍作区块未设 title/background 时的默认值)。
*/
export const ABSORBED_LEGACY_KEYS = ["gallery_title", "gallery_subtitle", "news_bg_image"];
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致) */
export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -160,7 +154,7 @@ export function parseSiteConfig(configJson: string | undefined | null): SiteConf
return model;
}
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
const reserved = new Set(RESERVED_SITE_CONFIG_KEYS);
const reserved = new Set([...RESERVED_SITE_CONFIG_KEYS, ...ABSORBED_LEGACY_KEYS]);
Object.keys(parsed).forEach(k => {
const v = parsed![k];
if (typeof v !== "string") {
+1 -1
View File
@@ -520,7 +520,7 @@ function openLink(url: string) {
<Select v-model="form.homeStyle" placeholder="默认卡片">
<Option v-for="s in homeStyleDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
</Select>
<p class="form-tip">首页该栏目区块的呈现形态;「双栏新闻卡/媒体聚焦列表」需皮肤包支持(如炫彩校园),旧主题按默认卡展示。</p>
<p class="form-tip">首页该栏目区块的呈现形态;「双栏新闻卡/媒体聚焦列表」需皮肤包支持(如炫彩校园),旧主题按默认卡展示。也可在「站点设置 → 首页布局」里按区块单独指定形态(以那里为准)。</p>
</FormItem>
<Divider size="small" orientation="left">SEO 设置</Divider>
+27 -266
View File
@@ -1,31 +1,20 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import Utils from "@/utils/index";
import { assetUrl, siteApi, themeApi } from "@/api/module/cms";
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
import { adminPathOf, portalUrlOf } from "@/utils/sitePath";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
import { SiteVo, ThemeVo } from "@/types/cms";
import { formModelFromTheme, FormModelValue, 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 SiteWizard from "@/views/cms/SiteWizard.vue";
/**
* 站点管理(方案 §9.1 + §6.2 主题选择增强)。
* 学校化管理(docs/09 §3.3):默认只显示当前学校站点(主站+院系子站);
* 超管可经工作台「全部站点总览」(?scope=all)查看全局列表。
* 行「进入管理」= 院系子站切换入口(壳面包屑条切换器已按 UX 收敛移除):
* 切 store.currentSite 落文章列表,跨校行先重建学校上下文;当前站点行带「当前」标记。
* 新建 → 三步向导(选模板→微调预览→确认);已建站点 → 编辑弹窗内
* 主题卡片单选 + 参数微调表单,保存带 themeParams(服务端合并进 config_json,方案 §5.3)。
* config_json 结构化编辑见 SiteConfigForm(docs/09 §3.1):
* 已知键分组表单 + 扩展参数动态行,不再手写 JSON;theme_params 仍由 ThemeParamForm 自动合并。
* 站点管理列表页(docs/16 主线A 收敛后):列表只做表格——
* 新建 → 站点设置中心创建模式(site/new);行「编辑」→ 站点设置中心(site/setting/:id)。
* 分区卡片/皮肤/首页布局等全部配置能力收敛到 SiteSetting.vue(原编辑弹窗与三步向导退役)。
* 学校化管理(docs/09 §3.3):默认只显示当前学校站点;超管可经工作台「全部站点总览」(?scope=all)。
* 行「进入管理」= 院系子站切换入口;跨校行先重建学校上下文;当前站点行带「当前」标记。
*/
const store = useCmsStore();
const route = useRoute();
@@ -34,7 +23,7 @@ const router = useRouter();
/** 超管全局视图(工作台入口带入 scope=all) */
const scopeAll = computed(() => route.query.scope === "all" && store.isSuperAdmin);
/** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管向导行为不变 */
/** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管同 */
const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool);
const canCreateSite = computed(() => store.isSuperAdmin || isSchoolAdmin.value);
@@ -42,7 +31,7 @@ const rows = ref<SiteVo[]>([]);
const loading = ref(false);
const tableHeight = Utils.useTableHeight({ isOperat: true });
/** 主题元数据映射(列表皮肤列缩略名 + 编辑表单) */
/** 主题元数据映射(列表皮肤列缩略名) */
const themes = ref<ThemeVo[]>([]);
const themeMeta = computed(() => {
const map: Record<string, ThemeVo> = {};
@@ -105,59 +94,12 @@ const columns = [
align: "center",
render: (h: any, p: any) => [
Utils.operatingHandle(h, "进入管理", "icon-apps", () => enterManage(p.row), p.row.id === store.siteId),
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)),
Utils.operatingHandle(h, "编辑", "icon-edit", () => openSetting(p.row)),
Utils.operatingHandle(h, "门户", "icon-browse", () => openPortal(p.row)),
],
},
];
/** 新建向导 */
const wizardVisible = ref(false);
/** 编辑弹窗 */
const modalVisible = ref(false);
const saving = ref(false);
const formRef = ref();
const configFormRef = ref();
const form = reactive({
id: undefined as number | undefined,
code: "",
parentSiteId: undefined as number | undefined,
schId: undefined as number | undefined,
schName: "",
name: "",
theme: "default",
configJson: "",
status: 1,
});
const editThemeParams = ref<FormModelValue>({});
const editTheme = computed(() => themeMeta.value[form.theme]);
/** 当前主题是否未注册(未注册保留原值但告警,保存将被服务端拒绝) */
const themeUnregistered = ref(false);
/** B3 自定义样式(config_json.custom_css):面板为唯一编辑入口,SiteConfigForm 侧该键为保留键 */
const customCss = ref("");
/** 编辑内预览 */
const previewVisible = ref(false);
/** 可选主站:顶层站点且非自身(院系子站层级固定两层) */
const parentOptions = computed(() =>
rows.value.filter(r => !r.parentSiteId && r.id !== form.id)
);
const rules = {
code: [
{ required: true, message: "站点编码不得为空", trigger: "blur" },
{
pattern: /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/,
message: "仅限小写字母/数字/短横线",
trigger: "blur",
},
],
name: [{ required: true, message: "站点名称不得为空", trigger: "blur" }],
};
async function run() {
loading.value = true;
try {
@@ -179,102 +121,25 @@ async function loadThemes() {
}
}
const editSitePath = ref<string>("");
function openWizard() {
wizardVisible.value = true;
}
function onWizardCreated() {
run();
}
function openEdit(row: SiteVo) {
editSitePath.value = row.path || row.code;
// 任务三:主题值保留原值(未注册时告警,由服务端校验拒绝),不再静默改写为 default
const resolved = resolveEditableTheme(
row.theme,
themes.value.map(t => t.name)
);
themeUnregistered.value = resolved.unregistered;
Object.assign(form, {
id: row.id,
code: row.code,
parentSiteId: row.parentSiteId || undefined,
schId: row.schId,
schName: row.schName || "",
name: row.name,
theme: resolved.theme,
configJson: row.configJson || "",
status: row.status,
});
customCss.value = readCustomCss(form.configJson);
const t = editTheme.value;
editThemeParams.value = t ? themeParamsFromConfigJson(row.configJson, t) : {};
modalVisible.value = true;
}
function readCustomCss(configJson: string): string {
try {
const obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
return obj && typeof obj === "object" && typeof obj.custom_css === "string" ? obj.custom_css : "";
} catch {
return "";
/** 行「编辑」→ 站点设置中心(docs/16 §3.1):主站形态无站点段,子站带站点段 */
function openSetting(row: SiteVo) {
const main = row.parentSiteId ? rows.value.find(s => s.id === row.parentSiteId) : row;
const schCode = main?.code || store.schoolCode;
if (!schCode) {
Message.warning("无法确定所属学校上下文");
return;
}
router.push(adminPathOf(schCode, row.parentSiteId ? row.code : null, `site/setting/${row.id}`));
}
/** 把面板值写入/移出 configJson(空值移除键;与 SiteConfigForm 的 preserved 往返兼容) */
function withCustomCss(configJson: string, css: string): string {
let obj: Record<string, unknown>;
try {
obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
} catch {
obj = {};
}
if (css.trim()) obj.custom_css = css;
else delete obj.custom_css;
return JSON.stringify(obj);
}
function pickTheme(name: string) {
if (form.theme === name) return;
form.theme = name;
themeUnregistered.value = false;
const t = editTheme.value;
editThemeParams.value = t ? formModelFromTheme(t, editThemeParams.value) : {};
}
async function save() {
const valid = await formRef.value?.validate();
if (!valid) return;
const configValid = await configFormRef.value?.validate();
if (!configValid) return;
saving.value = true;
try {
const t = editTheme.value;
const themeParams = t ? editThemeParams.value : {};
const baseConfig = withCustomCss(form.configJson || "", customCss.value);
await siteApi
.save({
id: form.id,
code: form.code,
parentSiteId: form.parentSiteId || 0,
// B1:校级角色隐藏学校信息字段,留空由服务端继承主站(防伪造通道)
schId: isSchoolAdmin.value ? undefined : form.schId,
schName: isSchoolAdmin.value ? undefined : form.schName || undefined,
name: form.name,
theme: form.theme || undefined,
configJson: t ? mergeThemeParamsIntoConfigJson(baseConfig, t, themeParams) : baseConfig || undefined,
status: form.status,
})
.send();
Message.success("保存成功!");
modalVisible.value = false;
run();
} finally {
saving.value = false;
/** 新建 → 站点设置中心创建模式;校级建子站、超管建任意站点 */
function openCreate() {
const schCode = store.schoolCode || rows.value.find(s => !s.parentSiteId)?.code;
if (!schCode) {
Message.warning("请先从学校工作台进入后再新建站点");
return;
}
router.push(adminPathOf(schCode, null, "site/new"));
}
function openPortal(row: SiteVo) {
@@ -305,10 +170,6 @@ function enterManage(row: SiteVo) {
router.push(adminPathOf(main.code, row.parentSiteId ? row.code : null, "article"));
}
function openEditPreview() {
previewVisible.value = true;
}
/** 全局视图返回学校工作台 */
function goBackWorkbench() {
window.location.hash = "#/workbench";
@@ -333,112 +194,12 @@ watch(() => store.currentSchool?.schId, () => run());
</Alert>
</div>
<div class="fix" v-if="canCreateSite">
<Button class="fl" type="primary" size="large" @click="openWizard">
<Button class="fl" type="primary" size="large" @click="openCreate">
<Icon type="ios-add" />
{{ isSchoolAdmin ? "新建院系子站" : "新建站点(向导)" }}
{{ isSchoolAdmin ? "新建院系子站" : "新建站点" }}
</Button>
</div>
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" />
<!-- 编辑弹窗 -->
<Modal v-model="modalVisible" title="编辑站点" :mask-closable="false" width="720px">
<Form ref="formRef" :model="form" :rules="rules" :label-width="110">
<FormItem label="站点编码" prop="code">
<Input v-model="form.code" :disabled="!!form.id" placeholder="URL 路径用,如 demo" />
</FormItem>
<FormItem label="站点名称" prop="name">
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" />
</FormItem>
<FormItem label="上级站点">
<Select v-model="form.parentSiteId" :clearable="!isSchoolAdmin" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
</Select>
<p class="form-tip">院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。{{ isSchoolAdmin ? "校级角色仅可在本校主站之间改挂。" : "" }}</p>
</FormItem>
<FormItem v-if="store.isSuperAdmin" label="学校ID">
<InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
</FormItem>
<FormItem v-if="store.isSuperAdmin" label="学校名称">
<Input v-model="form.schName" :maxlength="128" />
</FormItem>
<FormItem label="皮肤包">
<Alert v-if="themeUnregistered" type="warning" show-icon style="margin-bottom: 8px">
皮肤包「{{ form.theme }}」未在注册表中,保存将被服务端拒绝;请先由平台超管上传该皮肤包,或改选下方其他皮肤。
</Alert>
<Alert v-else-if="!themes.length" type="warning" show-icon style="margin-bottom: 8px">
皮肤包列表加载失败,暂时无法选择;站点当前皮肤包({{ form.theme }})保持不变,可关闭后重试。
</Alert>
<ThemePicker :themes="themes" :model-value="form.theme" @update:model-value="pickTheme" />
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openEditPreview">预览本皮肤</Button>
<ThemeParamForm :theme="editTheme" v-model="editThemeParams" />
<p class="form-tip">换肤/改参数保存后门户立即生效(缓存已自动失效)。</p>
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="form.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
<!-- B3 自定义样式(config_json.custom_css,服务端黑名单强校验) -->
<Collapse class="css-collapse">
<Panel name="css" header="自定义样式(站点级 CSS 覆盖)">
<Input
v-model="customCss"
type="textarea"
:rows="6"
:maxlength="10240"
placeholder="如:--primary:#59aeeb; 或 .site-header{height:80px}"
class="css-editor"
/>
<div class="css-meta">
<span>{{ customCss.length }}/10240 字符</span>
<span>保存后门户立即生效(缓存自动失效);服务端将拒绝 &lt;/style、&lt;script、@import、expression( 等危险内容</span>
</div>
</Panel>
</Collapse>
<!-- 站点配置结构化表单(独立 Form 分区,v-model 直绑 configJson) -->
<SiteConfigForm ref="configFormRef" v-model="form.configJson" />
<template #footer>
<Button @click="modalVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="save">保存</Button>
</template>
</Modal>
<!-- 编辑预览弹窗(iframe 加载 /api/theme/preview,no-store) -->
<Modal v-model="previewVisible" title="皮肤预览" :mask-closable="true" width="980px" footer-hide>
<ThemePreviewFrame v-if="previewVisible && editTheme" :site-path="editSitePath" :theme="form.theme" :theme-params="editThemeParams" />
</Modal>
<!-- 新建站点三步向导(B1:校级锁定为本校主站下建院系子站) -->
<SiteWizard v-model:visible="wizardVisible" :sites="rows" :lock-parent-id="isSchoolAdmin ? store.currentSchool?.mainSiteId : undefined" @created="onWizardCreated" />
</div>
</div>
</template>
<style scoped lang="scss">
.form-tip {
color: #999;
font-size: 12px;
line-height: 1.6;
}
.preview-btn {
margin: 8px 0 0;
}
.css-collapse {
margin: 4px 0 12px;
.css-editor :deep(textarea) {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
}
.css-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
color: #999;
font-size: 12px;
line-height: 1.6;
}
}
</style>
+724
View File
@@ -0,0 +1,724 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { siteApi, themeApi } from "@/api/module/cms";
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";
/**
* 站点设置中心(docs/16 主线A 整页化):编辑/新增站点从模态框/三步向导 → 完整页面。
* projman 模式:锚点导航 + 分区卡片(card-header/body/footer 三段式),每分区独立保存。
* 单一 configJson 事实源(§3.5):各分区编辑器只读写自己的键空间(SiteConfigForm 分区作用域
* + HomeLayoutEditor 仅动 home_layout 键),「仅保存本分区」= 校验本分区 → 当前 configJson 全量提交;
* 其他分区未保存的修改仍在内存里,下次任一分区保存会一并带上(卡片 footer 已注明)。
* 创建模式(site/new,D5 向导退役):仅①基本信息②皮肤与外观可编辑,③~⑦锁定置灰,
* 创建成功原地 replace 到 site/setting/{id} 解锁全部分区。
*/
const store = useCmsStore();
const route = useRoute();
const router = useRouter();
const siteIdParam = computed(() => (route.params.id ? Number(route.params.id) : undefined));
const isCreate = computed(() => siteIdParam.value == null);
/** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管行为不变 */
const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool);
const canCreateSite = computed(() => store.isSuperAdmin || isSchoolAdmin.value);
/** 校级锁定在本校主站下建子站(原向导 lockParentId 语义) */
const lockParentId = computed(() => (isCreate.value && isSchoolAdmin.value ? store.currentSchool?.mainSiteId : undefined));
const loading = ref(true);
const loadError = ref("");
const row = ref<SiteVo | null>(null);
const sites = ref<SiteVo[]>([]);
const themes = ref<ThemeVo[]>([]);
/** 分区清单(锚点导航;locked = 创建模式锁定) */
const sections = [
{ key: "basic", title: "基本信息", desc: "编码 / 名称 / 归属 / 状态" },
{ key: "theme", title: "皮肤与外观", desc: "风格选择与微调" },
{ key: "layout", title: "首页布局", desc: "区块序列编排", locked: true },
{ key: "display", title: "基础展示", desc: "品牌标识与图片" },
{ key: "footer", title: "页脚信息", desc: "联系方式与社媒" },
{ key: "seo", title: "SEO 与域名", desc: "收录与独立域名" },
{ key: "extras", title: "扩展参数", desc: "预留键位", locked: true },
];
const activeSection = ref("basic");
let observer: IntersectionObserver | null = null;
/** 单一 configJson 事实源(§3.5) */
const configJson = ref("");
/** ① 基本信息分区 */
const basicRef = ref();
const basic = reactive({
id: undefined as number | undefined,
code: "",
parentSiteId: undefined as number | undefined,
schId: undefined as number | undefined,
schName: "",
name: "",
status: 1,
});
/** ② 皮肤与外观分区 */
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));
/** ③~⑦ 分区表单 ref(分区独立校验) */
const layoutEditorRef = ref();
const displayFormRef = ref();
const footerFormRef = ref();
const seoFormRef = ref();
const extrasFormRef = ref();
const saving = reactive<Record<string, boolean>>({});
const schoolScopedSites = computed(() =>
!store.currentSchool ? sites.value : sites.value.filter(s => s.schId === store.currentSchool!.schId)
);
/** 可选主站:顶层站点且非自身(院系子站层级固定两层);校级锁定本校主站 */
const parentOptions = computed(() => {
if (lockParentId.value) {
return sites.value.filter(s => s.id === lockParentId.value && !s.parentSiteId);
}
return schoolScopedSites.value.filter(s => !s.parentSiteId && s.id !== basic.id);
});
const parentSiteOfBasic = computed(() => sites.value.find(s => s.id === basic.parentSiteId));
const portalPreviewUrl = computed(() =>
isCreate.value
? previewPortalUrl(parentSiteOfBasic.value?.path ?? parentSiteOfBasic.value?.code, basic.code)
: row.value
? portalUrlOf(row.value)
: ""
);
const rules = {
code: [
{ required: true, message: "站点编码不得为空", trigger: "blur" },
{ pattern: /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/, message: "仅限小写字母/数字/短横线", trigger: "blur" },
],
name: [{ required: true, message: "站点名称不得为空", trigger: "blur" }],
};
/** B-8:顶层站 schId 必填(async-validator 默认按 string,数字必选须 type:"number");动态规则挂 FormItem */
const schIdRules = [{ required: true, type: "number" as const, message: "顶层站点必须指定学校ID", trigger: "change" }];
const schIdRequired = computed(() => store.isSuperAdmin && !basic.parentSiteId);
onMounted(async () => {
await Promise.all([loadSites(), loadThemes()]);
if (!isCreate.value) {
initFromRow(siteIdParam.value!);
} else if (lockParentId.value) {
basic.parentSiteId = lockParentId.value;
}
loading.value = false;
nextTick(setupAnchorObserver);
});
onBeforeUnmount(() => observer?.disconnect());
async function loadSites() {
sites.value = (await siteApi.list().send()) || [];
}
async function loadThemes() {
try {
themes.value = (await themeApi.list().send()) || [];
} catch {
themes.value = [];
}
}
function initFromRow(id: number) {
const r = sites.value.find(s => s.id === id);
if (!r) {
loadError.value = "站点不存在或无权管理该站点";
return;
}
row.value = r;
// 主题值保留原值(未注册时告警,由服务端校验拒绝),不再静默改写为 default
const resolved = resolveEditableTheme(r.theme, themes.value.map(t => t.name));
themeUnregistered.value = resolved.unregistered;
Object.assign(basic, {
id: r.id,
code: r.code,
parentSiteId: r.parentSiteId || undefined,
schId: r.schId,
schName: r.schName || "",
name: r.name,
status: r.status,
});
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 {
try {
const obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
return obj && typeof obj === "object" && typeof obj.custom_css === "string" ? obj.custom_css : "";
} catch {
return "";
}
}
/** 把面板值写入/移出 configJson(空值移除键;与 SiteConfigForm 的 preserved 往返兼容) */
function withCustomCss(configJson: string, css: string): string {
let obj: Record<string, unknown>;
try {
obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
} catch {
obj = {};
}
if (css.trim()) obj.custom_css = css;
else delete obj.custom_css;
return JSON.stringify(obj);
}
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() {
previewVisible.value = true;
}
/**
* 统一提交(§3.5 分区独立保存模型):payload = 页面当前事实源 + 基本信息快照;
* patch.configJson 供分区保存前合并自己的键空间(如皮肤分区的 theme_params/custom_css)。
* 创建成功原地 replace 到 site/setting/{id} 并解锁全部分区。
*/
async function submitSection(key: string, patch: { configJson?: string }) {
saving[key] = true;
try {
const res = await siteApi
.save({
id: basic.id,
code: basic.code,
parentSiteId: basic.parentSiteId || 0,
// B1:校级角色隐藏学校信息字段,留空由服务端继承主站(防伪造通道)
schId: isSchoolAdmin.value ? undefined : basic.schId,
schName: isSchoolAdmin.value ? undefined : basic.schName || undefined,
name: basic.name,
theme: theme.value || undefined,
configJson: patch.configJson ?? configJson.value,
status: basic.status,
})
.send();
if (isCreate.value && res && res.id) {
Message.success("站点创建成功!全部分区已开放");
await loadSites();
const main = sites.value.find(s => s.id === (res.parentSiteId || 0));
const schCode = main?.code || res.code;
await router.replace(adminPathOf(schCode, res.parentSiteId ? res.code : null, `site/setting/${res.id}`));
initFromRow(res.id);
} else {
Message.success("保存成功!");
if (res) {
row.value = res;
// 服务端归一后的 configJson 刷新事实源(theme_params 合并等)
if (typeof res.configJson === "string") {
configJson.value = res.configJson;
customCss.value = readCustomCss(configJson.value);
}
}
}
} finally {
saving[key] = false;
}
}
async function saveBasic() {
const valid = await basicRef.value?.validate();
if (!valid) return;
await submitSection("basic", {});
}
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 });
}
async function saveDisplay() {
if (!(await displayFormRef.value?.validate())) return;
await submitSection("display", {});
}
async function saveFooter() {
if (!(await footerFormRef.value?.validate())) return;
await submitSection("footer", {});
}
async function saveSeo() {
if (!(await seoFormRef.value?.validate())) return;
await submitSection("seo", {});
}
async function saveExtras() {
if (!(await extrasFormRef.value?.validate())) return;
await submitSection("extras", {});
}
async function saveLayout() {
const err = layoutEditorRef.value?.validate();
if (err) {
Message.error(err);
return;
}
await submitSection("layout", {});
}
/** 门户预览(编辑模式):新窗口打开真实门户 */
function openPortal() {
if (!row.value) return;
window.open(`${config.serviceHost}${portalUrlOf(row.value)}`, "_blank");
}
function backToList() {
const main = row.value?.parentSiteId ? sites.value.find(s => s.id === row.value!.parentSiteId) : row.value;
const schCode = main?.code || store.schoolCode;
if (!schCode) {
router.push("/workbench");
return;
}
router.push(adminPathOf(schCode, row.value?.parentSiteId ? row.value.code : null, "site"));
}
function setupAnchorObserver() {
if (typeof IntersectionObserver === "undefined") return;
observer = new IntersectionObserver(
entries => {
entries.forEach(e => {
if (e.isIntersecting) {
activeSection.value = (e.target as HTMLElement).dataset.section || activeSection.value;
}
});
},
{ rootMargin: "-90px 0px -55% 0px", threshold: 0 },
);
sections.forEach(s => {
const el = document.getElementById(`sec-${s.key}`);
if (el) observer!.observe(el);
});
}
function scrollToSection(key: string) {
activeSection.value = key;
document.getElementById(`sec-${key}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
// 学校上下文切换即时重载(编辑中的站点归属变化防御)
watch(() => store.currentSchool?.schId, () => loadSites());
</script>
<template>
<div class="site-setting">
<div class="main-container">
<!-- 页头 -->
<div class="ss-header">
<div class="ss-title">
<h3>{{ isCreate ? (isSchoolAdmin ? "新建院系子站" : "新建站点") : basic.name || "站点设置" }}</h3>
<template v-if="!isCreate">
<Tag>{{ basic.code }}</Tag>
<Tag :color="basic.status === 1 ? 'success' : 'default'">{{ basic.status === 1 ? "启用" : "停用" }}</Tag>
</template>
</div>
<div class="ss-actions">
<Button v-if="!isCreate" icon="ios-browsers" @click="openPortal">门户预览</Button>
<Button icon="md-arrow-back" @click="backToList">返回列表</Button>
</div>
</div>
<Alert v-if="loadError" type="error" show-icon style="margin-bottom: 12px">{{ loadError }}</Alert>
<Spin v-if="loading" size="large" fix />
<div class="ss-layout">
<!-- 锚点导航 -->
<aside class="ss-nav">
<div
v-for="s in sections"
:key="s.key"
class="ss-nav-item"
:class="{ active: activeSection === s.key }"
@click="scrollToSection(s.key)"
>
<span class="ss-nav-title">
{{ s.title }}
<Icon v-if="isCreate && s.locked" type="md-lock" class="ss-nav-lock" />
</span>
<span class="ss-nav-desc">{{ s.desc }}</span>
</div>
</aside>
<!-- 分区卡片流 -->
<div class="ss-cards">
<!-- ① 基本信息 -->
<div id="sec-basic" class="card setting-card" data-section="basic">
<div class="card-header">
<span class="card-title">基本信息</span>
<span class="card-subtitle">站点编码 / 名称 / 归属与状态</span>
</div>
<div class="card-body">
<Form ref="basicRef" :model="basic" :rules="rules" :label-width="110">
<FormItem label="站点编码" prop="code">
<Input v-model="basic.code" :disabled="!isCreate" placeholder="URL 路径用,如 demo" style="max-width: 320px" />
<div v-if="!isCreate" class="form-hint">编码创建后不可修改(URL 末段)</div>
</FormItem>
<FormItem label="站点名称" prop="name">
<Input v-model="basic.name" :maxlength="128" placeholder="如 XX学校门户网站" style="max-width: 320px" />
</FormItem>
<FormItem label="上级站点">
<Select v-model="basic.parentSiteId" :clearable="!lockParentId" :disabled="!!lockParentId" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
</Select>
<p class="form-hint">
院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。
{{ isSchoolAdmin ? "校级角色仅可在本校主站之间改挂。" : "" }}
</p>
</FormItem>
<FormItem v-if="store.isSuperAdmin" label="学校ID" prop="schId" :rules="schIdRequired ? schIdRules : []">
<InputNumber v-model="basic.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
<div v-if="schIdRequired" class="form-hint">顶层站点必填(否则校级角色不可管理);院系子站可留空自动继承主站</div>
</FormItem>
<FormItem v-if="store.isSuperAdmin" label="学校名称">
<Input v-model="basic.schName" :maxlength="128" style="max-width: 320px" />
</FormItem>
<FormItem label="门户地址预览">
<span class="ss-portal-url">{{ portalPreviewUrl || "输入编码后自动生成" }}</span>
<div class="form-hint">子站地址 = 上级地址 / 编码;创建后经站点管理「门户路径」列直达</div>
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="basic.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.basic" @click="saveBasic">
{{ isCreate ? "创建站点" : "仅保存基本信息" }}
</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
<!-- ② 皮肤与外观 -->
<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>
</div>
<div class="card-body">
<div class="field-block">
<label class="field-label">皮肤包</label>
<Alert v-if="themeUnregistered" type="warning" show-icon style="margin-bottom: 8px">
皮肤包「{{ theme }}」未在注册表中,保存将被服务端拒绝;请先由平台超管上传该皮肤包,或改选下方其他皮肤。
</Alert>
<Alert v-else-if="!themes.length" type="warning" show-icon style="margin-bottom: 8px">
皮肤包列表加载失败,暂时无法选择;站点当前皮肤包({{ theme }})保持不变,可稍后重试。
</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" />
<p class="form-hint">换肤/改参数保存后门户立即生效(缓存已自动失效)。</p>
</div>
<!-- B3 自定义样式(config_json.custom_css,服务端黑名单强校验) -->
<Collapse class="css-collapse">
<Panel name="css" header="自定义样式(站点级 CSS 覆盖)">
<Input
v-model="customCss"
type="textarea"
:rows="6"
:maxlength="10240"
placeholder="如:--primary:#59aeeb; 或 .site-header{height:80px}"
class="css-editor"
/>
<div class="css-meta">
<span>{{ customCss.length }}/10240 字符</span>
<span>保存后门户立即生效(缓存自动失效);服务端将拒绝 &lt;/style、&lt;script、@import、expression( 等危险内容</span>
</div>
</Panel>
</Collapse>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.theme" @click="saveTheme">仅保存皮肤与外观</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
<!-- ③ 首页布局 -->
<div id="sec-layout" class="card setting-card" :class="{ locked: isCreate }" data-section="layout">
<div class="card-header">
<span class="card-title">首页布局</span>
<span class="card-subtitle">首页 = 可编排的区块序列(要闻/栏目文章/画廊/链接组)</span>
</div>
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
<div class="card-body">
<HomeLayoutEditor v-model="configJson" :site-id="basic.id" ref="layoutEditorRef" @save="saveLayout" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.layout" :disabled="isCreate" @click="saveLayout">仅保存首页布局</Button>
<span class="card-hint">保存后门户立即生效(缓存自动失效);未编排布局时门户按「全部栏目自动渲染」默认形态展示</span>
</div>
</div>
<!-- ④ 基础展示 -->
<div id="sec-display" class="card setting-card" :class="{ locked: isCreate }" data-section="display">
<div class="card-header">
<span class="card-title">基础展示</span>
<span class="card-subtitle">Logo / favicon / 标语 / 院系文案(图片支持直接上传)</span>
</div>
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
<div class="card-body">
<SiteConfigForm ref="displayFormRef" v-model="configJson" :groups="['display']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.display" :disabled="isCreate" @click="saveDisplay">仅保存基础展示</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
<!-- ⑤ 页脚信息 -->
<div id="sec-footer" class="card setting-card" :class="{ locked: isCreate }" data-section="footer">
<div class="card-header">
<span class="card-title">页脚信息</span>
<span class="card-subtitle">联系方式 / 多校区地址 / 二维码 / 社媒 / 版权备案</span>
</div>
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
<div class="card-body">
<SiteConfigForm ref="footerFormRef" v-model="configJson" :groups="['footer']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.footer" :disabled="isCreate" @click="saveFooter">仅保存页脚信息</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
<!-- ⑥ SEO 与域名 -->
<div id="sec-seo" class="card setting-card" :class="{ locked: isCreate }" data-section="seo">
<div class="card-header">
<span class="card-title">SEO 与域名</span>
<span class="card-subtitle">搜索引擎摘要 / 独立域名台账与对外基地址</span>
</div>
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
<div class="card-body">
<SiteConfigForm ref="seoFormRef" v-model="configJson" :groups="['seo', 'domain']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.seo" :disabled="isCreate" @click="saveSeo">仅保存 SEO 与域名</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
<!-- ⑦ 扩展参数 -->
<div id="sec-extras" class="card setting-card" :class="{ locked: isCreate }" data-section="extras">
<div class="card-header">
<span class="card-title">扩展参数</span>
<span class="card-subtitle">面向后续扩展的预留配置键</span>
</div>
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
<div class="card-body">
<SiteConfigForm ref="extrasFormRef" v-model="configJson" show-extras />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.extras" :disabled="isCreate" @click="saveExtras">仅保存扩展参数</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
</div>
</div>
</div>
<!-- 皮肤预览弹窗(iframe 加载 /api/theme/preview,no-store) -->
<Modal v-model="previewVisible" title="皮肤预览" :mask-closable="true" width="980px" footer-hide>
<ThemePreviewFrame
v-if="previewVisible && editTheme && row"
:site-path="row.path || row.code"
:theme="theme"
:theme-params="themeParams"
/>
</Modal>
</div>
</template>
<style scoped lang="scss">
.site-setting {
position: relative;
min-height: 400px;
.ss-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
.ss-title {
display: flex;
align-items: center;
gap: 10px;
h3 {
margin: 0;
}
}
}
.ss-layout {
display: flex;
gap: 16px;
align-items: flex-start;
}
.ss-nav {
width: 180px;
flex: none;
position: sticky;
top: 84px;
display: flex;
flex-direction: column;
gap: 4px;
.ss-nav-item {
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f0faff;
}
&.active {
background: #e8f4ff;
.ss-nav-title {
color: #2d8cf0;
}
}
.ss-nav-title {
display: block;
font-size: 13px;
font-weight: 600;
color: #515a6e;
}
.ss-nav-lock {
color: #c5c8ce;
font-size: 12px;
}
.ss-nav-desc {
display: block;
color: #999;
font-size: 12px;
margin-top: 2px;
}
}
}
.ss-cards {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.setting-card {
border: 1px solid #e8eaec;
border-radius: 8px;
background: #fff;
padding: 16px 18px;
scroll-margin-top: 76px;
&.locked {
.card-body {
opacity: 0.55;
pointer-events: none;
}
}
.card-header {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f2f5;
.card-title {
font-size: 15px;
font-weight: 600;
color: #17233d;
}
.card-subtitle {
font-size: 12px;
color: #999;
}
}
.card-footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #f0f2f5;
.card-hint {
color: #999;
font-size: 12px;
line-height: 1.5;
}
}
.ss-lock-tip {
margin-bottom: 12px;
}
}
.form-hint {
color: #999;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
.field-block {
margin-bottom: 12px;
.field-label {
display: block;
font-size: 13px;
color: #515a6e;
margin-bottom: 8px;
font-weight: 600;
}
}
.ss-portal-url {
color: #2d8cf0;
font-family: Consolas, Menlo, monospace;
word-break: break-all;
}
.preview-btn {
margin: 8px 0 0;
}
.css-collapse {
margin: 4px 0 12px;
.css-editor :deep(textarea) {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
}
.css-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
color: #999;
font-size: 12px;
line-height: 1.6;
}
}
}
</style>
-314
View File
@@ -1,314 +0,0 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { siteApi, themeApi } from "@/api/module/cms";
import { SiteVo, ThemeVo } from "@/types/cms";
import { formModelFromTheme, FormModelValue, mergeThemeParamsIntoConfigJson } from "@/utils/themeParamForm";
import { previewPortalUrl } from "@/utils/sitePath";
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";
/**
* 新建站点三步向导(方案 §6.1):①选模板 → ②微调+实时预览 → ③站点信息+确认。
* 预览内容源:当前启用站点(默认第一个);无启用站点时隐藏预览并提示。
* 向导中途关闭不产生任何数据;确认后创建成功才落库。
*/
const props = defineProps<{
visible: boolean;
sites: SiteVo[];
/** B1 建站权限拆分:校级锁定在本校主站下建院系子站(上级禁改、学校信息由服务端继承) */
lockParentId?: number;
}>();
const emit = defineEmits<{
(e: "update:visible", v: boolean): void;
(e: "created"): void;
}>();
const step = ref(1);
const themes = ref<ThemeVo[]>([]);
const themesLoading = ref(false);
const form = reactive({
code: "",
parentSiteId: undefined as number | undefined,
schId: undefined as number | undefined,
schName: "",
name: "",
status: 1,
configJson: "",
});
const themeName = ref("default");
const themeParams = ref<FormModelValue>({});
const sourceSitePath = ref<string>("");
const saving = ref(false);
/** 内容源候选:启用中的站点(顶层/子站皆可作内容源) */
const sourceSites = computed(() => props.sites.filter(s => s.status === 1));
const selectedTheme = computed(() => themes.value.find(t => t.name === themeName.value));
const canPreview = computed(() => !!sourceSitePath.value && !!themeName.value);
const previewTick = ref(0);
const previewParams = ref<FormModelValue>({});
/** 锁定模式:上级仅本校主站;否则全部顶层站可选 */
const parentOptions = computed(() =>
props.lockParentId
? props.sites.filter(s => s.id === props.lockParentId && !s.parentSiteId)
: props.sites.filter(s => !s.parentSiteId)
);
/** W4(G5):门户地址预览——随上级与编码输入联动(子站=上级地址/编码;顶层=编码) */
const wizardParent = computed(() =>
form.parentSiteId ? props.sites.find(s => s.id === form.parentSiteId) : undefined
);
const previewUrl = computed(() =>
previewPortalUrl(wizardParent.value?.path ?? wizardParent.value?.code, form.code)
);
function debouncePreview() {
previewParams.value = { ...themeParams.value };
previewTick.value = Date.now();
}
watch(themeName, async () => {
const t = selectedTheme.value;
if (t) themeParams.value = formModelFromTheme(t);
debouncePreview();
});
watch(themeParams, debouncePreview, { deep: true });
watch(
() => props.visible,
async v => {
if (!v) return;
step.value = 1;
Object.assign(form, { code: "", parentSiteId: props.lockParentId, schId: undefined, schName: "", name: "", status: 1, configJson: "" });
sourceSitePath.value = sourceSites.value[0]?.path || sourceSites.value[0]?.code || "";
if (!themes.value.length) await loadThemes();
themeName.value = "default";
const t = selectedTheme.value;
themeParams.value = t ? formModelFromTheme(t) : {};
debouncePreview();
},
);
async function loadThemes() {
themesLoading.value = true;
try {
themes.value = (await themeApi.list().send()) || [];
if (!themes.value.some(t => t.name === "default") && themes.value.length) {
themeName.value = themes.value[0].name;
}
} finally {
themesLoading.value = false;
}
}
function pickTheme(name: string) {
themeName.value = name;
}
function next() {
// 站点信息表单在第 3 步才挂载,步进本身不校验;校验放在「创建站点」时(见 createSite)
step.value += 1;
}
function prev() {
if (step.value > 1) step.value -= 1;
}
const formRef = ref();
const configFormRef = ref();
const rules = {
code: [
{ required: true, message: "站点编码不得为空", trigger: "blur" },
{ pattern: /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/, message: "仅限小写字母/数字/短横线", trigger: "blur" },
],
name: [{ required: true, message: "站点名称不得为空", trigger: "blur" }],
};
async function createSite() {
const valid = await formRef.value?.validate();
if (!valid) return;
const configValid = await configFormRef.value?.validate();
if (!configValid) return;
saving.value = true;
try {
const configJson = mergeThemeParamsIntoConfigJson(form.configJson || "", selectedTheme.value!, themeParams.value);
await siteApi
.save({
code: form.code,
parentSiteId: form.parentSiteId || 0,
// B1:校级锁定模式学校信息留空,服务端校验后继承主站(防伪造)
schId: props.lockParentId ? undefined : form.schId,
schName: props.lockParentId ? undefined : form.schName || undefined,
name: form.name,
theme: themeName.value,
configJson,
status: form.status,
})
.send();
Message.success("站点创建成功!");
emit("update:visible", false);
emit("created");
} finally {
saving.value = false;
}
}
</script>
<template>
<Modal
:model-value="visible"
:title="`${props.lockParentId ? '新建院系子站' : '新建站点'}向导 · 第 ${step} / 3 步`"
:mask-closable="false"
width="980px"
@update:model-value="emit('update:visible', $event)"
@on-cancel="emit('update:visible', false)"
>
<div class="wizard">
<!-- ① 选模板 -->
<div v-if="step === 1" class="wizard-step">
<p class="step-tip">选择一个皮肤包作为网站风格(后续可在站点管理中微调主色等参数)。</p>
<ThemePicker v-if="themes.length" :themes="themes" :model-value="themeName" @update:model-value="pickTheme" />
<p v-else-if="themesLoading" class="loading">主题列表加载中…</p>
<p v-else class="loading">主题列表为空 —— 请检查后端 /api/theme/list 是否可用。</p>
</div>
<!-- ② 微调 + 实时预览 -->
<div v-else-if="step === 2" class="wizard-step step2">
<div class="left">
<FormItem label="预览内容源" :label-width="90">
<Select v-model="sourceSitePath" style="width: 260px">
<Option v-for="s in sourceSites" :key="s.id" :value="s.path || s.code">{{ s.name }}({{ s.path || s.code }})</Option>
</Select>
</FormItem>
<ThemeParamForm :theme="selectedTheme" v-model="themeParams" />
</div>
<div class="right">
<ThemePreviewFrame
v-if="canPreview"
:site-path="sourceSitePath"
:theme="themeName"
:key="previewTick"
:theme-params="previewParams"
/>
<div v-else class="no-preview">
暂无启用站点可作为内容源 —— 请先在站点管理创建一个启用站点,或初始化演示数据。
</div>
</div>
</div>
<!-- ③ 站点信息 + 确认 -->
<div v-else class="wizard-step step3">
<p class="step-tip">
站点将使用皮肤包 <b>{{ selectedTheme?.title }}</b>({{ themeName }}),并携带上述微调参数。
</p>
<Form ref="formRef" :model="form" :rules="rules" :label-width="110" class="info-form">
<FormItem label="站点编码" prop="code">
<Input v-model="form.code" placeholder="URL 路径用,如 demo" style="width: 320px" />
</FormItem>
<FormItem label="站点名称" prop="name">
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" style="width: 320px" />
</FormItem>
<FormItem label="门户地址预览">
<span class="portal-preview-url">{{ previewUrl || "输入编码后自动生成" }}</span>
<div class="pbu-tip">子站地址 = 上级地址 / 编码;创建后经站点管理「门户路径」列直达。</div>
</FormItem>
<FormItem label="上级站点">
<Select v-model="form.parentSiteId" :disabled="!!props.lockParentId" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
</Select>
<div v-if="props.lockParentId" class="pbu-tip">校级角色仅可在本校主站下创建院系子站,学校信息自动继承主站。</div>
</FormItem>
<FormItem v-if="!props.lockParentId" label="学校ID">
<InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
<!-- B-8:顶层站 schId 必填(校级角色按此匹配管理权限);院系子站留空自动继承主站 -->
<div class="pbu-tip">
顶层站点必填(否则建成即校级角色不可管理);选了上级站点的院系子站可留空,自动继承主站。
</div>
</FormItem>
<FormItem v-if="!props.lockParentId" label="学校名称">
<Input v-model="form.schName" :maxlength="128" style="width: 320px" />
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="form.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
<!-- 站点配置结构化表单(docs/09 §3.1,替代原 JSON textarea) -->
<SiteConfigForm ref="configFormRef" v-model="form.configJson" class="cfg-form" />
</div>
</div>
<template #footer>
<Button v-if="step > 1" @click="prev">上一步</Button>
<Button v-if="step < 3" type="primary" :disabled="!themes.length" @click="next">下一步</Button>
<Button v-else type="primary" :loading="saving" @click="createSite">创建站点</Button>
</template>
</Modal>
</template>
<style scoped lang="scss">
.wizard-step {
min-height: 300px;
}
.step-tip {
color: #888;
font-size: 13px;
margin: 0 0 12px;
}
.pbu-tip {
color: #888;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
.loading {
color: #999;
font-size: 13px;
text-align: center;
padding: 60px 0;
}
.step2 {
display: flex;
gap: 20px;
.left {
width: 340px;
flex: none;
}
.right {
flex: 1;
min-width: 0;
}
}
.no-preview {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 13px;
border: 1px dashed #e5e6eb;
border-radius: 6px;
}
.info-form {
margin-top: 4px;
}
.portal-preview-url {
color: #2d8cf0;
font-family: Consolas, Menlo, monospace;
word-break: break-all;
}
.cfg-form {
margin-top: 12px;
padding-top: 4px;
border-top: 1px dashed #e5e6eb;
}
</style>
+51 -24
View File
@@ -7,21 +7,38 @@ import {
SiteConfigExtraRow,
SiteConfigFieldDef,
SiteConfigFormModel,
SiteConfigGroup,
extraRowError,
firstExtraError,
parseSiteConfig,
serializeSiteConfig,
validateFieldValue,
} from "@/utils/siteConfigForm";
import ImageAssetField from "@/components/ImageAssetField.vue";
/**
* 站点配置结构化表单(docs/09 §3.1):v-model 直接绑定 configJson 字符串。
* 内部 parse/serialize 双向映射;已知键分组表单 + 扩展参数动态行;
* 站点配置结构化表单(docs/09 §3.1;docs/16 §3.3 分区作用域扩展):
* v-model 绑定整页共享的 configJson 字符串(单一事实源)。
* props.groups 声明本分区渲染的分组;props.showExtras 控制扩展参数编辑区(每页仅一处)。
* 模型仍完整解析全部键(fields/extras/preserved 三通道),仅渲染限定分组——
* 序列化时未渲染分区的值原样回写,保证多分区实例绑定同一 configJson 互不踩踏。
* 保存前由父级调用 validate()(含扩展行兜底校验)。
*/
const props = defineProps<{ modelValue: string }>();
const props = defineProps<{
modelValue: string;
/** 渲染的配置分组(缺省=全部分组,兼容旧整表单用法) */
groups?: SiteConfigGroup[];
/** 是否渲染扩展参数编辑区(docs/16:仅「扩展参数」分区开启) */
showExtras?: boolean;
/** 图片类字段上传归属站点(附件场景引用) */
siteId?: number;
}>();
const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>();
/** 本实例渲染的分组集合(空=全部) */
const groupSet = (() => new Set(props.groups || SITE_CONFIG_GROUPS.map(g => g.name)))();
const visibleGroups = SITE_CONFIG_GROUPS.filter(g => groupSet.has(g.name));
const formRef = ref();
const formModel = reactive<{ fields: Record<string, string>; extras: SiteConfigExtraRow[] }>({
fields: {},
@@ -65,14 +82,16 @@ function removeExtra(i: number) {
emitUp();
}
/** 保存前校验:内置字段 Form 规则 + 扩展行兜底;通过返回 true */
/** 保存前校验:内置字段 Form 规则 + 扩展行兜底(仅渲染扩展区的实例校验,避免越区拦截);通过返回 true */
async function validate(): Promise<boolean> {
const valid = await formRef.value?.validate();
if (!valid) return false;
const extraMsg = firstExtraError(formModel.extras);
if (extraMsg) {
Message.error(`扩展参数有误:${extraMsg}`);
return false;
if (props.showExtras) {
const extraMsg = firstExtraError(formModel.extras);
if (extraMsg) {
Message.error(`扩展参数有误:${extraMsg}`);
return false;
}
}
return true;
}
@@ -93,13 +112,12 @@ function rulesFor(def: SiteConfigFieldDef) {
function defsOf(group: string): SiteConfigFieldDef[] {
return SITE_CONFIG_FIELD_DEFS.filter(d => d.group === group);
}
</script>
}</script>
<template>
<div class="site-config-form">
<Form ref="formRef" :model="formModel" :label-width="130">
<template v-for="g in SITE_CONFIG_GROUPS" :key="g.name">
<template v-for="g in visibleGroups" :key="g.name">
<div class="group-title">{{ g.title }}</div>
<FormItem
v-for="def in defsOf(g.name)"
@@ -108,8 +126,15 @@ function defsOf(group: string): SiteConfigFieldDef[] {
:prop="`fields.${def.key}`"
:rules="rulesFor(def)"
>
<ImageAssetField
v-if="def.kind === 'asset'"
v-model="formModel.fields[def.key]"
:site-id="siteId"
:placeholder="def.placeholder"
@update:model-value="emitUp"
/>
<Input
v-if="!def.multiline"
v-else-if="!def.multiline"
v-model="formModel.fields[def.key]"
:placeholder="def.placeholder"
@on-change="emitUp"
@@ -126,19 +151,21 @@ function defsOf(group: string): SiteConfigFieldDef[] {
</FormItem>
</template>
<div class="group-title">扩展参数</div>
<div v-for="(row, i) in formModel.extras" :key="i" class="extra-row">
<Input v-model="row.key" placeholder="参数名" style="width: 200px" @on-change="emitUp" />
<Input v-model="row.value" placeholder="参数值" style="flex: 1" @on-change="emitUp" />
<Button type="text" class="extra-del" icon="md-trash" @click="removeExtra(i)" />
<p v-if="extraRowError(formModel.extras, i)" class="cfg-error">
{{ extraRowError(formModel.extras, i) }}
<template v-if="showExtras">
<div class="group-title">扩展参数</div>
<div v-for="(row, i) in formModel.extras" :key="i" class="extra-row">
<Input v-model="row.key" placeholder="参数名" style="width: 200px" @on-change="emitUp" />
<Input v-model="row.value" placeholder="参数值" style="flex: 1" @on-change="emitUp" />
<Button type="text" class="extra-del" icon="md-trash" @click="removeExtra(i)" />
<p v-if="extraRowError(formModel.extras, i)" class="cfg-error">
{{ extraRowError(formModel.extras, i) }}
</p>
</div>
<Button type="dashed" long icon="md-add" class="extra-add" @click="addExtra">新增参数</Button>
<p class="cfg-tip">
面向后续扩展的预留配置项(键名仅字母/数字/下划线);系统托管参数(如 theme_params)不在此显示,保存时自动保留。
</p>
</div>
<Button type="dashed" long icon="md-add" class="extra-add" @click="addExtra">新增参数</Button>
<p class="cfg-tip">
面向后续扩展的预留配置项(键名仅字母/数字/下划线);系统托管参数(如 theme_params)不在此显示,保存时自动保留。
</p>
</template>
</Form>
</div>
</template>
@@ -0,0 +1,506 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { categoryApi } from "@/api/module/cms";
import { CategoryTreeNode } from "@/types/cms";
import MyConfirm from "@/components/MyConfirm";
import {
BlockType,
HOME_LAYOUT_VERSION,
LayoutBlock,
flattenListCategories,
genBlockId,
genFromCategories,
parseHomeLayout,
serializeHomeLayout,
validateHomeLayout,
} from "./homeLayout";
import { BLOCK_TYPES, blockDefOf, blockSummary } from "./blockTypes";
/**
* 首页布局编辑器(docs/16 主线B §4.3):嵌入站点设置「首页布局」分区。
* v-model 绑定整页共享的 configJson(仅读写 home_layout 键,其余键无损回写)。
* 左侧区块序列(上移/下移/隐藏/删除)+ 右侧属性面板(注册表 schema 驱动);
* 添加抽屉列组件库类型卡;「从当前栏目一键生成」复刻现状渲染为初始序列(首用救生索)。
* 保存由父分区发起(emit("save")),校验经 defineExpose.validate()。
*/
const props = defineProps<{
modelValue: string;
/** 布局数据源栏目树归属站点(编辑模式必传) */
siteId?: number;
}>();
const emit = defineEmits<{
(e: "update:modelValue", v: string): void;
(e: "save"): void;
}>();
const blocks = ref<LayoutBlock[]>([]);
const selectedId = ref<string | null>(null);
const tree = ref<CategoryTreeNode[]>([]);
const treeLoading = ref(false);
const addOpen = ref(false);
const flat = computed(() => flattenListCategories(tree.value));
const nameOf = (id: number) => flat.value.find(c => c.id === id)?.name || "";
/** 属性面板按注册表 schema 动态取值/赋值(索引签名放宽,v-model 直绑) */
type EditableBlock = LayoutBlock & Record<string, unknown>;
const selected = computed<EditableBlock | null>(() =>
(blocks.value.find(b => b.id === selectedId.value) as EditableBlock) || null
);
const selectedDef = computed(() => (selected.value ? blockDefOf(selected.value.type) : undefined));
const visibleFields = computed(() =>
(selectedDef.value?.propFields || []).filter(f => !f.showIf || (selected.value && f.showIf(selected.value)))
);
let lastEmitted: string | null = null;
function emitUp() {
const current = parseHomeLayout(props.modelValue);
const layout = blocks.value.length
? { version: HOME_LAYOUT_VERSION, blocks: blocks.value.map(b => ({ ...b })) }
: current
? { version: HOME_LAYOUT_VERSION, blocks: [] }
: null;
lastEmitted = serializeHomeLayout(props.modelValue, layout);
emit("update:modelValue", lastEmitted);
}
watch(
() => props.modelValue,
v => {
if (v === lastEmitted) return;
const layout = parseHomeLayout(v);
blocks.value = layout ? layout.blocks : [];
if (selectedId.value && !blocks.value.some(b => b.id === selectedId.value)) {
selectedId.value = null;
}
},
{ immediate: true },
);
async function loadTree() {
if (!props.siteId) return;
treeLoading.value = true;
try {
tree.value = (await categoryApi.tree(props.siteId).send()) || [];
} catch {
tree.value = [];
} finally {
treeLoading.value = false;
}
}
onMounted(loadTree);
function addBlock(type: string) {
const def = blockDefOf(type);
if (!def) return;
const block = { id: genBlockId(), type: type as BlockType, visible: true, ...def.defaultProps() } as LayoutBlock;
blocks.value.push(block);
selectedId.value = block.id;
addOpen.value = false;
emitUp();
}
function ensureTreeThenGen() {
const doGen = () => {
blocks.value = genFromCategories(tree.value, props.modelValue).blocks;
selectedId.value = null;
emitUp();
};
if (tree.value.length) {
doGen();
return;
}
loadTree().then(doGen);
}
function genFromCurrent() {
if (!blocks.value.length) {
ensureTreeThenGen();
return;
}
MyConfirm({
title: "从当前栏目一键生成",
msg: "将按现有栏目结构重建初始布局(覆盖当前区块序列)。继续吗?",
onOk: () => ensureTreeThenGen(),
});
}
/** 清除自定义布局:删除 home_layout 键,回到回落态(无布局 = 全栏目自动渲染) */
function clearLayout() {
MyConfirm({
title: "清除自定义布局",
msg: "删除首页布局配置后,门户将回到「全部栏目自动渲染」的默认形态。继续吗?",
onOk: () => {
blocks.value = [];
selectedId.value = null;
lastEmitted = serializeHomeLayout(props.modelValue, null);
emit("update:modelValue", lastEmitted);
},
});
}
function move(index: number, offset: number) {
const target = index + offset;
if (target < 0 || target >= blocks.value.length) return;
const list = blocks.value;
list.splice(target, 0, list.splice(index, 1)[0]);
emitUp();
}
function toggleVisible(block: LayoutBlock) {
block.visible = !block.visible;
emitUp();
}
function removeBlock(index: number) {
const [gone] = blocks.value.splice(index, 1);
if (gone && gone.id === selectedId.value) {
selectedId.value = null;
}
emitUp();
}
function toggleCategory(block: LayoutBlock, id: number) {
const ids = block.categoryIds || (block.categoryIds = []);
const index = ids.indexOf(id);
if (index >= 0) {
ids.splice(index, 1);
} else {
ids.push(id);
}
emitUp();
}
function onFieldChange() {
emitUp();
}
/** 保存前校验(父分区调用):返回 null=通过,否则错误消息串 */
function validate(): string | null {
const errors = validateHomeLayout({ version: HOME_LAYOUT_VERSION, blocks: blocks.value });
return errors.length ? errors.join(";") : null;
}
defineExpose({ validate });
</script>
<template>
<div class="home-layout-editor">
<div class="hle-toolbar">
<Button type="primary" icon="md-add" @click="addOpen = true">添加区块</Button>
<Button icon="ios-flash" class="hle-gen" @click="genFromCurrent">从当前栏目一键生成</Button>
<Button v-if="blocks.length" type="text" class="hle-clear" icon="ios-undo" @click="clearLayout">
清除自定义布局
</Button>
<span class="hle-hint">顶部焦点轮播为页面固定槽位(轮播管理维护),不参与此序列编排</span>
</div>
<!-- 空布局引导 -->
<div v-if="!blocks.length" class="hle-empty">
<p>尚未编排首页布局 —— 门户按「全部栏目自动渲染」的默认形态展示。</p>
<p class="hle-empty-sub">点「从当前栏目一键生成」得到与现状等价的初始序列,再删改微调;或从空白开始添加区块。</p>
</div>
<!-- 序列 + 属性面板 -->
<div v-else class="hle-body">
<div class="hle-sequence">
<div
v-for="(b, i) in blocks"
:key="b.id"
class="hle-row"
:class="{ active: b.id === selectedId, hidden: !b.visible }"
@click="selectedId = b.id"
>
<span class="hle-row-main">
<Icon :type="blockDefOf(b.type)?.icon || 'md-square'" class="hle-row-icon" />
<span class="hle-row-title">{{ b.title || blockDefOf(b.type)?.title || b.type }}</span>
<Tag v-if="!b.visible" color="default" class="hle-row-badge">已隐藏</Tag>
</span>
<span class="hle-row-sub">{{ blockSummary(b, nameOf) }}</span>
<span class="hle-row-ops" @click.stop>
<Button size="small" type="text" icon="ios-arrow-up" :disabled="i === 0" @click="move(i, -1)" />
<Button size="small" type="text" icon="ios-arrow-down" :disabled="i === blocks.length - 1" @click="move(i, 1)" />
<Button size="small" type="text" :icon="b.visible ? 'md-eye' : 'md-eye-off'" @click="toggleVisible(b)" />
<Button size="small" type="text" icon="md-trash" class="hle-del" @click="removeBlock(i)" />
</span>
</div>
</div>
<div class="hle-props">
<template v-if="selected && selectedDef">
<div class="hle-props-head">
<Icon :type="selectedDef.icon" />
<span>{{ selectedDef.title }}</span>
<span class="hle-props-desc">{{ selectedDef.description }}</span>
</div>
<div v-for="f in visibleFields" :key="f.key" class="hle-field">
<label class="hle-field-label">{{ f.label }}</label>
<Input
v-if="f.kind === 'text'"
v-model="selected[f.key]"
:maxlength="100"
:placeholder="f.placeholder"
@on-change="onFieldChange"
/>
<Select
v-else-if="f.kind === 'select'"
v-model="selected[f.key]"
:placeholder="f.placeholder"
@on-change="onFieldChange"
>
<Option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select>
<InputNumber
v-else-if="f.kind === 'number'"
v-model="selected[f.key]"
:min="f.min"
:max="f.max"
@on-change="onFieldChange"
/>
<iSwitch
v-else-if="f.kind === 'switch'"
v-model="selected[f.key]"
@on-change="onFieldChange"
/>
<div v-else-if="f.kind === 'categories'" class="hle-cats">
<Spin v-if="treeLoading" size="small" />
<template v-else>
<label v-for="c in flat" :key="c.id" class="hle-cat" :style="{ paddingLeft: 8 + c.depth * 18 + 'px' }">
<Checkbox
:model-value="(selected.categoryIds || []).includes(c.id)"
@on-change="toggleCategory(selected!, c.id)"
>
{{ c.name }}
</Checkbox>
</label>
<p v-if="!flat.length" class="hle-cats-empty">暂无列表型栏目</p>
</template>
</div>
<p v-if="f.tip" class="hle-field-tip">{{ f.tip }}</p>
</div>
</template>
<div v-else class="hle-props-empty">点击左侧区块编辑属性</div>
</div>
</div>
<!-- 添加区块抽屉 -->
<Drawer v-model="addOpen" title="添加区块" width="420" class="hle-drawer">
<div
v-for="def in BLOCK_TYPES"
:key="def.type"
class="hle-type-card"
@click="addBlock(def.type)"
>
<Icon :type="def.icon" class="hle-type-icon" />
<span class="hle-type-body">
<span class="hle-type-title">{{ def.title }}</span>
<span class="hle-type-desc">{{ def.description }}</span>
</span>
<Icon type="ios-arrow-forward" class="hle-type-go" />
</div>
<p class="hle-drawer-tip">新增区块类型 = 注册表加一项 + 门户写一个 fragment,属性面板自动出现(docs/16 §4.2)。</p>
</Drawer>
</div>
</template>
<style scoped lang="scss">
.home-layout-editor {
.hle-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
.hle-gen,
.hle-clear {
margin-left: 0;
}
.hle-clear {
color: #999;
&:hover {
color: #ed4014;
}
}
.hle-hint {
margin-left: auto;
color: #999;
font-size: 12px;
}
}
.hle-empty {
padding: 28px 16px;
border: 1px dashed #dcdee2;
border-radius: 6px;
color: #515a6e;
font-size: 13px;
text-align: center;
p {
margin: 0 0 6px;
}
.hle-empty-sub {
color: #999;
}
}
.hle-body {
display: flex;
gap: 14px;
align-items: flex-start;
}
.hle-sequence {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.hle-row {
border: 1px solid #dcdee2;
border-radius: 6px;
padding: 8px 12px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
&.active {
border-color: #2d8cf0;
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.1);
}
&.hidden {
opacity: 0.55;
}
.hle-row-main {
display: flex;
align-items: center;
gap: 8px;
.hle-row-icon {
color: #2d8cf0;
font-size: 16px;
}
.hle-row-title {
font-weight: 600;
font-size: 13px;
}
.hle-row-badge {
margin: 0;
}
}
.hle-row-sub {
display: block;
margin-top: 2px;
padding-left: 24px;
color: #999;
font-size: 12px;
}
.hle-row-ops {
float: right;
margin-top: -4px;
.hle-del:hover {
color: #ed4014;
}
}
}
.hle-props {
width: 320px;
flex: none;
border: 1px solid #e8eaec;
border-radius: 6px;
padding: 12px;
background: #fafbfc;
.hle-props-head {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 13px;
margin-bottom: 10px;
color: #2d8cf0;
.hle-props-desc {
font-weight: 400;
color: #999;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.hle-props-empty {
color: #999;
font-size: 13px;
text-align: center;
padding: 24px 0;
}
.hle-field {
margin-bottom: 10px;
.hle-field-label {
display: block;
font-size: 12px;
color: #515a6e;
margin-bottom: 4px;
}
.hle-field-tip {
color: #999;
font-size: 12px;
line-height: 1.5;
margin-top: 3px;
}
}
.hle-cats {
max-height: 220px;
overflow: auto;
border: 1px solid #dcdee2;
border-radius: 4px;
padding: 6px;
background: #fff;
.hle-cat {
display: block;
line-height: 26px;
}
.hle-cats-empty {
color: #999;
text-align: center;
margin: 8px 0;
}
}
}
.hle-type-card {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid #dcdee2;
border-radius: 6px;
padding: 12px;
margin-bottom: 10px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #2d8cf0;
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.1);
}
.hle-type-icon {
font-size: 22px;
color: #2d8cf0;
}
.hle-type-body {
flex: 1;
min-width: 0;
.hle-type-title {
display: block;
font-weight: 600;
font-size: 13px;
}
.hle-type-desc {
display: block;
color: #999;
font-size: 12px;
line-height: 1.5;
}
}
.hle-type-go {
color: #c5c8ce;
}
}
.hle-drawer-tip {
color: #999;
font-size: 12px;
line-height: 1.6;
}
}
</style>
+130
View File
@@ -0,0 +1,130 @@
/**
* 首页布局区块类型注册表(docs/16 §4.2):通用组件库的单一事实源。
* 新增一种区块组件 = 注册表加一项 + 门户写一个 fragment(common 或主题自有),
* 编辑器属性面板/添加抽屉按本表 schema 驱动自动出现。
* propFields 顺序即属性面板渲染顺序;showIf 控制条件显示(如 background 仅 news_grid)。
*/
import { LayoutBlock } from "./homeLayout";
import { LIST_STYLES, styleLabel } from "./homeLayout";
export interface BlockPropField {
key: string;
label: string;
kind: "text" | "number" | "select" | "switch" | "categories";
options?: { value: string; label: string }[];
min?: number;
max?: number;
placeholder?: string;
tip?: string;
/** 条件显示(返回 false 时隐藏且不校验) */
showIf?: (block: LayoutBlock) => boolean;
}
export interface BlockTypeDef {
type: string;
title: string;
icon: string;
description: string;
/** 添加区块时的初始属性 */
defaultProps: () => Partial<LayoutBlock>;
propFields: BlockPropField[];
}
export const BLOCK_TYPES: BlockTypeDef[] = [
{
type: "article_list",
title: "栏目文章区",
icon: "md-list",
description: "按栏目聚合文章列表,支持五种形态;多栏目可合并为一块",
defaultProps: () => ({ style: "card", size: 8, categoryIds: [] }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "留空显示第一个栏目名" },
{
key: "categoryIds", label: "数据栏目", kind: "categories",
tip: "可多选;渲染时聚合所选栏目的全部子孙栏目文章(文章通常挂叶子栏目)",
},
{ key: "style", label: "展示形态", kind: "select", options: LIST_STYLES },
{
key: "size", label: "文章条数", kind: "number", min: 1, max: 20,
showIf: b => b.style !== "image_wall",
},
{
key: "background", label: "显示背景大图", kind: "switch",
showIf: b => b.style === "news_grid",
tip: "背景图取站点原「新闻卡背景图」配置(站点设置→基础展示可维护上传)",
},
],
},
{
type: "ticker",
title: "滚动速递",
icon: "md-megaphone",
description: "横向滚动条:全站置顶文章或指定栏目最新文章",
defaultProps: () => ({ source: "top", size: 6 }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「要闻速递」" },
{
key: "source", label: "数据来源", kind: "select",
options: [
{ value: "top", label: "全站置顶文章" },
{ value: "category", label: "指定栏目最新" },
],
},
{
key: "categoryIds", label: "数据栏目", kind: "categories",
showIf: b => b.source === "category",
},
{ key: "size", label: "文章条数", kind: "number", min: 1, max: 20 },
],
},
{
type: "gallery",
title: "图片画廊",
icon: "md-images",
description: "线上展厅:轮播管理「线上展厅」展示位的图片(数量为全部展示)",
defaultProps: () => ({}),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「线上展厅」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "默认 Online Exhibition Hall" },
],
},
{
type: "links",
title: "链接组",
icon: "md-link",
description: "友情链接分组(友情链接管理维护数据)",
defaultProps: () => ({}),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「友情链接」" },
{ key: "group", label: "链接分组", kind: "text", placeholder: "留空显示全部分组" },
],
},
];
export function blockDefOf(type: string): BlockTypeDef | undefined {
return BLOCK_TYPES.find(t => t.type === type);
}
/** 序列行摘要(形态/条数/来源一句话;namesOf 提供栏目名映射) */
export function blockSummary(block: LayoutBlock, namesOf: (id: number) => string): string {
const parts: string[] = [];
if (block.type === "article_list") {
parts.push(styleLabel(block.style));
if (block.style !== "image_wall") {
parts.push(`${block.size ?? 8}条`);
}
} else if (block.type === "ticker") {
parts.push(block.source === "category" ? "指定栏目最新" : "全站置顶");
parts.push(`${block.size ?? 6}条`);
} else if (block.type === "gallery") {
parts.push("线上展厅图集");
} else if (block.type === "links") {
parts.push(block.group ? `分组:${block.group}` : "全部分组");
}
const ids = block.categoryIds || [];
if (ids.length) {
const names = ids.map(namesOf);
parts.push(`来源:${names.filter(Boolean).join("、") || `${ids.length} 个栏目`}`);
}
return parts.join(" · ");
}
+171
View File
@@ -0,0 +1,171 @@
/**
* 首页布局纯函数单测(docs/16 §4.7):parse/serialize/一键生成算法/校验。
* 守护契约:无 home_layout=回落(null);serialize 仅动 home_layout 键;
* genFromCategories 与渲染端 categoryBlocks 现状算法等价(style 映射 + 16/8 条 + ticker/gallery 收口)。
*/
import { describe, expect, it } from "vitest";
import { CategoryTreeNode } from "@/types/cms";
import {
HomeLayout,
LayoutBlock,
cleanBlock,
flattenListCategories,
genFromCategories,
parseHomeLayout,
serializeHomeLayout,
validateHomeLayout,
} from "./homeLayout";
function cat(id: number, name: string, type = 1, homeStyle?: string, children: CategoryTreeNode[] = []): CategoryTreeNode {
return { id, parentId: 0, name, type, homeStyle, sortNo: 0, status: 1, children };
}
describe("parseHomeLayout:容错解析(无键/非法→null 回落)", () => {
it("无配置/空串/非法 JSON/无 home_layout 键 → null", () => {
expect(parseHomeLayout(undefined)).toBeNull();
expect(parseHomeLayout("")).toBeNull();
expect(parseHomeLayout("not-json")).toBeNull();
expect(parseHomeLayout('{"logo":"/a.png"}')).toBeNull();
expect(parseHomeLayout('{"home_layout":"bad"}')).toBeNull();
expect(parseHomeLayout('{"home_layout":{"blocks":"bad"}}')).toBeNull();
});
it("合法布局解析:blocks 收敛为已知字段,未知 type 跳过,缺 id 补生成", () => {
const json = JSON.stringify({
theme_params: { primary: "#2d8cf0" },
home_layout: {
version: 1,
blocks: [
{ id: "b1", type: "article_list", title: "校园时讯", categoryIds: [12, 15], style: "news_grid", size: 16, background: true },
{ id: "b2", type: "ticker", source: "top", size: 6 },
{ type: "gallery", title: "线上展厅", subtitle: "Online Exhibition Hall" },
{ type: "richtext", html: "v2 未开放" },
],
},
});
const layout = parseHomeLayout(json)!;
expect(layout).not.toBeNull();
expect(layout.version).toBe(1);
expect(layout.blocks.length).toBe(3);
expect(layout.blocks[0]).toMatchObject({ id: "b1", type: "article_list", title: "校园时讯", categoryIds: [12, 15], style: "news_grid", size: 16, background: true, visible: true });
expect(layout.blocks[2].id).toMatch(/^b_/);
expect(layout.blocks.some(b => b.type === "richtext")).toBe(false);
});
it("visible:false 保留(保序下线),非字符串数字容错", () => {
const layout = parseHomeLayout(JSON.stringify({
home_layout: { blocks: [{ id: "b1", type: "links", visible: false, group: "常用链接" }] },
}))!;
expect(layout.blocks[0].visible).toBe(false);
expect(layout.blocks[0].group).toBe("常用链接");
});
});
describe("serializeHomeLayout:仅动 home_layout 键,其余键无损", () => {
it("写回布局并保留 theme_params/custom_css/logo 等其他键", () => {
const config = JSON.stringify({ logo: "/a.png", theme_params: { primary: "#2d8cf0" }, custom_css: ".x{}" });
const layout: HomeLayout = {
version: 1,
blocks: [{ id: "b1", type: "article_list", visible: true, title: "通知公告", categoryIds: [20, 21], style: "compact", size: 6 }],
};
const out = JSON.parse(serializeHomeLayout(config, layout));
expect(out.logo).toBe("/a.png");
expect(out.theme_params).toEqual({ primary: "#2d8cf0" });
expect(out.custom_css).toBe(".x{}");
expect(out.home_layout.blocks).toEqual([
{ id: "b1", type: "article_list", title: "通知公告", categoryIds: [20, 21], style: "compact", size: 6 },
]);
});
it("cleanBlock 序列化最小化:visible 仅 false 时写、默认 style/size 省略、background 仅 true 时写", () => {
expect(cleanBlock({ id: "b1", type: "article_list", visible: true, categoryIds: [1] })).toEqual({ id: "b1", type: "article_list", categoryIds: [1] });
expect(cleanBlock({ id: "b1", type: "article_list", visible: false, categoryIds: [1], style: "card", size: 8, background: true })).toEqual({
id: "b1", type: "article_list", visible: false, categoryIds: [1], size: 8, background: true,
});
expect(cleanBlock({ id: "b2", type: "ticker", visible: true, source: "category", categoryIds: [3] })).toEqual({
id: "b2", type: "ticker", source: "category", categoryIds: [3],
});
expect(cleanBlock({ id: "b3", type: "gallery", visible: true })).toEqual({ id: "b3", type: "gallery" });
expect(cleanBlock({ id: "b4", type: "links", visible: true, group: "常用" })).toEqual({ id: "b4", type: "links", group: "常用" });
});
it("layout=null 删除 home_layout 键(清除自定义布局→回落态);空 blocks 显式保留空布局", () => {
const config = JSON.stringify({ logo: "/a.png", home_layout: { version: 1, blocks: [{ id: "b", type: "links" }] } });
const cleared = JSON.parse(serializeHomeLayout(config, null));
expect(cleared).toEqual({ logo: "/a.png" });
const emptied = JSON.parse(serializeHomeLayout(config, { version: 1, blocks: [] }));
expect(emptied.home_layout).toEqual({ version: 1, blocks: [] });
});
it("parse→serialize 往返稳定(不改写无关键、不丢块)", () => {
const json = JSON.stringify({
logo: "/a.png",
home_layout: { version: 1, blocks: [{ id: "b1", type: "gallery", title: "展厅" }] },
});
const round = JSON.parse(serializeHomeLayout(json, parseHomeLayout(json)));
expect(round).toEqual(JSON.parse(json));
});
});
describe("validateHomeLayout:与后端 SiteService 同语义", () => {
const ok: LayoutBlock = { id: "b", type: "article_list", visible: true, categoryIds: [1], size: 8 };
it("null 布局与合法布局均通过", () => {
expect(validateHomeLayout(null)).toEqual([]);
expect(validateHomeLayout({ version: 1, blocks: [ok, { id: "t", type: "ticker", visible: true }] })).toEqual([]);
});
it("size 越界 / 标题超长 / article_list 缺栏目 / 区块超量 报错", () => {
expect(validateHomeLayout({ version: 1, blocks: [{ ...ok, size: 0 }] }).join(" ")).toContain("条数");
expect(validateHomeLayout({ version: 1, blocks: [{ ...ok, size: 21 }] }).join(" ")).toContain("条数");
expect(validateHomeLayout({ version: 1, blocks: [{ ...ok, title: "x".repeat(101) }] }).join(" ")).toContain("100");
expect(validateHomeLayout({ version: 1, blocks: [{ id: "a", type: "article_list", visible: true }] }).join(" ")).toContain("数据栏目");
expect(validateHomeLayout({ version: 1, blocks: Array.from({ length: 31 }, (_, i) => ({ id: `b${i}`, type: "links" as const, visible: true })) }).join(" ")).toContain("30");
expect(validateHomeLayout({ version: 1, blocks: [{ id: "t", type: "ticker", visible: true, source: "category" }] }).join(" ")).toContain("指定栏目");
});
});
describe("genFromCategories:一键生成与现状渲染等价", () => {
const tree: CategoryTreeNode[] = [
cat(1, "校园时讯", 1, "news_grid", [cat(11, "院系动态", 1), cat(12, "单页栏目", 2), cat(13, "外链", 3)]),
cat(2, "媒体聚焦", 1, "media_focus"),
cat(3, "学校概况", 2),
cat(4, "通知公告", 1),
];
it("序列 = ticker(置顶6条) + 列表型一级栏目(style 取 home_style、16/8 条、仅列表型入列)+ gallery", () => {
const layout = genFromCategories(tree, JSON.stringify({ gallery_title: "线上展厅", gallery_subtitle: "Online Exhibition Hall" }));
expect(layout.blocks.length).toBe(5);
expect(layout.blocks[0]).toMatchObject({ type: "ticker", source: "top", title: "要闻速递", size: 6 });
expect(layout.blocks[1]).toMatchObject({ type: "article_list", title: "校园时讯", categoryIds: [1], style: "news_grid", size: 16 });
expect(layout.blocks[2]).toMatchObject({ type: "article_list", title: "媒体聚焦", categoryIds: [2], style: "media_focus", size: 8 });
expect(layout.blocks[3]).toMatchObject({ type: "article_list", title: "通知公告", categoryIds: [4], style: "card", size: 8 });
expect(layout.blocks[4]).toMatchObject({ type: "gallery", title: "线上展厅", subtitle: "Online Exhibition Hall" });
// 子栏目不重复出块(聚合在渲染端按子孙收集);单页/外链栏目不入列
expect(layout.blocks.some(b => b.categoryIds?.includes(11))).toBe(false);
expect(layout.blocks.some(b => b.title === "学校概况")).toBe(false);
});
it("无旧键配置时 gallery 用缺省标题;旧键非法时同样缺省", () => {
const layout = genFromCategories([], "");
const gallery = layout.blocks.find(b => b.type === "gallery")!;
expect(gallery.title).toBe("线上展厅");
expect(gallery.subtitle).toBe("Online Exhibition Hall");
expect(genFromCategories([], "bad-json").blocks.find(b => b.type === "gallery")!.title).toBe("线上展厅");
});
});
describe("flattenListCategories:扁平化仅列表型(选择器数据源)", () => {
const tree: CategoryTreeNode[] = [
cat(1, "新闻", 1, undefined, [cat(11, "校内", 1), cat(12, "单页", 2)]),
cat(2, "概况", 2),
];
it("深度缩进 + 仅 type=1", () => {
const flat = flattenListCategories(tree);
expect(flat).toEqual([
{ id: 1, name: "新闻", depth: 0 },
{ id: 11, name: "校内", depth: 1 },
]);
});
});
+314
View File
@@ -0,0 +1,314 @@
/**
* 首页布局纯函数模块(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 字;
* 服务端 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";
/** article_list 展示形态(与后端渲染端 style 分支及主题 fragment 对应) */
export const LIST_STYLES: { value: string; label: string }[] = [
{ value: "card", label: "默认卡(头条+列表)" },
{ value: "news_grid", label: "双栏新闻卡" },
{ value: "media_focus", label: "媒体聚焦" },
{ value: "compact", label: "纯文字列表" },
{ value: "image_wall", label: "图片墙(有封面文章)" },
];
export function styleLabel(style: string | undefined): string {
return LIST_STYLES.find(s => s.value === style)?.label || "默认卡(头条+列表)";
}
/** 布局区块(编辑器内存形态:含 visible 运行时键;序列化时仅落业务键) */
export interface LayoutBlock {
id: string;
type: BlockType;
/** false = 保序下线(渲染端跳过) */
visible: boolean;
title?: string;
/** gallery 英文副题 */
subtitle?: string;
/** article_list / ticker(source=category):数据栏目(可多选,渲染端聚合子孙) */
categoryIds?: number[];
/** article_list 展示形态,缺省 card */
style?: string;
/** 条数(article_list 缺省 8、ticker 缺省 6;gallery 不设 = 全部) */
size?: number;
/** article_list news_grid 背景大图开关(取站点配置 news_bg_image 旧键) */
background?: boolean;
/** ticker 数据源:top=全站置顶(缺省)| category=指定栏目最新 */
source?: string;
/** links 分组名(空=全部分组) */
group?: string;
}
export interface HomeLayout {
version: number;
blocks: LayoutBlock[];
}
export const HOME_LAYOUT_VERSION = 1;
/** 服务端同款约束(SiteService §4.6) */
export const BLOCK_TYPES_ALLOWED: BlockType[] = ["article_list", "ticker", "gallery", "links"];
export const SIZE_MIN = 1;
export const SIZE_MAX = 20;
export const TITLE_MAX = 100;
export const MAX_BLOCKS = 30;
export function genBlockId(): string {
return "b_" + Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4);
}
function asObject(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
/**
* configJson → 布局(容错解析):无键 / 非法 JSON / home_layout 非对象 / blocks 非数组 → null(=回落现状)。
* 单块收敛为已知字段(未知键丢弃);type 不在白名单的块跳过。
*/
export function parseHomeLayout(configJson: string | undefined | null): HomeLayout | null {
if (!configJson || !configJson.trim()) {
return null;
}
let root: Record<string, unknown> | null = null;
try {
root = asObject(JSON.parse(configJson));
} catch {
return null;
}
if (!root) {
return null;
}
const hl = asObject(root["home_layout"]);
if (!hl || !Array.isArray(hl["blocks"])) {
return null;
}
const blocks: LayoutBlock[] = [];
for (const raw of hl["blocks"] as unknown[]) {
const obj = asObject(raw);
const type = typeof obj?.["type"] === "string" ? (obj["type"] as string) : "";
if (!BLOCK_TYPES_ALLOWED.includes(type as BlockType)) {
continue;
}
blocks.push(normalizeBlock(obj as Record<string, unknown>, type as BlockType));
}
return { version: typeof hl["version"] === "number" ? hl["version"] : HOME_LAYOUT_VERSION, blocks };
}
function toIdList(v: unknown): number[] | undefined {
if (!Array.isArray(v)) {
return undefined;
}
const ids = v.filter((x): x is number => typeof x === "number" && Number.isFinite(x));
return ids.length ? ids : undefined;
}
function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBlock {
const size = typeof obj["size"] === "number" ? Math.round(obj["size"]) : undefined;
const block: LayoutBlock = {
id: typeof obj["id"] === "string" && obj["id"] ? obj["id"] : genBlockId(),
type,
visible: obj["visible"] !== false,
};
if (typeof obj["title"] === "string") block.title = obj["title"];
if (typeof obj["subtitle"] === "string") block.subtitle = obj["subtitle"];
if (typeof obj["style"] === "string") block.style = obj["style"];
if (size !== undefined) block.size = size;
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"];
const ids = toIdList(obj["categoryIds"]);
if (ids) block.categoryIds = ids;
return block;
}
/** 单块 → 序列化对象:只写与类型相关的业务键(visible 仅 false 时写,size 仅显式设置时写) */
export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
const out: Record<string, unknown> = { id: block.id, type: block.type };
if (block.visible === false) {
out["visible"] = false;
}
const title = (block.title || "").trim();
if (title) {
out["title"] = title;
}
if (block.type === "article_list") {
if (block.categoryIds && block.categoryIds.length) {
out["categoryIds"] = block.categoryIds;
}
if (block.style && block.style !== "card") {
out["style"] = block.style;
}
if (block.size != null) {
out["size"] = block.size;
}
if (block.background) {
out["background"] = true;
}
} else if (block.type === "ticker") {
if (block.source && block.source !== "top") {
out["source"] = block.source;
}
if (block.categoryIds && block.categoryIds.length) {
out["categoryIds"] = block.categoryIds;
}
if (block.size != null) {
out["size"] = block.size;
}
} else if (block.type === "gallery") {
const subtitle = (block.subtitle || "").trim();
if (subtitle) {
out["subtitle"] = subtitle;
}
} else if (block.type === "links") {
const group = (block.group || "").trim();
if (group) {
out["group"] = group;
}
}
return out;
}
/**
* 布局写回 configJson(仅增删改 home_layout 键,其余键原样保留)。
* layout=null = 清除自定义布局(删除键,回到回落态);空 blocks = 显式空首页(写空数组,不删键)。
*/
export function serializeHomeLayout(configJson: string | undefined | null, layout: HomeLayout | null): 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 语义一致)
}
}
if (!layout) {
delete config["home_layout"];
} else {
config["home_layout"] = {
version: layout.version || HOME_LAYOUT_VERSION,
blocks: layout.blocks.map(cleanBlock),
};
}
return JSON.stringify(config);
}
/** 校验返回错误消息列表(空数组=通过;与后端 validateHomeLayout 同语义) */
export function validateHomeLayout(layout: HomeLayout | null): string[] {
const errors: string[] = [];
if (!layout) {
return errors;
}
if (layout.blocks.length > MAX_BLOCKS) {
errors.push(`区块数量不能超过 ${MAX_BLOCKS} 个`);
}
layout.blocks.forEach((b, i) => {
const no = `第 ${i + 1} 个区块(${b.title || b.type})`;
if (!BLOCK_TYPES_ALLOWED.includes(b.type)) {
errors.push(`${no}:类型不合法`);
}
if ((b.title || "").trim().length > TITLE_MAX || (b.subtitle || "").trim().length > TITLE_MAX) {
errors.push(`${no}:标题/副题不能超过 ${TITLE_MAX} 字`);
}
if (b.size != null && (b.size < SIZE_MIN || b.size > SIZE_MAX)) {
errors.push(`${no}:条数须在 ${SIZE_MIN}~${SIZE_MAX} 之间`);
}
if (b.type === "article_list" && !(b.categoryIds && b.categoryIds.length)) {
errors.push(`${no}:请选择数据栏目`);
}
if (b.type === "ticker" && b.source === "category" && !(b.categoryIds && b.categoryIds.length)) {
errors.push(`${no}:指定栏目模式请选择栏目`);
}
});
return errors;
}
/** 树展开为可勾选的扁平列表(仅列表型栏目可作数据源;外链/单页排除) */
export interface FlatCategory {
id: number;
name: string;
depth: number;
}
export function flattenListCategories(tree: CategoryTreeNode[], depth = 0, out: FlatCategory[] = []): FlatCategory[] {
(tree || []).forEach(node => {
if (node.type === 1) {
out.push({ id: node.id as number, name: node.name, depth });
}
if (node.children && node.children.length) {
flattenListCategories(node.children, depth + 1, out);
}
});
return out;
}
/** 树内全部栏目 id(布局数据源合法性参考) */
export function allCategoryIds(tree: CategoryTreeNode[]): number[] {
const ids: number[] = [];
(tree || []).forEach(node => {
if (node.id != null) {
ids.push(node.id);
}
ids.push(...allCategoryIds(node.children || []));
});
return ids;
}
/**
* 「从当前栏目一键生成」(docs/16 §4.3 首用救生索):复刻渲染端 categoryBlocks 现状算法,
* 生成与旧首页等价的初始序列(ticker → 各列表栏目区块(home_style→style、16/8 条)→ gallery),
* 用户在此基础上删改,心智从「从零搭」变成「修剪」。
*/
export function genFromCategories(tree: CategoryTreeNode[], configJson: string | undefined | null): HomeLayout {
let galleryTitle = "线上展厅";
let gallerySubtitle = "Online Exhibition Hall";
if (configJson && configJson.trim()) {
try {
const cfg = JSON.parse(configJson);
if (cfg && typeof cfg === "object") {
if (typeof cfg["gallery_title"] === "string" && cfg["gallery_title"].trim()) {
galleryTitle = cfg["gallery_title"];
}
if (typeof cfg["gallery_subtitle"] === "string" && cfg["gallery_subtitle"].trim()) {
gallerySubtitle = cfg["gallery_subtitle"];
}
}
} catch {
// 非法配置按缺省标题
}
}
const blocks: LayoutBlock[] = [
{ id: genBlockId(), type: "ticker", visible: true, title: "要闻速递", source: "top", size: 6 },
];
(tree || []).forEach(node => {
if (node.type !== 1 || node.id == null) {
return;
}
blocks.push({
id: genBlockId(),
type: "article_list",
visible: true,
title: node.name,
categoryIds: [node.id],
style: node.homeStyle || "card",
size: node.homeStyle === "news_grid" ? 16 : 8,
});
});
blocks.push({
id: genBlockId(),
type: "gallery",
visible: true,
title: galleryTitle,
subtitle: gallerySubtitle,
});
return { version: HOME_LAYOUT_VERSION, blocks };
}