Compare commits

...
8 Commits
Author SHA1 Message Date
chenxing b6b022e4ca [CMS-VISUAL] E 前端配套: 编辑下架主题站点时按别名表呈现归一主题(与后端 DEPRECATED_ALIASES 同构,不误报未注册,181 测试绿)
build / build (push) Successful in 2m20s
2026-09-28 15:04:02 +08:00
chenxing 6334b24d34 [CMS-VISUAL] F5: 页脚结构示意图(六处位置标注+实时回显)+ favicon 1:1/二维码 4:3 占位框与比例预览(180 测试绿) 2026-09-28 14:59:05 +08:00
chenxing 49678a6568 [CMS-VISUAL] D1-D10: 组件库全量——布局模型扩 6 新类型(stats_band/featured_topics/video_tour/events/people/badges)+ 编辑器新属性面板(统计项/专题卡/徽章数组编辑器、图标选择器)+ gallery 瀑布流 + banner 4 Tab(topic/event 位)+ 人物管理 CRUD 页 + 全类型英文副题(176 测试绿) 2026-09-28 13:48:51 +08:00
chenxing 1a913490ef [CMS-VISUAL] F2: 布局编辑器三栏化——中间 iframe 实时预览(500ms debounce 注入+手动刷新兜底+三档视口)、类型卡 SVG 缩略图(R-40)、展示形态 RadioCard、首用自动编排、序列高度示意(171 测试绿) 2026-09-28 12:52:57 +08:00
chenxing af451703e7 [CMS-VISUAL] F1: 站点设置 Tab 化(7 Tab 独立表单/dirty/保存,增量合并互不连带)+ 新建渐进式向导(两步后全开放,无灰态)+ 自定义样式独立 Tab + 页脚/基础展示字段视觉位置说明(165 测试绿) 2026-09-28 12:39:21 +08:00
chenxing 010fb39e85 [CMS-VISUAL] F4: Banner 管理改「首页轮播/线上展厅」双 Tab,各 Tab 独立新增/尺寸提示,弹窗归属锚定打开时 Tab 2026-09-28 12:33:39 +08:00
chenxing 3a0f1e0cc9 [CMS-VISUAL] F6: 站点设置「扩展参数」分区退役(7→6 分区),未知配置键改静默保留不丢失(vitest 164 绿) 2026-09-28 12:33:23 +08:00
chenxing 649a08629b [CMS-VISUAL] F3: 栏目管理移除「首页展示形态」下拉,形态统一归首页布局编辑器配置 2026-09-28 12:32:58 +08:00
24 changed files with 2650 additions and 652 deletions
+145 -9
View File
@@ -1,21 +1,24 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /**
* 首页布局编辑器(v2 工作项 B / 手册 §2.8)挂载回归:T-B-09—— * 首页布局编辑器(v2 工作项 B → docs/20 F2 三栏升级)挂载回归:
* quick_links 的属性面板必须是「数组编辑器」(每行 name + url,可增删、受 12 项上限约束), * T-B-09 quick_links/notice_bar 属性面板行为不变;
* notice_bar 必须是正文 + 可选跳转 + level 选择;序列行摘要走 blockSummary 新增分支。 * F2 新增:三栏结构(序列/预览/属性)、预览视口三档切换、类型卡 SVG 缩略图(R-40)、
* 沿用 siteSetting.spec.ts 的手写 createApp + vi.mock 桩风格。 * 展示形态 RadioCard、首用自动编排(无 home_layout + 有列表栏目 → 自动生成)。
*/ */
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, ref } from "vue"; import { createApp, defineComponent, h, nextTick, ref } from "vue";
import ViewUIPlus from "view-ui-plus"; import ViewUIPlus from "view-ui-plus";
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {}); Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
const treeMock = vi.hoisted(() => ({ send: vi.fn() }));
vi.mock("@/api/module/cms", () => ({ vi.mock("@/api/module/cms", () => ({
categoryApi: { tree: () => ({ send: async () => [] }) }, categoryApi: { tree: () => treeMock },
})); }));
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue"; import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
import { CategoryTreeNode } from "@/types/cms";
const cleanups: (() => void)[] = []; const cleanups: (() => void)[] = [];
@@ -41,13 +44,24 @@ const CONFIG = JSON.stringify({
}, },
}); });
async function mountEditor(configJson: string) { const TREE: CategoryTreeNode[] = [
{ id: 11, name: "校园时讯", type: 1, children: [] },
{ id: 12, name: "媒体聚焦", type: 1, children: [] },
] as unknown as CategoryTreeNode[];
async function mountEditor(configJson: string, opts?: { siteId?: number; portalUrl?: string }) {
const el = document.createElement("div"); const el = document.createElement("div");
document.body.appendChild(el); document.body.appendChild(el);
const model = ref(configJson); const model = ref(configJson);
const app = createApp( const app = createApp(
defineComponent({ defineComponent({
render: () => h(HomeLayoutEditor, { modelValue: model.value, "onUpdate:modelValue": (v: string) => (model.value = v) }), render: () =>
h(HomeLayoutEditor, {
modelValue: model.value,
siteId: opts?.siteId,
portalUrl: opts?.portalUrl,
"onUpdate:modelValue": (v: string) => (model.value = v),
}),
}), }),
); );
app.use(ViewUIPlus); app.use(ViewUIPlus);
@@ -74,11 +88,16 @@ function del(field: HTMLElement, index: number): HTMLElement {
return field.querySelectorAll<HTMLElement>(".hle-link-del")[index]; return field.querySelectorAll<HTMLElement>(".hle-link-del")[index];
} }
beforeEach(() => {
treeMock.send.mockReset();
treeMock.send.mockResolvedValue([]);
});
afterEach(() => { afterEach(() => {
cleanups.splice(0).forEach(fn => fn()); cleanups.splice(0).forEach(fn => fn());
}); });
describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09)", () => { describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09,F2 改造后回归)", () => {
it("quick_links:入口行编辑器渲染 name+url 双输入,添加/删除可用且回写 items", async () => { it("quick_links:入口行编辑器渲染 name+url 双输入,添加/删除可用且回写 items", async () => {
const { el, model } = await mountEditor(CONFIG); const { el, model } = await mountEditor(CONFIG);
// 序列行摘要走 blockSummary 新增分支 // 序列行摘要走 blockSummary 新增分支
@@ -140,3 +159,120 @@ describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09)",
expect(levelField.textContent).toContain("醒目"); expect(levelField.textContent).toContain("醒目");
}); });
}); });
describe("HomeLayoutEditor - F2 三栏 + 预览 + 缩略图 + 自动编排(docs/20)", () => {
it("三栏结构:序列 / 实时预览 iframe / 属性面板;视口三档与刷新按钮齐备", async () => {
const { el } = await mountEditor(CONFIG, { portalUrl: "http://x/portal/demo/" });
expect(el.querySelector(".hle-sequence")).toBeTruthy();
expect(el.querySelector(".hle-props")).toBeTruthy();
const preview = el.querySelector(".hle-preview")!;
expect(preview).toBeTruthy();
expect(preview.querySelector("iframe")).toBeTruthy();
expect(preview.querySelector("iframe")!.getAttribute("src")).toBe("http://x/portal/demo/");
// 视口三档
expect(preview.textContent).toContain("桌面 1200");
expect(preview.textContent).toContain("平板 768");
expect(preview.textContent).toContain("手机 390");
const refresh = [...preview.querySelectorAll("button")].find(b => b.textContent?.includes("刷新预览"));
expect(refresh).toBeTruthy();
});
it("添加抽屉:每个类型卡配 SVG 缩略示意图(R-40,注册表全量)", async () => {
const { el } = await mountEditor(CONFIG);
const addBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("添加区块"))!;
addBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await settle();
// Drawer 渲染到 body 级传送门
const drawer = document.querySelector(".hle-drawer") || document.body;
const cards = drawer.querySelectorAll(".hle-type-card");
expect(cards.length).toBeGreaterThanOrEqual(6);
cards.forEach(c => {
expect(c.querySelector(".hle-type-thumb svg")).toBeTruthy();
});
});
it("展示形态 = RadioCard 可视化选择(5 张缩略图 + 文字,非纯文字下拉)", async () => {
const config = JSON.stringify({
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] },
});
const { el, model } = await mountEditor(config);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const styleField = fieldOf(el as HTMLElement, "展示形态")!;
expect(styleField.querySelector(".hle-style-cards")).toBeTruthy();
expect(styleField.querySelectorAll(".hle-style-card").length).toBe(5);
expect(styleField.querySelectorAll(".hle-style-card svg").length).toBe(5);
// 点击第 2 张卡(news_grid)→ 选中态 + 回写布局
(styleField.querySelectorAll(".hle-style-card")[1] as HTMLElement).click();
await settle();
expect(styleField.querySelectorAll(".hle-style-card")[1].className).toContain("active");
expect(JSON.parse(model.value).home_layout.blocks[0].style).toBe("news_grid");
});
it("首用自动编排:无 home_layout 键 + 站点有列表栏目 → 自动生成初始序列(D-4)", async () => {
treeMock.send.mockResolvedValue(TREE);
const noLayoutConfig = JSON.stringify({ logo: "/a.png" });
const { model } = await mountEditor(noLayoutConfig, { siteId: 1 });
const layout = JSON.parse(model.value).home_layout;
expect(layout).toBeTruthy();
expect(layout.blocks.length).toBeGreaterThanOrEqual(4); // ticker + 2 栏目区块 + gallery
expect(layout.blocks[0].type).toBe("ticker");
expect(layout.blocks.some((b: { type: string; title?: string }) => b.type === "article_list" && b.title === "校园时讯")).toBe(true);
expect(layout.blocks.some((b: { type: string }) => b.type === "gallery")).toBe(true);
// 其余键无损
expect(JSON.parse(model.value).logo).toBe("/a.png");
});
it("显式空布局(blocks=[])不触发自动编排(用户选择优先)", async () => {
treeMock.send.mockResolvedValue(TREE);
const emptyLayout = JSON.stringify({ home_layout: { version: 1, blocks: [] } });
const { model } = await mountEditor(emptyLayout, { siteId: 1 });
expect(JSON.parse(model.value).home_layout.blocks).toEqual([]);
});
it("无列表栏目不触发自动编排", async () => {
treeMock.send.mockResolvedValue([]);
const { model } = await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteId: 1 });
expect(JSON.parse(model.value).home_layout).toBeUndefined();
});
});
describe("HomeLayoutEditor - D 组件属性面板(docs/20 D-1/D-8)", () => {
it("stats_band:统计项三列编辑器(名称/数值/单位)渲染并可增行", async () => {
const config = JSON.stringify({
home_layout: { version: 1, blocks: [
{ id: "s1", type: "stats_band", stats: [{ label: "在校生", value: "3.2", unit: "万" }] },
] },
});
const { el } = await mountEditor(config);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const field = fieldOf(el as HTMLElement, "统计项")!;
expect(field.querySelector(".hle-stat-row")).toBeTruthy();
expect((field.querySelector(".hle-stat-row input") as HTMLInputElement)).toBeTruthy();
const add = field.querySelector(".hle-stat-add, .hle-link-add") as HTMLElement;
add.click();
await settle();
expect(field.querySelectorAll(".hle-stat-row").length).toBe(2);
});
it("gallery:布局形态下拉含瀑布流(campus_wall)选项且选中回写", async () => {
const config = JSON.stringify({
home_layout: { version: 1, blocks: [{ id: "g1", type: "gallery" }] },
});
const { el, model } = await mountEditor(config);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const field = fieldOf(el as HTMLElement, "布局形态")!;
const select = field.querySelector(".ivu-select") as HTMLElement;
expect(select).toBeTruthy();
// 展开下拉点选 masonry
select.querySelector(".ivu-select-selection").dispatchEvent(new MouseEvent("click", { bubbles: true }));
await settle(8);
const opt = [...document.querySelectorAll(".ivu-select-item")].find(n => n.textContent?.includes("瀑布流")) as HTMLElement;
expect(opt).toBeTruthy();
opt.click();
await settle(8);
expect(JSON.parse(model.value).home_layout.blocks[0].layout).toBe("masonry");
});
});
+58 -36
View File
@@ -1,10 +1,12 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /**
* 站点设置中心(docs/16 主线A)挂载回归: * 站点设置中心(docs/16 主线A → docs/20 F1 Tab 化 + 渐进式向导)挂载回归:
* ① 创建模式:③~⑦锁定("创建站点后开放"×5)、主按钮=「创建站点」、 * ① 创建模式(渐进式向导):无任何「创建站点后开放」灰态;主按钮=「创建站点,下一步」;
* 校级锁定本校主站时门户地址预览随编码联动(原向导 T-W4-18 能力收编); * 创建成功自动进入第二步(皮肤 Tab + Steps + 「保存并完成」);
* ② 编辑模式:基本信息回填、未注册皮肤告警保留原值(任务三行为迁入②分区); * 校级锁定本校主站时门户地址预览随编码联动;
* ③ 分区保存:保存基本信息 = 当前 configJson 事实源全量提交(其他分区键不丢)。 * ② 编辑模式:7 Tab 结构、基本信息回填、未注册皮肤告警保留原值;
* ③ Tab 独立保存:保存基本信息 = 以服务端事实源 configJson 提交(其他 Tab 键不丢、不连带提交);
* ④ 自定义样式独立 Tab(textarea 代码编辑器,非折叠面板)。
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 siteManage.spec.ts 一致)。 * 沿用手写 createApp 挂载 + vi.mock 桩风格(与 siteManage.spec.ts 一致)。
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -12,8 +14,6 @@ import { createApp, defineComponent, h, nextTick } from "vue";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import ViewUIPlus from "view-ui-plus"; 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 routeMock = vi.hoisted(() => ({ params: {} as Record<string, string>, query: {} as Record<string, string> }));
const routerMock = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn() })); const routerMock = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn() }));
@@ -69,8 +69,8 @@ vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
})); }));
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({ vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
default: defineComponent({ default: defineComponent({
props: ["modelValue", "groups", "showExtras", "siteId"], props: ["modelValue", "groups", "siteId"],
methods: { validate: async () => true }, methods: { validate: async () => true, ownFields: () => ({}) },
render: () => h("div", "config-form-stub"), render: () => h("div", "config-form-stub"),
}), }),
})); }));
@@ -135,16 +135,15 @@ afterEach(() => {
cleanups = []; cleanups = [];
}); });
describe("SiteSetting 创建模式(site/new,向导退役收编)", () => { describe("SiteSetting 创建模式(docs/20 F1 渐进式向导)", () => {
it("③~⑦分区锁定(创建站点后开放×5),主按钮=创建站点", async () => { it("第一步仅基本信息表单:无任何灰态锁定提示,主按钮=创建站点,下一步", async () => {
const el = mountPage(); const el = mountPage();
await flush(); await flush();
const tips = (el.textContent.match(/创建站点后开放/g) || []).length; expect(el.textContent).not.toContain("创建站点后开放");
expect(tips).toBe(5);
expect(el.textContent).toContain("新建站点"); expect(el.textContent).toContain("新建站点");
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点")); expect(el.textContent).toContain("创建站点,下一步");
expect(createBtn).toBeTruthy(); expect(el.querySelector('input[placeholder="URL 路径用,如 demo"]')).toBeTruthy();
}); });
it("无上级时门户地址预览随编码联动(/portal/{code}/)", async () => { it("无上级时门户地址预览随编码联动(/portal/{code}/)", async () => {
@@ -179,7 +178,7 @@ describe("SiteSetting 创建模式(site/new,向导退役收编)", () => {
expect(selectEl).toBeTruthy(); expect(selectEl).toBeTruthy();
}); });
it("创建成功:replace 到站点设置页并解锁分区", async () => { it("创建成功:自动进入第二步(皮肤 Tab + Steps + 保存并完成),全 Tab 开放", async () => {
const el = mountPage(); const el = mountPage();
await flush(); await flush();
@@ -189,26 +188,34 @@ describe("SiteSetting 创建模式(site/new,向导退役收编)", () => {
setInputValue(nameInput, "机械学院门户"); setInputValue(nameInput, "机械学院门户");
await flush(); await flush();
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点"))!; const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点,下一步"))!;
createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush(); await flush(10);
expect(mocks.siteSave).toHaveBeenCalledTimes(1); expect(mocks.siteSave).toHaveBeenCalledTimes(1);
const payload = mocks.siteSave.mock.calls[0][0]; const payload = mocks.siteSave.mock.calls[0][0];
expect(payload.code).toBe("jdxy"); expect(payload.code).toBe("jdxy");
expect(payload.name).toBe("机械学院门户"); expect(payload.name).toBe("机械学院门户");
expect(routerMock.replace).toHaveBeenCalledWith("/sch/jdxy/site/setting/99"); expect(routerMock.replace).toHaveBeenCalledWith("/sch/jdxy/site/setting/99");
// 向导第二步:Steps 可见 + 皮肤 Tab 激活 + 完成按钮;无灰态
expect(el.textContent).toContain("皮肤与外观");
expect(el.textContent).toContain("保存并完成");
expect(el.textContent).toContain("跳过,稍后配置");
expect(el.textContent).not.toContain("创建站点后开放");
}); });
}); });
describe("SiteSetting 编辑模式(site/setting/:id)", () => { describe("SiteSetting 编辑模式(site/setting/:id,docs/20 F1 7 Tab)", () => {
it("基本信息回填 + 已注册皮肤无告警", async () => { it("7 个 Tab 标题齐备(基本信息…自定义样式),基本信息回填,无灰态提示", async () => {
routeMock.params = { id: "1" }; routeMock.params = { id: "1" };
const el = mountPage(); const el = mountPage();
await flush(); await flush();
["基本信息", "皮肤与外观", "首页布局", "基础展示", "页脚信息", "SEO 与域名", "自定义样式"].forEach(t =>
expect(el.textContent).toContain(t)
);
expect(el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!.value).toBe("demo"); expect(el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!.value).toBe("demo");
expect((el.textContent || "")).not.toContain("未在注册表中"); expect(el.textContent || "").not.toContain("未在注册表中");
expect(el.textContent).toContain("云海大学门户网站"); expect(el.textContent).toContain("云海大学门户网站");
expect(el.textContent).not.toContain("创建站点后开放"); expect(el.textContent).not.toContain("创建站点后开放");
}); });
@@ -223,7 +230,7 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
expect(el.textContent).toContain("skin-x"); expect(el.textContent).toContain("skin-x");
}); });
it("仅保存基本信息:以当前 configJson 事实源全量提交(home_layout 等其他分区键随行)", async () => { it("保存基本信息:以服务端事实源 configJson 提交(home_layout 等其他 Tab 键随行不丢)", async () => {
routeMock.params = { id: "1" }; routeMock.params = { id: "1" };
const el = mountPage(); const el = mountPage();
await flush(); await flush();
@@ -232,7 +239,7 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
setInputValue(nameInput, "改名后的门户"); setInputValue(nameInput, "改名后的门户");
await flush(); await flush();
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("仅保存基本信息"))!; const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存基本信息"))!;
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush(); await flush();
@@ -244,27 +251,21 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
const config = JSON.parse(payload.configJson); const config = JSON.parse(payload.configJson);
expect(config.logo).toBe("/cms/upload/logo.png"); expect(config.logo).toBe("/cms/upload/logo.png");
expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留 expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留
expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局分区未动 expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局 Tab 未动
}); });
it("T-T-05: 皮肤分区不再有参数微调区(只留选皮肤 + 站点级 CSS),保存不写 theme_params", async () => { it("T-T-05: 皮肤 Tab 只留选皮肤(无参数微调),保存不写 theme_params", async () => {
routeMock.params = { id: "1" }; routeMock.params = { id: "1" };
const el = mountPage(); const el = mountPage();
await flush(); await flush();
const themeCard = el.querySelector('[data-section="theme"]')!;
expect(themeCard).toBeTruthy();
// ThemeParamForm 已被桩化为 theme-param-stub:站点设置页不再出现该桩 // ThemeParamForm 已被桩化为 theme-param-stub:站点设置页不再出现该桩
expect(themeCard.textContent).not.toContain("theme-param-stub"); expect(el.textContent).not.toContain("theme-param-stub");
expect(themeCard.textContent).not.toContain("参数微调"); expect(el.textContent).not.toContain("参数微调");
// 站点级 CSS 覆盖区保留(副标题文案 + textarea 编辑框) expect(el.textContent).toContain("theme-picker-stub");
expect(themeCard.textContent).toContain("站点级 CSS 覆盖");
expect(themeCard.querySelector("textarea")).toBeTruthy();
// v1 §三:站点只负责选哪套包(选皮肤仍在)
expect(themeCard.textContent).toContain("theme-picker-stub");
const saveBtn = [...themeCard.querySelectorAll("button")].find(b => const saveBtn = [...el.querySelectorAll("button")].find(b =>
b.textContent?.includes("仅保存皮肤与外观") b.textContent?.includes("保存皮肤与外观")
)!; )!;
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush(); await flush();
@@ -274,4 +275,25 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
expect(config).not.toHaveProperty("theme_params"); // 参数值不再由站点提交 expect(config).not.toHaveProperty("theme_params"); // 参数值不再由站点提交
expect(config.logo).toBe("/cms/upload/logo.png"); // 其余键仍随事实源保留 expect(config.logo).toBe("/cms/upload/logo.png"); // 其余键仍随事实源保留
}); });
it("F1:自定义样式为独立 Tab 的代码编辑器(textarea + 字符计数),非折叠面板", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
const cssTextarea = el.querySelector("textarea");
expect(cssTextarea).toBeTruthy();
expect(el.textContent).toContain("自定义样式");
expect(el.textContent).toContain("/10240 字符");
// 独立保存按钮
const cssSaveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存自定义样式"))!;
expect(cssSaveBtn).toBeTruthy();
cssSaveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
const config = JSON.parse(mocks.siteSave.mock.calls[0][0].configJson);
expect(config).not.toHaveProperty("custom_css"); // 空样式 = 删除键
expect(config.home_layout).toBeTruthy(); // 其他 Tab 键不受影响
});
}); });
+107
View File
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
/**
* F5(docs/20)页脚/基础展示视觉位置示意:
* ① 页脚分组底部渲染 FooterPreview 结构示意图(六处位置标注,已填内容回显);
* ② asset 字段的 aspect 占位框(favicon 1:1 / 二维码 4:3,无值虚线框、有值比例预览)。
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import ViewUIPlus from "view-ui-plus";
vi.mock("@/api/module/cms", () => ({
assetUrl: (u: string) => u,
uploadUrl: () => "/api/attachment/upload",
}));
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
const cleanups: (() => void)[] = [];
async function settle(frames = 4) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
await nextTick();
}
const FOOTER_CONFIG = JSON.stringify({
contact: "电话 0571-88888888",
footer_addresses: "潭山校区:南湖路 237 号\n淮河校区:大学路 1 号",
qr_wechat: "/cms/upload/qr-wechat.png",
social_weibo: "https://weibo.com/xxx",
copyright: "XX学校 · 版权所有 © 2026",
beian: "浙ICP备XXXXXXXX号-1",
logo: "/cms/upload/logo.png",
});
async function mountForm(configJson: string, groups?: string[]) {
const el = document.createElement("div");
document.body.appendChild(el);
const model = ref(configJson);
const app = createApp(
defineComponent({
render: () =>
h(SiteConfigForm, {
modelValue: model.value,
groups,
"onUpdate:modelValue": (v: string) => (model.value = v),
}),
}),
);
app.use(ViewUIPlus);
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
});
await settle();
return el;
}
afterEach(() => {
cleanups.splice(0).forEach(fn => fn());
});
describe("F5 视觉位置示意(docs/20)", () => {
it("页脚分组:底部渲染结构示意图,六处位置标注 + 已填内容回显", async () => {
const el = await mountForm(FOOTER_CONFIG, ["footer"]);
const preview = el.querySelector(".footer-preview")!;
expect(preview).toBeTruthy();
const text = preview.textContent || "";
// 已填字段实时回显内容(而非占位标注)
expect(text).toContain("电话 0571-88888888");
expect(text).toContain("潭山校区:南湖路 237 号");
expect(text).toContain("XX学校 · 版权所有 © 2026");
expect(text).toContain("浙ICP备XXXXXXXX号-1");
expect(preview.querySelector('img[src="/cms/upload/qr-wechat.png"]')).toBeTruthy();
// 未填字段显示位置标注(微博二维码空 → 占位)
expect(text).toContain("微博二维码");
expect(text).toContain("社媒图标行");
});
it("页脚全空配置:六处位置标注完整(① 联系方式/② 地址/③ 二维码/④ 社媒/⑤ 版权/⑥ 备案)", async () => {
const el = await mountForm("{}", ["footer"]);
const text = el.querySelector(".footer-preview")!.textContent || "";
expect(text).toContain("① 联系方式");
expect(text).toContain("② 多校区地址");
expect(text).toContain("微信二维码");
expect(text).toContain("③ 4:3");
expect(text).toContain("④ 社媒图标行");
expect(text).toContain("⑤ 版权文字");
expect(text).toContain("⑥ 备案号");
});
it("基础展示分组:无示意图(仅页脚有)", async () => {
const el = await mountForm(FOOTER_CONFIG, ["display"]);
expect(el.querySelector(".footer-preview")).toBeFalsy();
});
it("aspect 占位框:二维码无值时 4:3 虚线占位、favicon 无值时 1:1 占位", async () => {
const el = await mountForm("{}", ["display", "footer"]);
const holders = el.querySelectorAll(".iaf-holder");
expect(holders.length).toBe(3); // favicon 1:1 + 微信/微博 4:3
expect(el.querySelector(".iaf-holder-1x1")).toBeTruthy();
expect(el.querySelectorAll(".iaf-holder-4x3").length).toBe(2);
expect(holders[0].textContent).toContain("1:1");
});
});
+17 -4
View File
@@ -8,21 +8,34 @@ import { resolveEditableTheme } from "@/utils/themeEdit";
*/ */
describe("resolveEditableTheme 站点编辑主题归一(任务三)", () => { describe("resolveEditableTheme 站点编辑主题归一(任务三)", () => {
it("注册中的主题原值保留且不告警", () => { it("注册中的主题原值保留且不告警", () => {
expect(resolveEditableTheme("mint-breeze", ["default", "mint-breeze"])).toEqual({ expect(resolveEditableTheme("scholar-blue", ["default", "scholar-blue"])).toEqual({
theme: "mint-breeze", theme: "scholar-blue",
unregistered: false, unregistered: false,
}); });
}); });
it("docs/20 E:下架主题解析为保留主题 + colorScheme 预设(不误报未注册)", () => {
expect(resolveEditableTheme("classic-red", ["default", "scholar-blue", "campus-glow"])).toEqual({
theme: "default",
unregistered: false,
aliasColorScheme: "classic-red",
});
expect(resolveEditableTheme("poly-blue", ["default", "scholar-blue", "campus-glow"])).toEqual({
theme: "scholar-blue",
unregistered: false,
aliasColorScheme: "poly",
});
});
it("未注册主题保留原值并标记 unregistered(不再归一 default)", () => { it("未注册主题保留原值并标记 unregistered(不再归一 default)", () => {
expect(resolveEditableTheme("ghost-theme", ["default", "mint-breeze"])).toEqual({ expect(resolveEditableTheme("ghost-theme", ["default", "scholar-blue"])).toEqual({
theme: "ghost-theme", theme: "ghost-theme",
unregistered: true, unregistered: true,
}); });
}); });
it("空主题归一 default(站点未选过皮肤的场景)", () => { it("空主题归一 default(站点未选过皮肤的场景)", () => {
expect(resolveEditableTheme(undefined, ["default", "mint-breeze"])).toEqual({ expect(resolveEditableTheme(undefined, ["default", "scholar-blue"])).toEqual({
theme: "default", theme: "default",
unregistered: false, unregistered: false,
}); });
+19
View File
@@ -14,6 +14,7 @@ import {
DeleteRequest, DeleteRequest,
LinkVo, LinkVo,
PageResult, PageResult,
PersonVo,
SchoolCard, SchoolCard,
SchoolOption, SchoolOption,
SiteVo, SiteVo,
@@ -34,6 +35,7 @@ const ARTICLE = "/api/article";
const ATTACHMENT = "/api/attachment"; const ATTACHMENT = "/api/attachment";
const BANNER = "/api/banner"; const BANNER = "/api/banner";
const LINK = "/api/link"; const LINK = "/api/link";
const PERSON = "/api/person";
const THEME = "/api/theme"; const THEME = "/api/theme";
/** GET(query 参数) */ /** GET(query 参数) */
@@ -208,6 +210,21 @@ class LinkApi {
} }
} }
/** 名师风采人物(docs/20 D-7) */
class PersonApi {
list(siteId: number) {
return get<PersonVo[]>(`${PERSON}/list`, { siteId });
}
save(data: Partial<PersonVo>) {
return postJSON<PersonVo>(`${PERSON}/save`, data);
}
delete(data: DeleteRequest) {
return postJSON<void>(`${PERSON}/delete`, data);
}
}
class ThemeApi { class ThemeApi {
/** 可用主题列表(含 params schema,建站向导/站点编辑共用,方案 §5.2) */ /** 可用主题列表(含 params schema,建站向导/站点编辑共用,方案 §5.2) */
list() { list() {
@@ -271,6 +288,7 @@ export const categoryApi = new CategoryApi();
export const articleApi = new ArticleApi(); export const articleApi = new ArticleApi();
export const attachmentApi = new AttachmentApi(); export const attachmentApi = new AttachmentApi();
export const bannerApi = new BannerApi(); export const bannerApi = new BannerApi();
export const personApi = new PersonApi();
export const linkApi = new LinkApi(); export const linkApi = new LinkApi();
export const themeApi = new ThemeApi(); export const themeApi = new ThemeApi();
@@ -283,6 +301,7 @@ export default {
articleApi, articleApi,
attachmentApi, attachmentApi,
bannerApi, bannerApi,
personApi,
linkApi, linkApi,
themeApi, themeApi,
uploadUrl, uploadUrl,
+252
View File
@@ -0,0 +1,252 @@
<script setup lang="ts">
/**
* 区块类型缩略示意图(docs/20 F2 / R-40):添加抽屉类型卡 + 属性面板形态 RadioCard 共用。
* 纯内联 SVG 线框示意(本地资产,无外部 CDN——皮肤包契约红线仅约束门户,管理端同样不引);
* 每种 type/variant 一幅极简骨架图,新组件类型 = 在 THUMBS 补一支绘制函数(注册表驱动配套)。
*/
const props = withDefaults(
defineProps<{
/** 区块类型(BLOCK_TYPES.type) */
type: string;
/** article_list 展示形态(style),其余类型忽略 */
variant?: string;
}>(),
{ variant: "" },
);
/** 图底色板(示意线框用,与皮肤无关) */
const C = {
band: "#dce6f5",
block: "#e8eef8",
strong: "#9db8e0",
text: "#c3cfe2",
accent: "#5b8dd9",
};
</script>
<template>
<svg class="block-type-thumb" viewBox="0 0 120 64" preserveAspectRatio="xMidYMid meet" role="img" aria-hidden="true">
<!-- article_list 五形态 -->
<template v-if="type === 'article_list'">
<!-- card:头条图 + 摘要 + 行列表 -->
<template v-if="variant === 'card' || !variant">
<rect x="6" y="6" width="34" height="22" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="45" y="9" width="44" height="5" rx="1.5" :fill="C.strong" />
<rect x="45" y="18" width="66" height="3" rx="1.5" :fill="C.text" />
<rect x="45" y="24" width="56" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="34" width="108" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="42" width="100" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="50" width="104" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="58" width="92" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- news_grid:色带底 + 双栏行 -->
<template v-else-if="variant === 'news_grid'">
<rect x="0" y="0" width="120" height="64" :fill="C.band" />
<rect x="8" y="8" width="104" height="48" rx="2" fill="#ffffff" />
<rect x="14" y="14" width="40" height="4" rx="2" :fill="C.accent" />
<rect x="14" y="26" width="42" height="3" rx="1.5" :fill="C.text" />
<rect x="14" y="34" width="38" height="3" rx="1.5" :fill="C.text" />
<rect x="14" y="42" width="40" height="3" rx="1.5" :fill="C.text" />
<rect x="64" y="26" width="42" height="3" rx="1.5" :fill="C.text" />
<rect x="64" y="34" width="38" height="3" rx="1.5" :fill="C.text" />
<rect x="64" y="42" width="40" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- media_focus:来源徽标 + 行 -->
<template v-else-if="variant === 'media_focus'">
<rect x="6" y="8" width="24" height="8" rx="2" :fill="C.accent" />
<rect x="36" y="11" width="60" height="3" rx="1.5" :fill="C.strong" />
<rect x="6" y="24" width="20" height="8" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="32" y="27" width="70" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="40" width="18" height="8" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="30" y="43" width="64" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="54" width="34" height="4" rx="1.5" :fill="C.band" />
</template>
<!-- compact:纯文字行 -->
<template v-else-if="variant === 'compact'">
<rect x="8" y="10" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="10" width="84" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="21" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="21" width="76" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="32" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="32" width="88" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="43" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="43" width="72" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="54" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="54" width="80" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- image_wall:图片格阵 -->
<template v-else-if="variant === 'image_wall'">
<rect x="6" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="35" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="64" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="93" y="8" width="21" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="6" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="35" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="64" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="93" y="34" width="21" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
</template>
</template>
<!-- ticker:横条 + 滚动标题 -->
<template v-else-if="type === 'ticker'">
<rect x="0" y="20" width="120" height="24" :fill="C.accent" />
<rect x="6" y="28" width="26" height="8" rx="2" fill="#ffffff" opacity="0.9" />
<rect x="38" y="30" width="34" height="4" rx="2" fill="#ffffff" opacity="0.75" />
<rect x="78" y="30" width="30" height="4" rx="2" fill="#ffffff" opacity="0.6" />
</template>
<!-- gallery:居中标题 + 图墙 -->
<template v-else-if="type === 'gallery'">
<rect x="46" y="4" width="28" height="5" rx="2" :fill="C.strong" />
<rect x="38" y="11" width="44" height="2.5" rx="1.25" :fill="C.text" />
<rect x="8" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="37" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="95" y="20" width="19" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="8" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="37" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="95" y="44" width="19" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
</template>
<!-- links:胶囊按钮行 -->
<template v-else-if="type === 'links'">
<rect x="46" y="6" width="28" height="5" rx="2" :fill="C.strong" />
<rect x="10" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="38" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="94" y="22" width="18" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="24" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="52" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="80" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
</template>
<!-- quick_links:图标宫格 -->
<template v-else-if="type === 'quick_links'">
<circle cx="26" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="18" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="60" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="52" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="94" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="86" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="26" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="18" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="60" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="52" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="94" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="86" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- notice_bar:通栏提示条 -->
<template v-else-if="type === 'notice_bar'">
<rect x="0" y="22" width="120" height="20" :fill="C.band" />
<rect x="0" y="22" width="120" height="1" :fill="C.strong" />
<rect x="0" y="41" width="120" height="1" :fill="C.strong" />
<rect x="8" y="29" width="20" height="6" rx="2" :fill="C.accent" />
<rect x="34" y="31" width="70" height="3" rx="1.5" :fill="C.strong" />
</template>
<!-- D 系新组件(docs/20 D-1~D-9):D 步上线时同步补绘 -->
<!-- D-1 stats_band:大数字横排 -->
<template v-else-if="type === 'stats_band'">
<rect x="0" y="10" width="120" height="44" :fill="C.band" />
<text x="24" y="34" font-size="13" font-weight="700" :fill="C.accent">3.2万</text>
<rect x="20" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
<text x="60" y="34" font-size="13" font-weight="700" :fill="C.accent">2800</text>
<rect x="56" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
<text x="94" y="34" font-size="13" font-weight="700" :fill="C.accent">12</text>
<rect x="90" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- D-2 icon_nav:六边形图标阵 -->
<template v-else-if="type === 'icon_nav'">
<polygon points="26,6 38,13 38,27 26,34 14,27 14,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="16" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="60,6 72,13 72,27 60,34 48,27 48,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="50" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="94,6 106,13 106,27 94,34 82,27 82,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="84" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="26,46 38,53 38,67 26,74 14,67 14,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="16" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="60,46 72,53 72,67 60,74 48,67 48,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="50" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="94,46 106,53 106,67 94,74 82,67 82,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="84" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- D-3 featured_topics:专题大卡 -->
<template v-else-if="type === 'featured_topics'">
<rect x="6" y="8" width="52" height="48" rx="3" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="12" y="40" width="30" height="4" rx="2" fill="#ffffff" />
<rect x="12" y="48" width="18" height="4" rx="2" :fill="C.accent" />
<rect x="62" y="8" width="52" height="48" rx="3" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="68" y="40" width="30" height="4" rx="2" fill="#ffffff" />
<rect x="68" y="48" width="18" height="4" rx="2" :fill="C.accent" />
</template>
<!-- D-5 video_tour:视频封面 + 播放键 -->
<template v-else-if="type === 'video_tour'">
<rect x="6" y="8" width="108" height="48" rx="3" :fill="C.band" />
<circle cx="60" cy="32" r="12" fill="#ffffff" opacity="0.9" />
<polygon points="56,25 70,32 56,39" :fill="C.accent" />
</template>
<!-- D-6 events:大日期块 + 活动列表 -->
<template v-else-if="type === 'events'">
<rect x="6" y="8" width="26" height="26" rx="2" :fill="C.accent" />
<rect x="10" y="13" width="18" height="5" rx="1.5" fill="#ffffff" opacity="0.9" />
<rect x="38" y="12" width="50" height="4" rx="2" :fill="C.strong" />
<rect x="38" y="21" width="66" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="40" width="26" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="38" y="44" width="50" height="4" rx="2" :fill="C.strong" />
<rect x="38" y="53" width="66" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- D-7 people:圆头像卡 -->
<template v-else-if="type === 'people'">
<circle cx="24" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="12" y="43" width="24" height="4" rx="2" :fill="C.strong" />
<rect x="14" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
<circle cx="60" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="48" y="43" width="24" height="4" rx="2" :fill="C.strong" />
<rect x="50" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
<circle cx="96" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="84" y="43" width="24" height="4" rx="2" :fill="C.strong" />
<rect x="86" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- D-8 campus_wall:瀑布流(错落图) -->
<template v-else-if="type === 'campus_wall'">
<rect x="8" y="6" width="24" height="28" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="37" y="6" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="6" width="24" height="32" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="95" y="6" width="19" height="22" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="8" y="40" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="37" y="30" width="24" height="28" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="44" width="24" height="14" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="95" y="34" width="19" height="24" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
</template>
<!-- D-9 badges:徽章横排 -->
<template v-else-if="type === 'badges'">
<circle cx="26" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
<rect x="18" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="60" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
<rect x="52" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="94" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
<rect x="86" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- 兜底:区块头 + 行 -->
<template v-else>
<rect x="8" y="8" width="32" height="5" rx="2" :fill="C.strong" />
<rect x="8" y="22" width="104" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="32" width="96" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="42" width="100" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="52" width="88" height="3" rx="1.5" :fill="C.text" />
</template>
</svg>
</template>
<style scoped lang="scss">
.block-type-thumb {
display: block;
width: 100%;
height: 100%;
background: #fbfcfe;
border-radius: 4px;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { assetUrl } from "@/api/module/cms";
/**
* F5(docs/20)页脚结构示意图:页脚 Tab 内的固定示意图,标注各字段在门户页脚中的视觉位置——
* 品牌区(站名+联系方式+多校区地址行)/ 二维码区(微信+微博,4:3)/ 社媒图标行 / 版权备案行。
* 已填字段实时回显内容(填完即知页脚长什么样);空字段以灰字占位标注。
*/
const props = defineProps<{
contact?: string;
footerAddresses?: string;
qrWechat?: string;
qrWeibo?: string;
socials?: { label: string; value?: string }[];
copyright?: string;
beian?: string;
}>();
const addressLines = (props.footerAddresses || "")
.split(/\r?\n/)
.map(l => l.trim())
.filter(Boolean)
.slice(0, 3);
</script>
<template>
<div class="footer-preview">
<div class="fp-caption">页脚结构示意(下图与字段一一对应,已填内容实时回显)</div>
<div class="fp-frame" role="img" aria-label="页脚结构示意图">
<div class="fp-brand">
<div class="fp-logo">
<span class="fp-logo-mark">LOGO</span>
<span class="fp-site">XX学校</span>
</div>
<p class="fp-line" :class="{ filled: contact }">
{{ contact || "① 联系方式(页脚品牌区)" }}
</p>
<p v-for="(l, i) in addressLines" :key="i" class="fp-line small filled">{{ l }}</p>
<p v-if="!addressLines.length" class="fp-line small">② 多校区地址(每行一个校区)</p>
</div>
<div class="fp-qr">
<div class="fp-qr-box">
<img v-if="qrWechat" :src="assetUrl(qrWechat)" alt="微信二维码预览" />
<span v-else>微信二维码<br />③ 4:3</span>
</div>
<div class="fp-qr-box">
<img v-if="qrWeibo" :src="assetUrl(qrWeibo)" alt="微博二维码预览" />
<span v-else>微博二维码<br />③ 4:3</span>
</div>
</div>
<div class="fp-social">
<span class="fp-social-label">④ 社媒图标行:</span>
<span v-for="s in socials" :key="s.label" class="fp-social-item" :class="{ on: s.value }">
{{ s.label }}
</span>
</div>
<div class="fp-bottom">
<span :class="{ filled: copyright }">{{ copyright || "⑤ 版权文字" }}</span>
<span :class="{ filled: beian }" class="fp-beian">{{ beian || "⑥ 备案号" }}</span>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.footer-preview {
margin-top: 6px;
.fp-caption {
color: #8a93a6;
font-size: 12px;
margin-bottom: 8px;
}
.fp-frame {
border: 1px solid #e9edf5;
border-radius: 8px;
background: linear-gradient(180deg, #1f3a5f 0%, #16294a 100%);
color: rgba(255, 255, 255, 0.5);
padding: 18px 22px 12px;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px 18px;
font-size: 12px;
line-height: 1.7;
.fp-brand {
.fp-logo {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
.fp-logo-mark {
border: 1px dashed rgba(255, 255, 255, 0.35);
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
}
.fp-site {
color: rgba(255, 255, 255, 0.75);
font-weight: 600;
}
}
.fp-line {
margin: 2px 0;
&.small {
font-size: 11px;
opacity: 0.9;
}
&.filled {
color: #fff;
}
}
}
.fp-qr {
display: flex;
gap: 12px;
align-items: flex-start;
.fp-qr-box {
width: 84px;
height: 63px;
border: 1px dashed rgba(255, 255, 255, 0.4);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 10px;
position: relative;
overflow: hidden;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
}
.fp-social {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.fp-social-label {
color: rgba(255, 255, 255, 0.45);
}
.fp-social-item {
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 1px 10px;
font-size: 11px;
opacity: 0.55;
&.on {
opacity: 1;
border-color: rgba(255, 255, 255, 0.85);
color: #fff;
}
}
}
.fp-bottom {
grid-column: 1 / -1;
border-top: 1px solid rgba(255, 255, 255, 0.15);
padding-top: 8px;
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 11px;
.filled {
color: rgba(255, 255, 255, 0.85);
}
}
}
}
</style>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
/**
* D-2(docs/20)内置图标选择器:图标网格预览 + 点选即绑定图标标识符(§3.3 定案 2 综合方案 A+C)。
* 数据源 icons.ts(与门户骨架 fragment 同 key);再次点击已选图标 = 清除(回 null = 首字徽章兜底)。
*/
import { ICON_DEFS } from "@/views/cms/homeLayout/icons";
const model = defineModel<string | null>("modelValue", { default: null });
function pick(key: string) {
model.value = model.value === key ? null : key;
}
</script>
<template>
<div class="icon-grid">
<button
v-for="icon in ICON_DEFS"
:key="icon.key"
type="button"
class="icon-cell"
:class="{ active: model === icon.key }"
:title="`${icon.label}(${icon.key})`"
@click="pick(icon.key)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path v-for="(d, i) in icon.paths" :key="i" :d="d" />
</svg>
<span class="icon-name">{{ icon.label }}</span>
</button>
<p class="icon-tip">未选图标时入口渲染「名称首字徽章」;再次点击已选图标可清除</p>
</div>
</template>
<style scoped lang="scss">
.icon-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
.icon-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
border: 1px solid #e2e8f2;
border-radius: 6px;
background: #fff;
padding: 7px 2px 5px;
cursor: pointer;
color: #515a6e;
transition: border-color 0.2s, color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #5cadff;
color: #2d8cf0;
}
&.active {
border-color: #2d8cf0;
color: #2d8cf0;
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.12);
}
svg {
width: 22px;
height: 22px;
}
.icon-name {
font-size: 11px;
line-height: 1.2;
}
}
.icon-tip {
grid-column: 1 / -1;
color: #999;
font-size: 12px;
margin: 4px 0 0;
}
}
</style>
+36 -1
View File
@@ -15,6 +15,9 @@ const props = defineProps<{
/** 附件场景(空=不带 scene;svc 场景已迁办事大厅,CMS 内为 article/banner 等) */ /** 附件场景(空=不带 scene;svc 场景已迁办事大厅,CMS 内为 article/banner 等) */
scene?: string; scene?: string;
placeholder?: string; placeholder?: string;
/** F5(docs/20):预览/占位框比例("1:1" favicon / "4:3" 二维码;缺省 8:5 通用横图),
* 无值时按比例显示虚线占位框,帮助确认最终在门户中的尺寸感 */
aspect?: "1:1" | "4:3";
}>(); }>();
const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>(); const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>();
@@ -76,12 +79,16 @@ function clearValue() {
> >
<Button size="small" icon="ios-cloud-upload-outline" :loading="uploading">上传图片</Button> <Button size="small" icon="ios-cloud-upload-outline" :loading="uploading">上传图片</Button>
</Upload> </Upload>
<span v-else class="iaf-thumb"> <span v-else class="iaf-thumb" :class="aspect ? 'iaf-thumb-' + aspect.replace(':', 'x') : ''">
<img :src="previewSrc" alt="" /> <img :src="previewSrc" alt="" />
<span class="iaf-thumb-del" title="清除" @click="clearValue"> <span class="iaf-thumb-del" title="清除" @click="clearValue">
<Icon type="ios-close" /> <Icon type="ios-close" />
</span> </span>
</span> </span>
<!-- F5:无值时按字段比例显示虚线占位框(尺寸感示意) -->
<span v-if="!modelValue && aspect" class="iaf-holder" :class="'iaf-holder-' + aspect!.replace(':', 'x')">
{{ aspect }} 位
</span>
<Button size="small" type="text" class="iaf-toggle" @click="manualOpen = !manualOpen"> <Button size="small" type="text" class="iaf-toggle" @click="manualOpen = !manualOpen">
{{ manualOpen ? "收起" : "手动粘贴路径" }} {{ manualOpen ? "收起" : "手动粘贴路径" }}
</Button> </Button>
@@ -123,6 +130,16 @@ function clearValue() {
border: 1px solid #dcdee2; border: 1px solid #dcdee2;
background: #f8f8f9; background: #f8f8f9;
} }
&.iaf-thumb-1x1 img {
width: 48px;
height: 48px;
object-fit: contain;
}
&.iaf-thumb-4x3 img {
width: 88px;
height: 66px;
object-fit: contain;
}
.iaf-thumb-del { .iaf-thumb-del {
position: absolute; position: absolute;
top: -7px; top: -7px;
@@ -142,6 +159,24 @@ function clearValue() {
display: block; display: block;
} }
} }
.iaf-holder {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px dashed #cfd8e6;
border-radius: 4px;
color: #b4bfce;
font-size: 11px;
background: #fafbfd;
&.iaf-holder-1x1 {
width: 48px;
height: 48px;
}
&.iaf-holder-4x3 {
width: 88px;
height: 66px;
}
}
.iaf-manual { .iaf-manual {
max-width: 420px; max-width: 420px;
margin-top: 6px; margin-top: 6px;
+8
View File
@@ -67,6 +67,14 @@ const cmsChildren: RouteRecordRaw[] = [
component: () => import("@/views/cms/BannerManage.vue"), component: () => import("@/views/cms/BannerManage.vue"),
meta: { title: "轮播管理", modId: 3 }, meta: { title: "轮播管理", modId: 3 },
}, },
{
// docs/20 D-7:名师风采数据维护(people 区块数据源)。菜单挂靠轮播管理族(平台 UserBase 未配
// 独立模块前经首页布局编辑器 people 区块属性面板直达;modId 3 = 轮播管理,面包屑归属一致)
path: "person",
name: "PersonManage",
component: () => import("@/views/cms/PersonManage.vue"),
meta: { title: "人物管理", modId: 3, subOf: "/banner" },
},
{ {
path: "link", path: "link",
name: "LinkManage", name: "LinkManage",
+28
View File
@@ -211,6 +211,17 @@ export interface BannerVo {
startTime?: string; startTime?: string;
endTime?: string; endTime?: string;
status: number; status: number;
// ===== docs/20 D-3/D-6:topic/event 展示位扩展字段 =====
/** 专题大卡描述(position=topic) */
description?: string;
/** 专题大卡 CTA 按钮文字 */
ctaText?: string;
/** 专题大卡 CTA 跳转地址 */
ctaUrl?: string;
/** 活动日期(position=event,yyyy-MM-dd) */
eventDate?: string;
/** 活动地点(position=event) */
location?: string;
} }
/* ===== 友链 ===== */ /* ===== 友链 ===== */
@@ -270,3 +281,20 @@ export interface ThemeVo {
} }
/* ===== 事项库(svc,事项库详细设计 §7.4)===== */ /* ===== 事项库(svc,事项库详细设计 §7.4)===== */
/* ===== 名师风采(docs/20 D-7,PersonVo) ===== */
export interface PersonVo {
id?: number;
siteId: number;
name: string;
/** 头衔/职称 */
title?: string;
/** 一句话简介 */
bio?: string;
/** 头像 URL(空 = 首字徽章兜底) */
avatar?: string;
/** 所属栏目 id(空 = 未分组) */
categoryId?: number;
sortNo: number;
status: number;
}
+11 -41
View File
@@ -1,17 +1,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
EXTRA_KEY_RE,
SITE_CONFIG_FIELD_DEFS, SITE_CONFIG_FIELD_DEFS,
extraRowError,
firstExtraError,
parseSiteConfig, parseSiteConfig,
serializeSiteConfig, serializeSiteConfig,
validateFieldValue, validateFieldValue,
} from "./siteConfigForm"; } from "./siteConfigForm";
/** docs/09 §3.1 站点配置结构化:parse/serialize/校验 */ /** docs/09 §3.1 站点配置结构化:parse/serialize/校验;docs/20 F6 扩展参数区退役(未知键静默保留) */
describe("siteConfigForm", () => { describe("siteConfigForm", () => {
it("parse:已知键入 fields、未知字符串键入扩展行、theme_params/对象键无损入 preserved", () => { it("parse:已知键入 fields、未知键(字符串/对象)与系统托管键均无损入 preserved(F6)", () => {
const json = JSON.stringify({ const json = JSON.stringify({
logo: "/cms/upload/logo.png", logo: "/cms/upload/logo.png",
contact: "电话 0571-88888888", contact: "电话 0571-88888888",
@@ -26,14 +23,14 @@ describe("siteConfigForm", () => {
expect(m.fields.contact).toBe("电话 0571-88888888"); expect(m.fields.contact).toBe("电话 0571-88888888");
expect(m.fields.domain).toBe("www.xxx.edu.cn"); expect(m.fields.domain).toBe("www.xxx.edu.cn");
expect(m.fields.public_base_url).toBe("https://www.xxx.edu.cn"); expect(m.fields.public_base_url).toBe("https://www.xxx.edu.cn");
expect(m.extras).toEqual([{ key: "my_ext", value: "自定义值" }]); expect(m.preserved.my_ext).toBe("自定义值"); // F6:未知字符串键不再进扩展行,静默保留
expect(m.preserved.theme_params).toEqual({ primary: "#b01a2e" }); expect(m.preserved.theme_params).toEqual({ primary: "#b01a2e" });
expect(m.preserved.wechat).toEqual({ appid: "wx123" }); expect(m.preserved.wechat).toEqual({ appid: "wx123" });
}); });
it("parse:非法 JSON / 空值按空配置处理,已知键非字符串值无损保留", () => { it("parse:非法 JSON / 空值按空配置处理,已知键非字符串值无损保留", () => {
expect(parseSiteConfig("这不是json{").fields).toEqual({}); expect(parseSiteConfig("这不是json{").fields).toEqual({});
expect(parseSiteConfig(null).extras).toEqual([]); expect(parseSiteConfig(null).preserved).toEqual({});
expect(parseSiteConfig("").preserved).toEqual({}); expect(parseSiteConfig("").preserved).toEqual({});
const m = parseSiteConfig(JSON.stringify({ logo: 123, other: [1, 2] })); const m = parseSiteConfig(JSON.stringify({ logo: 123, other: [1, 2] }));
expect(m.fields.logo).toBeUndefined(); expect(m.fields.logo).toBeUndefined();
@@ -41,22 +38,17 @@ describe("siteConfigForm", () => {
expect(m.preserved.other).toEqual([1, 2]); expect(m.preserved.other).toEqual([1, 2]);
}); });
it("serialize:空值剔除、扩展行空键/空值剔除、preserved 永不丢失", () => { it("serialize:空值剔除、preserved 永不丢失", () => {
const m = parseSiteConfig(JSON.stringify({ logo: "/cms/upload/logo.png", theme_params: { primary: "#fff" }, wechat: { x: 1 } })); const m = parseSiteConfig(JSON.stringify({ logo: "/cms/upload/logo.png", theme_params: { primary: "#fff" }, wechat: { x: 1 } }));
m.fields.contact = " "; // 空白 → 剔除 m.fields.contact = " "; // 空白 → 剔除
m.extras.push({ key: "k1", value: "v1" });
m.extras.push({ key: "", value: "被剔除" });
m.extras.push({ key: "k2", value: " " }); // 空值 → 剔除
const out = JSON.parse(serializeSiteConfig(m)); const out = JSON.parse(serializeSiteConfig(m));
expect(out.logo).toBe("/cms/upload/logo.png"); expect(out.logo).toBe("/cms/upload/logo.png");
expect(out).not.toHaveProperty("contact"); expect(out).not.toHaveProperty("contact");
expect(out.k1).toBe("v1");
expect(out).not.toHaveProperty("k2");
expect(out.theme_params).toEqual({ primary: "#fff" }); expect(out.theme_params).toEqual({ primary: "#fff" });
expect(out.wechat).toEqual({ x: 1 }); expect(out.wechat).toEqual({ x: 1 });
}); });
it("roundtrip:parse→serialize→parse 内容不丢(含扩展行与 preserved)", () => { it("roundtrip:parse→serialize→parse 内容不丢(未知键与 preserved 全保留)", () => {
const json = JSON.stringify({ const json = JSON.stringify({
copyright: "XX学校 · 版权所有 © 2026", copyright: "XX学校 · 版权所有 © 2026",
seo_description: "校讯", seo_description: "校讯",
@@ -68,12 +60,12 @@ describe("siteConfigForm", () => {
const twice = parseSiteConfig(serializeSiteConfig(once)); const twice = parseSiteConfig(serializeSiteConfig(once));
expect(twice.fields.slogan).toBe("厚德博学"); expect(twice.fields.slogan).toBe("厚德博学");
expect(twice.fields.copyright).toBe("XX学校 · 版权所有 © 2026"); expect(twice.fields.copyright).toBe("XX学校 · 版权所有 © 2026");
expect(twice.extras).toEqual([{ key: "my_ext", value: "v" }]); expect(twice.preserved.my_ext).toBe("v");
expect(twice.preserved.theme_params).toEqual({ bg: "#f7f5f2" }); expect(twice.preserved.theme_params).toEqual({ bg: "#f7f5f2" });
}); });
it("validateFieldValue:url/host/asset/text 各类型通过与拒绝", () => { it("validateFieldValue:url/host/asset/text 各类型通过与拒绝", () => {
const def = (kind: string, maxLength?: number) => const def = (kind: string) =>
SITE_CONFIG_FIELD_DEFS.find(d => d.key === { url: "public_base_url", host: "domain", asset: "logo", text: "copyright" }[kind])!; SITE_CONFIG_FIELD_DEFS.find(d => d.key === { url: "public_base_url", host: "domain", asset: "logo", text: "copyright" }[kind])!;
expect(validateFieldValue(def("url"), "https://www.xxx.edu.cn")).toBe(""); expect(validateFieldValue(def("url"), "https://www.xxx.edu.cn")).toBe("");
expect(validateFieldValue(def("url"), "www.xxx.edu.cn")).toContain("http"); expect(validateFieldValue(def("url"), "www.xxx.edu.cn")).toContain("http");
@@ -87,26 +79,6 @@ describe("siteConfigForm", () => {
expect(validateFieldValue(def("url"), "")).toBe(""); // 空值一律放行(非必填) expect(validateFieldValue(def("url"), "")).toBe(""); // 空值一律放行(非必填)
}); });
it("extraRowError:格式/重名/重复校验;空键行放行", () => {
const rows = [{ key: "my_ext", value: "v" }];
expect(extraRowError(rows, 0)).toBe("");
expect(extraRowError([{ key: "", value: "v" }], 0)).toBe("");
expect(extraRowError([{ key: "my-ext", value: "v" }], 0)).toContain("下划线");
expect(extraRowError([{ key: "1bad", value: "v" }], 0)).toContain("下划线");
expect(extraRowError([{ key: "logo", value: "v" }], 0)).toContain("重名");
expect(extraRowError([{ key: "theme_params", value: "v" }], 0)).toContain("重名");
expect(extraRowError([{ key: "a", value: "1" }, { key: "a", value: "2" }], 1)).toContain("重复");
expect(EXTRA_KEY_RE.test("wechat_appid")).toBe(true);
expect(EXTRA_KEY_RE.test("_priv")).toBe(true);
expect(EXTRA_KEY_RE.test("9bad")).toBe(false);
});
it("firstExtraError:返回第一条错误;全部合法返回空串", () => {
expect(firstExtraError([{ key: "ok_key", value: "1" }, { key: "bad-key", value: "2" }])).toContain("下划线");
expect(firstExtraError([{ key: "a", value: "1" }, { key: "b", value: "2" }])).toBe("");
expect(firstExtraError([])).toBe("");
});
it("已知键清单与 docs/09 §0 键盘点表一致(防漂移;docs/16 D4 吸收三旧键后收敛)", () => { it("已知键清单与 docs/09 §0 键盘点表一致(防漂移;docs/16 D4 吸收三旧键后收敛)", () => {
expect(SITE_CONFIG_FIELD_DEFS.map(d => d.key)).toEqual([ expect(SITE_CONFIG_FIELD_DEFS.map(d => d.key)).toEqual([
"logo", "favicon", "slogan", "colleges_label", "logo", "favicon", "slogan", "colleges_label",
@@ -128,7 +100,6 @@ describe("siteConfigForm", () => {
expect(m.fields.footer_addresses).toContain("淮河校区"); expect(m.fields.footer_addresses).toContain("淮河校区");
expect(m.fields.qr_wechat).toBe("/cms/upload/qr-wechat.png"); expect(m.fields.qr_wechat).toBe("/cms/upload/qr-wechat.png");
expect(m.fields.social_weibo).toBe("https://weibo.com/xxx"); expect(m.fields.social_weibo).toBe("https://weibo.com/xxx");
expect(m.extras).toEqual([]); // 新键均为已知键,不落扩展行
const qrDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "qr_weibo")!; const qrDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "qr_weibo")!;
const socialDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "social_douyin")!; const socialDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "social_douyin")!;
expect(validateFieldValue(qrDef, "javascript:alert(1)")).toContain("路径"); expect(validateFieldValue(qrDef, "javascript:alert(1)")).toContain("路径");
@@ -139,19 +110,18 @@ describe("siteConfigForm", () => {
expect(out.footer_addresses).toContain("邮编:464300"); expect(out.footer_addresses).toContain("邮编:464300");
expect(out).not.toHaveProperty("social_bilibili"); expect(out).not.toHaveProperty("social_bilibili");
}); });
it("docs/16 D4:gallery_title/gallery_subtitle/news_bg_image 被吸收为静默保留键(不落扩展行、无损回写)", () => { 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 json = JSON.stringify({ gallery_title: "线上展厅", news_bg_image: "/cms/upload/campus.jpg" });
const m = parseSiteConfig(json); const m = parseSiteConfig(json);
expect(m.fields).toEqual({}); // 不进任何分区表单 expect(m.fields).toEqual({}); // 不进任何分区表单
expect(m.extras).toEqual([]); // 也不落扩展行
expect(m.preserved.gallery_title).toBe("线上展厅"); expect(m.preserved.gallery_title).toBe("线上展厅");
expect(m.preserved.news_bg_image).toBe("/cms/upload/campus.jpg"); expect(m.preserved.news_bg_image).toBe("/cms/upload/campus.jpg");
expect(JSON.parse(serializeSiteConfig(m))).toEqual(JSON.parse(json)); // 存量值无损保留 expect(JSON.parse(serializeSiteConfig(m))).toEqual(JSON.parse(json)); // 存量值无损保留
}); });
it("B3:custom_css 为保留键,不入扩展行,parse/serialize 无损往返", () => { it("B3:custom_css 为保留键,parse/serialize 无损往返", () => {
const json = JSON.stringify({ custom_css: ".site-header{padding:16px 0}", my_ext: "v" }); const json = JSON.stringify({ custom_css: ".site-header{padding:16px 0}", my_ext: "v" });
const m = parseSiteConfig(json); const m = parseSiteConfig(json);
expect(m.extras).toEqual([{ key: "my_ext", value: "v" }]); expect(m.preserved.my_ext).toBe("v");
expect(m.preserved.custom_css).toBe(".site-header{padding:16px 0}"); expect(m.preserved.custom_css).toBe(".site-header{padding:16px 0}");
expect(JSON.parse(serializeSiteConfig(m)).custom_css).toBe(".site-header{padding:16px 0}"); expect(JSON.parse(serializeSiteConfig(m)).custom_css).toBe(".site-header{padding:16px 0}");
}); });
+48 -63
View File
@@ -1,7 +1,8 @@
/** /**
* 站点配置结构化表单纯函数模块(docs/09 §3.1)。 * 站点配置结构化表单纯函数模块(docs/09 §3.1;docs/20 F6 扩展参数分区退役)。
* 已知键与模板/Java 实际消费方对齐(docs/09 §0 键清单表);未知字符串键 → 可编辑扩展行 * 已知键与模板/Java 实际消费方对齐(docs/09 §0 键清单表);
* (Projman「添加项」模式);未知非字符串键(theme_params 等系统托管键)原样保留无损回写。 * 未知键(字符串与非字符串)一律无损入 preserved 静默保留——不再提供「扩展参数」编辑区
* (后端对未知键的宽容策略不变:不拦截、不渲染、不丢失)。
* vitest 单测对象:siteConfigForm.spec.ts,不依赖组件挂载基建。 * vitest 单测对象:siteConfigForm.spec.ts,不依赖组件挂载基建。
*/ */
@@ -16,6 +17,8 @@ export interface SiteConfigFieldDef {
multiline?: boolean; multiline?: boolean;
/** url: http(s) URL;host: 域名(不带协议);asset: / 开头路径或 http(s) URL;text: 仅长度 */ /** url: http(s) URL;host: 域名(不带协议);asset: / 开头路径或 http(s) URL;text: 仅长度 */
kind?: "text" | "url" | "host" | "asset"; kind?: "text" | "url" | "host" | "asset";
/** F5(docs/20):asset 类字段的预览/占位框比例(favicon 1:1、二维码 4:3) */
aspect?: "1:1" | "4:3";
maxLength?: number; maxLength?: number;
} }
@@ -27,8 +30,9 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
tip: "门户顶栏与页脚品牌位展示;建议先在「附件管理」上传,复制路径填到这里。", tip: "门户顶栏与页脚品牌位展示;建议先在「附件管理」上传,复制路径填到这里。",
}, },
{ {
key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500, key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500, aspect: "1:1",
placeholder: "如 /yschcms/upload/…/favicon.png(留空用主题内置图标)", placeholder: "如 /yschcms/upload/…/favicon.png(留空用主题内置图标)",
tip: "浏览器标签页与收藏夹的小图标,不显示在页面正文中。",
}, },
{ {
key: "slogan", label: "首页标语", group: "display", kind: "text", maxLength: 200, key: "slogan", label: "首页标语", group: "display", kind: "text", maxLength: 200,
@@ -42,6 +46,7 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
{ {
key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true, key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true,
placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号", placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号",
tip: "显示在页脚品牌区(页脚第一行位置)。",
}, },
{ {
key: "footer_addresses", label: "多校区地址", group: "footer", kind: "text", maxLength: 500, multiline: true, key: "footer_addresses", label: "多校区地址", group: "footer", kind: "text", maxLength: 500, multiline: true,
@@ -49,37 +54,44 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
tip: "逐行展示在页脚品牌区旁;换行即分行(最多 500 字)。", tip: "逐行展示在页脚品牌区旁;换行即分行(最多 500 字)。",
}, },
{ {
key: "qr_wechat", label: "微信二维码", group: "footer", kind: "asset", maxLength: 500, key: "qr_wechat", label: "微信二维码", group: "footer", kind: "asset", maxLength: 500, aspect: "4:3",
placeholder: "如 /yschcms/upload/…/qr-wechat.png(留空不展示)", placeholder: "如 /yschcms/upload/…/qr-wechat.png(留空不展示)",
tip: "页脚二维码,悬停放大预览;建议先在「附件管理」上传。", tip: "页脚二维码,悬停放大预览;建议先在「附件管理」上传。",
}, },
{ {
key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500, key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500, aspect: "4:3",
placeholder: "如 /yschcms/upload/…/qr-weibo.png(留空不展示)", placeholder: "如 /yschcms/upload/…/qr-weibo.png(留空不展示)",
tip: "显示在页脚右侧二维码区,与微信二维码并排,悬停放大预览。",
}, },
{ {
key: "social_wechat", label: "微信公众号", group: "footer", kind: "url", maxLength: 500, key: "social_wechat", label: "微信公众号", group: "footer", kind: "url", maxLength: 500,
placeholder: "微信公众号主页链接,如 https://mp.weixin.qq.com/…(留空不展示图标)", placeholder: "微信公众号主页链接,如 https://mp.weixin.qq.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
}, },
{ {
key: "social_weibo", label: "微博", group: "footer", kind: "url", maxLength: 500, key: "social_weibo", label: "微博", group: "footer", kind: "url", maxLength: 500,
placeholder: "微博主页链接 https://weibo.com/…(留空不展示图标)", placeholder: "微博主页链接 https://weibo.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
}, },
{ {
key: "social_douyin", label: "抖音", group: "footer", kind: "url", maxLength: 500, key: "social_douyin", label: "抖音", group: "footer", kind: "url", maxLength: 500,
placeholder: "抖音主页链接 https://www.douyin.com/…(留空不展示图标)", placeholder: "抖音主页链接 https://www.douyin.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
}, },
{ {
key: "social_bilibili", label: "哔哩哔哩", group: "footer", kind: "url", maxLength: 500, key: "social_bilibili", label: "哔哩哔哩", group: "footer", kind: "url", maxLength: 500,
placeholder: "B 站主页链接 https://space.bilibili.com/…(留空不展示图标)", placeholder: "B 站主页链接 https://space.bilibili.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
}, },
{ {
key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200, key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200,
placeholder: "如:XX学校 · 版权所有 © 2026", placeholder: "如:XX学校 · 版权所有 © 2026",
tip: "页脚最底行的版权声明文字。",
}, },
{ {
key: "beian", label: "备案号", group: "footer", kind: "text", maxLength: 100, key: "beian", label: "备案号", group: "footer", kind: "text", maxLength: 100,
placeholder: "如:浙ICP备XXXXXXXX号-1", placeholder: "如:浙ICP备XXXXXXXX号-1",
tip: "页脚最底行版权文字旁的 ICP 备案号(可点击性质由模板决定,通常为纯文字)。",
}, },
{ {
key: "seo_description", label: "SEO 描述", group: "seo", kind: "text", maxLength: 500, multiline: true, key: "seo_description", label: "SEO 描述", group: "seo", kind: "text", maxLength: 500, multiline: true,
@@ -121,28 +133,22 @@ export const RESERVED_SITE_CONFIG_KEYS = ["theme_params", "custom_css"];
*/ */
export const ABSORBED_LEGACY_KEYS = ["gallery_title", "gallery_subtitle", "news_bg_image"]; export const ABSORBED_LEGACY_KEYS = ["gallery_title", "gallery_subtitle", "news_bg_image"];
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致) */ /** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致;供后续功能键落位时复用) */
export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
export const HOST_RE = /^(\*\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/; export const HOST_RE = /^(\*\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
export const URL_RE = /^https?:\/\/\S+$/; export const URL_RE = /^https?:\/\/\S+$/;
export const ASSET_RE = /^(\/.+|https?:\/\/\S+)$/; export const ASSET_RE = /^(\/.+|https?:\/\/\S+)$/;
export interface SiteConfigExtraRow {
key: string;
value: string;
}
export interface SiteConfigFormModel { export interface SiteConfigFormModel {
fields: Record<string, string>; fields: Record<string, string>;
extras: SiteConfigExtraRow[]; /** 非已知键(含系统托管键与未知键)无损保留 */
/** 系统托管键 + 非字符串未知键,无损保留 */
preserved: Record<string, unknown>; preserved: Record<string, unknown>;
} }
/** configJson → 表单模型:已知键入 fields、未知字符串键入扩展行、其余无损入 preserved */ /** configJson → 表单模型:已知键入 fields,其余(系统托管键/旧键/未知键)无损入 preserved */
export function parseSiteConfig(configJson: string | undefined | null): SiteConfigFormModel { export function parseSiteConfig(configJson: string | undefined | null): SiteConfigFormModel {
const model: SiteConfigFormModel = { fields: {}, extras: [], preserved: {} }; const model: SiteConfigFormModel = { fields: {}, preserved: {} };
if (!configJson || !configJson.trim()) { if (!configJson || !configJson.trim()) {
return model; return model;
} }
@@ -159,23 +165,18 @@ export function parseSiteConfig(configJson: string | undefined | null): SiteConf
return model; return model;
} }
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key)); const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
const reserved = new Set([...RESERVED_SITE_CONFIG_KEYS, ...ABSORBED_LEGACY_KEYS]);
Object.keys(parsed).forEach(k => { Object.keys(parsed).forEach(k => {
const v = parsed![k]; const v = parsed![k];
if (typeof v !== "string") { if (known.has(k) && typeof v === "string") {
model.preserved[k] = v;
} else if (known.has(k)) {
model.fields[k] = v; model.fields[k] = v;
} else if (reserved.has(k)) {
model.preserved[k] = v;
} else { } else {
model.extras.push({ key: k, value: v }); model.preserved[k] = v;
} }
}); });
return model; return model;
} }
/** 表单模型 → configJson:空值剔除;扩展行空键/空值剔除;preserved 原样回写(永不丢失) */ /** 表单模型 → configJson:空值剔除;preserved 原样回写(永不丢失) */
export function serializeSiteConfig(model: SiteConfigFormModel): string { export function serializeSiteConfig(model: SiteConfigFormModel): string {
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
SITE_CONFIG_FIELD_DEFS.forEach(def => { SITE_CONFIG_FIELD_DEFS.forEach(def => {
@@ -184,19 +185,35 @@ export function serializeSiteConfig(model: SiteConfigFormModel): string {
out[def.key] = v; out[def.key] = v;
} }
}); });
(model.extras || []).forEach(row => {
const k = (row.key || "").trim();
const v = (row.value || "").trim();
if (k && v) {
out[k] = v;
}
});
Object.keys(model.preserved || {}).forEach(k => { Object.keys(model.preserved || {}).forEach(k => {
out[k] = model.preserved[k]; out[k] = model.preserved[k];
}); });
return JSON.stringify(out); return JSON.stringify(out);
} }
/**
* F1(docs/20)Tab 独立保存:服务端事实源 base + 本 Tab 增量 patch 合并——
* patch 值为空串/null/undefined = 删除该键;其余键原样保留。
* 各 Tab 只提交自己的键空间,其他 Tab 未保存的内存修改不会被连带提交。
*/
export function mergeConfigPatch(base: string | undefined | null, patch: Record<string, unknown>): string {
let obj: Record<string, unknown>;
try {
const parsed = base && base.trim() ? JSON.parse(base) : {};
obj = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
} catch {
obj = {};
}
Object.entries(patch).forEach(([k, v]) => {
if (v === undefined || v === null || (typeof v === "string" && !v.trim())) {
delete obj[k];
} else {
obj[k] = v;
}
});
return JSON.stringify(obj);
}
/** 已知字段值校验:返回错误消息,空串=通过(与后端 validateConfigJson 同语义) */ /** 已知字段值校验:返回错误消息,空串=通过(与后端 validateConfigJson 同语义) */
export function validateFieldValue(def: SiteConfigFieldDef, value: string): string { export function validateFieldValue(def: SiteConfigFieldDef, value: string): string {
const v = (value || "").trim(); const v = (value || "").trim();
@@ -214,35 +231,3 @@ export function validateFieldValue(def: SiteConfigFieldDef, value: string): stri
return def.maxLength && v.length > def.maxLength ? `不能超过 ${def.maxLength} 字` : ""; return def.maxLength && v.length > def.maxLength ? `不能超过 ${def.maxLength} 字` : "";
} }
} }
/** 扩展行校验:键名格式 / 与内置键或系统键冲突 / 行间重复;返回错误消息,空串=通过(空键行视为待填) */
export function extraRowError(rows: SiteConfigExtraRow[], index: number): string {
const row = rows[index];
const key = (row?.key || "").trim();
if (!key) {
return "";
}
if (!EXTRA_KEY_RE.test(key)) {
return "键名仅限字母/数字/下划线,且以字母或下划线开头";
}
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
if (known.has(key) || RESERVED_SITE_CONFIG_KEYS.includes(key)) {
return "扩展键不能与内置键/系统键重名";
}
const dup = rows.findIndex(r => (r.key || "").trim() === key);
if (dup >= 0 && dup !== index) {
return "扩展键重复";
}
return "";
}
/** 保存前兜底:返回第一条扩展行错误,空串=全部通过 */
export function firstExtraError(rows: SiteConfigExtraRow[]): string {
for (let i = 0; i < (rows || []).length; i++) {
const msg = extraRowError(rows, i);
if (msg) {
return msg;
}
}
return "";
}
+22 -1
View File
@@ -5,17 +5,38 @@
* 新行为:保留原值交服务端注册表校验(保存未注册主题会被明确拒绝),仅提示告警。 * 新行为:保留原值交服务端注册表校验(保存未注册主题会被明确拒绝),仅提示告警。
*/ */
export interface EditableTheme { export interface EditableTheme {
/** 编辑表单主题值(未注册时保留原值,不再偷偷归一 default) */ /** 编辑表单主题值(未注册时保留原值,不再偷偷归一 default;下架别名解析为保留主题) */
theme: string; theme: string;
/** 主题未在注册列表中(保存将被服务端拒绝,需提示用户改选或先上传) */ /** 主题未在注册列表中(保存将被服务端拒绝,需提示用户改选或先上传) */
unregistered: boolean; unregistered: boolean;
/** docs/20 E:下架主题别名解析到的 colorScheme 预设(保存后保持原配色;非别名为 undefined) */
aliasColorScheme?: string;
} }
/** docs/20 E:下架主题别名表(与后端 ThemeRegistry.DEPRECATED_ALIASES 同构,两侧同步维护) */
const DEPRECATED_ALIASES: Record<string, { target: string; colorScheme: string }> = {
"blue-white": { target: "default", colorScheme: "blue-white" },
"classic-red": { target: "default", colorScheme: "classic-red" },
"fresh-mint": { target: "default", colorScheme: "fresh-mint" },
"warm-kinder": { target: "default", colorScheme: "warm-kinder" },
"academic-navy": { target: "scholar-blue", colorScheme: "navy" },
"campus-azure": { target: "scholar-blue", colorScheme: "azure" },
"poly-blue": { target: "scholar-blue", colorScheme: "poly" },
"agri-forest": { target: "scholar-blue", colorScheme: "agri" },
"jade-normal": { target: "scholar-blue", colorScheme: "jade" },
"mint-breeze": { target: "scholar-blue", colorScheme: "mint" },
};
export function resolveEditableTheme(rowTheme: string | undefined, themeNames: string[]): EditableTheme { export function resolveEditableTheme(rowTheme: string | undefined, themeNames: string[]): EditableTheme {
const value = rowTheme || "default"; const value = rowTheme || "default";
if (!themeNames.length) { if (!themeNames.length) {
// 列表加载失败/为空:无法判定注册状态,保留原值且不误报(由服务端校验兜底) // 列表加载失败/为空:无法判定注册状态,保留原值且不误报(由服务端校验兜底)
return { theme: value, unregistered: false }; return { theme: value, unregistered: false };
} }
// docs/20 E:下架主题 → 呈现归一后的保留主题(与服务端保存归一化一致,不误报"未注册")
const alias = DEPRECATED_ALIASES[value];
if (alias && themeNames.includes(alias.target)) {
return { theme: alias.target, unregistered: false, aliasColorScheme: alias.colorScheme };
}
return { theme: value, unregistered: !themeNames.includes(value) }; return { theme: value, unregistered: !themeNames.includes(value) };
} }
+72 -18
View File
@@ -20,13 +20,19 @@ const linkTypeDict = [
]; ];
const linkTypeText = (v: number) => linkTypeDict.find(i => i.value === v)?.label || "-"; const linkTypeText = (v: number) => linkTypeDict.find(i => i.value === v)?.label || "-";
/** 展示位(docs/12):home=首页轮播 / gallery=线上展厅图片画廊 */ /** 展示位(docs/12;docs/20 F4 双 Tab 化):home=首页轮播 / gallery=线上展厅图片画廊 */
const position = ref("home"); const position = ref("home");
/** 弹窗打开时的展示位锚点(F4:保存归属以打开时所在 Tab 为准,弹窗期间切换 Tab 不串数据) */
const formPosition = ref("home");
const positionDict = [ const positionDict = [
{ value: "home", label: "首页轮播", tip: "轮播图设计尺寸建议 1920×560(皮肤包约定)" }, { value: "home", label: "首页轮播", short: "轮播", addLabel: "新增轮播", tip: "轮播图设计尺寸建议 1920×560(皮肤包约定)" },
{ value: "gallery", label: "线上展厅", tip: "画廊图片设计尺寸建议 800×520,多图横向轮播(炫彩校园皮肤消费)" }, { value: "gallery", label: "线上展厅", short: "展厅图片", addLabel: "新增展厅图片", tip: "画廊图片设计尺寸建议 800×520,多图横向轮播(炫彩校园皮肤消费)" },
// docs/20 D-3/D-6:新展示位(首页布局 featured_topics / events 区块消费)
{ value: "topic", label: "专题大卡", short: "专题卡", addLabel: "新增专题卡", tip: "专题大卡图片建议 800×500;首页需添加「专题大卡」区块才会展示" },
{ value: "event", label: "学术活动", short: "活动", addLabel: "新增活动", tip: "学术报告/活动海报位;首页需添加「学术日历」区块才会展示,按活动日期升序排列" },
]; ];
const positionTip = computed(() => positionDict.find(p => p.value === position.value)?.tip || ""); const formPositionMeta = computed(() => positionDict.find(p => p.value === formPosition.value)!);
const modalTitle = computed(() => `${form.id ? "编辑" : "新增"}${formPositionMeta.value.short}`);
const columns = [ const columns = [
{ {
@@ -91,6 +97,12 @@ const form = reactive({
startTime: "" as string | "", startTime: "" as string | "",
endTime: "" as string | "", endTime: "" as string | "",
status: 1, status: 1,
// docs/20 D-3/D-6:topic/event 展示位扩展字段
description: "",
ctaText: "",
ctaUrl: "",
eventDate: "" as string | "",
location: "",
}); });
const rules = { const rules = {
@@ -119,7 +131,13 @@ function openCreate() {
startTime: "", startTime: "",
endTime: "", endTime: "",
status: 1, status: 1,
description: "",
ctaText: "",
ctaUrl: "",
eventDate: "",
location: "",
}); });
formPosition.value = position.value;
modalVisible.value = true; modalVisible.value = true;
} }
@@ -134,7 +152,13 @@ function openEdit(row: BannerVo) {
startTime: row.startTime || "", startTime: row.startTime || "",
endTime: row.endTime || "", endTime: row.endTime || "",
status: row.status, status: row.status,
description: row.description || "",
ctaText: row.ctaText || "",
ctaUrl: row.ctaUrl || "",
eventDate: row.eventDate || "",
location: row.location || "",
}); });
formPosition.value = position.value;
modalVisible.value = true; modalVisible.value = true;
} }
@@ -154,7 +178,7 @@ async function save() {
return; return;
} }
if (!form.imageUrl) { if (!form.imageUrl) {
Message.warning("请上传轮播图片"); Message.warning("请上传图片");
return; return;
} }
saving.value = true; saving.value = true;
@@ -163,7 +187,8 @@ async function save() {
.save({ .save({
id: form.id, id: form.id,
siteId: store.siteId!, siteId: store.siteId!,
position: position.value, // F4:保存归属以弹窗打开时所在 Tab 为准(弹窗期间切换 Tab 不串数据)
position: formPosition.value,
title: form.title, title: form.title,
imageUrl: form.imageUrl, imageUrl: form.imageUrl,
linkType: form.linkType, linkType: form.linkType,
@@ -172,6 +197,12 @@ async function save() {
startTime: form.startTime || undefined, startTime: form.startTime || undefined,
endTime: form.endTime || undefined, endTime: form.endTime || undefined,
status: form.status, status: form.status,
// docs/20 D-3/D-6:topic/event 位扩展字段随行提交(其余位后端落库但不消费)
description: form.description || undefined,
ctaText: form.ctaText || undefined,
ctaUrl: form.ctaUrl || undefined,
eventDate: form.eventDate || undefined,
location: form.location || undefined,
}) })
.send(); .send();
Message.success("保存成功!"); Message.success("保存成功!");
@@ -184,7 +215,7 @@ async function save() {
function doDelete(row: BannerVo) { function doDelete(row: BannerVo) {
MyConfirm({ MyConfirm({
title: "删除轮播", title: `删除${formPositionMeta.value.short}`,
msg: `确定删除「${row.title}」吗?`, msg: `确定删除「${row.title}」吗?`,
async onOk() { async onOk() {
await bannerApi.delete({ id: row.id!, siteId: store.siteId! }).send(); await bannerApi.delete({ id: row.id!, siteId: store.siteId! }).send();
@@ -204,34 +235,34 @@ watch(position, () => run());
<template> <template>
<div class="page-wrapper"> <div class="page-wrapper">
<div class="main-container"> <div class="main-container">
<!-- F4(docs/20):首页轮播 / 线上展厅双 Tab——两个展示位尺寸与管理习惯不同,
各自独立列表、独立新增按钮、独立尺寸提示;切换 Tab 列表即时重载(watch position) -->
<Tabs v-model="position" class="banner-tabs">
<TabPane v-for="p in positionDict" :key="p.value" :label="p.label" :name="p.value">
<div class="fix mb-12"> <div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="openCreate"> <Button class="fl" type="primary" size="large" @click="openCreate">
<Icon type="ios-add" /> <Icon type="ios-add" />
新增轮播 {{ p.addLabel }}
</Button> </Button>
<span class="size-tip fr">{{ positionTip }}</span> <span class="size-tip fr">{{ p.tip }}</span>
</div>
<!-- 展示位是筛选维度不是操作,按 bsch 规范归搜索区(左侧下拉) -->
<div class="search-wrap fix">
<Select v-model="position" class="search-wrap_sel">
<Option v-for="p in positionDict" :key="p.value" :value="p.value">{{ p.label }}</Option>
</Select>
</div> </div>
</TabPane>
</Tabs>
<Table <Table
class="link-table table-bottom-box" class="link-table table-bottom-box"
:columns="columns" :columns="columns"
:data="rows" :data="rows"
:height="tableHeight" :height="tableHeight"
:loading="loading" :loading="loading"
no-data-text="暂无轮播" :no-data-text="position === 'home' ? '暂无轮播' : '暂无展厅图片'"
/> />
<Modal v-model="modalVisible" :title="form.id ? '编辑轮播' : '新增轮播'" :mask-closable="false" width="560px"> <Modal v-model="modalVisible" :title="modalTitle" :mask-closable="false" width="560px">
<Form ref="formRef" :model="form" :rules="rules" :label-width="100"> <Form ref="formRef" :model="form" :rules="rules" :label-width="100">
<FormItem label="标题" prop="title"> <FormItem label="标题" prop="title">
<Input v-model="form.title" :maxlength="200" placeholder="alt 文案/无图兜底" /> <Input v-model="form.title" :maxlength="200" placeholder="alt 文案/无图兜底" />
</FormItem> </FormItem>
<FormItem label="轮播图片" required> <FormItem :label="formPositionMeta.value.short" required>
<Upload <Upload
:action="uploadUrl() + '?siteId=' + store.siteId + '&scene=banner'" :action="uploadUrl() + '?siteId=' + store.siteId + '&scene=banner'"
with-credentials with-credentials
@@ -264,6 +295,29 @@ watch(position, () => run());
> >
<Input v-model="form.linkValue" :maxlength="64" :placeholder="form.linkType === 1 ? '文章 ID' : '栏目 ID'" /> <Input v-model="form.linkValue" :maxlength="64" :placeholder="form.linkType === 1 ? '文章 ID' : '栏目 ID'" />
</FormItem> </FormItem>
<!-- docs/20 D-3:专题大卡扩展字段(仅 topic 位) -->
<template v-if="formPosition === 'topic'">
<FormItem label="专题描述">
<Input v-model="form.description" type="textarea" :rows="2" :maxlength="500"
placeholder="卡片上一句话描述(可选)" />
</FormItem>
<FormItem label="按钮文字">
<Input v-model="form.ctaText" :maxlength="20" placeholder="如「进入专题」(可选,不填不显示按钮)" />
</FormItem>
<FormItem label="按钮地址">
<Input v-model="form.ctaUrl" :maxlength="500" placeholder="https://… 或站内 / 路径(可选)" />
</FormItem>
</template>
<!-- docs/20 D-6:学术活动扩展字段(仅 event 位) -->
<template v-if="formPosition === 'event'">
<FormItem label="活动日期">
<DatePicker type="date" v-model="form.eventDate" format="yyyy-MM-dd"
placeholder="大日期块展示(可选)" style="width: 220px" />
</FormItem>
<FormItem label="活动地点">
<Input v-model="form.location" :maxlength="200" placeholder="如 图书馆报告厅(可选)" />
</FormItem>
</template>
<FormItem label="排序"> <FormItem label="排序">
<InputNumber v-model="form.sortNo" :min="0" /> <InputNumber v-model="form.sortNo" :min="0" />
</FormItem> </FormItem>
+2 -16
View File
@@ -37,12 +37,8 @@ const typeDict = [
]; ];
const typeLabel = (t?: number) => typeDict.find(i => i.value === t)?.label || "未知"; const typeLabel = (t?: number) => typeDict.find(i => i.value === t)?.label || "未知";
/** 首页展示形态(docs/12;仅列表型栏目有意义,空=主题默认卡) */ // F3(docs/20 §四):「首页展示形态」配置入口已收敛到「站点设置 → 首页布局」区块属性,
const homeStyleDict = [ // 栏目管理只管数据(名称/类型/排序/状态/SEO);存量 homeStyle 值由后端保存时保留。
{ value: "", label: "默认卡片" },
{ value: "news_grid", label: "双栏新闻卡" },
{ value: "media_focus", label: "媒体聚焦列表" },
];
const sortModal = ref(false); const sortModal = ref(false);
const sortParentId = ref<number | null>(null); const sortParentId = ref<number | null>(null);
@@ -63,7 +59,6 @@ const form = reactive({
type: 1, type: 1,
linkUrl: "", linkUrl: "",
content: "", content: "",
homeStyle: "",
sortNo: 0, sortNo: 0,
seoTitle: "", seoTitle: "",
seoDescription: "", seoDescription: "",
@@ -158,7 +153,6 @@ async function showAddNode() {
form.type = 1; form.type = 1;
form.linkUrl = ""; form.linkUrl = "";
form.content = ""; form.content = "";
form.homeStyle = "";
form.sortNo = 0; form.sortNo = 0;
form.seoTitle = ""; form.seoTitle = "";
form.seoDescription = ""; form.seoDescription = "";
@@ -181,7 +175,6 @@ function showEditNode() {
type: node.type ?? 1, type: node.type ?? 1,
linkUrl: node.linkUrl || "", linkUrl: node.linkUrl || "",
content: node.content || "", content: node.content || "",
homeStyle: node.homeStyle || "",
sortNo: node.sortNo ?? 0, sortNo: node.sortNo ?? 0,
seoTitle: node.seoTitle || "", seoTitle: node.seoTitle || "",
seoDescription: node.seoDescription || "", seoDescription: node.seoDescription || "",
@@ -263,7 +256,6 @@ async function save() {
type: form.type, type: form.type,
linkUrl: form.linkUrl || undefined, linkUrl: form.linkUrl || undefined,
content: form.content || undefined, content: form.content || undefined,
homeStyle: form.type === 1 && form.homeStyle ? form.homeStyle : undefined,
sortNo: form.sortNo, sortNo: form.sortNo,
seoTitle: form.seoTitle || undefined, seoTitle: form.seoTitle || undefined,
seoDescription: form.seoDescription || undefined, seoDescription: form.seoDescription || undefined,
@@ -549,12 +541,6 @@ function openLink(url: string) {
<div v-else class="editor-placeholder">编辑器加载中……</div> <div v-else class="editor-placeholder">编辑器加载中……</div>
</div> </div>
</FormItem> </FormItem>
<FormItem v-if="form.type === 1" label="首页展示形态">
<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>
</FormItem>
<Divider size="small" orientation="left">SEO 设置</Divider> <Divider size="small" orientation="left">SEO 设置</Divider>
<FormItem label="SEO 标题"> <FormItem label="SEO 标题">
+309
View File
@@ -0,0 +1,309 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import Utils from "@/utils/index";
import MyConfirm from "@/components/MyConfirm";
import { categoryApi, personApi, uploadUrl } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import { CategoryTreeNode, PersonVo } from "@/types/cms";
/**
* 名师风采人物管理(docs/20 D-7,people 区块数据源)。
* 站点级 CRUD:姓名/头衔/简介/头像(附件上传 scene=person)/所属栏目(分组)/排序/状态。
* 入口:首页布局编辑器 people 区块属性面板「维护人物数据」直达;菜单挂靠见路由 meta(modId 3 轮播管理族)。
*/
const store = useCmsStore();
const rows = ref<PersonVo[]>([]);
const loading = ref(false);
const tree = ref<CategoryTreeNode[]>([]);
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true });
/** 树拍平(含虚拟根排除) */
const flat = computed(() => {
const out: { id: number; name: string; depth: number }[] = [];
const walk = (nodes: CategoryTreeNode[], depth: number) => {
(nodes || []).forEach(n => {
out.push({ id: n.id as number, name: n.name, depth });
walk(n.children || [], depth + 1);
});
};
walk(tree.value, 0);
return out;
});
const nameOfCategory = (id?: number) => (id == null ? "—" : flat.value.find(c => c.id === id)?.name || `#${id}`);
const columns = [
{
title: "头像",
key: "avatar",
width: 90,
align: "center",
render: (h: any, p: any) =>
p.row.avatar
? h("img", { src: p.row.avatar, style: { width: "48px", height: "48px", borderRadius: "50%", objectFit: "cover" } })
: h(
"span",
{ style: { display: "inline-flex", alignItems: "center", justifyContent: "center", width: "48px", height: "48px", borderRadius: "50%", background: "#e8f1ff", color: "#2d8cf0", fontWeight: 600 } },
(p.row.name || "?").slice(0, 1),
),
},
{ title: "姓名", key: "name", width: 120 },
{ title: "头衔/职称", key: "title", width: 160, tooltip: true, render: (h: any, p: any) => h("span", p.row.title || "—") },
{ title: "简介", key: "bio", minWidth: 220, tooltip: true, render: (h: any, p: any) => h("span", p.row.bio || "—") },
{
title: "所属栏目",
key: "categoryId",
width: 140,
render: (h: any, p: any) => h("span", nameOfCategory(p.row.categoryId)),
},
{ title: "排序", key: "sortNo", width: 80, align: "center" },
{
title: "状态",
key: "status",
width: 90,
align: "center",
render: (h: any, p: any) => h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () => (p.row.status === 1 ? "启用" : "停用")),
},
{
title: "操作",
key: "action",
width: 120,
fixed: "right",
align: "center",
render: (h: any, p: any) => [
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)),
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row)),
],
},
];
const modalVisible = ref(false);
const saving = ref(false);
const formRef = ref();
const form = reactive({
id: undefined as number | undefined,
name: "",
title: "",
bio: "",
avatar: "",
categoryId: undefined as number | undefined,
sortNo: 0,
status: 1,
});
const rules = {
name: [{ required: true, message: "姓名不得为空", trigger: "blur" }],
};
async function run() {
if (!store.siteId) return;
loading.value = true;
try {
rows.value = (await personApi.list(store.siteId).send()) || [];
} finally {
loading.value = false;
}
}
async function loadTree() {
if (!store.siteId) return;
try {
tree.value = (await categoryApi.tree(store.siteId).send()) || [];
} catch {
tree.value = [];
}
}
function openCreate() {
Object.assign(form, {
id: undefined,
name: "",
title: "",
bio: "",
avatar: "",
categoryId: undefined,
sortNo: 0,
status: 1,
});
modalVisible.value = true;
}
function openEdit(row: PersonVo) {
Object.assign(form, {
id: row.id,
name: row.name,
title: row.title || "",
bio: row.bio || "",
avatar: row.avatar || "",
categoryId: row.categoryId,
sortNo: row.sortNo,
status: row.status,
});
modalVisible.value = true;
}
function avatarUploaded(res: any) {
if (res.code === 0 && res.data) {
form.avatar = res.data.url;
} else {
Message.error(res.msg || "头像上传失败");
}
}
async function save() {
const valid = await formRef.value?.validate();
if (!valid) {
Message.warning("请填写红色星号内容!"); // bsch §4
return;
}
saving.value = true;
try {
await personApi
.save({
id: form.id,
siteId: store.siteId!,
name: form.name,
title: form.title || undefined,
bio: form.bio || undefined,
avatar: form.avatar || undefined,
categoryId: form.categoryId || undefined,
sortNo: form.sortNo,
status: form.status,
})
.send();
Message.success("保存成功!");
modalVisible.value = false;
run();
} finally {
saving.value = false;
}
}
function doDelete(row: PersonVo) {
MyConfirm({
title: "删除人物",
msg: `确定删除「${row.name}」吗?首页名师风采区块将不再展示该人物。`,
async onOk() {
await personApi.delete({ id: row.id!, siteId: store.siteId! }).send();
Message.success("操作成功!");
run();
},
});
}
onMounted(() => {
run();
loadTree();
});
// 站点上下文切换即时重载(docs/09 §3.3)
watch(() => store.siteId, () => {
run();
loadTree();
});
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="openCreate">
<Icon type="ios-add" />
新增人物
</Button>
<span class="size-tip fr">名师风采数据源:首页布局「名师风采」区块按所属栏目过滤展示(未分组 = 全部人物区块不选栏目时可见)</span>
</div>
<Table
class="link-table table-bottom-box"
:columns="columns"
:data="rows"
:height="tableHeight"
:loading="loading"
no-data-text="暂无人物"
/>
<Modal v-model="modalVisible" :title="form.id ? '编辑人物' : '新增人物'" :mask-closable="false" width="560px">
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
<FormItem label="姓名" prop="name">
<Input v-model="form.name" :maxlength="100" placeholder="如 王立群" />
</FormItem>
<FormItem label="头衔/职称">
<Input v-model="form.title" :maxlength="100" placeholder="如 教授、长江学者" />
</FormItem>
<FormItem label="简介">
<Input v-model="form.bio" type="textarea" :rows="2" :maxlength="500" placeholder="一句话简介(卡片下方展示)" />
</FormItem>
<FormItem label="头像">
<Upload
:action="uploadUrl() + '?siteId=' + store.siteId + '&scene=person'"
with-credentials
:show-upload-list="false"
:format="['jpg', 'jpeg', 'png', 'webp']"
:on-success="avatarUploaded"
>
<Button icon="ios-cloud-upload-outline">上传头像</Button>
</Upload>
<div v-if="form.avatar" class="avatar-preview-row">
<img :src="form.avatar" class="avatar-preview" />
<Button size="small" type="text" class="avatar-clear" @click="form.avatar = ''">清除</Button>
</div>
<p class="form-tip">建议正方形照片(如 300×300);不上传时首页显示姓名首字圆形徽章</p>
</FormItem>
<FormItem label="所属栏目">
<Select v-model="form.categoryId" clearable placeholder="不选 = 未分组(people 区块选栏目时不会出现)">
<Option v-for="c in flat" :key="c.id" :value="c.id">
<span :style="{ paddingLeft: c.depth * 16 + 'px' }">{{ c.name }}</span>
</Option>
</Select>
</FormItem>
<FormItem label="排序">
<InputNumber v-model="form.sortNo" :min="0" />
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="form.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
<template #footer>
<Button @click="modalVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="save">保存</Button>
</template>
</Modal>
</div>
</div>
</template>
<style scoped lang="scss">
.size-tip {
color: #999;
line-height: 40px;
}
.avatar-preview-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
.avatar-preview {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #e5e6eb;
}
.avatar-clear {
color: #999;
&:hover {
color: #ed4014;
}
}
}
.form-tip {
color: #999;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
</style>
+317 -352
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { siteApi, themeApi } from "@/api/module/cms"; import { siteApi, themeApi } from "@/api/module/cms";
@@ -8,19 +8,19 @@ import { portalUrlOf, adminPathOf, previewPortalUrl } from "@/utils/sitePath";
import { useCmsStore } from "@/store"; import { useCmsStore } from "@/store";
import { SiteVo, ThemeVo } from "@/types/cms"; import { SiteVo, ThemeVo } from "@/types/cms";
import { resolveEditableTheme } from "@/utils/themeEdit"; import { resolveEditableTheme } from "@/utils/themeEdit";
import { mergeConfigPatch } from "@/utils/siteConfigForm";
import ThemePicker from "@/components/theme/ThemePicker.vue"; import ThemePicker from "@/components/theme/ThemePicker.vue";
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue"; import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue"; import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue"; import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
/** /**
* 站点设置中心(docs/16 主线A 整页化):编辑/新增站点从模态框/三步向导 → 完整页面。 * 站点设置中心(docs/16 主线A 整页化 → docs/20 F1 Tab 化 + 渐进式向导):
* projman 模式:锚点导航 + 分区卡片(card-header/body/footer 三段式),每分区独立保存。 * 编辑模式 = 顶部 7 Tab(基本信息/皮肤与外观/首页布局/基础展示/页脚信息/SEO 与域名/自定义样式),
* 单一 configJson 事实源(§3.5):各分区编辑器只读写自己的键空间(SiteConfigForm 分区作用域 * 每个 Tab 独立表单、独立 dirty 状态、独立保存——保存只合并本 Tab 的键空间进
* + HomeLayoutEditor 仅动 home_layout 键),「仅保存本分区」= 校验本分区 → 当前 configJson 全量提交; * lastSavedConfigJson(服务端事实源),其他 Tab 的未保存修改既不丢失也不被连带提交。
* 其他分区未保存的修改仍在内存里,下次任一分区保存会一并带上(卡片 footer 已注明)。 * 新建模式 = 渐进式向导:第一步只填基本信息,创建成功自动进入第二步(选皮肤),
* 创建模式(site/new,D5 向导退役):仅①基本信息②皮肤与外观可编辑,③~⑦锁定置灰, * 完成后提示并展开全部 Tab;不再有任何「创建站点后开放」灰态锁定。
* 创建成功原地 replace 到 site/setting/{id} 解锁全部分区。
*/ */
const store = useCmsStore(); const store = useCmsStore();
const route = useRoute(); const route = useRoute();
@@ -31,7 +31,6 @@ const isCreate = computed(() => siteIdParam.value == null);
/** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管行为不变 */ /** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管行为不变 */
const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool); const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool);
const canCreateSite = computed(() => store.isSuperAdmin || isSchoolAdmin.value);
/** 校级锁定在本校主站下建子站(原向导 lockParentId 语义) */ /** 校级锁定在本校主站下建子站(原向导 lockParentId 语义) */
const lockParentId = computed(() => (isCreate.value && isSchoolAdmin.value ? store.currentSchool?.mainSiteId : undefined)); const lockParentId = computed(() => (isCreate.value && isSchoolAdmin.value ? store.currentSchool?.mainSiteId : undefined));
@@ -41,23 +40,26 @@ const row = ref<SiteVo | null>(null);
const sites = ref<SiteVo[]>([]); const sites = ref<SiteVo[]>([]);
const themes = ref<ThemeVo[]>([]); const themes = ref<ThemeVo[]>([]);
/** 分区清单(锚点导航;locked = 创建模式锁定) */ // ===== 向导(F1):1=填基本信息(创建模式);2=选皮肤(创建成功后);0=常规编辑 =====
const sections = [ const wizardStep = ref(isCreate.value ? 1 : 0);
{ key: "basic", title: "基本信息", desc: "编码 / 名称 / 归属 / 状态" },
{ key: "theme", title: "皮肤与外观", desc: "选皮肤 / 自定义 CSS" },
{ 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) */ // ===== Tab 结构(F1:7 Tab;自定义样式独立成 Tab)=====
const configJson = ref(""); const TABS = [
{ key: "basic", title: "基本信息" },
{ key: "theme", title: "皮肤与外观" },
{ key: "layout", title: "首页布局" },
{ key: "display", title: "基础展示" },
{ key: "footer", title: "页脚信息" },
{ key: "seo", title: "SEO 与域名" },
{ key: "css", title: "自定义样式" },
] as const;
type TabKey = (typeof TABS)[number]["key"];
const activeTab = ref<string>("basic");
/** ① 基本信息分区 */ /** 服务端事实源:最近一次保存成功(或载入)时的 configJson —— 各 Tab 保存以此为基础做增量合并 */
const lastSavedConfigJson = ref("");
/** ① 基本信息表单 */
const basicRef = ref(); const basicRef = ref();
const basic = reactive({ const basic = reactive({
id: undefined as number | undefined, id: undefined as number | undefined,
@@ -69,21 +71,36 @@ const basic = reactive({
status: 1, status: 1,
}); });
/** ② 皮肤与外观分区(工作项 T:参数微调已迁皮肤包管理,站点只负责选哪套包) */ /** ② 皮肤与外观(工作项 T:参数微调已迁皮肤包管理,站点只负责选哪套包) */
const theme = ref("default"); const theme = ref("default");
const themeUnregistered = ref(false); const themeUnregistered = ref(false);
const customCss = ref("");
const previewVisible = ref(false); const previewVisible = ref(false);
const editTheme = computed(() => themes.value.find(t => t.name === theme.value)); const editTheme = computed(() => themes.value.find(t => t.name === theme.value));
/** ③~⑦ 分区表单 ref(分区独立校验) */ /** ⑦ 自定义样式(F1:独立 Tab,config_json.custom_css,服务端黑名单强校验) */
const layoutEditorRef = ref(); const customCss = ref("");
const displayFormRef = ref();
const footerFormRef = ref();
const seoFormRef = ref();
const extrasFormRef = ref();
/** ③~⑥ 分区表单:各持独立模型(seed 后不再互相同步,保存各自合并) */
const layoutEditorRef = ref();
const layoutModel = ref("");
const displayFormRef = ref();
const displayModel = ref("");
const footerFormRef = ref();
const footerModel = ref("");
const seoFormRef = ref();
const seoModel = ref("");
/** 各 Tab 独立 dirty 状态(切 Tab 不丢;仅本 Tab 保存后清除) */
const dirty = reactive<Record<string, boolean>>({
basic: false, theme: false, layout: false, display: false, footer: false, seo: false, css: false,
});
const saving = reactive<Record<string, boolean>>({}); const saving = reactive<Record<string, boolean>>({});
/** initFromRow / 保存回灌种子时挂起 dirty 标记 */
let seeding = false;
function tabLabel(tab: { key: string; title: string }): string {
return dirty[tab.key] ? `${tab.title} ●` : tab.title;
}
const schoolScopedSites = computed(() => const schoolScopedSites = computed(() =>
!store.currentSchool ? sites.value : sites.value.filter(s => s.schId === store.currentSchool!.schId) !store.currentSchool ? sites.value : sites.value.filter(s => s.schId === store.currentSchool!.schId)
@@ -103,6 +120,10 @@ const portalPreviewUrl = computed(() =>
? portalUrlOf(row.value) ? portalUrlOf(row.value)
: "" : ""
); );
/** F2 实时预览 iframe 地址(编辑模式 = serviceHost + 正式门户首页) */
const portalFrameUrl = computed(() =>
!isCreate.value && row.value ? `${config.serviceHost}${portalUrlOf(row.value)}` : undefined
);
const rules = { const rules = {
code: [ code: [
@@ -123,31 +144,6 @@ onMounted(async () => {
basic.parentSiteId = lockParentId.value; basic.parentSiteId = lockParentId.value;
} }
loading.value = false; loading.value = false;
patchAncestorOverflow();
nextTick(setupAnchorObserver);
});
/**
* 左侧锚点导航 sticky 的生效前提:布局遗留的 .main-container 带 overflow:hidden
* (清浮动时代产物),sticky 会改为相对这个"自身不滚动的隐藏滚动盒"定位 → 表现为
* 跟随内容滚走(2026-09-20 实测滚动 600px 导航 top=-387)。本页内容为 flex/常规流,
* 不依赖该 overflow 裁剪浮动,故进入本页时运行时改写、离开时还原,零全局影响。
*/
let patchedContainer: HTMLElement | null = null;
function patchAncestorOverflow() {
const el = document.querySelector<HTMLElement>(".base-wrap-main .main-container");
if (el && getComputedStyle(el).overflow === "hidden") {
patchedContainer = el;
el.style.overflow = "visible";
}
}
onBeforeUnmount(() => {
observer?.disconnect();
if (patchedContainer) {
patchedContainer.style.overflow = "";
patchedContainer = null;
}
}); });
async function loadSites() { async function loadSites() {
@@ -162,12 +158,14 @@ async function loadThemes() {
} }
} }
/** 载入站点 → 各 Tab 模型按 lastSavedConfigJson 种子(seeding 挂起 dirty 标记) */
function initFromRow(id: number) { function initFromRow(id: number) {
const r = sites.value.find(s => s.id === id); const r = sites.value.find(s => s.id === id);
if (!r) { if (!r) {
loadError.value = "站点不存在或无权管理该站点"; loadError.value = "站点不存在或无权管理该站点";
return; return;
} }
seeding = true;
row.value = r; row.value = r;
// 主题值保留原值(未注册时告警,由服务端校验拒绝),不再静默改写为 default // 主题值保留原值(未注册时告警,由服务端校验拒绝),不再静默改写为 default
const resolved = resolveEditableTheme(r.theme, themes.value.map(t => t.name)); const resolved = resolveEditableTheme(r.theme, themes.value.map(t => t.name));
@@ -182,8 +180,14 @@ function initFromRow(id: number) {
status: r.status, status: r.status,
}); });
theme.value = resolved.theme; theme.value = resolved.theme;
configJson.value = r.configJson || ""; lastSavedConfigJson.value = r.configJson || "";
customCss.value = readCustomCss(configJson.value); layoutModel.value = lastSavedConfigJson.value;
displayModel.value = lastSavedConfigJson.value;
footerModel.value = lastSavedConfigJson.value;
seoModel.value = lastSavedConfigJson.value;
customCss.value = readCustomCss(lastSavedConfigJson.value);
Object.keys(dirty).forEach(k => (dirty[k] = false));
seeding = false;
} }
function readCustomCss(configJson: string): string { function readCustomCss(configJson: string): string {
@@ -195,24 +199,24 @@ function readCustomCss(configJson: string): string {
} }
} }
/** 把面板值写入/移出 configJson(空值移除键;与 SiteConfigForm 的 preserved 往返兼容) */ /** HomeLayoutEditor 的整份 config 输出 → 仅取 home_layout 键的增量(无布局=删除键) */
function withCustomCss(configJson: string, css: string): string { function layoutPatchOf(model: string): { home_layout?: unknown } {
let obj: Record<string, unknown>;
try { try {
obj = configJson && configJson.trim() ? JSON.parse(configJson) : {}; const parsed = model && model.trim() ? JSON.parse(model) : {};
if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {}; if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
} catch { return { home_layout: (parsed as Record<string, unknown>)["home_layout"] };
obj = {};
} }
if (css.trim()) obj.custom_css = css; } catch {
else delete obj.custom_css; // 非法 JSON 视为无变更
return JSON.stringify(obj); }
return {};
} }
function pickTheme(name: string) { function pickTheme(name: string) {
if (theme.value === name) return; if (theme.value === name) return;
theme.value = name; theme.value = name;
themeUnregistered.value = false; themeUnregistered.value = false;
dirty.theme = true;
} }
function openPreview() { function openPreview() {
@@ -220,13 +224,16 @@ function openPreview() {
} }
/** /**
* 统一提交(§3.5 分区独立保存模型):payload = 页面当前事实源 + 基本信息快照; * 统一提交(F1 Tab 独立保存):site 字段 = 当前基本信息快照 + 当前皮肤;
* patch.configJson 供分区保存前合并自己的键空间(如皮肤分区的 custom_css)。 * configJson = lastSavedConfigJson + 本 Tab 增量 patch(patch=null = 不动 config)。
* 创建成功原地 replace 到 site/setting/{id} 并解锁全部分区。 * 成功后回写事实源并清本 Tab dirty;其他 Tab 的内存修改不受影响。
*/ */
async function submitSection(key: string, patch: { configJson?: string }) { async function persistTab(tab: TabKey, patch: Record<string, unknown> | null) {
saving[key] = true; saving[tab] = true;
try { try {
const configJson = patch == null
? lastSavedConfigJson.value || undefined
: mergeConfigPatch(lastSavedConfigJson.value, patch);
const res = await siteApi const res = await siteApi
.save({ .save({
id: basic.id, id: basic.id,
@@ -237,30 +244,41 @@ async function submitSection(key: string, patch: { configJson?: string }) {
schName: isSchoolAdmin.value ? undefined : basic.schName || undefined, schName: isSchoolAdmin.value ? undefined : basic.schName || undefined,
name: basic.name, name: basic.name,
theme: theme.value || undefined, theme: theme.value || undefined,
configJson: patch.configJson ?? configJson.value, configJson,
status: basic.status, status: basic.status,
}) })
.send(); .send();
if (isCreate.value && res && res.id) { if (isCreate.value && res && res.id) {
Message.success("站点创建成功!全部分区已开放"); // 向导第一步完成:原地 replace 解锁全部 Tab,自动进入第二步(选皮肤)
Message.success("站点创建成功!接下来为它挑选一套皮肤");
await loadSites(); await loadSites();
const main = sites.value.find(s => s.id === (res.parentSiteId || 0)); const main = sites.value.find(s => s.id === (res.parentSiteId || 0));
const schCode = main?.code || res.code; const schCode = main?.code || res.code;
await router.replace(adminPathOf(schCode, res.parentSiteId ? res.code : null, `site/setting/${res.id}`)); await router.replace(adminPathOf(schCode, res.parentSiteId ? res.code : null, `site/setting/${res.id}`));
initFromRow(res.id); initFromRow(res.id);
} else { wizardStep.value = 2;
activeTab.value = "theme";
return;
}
Message.success("保存成功!"); Message.success("保存成功!");
if (res) { if (res) {
row.value = res; row.value = res;
// 服务端归一后的 configJson 刷新事实源
if (typeof res.configJson === "string") { if (typeof res.configJson === "string") {
configJson.value = res.configJson; lastSavedConfigJson.value = res.configJson;
customCss.value = readCustomCss(configJson.value);
} }
} }
dirty[tab] = false;
// 保存回灌:本 Tab 模型与服务端事实源对齐(seeding 挂起 dirty 重标)
seeding = true;
if (tab === "layout") {
layoutModel.value = lastSavedConfigJson.value;
layoutEditorRef.value?.refreshAfterSave?.();
} else if (tab === "css") {
customCss.value = readCustomCss(lastSavedConfigJson.value);
} }
seeding = false;
} finally { } finally {
saving[key] = false; saving[tab] = false;
} }
} }
@@ -270,43 +288,24 @@ async function saveBasic() {
Message.warning("请填写红色星号内容!"); // bsch §4 统一提示 Message.warning("请填写红色星号内容!"); // bsch §4 统一提示
return; return;
} }
await submitSection("basic", {}); await persistTab("basic", null);
} }
async function saveTheme() { async function saveTheme() {
await submitSection("theme", { configJson: withCustomCss(configJson.value, customCss.value) }); await persistTab("theme", null);
} }
async function saveDisplay() { /** 向导第二步收口:保存皮肤 → 提示可继续配置首页布局,展开全部 Tab */
if (!(await displayFormRef.value?.validate())) { async function finishWizard() {
Message.warning("请填写红色星号内容!"); // bsch §4 await persistTab("theme", null);
return; wizardStep.value = 0;
} Message.success("站点创建成功,可继续配置首页布局");
await submitSection("display", {});
} }
async function saveFooter() { /** 向导第二步跳过(皮肤先用当前选择/默认) */
if (!(await footerFormRef.value?.validate())) { function skipWizard() {
Message.warning("请填写红色星号内容!"); // bsch §4 wizardStep.value = 0;
return; Message.info("已跳过皮肤配置,全部设置已开放");
}
await submitSection("footer", {});
}
async function saveSeo() {
if (!(await seoFormRef.value?.validate())) {
Message.warning("请填写红色星号内容!"); // bsch §4
return;
}
await submitSection("seo", {});
}
async function saveExtras() {
if (!(await extrasFormRef.value?.validate())) {
Message.warning("请填写红色星号内容!"); // bsch §4
return;
}
await submitSection("extras", {});
} }
async function saveLayout() { async function saveLayout() {
@@ -315,7 +314,19 @@ async function saveLayout() {
Message.error(err); Message.error(err);
return; return;
} }
await submitSection("layout", {}); await persistTab("layout", layoutPatchOf(layoutModel.value));
}
async function saveGroupForm(tab: "display" | "footer" | "seo", formRef: typeof displayFormRef) {
if (!(await formRef.value?.validate())) {
Message.warning("请填写红色星号内容!"); // bsch §4
return;
}
await persistTab(tab, formRef.value?.ownFields?.() || {});
}
async function saveCss() {
await persistTab("css", { custom_css: customCss.value });
} }
/** 门户预览(编辑模式):新窗口打开真实门户 */ /** 门户预览(编辑模式):新窗口打开真实门户 */
@@ -334,28 +345,25 @@ function backToList() {
router.push(adminPathOf(schCode, row.value?.parentSiteId ? row.value.code : null, "site")); router.push(adminPathOf(schCode, row.value?.parentSiteId ? row.value.code : null, "site"));
} }
function setupAnchorObserver() { // ===== dirty 追踪:各 Tab 模型变更即标脏(seeding 期间挂起)=====
if (typeof IntersectionObserver === "undefined") return; watch(basic, () => {
observer = new IntersectionObserver( if (!seeding) dirty.basic = true;
entries => { });
entries.forEach(e => { watch(customCss, () => {
if (e.isIntersecting) { if (!seeding) dirty.css = true;
activeSection.value = (e.target as HTMLElement).dataset.section || activeSection.value; });
} watch(layoutModel, () => {
}); if (!seeding) dirty.layout = true;
}, });
{ rootMargin: "-90px 0px -55% 0px", threshold: 0 }, watch(displayModel, () => {
); if (!seeding) dirty.display = true;
sections.forEach(s => { });
const el = document.getElementById(`sec-${s.key}`); watch(footerModel, () => {
if (el) observer!.observe(el); if (!seeding) dirty.footer = true;
}); });
} watch(seoModel, () => {
if (!seeding) dirty.seo = true;
function scrollToSection(key: string) { });
activeSection.value = key;
document.getElementById(`sec-${key}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
// 学校上下文切换即时重载(编辑中的站点归属变化防御) // 学校上下文切换即时重载(编辑中的站点归属变化防御)
watch(() => store.currentSchool?.schId, () => loadSites()); watch(() => store.currentSchool?.schId, () => loadSites());
@@ -381,28 +389,15 @@ watch(() => store.currentSchool?.schId, () => loadSites());
<Alert v-if="loadError" type="error" show-icon style="margin-bottom: 12px">{{ loadError }}</Alert> <Alert v-if="loadError" type="error" show-icon style="margin-bottom: 12px">{{ loadError }}</Alert>
<Spin v-if="loading" size="large" fix /> <Spin v-if="loading" size="large" fix />
<div class="ss-layout"> <!-- 渐进式向导进度(F1):仅创建流程期间展示 -->
<!-- 锚点导航 --> <Steps v-if="wizardStep > 0" :current="wizardStep" class="ss-steps">
<aside class="ss-nav"> <Step title="基本信息" content="编码 / 名称 / 归属"></Step>
<div <Step title="皮肤与外观" content="挑一套门户皮肤"></Step>
v-for="s in sections" <Step title="完整设置" content="首页布局等全部配置开放"></Step>
:key="s.key" </Steps>
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 v-if="wizardStep === 1" class="card setting-card">
<!-- ① 基本信息 -->
<div id="sec-basic" class="card setting-card" data-section="basic">
<div class="card-header"> <div class="card-header">
<span class="card-title">基本信息</span> <span class="card-title">基本信息</span>
<span class="card-subtitle">站点编码 / 名称 / 归属与状态</span> <span class="card-subtitle">站点编码 / 名称 / 归属与状态</span>
@@ -410,8 +405,7 @@ watch(() => store.currentSchool?.schId, () => loadSites());
<div class="card-body"> <div class="card-body">
<Form ref="basicRef" :model="basic" :rules="rules" :label-width="110"> <Form ref="basicRef" :model="basic" :rules="rules" :label-width="110">
<FormItem label="站点编码" prop="code"> <FormItem label="站点编码" prop="code">
<Input v-model="basic.code" :disabled="!isCreate" placeholder="URL 路径用,如 demo" style="max-width: 320px" /> <Input v-model="basic.code" placeholder="URL 路径用,如 demo" style="max-width: 320px" />
<div v-if="!isCreate" class="form-hint">编码创建后不可修改(URL 末段)</div>
</FormItem> </FormItem>
<FormItem label="站点名称" prop="name"> <FormItem label="站点名称" prop="name">
<Input v-model="basic.name" :maxlength="128" placeholder="如 XX学校门户网站" style="max-width: 320px" /> <Input v-model="basic.name" :maxlength="128" placeholder="如 XX学校门户网站" style="max-width: 320px" />
@@ -445,18 +439,67 @@ watch(() => store.currentSchool?.schId, () => loadSites());
</Form> </Form>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<Button type="primary" :loading="saving.basic" @click="saveBasic"> <Button type="primary" :loading="saving.basic" @click="saveBasic">创建站点,下一步</Button>
{{ isCreate ? "创建站点" : "仅保存基本信息" }} <span class="card-hint">创建后自动进入第二步挑选皮肤,其余配置随即全部开放</span>
</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div> </div>
</div> </div>
<!-- ② 皮肤与外观 --> <!-- ===== 常规编辑(含向导第二步起):顶部 Tab,7 个分区各持独立表单与保存 ===== -->
<div id="sec-theme" class="card setting-card" data-section="theme"> <Tabs v-else v-model="activeTab" class="ss-tabs" :animated="false">
<TabPane :label="tabLabel(TABS[0])" name="basic">
<div class="card setting-card">
<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>
</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">保存基本信息</Button>
<span class="card-hint">仅保存本 Tab;其他 Tab 未保存的修改保留在各自表单里,不会连带提交</span>
</div>
</div>
</TabPane>
<TabPane :label="tabLabel(TABS[1])" name="theme">
<div class="card setting-card">
<div class="card-header"> <div class="card-header">
<span class="card-title">皮肤与外观</span> <span class="card-title">皮肤与外观</span>
<span class="card-subtitle">风格选择 / 站点级 CSS 覆盖(微调请去「皮肤包管理」)</span> <span class="card-subtitle">风格选择(皮肤参数的调整入口在「皮肤包管理」;自定义 CSS 已独立为专属 Tab)</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="field-block"> <div class="field-block">
@@ -470,13 +513,102 @@ watch(() => store.currentSchool?.schId, () => loadSites());
<ThemePicker :themes="themes" :model-value="theme" @update:model-value="pickTheme" /> <ThemePicker :themes="themes" :model-value="theme" @update:model-value="pickTheme" />
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openPreview">预览本皮肤</Button> <Button size="small" class="preview-btn" :disabled="!editTheme" @click="openPreview">预览本皮肤</Button>
</div> </div>
<!-- B3 自定义样式(config_json.custom_css,服务端黑名单强校验) --> </div>
<Collapse class="css-collapse"> <div class="card-footer">
<Panel name="css" header="自定义样式(站点级 CSS 覆盖)"> <template v-if="wizardStep === 2">
<Button type="primary" :loading="saving.theme" @click="finishWizard">保存并完成</Button>
<Button @click="skipWizard">跳过,稍后配置</Button>
<span class="card-hint">第二步(选皮肤)完成后全部设置开放</span>
</template>
<template v-else>
<Button type="primary" :loading="saving.theme" @click="saveTheme">保存皮肤与外观</Button>
<span class="card-hint">换肤保存后门户立即生效(缓存已自动失效)</span>
</template>
</div>
</div>
</TabPane>
<TabPane :label="tabLabel(TABS[2])" name="layout">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">首页布局</span>
<span class="card-subtitle">首页 = 可编排的区块序列(要闻/栏目文章/画廊/链接组)</span>
</div>
<div class="card-body">
<HomeLayoutEditor
v-model="layoutModel"
:site-id="basic.id"
:portal-url="portalFrameUrl"
ref="layoutEditorRef"
@save="saveLayout"
/>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.layout" @click="saveLayout">保存首页布局</Button>
<span class="card-hint">保存后门户立即生效(缓存自动失效);未编排布局时门户按「全部栏目自动渲染」默认形态展示</span>
</div>
</div>
</TabPane>
<TabPane :label="tabLabel(TABS[3])" name="display">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">基础展示</span>
<span class="card-subtitle">Logo / favicon / 标语 / 院系文案(图片支持直接上传)</span>
</div>
<div class="card-body">
<SiteConfigForm ref="displayFormRef" v-model="displayModel" :groups="['display']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.display" @click="saveGroupForm('display', displayFormRef)">保存基础展示</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
<TabPane :label="tabLabel(TABS[4])" name="footer">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">页脚信息</span>
<span class="card-subtitle">联系方式 / 多校区地址 / 二维码 / 社媒 / 版权备案</span>
</div>
<div class="card-body">
<SiteConfigForm ref="footerFormRef" v-model="footerModel" :groups="['footer']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.footer" @click="saveGroupForm('footer', footerFormRef)">保存页脚信息</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
<TabPane :label="tabLabel(TABS[5])" name="seo">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">SEO 与域名</span>
<span class="card-subtitle">搜索引擎摘要 / 独立域名台账与对外基地址</span>
</div>
<div class="card-body">
<SiteConfigForm ref="seoFormRef" v-model="seoModel" :groups="['seo', 'domain']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.seo" @click="saveGroupForm('seo', seoFormRef)">保存 SEO 与域名</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
<TabPane :label="tabLabel(TABS[6])" name="css">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">自定义样式</span>
<span class="card-subtitle">站点级 CSS 覆盖(面向需要微调的高级用户)</span>
</div>
<div class="card-body">
<Input <Input
v-model="customCss" v-model="customCss"
type="textarea" type="textarea"
:rows="6" :rows="14"
:maxlength="10240" :maxlength="10240"
placeholder="如:--primary:#59aeeb; 或 .site-header{height:80px}" placeholder="如:--primary:#59aeeb; 或 .site-header{height:80px}"
class="css-editor" class="css-editor"
@@ -485,95 +617,14 @@ watch(() => store.currentSchool?.schId, () => loadSites());
<span>{{ customCss.length }}/10240 字符</span> <span>{{ customCss.length }}/10240 字符</span>
<span>保存后门户立即生效(缓存自动失效);服务端将拒绝 &lt;/style、&lt;script、@import、expression( 等危险内容</span> <span>保存后门户立即生效(缓存自动失效);服务端将拒绝 &lt;/style、&lt;script、@import、expression( 等危险内容</span>
</div> </div>
</Panel>
</Collapse>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<Button type="primary" :loading="saving.theme" @click="saveTheme">仅保存皮肤与外观</Button> <Button type="primary" :loading="saving.css" @click="saveCss">保存自定义样式</Button>
<span class="card-hint">换肤/改参数保存后门户立即生效(缓存已自动失效);其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span> <span class="card-hint">仅保存本 Tab 的字段</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>
</TabPane>
</Tabs>
</div> </div>
</div> </div>
@@ -586,7 +637,6 @@ watch(() => store.currentSchool?.schId, () => loadSites());
:theme-params="editTheme?.paramValues || {}" :theme-params="editTheme?.paramValues || {}"
/> />
</Modal> </Modal>
</div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -607,108 +657,28 @@ watch(() => store.currentSchool?.schId, () => loadSites());
} }
} }
} }
.ss-layout { .ss-steps {
display: flex; margin-bottom: 18px;
gap: 16px; background: #fff;
align-items: flex-start;
}
.ss-nav {
width: 184px;
flex: none;
position: sticky;
top: 72px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid #e9edf5; border: 1px solid #e9edf5;
border-radius: 10px; border-radius: 10px;
padding: 14px 24px 4px;
}
.ss-tabs {
background: #fff; background: #fff;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 14px 30px -22px rgba(16, 24, 40, 0.4); border: 1px solid #e9edf5;
.ss-nav-item { border-radius: 10px;
position: relative; padding: 4px 16px 16px;
padding: 8px 10px 8px 14px; :deep(.ivu-tabs-nav) {
border-radius: 7px; margin-bottom: 12px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f0f4ff;
} }
&.active {
background: #eef2ff;
&::before {
content: "";
position: absolute;
left: 4px;
top: 10px;
bottom: 10px;
width: 3px;
border-radius: 2px;
background: #577afb;
}
.ss-nav-title {
color: #4264e4;
}
}
.ss-nav-title {
display: block;
font-size: 13px;
font-weight: 600;
color: #1e2635;
}
.ss-nav-lock {
color: #c5c8ce;
font-size: 12px;
}
.ss-nav-desc {
display: block;
color: #8a93a6;
font-size: 12px;
margin-top: 2px;
}
}
}
@media (max-width: 1080px) {
.ss-layout {
flex-direction: column;
}
.ss-nav {
position: static;
width: 100%;
flex-direction: row;
flex-wrap: wrap;
gap: 4px;
.ss-nav-item {
flex: none;
.ss-nav-desc {
display: none;
}
&.active::before {
display: none;
}
}
}
}
.ss-cards {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
} }
.setting-card { .setting-card {
border: 1px solid #e9edf5; border: 1px solid #f0f2f5;
border-radius: 10px; border-radius: 10px;
background: #fff; background: #fff;
padding: 18px 20px; padding: 18px 20px;
scroll-margin-top: 84px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
&.locked {
.card-body {
opacity: 0.55;
pointer-events: none;
}
}
.card-header { .card-header {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -753,9 +723,6 @@ watch(() => store.currentSchool?.schId, () => loadSites());
line-height: 1.6; line-height: 1.6;
} }
} }
.ss-lock-tip {
margin-bottom: 12px;
}
} }
.form-hint { .form-hint {
color: #999; color: #999;
@@ -781,11 +748,10 @@ watch(() => store.currentSchool?.schId, () => loadSites());
.preview-btn { .preview-btn {
margin: 8px 0 0; margin: 8px 0 0;
} }
.css-collapse {
margin: 4px 0 12px;
.css-editor :deep(textarea) { .css-editor :deep(textarea) {
font-family: Consolas, Monaco, monospace; font-family: Consolas, Monaco, monospace;
font-size: 12px; font-size: 12px;
line-height: 1.7;
} }
.css-meta { .css-meta {
display: flex; display: flex;
@@ -796,6 +762,5 @@ watch(() => store.currentSchool?.schId, () => loadSites());
font-size: 12px; font-size: 12px;
line-height: 1.6; line-height: 1.6;
} }
}
} }
</style> </style>
+43 -74
View File
@@ -1,35 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, watch } from "vue"; import { reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { import {
SITE_CONFIG_FIELD_DEFS, SITE_CONFIG_FIELD_DEFS,
SITE_CONFIG_GROUPS, SITE_CONFIG_GROUPS,
SiteConfigExtraRow,
SiteConfigFieldDef, SiteConfigFieldDef,
SiteConfigFormModel, SiteConfigFormModel,
SiteConfigGroup, SiteConfigGroup,
extraRowError,
firstExtraError,
parseSiteConfig, parseSiteConfig,
serializeSiteConfig, serializeSiteConfig,
validateFieldValue, validateFieldValue,
} from "@/utils/siteConfigForm"; } from "@/utils/siteConfigForm";
import ImageAssetField from "@/components/ImageAssetField.vue"; import ImageAssetField from "@/components/ImageAssetField.vue";
import FooterPreview from "@/components/FooterPreview.vue";
/** /**
* 站点配置结构化表单(docs/09 §3.1;docs/16 §3.3 分区作用域扩展): * 站点配置结构化表单(docs/09 §3.1;docs/16 §3.3 分区作用域;docs/20 F6 扩展参数区退役):
* v-model 绑定整页共享的 configJson 字符串(单一事实源)。 * v-model 绑定整页共享的 configJson 字符串(单一事实源)。
* props.groups 声明本分区渲染的分组;props.showExtras 控制扩展参数编辑区(每页仅一处)。 * props.groups 声明本分区渲染的分组。模型仍完整解析全部键(fields/preserved 双通道),
* 模型仍完整解析全部键(fields/extras/preserved 三通道),仅渲染限定分组—— * 仅渲染限定分组——序列化时未渲染分区的值原样回写,保证多分区实例绑定同一 configJson 互不踩踏。
* 序列化时未渲染分区的值原样回写,保证多分区实例绑定同一 configJson 互不踩踏。 * 保存前由父级调用 validate()。
* 保存前由父级调用 validate()(含扩展行兜底校验)。
*/ */
const props = defineProps<{ const props = defineProps<{
modelValue: string; modelValue: string;
/** 渲染的配置分组(缺省=全部分组,兼容旧整表单用法) */ /** 渲染的配置分组(缺省=全部分组,兼容旧整表单用法) */
groups?: SiteConfigGroup[]; groups?: SiteConfigGroup[];
/** 是否渲染扩展参数编辑区(docs/16:仅「扩展参数」分区开启) */
showExtras?: boolean;
/** 图片类字段上传归属站点(附件场景引用) */ /** 图片类字段上传归属站点(附件场景引用) */
siteId?: number; siteId?: number;
}>(); }>();
@@ -40,9 +34,8 @@ const groupSet = (() => new Set(props.groups || SITE_CONFIG_GROUPS.map(g => g.na
const visibleGroups = SITE_CONFIG_GROUPS.filter(g => groupSet.has(g.name)); const visibleGroups = SITE_CONFIG_GROUPS.filter(g => groupSet.has(g.name));
const formRef = ref(); const formRef = ref();
const formModel = reactive<{ fields: Record<string, string>; extras: SiteConfigExtraRow[] }>({ const formModel = reactive<{ fields: Record<string, string> }>({
fields: {}, fields: {},
extras: [],
}); });
let preserved: Record<string, unknown> = {}; let preserved: Record<string, unknown> = {};
let lastEmitted: string | null = null; let lastEmitted: string | null = null;
@@ -51,7 +44,6 @@ function parseIn(v: string) {
const m: SiteConfigFormModel = parseSiteConfig(v); const m: SiteConfigFormModel = parseSiteConfig(v);
Object.keys(formModel.fields).forEach(k => delete formModel.fields[k]); Object.keys(formModel.fields).forEach(k => delete formModel.fields[k]);
Object.assign(formModel.fields, m.fields); Object.assign(formModel.fields, m.fields);
formModel.extras.splice(0, formModel.extras.length, ...m.extras.map(r => ({ ...r })));
preserved = m.preserved; preserved = m.preserved;
} }
@@ -67,36 +59,34 @@ watch(
function emitUp() { function emitUp() {
lastEmitted = serializeSiteConfig({ lastEmitted = serializeSiteConfig({
fields: { ...formModel.fields }, fields: { ...formModel.fields },
extras: formModel.extras.map(r => ({ ...r })),
preserved, preserved,
}); });
emit("update:modelValue", lastEmitted); emit("update:modelValue", lastEmitted);
} }
function addExtra() { /** 保存前校验:内置字段 Form 规则;通过返回 true */
formModel.extras.push({ key: "", value: "" });
}
function removeExtra(i: number) {
formModel.extras.splice(i, 1);
emitUp();
}
/** 保存前校验:内置字段 Form 规则 + 扩展行兜底(仅渲染扩展区的实例校验,避免越区拦截);通过返回 true */
async function validate(): Promise<boolean> { async function validate(): Promise<boolean> {
const valid = await formRef.value?.validate(); return !!(await formRef.value?.validate());
if (!valid) return false;
if (props.showExtras) {
const extraMsg = firstExtraError(formModel.extras);
if (extraMsg) {
Message.error(`扩展参数有误:${extraMsg}`);
return false;
}
}
return true;
} }
defineExpose({ validate }); /**
* F1(docs/20)Tab 独立保存:仅导出本实例渲染分组的字段值(空值剔除)。
* 父级把返回值合并进服务端事实源 configJson 后提交——其他 Tab 的未保存修改不会被连带提交。
*/
function ownFields(): Record<string, string> {
const out: Record<string, string> = {};
visibleGroups.forEach(g => {
defsOf(g.name).forEach(d => {
const v = (formModel.fields[d.key] || "").trim();
if (v) {
out[d.key] = v;
}
});
});
return out;
}
defineExpose({ validate, ownFields });
function rulesFor(def: SiteConfigFieldDef) { function rulesFor(def: SiteConfigFieldDef) {
return [ return [
@@ -131,6 +121,7 @@ function defsOf(group: string): SiteConfigFieldDef[] {
v-model="formModel.fields[def.key]" v-model="formModel.fields[def.key]"
:site-id="siteId" :site-id="siteId"
:placeholder="def.placeholder" :placeholder="def.placeholder"
:aspect="def.aspect"
@update:model-value="emitUp" @update:model-value="emitUp"
/> />
<Input <Input
@@ -149,22 +140,22 @@ function defsOf(group: string): SiteConfigFieldDef[] {
/> />
<p v-if="def.tip" class="cfg-tip">{{ def.tip }}</p> <p v-if="def.tip" class="cfg-tip">{{ def.tip }}</p>
</FormItem> </FormItem>
</template> <!-- F5(docs/20):页脚分组底部固定结构示意图(字段↔视觉位置一一对应,实时回显) -->
<FooterPreview
<template v-if="showExtras"> v-if="g.name === 'footer'"
<div class="group-title">扩展参数</div> :contact="formModel.fields.contact"
<div v-for="(row, i) in formModel.extras" :key="i" class="extra-row"> :footer-addresses="formModel.fields.footer_addresses"
<Input v-model="row.key" placeholder="参数名" style="width: 200px" @on-change="emitUp" /> :qr-wechat="formModel.fields.qr_wechat"
<Input v-model="row.value" placeholder="参数值" style="flex: 1" @on-change="emitUp" /> :qr-weibo="formModel.fields.qr_weibo"
<Button type="text" class="extra-del" icon="md-trash" @click="removeExtra(i)" /> :socials="[
<p v-if="extraRowError(formModel.extras, i)" class="cfg-error"> { label: '微信', value: formModel.fields.social_wechat },
{{ extraRowError(formModel.extras, i) }} { label: '微博', value: formModel.fields.social_weibo },
</p> { label: '抖音', value: formModel.fields.social_douyin },
</div> { label: 'B站', value: formModel.fields.social_bilibili },
<Button type="dashed" long icon="md-add" class="extra-add" @click="addExtra">新增参数</Button> ]"
<p class="cfg-tip"> :copyright="formModel.fields.copyright"
面向后续扩展的预留配置项(键名仅字母/数字/下划线);系统托管参数(如 theme_params)不在此显示,保存时自动保留。 :beian="formModel.fields.beian"
</p> />
</template> </template>
</Form> </Form>
</div> </div>
@@ -190,27 +181,5 @@ function defsOf(group: string): SiteConfigFieldDef[] {
line-height: 1.6; line-height: 1.6;
margin-top: 4px; margin-top: 4px;
} }
.cfg-error {
color: #ed4014;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
.extra-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
.extra-del {
color: #999;
&:hover {
color: #ed4014;
}
}
}
.extra-add {
margin: 4px 0 8px;
}
} }
</style> </style>
+406 -30
View File
@@ -1,14 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { categoryApi } from "@/api/module/cms"; import { categoryApi } from "@/api/module/cms";
import { CategoryTreeNode } from "@/types/cms"; import { CategoryTreeNode } from "@/types/cms";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import BlockTypeThumb from "@/components/BlockTypeThumb.vue";
import IconGrid from "@/components/IconGrid.vue";
import { iconLabelOf } from "./icons";
import { import {
BadgeItem,
BlockType, BlockType,
HOME_LAYOUT_VERSION, HOME_LAYOUT_VERSION,
LayoutBlock, LayoutBlock,
LINK_NAME_MAX, LINK_NAME_MAX,
QUICK_LINK_MAX, QUICK_LINK_MAX,
STATS_MAX,
StatItem,
TOPICS_MAX,
TopicItem,
BADGES_MAX,
flattenListCategories, flattenListCategories,
genBlockId, genBlockId,
genFromCategories, genFromCategories,
@@ -19,16 +28,19 @@ import {
import { BLOCK_TYPES, blockDefOf, blockSummary } from "./blockTypes"; import { BLOCK_TYPES, blockDefOf, blockSummary } from "./blockTypes";
/** /**
* 首页布局编辑器(docs/16 主线B §4.3):嵌入站点设置「首页布局」分区。 * 首页布局编辑器(docs/16 主线B → docs/20 F2 三栏升级):
* v-model 绑定整页共享的 configJson(仅读写 home_layout 键,其余键无损回写)。 * 左=区块序列(类型小图标 + 高度示意骨架)· 中=实时预览 iframe(加载正式门户,
* 左侧区块序列(上移/下移/隐藏/删除)+ 右侧属性面板(注册表 schema 驱动); * 编排变更 debounce 500ms postMessage 注入近似模拟,含手动刷新兜底,D-3 方案 B)· 右=属性面板(schema 驱动)。
* 添加抽屉列组件库类型卡;「从当前栏目一键生成」复刻现状渲染为初始序列(首用救生索)。 * 添加抽屉类型卡与「展示形态」RadioCard 均配 SVG 缩略示意图(R-40)。
* 保存由父分区发起(emit("save")),校验经 defineExpose.validate()。 * 首用自动编排(D-4):无 home_layout 键且站点有列表型栏目时进入即自动生成初始序列;
* 显式空布局(blocks=[]) 是用户选择,不再自动生成。v-model 仍只读写 home_layout 键。
*/ */
const props = defineProps<{ const props = defineProps<{
modelValue: string; modelValue: string;
/** 布局数据源栏目树归属站点(编辑模式必传) */ /** 布局数据源栏目树归属站点(编辑模式必传) */
siteId?: number; siteId?: number;
/** 实时预览 iframe 的正式门户地址(编辑模式传入;创建模式无站点则隐藏预览栏) */
portalUrl?: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "update:modelValue", v: string): void; (e: "update:modelValue", v: string): void;
@@ -64,6 +76,7 @@ function emitUp() {
: null; : null;
lastEmitted = serializeHomeLayout(props.modelValue, layout); lastEmitted = serializeHomeLayout(props.modelValue, layout);
emit("update:modelValue", lastEmitted); emit("update:modelValue", lastEmitted);
schedulePreview();
} }
watch( watch(
@@ -91,7 +104,78 @@ async function loadTree() {
} }
} }
onMounted(loadTree); /** F2 首用自动编排:进入编辑器时无 home_layout 键(回落态)且站点有列表型栏目 → 静默生成初始序列 */
async function autoGenOnFirstUse() {
if (parseHomeLayout(props.modelValue) || !props.siteId) {
return;
}
await loadTree();
if (!blocks.value.length && flat.value.length) {
blocks.value = genFromCategories(tree.value, props.modelValue).blocks;
selectedId.value = null;
emitUp();
}
}
onMounted(autoGenOnFirstUse);
// ===== 中栏:实时预览(iframe + 视口切换 + postMessage 近似模拟 + 手动刷新兜底)=====
const VIEWPORTS = [
{ value: 1200, label: "桌面 1200" },
{ value: 768, label: "平板 768" },
{ value: 390, label: "手机 390" },
] as const;
const viewport = ref<number>(1200);
/** iframe 重载键(key 变更 = 重新挂载 = 重新加载正式门户) */
const frameKey = ref(0);
const frameWrap = ref<HTMLElement | null>(null);
/** 预览容器宽 → 缩放比(视口宽超出容器时等比缩小,保证整页可见) */
const frameScale = computed(() => {
const w = frameWrap.value?.clientWidth || 0;
return w && viewport.value > w ? w / viewport.value : 1;
});
const frameHeight = computed(() => Math.round(560 * (1 / frameScale.value)));
/** 预览就绪/模拟状态提示(注入成功置"已同步",未保存新区块走手动刷新) */
const previewState = ref("");
let previewTimer: ReturnType<typeof setTimeout> | null = null;
/** 编排变更 → debounce 500ms → postMessage 注入(R-41) */
function schedulePreview() {
if (!props.portalUrl) return;
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(sendPreview, 500);
}
function sendPreview() {
const frame = document.querySelector<HTMLIFrameElement>(".hle-preview-frame iframe");
const win = frame?.contentWindow;
if (!win) return;
const order = blocks.value.filter(b => b.visible).map(b => b.id);
try {
win.postMessage({ type: "cms-layout-preview", order }, "*");
previewState.value = "已同步未保存的顺序/显隐(新区块保存后可见)";
} catch {
previewState.value = "预览注入不可用,请点「刷新预览」";
}
}
function reloadPreview() {
frameKey.value++;
previewState.value = "";
}
onBeforeUnmount(() => {
if (previewTimer) clearTimeout(previewTimer);
});
/** 保存成功后由父级调用:重载 iframe 呈现真实效果(验收 5「保存即可见」闭环) */
function refreshAfterSave() {
reloadPreview();
}
// ===== 序列操作 =====
function addBlock(type: string) { function addBlock(type: string) {
const def = blockDefOf(type); const def = blockDefOf(type);
@@ -138,6 +222,7 @@ function clearLayout() {
selectedId.value = null; selectedId.value = null;
lastEmitted = serializeHomeLayout(props.modelValue, null); lastEmitted = serializeHomeLayout(props.modelValue, null);
emit("update:modelValue", lastEmitted); emit("update:modelValue", lastEmitted);
reloadPreview();
}, },
}); });
} }
@@ -179,9 +264,51 @@ function onFieldChange() {
} }
/** quick_links 入口行:编辑期内存允许留空行,提交时由 cleanBlock 剔除 */ /** quick_links 入口行:编辑期内存允许留空行,提交时由 cleanBlock 剔除 */
type LinkItems = { name: string; url: string }[]; type LinkItems = { name: string; url: string; icon?: string }[];
function itemsOf(block: LayoutBlock): LinkItems { function itemsOf(block: LayoutBlock): LinkItems {
return block.items || (block.items = []); return (block.items as LinkItems) || (block.items = [] as unknown as LayoutBlock["items"]);
}
/** D-1 统计项行(编辑期允许空行,提交时 cleanBlock 剔除) */
function statsOf(block: LayoutBlock): StatItem[] {
return block.stats || (block.stats = []);
}
function addStat(block: LayoutBlock) {
if (statsOf(block).length >= STATS_MAX) return;
statsOf(block).push({ label: "", value: "", unit: "" });
emitUp();
}
function removeStat(block: LayoutBlock, index: number) {
statsOf(block).splice(index, 1);
emitUp();
}
/** D-3 专题卡行(编辑器模型 topicItems,cleanBlock 序列化为契约键 items) */
function topicsOf(block: LayoutBlock): TopicItem[] {
return block.topicItems || (block.topicItems = []);
}
function addTopic(block: LayoutBlock) {
if (topicsOf(block).length >= TOPICS_MAX) return;
topicsOf(block).push({ title: "", image: "" });
emitUp();
}
function removeTopic(block: LayoutBlock, index: number) {
topicsOf(block).splice(index, 1);
emitUp();
}
/** D-9 徽章行 */
function badgesOf(block: LayoutBlock): BadgeItem[] {
return block.badgeItems || (block.badgeItems = []);
}
function addBadge(block: LayoutBlock) {
if (badgesOf(block).length >= BADGES_MAX) return;
badgesOf(block).push({ label: "", image: "" });
emitUp();
}
function removeBadge(block: LayoutBlock, index: number) {
badgesOf(block).splice(index, 1);
emitUp();
} }
function addLink(block: LayoutBlock) { function addLink(block: LayoutBlock) {
@@ -202,7 +329,7 @@ function validate(): string | null {
return errors.length ? errors.join(";") : null; return errors.length ? errors.join(";") : null;
} }
defineExpose({ validate }); defineExpose({ validate, refreshAfterSave });
</script> </script>
<template> <template>
@@ -222,8 +349,9 @@ defineExpose({ validate });
<p class="hle-empty-sub">点「从当前栏目一键生成」得到与现状等价的初始序列,再删改微调;或从空白开始添加区块。</p> <p class="hle-empty-sub">点「从当前栏目一键生成」得到与现状等价的初始序列,再删改微调;或从空白开始添加区块。</p>
</div> </div>
<!-- 序列 + 属性面板 --> <!-- 三栏:序列 + 实时预览 + 属性面板 -->
<div v-else class="hle-body"> <div v-else class="hle-body">
<!-- 左:区块序列(图标 + 高度示意骨架 + 摘要) -->
<div class="hle-sequence"> <div class="hle-sequence">
<div <div
v-for="(b, i) in blocks" v-for="(b, i) in blocks"
@@ -232,6 +360,7 @@ defineExpose({ validate });
:class="{ active: b.id === selectedId, hidden: !b.visible }" :class="{ active: b.id === selectedId, hidden: !b.visible }"
@click="selectedId = b.id" @click="selectedId = b.id"
> >
<span class="hle-row-vis" :class="`vis-${b.type}${b.type === 'article_list' ? '-' + (b.style || 'card') : ''}`"></span>
<span class="hle-row-main"> <span class="hle-row-main">
<Icon :type="blockDefOf(b.type)?.icon || 'md-square'" class="hle-row-icon" /> <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> <span class="hle-row-title">{{ b.title || blockDefOf(b.type)?.title || b.type }}</span>
@@ -247,6 +376,30 @@ defineExpose({ validate });
</div> </div>
</div> </div>
<!-- 中:实时预览(正式门户 iframe,方案 B 近似模拟) -->
<div class="hle-preview">
<div class="hle-preview-bar">
<RadioGroup v-model="viewport" size="small" @on-change="schedulePreview">
<Radio v-for="v in VIEWPORTS" :key="v.value" :label="v.value">{{ v.label }}</Radio>
</RadioGroup>
<Button size="small" icon="md-refresh" @click="reloadPreview">刷新预览</Button>
</div>
<div ref="frameWrap" class="hle-preview-frame">
<iframe
:key="frameKey"
:src="portalUrl"
:style="{ width: viewport + 'px', height: frameHeight + 'px', transform: `scale(${frameScale})` }"
title="门户首页实时预览"
loading="lazy"
@load="previewState = ''"
></iframe>
</div>
<p class="hle-preview-tip">
{{ previewState || "预览为未保存编排的近似模拟(顺序/显隐即时注入;新增或改属性区块保存后点「刷新预览」可见)" }}
</p>
</div>
<!-- 右:属性面板 -->
<div class="hle-props"> <div class="hle-props">
<template v-if="selected && selectedDef"> <template v-if="selected && selectedDef">
<div class="hle-props-head"> <div class="hle-props-head">
@@ -256,8 +409,21 @@ defineExpose({ validate });
</div> </div>
<div v-for="f in visibleFields" :key="f.key" class="hle-field"> <div v-for="f in visibleFields" :key="f.key" class="hle-field">
<label class="hle-field-label">{{ f.label }}</label> <label class="hle-field-label">{{ f.label }}</label>
<!-- F2/R-40:展示形态 = RadioCard 可视化选择(缩略图 + 文字),不盲选下拉 -->
<div v-if="f.kind === 'select' && f.card" class="hle-style-cards">
<label
v-for="o in f.options"
:key="o.value"
class="hle-style-card"
:class="{ active: (selected[f.key] || 'card') === o.value }"
@click.prevent="selected![f.key] = o.value; onFieldChange()"
>
<BlockTypeThumb type="article_list" :variant="o.value" />
<span class="hle-style-name">{{ o.label }}</span>
</label>
</div>
<Input <Input
v-if="f.kind === 'text'" v-else-if="f.kind === 'text'"
v-model="selected[f.key]" v-model="selected[f.key]"
:maxlength="f.maxlength || 100" :maxlength="f.maxlength || 100"
:placeholder="f.placeholder" :placeholder="f.placeholder"
@@ -307,6 +473,14 @@ defineExpose({ validate });
@on-change="onFieldChange" @on-change="onFieldChange"
/> />
<Input v-model="l.url" placeholder="https://… 或 / 站内路径" @on-change="onFieldChange" /> <Input v-model="l.url" placeholder="https://… 或 / 站内路径" @on-change="onFieldChange" />
<Poptip placement="bottom" width="300" transfer>
<Button size="small" class="hle-link-icon-btn">
{{ l.icon ? iconLabelOf(l.icon) : "图标" }}
</Button>
<template #content>
<IconGrid v-model="l.icon" @update:model-value="onFieldChange" />
</template>
</Poptip>
<Button <Button
size="small" size="small"
type="text" type="text"
@@ -326,6 +500,62 @@ defineExpose({ validate });
添加入口 添加入口
</Button> </Button>
</div> </div>
<!-- D-1 统计项编辑器 -->
<div v-else-if="f.kind === 'stats'" class="hle-rows-editor">
<div v-for="(st, si) in statsOf(selected!)" :key="si" class="hle-stat-row">
<Input v-model="st.label" class="hle-stat-label" maxlength="20" placeholder="名称(如 在校生)" @on-change="onFieldChange" />
<Input v-model="st.value" class="hle-stat-value" maxlength="10" placeholder="数值(如 3.2)" @on-change="onFieldChange" />
<Input v-model="st.unit" class="hle-stat-unit" maxlength="10" placeholder="单位(如 万)" @on-change="onFieldChange" />
<Button size="small" type="text" icon="md-trash" class="hle-link-del" @click="removeStat(selected!, si)" />
</div>
<p v-if="!statsOf(selected!).length" class="hle-links-empty">还没有统计项,点下方按钮添加</p>
<Button class="hle-link-add" long icon="md-add" :disabled="statsOf(selected!).length >= STATS_MAX" @click="addStat(selected!)">
添加统计项
</Button>
</div>
<!-- D-3 专题卡编辑器 -->
<div v-else-if="f.kind === 'topics'" class="hle-rows-editor">
<div v-for="(t, ti) in topicsOf(selected!)" :key="ti" class="hle-topic-card">
<div class="hle-topic-head">
<span>专题 {{ ti + 1 }}</span>
<Button size="small" type="text" icon="md-trash" class="hle-link-del" @click="removeTopic(selected!, ti)" />
</div>
<Input v-model="t.title" maxlength="50" placeholder="专题标题(如 2026 招生专题)" @on-change="onFieldChange" />
<Input v-model="t.image" maxlength="500" placeholder="图片路径 /yschcms/upload/…(附件管理上传后复制)" @on-change="onFieldChange" />
<Input v-model="t.description" maxlength="200" placeholder="一句话描述(可选)" @on-change="onFieldChange" />
<div class="hle-topic-cta">
<Input v-model="t.ctaText" maxlength="20" placeholder="按钮文字(如 进入专题)" @on-change="onFieldChange" />
<Input v-model="t.ctaUrl" maxlength="500" placeholder="按钮地址 https://… 或 / 路径" @on-change="onFieldChange" />
</div>
</div>
<p v-if="!topicsOf(selected!).length" class="hle-links-empty">还没有专题卡,点下方按钮添加</p>
<Button class="hle-link-add" long icon="md-add" :disabled="topicsOf(selected!).length >= TOPICS_MAX" @click="addTopic(selected!)">
添加专题卡
</Button>
</div>
<!-- D-9 徽章编辑器 -->
<div v-else-if="f.kind === 'badges'" class="hle-rows-editor">
<div v-for="(bd, bi) in badgesOf(selected!)" :key="bi" class="hle-badge-row">
<Input v-model="bd.label" class="hle-stat-label" maxlength="20" placeholder="名称(如 软科排名)" @on-change="onFieldChange" />
<Input v-model="bd.image" maxlength="500" placeholder="徽章图路径 /yschcms/upload/…" @on-change="onFieldChange" />
<Input v-model="bd.desc" class="hle-stat-unit" maxlength="50" placeholder="说明(如 前100强)" @on-change="onFieldChange" />
<Button size="small" type="text" icon="md-trash" class="hle-link-del" @click="removeBadge(selected!, bi)" />
</div>
<p v-if="!badgesOf(selected!).length" class="hle-links-empty">还没有徽章,点下方按钮添加</p>
<Button class="hle-link-add" long icon="md-add" :disabled="badgesOf(selected!).length >= BADGES_MAX" @click="addBadge(selected!)">
添加徽章
</Button>
</div>
<!-- D-7 人物分组栏目(单选下拉) -->
<Select
v-else-if="f.kind === 'category'"
:model-value="selected.categoryId"
clearable
placeholder="不选 = 全部人物"
@on-change="(v: any) => { selected!.categoryId = v || undefined; onFieldChange(); }"
>
<Option v-for="c in flat" :key="c.id" :value="c.id">{{ c.name }}</Option>
</Select>
<p v-if="f.tip" class="hle-field-tip">{{ f.tip }}</p> <p v-if="f.tip" class="hle-field-tip">{{ f.tip }}</p>
</div> </div>
</template> </template>
@@ -333,17 +563,19 @@ defineExpose({ validate });
</div> </div>
</div> </div>
<!-- 添加区块抽屉 --> <!-- 添加区块抽屉(R-40:每类型配缩略示意图) -->
<Drawer v-model="addOpen" title="添加区块" width="420" class="hle-drawer"> <Drawer v-model="addOpen" title="添加区块" width="440" class="hle-drawer">
<div <div
v-for="def in BLOCK_TYPES" v-for="def in BLOCK_TYPES"
:key="def.type" :key="def.type"
class="hle-type-card" class="hle-type-card"
@click="addBlock(def.type)" @click="addBlock(def.type)"
> >
<Icon :type="def.icon" class="hle-type-icon" /> <div class="hle-type-thumb">
<BlockTypeThumb :type="def.type" />
</div>
<span class="hle-type-body"> <span class="hle-type-body">
<span class="hle-type-title">{{ def.title }}</span> <span class="hle-type-title"><Icon :type="def.icon" /> {{ def.title }}</span>
<span class="hle-type-desc">{{ def.description }}</span> <span class="hle-type-desc">{{ def.description }}</span>
</span> </span>
<Icon type="ios-arrow-forward" class="hle-type-go" /> <Icon type="ios-arrow-forward" class="hle-type-go" />
@@ -360,10 +592,6 @@ defineExpose({ validate });
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin-bottom: 12px; margin-bottom: 12px;
.hle-gen,
.hle-clear {
margin-left: 0;
}
.hle-clear { .hle-clear {
color: #999; color: #999;
&:hover { &:hover {
@@ -392,20 +620,23 @@ defineExpose({ validate });
} }
.hle-body { .hle-body {
display: flex; display: flex;
gap: 14px; gap: 12px;
align-items: flex-start; align-items: stretch;
} }
.hle-sequence { .hle-sequence {
flex: 1; width: 262px;
min-width: 0; flex: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
max-height: 640px;
overflow: auto;
} }
.hle-row { .hle-row {
position: relative;
border: 1px solid #dcdee2; border: 1px solid #dcdee2;
border-radius: 6px; border-radius: 6px;
padding: 8px 12px; padding: 8px 12px 8px 16px;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
&.active { &.active {
@@ -415,6 +646,28 @@ defineExpose({ validate });
&.hidden { &.hidden {
opacity: 0.55; opacity: 0.55;
} }
.hle-row-vis {
position: absolute;
left: 5px;
top: 10px;
bottom: 10px;
width: 4px;
border-radius: 2px;
background: linear-gradient(180deg, #7fb2f0 0%, #4a7fd0 100%);
opacity: 0.75;
}
.hle-row-vis.vis-ticker,
.hle-row-vis.vis-notice_bar {
top: 45%;
background: #2d8cf0;
}
.hle-row-vis.vis-links,
.hle-row-vis.vis-quick_links {
top: 20%;
}
.hle-row-vis.vis-gallery {
top: 15%;
}
.hle-row-main { .hle-row-main {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -446,13 +699,53 @@ defineExpose({ validate });
} }
} }
} }
.hle-preview {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
border: 1px solid #e8eaec;
border-radius: 6px;
background: #f6f8fb;
padding: 8px;
.hle-preview-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.hle-preview-frame {
position: relative;
flex: 1;
min-height: 480px;
overflow: hidden;
border: 1px solid #e2e8f2;
border-radius: 4px;
background: #fff;
iframe {
border: 0;
transform-origin: top left;
display: block;
}
}
.hle-preview-tip {
margin: 6px 2px 0;
color: #999;
font-size: 12px;
line-height: 1.5;
}
}
.hle-props { .hle-props {
width: 320px; width: 300px;
flex: none; flex: none;
border: 1px solid #e8eaec; border: 1px solid #e8eaec;
border-radius: 6px; border-radius: 6px;
padding: 12px; padding: 12px;
background: #fafbfc; background: #fafbfc;
max-height: 640px;
overflow: auto;
.hle-props-head { .hle-props-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -491,6 +784,37 @@ defineExpose({ validate });
margin-top: 3px; margin-top: 3px;
} }
} }
.hle-style-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
.hle-style-card {
border: 1px solid #dcdee2;
border-radius: 6px;
padding: 6px;
cursor: pointer;
background: #fff;
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #5cadff;
}
&.active {
border-color: #2d8cf0;
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.12);
.hle-style-name {
color: #2d8cf0;
font-weight: 600;
}
}
.hle-style-name {
display: block;
margin-top: 4px;
font-size: 12px;
color: #515a6e;
text-align: center;
}
}
}
.hle-cats { .hle-cats {
max-height: 220px; max-height: 220px;
overflow: auto; overflow: auto;
@@ -533,6 +857,51 @@ defineExpose({ validate });
border-style: dashed; border-style: dashed;
} }
} }
.hle-rows-editor {
display: flex;
flex-direction: column;
gap: 8px;
.hle-stat-row,
.hle-badge-row {
display: flex;
align-items: center;
gap: 6px;
.hle-stat-label {
flex: 2;
min-width: 0;
}
.hle-stat-value {
flex: 1;
min-width: 0;
}
.hle-stat-unit {
flex: 1;
min-width: 0;
}
}
.hle-topic-card {
border: 1px solid #e2e8f2;
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
.hle-topic-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #8a93a6;
}
.hle-topic-cta {
display: flex;
gap: 6px;
}
}
}
.hle-link-icon-btn {
flex: none;
}
} }
.hle-type-card { .hle-type-card {
display: flex; display: flex;
@@ -540,7 +909,7 @@ defineExpose({ validate });
gap: 10px; gap: 10px;
border: 1px solid #dcdee2; border: 1px solid #dcdee2;
border-radius: 6px; border-radius: 6px;
padding: 12px; padding: 10px;
margin-bottom: 10px; margin-bottom: 10px;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
@@ -548,9 +917,12 @@ defineExpose({ validate });
border-color: #2d8cf0; border-color: #2d8cf0;
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.1); box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.1);
} }
.hle-type-icon { .hle-type-thumb {
font-size: 22px; width: 116px;
color: #2d8cf0; flex: none;
border: 1px solid #edf1f7;
border-radius: 4px;
overflow: hidden;
} }
.hle-type-body { .hle-type-body {
flex: 1; flex: 1;
@@ -559,6 +931,10 @@ defineExpose({ validate });
display: block; display: block;
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 13px;
.ivu-icon {
color: #2d8cf0;
margin-right: 4px;
}
} }
.hle-type-desc { .hle-type-desc {
display: block; display: block;
+126 -4
View File
@@ -9,7 +9,8 @@ import { LayoutBlock, LINK_NAME_MAX, LIST_STYLES, NOTICE_TEXT_MAX, QUICK_LINK_MA
export interface BlockPropField { export interface BlockPropField {
key: string; key: string;
label: string; label: string;
kind: "text" | "number" | "select" | "switch" | "categories" | "links"; kind: "text" | "number" | "select" | "switch" | "categories" | "links"
| "stats" | "topics" | "badges" | "category" | "video";
options?: { value: string; label: string }[]; options?: { value: string; label: string }[];
min?: number; min?: number;
max?: number; max?: number;
@@ -19,6 +20,8 @@ export interface BlockPropField {
tip?: string; tip?: string;
/** 条件显示(返回 false 时隐藏且不校验) */ /** 条件显示(返回 false 时隐藏且不校验) */
showIf?: (block: LayoutBlock) => boolean; showIf?: (block: LayoutBlock) => boolean;
/** select 的可视化选项卡渲染(RadioCard + 缩略图,F2/R-40:形态选择不盲选) */
card?: boolean;
} }
export interface BlockTypeDef { export interface BlockTypeDef {
@@ -40,11 +43,12 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
defaultProps: () => ({ style: "card", size: 8, categoryIds: [] }), defaultProps: () => ({ style: "card", size: 8, categoryIds: [] }),
propFields: [ propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "留空显示第一个栏目名" }, { key: "title", label: "区块标题", kind: "text", placeholder: "留空显示第一个栏目名" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Campus News(主题开双语后显示)" },
{ {
key: "categoryIds", label: "数据栏目", kind: "categories", key: "categoryIds", label: "数据栏目", kind: "categories",
tip: "可多选;渲染时聚合所选栏目的全部子孙栏目文章(文章通常挂叶子栏目)", tip: "可多选;渲染时聚合所选栏目的全部子孙栏目文章(文章通常挂叶子栏目)",
}, },
{ key: "style", label: "展示形态", kind: "select", options: LIST_STYLES }, { key: "style", label: "展示形态", kind: "select", options: LIST_STYLES, card: true, tip: "卡片缩略图为该形态的典型渲染示意,最终视觉以皮肤包实现为准" },
{ {
key: "size", label: "文章条数", kind: "number", min: 1, max: 20, key: "size", label: "文章条数", kind: "number", min: 1, max: 20,
showIf: b => b.style !== "image_wall", showIf: b => b.style !== "image_wall",
@@ -64,6 +68,7 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
defaultProps: () => ({ source: "top", size: 6 }), defaultProps: () => ({ source: "top", size: 6 }),
propFields: [ propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「要闻速递」" }, { key: "title", label: "区块标题", kind: "text", placeholder: "默认「要闻速递」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Latest News(主题开双语后显示)" },
{ {
key: "source", label: "数据来源", kind: "select", key: "source", label: "数据来源", kind: "select",
options: [ options: [
@@ -87,6 +92,14 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
propFields: [ propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「线上展厅」" }, { key: "title", label: "区块标题", kind: "text", placeholder: "默认「线上展厅」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "默认 Online Exhibition Hall" }, { key: "subtitle", label: "英文副题", kind: "text", placeholder: "默认 Online Exhibition Hall" },
{
key: "layout", label: "布局形态", kind: "select",
options: [
{ value: "grid", label: "规整网格" },
{ value: "masonry", label: "瀑布流(campus_wall)" },
],
tip: "瀑布流 = 不等高错落排布,hover 放大;适合校园风光多图",
},
], ],
}, },
{ {
@@ -97,6 +110,7 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
defaultProps: () => ({}), defaultProps: () => ({}),
propFields: [ propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「友情链接」" }, { key: "title", label: "区块标题", kind: "text", placeholder: "默认「友情链接」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Friendly Links(主题开双语后显示)" },
{ key: "group", label: "链接分组", kind: "text", placeholder: "留空显示全部分组" }, { key: "group", label: "链接分组", kind: "text", placeholder: "留空显示全部分组" },
], ],
}, },
@@ -108,9 +122,10 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
defaultProps: () => ({ items: [{ name: "", url: "" }] }), defaultProps: () => ({ items: [{ name: "", url: "" }] }),
propFields: [ propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「快捷通道」" }, { key: "title", label: "区块标题", kind: "text", placeholder: "默认「快捷通道」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Quick Links(主题开双语后显示)" },
{ {
key: "items", label: "入口列表", kind: "links", key: "items", label: "入口列表", kind: "links",
tip: `每个入口 = 名称(${LINK_NAME_MAX} 字内)+ 地址(http(s):// 或站内 / 路径),最多 ${QUICK_LINK_MAX} 个`, tip: `每个入口 = 名称(${LINK_NAME_MAX} 字内)+ 地址 + 可选图标,最多 ${QUICK_LINK_MAX} 个;未选图标显示首字徽章`,
}, },
], ],
}, },
@@ -122,6 +137,7 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
defaultProps: () => ({ text: "", level: "info" }), defaultProps: () => ({ text: "", level: "info" }),
propFields: [ propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「公告」,显示为左侧标签" }, { key: "title", label: "区块标题", kind: "text", placeholder: "默认「公告」,显示为左侧标签" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选(主题开双语后显示)" },
{ {
key: "text", label: "公告正文", kind: "text", maxlength: NOTICE_TEXT_MAX, key: "text", label: "公告正文", kind: "text", maxlength: NOTICE_TEXT_MAX,
placeholder: "如:国庆放假安排通知如下……", tip: `通栏一行展示,建议一句话(${NOTICE_TEXT_MAX} 字内)`, placeholder: "如:国庆放假安排通知如下……", tip: `通栏一行展示,建议一句话(${NOTICE_TEXT_MAX} 字内)`,
@@ -136,6 +152,97 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
}, },
], ],
}, },
// ===== docs/20 D-1~D-9 新组件(骨架兜底渲染,所有主题可用) =====
{
type: "stats_band",
title: "数字计数带",
icon: "md-stats",
description: "在校生/教职工/占地/院士等大数字横排,滚动可见时自动计数动画",
defaultProps: () => ({ stats: [{ label: "", value: "", unit: "" }] }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「数说学校」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Facts & Figures" },
{
key: "stats", label: "统计项", kind: "stats",
tip: "数值支持小数与「万/千」单位(如 3.2 + 万);滚动到可视区时数字从 0 滚动到目标值",
},
],
},
{
type: "featured_topics",
title: "专题大卡",
icon: "md-photos",
description: "招生/校庆/双一流等专题:大图 + 标题 + 描述 + 按钮,2~3 张并排",
defaultProps: () => ({ topicItems: [{ title: "", image: "" }] }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「专题推荐」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Featured Topics" },
{
key: "items", label: "专题卡", kind: "topics",
tip: "图片先在「附件管理」上传后复制路径填入(/ 开头),建议尺寸 800×500",
},
],
},
{
type: "video_tour",
title: "视频/VR 校园",
icon: "md-play-circle",
description: "校园宣传片/VR 导览:封面大图 + 播放按钮,点击跳转视频地址",
defaultProps: () => ({ cover: "", url: "" }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「云端校园」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Virtual Tour" },
{
key: "cover", label: "封面图地址", kind: "text", maxlength: 500,
placeholder: "/yschcms/upload/… 或 https://…(附件管理上传后复制路径)",
},
{
key: "url", label: "视频/VR 地址", kind: "text", maxlength: 500,
placeholder: "https://… 或站内 / 路径,点击封面跳转",
},
],
},
{
type: "events",
title: "学术日历",
icon: "md-calendar",
description: "学术报告/活动:大日期块 + 活动名 + 地点;数据在「轮播管理 → 学术活动」维护",
defaultProps: () => ({}),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「学术活动」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Academic Events" },
],
},
{
type: "people",
title: "名师风采",
icon: "md-contacts",
description: "圆头像 + 姓名 + 头衔 + 一句话简介卡片;人物数据在「人物管理」维护",
defaultProps: () => ({}),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「名师风采」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Our Faculty" },
{
key: "categoryId", label: "人物分组栏目", kind: "category",
tip: "可选;选择后仅展示挂在该栏目下的人物(人物编辑时指定所属栏目),不选 = 全部人物。人物的新增/编辑在「人物管理」页(/person)维护",
},
],
},
{
type: "badges",
title: "排名/学科徽章带",
icon: "md-ribbon",
description: "软科排名 / 学科评估 A+ / ESI 前 1% 等徽章图片 + 文字横排",
defaultProps: () => ({ badgeItems: [{ label: "", image: "" }] }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「实力声誉」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Rankings" },
{
key: "badgeItems", label: "徽章列表", kind: "badges",
tip: "徽章图片建议透明底 PNG(附件管理上传后复制路径,/ 开头)",
},
],
},
]; ];
export function blockDefOf(type: string): BlockTypeDef | undefined { export function blockDefOf(type: string): BlockTypeDef | undefined {
@@ -154,7 +261,7 @@ export function blockSummary(block: LayoutBlock, namesOf: (id: number) => string
parts.push(block.source === "category" ? "指定栏目最新" : "全站置顶"); parts.push(block.source === "category" ? "指定栏目最新" : "全站置顶");
parts.push(`${block.size ?? 6}条`); parts.push(`${block.size ?? 6}条`);
} else if (block.type === "gallery") { } else if (block.type === "gallery") {
parts.push("线上展厅图集"); parts.push(block.layout === "masonry" ? "线上展厅 · 瀑布流" : "线上展厅图集");
} else if (block.type === "links") { } else if (block.type === "links") {
parts.push(block.group ? `分组:${block.group}` : "全部分组"); parts.push(block.group ? `分组:${block.group}` : "全部分组");
} else if (block.type === "quick_links") { } else if (block.type === "quick_links") {
@@ -166,6 +273,21 @@ export function blockSummary(block: LayoutBlock, namesOf: (id: number) => string
if (text) { if (text) {
parts.push(text.length > 18 ? `${text.slice(0, 18)}…` : text); parts.push(text.length > 18 ? `${text.slice(0, 18)}…` : text);
} }
} else if (block.type === "stats_band") {
const filled = (block.stats || []).filter(st => (st.label || "").trim() || (st.value || "").trim());
parts.push(`${filled.length} 项数字`);
} else if (block.type === "featured_topics") {
const filled = (block.topicItems || []).filter(t => (t.title || "").trim());
parts.push(`${filled.length} 张专题卡`);
} else if (block.type === "video_tour") {
parts.push("封面 + 播放跳转");
} else if (block.type === "events") {
parts.push("学术活动(轮播管理维护)");
} else if (block.type === "people") {
parts.push(block.categoryId ? "按分组栏目过滤" : "全部人物");
} else if (block.type === "badges") {
const filled = (block.badgeItems || []).filter(b => (b.label || "").trim());
parts.push(`${filled.length} 个徽章`);
} }
const ids = block.categoryIds || []; const ids = block.categoryIds || [];
if (ids.length) { if (ids.length) {
+72 -1
View File
@@ -49,7 +49,7 @@ describe("parseHomeLayout:容错解析(无键/非法→null 回落)", () =
expect(layout.blocks.length).toBe(3); 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[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[2].id).toMatch(/^b_/);
expect(layout.blocks.some(b => b.type === "richtext")).toBe(false); expect(layout.blocks.some((b: LayoutBlock) => (b as { type: string }).type === "richtext")).toBe(false);
}); });
it("visible:false 保留(保序下线),非字符串数字容错", () => { it("visible:false 保留(保序下线),非字符串数字容错", () => {
@@ -247,3 +247,74 @@ describe("flattenListCategories:扁平化仅列表型(选择器数据源)"
]); ]);
}); });
}); });
/* ===== docs/20 D 组件库:新类型解析/序列化/校验 ===== */
describe("homeLayout - D 组件(docs/20)", () => {
const D_LAYOUT = JSON.stringify({
home_layout: {
version: 1,
blocks: [
{ id: "s1", type: "stats_band", title: "数说学校", stats: [
{ label: "在校生", value: "3.2", unit: "万", icon: "xiaoli" },
{ label: "教职工", value: "2800" },
] },
{ id: "t1", type: "featured_topics", items: [
{ title: "2026 招生专题", image: "/cms/upload/topic1.jpg", description: "欢迎报考", ctaText: "进入专题", ctaUrl: "https://zs.example.edu.cn" },
] },
{ id: "v1", type: "video_tour", cover: "/cms/upload/vr.jpg", url: "https://vr.example.edu.cn" },
{ id: "e1", type: "events" },
{ id: "p1", type: "people", categoryId: 12 },
{ id: "b1", type: "badges", badgeItems: [{ label: "软科排名", desc: "前100强", image: "/cms/upload/badge.png" }] },
{ id: "g1", type: "gallery", layout: "masonry" },
{ id: "q1", type: "quick_links", items: [{ name: "办事大厅", url: "/service", icon: "banshi" }] },
],
},
});
it("parse:D 新类型全量解析(stats/topicItems→items 契约/video/people.categoryId/gallery.layout/icon)", () => {
const layout = parseHomeLayout(D_LAYOUT)!;
expect(layout.blocks).toHaveLength(8);
const s1 = layout.blocks.find(b => b.id === "s1")!;
expect(s1.stats?.[0]).toMatchObject({ label: "在校生", value: "3.2", unit: "万", icon: "xiaoli" });
const t1 = layout.blocks.find(b => b.id === "t1")!;
expect(t1.topicItems?.[0].ctaUrl).toBe("https://zs.example.edu.cn");
const v1 = layout.blocks.find(b => b.id === "v1")!;
expect(v1.cover).toBe("/cms/upload/vr.jpg");
const p1 = layout.blocks.find(b => b.id === "p1")!;
expect(p1.categoryId).toBe(12);
const g1 = layout.blocks.find(b => b.id === "g1")!;
expect(g1.layout).toBe("masonry");
const q1 = layout.blocks.find(b => b.id === "q1")!;
expect(q1.items?.[0].icon).toBe("banshi");
});
it("roundtrip:D 类型 serialize→parse 无损(topicItems 序列化为契约键 items;空行剔除)", () => {
const once = parseHomeLayout(D_LAYOUT)!;
const twice = parseHomeLayout(serializeHomeLayout("{}", once))!;
const t1 = twice.blocks.find(b => b.id === "t1")!;
expect(JSON.stringify(t1.topicItems)).toBe(JSON.stringify(once.blocks.find(b => b.id === "t1")!.topicItems));
const b1 = twice.blocks.find(b => b.id === "b1")!;
expect(b1.badgeItems?.[0].label).toBe("软科排名");
// quick_links icon 随行序列化
const q1 = twice.blocks.find(b => b.id === "q1")!;
expect(q1.items?.[0].icon).toBe("banshi");
// gallery masonry 序列化
const g1 = twice.blocks.find(b => b.id === "g1")!;
expect(g1.layout).toBe("masonry");
});
it("validate:D 各类缺失/超限/非法值给出可读错误", () => {
const mk = (blocks: unknown[]) =>
({ version: 1, blocks: blocks as never[] });
expect(validateHomeLayout(mk([{ id: "x", type: "stats_band", visible: true }]))[0]).toContain("统计项");
expect(validateHomeLayout(mk([{ id: "x", type: "stats_band", visible: true, stats: [{ label: "在校生", value: "12345678901" }] }]))[0]).toContain("数值");
expect(validateHomeLayout(mk([{ id: "x", type: "featured_topics", visible: true }]))[0]).toContain("专题卡");
expect(validateHomeLayout(mk([{ id: "x", type: "featured_topics", visible: true, topicItems: [{ title: "专题", image: "javascript:alert(1)" }] }]))[0]).toContain("图片");
expect(validateHomeLayout(mk([{ id: "x", type: "video_tour", visible: true }]))[0]).toContain("封面");
expect(validateHomeLayout(mk([{ id: "x", type: "video_tour", visible: true, cover: "/cms/upload/a.jpg" }]))[0]).toContain("视频");
expect(validateHomeLayout(mk([{ id: "x", type: "badges", visible: true }]))[0]).toContain("徽章");
expect(validateHomeLayout(mk([{ id: "x", type: "gallery", visible: true, layout: "zigzag" }]))[0]).toContain("布局");
// 合法布局零错误
expect(validateHomeLayout(mk([{ id: "x", type: "gallery", visible: true, layout: "masonry" }]))).toEqual([]);
});
});
+268 -8
View File
@@ -9,13 +9,41 @@
*/ */
import { CategoryTreeNode } from "@/types/cms"; import { CategoryTreeNode } from "@/types/cms";
/** 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */ /** 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致;docs/20 D 新增后 12 种) */
export type BlockType = "article_list" | "ticker" | "gallery" | "links" | "quick_links" | "notice_bar"; export type BlockType =
| "article_list" | "ticker" | "gallery" | "links" | "quick_links" | "notice_bar"
// docs/20 D-1~D-9
| "stats_band" | "featured_topics" | "video_tour" | "events" | "people" | "badges";
/** quick_links 单个入口(v2 手册 §2.8) */ /** quick_links 单个入口(v2 手册 §2.8;D-2 加可选 icon 内置图标标识符) */
export interface QuickLinkItem { export interface QuickLinkItem {
name: string; name: string;
url: string; url: string;
icon?: string;
}
/** stats_band 单个统计项(D-1:数字计数带) */
export interface StatItem {
label: string;
value: string;
unit?: string;
icon?: string;
}
/** featured_topics 单张专题卡(D-3) */
export interface TopicItem {
title: string;
description?: string;
image: string;
ctaText?: string;
ctaUrl?: string;
}
/** badges 单个徽章(D-9) */
export interface BadgeItem {
label: string;
desc?: string;
image: string;
} }
/** article_list 展示形态(与后端渲染端 style 分支及主题 fragment 对应) */ /** article_list 展示形态(与后端渲染端 style 分支及主题 fragment 对应) */
@@ -60,6 +88,20 @@ export interface LayoutBlock {
url?: string; url?: string;
/** notice_bar 提示级别:info=一般(缺省)| warn=醒目 */ /** notice_bar 提示级别:info=一般(缺省)| warn=醒目 */
level?: string; level?: string;
// ===== docs/20 D 组件库新增字段 =====
/** gallery 布局(D-8):grid 规整网格(缺省)| masonry 瀑布流 */
layout?: string;
/** quick_links 统一图标形状随主题参数,不落区块 */
/** stats_band 统计项(D-1,1~8 项) */
stats?: StatItem[];
/** featured_topics 专题卡(D-3,编辑器模型;序列化键为 items,与后端契约对齐) */
topicItems?: TopicItem[];
/** video_tour(D-5) */
cover?: string;
/** people 数据栏目(D-7,可空 = 全部人物) */
categoryId?: number;
/** badges 徽章(D-9,1~8 项) */
badgeItems?: BadgeItem[];
} }
export interface HomeLayout { export interface HomeLayout {
@@ -69,9 +111,10 @@ export interface HomeLayout {
export const HOME_LAYOUT_VERSION = 1; export const HOME_LAYOUT_VERSION = 1;
/** 服务端同款约束(SiteService §4.6 + v2 手册 §2.8) */ /** 服务端同款约束(SiteService §4.6 + v2 手册 §2.8 + docs/20 D) */
export const BLOCK_TYPES_ALLOWED: BlockType[] = [ export const BLOCK_TYPES_ALLOWED: BlockType[] = [
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar", "article_list", "ticker", "gallery", "links", "quick_links", "notice_bar",
"stats_band", "featured_topics", "video_tour", "events", "people", "badges",
]; ];
export const SIZE_MIN = 1; export const SIZE_MIN = 1;
export const SIZE_MAX = 20; export const SIZE_MAX = 20;
@@ -81,6 +124,14 @@ export const QUICK_LINK_MAX = 12;
export const LINK_NAME_MAX = 20; export const LINK_NAME_MAX = 20;
export const NOTICE_TEXT_MAX = 200; export const NOTICE_TEXT_MAX = 200;
export const NOTICE_LEVELS = ["info", "warn"]; export const NOTICE_LEVELS = ["info", "warn"];
// ===== docs/20 D 组件库约束(与后端 SiteService 同契约) =====
export const STATS_MAX = 8;
export const TOPICS_MAX = 6;
export const BADGES_MAX = 8;
export const GALLERY_LAYOUTS = ["grid", "masonry"];
/** 内置图标标识符(D-2:与门户骨架 icon fragment 同 key 集) */
export const ICON_KEY_PATTERN = /^[a-z0-9-]{1,30}$/;
export const ASSET_URL_PATTERN = /^(\/.+|https?:\/\/\S+)$/;
/** 入口/跳转地址:http(s):// 绝对地址或站内 / 路径(与后端 HOME_LAYOUT_LINK_PATTERN 同语义) */ /** 入口/跳转地址:http(s):// 绝对地址或站内 / 路径(与后端 HOME_LAYOUT_LINK_PATTERN 同语义) */
const LINK_URL_PATTERN = /^(https?:\/\/\S+|\/.+)$/; const LINK_URL_PATTERN = /^(https?:\/\/\S+|\/.+)$/;
@@ -146,7 +197,62 @@ function toLinkItems(v: unknown): QuickLinkItem[] | undefined {
if (name === null || url === null) { if (name === null || url === null) {
continue; continue;
} }
items.push({ name, url }); const item: QuickLinkItem = { name, url };
if (typeof obj?.["icon"] === "string" && ICON_KEY_PATTERN.test(obj["icon"] as string)) {
item.icon = obj["icon"] as string;
}
items.push(item);
}
return items.length ? items : undefined;
}
/** stats_band 统计项容错还原(D-1) */
function toStatItems(v: unknown): StatItem[] | undefined {
if (!Array.isArray(v)) return undefined;
const items: StatItem[] = [];
for (const raw of v as unknown[]) {
const obj = asObject(raw);
const label = typeof obj?.["label"] === "string" ? (obj["label"] as string).trim() : "";
const value = typeof obj?.["value"] === "string" ? (obj["value"] as string).trim() : "";
if (!label || !value) continue;
const item: StatItem = { label, value };
if (typeof obj?.["unit"] === "string" && (obj["unit"] as string).trim()) item.unit = (obj["unit"] as string).trim();
if (typeof obj?.["icon"] === "string" && ICON_KEY_PATTERN.test(obj["icon"] as string)) item.icon = obj["icon"] as string;
items.push(item);
}
return items.length ? items : undefined;
}
/** featured_topics 专题卡容错还原(D-3) */
function toTopicItems(v: unknown): TopicItem[] | undefined {
if (!Array.isArray(v)) return undefined;
const items: TopicItem[] = [];
for (const raw of v as unknown[]) {
const obj = asObject(raw);
const title = typeof obj?.["title"] === "string" ? (obj["title"] as string).trim() : "";
const image = typeof obj?.["image"] === "string" ? (obj["image"] as string).trim() : "";
if (!title || !image) continue;
const item: TopicItem = { title, image };
if (typeof obj?.["description"] === "string" && (obj["description"] as string).trim()) item.description = (obj["description"] as string).trim();
if (typeof obj?.["ctaText"] === "string" && (obj["ctaText"] as string).trim()) item.ctaText = (obj["ctaText"] as string).trim();
if (typeof obj?.["ctaUrl"] === "string" && (obj["ctaUrl"] as string).trim()) item.ctaUrl = (obj["ctaUrl"] as string).trim();
items.push(item);
}
return items.length ? items : undefined;
}
/** badges 徽章容错还原(D-9,序列化键 badgeItems 防与 featured_topics.items 语义混淆) */
function toBadgeItems(v: unknown): BadgeItem[] | undefined {
if (!Array.isArray(v)) return undefined;
const items: BadgeItem[] = [];
for (const raw of v as unknown[]) {
const obj = asObject(raw);
const label = typeof obj?.["label"] === "string" ? (obj["label"] as string).trim() : "";
const image = typeof obj?.["image"] === "string" ? (obj["image"] as string).trim() : "";
if (!label || !image) continue;
const item: BadgeItem = { label, image };
if (typeof obj?.["desc"] === "string" && (obj["desc"] as string).trim()) item.desc = (obj["desc"] as string).trim();
items.push(item);
} }
return items.length ? items : undefined; return items.length ? items : undefined;
} }
@@ -174,6 +280,24 @@ function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBl
if (typeof obj["url"] === "string") block.url = obj["url"]; if (typeof obj["url"] === "string") block.url = obj["url"];
if (typeof obj["level"] === "string") block.level = obj["level"]; if (typeof obj["level"] === "string") block.level = obj["level"];
} }
// ===== docs/20 D =====
if (typeof obj["layout"] === "string") block.layout = obj["layout"];
if (typeof obj["cover"] === "string") block.cover = obj["cover"];
if (typeof obj["categoryId"] === "number" && Number.isFinite(obj["categoryId"]) && (obj["categoryId"] as number) > 0) {
block.categoryId = obj["categoryId"] as number;
}
if (type === "stats_band") {
const stats = toStatItems(obj["stats"]);
if (stats) block.stats = stats;
}
if (type === "featured_topics") {
const items = toTopicItems(obj["items"]);
if (items) block.topicItems = items;
}
if (type === "badges") {
const items = toBadgeItems(obj["badgeItems"]);
if (items) block.badgeItems = items;
}
const ids = toIdList(obj["categoryIds"]); const ids = toIdList(obj["categoryIds"]);
if (ids) block.categoryIds = ids; if (ids) block.categoryIds = ids;
return block; return block;
@@ -217,16 +341,25 @@ export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
if (subtitle) { if (subtitle) {
out["subtitle"] = subtitle; out["subtitle"] = subtitle;
} }
if (block.layout === "masonry") {
out["layout"] = "masonry";
}
} else if (block.type === "links") { } else if (block.type === "links") {
const group = (block.group || "").trim(); const group = (block.group || "").trim();
if (group) { if (group) {
out["group"] = group; out["group"] = group;
} }
} else if (block.type === "quick_links") { } else if (block.type === "quick_links") {
// 编辑期允许存在未填写的空行,提交前整行剔除(后端要求 name/url 齐备) // 编辑期允许存在未填写的空行,提交前整行剔除(后端要求 name/url 齐备);icon 可选随行
const items = (block.items || []) const items = (block.items || [])
.map(i => ({ name: (i.name || "").trim(), url: (i.url || "").trim() })) .map(i => {
.filter(i => i.name || i.url); const row: Record<string, string> = { name: (i.name || "").trim(), url: (i.url || "").trim() };
if (i.icon && ICON_KEY_PATTERN.test(i.icon)) {
row["icon"] = i.icon;
}
return row;
})
.filter(i => i["name"] || i["url"]);
if (items.length) { if (items.length) {
out["items"] = items; out["items"] = items;
} }
@@ -242,6 +375,30 @@ export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
if (block.level === "warn") { if (block.level === "warn") {
out["level"] = "warn"; out["level"] = "warn";
} }
} else if (block.type === "stats_band") {
const stats = (block.stats || []).filter(st => st.label.trim() && st.value.trim());
if (stats.length) {
out["stats"] = stats;
}
} else if (block.type === "featured_topics") {
const items = (block.topicItems || []).filter(t => t.title.trim() && t.image.trim());
if (items.length) {
out["items"] = items;
}
} else if (block.type === "video_tour") {
const cover = (block.cover || "").trim();
const url = (block.url || "").trim();
if (cover) out["cover"] = cover;
if (url) out["url"] = url;
} else if (block.type === "people") {
if (block.categoryId) {
out["categoryId"] = block.categoryId;
}
} else if (block.type === "badges") {
const items = (block.badgeItems || []).filter(b => b.label.trim() && b.image.trim());
if (items.length) {
out["badgeItems"] = items;
}
} }
return out; return out;
} }
@@ -334,6 +491,109 @@ export function validateHomeLayout(layout: HomeLayout | null): string[] {
errors.push(`${no}:提示级别只能是 info 或 warn`); errors.push(`${no}:提示级别只能是 info 或 warn`);
} }
} }
// ===== docs/20 D 组件校验(与后端 SiteService 同语义) =====
if (b.type === "gallery" && b.layout != null && !GALLERY_LAYOUTS.includes(b.layout)) {
errors.push(`${no}:画廊布局只能是 grid 或 masonry`);
}
if (b.type === "stats_band") {
const stats = b.stats || [];
if (!stats.length) {
errors.push(`${no}:请至少填写 1 个统计项`);
} else if (stats.length > STATS_MAX) {
errors.push(`${no}:统计项不能超过 ${STATS_MAX} 个`);
}
stats.forEach(st => {
const label = (st.label || "").trim();
const value = (st.value || "").trim();
if (!label) {
errors.push(`${no}:统计项名称不能为空`);
} else if (label.length > 20) {
errors.push(`${no}:统计项名称不能超过 20 字`);
}
if (!value) {
errors.push(`${no}:统计项数值不能为空`);
} else if (value.length > 10) {
errors.push(`${no}:统计项数值不能超过 10 字`);
}
if ((st.unit || "").length > 10) {
errors.push(`${no}:统计项单位不能超过 10 字`);
}
if (st.icon && !ICON_KEY_PATTERN.test(st.icon)) {
errors.push(`${no}:统计项图标标识不合法`);
}
});
}
if (b.type === "featured_topics") {
const items = b.topicItems || [];
if (!items.length) {
errors.push(`${no}:请至少填写 1 张专题卡`);
} else if (items.length > TOPICS_MAX) {
errors.push(`${no}:专题卡不能超过 ${TOPICS_MAX} 张`);
}
items.forEach(t => {
const title = (t.title || "").trim();
const image = (t.image || "").trim();
if (!title) {
errors.push(`${no}:专题卡标题不能为空`);
} else if (title.length > 50) {
errors.push(`${no}:专题卡标题不能超过 50 字`);
}
if ((t.description || "").length > 200) {
errors.push(`${no}:专题卡描述不能超过 200 字`);
}
if (!image) {
errors.push(`${no}:专题卡必须上传图片`);
} else if (!ASSET_URL_PATTERN.test(image)) {
errors.push(`${no}:专题卡图片须为上传路径(/ 开头)或 http(s) 地址`);
}
if ((t.ctaText || "").length > 20) {
errors.push(`${no}:按钮文字不能超过 20 字`);
}
const ctaUrl = (t.ctaUrl || "").trim();
if (ctaUrl && !LINK_URL_PATTERN.test(ctaUrl)) {
errors.push(`${no}:按钮地址须为 http(s):// 开头或站内路径(/ 开头)`);
}
});
}
if (b.type === "video_tour") {
const cover = (b.cover || "").trim();
const url = (b.url || "").trim();
if (!cover) {
errors.push(`${no}:请上传视频封面图`);
} else if (!ASSET_URL_PATTERN.test(cover)) {
errors.push(`${no}:封面图须为上传路径(/ 开头)或 http(s) 地址`);
}
if (!url) {
errors.push(`${no}:请填写视频/VR 页面地址`);
} else if (!LINK_URL_PATTERN.test(url)) {
errors.push(`${no}:视频地址须为 http(s):// 开头或站内路径(/ 开头)`);
}
}
if (b.type === "badges") {
const items = b.badgeItems || [];
if (!items.length) {
errors.push(`${no}:请至少填写 1 个徽章`);
} else if (items.length > BADGES_MAX) {
errors.push(`${no}:徽章不能超过 ${BADGES_MAX} 个`);
}
items.forEach(bd => {
const label = (bd.label || "").trim();
const image = (bd.image || "").trim();
if (!label) {
errors.push(`${no}:徽章名称不能为空`);
} else if (label.length > 20) {
errors.push(`${no}:徽章名称不能超过 20 字`);
}
if ((bd.desc || "").length > 50) {
errors.push(`${no}:徽章说明不能超过 50 字`);
}
if (!image) {
errors.push(`${no}:徽章必须上传图片`);
} else if (!ASSET_URL_PATTERN.test(image)) {
errors.push(`${no}:徽章图片须为上传路径(/ 开头)或 http(s) 地址`);
}
});
}
}); });
return errors; return errors;
} }
+41
View File
@@ -0,0 +1,41 @@
/**
* D-2(docs/20)内置通用 SVG 图标集(管理端选择器用)。
* 与门户骨架 themes/common/fragments/home-blocks.html :: icon(name) 同 key 同 path——
* 两端各持一份(Thymeleaf fragment / Vue 组件无法共享渲染层),改图标须两侧同步。
* 24x24 线条风格:stroke=currentColor,fill=none。
*/
export interface IconDef {
key: string;
label: string;
/** path d 列表(含 circle 用 path 近似或直接多 path) */
paths: string[];
}
export const ICON_DEFS: IconDef[] = [
{ key: "zhaosheng", label: "招生", paths: ["M3 9.5 12 4.5 21 9.5 12 14.5 3 9.5z", "M7 12v4.5c0 1 2.2 2 5 2s5-1 5-2V12", "M21 9.5V15"] },
{ key: "jiuye", label: "就业", paths: ["M3.5 8h17a1.5 1.5 0 0 1 1.5 1.5v8.5a2 2 0 0 1-2 2H4a.5.5 0 0 1-.5-.5z", "M9 8V6.2C9 5.5 9.6 5 10.3 5h3.4c.7 0 1.3.5 1.3 1.2V8", "M3.5 12.5h17"] },
{ key: "tushuguan", label: "图书馆", paths: ["M4 19.5 4.9 6.5 11 5v15", "M11 20h9V8l-7.9-1.5", "M4 19.5h7", "M14.5 11.5h3M14.5 15h3"] },
{ key: "banshi", label: "办事大厅", paths: ["M4 5.5h16v10H9.5L5 19.2V15.5H4z"] },
{ key: "xinxi", label: "信息公开", paths: ["M2.5 12C5 7.5 8.3 5.5 12 5.5S19 7.5 21.5 12C19 16.5 15.7 18.5 12 18.5S5 16.5 2.5 12z", "M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"] },
{ key: "xiaoyou", label: "校友", paths: ["M6 8.5a3 3 0 1 0 6 0 3 3 0 0 0-6 0z", "M3.5 19c0-3 2.5-5 5.5-5s5.5 2 5.5 5", "M14.8 9.5a2.2 2.2 0 1 0 4.4 0 2.2 2.2 0 0 0-4.4 0z", "M16 14.2c2.6.3 4.5 2.1 4.5 4.8"] },
{ key: "youjian", label: "邮箱", paths: ["M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z", "m3.5 7 8.5 6 8.5-6"] },
{ key: "dianhua", label: "电话", paths: ["M5 4h3.5l1.7 4.3-2.3 1.8a12.5 12.5 0 0 0 5.9 5.9l1.8-2.3L20 15.4V19a1.5 1.5 0 0 1-1.6 1.5C10 20 4 14 3.5 5.6A1.5 1.5 0 0 1 5 4z"] },
{ key: "ditu", label: "地图/定位", paths: ["M12 21.5c4.2-5 6.8-8 6.8-11.5a6.8 6.8 0 1 0-13.6 0c0 3.5 2.6 6.5 6.8 11.5z", "M9.6 9.7a2.4 2.4 0 1 0 4.8 0 2.4 2.4 0 0 0-4.8 0z"] },
{ key: "xinwen", label: "新闻", paths: ["M4.5 5h15a1.5 1.5 0 0 1 1.5 1.5v11A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-11A1.5 1.5 0 0 1 4.5 5z", "M6.5 9h6M6.5 12.5h6M6.5 16h6", "M15.5 9h3v7.5h-3z"] },
{ key: "rili", label: "日历", paths: ["M5.5 5h13a2 2 0 0 1 2 2v11.5a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z", "M3.5 10h17", "M8 3v4M16 3v4"] },
{ key: "shipin", label: "视频", paths: ["M3.5 12a8.5 8.5 0 1 0 17 0 8.5 8.5 0 0 0-17 0z", "M10 8.5 16 12l-6 3.5z"] },
{ key: "weixin", label: "微信", paths: ["M9.5 5.5C6 5.5 3 7.8 3 10.7c0 1.6.9 3 2.3 4L5 17l2.6-1.3c.6.2 1.3.3 1.9.3", "M21.5 14.8c0-2.4-2.3-4.3-5.1-4.3s-5.1 1.9-5.1 4.3 2.3 4.3 5.1 4.3c.6 0 1.2-.1 1.7-.3L20.5 20l-.5-1.7c.9-.8 1.5-2.1 1.5-3.5z"] },
{ key: "weibo", label: "微博", paths: ["M10.5 19.5c-3.5 0-6.5-2-6.5-4.7 0-2.9 3.3-5.6 6.8-5.6 3 0 6.5 1.6 6.5 4.2 0 2.3-1.9 4.3-4.4 4.3-1.8 0-3.6-.8-3.6-2 0-.8.7-1.5 1.7-1.5.9 0 1.5.5 1.5 1.1 0 .4-.3.7-.7.8", "M15.5 4.5c2.9 0 5 1.8 5.2 4.3", "M15.8 7.2c1.5 0 2.7.9 2.8 2.3"] },
{ key: "douyin", label: "抖音", paths: ["M5 17.5a3 3 0 1 0 6 0 3 3 0 0 0-6 0z", "M11 17.5V4.5l7 2v3.2l-4.6-1.3"] },
{ key: "bilibili", label: "B站", paths: ["M6 6.5h12a3 3 0 0 1 3 3v6.5a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-6.5a3 3 0 0 1 3-3z", "M7 4l2.2 2.4M17 4l-2.2 2.4", "M9.2 10.5v4M14.8 10.5v4"] },
{ key: "lianjie", label: "链接", paths: ["m9.5 14.5 5-5", "m8 12-3 3a4 4 0 0 0 5.7 5.7l3-3", "m16 12 3-3a4 4 0 0 0-5.7-5.7l-3 3"] },
{ key: "dangan", label: "档案", paths: ["M6 3.5h8.5L18 7v13.5H6z", "M14 3.5V7h4", "M9 11h6M9 14.5h6M9 18h3.5"] },
{ key: "xiaoli", label: "校历/校史", paths: ["M4 21V10l8-6.2L20 10v11", "M10 21v-6h4v6", "M4 21h16", "M9.5 10.5h5"] },
{ key: "tiyu", label: "体育", paths: ["M8 4.5h8v5a4 4 0 0 1-8 0z", "M8 5.5H5v1.6A3.4 3.4 0 0 0 8.4 10M16 5.5h3v1.6A3.4 3.4 0 0 1 15.6 10", "M12 13.5v3M8.5 20h7M9.8 20c0-1.7.9-3.5 2.2-3.5s2.2 1.8 2.2 3.5"] },
{ key: "yishu", label: "艺术", paths: ["M12 3a9 9 0 0 0 0 18c1.4 0 2-.8 2-1.7 0-1.5-1.6-1.9-1.6-3.1 0-1 .8-1.7 2-1.7h1.8A4.8 4.8 0 0 0 21 9.7C21 5.9 17 3 12 3z", "M6.9 10.5a.9.9 0 1 0 1.8 0 .9.9 0 0 0-1.8 0z", "M11.1 7.6a.9.9 0 1 0 1.8 0 .9.9 0 0 0-1.8 0z", "M15.3 10a.9.9 0 1 0 1.8 0 .9.9 0 0 0-1.8 0z"] },
{ key: "shiyan", label: "实验", paths: ["M10 3.5h4M11 3.5v6l-4.6 8.2A2 2 0 0 0 8.2 20.5h7.6a2 2 0 0 0 1.8-2.8L13 9.5v-6", "M8.5 14.5h7"] },
];
export function iconLabelOf(key: string | undefined | null): string {
return ICON_DEFS.find(i => i.key === key)?.label || "";
}