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
build / build (push) Successful in 2m19s
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" }]); // 首页布局分区未动
|
||||
});
|
||||
});
|
||||
@@ -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/");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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") },
|
||||
{
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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>保存后门户立即生效(缓存自动失效);服务端将拒绝 </style、<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>
|
||||
|
||||
@@ -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>保存后门户立即生效(缓存自动失效);服务端将拒绝 </style、<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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(" · ");
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user