feat(site-layer): 学校站点分层方案C落地(docs/13)+栏目管理增强+管理端三修复+首页视觉升级管理端+审计复检T1~T5(docs/15)整合批次-URL站点段/site/{siteCode}无component镜像层(去name防重名,router-view跳过推进渲染)+身份条SiteSwitcher自绘(徽章#577afb/#ff9900+分组下拉✓+path次级列+Esc/外点收起)+URL唯一事实源syncSiteFromRoute(非法/跨校/主站码当子站段告警打回同页主站,localStorage降级刷新首帧兜底)+菜单剥段高亮stripSiteSeg/withSiteSeg补段+menuRoute镜像层防重影+跳转收敛adminPathOf(工作台进入/全部站点总览/站点管理行进入管理纯URL切站);栏目管理-上级栏目可选可改(禁选自身及后代防成环)+批量添加BatchAddCategory(行序写排序号单行失败不中断)+单页wangEditor富文本预览字数分区+categoryIds契约前端/types/mock三处统一+SchoolLayout高度链.layout-wrap修复+ArticleListPanel fixed模式;管理端三修复-皮肤预览Modal全屏(ThemePreviewFrame可选fill prop)+SiteManage补themeUnregistered声明+长图文编辑器随内容增高;管理端视觉升级配套-SiteConfigForm 12新键+BannerManage gallery展示位切换+栏目homeStyle下拉+PortalPreview侧栏同步;审计复检-新增siteRouter.spec真实路由集成7用例(镜像层matched3层/守卫子站码定向主站前缀/越权回工作台/旧链带id重定向)+sitePath空白容错+siteLayer持久化round-trip与身份条次级入口(docs/15);vitest 99/99+build通过+S1~S10 GUI走查全过
build / build (push) Successful in 2m43s
build / build (push) Successful in 2m43s
This commit is contained in:
+4
-3
@@ -626,14 +626,15 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string
|
||||
/* ---- 文章 ---- */
|
||||
if (method === "GET" && path === "/api/article/page") {
|
||||
const siteId = num(q(req, "siteId"), 0)!;
|
||||
const categoryId = num(q(req, "categoryId"));
|
||||
// 与后端 ArticlePageQuery 契约一致:categoryIds(逗号分隔多选;栏目工作台传单值)
|
||||
const categoryId = num(q(req, "categoryIds"));
|
||||
const status = num(q(req, "status"));
|
||||
const keyword = q(req, "keyword") || "";
|
||||
const page = num(q(req, "page"), 1)!;
|
||||
const pageSize = num(q(req, "pageSize"), 10)!;
|
||||
const categoryIds = categoryId ? categoryWithChildren(siteId, categoryId) : null;
|
||||
const filterCategoryIds = categoryId ? categoryWithChildren(siteId, categoryId) : null;
|
||||
let rows = db.articles.filter(a => a.siteId === siteId);
|
||||
if (categoryIds) rows = rows.filter(a => categoryIds.includes(a.categoryId));
|
||||
if (filterCategoryIds) rows = rows.filter(a => filterCategoryIds.includes(a.categoryId));
|
||||
if (status !== undefined) rows = rows.filter(a => a.status === status);
|
||||
if (keyword) rows = rows.filter(a => a.title.includes(keyword) || a.summary.includes(keyword));
|
||||
rows = [...rows].sort(
|
||||
|
||||
@@ -14,11 +14,13 @@ import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
const { pageMock, routerPush, openMock, saveMock } = vi.hoisted(() => ({
|
||||
const { pageMock, routerPush, openMock, saveMock, saveRejection } = vi.hoisted(() => ({
|
||||
pageMock: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
openMock: vi.fn(),
|
||||
saveMock: vi.fn(),
|
||||
/** 第 N 次(0 起)save.send() 拒绝;-1=全部成功(批量添加"失败继续"用) */
|
||||
saveRejection: { value: -1 },
|
||||
}));
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
@@ -35,8 +37,14 @@ vi.mock("@/api/module/cms", () => ({
|
||||
categoryApi: {
|
||||
tree: () => ({ send: () => Promise.resolve([]) }),
|
||||
save: (payload: any) => {
|
||||
const nth = saveMock.mock.calls.length;
|
||||
saveMock(payload);
|
||||
return { send: () => Promise.resolve({}) };
|
||||
return {
|
||||
send: () =>
|
||||
saveRejection.value === nth
|
||||
? Promise.reject(new Error("栏目名称已存在"))
|
||||
: Promise.resolve({ id: 100 + nth }),
|
||||
};
|
||||
},
|
||||
delete: () => ({ send: () => Promise.resolve({}) }),
|
||||
},
|
||||
@@ -79,6 +87,7 @@ vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
|
||||
|
||||
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
|
||||
import CategoryManage from "@/views/cms/CategoryManage.vue";
|
||||
import BatchAddCategory from "@/components/BatchAddCategory.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { useCmsStore } from "@/store";
|
||||
@@ -137,7 +146,7 @@ describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () =
|
||||
await flush();
|
||||
|
||||
expect(pageMock).toHaveBeenCalled();
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryId: 5 });
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryIds: 5 });
|
||||
// 栏目下拉隐藏(剩余 .ivu-select 为状态筛选与分页 sizer,占位文本不含"全部栏目")
|
||||
expect(el.textContent).not.toContain("全部栏目");
|
||||
expect([...el.querySelectorAll(".ivu-select-placeholder")].every(p => !p.textContent?.includes("全部栏目"))).toBe(true);
|
||||
@@ -156,11 +165,11 @@ describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () =
|
||||
await flush();
|
||||
|
||||
expect(pageMock).toHaveBeenCalledTimes(2);
|
||||
expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryId: 7 });
|
||||
expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryIds: 7 });
|
||||
});
|
||||
|
||||
it("⑤ 面板新建文章:路由跳转带 categoryId query", async () => {
|
||||
const state = reactive({ cid: 5, fixed: true });
|
||||
it("⑤ 面板新建文章:非固定模式带 categoryId query 跳转;固定模式入口收敛到工作台面板头(面板内无重复按钮)", async () => {
|
||||
const state = reactive({ cid: 5, fixed: false });
|
||||
const host = defineComponent({
|
||||
setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }),
|
||||
});
|
||||
@@ -172,7 +181,13 @@ describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () =
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article/edit?categoryId=5");
|
||||
// 全局列表模式:新建不带栏目预选(栏目由用户在下拉自选)
|
||||
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article/edit");
|
||||
|
||||
// 固定栏目模式:新建入口由 CategoryManage 面板头承担,面板内不再渲染
|
||||
state.fixed = true;
|
||||
await flush();
|
||||
expect([...el.querySelectorAll("button")].some(b => b.textContent?.includes("新建文章"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,7 +226,7 @@ describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => {
|
||||
|
||||
expect(el.querySelector(".article-list-panel")).toBeTruthy();
|
||||
expect(pageMock).toHaveBeenCalled();
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ categoryId: 5 });
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ categoryIds: 5 });
|
||||
});
|
||||
|
||||
it("未选中节点:右侧显示空态引导", async () => {
|
||||
@@ -221,6 +236,65 @@ describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("categoryWorkbench - BatchAddCategory(批量添加栏目)", () => {
|
||||
/** 打开批量添加弹窗并填入多行名称(Modal transfer 到 body,按 .ivu-modal 定位) */
|
||||
async function openBatchWith(names: string, props: any = {}) {
|
||||
const onSuccess = vi.fn();
|
||||
const host = defineComponent({
|
||||
setup: () => () =>
|
||||
h(BatchAddCategory, {
|
||||
visible: true,
|
||||
defaultPid: 5,
|
||||
parentName: "新闻中心",
|
||||
defaultType: 1,
|
||||
onSuccess,
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
const el = mountApp(host);
|
||||
await flush();
|
||||
|
||||
const textarea = document.querySelector(".ivu-modal textarea") as HTMLTextAreaElement;
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
|
||||
setter.call(textarea, names);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
const submit = [...document.querySelectorAll(".ivu-modal button")].find(b => b.textContent?.includes("确认"));
|
||||
submit!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush(8);
|
||||
return { onSuccess, el };
|
||||
}
|
||||
|
||||
it("多行名称逐条循环调 save,按行序写 sortNo=1..n,成功后回调", async () => {
|
||||
saveRejection.value = -1;
|
||||
const { onSuccess } = await openBatchWith("通知公告\n规章制度\n三务公开");
|
||||
|
||||
expect(saveMock).toHaveBeenCalledTimes(3);
|
||||
expect(saveMock.mock.calls[0][0]).toMatchObject({ parentId: 5, name: "通知公告", type: 1, sortNo: 1 });
|
||||
expect(saveMock.mock.calls[1][0]).toMatchObject({ name: "规章制度", sortNo: 2 });
|
||||
expect(saveMock.mock.calls[2][0]).toMatchObject({ name: "三务公开", sortNo: 3 });
|
||||
expect(onSuccess).toHaveBeenCalledWith(102);
|
||||
});
|
||||
|
||||
it("空行自动忽略;某行失败不影响后续行,结束统一告警", async () => {
|
||||
saveRejection.value = 1; // 第 2 条拒绝
|
||||
const warnSpy = vi.spyOn(Message, "warning");
|
||||
const { onSuccess } = await openBatchWith("通知公告\n\n规章制度\n三务公开");
|
||||
|
||||
expect(saveMock).toHaveBeenCalledTimes(3);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
saveRejection.value = -1;
|
||||
});
|
||||
|
||||
it("未输入名称时不发请求", async () => {
|
||||
const callsBefore = saveMock.mock.calls.length;
|
||||
await openBatchWith(" ");
|
||||
expect(saveMock.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("W2 栏目树修订(T-W2-06..10)", () => {
|
||||
const selectRoot = async () => {
|
||||
(globalThis as any).__treeEmit({ id: 0, name: "演示学校", virtual: true, children: [] });
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* docs/13 方案 C 站点分层(URL 站点段 + 站点身份条):
|
||||
* ① 深链子站 URL → store.currentSite 同步为子站并可感知(徽章/色带);
|
||||
* ② 非法/跨校/主站编码当子站段 → 告警并打回同页主站形态;
|
||||
* ③ URL 无站点段即主站(localStorage 记忆不再作导航决策);
|
||||
* ④ 身份条下拉一步切换:只改 URL、停留当前功能页、点后收起;
|
||||
* ⑤ 子站形态菜单高亮剥段匹配(菜单 page 保持主站形态)。
|
||||
*
|
||||
* 沿用手写 createApp 挂载 + vi.mock(vue-router) 桩风格(与 categoryWorkbench.spec.ts 一致);
|
||||
* route 用 reactive 包装以驱动 SchoolLayout 的 watch(fullPath)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import ViewUIPlus, { Message } from "view-ui-plus";
|
||||
|
||||
const box = vi.hoisted(() => ({
|
||||
route: null as any,
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
go: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRoute: () => box.route,
|
||||
useRouter: () => ({ push: box.push, replace: box.replace, go: box.go, getRoutes: () => [] }),
|
||||
}));
|
||||
|
||||
import SchoolLayout from "@/components/SchoolLayout.vue";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
const SITES = [
|
||||
{ id: 1, code: "demo", name: "云海大学门户", path: "demo", parentSiteId: 0, schId: 1001, schName: "云海大学" },
|
||||
{ id: 2, code: "jdxy", name: "机电学院", path: "demo/jdxy", parentSiteId: 1, schId: 1001, schName: "云海大学" },
|
||||
{ id: 3, code: "wtxy", name: "文学院", path: "demo/wtxy", parentSiteId: 1, schId: 1001, schName: "云海大学" },
|
||||
];
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
let currentStore: ReturnType<typeof useCmsStore> | null = null;
|
||||
|
||||
function setRoute(path: string, params: Record<string, string>, meta: any = { title: "文章列表", modId: 11 }) {
|
||||
box.route = reactive({ path, fullPath: path, params, query: {}, hash: "", meta });
|
||||
}
|
||||
|
||||
/** 挂载学校壳;restoredSite 模拟刷新首帧从 localStorage 恢复的站点残留 */
|
||||
function mountLayout(restoredSite?: any) {
|
||||
const pinia = createPinia();
|
||||
const store = useCmsStore(pinia);
|
||||
currentStore = store;
|
||||
store.user = { superAdmin: true, sites: SITES } as any;
|
||||
store.currentSchool = {
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
mainSiteId: 1,
|
||||
mainSiteCode: "demo",
|
||||
mainSiteName: "云海大学门户",
|
||||
} as any;
|
||||
store.currentSite = (restoredSite || { id: 1, code: "demo", name: "云海大学门户" }) as any;
|
||||
store.session.modList = [{ id: 11, name: "文章列表", modId: 11, page: "/article" }];
|
||||
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp(defineComponent({ render: () => h(SchoolLayout) }));
|
||||
app.use(pinia);
|
||||
app.use(ViewUIPlus);
|
||||
app.component("router-view", defineComponent({ render: () => h("div", { class: "rv-stub" }) }));
|
||||
// BreadcrumbItem 传 to 时经 mixins-link 解析链接,需要 $router 桩(测试不引入真实路由实例)
|
||||
app.config.globalProperties.$router = {
|
||||
resolve: (to: any) => ({ href: "#" + (typeof to === "string" ? to : to?.path || "") }),
|
||||
push: box.push,
|
||||
replace: box.replace,
|
||||
};
|
||||
app.config.globalProperties.$route = box.route;
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
async function flush(frames = 5) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
currentStore = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("docs/13 站点分层:URL 站点段 ↔ 上下文同步", () => {
|
||||
it("① 深链子站 URL:currentSite 同步为子站,徽章「院系子站」+ 橙色带 + 面包屑站点段", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
expect(currentStore!.currentSite!.id).toBe(2);
|
||||
expect(currentStore!.currentSite!.code).toBe("jdxy");
|
||||
expect(el.querySelector(".site-identity .bdg")!.textContent).toContain("院系子站");
|
||||
expect(el.querySelector(".path-nav")!.className).toContain("band-sub");
|
||||
// 面包屑:学校列表 > 云海大学 > [院系子站] 机电学院 > 文章列表
|
||||
expect(el.textContent).toContain("云海大学");
|
||||
expect(el.textContent).toContain("机电学院");
|
||||
expect(el.textContent).toContain("文章列表");
|
||||
});
|
||||
|
||||
it("② 非法/跨校 siteCode:告警并打回同页主站形态,上下文归主站", async () => {
|
||||
const warn = vi.spyOn(Message, "warning");
|
||||
setRoute("/sch/demo/site/nope/category", { schCode: "demo", siteCode: "nope" }, { title: "栏目管理", modId: 2 });
|
||||
mountLayout();
|
||||
await flush();
|
||||
|
||||
expect(warn).toHaveBeenCalled();
|
||||
expect(box.replace).toHaveBeenCalledWith(expect.objectContaining({ path: "/sch/demo/category" }));
|
||||
expect(currentStore!.currentSite!.id).toBe(1);
|
||||
});
|
||||
|
||||
it("②b 把主站编码当子站段(/site/demo/)同样视为非法并打回", async () => {
|
||||
setRoute("/sch/demo/site/demo/article", { schCode: "demo", siteCode: "demo" });
|
||||
mountLayout();
|
||||
await flush();
|
||||
expect(box.replace).toHaveBeenCalledWith(expect.objectContaining({ path: "/sch/demo/article" }));
|
||||
});
|
||||
|
||||
it("③ URL 无站点段即主站:localStorage 里残留的子站上下文不再恢复", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout({ id: 2, code: "jdxy", name: "机电学院" });
|
||||
await flush();
|
||||
|
||||
expect(currentStore!.currentSite!.id).toBe(1);
|
||||
expect(el.querySelector(".path-nav")!.className).toContain("band-main");
|
||||
expect(el.querySelector(".site-identity .bdg")!.textContent).toContain("校主站");
|
||||
});
|
||||
|
||||
it("②c 切换 URL 到子站:同步跟随(同一壳内导航,五联动中的 store/徽章/色带)", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
expect(currentStore!.currentSite!.id).toBe(1);
|
||||
|
||||
const p = "/sch/demo/site/wtxy/article";
|
||||
box.route.path = p;
|
||||
box.route.fullPath = p;
|
||||
box.route.params = { schCode: "demo", siteCode: "wtxy" };
|
||||
await flush();
|
||||
|
||||
expect(currentStore!.currentSite!.id).toBe(3);
|
||||
expect(el.querySelector(".path-nav")!.className).toContain("band-sub");
|
||||
expect(el.textContent).toContain("文学院");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 站点身份条:SiteSwitcher 一步切换", () => {
|
||||
it("④ 展开下拉 → 点击子站行 → push 当前功能页的子站形态 URL 并收起", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
const dd = el.querySelector(".site-switcher-dd");
|
||||
expect(dd).toBeTruthy();
|
||||
// 分组结构:主站 1 行 + 院系子站 2 行
|
||||
expect(el.querySelectorAll(".site-switcher-dd .dd-item")).toHaveLength(3);
|
||||
expect(dd!.textContent).toContain("院系子站(2)");
|
||||
|
||||
const jdxy = [...el.querySelectorAll(".site-switcher-dd .dd-item")].find(r =>
|
||||
r.textContent?.includes("机电学院")
|
||||
)!;
|
||||
jdxy.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
// 停留当前功能页(article),只改 URL;store 由路由同步接管
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/article");
|
||||
expect(el.querySelector(".site-switcher-dd")).toBeNull();
|
||||
});
|
||||
|
||||
it("④b 子站态下切回主站:URL 省略站点段;Escape 收起面板", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/category", { schCode: "demo", siteCode: "jdxy" }, { title: "栏目管理", modId: 2 });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
const mainRow = [...el.querySelectorAll(".site-switcher-dd .dd-item")].find(r =>
|
||||
r.textContent?.includes("云海大学门户")
|
||||
)!;
|
||||
mainRow.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/category");
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(el.querySelector(".site-switcher-dd")).toBeTruthy();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
await flush();
|
||||
expect(el.querySelector(".site-switcher-dd")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 菜单高亮:剥段匹配", () => {
|
||||
it("⑤ 子站形态下菜单高亮命中;列表子页面面包屑链接带站点段(不落回主站)", async () => {
|
||||
setRoute(
|
||||
"/sch/demo/site/jdxy/article/edit",
|
||||
{ schCode: "demo", siteCode: "jdxy" },
|
||||
{ title: "新建文章", modId: 11, subOf: "/article" }
|
||||
);
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
const li = [...el.querySelectorAll(".left-menu li")].find(x => x.textContent?.includes("文章列表"));
|
||||
expect(li).toBeTruthy();
|
||||
expect(li!.className).toContain("active");
|
||||
// 面包屑「文章列表」为可点段(非末段),链接须带 /site/jdxy 以停留子站
|
||||
const crumb = [...el.querySelectorAll(".path-nav a")].find(a => a.textContent?.includes("文章列表"));
|
||||
expect(crumb).toBeTruthy();
|
||||
expect(crumb!.getAttribute("href")).toContain("/sch/demo/site/jdxy/article");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 站点上下文持久化:刷新首帧兜底(docs/15 T3)", () => {
|
||||
it("⑥ setCurrentSite 写入 localStorage;新 pinia restoreSite 恢复同值", () => {
|
||||
const pinia1 = createPinia();
|
||||
setActivePinia(pinia1);
|
||||
const s1 = useCmsStore(pinia1);
|
||||
s1.setCurrentSite({ id: 2, code: "jdxy", path: "demo/jdxy", name: "机电学院" } as any);
|
||||
expect(JSON.parse(localStorage.getItem("cms.currentSite")!).code).toBe("jdxy");
|
||||
|
||||
const pinia2 = createPinia();
|
||||
setActivePinia(pinia2);
|
||||
const s2 = useCmsStore(pinia2);
|
||||
s2.restoreSite();
|
||||
expect(s2.currentSite?.id).toBe(2);
|
||||
expect(s2.currentSite?.code).toBe("jdxy");
|
||||
});
|
||||
|
||||
it("⑥b setCurrentSite(null) 清除持久化;无残留时 restoreSite 归 null", () => {
|
||||
const pinia1 = createPinia();
|
||||
setActivePinia(pinia1);
|
||||
const s1 = useCmsStore(pinia1);
|
||||
s1.setCurrentSite({ id: 2, code: "jdxy", name: "机电学院" } as any);
|
||||
s1.setCurrentSite(null);
|
||||
expect(localStorage.getItem("cms.currentSite")).toBeNull();
|
||||
|
||||
const pinia2 = createPinia();
|
||||
setActivePinia(pinia2);
|
||||
const s2 = useCmsStore(pinia2);
|
||||
s2.restoreSite();
|
||||
expect(s2.currentSite).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 身份条次级入口与幂等切换(docs/15 T5)", () => {
|
||||
it("⑦ 主站态「前往站点管理」:URL 不带段", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
el.querySelector(".site-switcher-dd .dd-foot")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/site");
|
||||
});
|
||||
|
||||
it("⑦b 子站态「前往站点管理」保持站点段;点当前站点行不重复 push(幂等)", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/banner", { schCode: "demo", siteCode: "jdxy" }, { title: "轮播管理", modId: 3 });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
// 幂等:点当前子站行,目标 URL === 当前 URL,不产生 push
|
||||
const jdxyRow = [...el.querySelectorAll(".site-switcher-dd .dd-item")].find(r =>
|
||||
r.textContent?.includes("机电学院")
|
||||
)!;
|
||||
jdxyRow.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(box.push).not.toHaveBeenCalled();
|
||||
|
||||
// 再展开 → 底部入口带段保持当前站点上下文
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
el.querySelector(".site-switcher-dd .dd-foot")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/site");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 站点管理页回归(2026-09-10 修复批次):
|
||||
* ① 行「编辑」打开编辑弹窗——修复 themeUnregistered ref 声明缺失导致 openEdit 抛
|
||||
* ReferenceError、弹窗无法打开(编辑功能整体失效)的回归;
|
||||
* ② 已注册皮肤不出现"未注册"告警;未注册皮肤保留原值并出现告警(任务三行为)。
|
||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 themeManage.spec.ts 一致)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus, { Tag } from "view-ui-plus";
|
||||
|
||||
// SiteManage 的 columns 渲染函数裸用 Tag(vite dev 由 unplugin-auto-import 注入,
|
||||
// vitest 管线不走该插件)——这里挂到全局作用域供其解析
|
||||
(globalThis as any).Tag = Tag;
|
||||
|
||||
const { siteListMock, themeListMock } = vi.hoisted(() => ({
|
||||
siteListMock: vi.fn(),
|
||||
themeListMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const siteRow = {
|
||||
id: 1,
|
||||
code: "demo",
|
||||
path: "demo",
|
||||
parentSiteId: 0,
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
name: "云海大学门户网站",
|
||||
theme: "default",
|
||||
configJson: '{"logo_text":"云海大学"}',
|
||||
status: 1,
|
||||
};
|
||||
|
||||
const themeRow = {
|
||||
name: "default",
|
||||
title: "高校蓝(默认)",
|
||||
description: "",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
|
||||
source: "builtin",
|
||||
inUseCount: 1,
|
||||
editable: true,
|
||||
usedBySiteNames: ["云海大学门户网站"],
|
||||
};
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
siteApi: { list: () => ({ send: siteListMock }) },
|
||||
themeApi: { list: () => ({ send: themeListMock }) },
|
||||
assetUrl: (u: string) => u,
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
useRoute: () => ({ params: {}, query: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePicker.vue", () => ({
|
||||
default: defineComponent({ props: ["themes", "modelValue"], render: () => h("div", "theme-picker-stub") }),
|
||||
}));
|
||||
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
|
||||
default: defineComponent({ props: ["theme", "modelValue"], render: () => h("div", "theme-param-stub") }),
|
||||
}));
|
||||
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }),
|
||||
}));
|
||||
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
|
||||
default: defineComponent({ props: ["modelValue"], render: () => h("div", "config-form-stub") }),
|
||||
}));
|
||||
vi.mock("@/views/cms/SiteWizard.vue", () => ({
|
||||
default: defineComponent({ props: ["visible", "sites", "lockParentId"], emits: ["created"], render: () => h("div", "wizard-stub") }),
|
||||
}));
|
||||
|
||||
import SiteManage from "@/views/cms/SiteManage.vue";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
async function flush(frames = 6) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
function mountPage() {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp(defineComponent({ render: () => h(SiteManage) }));
|
||||
app.use(createPinia());
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
/** 操作列图标按钮(iView 固定列克隆两份,取第一个命中的) */
|
||||
function clickRowAction(el: ParentNode, iconClass: string) {
|
||||
const btn = [...el.querySelectorAll(`.operating-btn.${iconClass}`)][0];
|
||||
btn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
return btn;
|
||||
}
|
||||
|
||||
function modalByTitle(title: string): HTMLElement | undefined {
|
||||
return [...document.querySelectorAll(".ivu-modal")].find(m => m.querySelector(".ivu-modal-header-inner")?.textContent === title);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([siteRow])));
|
||||
themeListMock.mockResolvedValue(JSON.parse(JSON.stringify([themeRow])));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("SiteManage 站点管理(2026-09-10 修复回归)", () => {
|
||||
it("① 行「编辑」打开编辑弹窗并回填数据(不再因 themeUnregistered 未声明而失效)", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const btn = clickRowAction(el, "icon-edit");
|
||||
expect(btn).toBeTruthy();
|
||||
await flush();
|
||||
|
||||
const modal = modalByTitle("编辑站点");
|
||||
expect(modal).toBeTruthy();
|
||||
const codeInput = modal!.querySelector<HTMLInputElement>(".ivu-input");
|
||||
expect(codeInput?.value).toBe("demo");
|
||||
});
|
||||
|
||||
it("② 已注册皮肤无告警", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
// 已注册:default 在皮肤列表中 → 不出现告警
|
||||
clickRowAction(el, "icon-edit");
|
||||
await flush();
|
||||
const modal = modalByTitle("编辑站点");
|
||||
expect(modal?.textContent).not.toContain("未在注册表中");
|
||||
});
|
||||
|
||||
it("③ 未注册皮肤保留原值且弹出告警(不静默归一 default)", async () => {
|
||||
siteListMock.mockResolvedValue(
|
||||
JSON.parse(JSON.stringify([{ ...siteRow, theme: "skin-x" }]))
|
||||
);
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
clickRowAction(el, "icon-edit");
|
||||
await flush();
|
||||
const modal = modalByTitle("编辑站点");
|
||||
expect(modal?.textContent).toContain("未在注册表中");
|
||||
expect(modal?.textContent).toContain("skin-x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* docs/13 方案 C 复检:真实 vue-router 集成(docs/15 §3 T1/T4)。
|
||||
* siteLayer.spec.ts 全程 router 桩(vi.mock vue-router),覆盖不到镜像层真实匹配与守卫重定向,
|
||||
* 本文件装载 routes.ts 全量路由 + router/index.ts 守卫补位:
|
||||
* ① 路由结构:/site/{code} 无 component 镜像层存在、镜像去 name、meta.modId 保留、空段 redirect 兜底;
|
||||
* ② 镜像层真实 resolve 命中(渲染层由 router-view 对无 component 记录跳过推进,此处验匹配链);
|
||||
* ③ 守卫:子站编码当学校段 → 定向主站前缀且余部原样保留(含站点段),并恢复学校上下文;
|
||||
* ④ legacyRedirect 旧链带 id 重定向。
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useCmsStore } from "@/store";
|
||||
import router from "@/router";
|
||||
import { routes } from "@/router/routes";
|
||||
|
||||
const SITES = [
|
||||
{ id: 1, code: "demo", name: "云海大学门户", parentSiteId: 0, schId: 1001, schName: "云海大学" },
|
||||
{ id: 2, code: "jdxy", name: "机电学院", parentSiteId: 1, schId: 1001, schName: "云海大学" },
|
||||
];
|
||||
|
||||
let store: ReturnType<typeof useCmsStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
store = useCmsStore(pinia);
|
||||
store.user = { superAdmin: true, sites: SITES } as any;
|
||||
});
|
||||
|
||||
describe("docs/13 路由结构(真实 routes 数组,docs/15 T1)", () => {
|
||||
it("① /site/{code} 无 component 镜像层存在;镜像去 name、meta.modId 保留、空段 redirect 到 article", () => {
|
||||
const sch = routes.find(r => r.path === "/sch/:schCode")!;
|
||||
const siteLayer = sch.children!.find(r => r.path === "site/:siteCode") as any;
|
||||
expect(siteLayer).toBeTruthy();
|
||||
expect(siteLayer.component).toBeUndefined();
|
||||
expect(siteLayer.children![0].path).toBe("");
|
||||
expect(siteLayer.children![0].redirect).toBeTruthy();
|
||||
const mirror = siteLayer.children!.find((c: any) => c.path === "category");
|
||||
expect(mirror!.name).toBeUndefined();
|
||||
expect((mirror!.meta as any).modId).toBe(2);
|
||||
});
|
||||
|
||||
it("①b 主站清单与镜像层不重名(防重复注册告警)", () => {
|
||||
expect(router.getRoutes().filter(r => r.name === "CategoryManage")).toHaveLength(1);
|
||||
expect(router.getRoutes().filter(r => r.name === "ArticleList")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("② 子站 URL 真实 resolve 命中镜像记录;主站 URL 命中主记录", () => {
|
||||
const sub = router.resolve("/sch/demo/site/jdxy/category");
|
||||
expect(sub.matched).toHaveLength(3);
|
||||
expect(sub.name).toBeUndefined();
|
||||
expect(sub.meta.modId).toBe(2);
|
||||
|
||||
const main = router.resolve("/sch/demo/category");
|
||||
expect(main.matched).toHaveLength(2);
|
||||
expect(main.name).toBe("CategoryManage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 守卫与旧链兼容(真实 router.push,docs/15 T4)", () => {
|
||||
it("③ 子站编码当学校段 → 重定向主站前缀(余部保留)并恢复学校上下文", async () => {
|
||||
await router.push("/sch/jdxy/article");
|
||||
expect(router.currentRoute.value.path).toBe("/sch/demo/article");
|
||||
expect(store.currentSchool?.mainSiteCode).toBe("demo");
|
||||
});
|
||||
|
||||
it("③b 子站编码当学校段且 URL 已带站点段 → 站点段原样保留", async () => {
|
||||
await router.push("/sch/jdxy/site/jdxy/category");
|
||||
expect(router.currentRoute.value.path).toBe("/sch/demo/site/jdxy/category");
|
||||
expect(router.currentRoute.value.params.siteCode).toBe("jdxy");
|
||||
});
|
||||
|
||||
it("③c 学校编码不在用户可管理站点内 → 回工作台", async () => {
|
||||
await router.push("/sch/other-school/article");
|
||||
expect(router.currentRoute.value.path).toBe("/workbench");
|
||||
});
|
||||
|
||||
it("④ 旧链 /article/edit/:id → 当前学校上下文主站形态(id 后缀保留)", async () => {
|
||||
store.currentSchool = {
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
mainSiteId: 1,
|
||||
mainSiteCode: "demo",
|
||||
mainSiteName: "云海大学门户",
|
||||
} as any;
|
||||
await router.push("/article/edit/9");
|
||||
expect(router.currentRoute.value.path).toBe("/sch/demo/article/edit/9");
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,11 @@ vi.mock("@/api/module/cms", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }),
|
||||
default: defineComponent({
|
||||
props: ["sitePath", "theme", "themeParams", "fill"],
|
||||
setup: (props: any) => () =>
|
||||
h("div", { "data-preview-stub": "true", "data-fill": String(props.fill !== undefined && props.fill !== false) }, "preview-stub"),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
|
||||
@@ -240,4 +244,21 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(deriveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("2026-09-10: 预览弹窗全屏展示(fullscreen + 无 footer),预览框 fill 占满余量", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const builtin = [...el.querySelectorAll(".card")][0];
|
||||
clickButtonByText(builtin, "预览");
|
||||
await flush();
|
||||
|
||||
const previewModal = modalContaining("内容源站点");
|
||||
expect(previewModal).toBeTruthy();
|
||||
expect(previewModal!.className).toContain("ivu-modal-fullscreen");
|
||||
expect(previewModal!.className).toContain("ivu-modal-fullscreen-no-footer");
|
||||
const stub = previewModal!.querySelector("[data-preview-stub]");
|
||||
expect(stub).toBeTruthy();
|
||||
expect(stub!.getAttribute("data-fill")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<Modal v-model="visible" title="批量添加栏目" :width="520" :mask-closable="false">
|
||||
<div class="batch-body">
|
||||
<Form :label-width="90">
|
||||
<Form-Item label="上级栏目:">
|
||||
<span class="batch-val">{{ parentName || '根栏目' }}</span>
|
||||
</Form-Item>
|
||||
<Form-Item label="栏目类型:">
|
||||
<RadioGroup v-model="typeValue">
|
||||
<!-- 外链必须逐个填地址,不提供批量 -->
|
||||
<Radio :label="1">文章列表</Radio>
|
||||
<Radio :label="2">单页</Radio>
|
||||
</RadioGroup>
|
||||
</Form-Item>
|
||||
<Form-Item label="栏目名称:">
|
||||
<Input v-model="rawNames" type="textarea" :rows="8" placeholder="一行一个栏目名称,保存时按行顺序写入排序号" />
|
||||
</Form-Item>
|
||||
</Form>
|
||||
<p class="batch-tip">将按行逐个创建栏目;某行失败不影响其余行,结束后统一提示结果。</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="loading" @click="submit">确认</Button>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/* UserBase BatchAddGroup 同款交互:textarea 一行一个名称批量建组;
|
||||
CMS 无后端批量接口,前端循环调 categoryApi.save(用户约定方案),按行序写 sortNo=1..n。 */
|
||||
import { ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { categoryApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 上级栏目 id(0=根;来自当前选中节点,虚拟根 id=0 天然合法) */
|
||||
defaultPid?: number | null
|
||||
/** 上级栏目名称(展示用) */
|
||||
parentName?: string
|
||||
defaultType?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ success: [lastSavedId?: number] }>()
|
||||
|
||||
const store = useCmsStore();
|
||||
const loading = ref(false)
|
||||
const rawNames = ref('')
|
||||
const typeValue = ref<number>(1)
|
||||
|
||||
function normalizeNames() {
|
||||
return rawNames.value
|
||||
.split(/\r?\n/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!store.siteId) {
|
||||
Message.warning('请先选择站点');
|
||||
return;
|
||||
}
|
||||
const names = normalizeNames();
|
||||
if (!names.length) {
|
||||
Message.warning('请输入栏目名称');
|
||||
return;
|
||||
}
|
||||
const parentId = props.defaultPid ?? 0;
|
||||
loading.value = true;
|
||||
const failed: string[] = [];
|
||||
let lastSavedId: number | undefined;
|
||||
try {
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const name = names[i];
|
||||
try {
|
||||
const saved = await categoryApi
|
||||
.save({
|
||||
siteId: store.siteId,
|
||||
parentId,
|
||||
name,
|
||||
type: typeValue.value,
|
||||
sortNo: i + 1,
|
||||
status: 1,
|
||||
})
|
||||
.send();
|
||||
lastSavedId = saved?.id ?? lastSavedId;
|
||||
} catch (e: any) {
|
||||
failed.push(name);
|
||||
console.warn(`栏目「${name}」创建失败`, e?.message);
|
||||
}
|
||||
}
|
||||
if (failed.length) {
|
||||
Message.warning(`创建完成:成功 ${names.length - failed.length} 条,失败 ${failed.length} 条(${failed.join('、')})`);
|
||||
} else {
|
||||
Message.success(`成功创建 ${names.length} 个栏目!`);
|
||||
}
|
||||
emit('success', lastSavedId);
|
||||
visible.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (v) {
|
||||
rawNames.value = '';
|
||||
typeValue.value = props.defaultType === 2 ? 2 : 1;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.batch-body {
|
||||
.batch-val {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.batch-tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+101
-25
@@ -1,14 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
/* 学校专属管理壳(docs/09 §3.3,由 layout.vue 改造,二级壳 /sch/:schCode):
|
||||
* header 左侧仅超管可见「↩返回学校列表」图标(校级账号不显示);
|
||||
* 面包屑首段=校名(落点为院系子站时后跟当前站点名),超管在校名前多一段可点「学校列表」(与返回图标同效)。
|
||||
* 学校/站点切换一律离壳完成:学校在 /workbench,站点在站点管理行「进入管理」。
|
||||
* 面包屑 = 学校列表(超管)> 校名 > 站点身份条 > 页面。
|
||||
* 站点分层(docs/13 方案 C):
|
||||
* URL 为站点上下文唯一事实源——主站 /sch/{code}/{page},子站 /sch/{code}/site/{siteCode}/{page};
|
||||
* 站点身份条(SiteSwitcher)一步切换并停留当前功能页,上下文由 syncSiteFromRoute 同步;
|
||||
* 菜单 page 保持主站形态,高亮剥段匹配、跳转按当前站点补段。
|
||||
* 学校切换仍在壳外完成:/workbench 工作台选校。
|
||||
* 菜单/高亮/子页面面包屑逻辑与原 layout 一致,布局数据来自 store.session。 */
|
||||
import { ref, computed, watch, onBeforeMount } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath";
|
||||
import SiteSwitcher from "@/components/SiteSwitcher.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -31,18 +37,21 @@ const modList = computed(() => store.session.modList || []);
|
||||
|
||||
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
|
||||
|
||||
/** 当前学校上下文(面包屑首段) */
|
||||
/** 当前学校上下文(面包屑校名段) */
|
||||
const school = computed(() => store.currentSchool);
|
||||
const isSuper = computed(() => store.isSuperAdmin);
|
||||
|
||||
/** 面包屑校名后缀:操作落点非主站时跟随展示当前站点名(院系子站管理可见性,
|
||||
* 站点切换入口在站点管理行「进入管理」,壳上不再放切换器) */
|
||||
const currentSiteSuffix = computed(() => {
|
||||
const s = store.currentSite;
|
||||
const sc = school.value;
|
||||
if (!s || !sc || s.id === sc.mainSiteId) return "";
|
||||
return s.name || "";
|
||||
});
|
||||
/** 当前落点是否院系子站(store 契约):决定身份条徽章与底边色带 */
|
||||
const isSubSite = computed(() => store.isSubSite);
|
||||
const pathNavClass = computed(() => (isSubSite.value ? "band-sub" : "band-main"));
|
||||
|
||||
/** 菜单/面包屑 page(主站形态完整路径)→ 当前站点形态:子站插入 /site/{code} 段(docs/13 §4)。
|
||||
* 非 /sch/ 前缀(外链模块)原样返回,防拼坏 */
|
||||
const withSiteSeg = (page: string): string => {
|
||||
const code = school.value?.mainSiteCode;
|
||||
if (!page || !code || !page.startsWith("/sch/")) return page;
|
||||
return adminPathOf(code, store.siteSegCode, schoolSubPath(page));
|
||||
};
|
||||
|
||||
const flaten = (tree: any[], pid = 0): any[] => {
|
||||
const arr: any[] = [];
|
||||
@@ -70,6 +79,8 @@ const routeChange = (to: any) => {
|
||||
const meta: any = to.meta || {};
|
||||
const { modId, title, subOf } = meta;
|
||||
const navArr: any[] = [];
|
||||
// 菜单 page 为主站形态,匹配前剥掉站点中缀(docs/13 §4 剥段匹配)
|
||||
const toLogic = stripSiteSeg(to.path || "");
|
||||
|
||||
// 侧边栏子菜单组自动展开:当前高亮落在某组子项时展开该组(改善直达体验)
|
||||
const expandGroupOf = (page: string) => {
|
||||
@@ -95,7 +106,7 @@ const routeChange = (to: any) => {
|
||||
const gp = menuArr.value.find((x: any) => x.id === parent.pid);
|
||||
if (gp) navArr.push({ name: gp.name });
|
||||
}
|
||||
navArr.push({ name: parent.name, page: parent.page });
|
||||
navArr.push({ name: parent.name, page: withSiteSeg(parent.page) });
|
||||
navArr.push({ name: title });
|
||||
navPath.value = navArr;
|
||||
return;
|
||||
@@ -105,7 +116,7 @@ const routeChange = (to: any) => {
|
||||
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
|
||||
// 匹配不到再退回 modId 对照
|
||||
const find: any =
|
||||
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
|
||||
menuArr.value.find((x: any) => x.page && x.page === toLogic) ||
|
||||
menuArr.value.find((x: any) => x.modId === modId);
|
||||
if (find) {
|
||||
currPath.value = find.page || "";
|
||||
@@ -114,7 +125,7 @@ const routeChange = (to: any) => {
|
||||
const pfind = menuArr.value.find((x: any) => x.id === find.pid);
|
||||
if (pfind) navArr.push({ name: pfind.name });
|
||||
}
|
||||
navArr.push({ name: find.name || title, page: find.page });
|
||||
navArr.push({ name: find.name || title, page: withSiteSeg(find.page) });
|
||||
navPath.value = navArr;
|
||||
return;
|
||||
}
|
||||
@@ -122,8 +133,8 @@ const routeChange = (to: any) => {
|
||||
if (modId != null) {
|
||||
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
|
||||
if (r) {
|
||||
currPath.value = r.path;
|
||||
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
|
||||
currPath.value = stripSiteSeg(r.path);
|
||||
navPath.value = [{ name: (r.meta as any).title, page: withSiteSeg(r.path) }];
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -144,10 +155,12 @@ const linkMenu = (item: any) => {
|
||||
window.location.href = page;
|
||||
return;
|
||||
}
|
||||
if (page === route.path) {
|
||||
// 子站上下文下菜单目标补站点段(主站形态 → 当前站点形态)
|
||||
const target = withSiteSeg(page);
|
||||
if (target === route.path) {
|
||||
router.go(0);
|
||||
} else {
|
||||
router.push(page);
|
||||
router.push(target);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -192,7 +205,34 @@ function goWorkbench() {
|
||||
router.push("/workbench");
|
||||
}
|
||||
|
||||
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文 */
|
||||
/**
|
||||
* URL 站点段 → store 同步(docs/13 §3:URL 是站点上下文唯一事实源)。
|
||||
* 无 siteCode = 主站形态;有则按 code 匹配本校站点(parentSiteId 归属校验),
|
||||
* 非法/跨校 siteCode 打回同页主站形态并告警。
|
||||
* 启动引导期(user 未装载 → schoolSites 为空)跳过,由 user 就绪后的 watch 兜底重跑。
|
||||
*/
|
||||
function syncSiteFromRoute() {
|
||||
const sc = school.value;
|
||||
if (!sc || !store.user) return;
|
||||
const sites = (store.schoolSites || []) as any[];
|
||||
if (!sites.length) return;
|
||||
const { site: target, invalid } = resolveRouteSite(sites, sc.mainSiteId, route.params.siteCode as string);
|
||||
if (invalid) {
|
||||
Message.warning("站点不存在或无权限,已返回主站");
|
||||
router.replace({ path: stripSiteSeg(route.path), query: route.query, hash: route.hash });
|
||||
return;
|
||||
}
|
||||
if (target && store.currentSite?.id !== target.id) {
|
||||
store.setCurrentSite({
|
||||
id: target.id,
|
||||
code: target.code,
|
||||
path: target.path || target.code,
|
||||
name: target.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文,再按 URL 同步站点 */
|
||||
watch(
|
||||
() => store.user,
|
||||
u => {
|
||||
@@ -216,6 +256,8 @@ watch(
|
||||
sites.filter((s: any) => s.id === main.id || s.parentSiteId === main.id)
|
||||
);
|
||||
}
|
||||
syncSiteFromRoute();
|
||||
routeChange(route);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
@@ -226,13 +268,19 @@ onBeforeMount(() => {
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => routeChange(route)
|
||||
() => {
|
||||
syncSiteFromRoute();
|
||||
routeChange(route);
|
||||
}
|
||||
);
|
||||
|
||||
// 深链启动期:学校上下文/菜单晚于首次 routeChange 到达,就绪后重算面包屑与高亮
|
||||
watch(
|
||||
() => [store.currentSchool?.mainSiteCode, store.session.modList.length],
|
||||
() => routeChange(route)
|
||||
() => {
|
||||
syncSiteFromRoute();
|
||||
routeChange(route);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -305,14 +353,18 @@ watch(
|
||||
<div class="clear-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="path-nav" :style="pathNavStyle">
|
||||
<div class="path-nav" :class="pathNavClass" :style="pathNavStyle">
|
||||
<Breadcrumb separator=">">
|
||||
<!-- 超管:校名前多一段可点「学校列表」,与 header 返回图标同效(回工作台切换学校) -->
|
||||
<BreadcrumbItem v-if="isSuper" to="/workbench">学校列表</BreadcrumbItem>
|
||||
<!-- 面包屑首段 = 学校上下文(常驻);落点为院系子站时跟出当前站点名 -->
|
||||
<!-- 面包屑第二段 = 学校上下文(常驻,不承载站点信息) -->
|
||||
<BreadcrumbItem v-if="school">
|
||||
<Icon type="ios-school" />
|
||||
{{ school.schName || school.mainSiteName }}<template v-if="currentSiteSuffix"> · {{ currentSiteSuffix }}</template>
|
||||
{{ school.schName || school.mainSiteName }}
|
||||
</BreadcrumbItem>
|
||||
<!-- 站点身份条(docs/13 §1):徽章+站点名▼,下拉一步切换;不传 to(否则 mixins-link 抢走点击) -->
|
||||
<BreadcrumbItem v-if="store.currentSite">
|
||||
<SiteSwitcher />
|
||||
</BreadcrumbItem>
|
||||
<template v-for="(item, index) in navPath" :key="index">
|
||||
<BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page">
|
||||
@@ -373,11 +425,35 @@ watch(
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 站点色带(docs/13 §1.4):::after 挂底边——.path-nav 高度 56px 固定且未设 border-box,
|
||||
直接加 border-bottom 会改盒高(W2 高度链教训),绝对定位不参与布局 */
|
||||
.path-nav::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
}
|
||||
.path-nav.band-main::after {
|
||||
background: #577afb;
|
||||
}
|
||||
.path-nav.band-sub::after {
|
||||
background: #ff9900;
|
||||
}
|
||||
|
||||
/* W2(D6):左树高度链修复——UserBase App.vue:222-224 同款覆盖式。
|
||||
layout.css 全局 .main-wrapper 只有 min-height:100% 无 height,
|
||||
子级 .tree-wrapper{height:100%} 因父链高度 auto 解析失败,栏目树缩在一起。
|
||||
scoped 生效前提:.base-wrap-main/.main-wrapper 是本组件模板内节点(:348-349) */
|
||||
scoped 生效前提:.base-wrap-main/.main-wrapper 是本组件模板内节点 */
|
||||
.base-wrap-main .main-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 高度链顶层:layout.css 只定义到 .base-wrap-main{height:calc(100% - 56px)},
|
||||
其百分比基于 .layout-wrap——本组件根节点,无高度定义则整条链解析失败
|
||||
(UserBase 同名壳在 layout.vue 全局样式中补了这一层,此前 CMS 缺失) */
|
||||
.layout-wrap {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { adminPathOf, schoolSubPath } from "@/utils/sitePath";
|
||||
|
||||
/**
|
||||
* 站点身份条(docs/13 方案 C):面包屑站点格内的「徽章 + 站点名 + 下拉」。
|
||||
* 数据源 store.schoolSites(主站 + 本校院系子站,零后端改动);选中只改 URL,
|
||||
* 站点上下文由 SchoolLayout.syncSiteFromRoute 按 URL 段同步——见 docs/13 §1.3/§3。
|
||||
* 不用 iView Dropdown:分组头/徽章/✓/path 次级列的表达力不足且无可靠收起 API,自绘面板。
|
||||
*/
|
||||
const store = useCmsStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const open = ref(false);
|
||||
const anchor = ref<HTMLElement | null>(null);
|
||||
|
||||
const schoolCode = computed(() => store.currentSchool?.mainSiteCode || "");
|
||||
const mainSite = computed(
|
||||
() => store.schoolSites.find((s: any) => s.id === store.currentSchool?.mainSiteId) || null
|
||||
);
|
||||
const subSites = computed(() =>
|
||||
store.schoolSites.filter((s: any) => s.parentSiteId === store.currentSchool?.mainSiteId)
|
||||
);
|
||||
/** 当前落点是否子站:主站徽章/配色/落点判定统一走 store 契约 */
|
||||
const isSub = computed(() => store.isSubSite);
|
||||
const currentName = computed(() => store.currentSite?.name || "");
|
||||
|
||||
/** 当前功能页子路径:切换站点停留当前页(剥掉站点段与学校前缀) */
|
||||
const curSub = computed(() => schoolSubPath(route.path) || "/article");
|
||||
|
||||
function pick(site: any) {
|
||||
open.value = false;
|
||||
if (!site) return;
|
||||
const isMain = site.id === store.currentSchool?.mainSiteId;
|
||||
const target = adminPathOf(schoolCode.value, isMain ? null : site.code, curSub.value);
|
||||
if (target === route.path) return;
|
||||
router.push(target);
|
||||
}
|
||||
|
||||
/** 底部入口:管理站点集合(带段=保持当前站点上下文,语义与菜单「站点管理」一致) */
|
||||
function gotoManage() {
|
||||
open.value = false;
|
||||
router.push(adminPathOf(schoolCode.value, store.siteSegCode, "site"));
|
||||
}
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (!open.value) return;
|
||||
if (anchor.value && !anchor.value.contains(e.target as Node)) {
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") open.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="anchor" class="site-identity" :class="{ sub: isSub }">
|
||||
<span class="bdg" :class="isSub ? 'bdg-sub' : 'bdg-main'" @click.stop="open = !open">
|
||||
{{ isSub ? "院系子站" : "校主站" }}
|
||||
</span>
|
||||
<b class="site-name" @click.stop="open = !open">{{ currentName }}</b>
|
||||
<Icon type="ios-arrow-down" class="caret" @click.stop="open = !open" />
|
||||
|
||||
<div v-if="open" class="site-switcher-dd" @click.stop>
|
||||
<div class="dd-cap">切换管理站点(当前:<b>{{ currentName }}</b>)</div>
|
||||
<div class="dd-group">
|
||||
<div v-if="mainSite" class="dd-item" :class="{ active: !isSub }" @click="pick(mainSite)">
|
||||
<span class="check">{{ !isSub ? "✓" : "" }}</span>
|
||||
<span>{{ mainSite.name }}</span>
|
||||
<span class="bdg bdg-main">主站</span>
|
||||
<span class="code">{{ mainSite.path || mainSite.code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dd-group">
|
||||
<div class="dd-cap sub-cap">院系子站({{ subSites.length }})</div>
|
||||
<div v-if="!subSites.length" class="dd-empty">本校暂无院系子站(可在「站点管理」新建)</div>
|
||||
<div
|
||||
v-for="s in subSites"
|
||||
:key="s.id"
|
||||
class="dd-item"
|
||||
:class="{ active: store.currentSite?.id === s.id }"
|
||||
@click="pick(s)"
|
||||
>
|
||||
<span class="check">{{ store.currentSite?.id === s.id ? "✓" : "" }}</span>
|
||||
<span>{{ s.name }}</span>
|
||||
<span class="bdg bdg-sub">院系</span>
|
||||
<span class="code">{{ s.path || s.code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dd-foot" @click="gotoManage">前往「站点管理」维护站点 →</div>
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 徽章:实心色块对齐已验收原型(bdg-main #577afb / bdg-sub #ff9900);
|
||||
iView Tag 预设为浅底描边样式,视觉与原型不一致,故自绘 */
|
||||
.bdg {
|
||||
font-size: 10px;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
color: #fff;
|
||||
line-height: 16px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bdg-main {
|
||||
background: #577afb;
|
||||
}
|
||||
.bdg-sub {
|
||||
background: #ff9900;
|
||||
}
|
||||
|
||||
.site-identity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.site-name {
|
||||
color: #4465d4;
|
||||
font-weight: bold;
|
||||
}
|
||||
.caret {
|
||||
font-size: 10px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
&.sub {
|
||||
background: #fff7ec;
|
||||
&:hover {
|
||||
background: #ffefd6;
|
||||
}
|
||||
.site-name {
|
||||
color: #d48806;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.site-switcher-dd {
|
||||
position: absolute;
|
||||
top: calc(100% + 12px);
|
||||
left: -8px;
|
||||
min-width: 300px;
|
||||
background: #fff;
|
||||
color: #272829;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 28px rgba(31, 42, 58, 0.2);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
text-align: left;
|
||||
animation: dd-in 0.15s ease-out;
|
||||
cursor: default;
|
||||
.dd-cap {
|
||||
padding: 10px 14px 4px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.sub-cap {
|
||||
padding: 2px 14px;
|
||||
}
|
||||
.dd-group {
|
||||
padding: 6px 0;
|
||||
& + .dd-group {
|
||||
border-top: 1px solid #f0f2f5;
|
||||
}
|
||||
}
|
||||
.dd-empty {
|
||||
padding: 6px 14px 8px;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.dd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: #eff4ff;
|
||||
}
|
||||
&.active {
|
||||
background: #eff4ff;
|
||||
color: #4465d4;
|
||||
font-weight: bold;
|
||||
}
|
||||
.check {
|
||||
color: #577afb;
|
||||
font-weight: bold;
|
||||
width: 11px;
|
||||
}
|
||||
.code {
|
||||
margin-left: auto;
|
||||
color: #c0c4cc;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
.dd-foot {
|
||||
border-top: 1px solid #f0f2f5;
|
||||
padding: 9px 14px;
|
||||
font-size: 12px;
|
||||
color: #577afb;
|
||||
cursor: pointer;
|
||||
background: #fafbfc;
|
||||
&:hover {
|
||||
background: #f0f3ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dd-in {
|
||||
from {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@ import MyConfirm from "@/components/MyConfirm";
|
||||
import { articleApi, categoryApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { ArticlePageItem, CategoryTreeNode } from "@/types/cms";
|
||||
import { adminPathOf } from "@/utils/sitePath";
|
||||
|
||||
/**
|
||||
* 文章列表面板(B5 栏目工作台组件化,自 ArticleList.vue 原样抽出):
|
||||
@@ -17,6 +18,8 @@ import { ArticlePageItem, CategoryTreeNode } from "@/types/cms";
|
||||
const props = defineProps<{
|
||||
categoryId?: number;
|
||||
fixedCategory?: boolean;
|
||||
/** 表格高度额外扣减(固定栏目模式嵌入工作台时扣面板头高度) */
|
||||
extraHeight?: number;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
@@ -45,7 +48,12 @@ const statusDict = [
|
||||
const statusText = (s: number) => statusDict.find(i => i.value === s)?.label || "未知";
|
||||
const statusColor = (s: number) => statusDict.find(i => i.value === s)?.color || "default";
|
||||
|
||||
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true });
|
||||
const tableHeight = Utils.useTableHeight({
|
||||
isSearch: true,
|
||||
isOperat: !props.fixedCategory,
|
||||
isPage: true,
|
||||
otherH: props.extraHeight ?? 0,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -137,7 +145,9 @@ async function run() {
|
||||
const page = await articleApi
|
||||
.page({
|
||||
siteId: store.siteId,
|
||||
categoryId: props.fixedCategory ? props.categoryId : search.categoryId,
|
||||
// 后端 ArticlePageQuery 字段为 categoryIds(List<Integer>),传 categoryId 会绑定不上、
|
||||
// 退化成全站检索(栏目工作台"切节点表格不变"的根因)
|
||||
categoryIds: props.fixedCategory ? props.categoryId : search.categoryId,
|
||||
status: search.status,
|
||||
keyword: search.keyword || undefined,
|
||||
page: pageNum.value,
|
||||
@@ -166,7 +176,8 @@ function goEdit(id?: number) {
|
||||
return;
|
||||
}
|
||||
const query = props.fixedCategory && props.categoryId ? `?categoryId=${props.categoryId}` : "";
|
||||
router.push(id ? `/sch/${code}/article/edit/${id}${query}` : `/sch/${code}/article/edit${query}`);
|
||||
// 站点段跟随当前上下文(docs/13):子站编辑页 URL 带 /site/{code}
|
||||
router.push(adminPathOf(code, store.siteSegCode, id ? `article/edit/${id}` : "article/edit") + query);
|
||||
}
|
||||
|
||||
async function doPublish(row: ArticlePageItem) {
|
||||
@@ -222,7 +233,8 @@ watch(() => store.siteId, async () => {
|
||||
|
||||
<template>
|
||||
<div class="article-list-panel">
|
||||
<div class="fix mb-12">
|
||||
<!-- 固定栏目模式:新建入口由工作台面板头承担,避免双按钮重复 -->
|
||||
<div v-if="!fixedCategory" class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="goEdit()">
|
||||
<Icon type="ios-add" />
|
||||
新建文章
|
||||
|
||||
@@ -7,12 +7,17 @@ import { FormModelValue } from "@/utils/themeParamForm";
|
||||
* 主题预览框(方案 §5.5/§6.3):iframe 加载 /api/theme/preview(同源 CAS 会话,
|
||||
* no-store),参数变更由父级 debounce 后更新 src。内容源站点不存在时提示。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 预览内容源站点门户路径(W4:sitePath 语义,取该站栏目/文章/轮播渲染) */
|
||||
sitePath?: string;
|
||||
theme?: string;
|
||||
themeParams?: FormModelValue;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 预览内容源站点门户路径(W4:sitePath 语义,取该站栏目/文章/轮播渲染) */
|
||||
sitePath?: string;
|
||||
theme?: string;
|
||||
themeParams?: FormModelValue;
|
||||
/** 填满父容器高度(皮肤包管理全屏预览弹窗);默认固定高,向导/编辑弹窗内不变 */
|
||||
fill?: boolean;
|
||||
}>(),
|
||||
{ fill: false }
|
||||
);
|
||||
|
||||
const src = computed(() => {
|
||||
if (!props.sitePath || !props.theme) return "";
|
||||
@@ -21,7 +26,7 @@ const src = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-preview">
|
||||
<div class="theme-preview" :class="{ 'theme-preview--fill': fill }">
|
||||
<template v-if="!sitePath || !theme">
|
||||
<div class="preview-empty">
|
||||
无可用内容源站点 —— 预览需要借用一个已有站点的内容
|
||||
@@ -66,4 +71,18 @@ const src = computed(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.theme-preview--fill {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.preview-frame {
|
||||
height: auto;
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
}
|
||||
.preview-empty {
|
||||
height: auto;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+76
-57
@@ -5,7 +5,9 @@ import { useCmsStore } from "@/store";
|
||||
* CMS 管理端路由(hash 模式,部署于 /yschcms/admin 子路径)。
|
||||
* 学校化管理双层壳(docs/09 §3.3):
|
||||
* 一级 /workbench 学校工作台(无壳全屏);二级 /sch/:schCode/* 学校专属管理壳(SchoolLayout)。
|
||||
* 站点/学校上下文经 localStorage 持久化,各页面从 store 读取。
|
||||
* 站点分层(docs/13 方案 C):主站 `/sch/{code}/{page}`;子站 `/sch/{code}/site/{siteCode}/{page}`,
|
||||
* URL 为站点上下文唯一事实源。site 版路由由主站清单深拷贝去 name 生成,
|
||||
* 保证 `router.push({ name })` 永远产出主站形态。
|
||||
* meta.modId 与基础平台模块(GetUserMod)契约对应,供 menuRoute 解析菜单/面包屑;
|
||||
* meta.subOf 声明「列表子页面」(面包屑/侧边栏高亮归属所属列表)。
|
||||
*/
|
||||
@@ -30,6 +32,71 @@ function legacyRedirect(sub: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 学校壳内功能页(主站形态一份;meta/modId 供菜单解析与剥段高亮) */
|
||||
const cmsChildren: RouteRecordRaw[] = [
|
||||
{ path: "", redirect: (to: any) => `/sch/${to.params.schCode}/article` },
|
||||
{
|
||||
path: "article",
|
||||
name: "ArticleList",
|
||||
component: () => import("@/views/cms/ArticleList.vue"),
|
||||
meta: { title: "文章列表", modId: 11 },
|
||||
},
|
||||
{
|
||||
// 新增文章 = 文章列表的子页面(docs/09 §3.2):不挂菜单节点,
|
||||
// 面包屑「内容管理>文章列表>新建文章」、侧边栏高亮文章列表
|
||||
path: "article/edit",
|
||||
name: "ArticleEdit",
|
||||
component: () => import("@/views/cms/ArticleEdit.vue"),
|
||||
meta: { title: "新建文章", modId: 11, subOf: "/article" },
|
||||
},
|
||||
{
|
||||
path: "article/edit/:id",
|
||||
name: "ArticleEditId",
|
||||
component: () => import("@/views/cms/ArticleEdit.vue"),
|
||||
meta: { title: "编辑文章", modId: 11, subOf: "/article" },
|
||||
},
|
||||
{
|
||||
path: "category",
|
||||
name: "CategoryManage",
|
||||
component: () => import("@/views/cms/CategoryManage.vue"),
|
||||
meta: { title: "栏目管理", modId: 2 },
|
||||
},
|
||||
{
|
||||
path: "banner",
|
||||
name: "BannerManage",
|
||||
component: () => import("@/views/cms/BannerManage.vue"),
|
||||
meta: { title: "轮播管理", modId: 3 },
|
||||
},
|
||||
{
|
||||
path: "link",
|
||||
name: "LinkManage",
|
||||
component: () => import("@/views/cms/LinkManage.vue"),
|
||||
meta: { title: "友情链接", modId: 4 },
|
||||
},
|
||||
{
|
||||
path: "attachment",
|
||||
name: "AttachmentManage",
|
||||
component: () => import("@/views/cms/AttachmentManage.vue"),
|
||||
meta: { title: "附件管理", modId: 5 },
|
||||
},
|
||||
{
|
||||
path: "site",
|
||||
name: "SiteManage",
|
||||
component: () => import("@/views/cms/SiteManage.vue"),
|
||||
meta: { title: "站点管理", modId: 6 },
|
||||
},
|
||||
{
|
||||
// 门户预览(SPA 内嵌简版门户;真实门户由后端 Thymeleaf 提供)
|
||||
path: "portal-preview",
|
||||
name: "PortalPreview",
|
||||
component: () => import("@/views/cms/PortalPreview.vue"),
|
||||
meta: { title: "门户预览", modId: 99 },
|
||||
},
|
||||
];
|
||||
|
||||
/** 子站形态:同一批页面,去掉 name(防重复注册告警;子站导航一律走 path 拼装) */
|
||||
const siteChildren: RouteRecordRaw[] = cmsChildren.map(r => ({ ...r, name: undefined }));
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/",
|
||||
@@ -55,63 +122,15 @@ export const routes: RouteRecordRaw[] = [
|
||||
path: "/sch/:schCode",
|
||||
component: () => import("@/components/SchoolLayout.vue"),
|
||||
children: [
|
||||
{ path: "", redirect: (to: any) => `/sch/${to.params.schCode}/article` },
|
||||
...cmsChildren,
|
||||
{
|
||||
path: "article",
|
||||
name: "ArticleList",
|
||||
component: () => import("@/views/cms/ArticleList.vue"),
|
||||
meta: { title: "文章列表", modId: 11 },
|
||||
},
|
||||
{
|
||||
// 新增文章 = 文章列表的子页面(docs/09 §3.2):不挂菜单节点,
|
||||
// 面包屑「内容管理>文章列表>新建文章」、侧边栏高亮文章列表
|
||||
path: "article/edit",
|
||||
name: "ArticleEdit",
|
||||
component: () => import("@/views/cms/ArticleEdit.vue"),
|
||||
meta: { title: "新建文章", modId: 11, subOf: "/article" },
|
||||
},
|
||||
{
|
||||
path: "article/edit/:id",
|
||||
name: "ArticleEditId",
|
||||
component: () => import("@/views/cms/ArticleEdit.vue"),
|
||||
meta: { title: "编辑文章", modId: 11, subOf: "/article" },
|
||||
},
|
||||
{
|
||||
path: "category",
|
||||
name: "CategoryManage",
|
||||
component: () => import("@/views/cms/CategoryManage.vue"),
|
||||
meta: { title: "栏目管理", modId: 2 },
|
||||
},
|
||||
{
|
||||
path: "banner",
|
||||
name: "BannerManage",
|
||||
component: () => import("@/views/cms/BannerManage.vue"),
|
||||
meta: { title: "轮播管理", modId: 3 },
|
||||
},
|
||||
{
|
||||
path: "link",
|
||||
name: "LinkManage",
|
||||
component: () => import("@/views/cms/LinkManage.vue"),
|
||||
meta: { title: "友情链接", modId: 4 },
|
||||
},
|
||||
{
|
||||
path: "attachment",
|
||||
name: "AttachmentManage",
|
||||
component: () => import("@/views/cms/AttachmentManage.vue"),
|
||||
meta: { title: "附件管理", modId: 5 },
|
||||
},
|
||||
{
|
||||
path: "site",
|
||||
name: "SiteManage",
|
||||
component: () => import("@/views/cms/SiteManage.vue"),
|
||||
meta: { title: "站点管理", modId: 6 },
|
||||
},
|
||||
{
|
||||
// 门户预览(SPA 内嵌简版门户;真实门户由后端 Thymeleaf 提供)
|
||||
path: "portal-preview",
|
||||
name: "PortalPreview",
|
||||
component: () => import("@/views/cms/PortalPreview.vue"),
|
||||
meta: { title: "门户预览", modId: 99 },
|
||||
// 站点段前缀层(docs/13 §2):无 component,子记录直接渲染到 SchoolLayout 的 router-view;
|
||||
// 站点上下文由 SchoolLayout.syncSiteFromRoute 按此段同步 store
|
||||
path: "site/:siteCode",
|
||||
children: [
|
||||
{ path: "", redirect: (to: any) => `/sch/${to.params.schCode}/site/${to.params.siteCode}/article` },
|
||||
...siteChildren,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+11
-6
@@ -33,6 +33,15 @@ export const useCmsStore = defineStore("cmsStore", {
|
||||
s => s.id === school.mainSiteId || s.parentSiteId === school.mainSiteId
|
||||
);
|
||||
},
|
||||
/** 当前落点是否院系子站(docs/13:主站 = 学校主站 id)——决定 URL 是否带 /site 段 */
|
||||
isSubSite: (state): boolean =>
|
||||
!!state.currentSite && !!state.currentSchool && state.currentSite.id !== state.currentSchool.mainSiteId,
|
||||
/** adminPathOf 用的站点段 code:子站=站点 code;主站=null(省略段,URL 全兼容) */
|
||||
siteSegCode(state): string | null {
|
||||
return state.currentSite && state.currentSchool && state.currentSite.id !== state.currentSchool.mainSiteId
|
||||
? state.currentSite.code
|
||||
: null;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setUser(user: AuthUserInfo | null) {
|
||||
@@ -84,14 +93,10 @@ export const useCmsStore = defineStore("cmsStore", {
|
||||
}
|
||||
return this.currentSite;
|
||||
},
|
||||
/** 进入学校上下文:校卡 + 默认站点(记住的站点仍属本校则沿用,否则主站) */
|
||||
/** 进入学校上下文:校卡 + 主站点(docs/13:站点上下文由 URL 决定,
|
||||
* URL 没写站点段就是主站;localStorage 记忆仅作刷新首帧兜底,不作导航决策) */
|
||||
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);
|
||||
if (belongs) {
|
||||
return;
|
||||
}
|
||||
const main = sites.find(s => s.id === school.mainSiteId) || sites[0];
|
||||
if (main) {
|
||||
this.setCurrentSite({ id: main.id, code: main.code, path: main.path || main.code, name: main.name });
|
||||
|
||||
+4
-1
@@ -95,6 +95,8 @@ export interface CategoryTreeNode {
|
||||
type: number;
|
||||
linkUrl?: string;
|
||||
template?: string;
|
||||
/** 首页展示形态(docs/12:空=默认卡 / news_grid 双栏新闻卡 / media_focus 来源标签列表) */
|
||||
homeStyle?: string;
|
||||
/** 单页内容(type=2) */
|
||||
content?: string;
|
||||
sortNo: number;
|
||||
@@ -167,7 +169,8 @@ export interface ArticleSaveRequest {
|
||||
/** 文章分页查询(ArticlePageQuery) */
|
||||
export interface ArticlePageQuery {
|
||||
siteId: number;
|
||||
categoryId?: number;
|
||||
/** 栏目 id(后端字段 categoryIds: List<Integer>,多选逗号分隔;空=全部栏目) */
|
||||
categoryIds?: number;
|
||||
status?: number;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
|
||||
@@ -34,6 +34,11 @@ const ROUTE_ENTRIES: RouteEntry[] = [];
|
||||
if (raw.includes(":") && !isSchoolRoot && !underSchool) {
|
||||
return;
|
||||
}
|
||||
// 站点段前缀层(docs/13):无 component 的参数化嵌套层,其子记录只是主站清单的镜像。
|
||||
// 纳入清单会让名称/modId 匹配出现重影(双向包含匹配命中多个即放弃),故整枝跳过
|
||||
if (raw.includes(":") && !isSchoolRoot && !r.component && !r.redirect) {
|
||||
return;
|
||||
}
|
||||
const nextUnder = underSchool || isSchoolRoot;
|
||||
const sub = nextUnder && !raw.startsWith("/") ? "/" + raw : raw;
|
||||
if (!r.redirect && r.component) {
|
||||
|
||||
@@ -110,11 +110,38 @@ describe("siteConfigForm", () => {
|
||||
it("已知键清单与 docs/09 §0 键盘点表一致(防漂移)", () => {
|
||||
expect(SITE_CONFIG_FIELD_DEFS.map(d => d.key)).toEqual([
|
||||
"logo", "favicon", "slogan", "colleges_label",
|
||||
"contact", "copyright", "beian",
|
||||
"news_bg_image", "gallery_title", "gallery_subtitle",
|
||||
"contact", "footer_addresses", "qr_wechat", "qr_weibo",
|
||||
"social_wechat", "social_weibo", "social_douyin", "social_bilibili",
|
||||
"copyright", "beian",
|
||||
"seo_description",
|
||||
"domain", "public_base_url",
|
||||
]);
|
||||
});
|
||||
it("docs/12:页脚增强与首页视觉新键 parse/serialize/校验(qr_* asset、social_* url)", () => {
|
||||
const json = JSON.stringify({
|
||||
footer_addresses: "潭山校区:南湖路 237 号 邮编:464000\n淮河校区:大学路 1 号 邮编:464300",
|
||||
qr_wechat: "/cms/upload/qr-wechat.png",
|
||||
social_weibo: "https://weibo.com/xxx",
|
||||
social_douyin: "not-a-url",
|
||||
gallery_title: "线上展厅",
|
||||
});
|
||||
const m = parseSiteConfig(json);
|
||||
expect(m.fields.footer_addresses).toContain("淮河校区");
|
||||
expect(m.fields.qr_wechat).toBe("/cms/upload/qr-wechat.png");
|
||||
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 socialDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "social_douyin")!;
|
||||
expect(validateFieldValue(qrDef, "javascript:alert(1)")).toContain("路径");
|
||||
expect(validateFieldValue(socialDef, "not-a-url")).toContain("http");
|
||||
// 空值统一剔除:不污染 config_json
|
||||
m.fields.social_bilibili = " ";
|
||||
const out = JSON.parse(serializeSiteConfig(m));
|
||||
expect(out.footer_addresses).toContain("邮编:464300");
|
||||
expect(out).not.toHaveProperty("social_bilibili");
|
||||
expect(out.gallery_title).toBe("线上展厅");
|
||||
});
|
||||
it("B3:custom_css 为保留键,不入扩展行,parse/serialize 无损往返", () => {
|
||||
const json = JSON.stringify({ custom_css: ".site-header{padding:16px 0}", my_ext: "v" });
|
||||
const m = parseSiteConfig(json);
|
||||
|
||||
@@ -39,10 +39,55 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
|
||||
placeholder: "留空默认「专业院系」",
|
||||
tip: "门户右上「专业院系」下拉的文案;仅主站有效(下拉由院系子站生成)。",
|
||||
},
|
||||
{
|
||||
key: "news_bg_image", label: "新闻卡背景图", group: "display", kind: "asset", maxLength: 500,
|
||||
placeholder: "如 /yschcms/upload/…/campus.jpg(留空用主题渐变)",
|
||||
tip: "首页「双栏新闻卡」板块的全幅背景大图(建议 1920×700);仅展示型皮肤(campus-glow)消费。",
|
||||
},
|
||||
{
|
||||
key: "gallery_title", label: "展厅标题", group: "display", kind: "text", maxLength: 50,
|
||||
placeholder: "留空默认「线上展厅」",
|
||||
tip: "首页图片画廊文区头主标题。",
|
||||
},
|
||||
{
|
||||
key: "gallery_subtitle", label: "展厅英文副题", group: "display", kind: "text", maxLength: 100,
|
||||
placeholder: "留空默认 Online Exhibition Hall",
|
||||
tip: "首页图片画廊文区头英文副题。",
|
||||
},
|
||||
{
|
||||
key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true,
|
||||
placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号",
|
||||
},
|
||||
{
|
||||
key: "footer_addresses", label: "多校区地址", group: "footer", kind: "text", maxLength: 500, multiline: true,
|
||||
placeholder: "一行一个校区,如:\nXX校区:杭州市 XX 路 100 号 邮编:310000",
|
||||
tip: "逐行展示在页脚品牌区旁;换行即分行(最多 500 字)。",
|
||||
},
|
||||
{
|
||||
key: "qr_wechat", label: "微信二维码", group: "footer", kind: "asset", maxLength: 500,
|
||||
placeholder: "如 /yschcms/upload/…/qr-wechat.png(留空不展示)",
|
||||
tip: "页脚二维码,悬停放大预览;建议先在「附件管理」上传。",
|
||||
},
|
||||
{
|
||||
key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500,
|
||||
placeholder: "如 /yschcms/upload/…/qr-weibo.png(留空不展示)",
|
||||
},
|
||||
{
|
||||
key: "social_wechat", label: "微信公众号", group: "footer", kind: "url", maxLength: 500,
|
||||
placeholder: "微信公众号主页链接,如 https://mp.weixin.qq.com/…(留空不展示图标)",
|
||||
},
|
||||
{
|
||||
key: "social_weibo", label: "微博", group: "footer", kind: "url", maxLength: 500,
|
||||
placeholder: "微博主页链接 https://weibo.com/…(留空不展示图标)",
|
||||
},
|
||||
{
|
||||
key: "social_douyin", label: "抖音", group: "footer", kind: "url", maxLength: 500,
|
||||
placeholder: "抖音主页链接 https://www.douyin.com/…(留空不展示图标)",
|
||||
},
|
||||
{
|
||||
key: "social_bilibili", label: "哔哩哔哩", group: "footer", kind: "url", maxLength: 500,
|
||||
placeholder: "B 站主页链接 https://space.bilibili.com/…(留空不展示图标)",
|
||||
},
|
||||
{
|
||||
key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200,
|
||||
placeholder: "如:XX学校 · 版权所有 © 2026",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* previewPortalUrl:上级 path + 编码 → 子站门户地址(parentPath 空 → 单段)。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { portalUrlOf, previewPortalUrl } from "@/utils/sitePath";
|
||||
import { portalUrlOf, previewPortalUrl, adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath";
|
||||
|
||||
describe("portalUrlOf(T-W4-16)", () => {
|
||||
it("path 优先:嵌套 path 拼单段门户地址 + 尾斜杠", () => {
|
||||
@@ -27,3 +27,73 @@ describe("previewPortalUrl(T-W4-16)", () => {
|
||||
expect(previewPortalUrl(undefined, "sch-9")).toBe("/portal/sch-9/");
|
||||
});
|
||||
});
|
||||
|
||||
/* ===== docs/13 方案 C:管理端 URL 站点段 ===== */
|
||||
|
||||
describe("adminPathOf(T-D13-01)", () => {
|
||||
it("主站(siteCode 为空/null/等于学校编码)省略站点段——旧链全兼容", () => {
|
||||
expect(adminPathOf("demo", null, "article")).toBe("/sch/demo/article");
|
||||
expect(adminPathOf("demo", "", "article/edit/3")).toBe("/sch/demo/article/edit/3");
|
||||
expect(adminPathOf("demo", "demo", "site")).toBe("/sch/demo/site");
|
||||
});
|
||||
|
||||
it("院系子站插入 /site/{code} 中缀", () => {
|
||||
expect(adminPathOf("demo", "jdxy", "article")).toBe("/sch/demo/site/jdxy/article");
|
||||
expect(adminPathOf("demo", "jdxy", "/category")).toBe("/sch/demo/site/jdxy/category");
|
||||
expect(adminPathOf("demo", "jdxy", "article/edit/9")).toBe("/sch/demo/site/jdxy/article/edit/9");
|
||||
});
|
||||
|
||||
it("空白容错:schoolCode/siteCode 首尾空白 trimmed 后再拼(docs/15 T2)", () => {
|
||||
expect(adminPathOf("demo", " jdxy ", "article")).toBe("/sch/demo/site/jdxy/article");
|
||||
expect(adminPathOf(" demo ", null, "article")).toBe("/sch/demo/article");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripSiteSeg(T-D13-02)", () => {
|
||||
it("剥掉 /site/{code} 中缀,回到菜单匹配用的主站形态", () => {
|
||||
expect(stripSiteSeg("/sch/demo/site/jdxy/article")).toBe("/sch/demo/article");
|
||||
expect(stripSiteSeg("/sch/demo/site/jdxy/article/edit/9")).toBe("/sch/demo/article/edit/9");
|
||||
expect(stripSiteSeg("/sch/demo/site/jdxy")).toBe("/sch/demo");
|
||||
});
|
||||
|
||||
it("主站形态原样返回(幂等)", () => {
|
||||
expect(stripSiteSeg("/sch/demo/article")).toBe("/sch/demo/article");
|
||||
expect(stripSiteSeg(stripSiteSeg("/sch/demo/site/jdxy/category"))).toBe("/sch/demo/category");
|
||||
});
|
||||
});
|
||||
|
||||
describe("schoolSubPath(T-D13-03)", () => {
|
||||
it("取学校壳内子路径(主站/子站形态一致)", () => {
|
||||
expect(schoolSubPath("/sch/demo/article")).toBe("/article");
|
||||
expect(schoolSubPath("/sch/demo/site/jdxy/article/edit/9")).toBe("/article/edit/9");
|
||||
expect(schoolSubPath("/sch/demo")).toBe("");
|
||||
});
|
||||
|
||||
it("非学校壳路径原样返回", () => {
|
||||
expect(schoolSubPath("/workbench")).toBe("/workbench");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRouteSite(T-D13-04:URL 段 → 目标站点)", () => {
|
||||
const sites = [
|
||||
{ id: 1, code: "demo", name: "云海大学门户", parentSiteId: 0 },
|
||||
{ id: 2, code: "jdxy", name: "机电学院", parentSiteId: 1 },
|
||||
{ id: 3, code: "wtxy", name: "文学院", parentSiteId: 1 },
|
||||
];
|
||||
|
||||
it("无 siteCode(主站形态)→ 主站", () => {
|
||||
expect(resolveRouteSite(sites, 1, undefined)).toEqual({ site: sites[0], invalid: false });
|
||||
expect(resolveRouteSite(sites, 1, "")).toEqual({ site: sites[0], invalid: false });
|
||||
});
|
||||
|
||||
it("站点段命中本校子站 → 该子站", () => {
|
||||
expect(resolveRouteSite(sites, 1, "jdxy")).toEqual({ site: sites[1], invalid: false });
|
||||
});
|
||||
|
||||
it("不存在 / 跨校 / 把主站编码当子站段 → invalid 并回主站", () => {
|
||||
expect(resolveRouteSite(sites, 1, "nope")).toEqual({ site: sites[0], invalid: true });
|
||||
expect(resolveRouteSite(sites, 1, "demo")).toEqual({ site: sites[0], invalid: true });
|
||||
const other = [...sites, { id: 9, code: "jdxy2", name: "他校子站", parentSiteId: 8 }];
|
||||
expect(resolveRouteSite(other, 1, "jdxy2")).toEqual({ site: sites[0], invalid: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,3 +15,57 @@ export function previewPortalUrl(parentPath: string | undefined | null, code: st
|
||||
const parent = parentPath && parentPath.trim() ? parentPath.trim() : "";
|
||||
return parent ? `/portal/${parent}/${code}/` : `/portal/${code}/`;
|
||||
}
|
||||
|
||||
/* ===== 管理端路由站点段(docs/13:URL 为站点上下文唯一事实源) ===== */
|
||||
|
||||
/**
|
||||
* 管理端路由 path 唯一拼装入口:主站省略站点段(旧链全兼容),
|
||||
* 子站插入 `/site/{siteCode}` 中缀。siteCode 传 null/空/等于学校主站编码时不带段。
|
||||
* 全站硬拼 `/sch/...` 的跳转都应经此函数,防主站/子站形态混写。
|
||||
*/
|
||||
export function adminPathOf(schCode: string, siteCode: string | null | undefined, sub: string): string {
|
||||
const code = String(schCode ?? "").trim();
|
||||
const tail = String(sub ?? "").replace(/^\/+/, "");
|
||||
const seg = String(siteCode ?? "").trim();
|
||||
const isSub = !!seg && seg !== code;
|
||||
return isSub ? `/sch/${code}/site/${seg}/${tail}` : `/sch/${code}/${tail}`;
|
||||
}
|
||||
|
||||
/** 剥掉 URL 中的 `/site/{code}` 中缀(菜单高亮"剥段匹配"与面包屑取页面子路径用) */
|
||||
export function stripSiteSeg(p: string): string {
|
||||
return String(p ?? "").replace(/\/site\/[^/]+(?=\/|$)/, "");
|
||||
}
|
||||
|
||||
/** 完整管理端 path(主站形态,如 `/sch/demo/article`)取其学校壳内子路径(`/article`);非壳内路径返回原值 */
|
||||
export function schoolSubPath(fullPath: string): string {
|
||||
const p = stripSiteSeg(String(fullPath ?? ""));
|
||||
const m = p.match(/^\/sch\/[^/]+(\/.*)?$/);
|
||||
return m ? m[1] || "" : p;
|
||||
}
|
||||
|
||||
export interface RouteSiteHit {
|
||||
/** 命中的站点(无段=主站;非法段也回主站,配合 invalid 打回 URL) */
|
||||
site: { id: number; code: string; name: string; path?: string; parentSiteId?: number } | null;
|
||||
/** URL 站点段非法/跨校:调用方应打回同页主站形态 */
|
||||
invalid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 站点段 → 目标站点(docs/13 §3 守卫的纯函数核心,便于单测):
|
||||
* - 无 siteCode(主站形态 `/sch/{code}/**`)→ 主站;
|
||||
* - 有 siteCode → 在本校站点中按 code 匹配 **且** parentSiteId 归属本校主站(权限复用 user.sites);
|
||||
* 未命中(不存在/他校/把主站编码当子站段)→ invalid,site 回主站供打回使用。
|
||||
*/
|
||||
export function resolveRouteSite(
|
||||
sites: { id: number; code: string; name: string; path?: string; parentSiteId?: number }[],
|
||||
mainSiteId: number,
|
||||
siteCode?: string | null
|
||||
): RouteSiteHit {
|
||||
const main = sites.find(s => s.id === mainSiteId) || null;
|
||||
const code = String(siteCode ?? "").trim();
|
||||
if (!code) {
|
||||
return { site: main, invalid: false };
|
||||
}
|
||||
const hit = sites.find(s => s.code === code && s.parentSiteId === mainSiteId) || null;
|
||||
return hit ? { site: hit, invalid: false } : { site: main, invalid: true };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import "@wangeditor/editor/dist/css/style.css";
|
||||
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 { portalUrlOf, adminPathOf } from "@/utils/sitePath";
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -190,7 +190,8 @@ async function saveDraft() {
|
||||
if (id) {
|
||||
Message.success("保存成功!");
|
||||
const code = store.schoolCode;
|
||||
router.replace(code ? `/sch/${code}/article/edit/${id}` : "/workbench");
|
||||
// 站点段跟随当前上下文(docs/13):子站编辑页 URL 带 /site/{code}
|
||||
router.replace(code ? adminPathOf(code, store.siteSegCode, `article/edit/${id}`) : "/workbench");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +201,7 @@ async function saveAndPublish() {
|
||||
await articleApi.publish(store.siteId!, id).send();
|
||||
Message.success("已提交发布!");
|
||||
const code = store.schoolCode;
|
||||
router.replace(code ? `/sch/${code}/article` : "/workbench");
|
||||
router.replace(code ? adminPathOf(code, store.siteSegCode, "article") : "/workbench");
|
||||
}
|
||||
|
||||
/** 预览:仅已发布文章在门户可见 */
|
||||
@@ -330,12 +331,17 @@ watch(() => store.siteId, () => load());
|
||||
border-bottom: 1px solid #dcdee2;
|
||||
}
|
||||
.editor-content {
|
||||
// wangEditor v5 高度必须打在内层 .w-e-text-container(外层高度会被内部 100% 链路吞掉),
|
||||
// Notice NewNotice.vue 同款修复(D6-5);视口自适应、下限 480px,430≈壳层(56+56)+表单区高度
|
||||
// 长图文显示不全修复(2026-09-10):旧做法把 .w-e-text-container 钉在
|
||||
// max(480px, 100vh-430px) 固定高,长文被关进内滚窗口且 Windows overlay
|
||||
// 滚动条不常显,正文尾部看似被截断。改为容器高度随内容自动增长,
|
||||
// 整篇平铺进页面由窗口滚动;空态/短文用 slate 区 min-height 铺满视口余量
|
||||
//(430≈壳层(56+56)+表单区高度,下限 480px)。
|
||||
:deep(.w-e-text-container) {
|
||||
height: max(480px, calc(100vh - 430px)) !important;
|
||||
height: auto !important;
|
||||
}
|
||||
:deep([data-slate-editor]) {
|
||||
min-height: max(480px, calc(100vh - 430px)) !important;
|
||||
}
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
@@ -20,6 +20,14 @@ const linkTypeDict = [
|
||||
];
|
||||
const linkTypeText = (v: number) => linkTypeDict.find(i => i.value === v)?.label || "-";
|
||||
|
||||
/** 展示位(docs/12):home=首页轮播 / gallery=线上展厅图片画廊 */
|
||||
const position = ref("home");
|
||||
const positionDict = [
|
||||
{ value: "home", label: "首页轮播", tip: "轮播图设计尺寸建议 1920×560(皮肤包约定)" },
|
||||
{ value: "gallery", label: "线上展厅", tip: "画廊图片设计尺寸建议 800×520,多图横向轮播(炫彩校园皮肤消费)" },
|
||||
];
|
||||
const positionTip = computed(() => positionDict.find(p => p.value === position.value)?.tip || "");
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "图片",
|
||||
@@ -94,7 +102,7 @@ async function run() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = (await bannerApi.list(store.siteId, "home").send()) || [];
|
||||
rows.value = (await bannerApi.list(store.siteId, position.value).send()) || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -151,7 +159,7 @@ async function save() {
|
||||
.save({
|
||||
id: form.id,
|
||||
siteId: store.siteId!,
|
||||
position: "home",
|
||||
position: position.value,
|
||||
title: form.title,
|
||||
imageUrl: form.imageUrl,
|
||||
linkType: form.linkType,
|
||||
@@ -184,19 +192,23 @@ function doDelete(row: BannerVo) {
|
||||
|
||||
onMounted(run);
|
||||
|
||||
// 站点上下文切换即时重载(docs/09 §3.3)
|
||||
// 站点上下文切换即时重载(docs/09 §3.3);展示位切换即时重载(docs/12)
|
||||
watch(() => store.siteId, () => run());
|
||||
watch(position, () => run());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix">
|
||||
<RadioGroup v-model="position" class="fl position-switch" type="button">
|
||||
<Radio v-for="p in positionDict" :key="p.value" :label="p.value">{{ p.label }}</Radio>
|
||||
</RadioGroup>
|
||||
<Button class="fl" type="primary" size="large" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
新增轮播
|
||||
</Button>
|
||||
<span class="size-tip fr">轮播图设计尺寸建议 1920×560(皮肤包约定)</span>
|
||||
<span class="size-tip fr">{{ positionTip }}</span>
|
||||
</div>
|
||||
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无轮播" />
|
||||
|
||||
@@ -264,6 +276,10 @@ watch(() => store.siteId, () => run());
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.position-switch {
|
||||
margin-right: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.size-tip {
|
||||
color: #999;
|
||||
line-height: 40px;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, useTemplateRef, watch } from "vue";
|
||||
import { computed, nextTick, reactive, ref, shallowRef, useTemplateRef, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import CategoryTreePanel from "@/components/CategoryTreePanel.vue";
|
||||
import CategorySortModal from "@/components/CategorySortModal.vue";
|
||||
import BatchAddCategory from "@/components/BatchAddCategory.vue";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
|
||||
import { categoryApi } from "@/api/module/cms";
|
||||
@@ -11,9 +14,10 @@ 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";
|
||||
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
|
||||
|
||||
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除) */
|
||||
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除)。
|
||||
B5 栏目工作台:右侧类型化面板(列表=文章表格 / 单页=富文本预览 / 外链=直达)。 */
|
||||
const store = useCmsStore();
|
||||
|
||||
const refTreePanel = useTemplateRef("refTreePanel");
|
||||
@@ -29,12 +33,23 @@ const typeDict = [
|
||||
{ value: 2, label: "单页" },
|
||||
{ value: 3, label: "外链" },
|
||||
];
|
||||
const typeLabel = (t?: number) => typeDict.find(i => i.value === t)?.label || "未知";
|
||||
|
||||
/** 首页展示形态(docs/12;仅列表型栏目有意义,空=主题默认卡) */
|
||||
const homeStyleDict = [
|
||||
{ value: "", label: "默认卡片" },
|
||||
{ value: "news_grid", label: "双栏新闻卡" },
|
||||
{ value: "media_focus", label: "媒体聚焦列表" },
|
||||
];
|
||||
|
||||
const sortModal = ref(false);
|
||||
const sortParentId = ref<number | null>(null);
|
||||
const sortParentName = ref("");
|
||||
const sortChildrenSource = ref<any[]>([]);
|
||||
|
||||
const batchModal = ref(false);
|
||||
const batchDefaultType = ref<number | null>(null);
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
@@ -46,6 +61,7 @@ const form = reactive({
|
||||
type: 1,
|
||||
linkUrl: "",
|
||||
content: "",
|
||||
homeStyle: "",
|
||||
sortNo: 0,
|
||||
seoTitle: "",
|
||||
seoDescription: "",
|
||||
@@ -55,6 +71,7 @@ const form = reactive({
|
||||
const rules = {
|
||||
name: [{ required: true, message: "栏目名称不得为空", trigger: "blur" }],
|
||||
type: [{ required: true, type: "number", message: "请选择类型", trigger: "change" }],
|
||||
linkUrl: [{ required: true, message: "外链地址不得为空", trigger: "blur" }],
|
||||
};
|
||||
|
||||
/** 根节点 label(W2 虚拟根,D5):主站=学校名(缺省回退 mainSiteName),子站=子站名;随站点上下文响应 */
|
||||
@@ -95,6 +112,32 @@ function findNode(list: any[], id: number): any {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** target 是否 node 自身或其子孙(上级下拉排除,防把栏目挂到自己子树下成环) */
|
||||
function isSelfOrDescendant(node: any, targetId: number | undefined): boolean {
|
||||
if (!node || targetId == null) return false;
|
||||
if (node.id === targetId) return true;
|
||||
return (node.children || []).some((c: any) => isSelfOrDescendant(c, targetId));
|
||||
}
|
||||
|
||||
/** 上级栏目选项:虚拟根(0=rootLabel)+ 真实树拍平缩进;编辑态禁选自身及后代(后端仅防一级成环) */
|
||||
const parentOptions = computed(() => {
|
||||
const list: { id: number; label: string; disabled: boolean }[] = [
|
||||
{ id: 0, label: rootLabel.value || "根栏目", disabled: false },
|
||||
];
|
||||
const walk = (nodes: any[], depth: number) => {
|
||||
(nodes || []).forEach((n: any) => {
|
||||
list.push({
|
||||
id: n.id,
|
||||
label: `${" ".repeat(depth)}${n.name}`,
|
||||
disabled: isSelfOrDescendant(n, form.id),
|
||||
});
|
||||
walk(n.children || [], depth + 1);
|
||||
});
|
||||
};
|
||||
walk(treeData.value[0]?.children || [], 1);
|
||||
return list;
|
||||
});
|
||||
|
||||
function currentNode() {
|
||||
return selected.value || {};
|
||||
}
|
||||
@@ -110,6 +153,7 @@ async function showAddNode() {
|
||||
form.type = 1;
|
||||
form.linkUrl = "";
|
||||
form.content = "";
|
||||
form.homeStyle = "";
|
||||
form.sortNo = 0;
|
||||
form.seoTitle = "";
|
||||
form.seoDescription = "";
|
||||
@@ -132,6 +176,7 @@ function showEditNode() {
|
||||
type: node.type ?? 1,
|
||||
linkUrl: node.linkUrl || "",
|
||||
content: node.content || "",
|
||||
homeStyle: node.homeStyle || "",
|
||||
sortNo: node.sortNo ?? 0,
|
||||
seoTitle: node.seoTitle || "",
|
||||
seoDescription: node.seoDescription || "",
|
||||
@@ -186,6 +231,7 @@ async function save() {
|
||||
type: form.type,
|
||||
linkUrl: form.linkUrl || undefined,
|
||||
content: form.content || undefined,
|
||||
homeStyle: form.type === 1 && form.homeStyle ? form.homeStyle : undefined,
|
||||
sortNo: form.sortNo,
|
||||
seoTitle: form.seoTitle || undefined,
|
||||
seoDescription: form.seoDescription || undefined,
|
||||
@@ -204,17 +250,84 @@ function onSortSaved() {
|
||||
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
|
||||
}
|
||||
|
||||
/* ===== B5 栏目工作台:右侧类型化内容面板 ===== */
|
||||
|
||||
/** 单页摘要:去 HTML 标签截断展示 */
|
||||
function summaryText(node: any): string {
|
||||
const text = String(node?.content || "").replace(/<[^>]+>/g, "").trim();
|
||||
return text.length > 200 ? text.slice(0, 200) + "…" : text || "(暂无内容,点击「编辑内容」补充)";
|
||||
/** 批量添加完成:刷新树并定位到最后创建成功的节点 */
|
||||
function onBatchSuccess(lastSavedId?: number) {
|
||||
refTreePanel.value?.refreshTree(lastSavedId ?? curTreeNode.value?.id);
|
||||
}
|
||||
|
||||
/** 新建文章:带 categoryId 预选跳编辑页(ArticleEdit onMounted 回填) */
|
||||
/** 新增模态框 → 批量添加(UserBase switchToBatch 同款,仅新增态开放) */
|
||||
function switchToBatch() {
|
||||
batchDefaultType.value = form.type;
|
||||
modalVisible.value = false;
|
||||
batchModal.value = true;
|
||||
}
|
||||
|
||||
/* ===== 单页富文本编辑(wangEditor,配置对齐 ArticleEdit;贴图 scene=category 标记引用) ===== */
|
||||
const editorRef = shallowRef();
|
||||
const editorReady = ref(false);
|
||||
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
const editorConfig = {
|
||||
placeholder: "请输入单页内容,支持图文混排……",
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("siteId", String(store.siteId));
|
||||
// scene=category:单页贴图计入栏目引用,附件管理不计为「未使用」(后端白名单)
|
||||
fd.append("scene", "category");
|
||||
try {
|
||||
const resp = await fetch(`${serviceBase}/api/attachment/upload`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res.code === 0 && res.data) {
|
||||
insertFn(res.data.url, res.data.fileName || "", res.data.url);
|
||||
} else {
|
||||
Message.error(res.msg || "图片上传失败");
|
||||
}
|
||||
} catch {
|
||||
Message.error("图片上传失败");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor;
|
||||
}
|
||||
|
||||
// wangEditor 不能在 display:none 容器内创建(宽度塌陷):模态框展开动画结束后再挂载,
|
||||
// 关闭即卸载销毁(@wangeditor/editor-for-vue 组件卸载时自动 destroy)
|
||||
watch(modalVisible, v => {
|
||||
if (v) {
|
||||
editorReady.value = false;
|
||||
setTimeout(() => {
|
||||
editorReady.value = form.type === 2;
|
||||
}, 320);
|
||||
} else {
|
||||
editorReady.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* ===== B5 栏目工作台:右侧类型化内容面板 ===== */
|
||||
|
||||
/** 单页正文纯文本(字数统计/空态判断用) */
|
||||
function plainText(html: string): string {
|
||||
return String(html || "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** 新建文章:带 categoryId 预选跳编辑页(ArticleEdit onMounted 回填);站点段跟随当前上下文 */
|
||||
function goNewArticle(categoryId: number) {
|
||||
window.location.hash = `#/sch/${store.schoolCode}/article/edit?categoryId=${categoryId}`;
|
||||
const code = store.schoolCode;
|
||||
if (!code) return;
|
||||
window.location.hash = `#${adminPathOf(code, store.siteSegCode, "article/edit")}?categoryId=${categoryId}`;
|
||||
}
|
||||
|
||||
/** 门户预览:新开 /portal/{path}/c/{id}(W4 嵌套路径) */
|
||||
@@ -277,7 +390,7 @@ function openLink(url: string) {
|
||||
<div class="panel-title">
|
||||
<h3>{{ selected.name }}</h3>
|
||||
<Tag :color="selected.type === 1 ? 'primary' : selected.type === 2 ? 'success' : 'warning'">
|
||||
{{ typeDict.find(t => t.value === selected!.type)?.label }}
|
||||
{{ typeLabel(selected.type) }}
|
||||
</Tag>
|
||||
<span class="panel-meta">排序 {{ selected.sortNo }} · {{ selected.status === 1 ? "显示" : "隐藏" }}</span>
|
||||
</div>
|
||||
@@ -302,12 +415,25 @@ function openLink(url: string) {
|
||||
v-if="selected.type === 1"
|
||||
class="panel-body"
|
||||
:category-id="selected.id"
|
||||
:extra-height="78"
|
||||
fixed-category
|
||||
/>
|
||||
|
||||
<!-- 单页型:内容摘要 + 编辑入口 -->
|
||||
<!-- 单页型:正文渲染预览 + 元信息 + 编辑入口 -->
|
||||
<div v-else-if="selected.type === 2" class="single-summary">
|
||||
<p class="summary-text">{{ summaryText(selected) }}</p>
|
||||
<div class="summary-meta">
|
||||
<span>共 {{ plainText(selected.content).length }} 字</span>
|
||||
<span>{{ selected.status === 1 ? "已显示" : "已隐藏" }}</span>
|
||||
</div>
|
||||
<!-- content 入库前经后端 jsoup 白名单清洗,回显渲染安全 -->
|
||||
<div v-if="plainText(selected.content)" class="rich-preview" v-html="selected.content"></div>
|
||||
<div v-else class="summary-empty">
|
||||
<p>暂无内容</p>
|
||||
<Button type="primary" ghost @click="showEditNode">
|
||||
<Icon type="ios-create" />
|
||||
去编写
|
||||
</Button>
|
||||
</div>
|
||||
<div class="summary-actions">
|
||||
<Button type="primary" @click="showEditNode">
|
||||
<Icon type="ios-create" />
|
||||
@@ -342,11 +468,16 @@ function openLink(url: string) {
|
||||
v-model="modalVisible"
|
||||
:title="form.id ? '编辑栏目' : '新增子栏目'"
|
||||
:mask-closable="false"
|
||||
width="560px"
|
||||
width="640px"
|
||||
>
|
||||
<Form ref="refFormData" :model="form" :rules="rules" :label-width="100">
|
||||
<FormItem label="上级栏目">
|
||||
<Input :value="form.parentId === 0 ? '根栏目' : '当前选中栏目下'" disabled />
|
||||
<Divider size="small" orientation="left">基本信息</Divider>
|
||||
<FormItem label="上级栏目" prop="parentId">
|
||||
<Select v-model="form.parentId" transfer>
|
||||
<Option v-for="opt in parentOptions" :key="opt.id" :value="opt.id" :disabled="opt.disabled">
|
||||
{{ opt.label }}
|
||||
</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="栏目名称" prop="name">
|
||||
<Input ref="refNodeNameInput" v-model="form.name" :maxlength="64" placeholder="栏目名称" />
|
||||
@@ -356,34 +487,66 @@ function openLink(url: string) {
|
||||
<Radio v-for="t in typeDict" :key="t.value" :label="t.value">{{ t.label }}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem v-if="form.type === 3" label="外链地址" prop="linkUrl">
|
||||
<Input v-model="form.linkUrl" :maxlength="500" placeholder="http(s):// 开头" />
|
||||
</FormItem>
|
||||
<FormItem v-if="form.type === 2" label="单页内容">
|
||||
<Input v-model="form.content" type="textarea" :rows="8" placeholder="单页 HTML 内容(保存时自动做安全清洗)" />
|
||||
</FormItem>
|
||||
<FormItem label="排序">
|
||||
<InputNumber v-model="form.sortNo" :min="0" />
|
||||
</FormItem>
|
||||
<FormItem label="SEO 标题">
|
||||
<Input v-model="form.seoTitle" :maxlength="200" placeholder="空=用栏目名" />
|
||||
</FormItem>
|
||||
<FormItem label="SEO 描述">
|
||||
<Input v-model="form.seoDescription" type="textarea" :rows="2" :maxlength="500" />
|
||||
</FormItem>
|
||||
<FormItem label="状态">
|
||||
<RadioGroup v-model="form.status">
|
||||
<Radio :label="1">显示</Radio>
|
||||
<Radio :label="0">隐藏</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
|
||||
<Divider size="small" orientation="left">内容设置</Divider>
|
||||
<FormItem v-if="form.type === 3" label="外链地址" prop="linkUrl">
|
||||
<Input v-model="form.linkUrl" :maxlength="500" placeholder="http(s):// 开头" />
|
||||
</FormItem>
|
||||
<FormItem v-if="form.type === 2" label="单页内容">
|
||||
<div class="editor-wrap">
|
||||
<template v-if="editorReady">
|
||||
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
||||
<Editor
|
||||
class="editor-content"
|
||||
v-model="form.content"
|
||||
:defaultConfig="editorConfig"
|
||||
mode="default"
|
||||
@onCreated="handleCreated"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="editor-placeholder">编辑器加载中……</div>
|
||||
</div>
|
||||
</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>
|
||||
<FormItem label="SEO 标题">
|
||||
<Input v-model="form.seoTitle" :maxlength="200" placeholder="空=用栏目名" />
|
||||
</FormItem>
|
||||
<FormItem label="SEO 描述">
|
||||
<Input v-model="form.seoDescription" type="textarea" :rows="2" :maxlength="500" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button v-if="!form.id" type="link" @click="switchToBatch">切换至批量添加</Button>
|
||||
<Button @click="modalVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="save">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 批量添加栏目(UserBase BatchAddGroup 同款:一行一个名称,前端循环调 save) -->
|
||||
<BatchAddCategory
|
||||
v-model:visible="batchModal"
|
||||
:default-pid="curTreeNode?.id ?? 0"
|
||||
:parent-name="curTreeNode?.id ? curTreeNode?.name : rootLabel"
|
||||
:default-type="batchDefaultType"
|
||||
@success="onBatchSuccess"
|
||||
/>
|
||||
|
||||
<!-- 子栏目排序(userbase GroupSort 同款拖拽) -->
|
||||
<CategorySortModal
|
||||
v-model:visible="sortModal"
|
||||
@@ -396,6 +559,13 @@ function openLink(url: string) {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* UserBase group/index 同款工具栏样式 */
|
||||
.opera-ul {
|
||||
display: flex;
|
||||
@@ -441,18 +611,6 @@ function openLink(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
p {
|
||||
color: #555;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== B5 栏目工作台右侧面板 ===== */
|
||||
.workbench-main {
|
||||
display: flex;
|
||||
@@ -469,6 +627,7 @@ function openLink(url: string) {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
@@ -510,18 +669,53 @@ function openLink(url: string) {
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
|
||||
.summary-text {
|
||||
color: #333;
|
||||
.summary-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rich-preview {
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin-bottom: 14px;
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
border-collapse: collapse;
|
||||
|
||||
td,
|
||||
th {
|
||||
border: 1px solid #ccc;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.summary-empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px 0;
|
||||
|
||||
p {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.seo-line {
|
||||
@@ -545,6 +739,32 @@ function openLink(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 单页富文本编辑器(模态框内):wangEditor v5 高度打在内层 .w-e-text-container */
|
||||
.editor-wrap {
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
z-index: 100;
|
||||
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid #dcdee2;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
:deep(.w-e-text-container) {
|
||||
height: 320px !important;
|
||||
}
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.editor-placeholder {
|
||||
height: 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
color: #999;
|
||||
text-align: center;
|
||||
|
||||
+143
-33
@@ -21,7 +21,41 @@ const sites = ref<SiteVo[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
/** 当前查看的文章(点列表进入详情态) */
|
||||
const currentArticle = ref<{ title: string; content: string; publishTime?: string; source?: string } | null>(null);
|
||||
const currentArticle = ref<{ categoryId?: number; title: string; content: string; publishTime?: string; source?: string } | null>(null);
|
||||
|
||||
/** 自栏目树求 根→目标 路径(仅启用节点);查不到返回 null */
|
||||
function findCategoryPath(tree: CategoryTreeNode[], id: number): CategoryTreeNode[] | null {
|
||||
for (const root of tree) {
|
||||
if (root.id === id) return [root];
|
||||
if (!root.children?.length) continue;
|
||||
const sub = findCategoryPath(root.children, id);
|
||||
if (sub) return [root, ...sub];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 左侧栏目导航(docs/11 同款规则):模块有二级子栏目时,栏目/文章页左侧
|
||||
* 展示"模块名 + 全部二级兄弟"。详情态按文章所属栏目定位并高亮;列表态为
|
||||
* 全站混合列表无具体栏目,取第一个有二级子栏目的模块做形态演示(无高亮)。
|
||||
*/
|
||||
const sideNav = computed(() => {
|
||||
const enabled = categories.value.filter(c => c.status === 1);
|
||||
let path: CategoryTreeNode[] | null = null;
|
||||
const currentCatId = currentArticle.value?.categoryId;
|
||||
if (currentCatId != null) {
|
||||
path = findCategoryPath(enabled, currentCatId);
|
||||
}
|
||||
if (!path) {
|
||||
const demo = enabled.find(c => c.children?.length);
|
||||
path = demo ? [demo] : null;
|
||||
}
|
||||
if (!path || !path[0].children?.length) return null;
|
||||
const top = path[0];
|
||||
const activeId = path.length === 2 ? path[1].id
|
||||
: path.length > 2 ? path[1].id : null;
|
||||
return { top, items: top.children.filter(c => c.status === 1), activeId };
|
||||
});
|
||||
|
||||
/** 轮播 */
|
||||
const bannerIndex = ref(0);
|
||||
@@ -92,6 +126,7 @@ async function openArticle(item: ArticlePageItem) {
|
||||
const detail = await articleApi.get(store.siteId!, item.id).send();
|
||||
if (detail) {
|
||||
currentArticle.value = {
|
||||
categoryId: item.categoryId,
|
||||
title: detail.title,
|
||||
content: detail.content || "<p>(无正文)</p>",
|
||||
publishTime: detail.publishTime,
|
||||
@@ -117,7 +152,7 @@ onBeforeUnmount(stopCarousel);
|
||||
|
||||
<template>
|
||||
<div class="portal-preview">
|
||||
<div class="portal-tip">门户预览(简版):正式门户由后端 Thymeleaf 渲染于 /portal/{code}/</div>
|
||||
<div class="portal-tip">门户预览(简版):正式门户由后端 Thymeleaf 渲染于 /portal/{code}/;有二级栏目的模块,其栏目页/文章页左侧展示兄弟栏目导航(此处同步演示)</div>
|
||||
|
||||
<!-- 门户头部 -->
|
||||
<header class="portal-header">
|
||||
@@ -169,39 +204,55 @@ onBeforeUnmount(stopCarousel);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章详情态 -->
|
||||
<div class="article-detail" v-if="currentArticle">
|
||||
<a class="back-link" href="javascript:;" @click="backToList">← 返回列表</a>
|
||||
<h1>{{ currentArticle.title }}</h1>
|
||||
<p class="meta">
|
||||
发布时间:{{ currentArticle.publishTime || "-" }}
|
||||
<template v-if="currentArticle.source"> 来源:{{ currentArticle.source }}</template>
|
||||
</p>
|
||||
<div class="article-content" v-html="currentArticle.content"></div>
|
||||
</div>
|
||||
<div class="content-cols">
|
||||
<!-- 左侧栏目导航(docs/11 同款规则):有二级子栏目的模块展示"模块名 + 全部兄弟" -->
|
||||
<aside class="module-nav" v-if="sideNav">
|
||||
<div class="module-nav-head">{{ sideNav.top.name }}</div>
|
||||
<ul>
|
||||
<li v-for="s in sideNav.items" :key="s.id" :class="{ on: s.id === sideNav.activeId }">
|
||||
<a
|
||||
:href="s.type === 3 ? s.linkUrl : 'javascript:;'"
|
||||
:target="s.type === 3 ? '_blank' : undefined"
|
||||
rel="noopener noreferrer"
|
||||
>{{ s.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- 列表态 -->
|
||||
<div class="portal-columns" v-else>
|
||||
<div class="main-col">
|
||||
<div class="col-head"><h2>文章列表</h2><span>仅展示「已发布」文章,置顶优先</span></div>
|
||||
<ul class="article-list">
|
||||
<li v-for="a in articles" :key="a.id">
|
||||
<a href="javascript:;" @click="openArticle(a)">
|
||||
<em v-if="a.topmost === 1">[置顶]</em>{{ a.title }}
|
||||
</a>
|
||||
<span class="date">{{ (a.publishTime || "").slice(0, 10) }}</span>
|
||||
</li>
|
||||
<li v-if="!articles.length" class="empty">该站点暂无已发布文章</li>
|
||||
</ul>
|
||||
<!-- 文章详情态 -->
|
||||
<div class="article-detail" v-if="currentArticle">
|
||||
<a class="back-link" href="javascript:;" @click="backToList">← 返回列表</a>
|
||||
<h1>{{ currentArticle.title }}</h1>
|
||||
<p class="meta">
|
||||
发布时间:{{ currentArticle.publishTime || "-" }}
|
||||
<template v-if="currentArticle.source"> 来源:{{ currentArticle.source }}</template>
|
||||
</p>
|
||||
<div class="article-content" v-html="currentArticle.content"></div>
|
||||
</div>
|
||||
<div class="side-col">
|
||||
<div class="col-head"><h2>栏目导航</h2></div>
|
||||
<ul class="cat-list">
|
||||
<li v-for="c in topNav" :key="c.id">
|
||||
{{ c.name }}
|
||||
<em>{{ c.children?.length ? `${c.children.length} 个子栏目` : { 1: "文章列表", 2: "单页", 3: "外链" }[c.type] }}</em>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 列表态 -->
|
||||
<div class="portal-columns" v-else>
|
||||
<div class="main-col">
|
||||
<div class="col-head"><h2>文章列表</h2><span>仅展示「已发布」文章,置顶优先</span></div>
|
||||
<ul class="article-list">
|
||||
<li v-for="a in articles" :key="a.id">
|
||||
<a href="javascript:;" @click="openArticle(a)">
|
||||
<em v-if="a.topmost === 1">[置顶]</em>{{ a.title }}
|
||||
</a>
|
||||
<span class="date">{{ (a.publishTime || "").slice(0, 10) }}</span>
|
||||
</li>
|
||||
<li v-if="!articles.length" class="empty">该站点暂无已发布文章</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="side-col">
|
||||
<div class="col-head"><h2>栏目导航</h2></div>
|
||||
<ul class="cat-list">
|
||||
<li v-for="c in topNav" :key="c.id">
|
||||
{{ c.name }}
|
||||
<em>{{ c.children?.length ? `${c.children.length} 个子栏目` : { 1: "文章列表", 2: "单页", 3: "外链" }[c.type] }}</em>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -388,6 +439,65 @@ onBeforeUnmount(stopCarousel);
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
/* 左侧栏目导航 + 内容两栏(docs/11 同款形态) */
|
||||
.content-cols {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
> .portal-columns,
|
||||
> .article-detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.module-nav {
|
||||
flex: none;
|
||||
width: 200px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-top: 3px solid #2d8cf0;
|
||||
|
||||
.module-nav-head {
|
||||
background: #2d8cf0;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
padding: 14px 10px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li + li {
|
||||
border-top: 1px dashed #eee;
|
||||
}
|
||||
|
||||
li a {
|
||||
display: block;
|
||||
padding: 10px 14px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
background: #f2f6fc;
|
||||
}
|
||||
}
|
||||
|
||||
li.on a {
|
||||
background: #2d8cf0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-box {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { schoolApi, siteApi, themeApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { SchoolCard, SchoolOption, ThemeVo } from "@/types/cms";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
|
||||
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
||||
import WorkbenchHeader from "@/components/WorkbenchHeader.vue";
|
||||
|
||||
@@ -56,18 +56,19 @@ function enter(card: SchoolCard) {
|
||||
(s: any) => s.id === card.mainSiteId || s.parentSiteId === card.mainSiteId
|
||||
)
|
||||
);
|
||||
router.push(`/sch/${card.mainSiteCode}/article`);
|
||||
// 进入学校 = 主站形态(docs/13:URL 没写站点段就是主站)
|
||||
router.push(adminPathOf(card.mainSiteCode, null, "article"));
|
||||
}
|
||||
|
||||
function openPortal(card: SchoolCard) {
|
||||
window.open(`${config.serviceHost}${portalUrlOf({ path: card.mainSitePath, code: card.mainSiteCode })}`, "_blank");
|
||||
}
|
||||
|
||||
/** 超管全局站点视图(借道当前/第一个学校上下文 + scope=all) */
|
||||
/** 超管全局站点视图(借道当前/第一个学校上下文 + scope=all):跨校总览落主站形态 */
|
||||
function goAllSites() {
|
||||
const code = store.schoolCode || cards.value[0]?.mainSiteCode;
|
||||
if (code) {
|
||||
router.push({ path: `/sch/${code}/site`, query: { scope: "all" } });
|
||||
router.push({ path: adminPathOf(code, null, "site"), query: { scope: "all" } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import { assetUrl, siteApi, themeApi } from "@/api/module/cms";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||
@@ -132,6 +132,8 @@ const form = reactive({
|
||||
});
|
||||
const editThemeParams = ref<FormModelValue>({});
|
||||
const editTheme = computed(() => themeMeta.value[form.theme]);
|
||||
/** 当前主题是否未注册(未注册保留原值但告警,保存将被服务端拒绝) */
|
||||
const themeUnregistered = ref(false);
|
||||
|
||||
/** B3 自定义样式(config_json.custom_css):面板为唯一编辑入口,SiteConfigForm 侧该键为保留键 */
|
||||
const customCss = ref("");
|
||||
@@ -279,7 +281,8 @@ function openPortal(row: SiteVo) {
|
||||
window.open(`${config.serviceHost}${portalUrlOf(row)}`, "_blank");
|
||||
}
|
||||
|
||||
/** 行级「进入管理」(docs/09 §3.3 子站管理入口):切当前操作站点后落文章列表;
|
||||
/** 行级「进入管理」(docs/13 §5):跳 URL 即完成站点切换——主站形态 /article,
|
||||
* 院系子站带 /site/{code} 段,store 由 SchoolLayout.syncSiteFromRoute 按 URL 同步;
|
||||
* 全局视图跨校行先重建学校上下文(面包屑/左侧菜单跟随),子站行向上找主站行定学校壳 */
|
||||
function enterManage(row: SiteVo) {
|
||||
const main = row.parentSiteId ? rows.value.find(s => s.id === row.parentSiteId) : row;
|
||||
@@ -299,8 +302,7 @@ function enterManage(row: SiteVo) {
|
||||
rows.value.filter(s => s.id === main.id || s.parentSiteId === main.id)
|
||||
);
|
||||
}
|
||||
store.setCurrentSite({ id: row.id, code: row.code, path: row.path || row.code, name: row.name });
|
||||
router.push(`/sch/${main.code}/article`);
|
||||
router.push(adminPathOf(main.code, row.parentSiteId ? row.code : null, "article"));
|
||||
}
|
||||
|
||||
function openEditPreview() {
|
||||
|
||||
@@ -323,17 +323,19 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 预览弹窗(复用 ThemePreviewFrame,内容源下拉同向导) -->
|
||||
<Modal v-model="previewVisible" :title="`预览皮肤:${previewTheme?.title || ''}`" width="860px">
|
||||
<div class="preview-source">
|
||||
<label>内容源站点</label>
|
||||
<Select :model-value="previewSitePath" style="width: 280px" @on-change="onContentSourceChange">
|
||||
<Option v-for="s in contentSources" :key="s.id" :value="s.path || s.code">
|
||||
{{ s.name }}({{ s.path || s.code }})
|
||||
</Option>
|
||||
</Select>
|
||||
<!-- 预览弹窗(全屏展示,复用 ThemePreviewFrame,内容源下拉同向导) -->
|
||||
<Modal v-model="previewVisible" :title="`预览皮肤:${previewTheme?.title || ''}`" fullscreen footer-hide>
|
||||
<div class="preview-full">
|
||||
<div class="preview-source">
|
||||
<label>内容源站点</label>
|
||||
<Select :model-value="previewSitePath" style="width: 280px" @on-change="onContentSourceChange">
|
||||
<Option v-for="s in contentSources" :key="s.id" :value="s.path || s.code">
|
||||
{{ s.name }}({{ s.path || s.code }})
|
||||
</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<ThemePreviewFrame fill :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" />
|
||||
</div>
|
||||
<ThemePreviewFrame :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" />
|
||||
</Modal>
|
||||
|
||||
<!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema) -->
|
||||
@@ -378,6 +380,12 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
||||
overflow-y: auto;
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
/* 全屏预览:弹窗 body 高度确定(top:51px/bottom:0),内容源行占自然高、预览框吃满余量 */
|
||||
.preview-full {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.preview-source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user