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 ? `
请先创建/启用一个站点(或初始化演示数据)后再预览
diff --git a/src/store/index.ts b/src/store/index.ts index 54fccca..21ba833 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -85,7 +85,7 @@ export const useCmsStore = defineStore("cmsStore", { return this.currentSite; }, /** 进入学校上下文:校卡 + 默认站点(记住的站点仍属本校则沿用,否则主站) */ - enterSchool(school: CurrentSchool, sites: { id: number; code: string; name: string; parentSiteId?: number }[]) { + enterSchool(school: CurrentSchool, sites: { id: number; code: string; path?: string; name: string; parentSiteId?: number }[]) { this.setCurrentSchool(school); const remembered = this.currentSite; const belongs = remembered && sites.some(s => s.id === remembered.id); @@ -94,7 +94,7 @@ export const useCmsStore = defineStore("cmsStore", { } const main = sites.find(s => s.id === school.mainSiteId) || sites[0]; if (main) { - this.setCurrentSite({ id: main.id, code: main.code, name: main.name }); + this.setCurrentSite({ id: main.id, code: main.code, path: main.path || main.code, name: main.name }); } else { // 该校无可管理站点:清掉他校残留,避免页面串数据 this.setCurrentSite(null); diff --git a/src/types/cms.ts b/src/types/cms.ts index c7ce9aa..ec84a1c 100644 --- a/src/types/cms.ts +++ b/src/types/cms.ts @@ -26,6 +26,8 @@ export interface AuthUserInfo { export interface SiteVo { id: number; code: string; + /** 门户路径(W4:顶层=code;子站=父path/code,/portal/{path}/ 唯一标识) */ + path?: string; /** 上级站点ID(0=顶层校站点;院系子站挂主站 id) */ parentSiteId?: number; /** 上级站点名称(列表展示用,服务端装配) */ @@ -44,6 +46,8 @@ export interface SiteVo { export interface CurrentSite { id: number; code: string; + /** 门户路径(W4:嵌套 path) */ + path?: string; name: string; } @@ -53,6 +57,8 @@ export interface CurrentSchool { schName?: string; mainSiteId: number; mainSiteCode: string; + /** 主站门户路径(W4:/portal/{mainSitePath}/) */ + mainSitePath?: string; mainSiteName: string; } @@ -62,6 +68,8 @@ export interface SchoolCard { schName?: string; mainSiteId: number; mainSiteCode: string; + /** 主站门户路径(W4:/portal/{mainSitePath}/) */ + mainSitePath?: string; mainSiteName: string; theme?: string; siteCount: number; diff --git a/src/utils/sitePath.spec.ts b/src/utils/sitePath.spec.ts new file mode 100644 index 0000000..db87dab --- /dev/null +++ b/src/utils/sitePath.spec.ts @@ -0,0 +1,29 @@ +/** + * W4 嵌套路径:站点门户 URL 拼接纯函数(方案 §8.3 T-W4-16)。 + * portalUrlOf:站点对象 → 门户地址(path 优先,尾斜杠;path 缺失回退 code 兼容历史数据); + * previewPortalUrl:上级 path + 编码 → 子站门户地址(parentPath 空 → 单段)。 + */ +import { describe, expect, it } from "vitest"; +import { portalUrlOf, previewPortalUrl } from "@/utils/sitePath"; + +describe("portalUrlOf(T-W4-16)", () => { + it("path 优先:嵌套 path 拼单段门户地址 + 尾斜杠", () => { + expect(portalUrlOf({ path: "sch-3/jdxy", code: "jdxy" })).toBe("/portal/sch-3/jdxy/"); + }); + + it("path 缺失回退 code(历史数据兼容)", () => { + expect(portalUrlOf({ code: "demo" })).toBe("/portal/demo/"); + expect(portalUrlOf({ path: "", code: "demo" })).toBe("/portal/demo/"); + }); +}); + +describe("previewPortalUrl(T-W4-16)", () => { + it("上级 path 非空 → 两段嵌套 + 尾斜杠", () => { + expect(previewPortalUrl("sch-3", "jdxy")).toBe("/portal/sch-3/jdxy/"); + }); + + it("上级 path 空 → 单段(顶层站)", () => { + expect(previewPortalUrl("", "sch-9")).toBe("/portal/sch-9/"); + expect(previewPortalUrl(undefined, "sch-9")).toBe("/portal/sch-9/"); + }); +}); diff --git a/src/utils/sitePath.ts b/src/utils/sitePath.ts new file mode 100644 index 0000000..72740b9 --- /dev/null +++ b/src/utils/sitePath.ts @@ -0,0 +1,17 @@ +/** + * W4 嵌套路径:站点门户 URL 拼接纯函数(前后端各一份同构语义,后端 SiteService.buildPath)。 + * 规范:门户地址 = /portal/{path}/(尾斜杠);path 缺失回退 code(历史数据兼容); + * 子站预览地址 = /portal/{parentPath}/{code}/(parentPath 空 → 单段顶层)。 + */ + +/** 站点对象 → 门户地址(尾斜杠;path 优先,缺失回退 code) */ +export function portalUrlOf(site: { path?: string; code: string }): string { + const seg = site.path && site.path.trim() ? site.path.trim() : site.code; + return `/portal/${seg}/`; +} + +/** 上级 path + 编码 → 子站门户地址预览(parentPath 空 → 单段顶层站) */ +export function previewPortalUrl(parentPath: string | undefined | null, code: string): string { + const parent = parentPath && parentPath.trim() ? parentPath.trim() : ""; + return parent ? `/portal/${parent}/${code}/` : `/portal/${code}/`; +} diff --git a/src/views/cms/ArticleEdit.vue b/src/views/cms/ArticleEdit.vue index 2beccd7..42b670c 100644 --- a/src/views/cms/ArticleEdit.vue +++ b/src/views/cms/ArticleEdit.vue @@ -4,9 +4,10 @@ import { useRoute, useRouter } from "vue-router"; import { Message } from "view-ui-plus"; import { Editor, Toolbar } from "@wangeditor/editor-for-vue"; import "@wangeditor/editor/dist/css/style.css"; -import { articleApi, categoryApi, portalBase, uploadUrl } from "@/api/module/cms"; +import { articleApi, categoryApi, uploadUrl } from "@/api/module/cms"; import { useCmsStore } from "@/store"; import config from "@/assets/config/host.config"; +import { portalUrlOf } from "@/utils/sitePath"; import { CategoryTreeNode } from "@/types/cms"; const route = useRoute(); @@ -205,7 +206,7 @@ async function saveAndPublish() { /** 预览:仅已发布文章在门户可见 */ function preview() { if (!editId.value || !store.currentSite) return; - window.open(`${portalBase(store.currentSite.code)}/a/${editId.value}`, "_blank"); + window.open(`${config.serviceHost}${portalUrlOf(store.currentSite)}a/${editId.value}`, "_blank"); } onMounted(load); diff --git a/src/views/cms/CategoryManage.vue b/src/views/cms/CategoryManage.vue index 7972001..febf26d 100644 --- a/src/views/cms/CategoryManage.vue +++ b/src/views/cms/CategoryManage.vue @@ -11,6 +11,7 @@ import { useCmsStore } from "@/store"; import config from "@/assets/config/host.config"; import { CategoryTreeNode } from "@/types/cms"; import { rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree"; +import { portalUrlOf } from "@/utils/sitePath"; /* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除) */ const store = useCmsStore(); @@ -216,14 +217,13 @@ function goNewArticle(categoryId: number) { window.location.hash = `#/sch/${store.schoolCode}/article/edit?categoryId=${categoryId}`; } -/** 门户预览:新开 /portal/{siteCode}/c/{id} */ +/** 门户预览:新开 /portal/{path}/c/{id}(W4 嵌套路径) */ function openPreview(node: any) { - const code = store.currentSite?.code || store.schoolCode; - if (!code) { + if (!store.currentSite) { Message.warning("当前无站点上下文,无法预览"); return; } - window.open(`${config.serviceHost}/portal/${code}/c/${node.id}`, "_blank"); + window.open(`${config.serviceHost}${portalUrlOf(store.currentSite)}c/${node.id}`, "_blank"); } /** 外链栏目直达 */ diff --git a/src/views/cms/PortalPreview.vue b/src/views/cms/PortalPreview.vue index e138ebc..bfd8036 100644 --- a/src/views/cms/PortalPreview.vue +++ b/src/views/cms/PortalPreview.vue @@ -4,6 +4,7 @@ import { categoryApi, articleApi, bannerApi, linkApi, siteApi } from "@/api/modu import { useCmsStore } from "@/store"; import { CategoryTreeNode, ArticlePageItem, BannerVo, LinkVo, SiteVo } from "@/types/cms"; import config from "@/assets/config/host.config"; +import { portalUrlOf } from "@/utils/sitePath"; /** * 门户预览(SPA 内简版门户):mock 模式下「预览门户」跳转到这里, @@ -36,9 +37,9 @@ const colleges = computed(() => { return sites.value.filter(s => s.parentSiteId === anchor && s.status === 1); }); -/** 切换到该院系的真实门户(mock 模式由 mock 中间件承接,真实模式为 SSR 门户) */ -function openCollege(code: string) { - window.open(`${config.serviceHost}/portal/${code}/`, "_blank"); +/** 切换到该院系的真实门户(mock 模式由 mock 中间件承接,真实模式为 SSR 门户;W4 嵌套 path) */ +function openCollege(site: SiteVo) { + window.open(`${config.serviceHost}${portalUrlOf(site)}`, "_blank"); } const linkGroups = computed(() => { const map = new Map