diff --git a/mock/data.ts b/mock/data.ts index 5891611..7518010 100644 --- a/mock/data.ts +++ b/mock/data.ts @@ -8,6 +8,8 @@ export interface MockSite { id: number; code: string; + /** 门户路径(W4:顶层=code;子站=父path/code) */ + path: string; /** 上级站点ID(0=顶层校站点;院系子站挂主站 id) */ parentSiteId: number; schId: number; @@ -141,6 +143,7 @@ export function seedDb(): MockDb { { id: 1, code: "demo", + path: "demo", parentSiteId: 0, schId: 1001, schName: "云海大学", @@ -162,6 +165,7 @@ export function seedDb(): MockDb { { id: 2, code: "fsz", + path: "fsz", parentSiteId: 0, schId: 1002, schName: "云海大学附属中学", @@ -174,6 +178,7 @@ export function seedDb(): MockDb { { id: 3, code: "jdx", + path: "demo/jdx", parentSiteId: 1, schId: 1001, schName: "云海大学", @@ -189,6 +194,7 @@ export function seedDb(): MockDb { { id: 4, code: "xxgc", + path: "demo/xxgc", parentSiteId: 1, schId: 1001, schName: "云海大学", diff --git a/mock/index.ts b/mock/index.ts index ea598b1..3b9abba 100644 --- a/mock/index.ts +++ b/mock/index.ts @@ -251,15 +251,19 @@ function extractFile(body: Buffer): { name: string; mime: string; buffer: Buffer /* ===== 简版门户预览页 ===== */ +function siteByPath(sitePath: string): MockSite | undefined { + return db.sites.find(s => s.path === sitePath || (s.parentSiteId === 0 && s.code === sitePath)); +} + function siteByCode(code: string): MockSite | undefined { return db.sites.find(s => s.code === code); } function portalPage(req: IncomingMessage, res: ServerResponse) { const path = new URL(req.url!, "http://mock.local").pathname; - // /portal/{code} 或 /portal/{code}/...(含 /a/{id}) + // W4:/portal/{path}/ 或 /portal/{path}/...(path 支持嵌套两段;含 /a/{id}) const m = path.replace(/^\/portal\//, "").split("/"); - const site = siteByCode(m[0]); + const site = siteByPath(m[0]); const nav = ["学校概况", "新闻中心", "教学科研", "招生网", "联系我们"] .map(n => `${n}`) .join(""); @@ -274,7 +278,7 @@ function portalPage(req: IncomingMessage, res: ServerResponse) { const colleges = db.sites.filter(s => s.parentSiteId === anchorId && s.status === 1); const collegesHtml = colleges.length ? `
专业院系 ▾
` : ""; const articleMatch = path.match(/\/a\/(\d+)/); @@ -333,7 +337,7 @@ function portalPage(req: IncomingMessage, res: ServerResponse) { function sendThemePreview(req: IncomingMessage, res: ServerResponse) { const u = new URL(req.url!, "http://mock.local"); - const site = siteByCode(u.searchParams.get("siteCode") || ""); + const site = siteByPath(u.searchParams.get("sitePath") || ""); const themeName = u.searchParams.get("theme") || "default"; const theme = themeByName(themeName); const params: Record = {}; diff --git a/src/__tests__/siteWizardUrl.spec.ts b/src/__tests__/siteWizardUrl.spec.ts new file mode 100644 index 0000000..c973157 --- /dev/null +++ b/src/__tests__/siteWizardUrl.spec.ts @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +/** + * W4 向导地址预览联动(方案 §8.3 T-W4-18): + * 第 3 步「门户地址预览」随上级与编码输入联动(校级 lockParent 模式预置上级=选上级)。 + * 挂载 SiteWizard(Modal transfer 到 body),桩掉主题三件套与配置表单。 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createApp, defineComponent, h, nextTick, ref } from "vue"; +import { createPinia } from "pinia"; +import ViewUIPlus from "view-ui-plus"; + +const themesMock = vi.hoisted(() => ({ + list: vi.fn(async () => [ + { + name: "default", + title: "云端默认", + screenshotUrl: "/api/theme/default/asset/screenshot.png", + params: [], + }, + ]), +})); + +vi.mock("@/api/module/cms", () => ({ + themeApi: { list: () => ({ send: themesMock.list }) }, + siteApi: { save: () => ({ send: async () => ({}) }) }, +})); + +vi.mock("@/components/theme/ThemePicker.vue", () => ({ + default: defineComponent({ + props: ["themes", "modelValue"], + emits: ["update:modelValue", "pick"], + setup(_, { emit }) { + (globalThis as any).__wizardPickTheme = (name: string) => emit("pick", name); + return () => h("div", { class: "theme-picker-stub" }, "themes"); + }, + }), +})); + +vi.mock("@/components/theme/ThemeParamForm.vue", () => ({ + default: defineComponent({ props: ["modelValue"], emits: ["update:modelValue"], render: () => null }), +})); + +vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({ + default: defineComponent({ props: ["url", "tick"], render: () => h("div", "preview-stub") }), +})); + +vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({ + default: defineComponent({ + props: ["modelValue"], + methods: { validate: async () => true }, + render: () => null, + }), +})); + +import SiteWizard from "@/views/cms/SiteWizard.vue"; + +let cleanups: (() => void)[] = []; + +async function flush(frames = 6) { + for (let i = 0; i < frames; i++) { + await nextTick(); + } + await new Promise(r => setTimeout(r, 0)); +} + +function mountWizard(lockParentId?: number, secondMain = false) { + const sites = [ + { id: 1, code: "sch-3", path: "sch-3", parentSiteId: 0, schId: 1, schName: "第一中学", name: "第一中学门户", status: 1 }, + ...(secondMain + ? [{ id: 2, code: "sch-8", path: "sch-8", parentSiteId: 0, schId: 2, schName: "第八中学", name: "第八中学门户", status: 1 }] + : []), + ]; + const visible = ref(false); // 挂载后翻转以触发 SiteWizard 的 visible watch(真实打开路径) + const pinia = createPinia(); + const el = document.createElement("div"); + document.body.appendChild(el); + const app = createApp(defineComponent({ + setup() { + return () => h(SiteWizard, { + visible: visible.value, + sites, + lockParentId, + "onUpdate:visible": (v: boolean) => { visible.value = v; }, + }); + }, + })); + app.use(pinia); + app.use(ViewUIPlus); + // SiteWizard 第 2 步的 FormItem 不在 Form 内(现状如此),jsdom 卸载时 view-ui-plus + // 内部 removeField/colon 读取 undefined 崩溃 → 本测试用展示桩覆盖 FormItem + app.component("FormItem", defineComponent({ + props: ["label", "prop", "labelWidth"], + setup(props, { slots }) { + return () => h("div", { class: "form-item-stub" }, [ + props.label ? h("label", props.label) : null, + slots.default?.(), + ]); + }, + })); + app.mount(el); + visible.value = true; + cleanups.push(() => { + app.unmount(); + el.remove(); + document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove()); + }); + return el; +} + +async function gotoStep3() { + // step1 → step2 → step3(footer 按钮 transfer 到 body) + const clickNext = async () => { + await flush(); + const btn = [...document.querySelectorAll(".ivu-modal-footer button")] + .find(b => b.textContent?.includes("下一步")); + btn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + }; + await clickNext(); + await clickNext(); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); +afterEach(() => { + cleanups.forEach(fn => fn()); + cleanups = []; +}); + +describe("SiteWizard 门户地址预览(T-W4-18)", () => { + it("选上级(lockParent 预置 sch-3)+ 输入 jdxy → 预览 /portal/sch-3/jdxy/", async () => { + mountWizard(1); + await gotoStep3(); + + const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement; + expect(input).toBeTruthy(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(input, "jdxy"); + input.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + + expect(document.body.textContent).toContain("门户地址预览"); + expect(document.body.textContent).toContain("/portal/sch-3/jdxy/"); + }); + + it("改上级即变:换到另一主站 sch-8 → 预览 /portal/sch-8/jdxy/", async () => { + mountWizard(2, true); + await gotoStep3(); + + const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(input, "jdxy"); + input.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + + expect(document.body.textContent).toContain("/portal/sch-8/jdxy/"); + expect(document.body.textContent).not.toContain("/portal/sch-3/jdxy/"); + }); +}); diff --git a/src/api/module/cms.spec.ts b/src/api/module/cms.spec.ts index a06e243..bcd3432 100644 --- a/src/api/module/cms.spec.ts +++ b/src/api/module/cms.spec.ts @@ -21,7 +21,7 @@ vi.mock("@/utils/alova/alova-iview", () => ({ }, })); -import { articleApi, assetUrl } from "./cms"; +import { articleApi, assetUrl, themePreviewUrl } from "./cms"; import { alovaInstance } from "@/utils/alova/alova-iview"; const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" }; @@ -99,3 +99,13 @@ describe("assetUrl 站内资产路径补 serviceHost 前缀(线上主题截图 ); }); }); + +describe("themePreviewUrl 参数语义(T-W4-17:W4 起 sitePath)", () => { + it("query 携带 sitePath(嵌套路径 URL 编码)与 p_ 前缀参数", () => { + const url = themePreviewUrl("sch-3/jdxy", "t-demo-red", { primary: "#b01a2e" }); + expect(url).toContain("sitePath=sch-3%2Fjdxy"); + expect(url).toContain("theme=t-demo-red"); + expect(url).toContain("p_primary=%23b01a2e"); + expect(url).not.toContain("siteCode="); + }); +}); diff --git a/src/api/module/cms.ts b/src/api/module/cms.ts index 8f3a8e0..20bae2e 100644 --- a/src/api/module/cms.ts +++ b/src/api/module/cms.ts @@ -214,9 +214,10 @@ class ThemeApi { } } -/** 主题预览 iframe URL(方案 §5.5:/api/theme/preview,CAS 会话同源,参数 p_ 前缀) */ -export function themePreviewUrl(siteCode: string, theme: string, themeParams: Record = {}): string { - const params = new URLSearchParams({ siteCode, theme, page: "home" }); +/** 主题预览 iframe URL(方案 §5.5:/api/theme/preview,CAS 会话同源,参数 p_ 前缀)。 + * W4:内容源站点参数 sitePath(嵌套路径语义,后端 siteSnapshotByPath 解析)。 */ +export function themePreviewUrl(sitePath: string, theme: string, themeParams: Record = {}): string { + const params = new URLSearchParams({ sitePath, theme, page: "home" }); Object.keys(themeParams).forEach(k => params.set(`p_${k}`, themeParams[k])); return `${conf.isMock ? "" : conf.serviceHost}/api/theme/preview?${params.toString()}`; } diff --git a/src/components/theme/ThemePreviewFrame.vue b/src/components/theme/ThemePreviewFrame.vue index f0f58fa..ca4a84c 100644 --- a/src/components/theme/ThemePreviewFrame.vue +++ b/src/components/theme/ThemePreviewFrame.vue @@ -8,21 +8,21 @@ import { FormModelValue } from "@/utils/themeParamForm"; * no-store),参数变更由父级 debounce 后更新 src。内容源站点不存在时提示。 */ const props = defineProps<{ - /** 预览内容源站点编码(取该站栏目/文章/轮播渲染) */ - siteCode?: string; + /** 预览内容源站点门户路径(W4:sitePath 语义,取该站栏目/文章/轮播渲染) */ + sitePath?: string; theme?: string; themeParams?: FormModelValue; }>(); const src = computed(() => { - if (!props.siteCode || !props.theme) return ""; - return themePreviewUrl(props.siteCode, props.theme, props.themeParams || {}); + if (!props.sitePath || !props.theme) return ""; + return themePreviewUrl(props.sitePath, props.theme, props.themeParams || {}); });