Compare commits

..
2 Commits
Author SHA1 Message Date
chenxing 31cbec152a feat(category): S9 内容管理 IA 切分——栏目页降级为内容概览 + 首页形态误导文案修正
build / build (push) Successful in 2m20s
- 新建 components/category/CategoryContentSummary.vue(194):列表型栏目右侧不再挂文章表格,
  改「共 N 篇 + 最近 5 行 + 查看全部」概览;计数口径=直属+子孙(与门户 collectListDescendantIds
  同语义)、全状态计入,一次 page 请求同时拿 total 与 5 行(零新接口、零后端改动)
- CategoryManage.vue 列表分支换组件 + 头注更新;编辑弹窗「首页展示形态」提示改为
  「展示形态由当前皮肤包的主题化实现决定,未实现的形态回落通用骨架渲染」(v2 §七 验收第 8 条点名项)
- ArticleListPanel.vue +7:setup 读 route.query.categoryId 作为初始筛选(深链回显)
- types/cms.ts 1 行:ArticlePageQuery.categoryIds 放宽为 number|string(逗号串),手册未点名、已在
  audit-D §六 披露为偏差
- categoryWorkbench.spec.ts 15→17 例:T-D-01 按裁决改写旧断言(概览而非表格,并机器证明无操作列)、
  T-D-02 深链回显、T-D-03 查看全部跳转;夹具上提 + beforeEach 复位,默认值与旧硬编码行为逐字一致
- 未动 router/routes.ts 的 meta.subOf(护栏 siteRouter.spec 8 / siteLayer.spec 16 绿)
- 遗留立案:R-43 fixedCategory/extraHeight 无生产调用方(按纪律未删)/ R-44 概览含子孙而深链只筛直属
  需产品定口径 / R-45 菜单名来自平台模块表 / R-46 ?categoryId 仅读一次
- 前端 166/166 绿 + build:base exit=0;S10 复跑同绿
2026-09-22 21:55:23 +08:00
chenxing 5d048abdae feat(site-layer): S8 站点上下文卡 + 子站身份染色(方案 A,零后端改动)
- 新建 components/SiteContextCard.vue(194):数据源 store.schoolSites(含 SiteVo.theme),
  展示学校›站点层级与当前站皮肤包名;切站沿用既有纯 URL 通道 adminPathOf,不新增状态机
- SchoolLayout.vue 挂载卡片到原空 .menu-top-box 槽 + 子站态身份染色(侧栏淡底 #fff7ec、
  卡区顶 3px 橙条、页头胶囊转橙);主站态不染色
- 硬约束:未动 utils/sitePath.ts 的 stripSiteSeg/withSiteSeg(剥段高亮全靠它)、未动 router/routes.ts
- siteLayer.spec.ts +4 例(纯加,SITES 夹具补 theme 字段);护栏 siteRouter.spec(8)/sitePath.spec(14)/
  menuRoute.spec(5) 原样绿
- 遗留立案:R-39 isAlone 档不可见 / R-40 门户地址未展示 / R-41 面包屑是否退役 SiteSwitcher 待裁决 /
  R-42 显示包 code 而非中文名
- 前端 164/164 绿 + build:base exit=0
2026-09-22 21:54:58 +08:00
8 changed files with 641 additions and 30 deletions
+98 -12
View File
@@ -1,11 +1,12 @@
// @vitest-environment jsdom
/**
* B5 栏目工作台组件行为(方案 P1-2):
* ① 列表型栏目 → 右侧 ArticleListPanel 且 categoryId 正确传递;
* ② 切换树节点 → 面板 watch categoryId 联动 reload;
* B5 栏目工作台组件行为(方案 P1-2)+ P-D1 内容管理 IA 切分(v2 §B):
* ① 列表型栏目 → 右侧「栏目内容概览」(统计 + 最近 5 条只读 + CTA),不再渲染完整操作表;
* ② 切换树节点 → 概览 watch categoryId 联动 reload;
* ③ 单页型 → 摘要面板(含"编辑内容"入口)不渲染文章列表;
* ④ 外链型 → linkUrl + 打开链接;
* ⑤ 新建文章按钮 → 路由跳转带 categoryId query。
* ⑤ 新建文章按钮 → 路由跳转带 categoryId query;
* ⑥ T-D-02/03:/article 读 ?categoryId= 预筛选;概览"查看全部"深链到该地址。
*
* 无 @vue/test-utils 依赖:createApp 手写挂载 + vi.mock 桩(树面板/排序弹窗/图标/确认框/api/router)。
*/
@@ -14,20 +15,26 @@ import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { createPinia } from "pinia";
import ViewUIPlus from "view-ui-plus";
const { pageMock, routerPush, openMock, saveMock, saveRejection } = vi.hoisted(() => ({
const { pageMock, routerPush, openMock, saveMock, saveRejection, pageResult, treeFixture, routeMock } = vi.hoisted(() => ({
pageMock: vi.fn(),
routerPush: vi.fn(),
openMock: vi.fn(),
saveMock: vi.fn(),
/** 第 N 次(0 起)save.send() 拒绝;-1=全部成功(批量添加"失败继续"用) */
saveRejection: { value: -1 },
/** articleApi.page 返回值桩:默认空列表(各用例按需覆盖 list/total) */
pageResult: { value: { list: [] as any[], total: 0 } },
/** categoryApi.tree 返回值桩:默认空树(用例按需给栏目层级) */
treeFixture: { value: [] as any[] },
/** useRoute 桩:可变 query,测 ?categoryId= 深链初始化 */
routeMock: { value: { params: {}, query: {} as Record<string, string> } },
}));
vi.mock("@/api/module/cms", () => ({
articleApi: {
page: (query: any) => {
pageMock(query);
return { send: () => Promise.resolve({ list: [], total: 0 }) };
return { send: () => Promise.resolve(pageResult.value) };
},
publish: () => ({ send: () => Promise.resolve({}) }),
offline: () => ({ send: () => Promise.resolve({}) }),
@@ -35,7 +42,7 @@ vi.mock("@/api/module/cms", () => ({
delete: () => ({ send: () => Promise.resolve({}) }),
},
categoryApi: {
tree: () => ({ send: () => Promise.resolve([]) }),
tree: () => ({ send: () => Promise.resolve(treeFixture.value) }),
save: (payload: any) => {
const nth = saveMock.mock.calls.length;
saveMock(payload);
@@ -54,7 +61,7 @@ vi.mock("@/api/module/cms", () => ({
vi.mock("vue-router", () => ({
useRouter: () => ({ push: routerPush, replace: vi.fn() }),
useRoute: () => ({ params: {}, query: {} }),
useRoute: () => routeMock.value,
}));
/** 树面板桩:把 emit 挂到全局,测试用它模拟"选中节点";捕获 props 供 treeApi 断言 */
@@ -67,6 +74,8 @@ vi.mock("@/components/CategoryTreePanel.vue", () => ({
emit("update:curTreeNode", node);
emit("on-change", node);
};
// P-D-01:概览统计要"直属+子孙",用 on-tree-data 把(含虚拟根的)栏目树喂给 CategoryManage.treeData
(globalThis as any).__treeEmitData = (list: any[]) => emit("on-tree-data", list);
(globalThis as any).__treeProps = props;
// CategoryManage 会经模板 ref 调这些方法(真实组件的 exposed API)
expose({ loadTree: () => {}, refreshTree: () => {}, reloadTree: () => {} });
@@ -126,7 +135,11 @@ async function flush(frames = 5) {
beforeEach(() => {
vi.clearAllMocks();
(globalThis as any).__treeEmit = undefined;
(globalThis as any).__treeEmitData = undefined;
(globalThis as any).__treeProps = undefined;
pageResult.value = { list: [], total: 0 };
treeFixture.value = [];
routeMock.value = { params: {}, query: {} };
currentStore = null;
window.open = openMock as any;
});
@@ -168,6 +181,19 @@ describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () =
expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryIds: 7 });
});
it("T-D-02 文章页 ?categoryId= 深链:读 query 初始化栏目筛选并回显下拉", async () => {
treeFixture.value = [{ id: 5, name: "新闻中心", type: 1, children: [{ id: 51, name: "校园时讯", type: 1 }] }];
routeMock.value = { params: {}, query: { categoryId: "5" } };
const el = mountApp(defineComponent({
setup: () => () => h(ArticleListPanel, {}),
}));
await flush();
// query 里的字符串 id 须转成数字:既对齐后端 categoryIds,也让栏目下拉回显选中项
expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryIds: 5 });
expect(el.querySelector(".ivu-select-selected-value")?.textContent).toContain("新闻中心");
});
it("⑤ 面板新建文章:非固定模式带 categoryId query 跳转;固定模式入口收敛到工作台面板头(面板内无重复按钮)", async () => {
const state = reactive({ cid: 5, fixed: false });
const host = defineComponent({
@@ -218,15 +244,75 @@ describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => {
expect(openMock).toHaveBeenCalledWith("https://www.moe.gov.cn", "_blank");
});
it("列表型栏目:右侧渲染 ArticleListPanel(categoryId=选中栏目)", async () => {
it("T-D-01 列表型栏目:右侧降级为「栏目内容概览」(统计 + 最近 5 条只读),不再渲染操作表", async () => {
pageResult.value = {
total: 12,
list: [1, 2, 3, 4, 5, 6].map(i => ({
id: 900 + i,
title: `云海大学第${i}次党代会筹备推进会`,
categoryId: 5,
categoryName: "新闻中心",
status: 1,
publishTime: "2026-09-20 10:00",
topmost: 0,
})),
};
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
await flush();
// 统计口径 = 直属 + 子孙栏目:树经 on-tree-data 下发(含 W2 虚拟根),子孙 51/52 须并入计数
(globalThis as any).__treeEmitData([
{
id: 0,
name: "演示学校",
virtual: true,
children: [
{
id: 5,
name: "新闻中心",
type: 1,
children: [
{ id: 51, name: "校园时讯", type: 1 },
{ id: 52, name: "通知公告", type: 1 },
],
},
],
},
]);
(globalThis as any).__treeEmit({ id: 5, parentId: 0, name: "新闻中心", type: 1, sortNo: 0, status: 1 });
await flush();
const box = el.querySelector(".category-content-summary");
expect(box).toBeTruthy();
// 完整的文章操作表不再出现在栏目页(唯一操作台是 /article)
expect(el.querySelector(".article-list-panel")).toBeNull();
expect(box!.querySelector(".ivu-table")).toBeNull();
// 概览内不得出现文章操作入口(发布/下架/置顶/删除按钮);状态说明文字不算
const affordances = [...box!.querySelectorAll("button, a")].map(n => (n.textContent || "").trim());
expect(affordances.some(t => ["发布", "下架", "取消置顶", "置顶", "删除"].includes(t))).toBe(false);
// 统计查询:直属+子孙(逗号分隔,后端 List<Integer> 契约)、全状态(不带 status)、只要最近 5 条
expect(pageMock).toHaveBeenCalled();
const q = pageMock.mock.calls[0][0];
expect(q).toMatchObject({ siteId: 1, categoryIds: "5,51,52", page: 1, pageSize: 5 });
expect(q.status).toBeUndefined();
expect(box!.textContent).toContain("共 12 篇");
// 只读 5 条(第 6 条不渲染)
expect(box!.querySelectorAll(".ccs-row")).toHaveLength(5);
expect(box!.textContent).toContain("云海大学第1次党代会筹备推进会");
});
it("T-D-03 概览「查看全部 →」跳 /article 深链(带 categoryId 预筛选)", async () => {
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
await flush();
(globalThis as any).__treeEmit({ id: 5, parentId: 0, name: "新闻中心", type: 1, sortNo: 0, status: 1 });
await flush();
expect(el.querySelector(".article-list-panel")).toBeTruthy();
expect(pageMock).toHaveBeenCalled();
expect(pageMock.mock.calls[0][0]).toMatchObject({ categoryIds: 5 });
const more = [...el.querySelectorAll("a, button")].find(b => b.textContent?.includes("查看全部"));
expect(more).toBeTruthy();
more!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article?categoryId=5");
});
it("未选中节点:右侧显示空态引导", async () => {
+79 -2
View File
@@ -30,9 +30,10 @@ vi.mock("vue-router", () => ({
import SchoolLayout from "@/components/SchoolLayout.vue";
import { useCmsStore } from "@/store";
/** 站点卡皮肤包名取 user.sites[].theme(后端 AuthUserVo.sites = List<SiteVo>,字段已具备,零后端改动) */
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: 1, code: "demo", name: "云海大学门户", path: "demo", parentSiteId: 0, theme: "scholar-blue", schId: 1001, schName: "云海大学" },
{ id: 2, code: "jdxy", name: "机电学院", path: "demo/jdxy", parentSiteId: 1, theme: "campus-glow", schId: 1001, schName: "云海大学" },
{ id: 3, code: "wtxy", name: "文学院", path: "demo/wtxy", parentSiteId: 1, schId: 1001, schName: "云海大学" },
];
@@ -296,3 +297,79 @@ describe("docs/13 身份条次级入口与幂等切换(docs/15 T5)", () => {
expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/site");
});
});
/* ===== v2 §C.3 方案 A(P-C1 / T-C-01~03):站点身份常驻侧栏站点卡 + 壳层染色 + 标题带站点 =====
口径:站点卡承载切换与身份,功能菜单仍只有一份(stripSiteSeg 剥段高亮是方案 A 的前置设计,不改)。 */
describe("P-C1 站点卡:身份常驻侧栏(v2 §C.3 方案 A)", () => {
it("T-C-01 主站态渲染站点卡:站名 + 层级(学校 › 主站)+ 皮肤包名 + 展开的站点列表(当前站高亮)", async () => {
setRoute("/sch/demo/article", { schCode: "demo" });
const el = mountLayout();
await flush();
// 站点卡挂在侧栏原空位 .menu-top-box 内(零布局改造落点,v2 §C.2)
const card = el.querySelector(".left-menu .menu-top-box .site-context-card");
expect(card).toBeTruthy();
expect(card!.querySelector(".scc-name")!.textContent).toContain("云海大学门户");
expect(card!.querySelector(".scc-tier")!.textContent).toContain("云海大学");
expect(card!.querySelector(".scc-tier")!.textContent).toContain("学校主站");
// 皮肤可见(§C.3 #4):数据源 user.sites[].theme(SiteVo 已带,零后端改动)
expect(card!.querySelector(".scc-theme")!.textContent).toContain("scholar-blue");
// 站点列表展开常驻(非下拉):主站 1 + 院系 2 = 3 行,当前站唯一高亮
const rows = el.querySelectorAll(".scc-site");
expect(rows).toHaveLength(3);
expect(el.querySelectorAll(".scc-site.active")).toHaveLength(1);
expect(rows[0].classList.contains("active")).toBe(true);
expect(rows[1].textContent).toContain("机电学院");
});
it("T-C-01b 站点卡点行即切换:只改 URL、停留当前功能页(复用 SiteSwitcher 的 adminPathOf 补段语义)", async () => {
setRoute("/sch/demo/article", { schCode: "demo" });
const el = mountLayout();
await flush();
const jdxy = [...el.querySelectorAll(".scc-site")].find(r => r.textContent?.includes("机电学院"))!;
jdxy.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/article");
// 点当前站行幂等:目标 === 当前路径,不产生 push
box.push.mockClear();
const main = [...el.querySelectorAll(".scc-site")].find(r => r.textContent?.includes("云海大学门户"))!;
main.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(box.push).not.toHaveBeenCalled();
});
it("T-C-02 身份染色:子站态壳层挂 band-sub(卡片 sub 态),主站态挂 band-main 且不染色", async () => {
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
const sub = mountLayout();
await flush();
expect(sub.querySelector(".left-menu")!.className).toContain("band-sub");
expect(sub.querySelector(".site-context-card")!.classList.contains("sub")).toBe(true);
// 皮肤随站而变(§C.3 #4):子站独立皮肤 = 最直观的"两站不同"信号
expect(sub.querySelector(".scc-theme")!.textContent).toContain("campus-glow");
// 皮肤缺省也要有身份:色点常驻
expect(sub.querySelector(".scc-dot")).toBeTruthy();
setRoute("/sch/demo/article", { schCode: "demo" });
const main = mountLayout();
await flush();
expect(main.querySelector(".left-menu")!.className).toContain("band-main");
expect(main.querySelector(".site-context-card")!.classList.contains("sub")).toBe(false);
});
it("T-C-03 标题带站点:页头标题与 document.title 均为「页面名 · 站点名」,切站后跟随", async () => {
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
const el = mountLayout();
await flush();
expect(document.title).toBe("文章列表 · 机电学院");
expect(el.querySelector(".header-container h4")!.textContent).toContain("· 机电学院");
// 同一壳内切到主站形态:标题与 document.title 同步换站名
box.route.path = "/sch/demo/article";
box.route.fullPath = "/sch/demo/article";
box.route.params = { schCode: "demo" };
await flush();
expect(document.title).toBe("文章列表 · 云海大学门户");
});
});
+57 -6
View File
@@ -15,6 +15,7 @@ import { resolveMenuPage } from "@/utils/menuRoute";
import { useCmsStore } from "@/store";
import { adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath";
import SiteSwitcher from "@/components/SiteSwitcher.vue";
import SiteContextCard from "@/components/SiteContextCard.vue";
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
import config from "@/assets/config/host.config";
@@ -42,9 +43,20 @@ const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%"
const school = computed(() => store.currentSchool);
const isSuper = computed(() => store.isSuperAdmin);
/** 当前落点是否院系子站(store 契约):决定身份条徽章与底边色带 */
/** 当前落点是否院系子站(store 契约):决定身份条徽章、侧栏/面包屑身份色带与页头站点名(v2 §C.3 #2/#3) */
const isSubSite = computed(() => store.isSubSite);
const pathNavClass = computed(() => (isSubSite.value ? "band-sub" : "band-main"));
/** 身份档位类:同名类挂在 .left-menu 与 .path-nav 两处壳层节点,主站不染色(默认为主站、偏离才提示) */
const identityClass = computed(() => (isSubSite.value ? "band-sub" : "band-main"));
/* 标题带站点(§C.3 #3):页头标题与 document.title 都写成「页面名 · 站点名」,多标签页也能分辨 */
const siteName = computed(() => store.currentSite?.name || "");
const shellTitle = computed(() => (siteName.value ? `${appName.value} · ${siteName.value}` : appName.value));
const docTitle = computed(() => {
const t = String((route.meta as any)?.title || "");
if (!siteName.value) return t;
return t ? `${t} · ${siteName.value}` : siteName.value;
});
watch(docTitle, v => { if (v) document.title = v; }, { immediate: true });
/** 菜单/面包屑 page(主站形态完整路径)→ 当前站点形态:子站插入 /site/{code} 段(docs/13 §4)。
* 非 /sch/ 前缀(外链模块)原样返回,防拼坏 */
@@ -315,7 +327,9 @@ watch(
<Icon type="ios-undo" :size="18" />
</a>
<div v-if="isSuper" class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ appName }}</h4>
<h4 class="fl align-middle" :title="shellTitle">
{{ appName }}<span v-if="siteName" class="shell-site" :class="identityClass"> · {{ siteName }}</span>
</h4>
<div class="header-aside fr">
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
@@ -329,8 +343,9 @@ watch(
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
<div class="theme-cap fr"><ThemeSwitcher /></div>
</header>
<div class="left-menu" v-if="!isAlone">
<div class="menu-top-box"></div>
<div class="left-menu" :class="identityClass" v-if="!isAlone">
<!-- 站点卡(v2 §C.3 #1):占用原本空置的 .menu-top-box,身份常驻侧栏 -->
<div class="menu-top-box"><SiteContextCard /></div>
<ul>
<template v-for="(item, index) in modList" :key="'menu_li_' + index">
<template v-if="item.children && item.children.length > 0">
@@ -366,7 +381,7 @@ watch(
<div class="clear-box"></div>
</div>
<div class="path-nav" :class="pathNavClass" :style="pathNavStyle">
<div class="path-nav" :class="identityClass" :style="pathNavStyle">
<Breadcrumb separator=">">
<!-- 首页段(常驻第一段):旧 BschHome 已下线,统一跳用户中心 userhome -->
<a href="javascript:;" @click="linkHomePage">
@@ -478,6 +493,42 @@ watch(
margin-right: 8px;
}
/* ===== 站点卡落位与身份染色(v2 §C.3 方案 A · P-C1)=====
.menu-top-box 在压缩产物 layout.css 里是「10px 空档 + 右边框」,放卡后必须让位;
特异性 .left-menu .menu-top-box[data-v-x](0,3,0)> 全局 .left-menu .menu-top-box(0,2,0),可覆盖。
sticky:侧栏是 overflow:auto 的滚动容器,卡片随之滚走就谈不上"身份常驻",故吸附在顶部并垫同色底。 */
.left-menu .menu-top-box {
height: auto;
border-right: none;
position: sticky;
top: 0;
z-index: 2;
background: var(--side-bg);
}
/* 子站 3px 身份色条贴在常驻卡区顶部(inset 阴影不占布局、不随内容滚走);主站不画——
默认为主站、偏离才提示(§C.3 设计原则),避免两态都染色带来视觉噪音 */
.left-menu.band-sub .menu-top-box {
box-shadow: inset 0 3px 0 #ff9900;
padding-top: 3px;
}
/* 页头站点名胶囊:站名可能很长,h4 截断(完整「页面名 · 站点名」在 title 与 document.title) */
.header-container h4 {
max-width: 46vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shell-site {
font-size: 13px;
font-weight: normal;
padding: 2px 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.18);
}
.shell-site.band-sub {
background: rgba(255, 153, 0, 0.32);
}
/* 面包屑「首页」垂直居中:layout.css 遗留的 .path-nav .icon-category{position:relative;top:1px}
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
这里用 inline-flex 真居中并关掉该补偿(不动压缩产物 layout.css,覆盖收在本组件内)。
+194
View File
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useCmsStore } from "@/store";
import { adminPathOf, schoolSubPath } from "@/utils/sitePath";
/**
* 站点卡(v2 §C.3 方案 A · P-C1):侧栏常驻的「站点身份 + 站点切换」卡,占掉 SchoolLayout 的空 .menu-top-box。
* 动因(§C.2):站点身份此前只活在面包屑最后一格的 SiteSwitcher 里,进入工作区后侧栏/页头零标识,
* 主站与院系子站看起来像同一个站。方案 A 的口径是身份常驻在管理壳(Chrome Identity),功能菜单仍只有一份。
* 数据源 store.schoolSites(= user.sites 按本校过滤,含 SiteVo.theme),零后端改动。
* 切换语义与 SiteSwitcher 一致:只改 URL(补/省 /site/{code} 段)并停留当前功能页,上下文由路由同步。
*/
const store = useCmsStore();
const route = useRoute();
const router = useRouter();
const school = computed(() => store.currentSchool);
const isSub = computed(() => store.isSubSite);
const currentSite = computed(() => store.currentSite as any);
const currentName = computed(() => currentSite.value?.name || "");
/** 层级行:学校 › 学校主站 / 学校 › 院系子站(子站是他校站点时无父名可显,退化为站名) */
const tierText = computed(() => {
const sch = school.value?.schName || school.value?.mainSiteName || "";
const tier = isSub.value ? "院系子站" : "学校主站";
return sch ? `${sch} › ${tier}` : tier;
});
/** 皮肤包名(§C.3 #4:子站可独立选皮肤,这是最直观的"两站不同"信号)。取站点列表原始数据,currentSite 精简态不带 theme */
const currentTheme = computed(
() => (store.schoolSites as any[])?.find((s: any) => s.id === currentSite.value?.id)?.theme || ""
);
const sites = computed(() => (store.schoolSites || []) as any[]);
const isMainSite = (site: any) => site?.id === school.value?.mainSiteId;
/** 当前功能页子路径:点站点只换上下文、不换页面(与 SiteSwitcher.pick 同语义) */
const curSub = computed(() => schoolSubPath(route.path) || "/article");
function pick(site: any) {
if (!site) return;
const target = adminPathOf(school.value?.mainSiteCode || "", isMainSite(site) ? null : site.code, curSub.value);
if (target === route.path) return;
router.push(target);
}
</script>
<template>
<div v-if="currentSite" class="site-context-card" :class="{ sub: isSub }">
<div class="scc-head">
<span class="scc-dot" aria-hidden="true"></span>
<div class="scc-id">
<b class="scc-name" :title="currentName">{{ currentName }}</b>
<span class="scc-tier">{{ tierText }}</span>
</div>
</div>
<p class="scc-theme">皮肤包<b>{{ currentTheme || "未设置" }}</b></p>
<p class="scc-cap">切换管理站点({{ sites.length }})</p>
<ul class="scc-list">
<li
v-for="s in sites"
:key="s.id"
class="scc-site"
:class="{ active: s.id === currentSite.id }"
:aria-current="s.id === currentSite.id ? 'true' : null"
:title="s.name"
@click="pick(s)"
>
<span class="scc-site-bdg" :class="isMainSite(s) ? 'main' : 'sub'">
{{ isMainSite(s) ? "主站" : "院系" }}
</span>
<span class="scc-site-name">{{ s.name }}</span>
</li>
</ul>
</div>
</template>
<style scoped lang="scss">
/* 配色复用 SiteSwitcher 的定稿身份色(主站 #577afb / 子站 #ff9900 / 子站淡底 #fff7ec),不引新色值。
侧栏默认档是深色渐变(yh-theme.css --side-bg),故卡用白底浮起面板 + 8px 右左缩进,
与 .left-menu li{margin:1px 8px} 的栅格对齐,深浅两档都成立。 */
.site-context-card {
box-sizing: border-box;
margin: 8px 8px 2px;
padding: 10px 10px 8px;
border-radius: var(--r-s, 7px);
background: #fff;
color: #272829;
font-size: 12px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08);
&.sub {
background: #fff7ec;
.scc-name {
color: #d48806;
}
}
}
.scc-head {
display: flex;
align-items: flex-start;
gap: 8px;
}
.scc-dot {
flex-shrink: 0;
width: 10px;
height: 10px;
margin-top: 4px;
border-radius: 50%;
background: #577afb;
}
.sub .scc-dot {
background: #ff9900;
}
.scc-id {
min-width: 0;
display: flex;
flex-direction: column;
}
.scc-name {
font-size: 14px;
color: #4465d4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scc-tier {
margin-top: 2px;
color: #969799;
line-height: 16px;
}
.scc-theme {
margin: 6px 0 0;
color: #969799;
line-height: 18px;
b {
color: #656667;
font-weight: normal;
margin-left: 2px;
}
}
.scc-cap {
margin: 6px 0 2px;
padding-top: 6px;
border-top: 1px solid #f0f2f5;
color: #c0c4cc;
line-height: 18px;
}
.scc-list {
list-style: none;
margin: 0;
padding: 0;
/* RK-07 缓解:院系子站多时卡内限高滚动,不把侧栏撑成一屏长的站点列表
(>8 站的"最近访问 + 全部"两段式属后续项,未在本工作项清单内) */
max-height: 40vh;
overflow-y: auto;
}
.scc-site {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 4px;
cursor: pointer;
border-radius: 3px;
&:hover {
background: #eff4ff;
}
&.active {
background: #eff4ff;
.scc-site-name {
color: #4465d4;
font-weight: bold;
}
}
}
.scc-site-bdg {
flex-shrink: 0;
font-size: 10px;
line-height: 16px;
padding: 0 5px;
border-radius: 3px;
color: #fff;
&.main {
background: #577afb;
}
&.sub {
background: #ff9900;
}
}
.scc-site-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+5 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { reactive, ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { Message, Tag } from "view-ui-plus";
import Utils from "@/utils/index";
import MyConfirm from "@/components/MyConfirm";
@@ -23,6 +23,7 @@ const props = defineProps<{
}>();
const router = useRouter();
const route = useRoute();
const store = useCmsStore();
const rows = ref<ArticlePageItem[]>([]);
@@ -32,7 +33,9 @@ const pageNum = ref(1);
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
const search = reactive({
categoryId: undefined as number | undefined,
// P-D1:栏目页概览「查看全部 →」以 /article?categoryId={id} 深链进入,读 query 预筛选
// (query 是字符串,转数字才能对上 Option 的 value 并回显下拉选中项)
categoryId: Number((route.query?.categoryId as any)) || undefined,
status: undefined as number | undefined,
keyword: "",
});
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { Tag } from "view-ui-plus";
import { articleApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import { ArticlePageItem } from "@/types/cms";
import { adminPathOf } from "@/utils/sitePath";
/**
* 栏目内容概览(P-D1,v2 §B.3):/category 右侧列表型栏目的降级面板。
* 定位=只读概览;文章的发布/下架/置顶/删除只在 /article(唯一操作台)做。
*
* 统计口径(手册 P-D1 定稿,勿自行改):
* - 范围 = 选中栏目的**直属 + 全部子孙栏目**。后端 ArticlePageQuery.categoryIds 是不递归的 IN 查询,
* 故子孙 id 由前端从栏目树展开(与门户 PortalPageRenderer.collectListDescendantIds 同语义)。
* - 状态 = **全状态计数**(草稿/已发布/已下架全部计入,不带 status 过滤;系统内无"回收站"概念)。
* - 一次 page 请求同时拿到总数(total)与最近 5 条(list,后端按置顶降序 + 创建时间降序)。
*/
const props = defineProps<{
categoryId: number;
categoryName?: string;
/** 全量栏目树(CategoryManage 的 treeData,含 W2 虚拟根;按 id 定位,虚拟根 id=0 不会命中真实栏目) */
tree?: any[];
}>();
const router = useRouter();
const store = useCmsStore();
const total = ref(0);
const rows = ref<ArticlePageItem[]>([]);
const loading = ref(false);
const statusDict = [
{ value: 0, label: "草稿", color: "default" },
{ value: 1, label: "已发布", color: "success" },
{ value: 2, label: "已下架", color: "warning" },
];
const statusText = (s: number) => statusDict.find(i => i.value === s)?.label || "未知";
const statusColor = (s: number) => statusDict.find(i => i.value === s)?.color || "default";
function findNode(list: any[], id: number): any {
for (const n of list || []) {
if (Number(n.id) === Number(id)) return n;
const hit = findNode(n.children || [], id);
if (hit) return hit;
}
return null;
}
function collectIds(node: any, acc: number[]): number[] {
acc.push(Number(node.id));
(node.children || []).forEach((c: any) => collectIds(c, acc));
return acc;
}
/** 统计范围 id:树未就绪(或未命中)时退化为仅直属栏目 */
const scopeIds = computed<number[]>(() => {
const node = findNode(props.tree || [], props.categoryId);
return node ? collectIds(node, []) : [Number(props.categoryId)];
});
const scopeKey = computed(() => scopeIds.value.join(","));
async function run() {
if (!store.siteId) return;
loading.value = true;
try {
const page = await articleApi
.page({
siteId: store.siteId,
categoryIds: scopeKey.value,
page: 1,
pageSize: 5,
})
.send();
total.value = page?.total || 0;
rows.value = (page?.list || []).slice(0, 5);
} finally {
loading.value = false;
}
}
/** 跳 /article 深链预筛选(子栏目行按自身栏目过滤,父栏目"查看全部"按父栏目过滤) */
function goArticles(categoryId?: number) {
const code = store.schoolCode;
if (!code) return;
const id = categoryId ?? props.categoryId;
router.push(adminPathOf(code, store.siteSegCode, "article") + `?categoryId=${id}`);
}
// 树稍后到达(子孙变化)与切换栏目都重取;站点切换由 CategoryManage 清选中后重建本组件
watch(scopeKey, run);
onMounted(run);
</script>
<template>
<div class="category-content-summary">
<div class="ccs-stat fix">
<span class="ccs-total">共 <b>{{ total }}</b> 篇</span>
<span class="ccs-scope">
{{ categoryName || "本栏目" }}及子栏目({{ scopeIds.length }} 个栏目)· 草稿/已发布/已下架全部计入
</span>
<Button class="fr" size="small" @click="goArticles()">查看全部 →</Button>
</div>
<div v-if="loading" class="ccs-empty">加载中…</div>
<ul v-else-if="rows.length" class="ccs-list">
<li v-for="row in rows" :key="row.id" class="ccs-row fix">
<a class="ccs-title operating-link" :title="row.title" @click="goArticles(row.categoryId)">{{ row.title }}</a>
<Tag class="ccs-tag" :color="statusColor(row.status)">{{ statusText(row.status) }}</Tag>
<span class="ccs-time">{{ row.publishTime || row.createTime || "-" }}</span>
</li>
</ul>
<div v-else class="ccs-empty">
<p>本栏目及子栏目下暂无文章</p>
<p class="ccs-empty-tip">可在上方「新建文章」直接发布到本栏目,或到「文章列表」页检索全站内容。</p>
</div>
</div>
</template>
<style scoped lang="scss">
.category-content-summary {
background: #fff;
border-radius: 4px;
padding: 4px 16px 16px;
}
.ccs-stat {
padding: 14px 0 10px;
border-bottom: 1px solid #f0f0f0;
line-height: 24px;
}
.ccs-total {
font-size: 14px;
color: #515a6e;
b {
font-size: 20px;
color: #2d8cf0;
margin: 0 2px;
}
}
.ccs-scope {
margin-left: 10px;
font-size: 12px;
color: #808695;
}
.ccs-list {
list-style: none;
margin: 0;
padding: 0;
}
.ccs-row {
padding: 10px 0;
border-bottom: 1px dashed #f0f0f0;
display: flex;
align-items: center;
&:last-child {
border-bottom: none;
}
}
.ccs-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.ccs-tag {
flex: none;
margin: 0 10px;
}
.ccs-time {
flex: none;
font-size: 12px;
color: #808695;
}
.ccs-empty {
padding: 32px 0;
text-align: center;
color: #808695;
}
.ccs-empty-tip {
margin-top: 6px;
font-size: 12px;
}
</style>
+1 -1
View File
@@ -170,7 +170,7 @@ export interface ArticleSaveRequest {
export interface ArticlePageQuery {
siteId: number;
/** 栏目 id(后端字段 categoryIds: List<Integer>,多选逗号分隔;空=全部栏目) */
categoryIds?: number;
categoryIds?: number | string;
status?: number;
keyword?: string;
page?: number;
+9 -7
View File
@@ -8,7 +8,7 @@ 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 CategoryContentSummary from "@/components/category/CategoryContentSummary.vue";
import { categoryApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
@@ -17,7 +17,9 @@ import { rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree";
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除)。
B5 栏目工作台:右侧类型化面板(列表=文章表格 / 单页=富文本预览 / 外链=直达)。 */
B5 栏目工作台:右侧类型化面板(单页=富文本预览 / 外链=直达)。
P-D1 内容管理 IA 切分:列表型栏目右侧降级为「栏目内容概览」(只读统计 + 最近 5 条),
文章的检索与操作统一在 /article(内容视图)。 */
const store = useCmsStore();
const refTreePanel = useTemplateRef("refTreePanel");
@@ -440,13 +442,13 @@ function openLink(url: string) {
</div>
</div>
<!-- 列表型:本栏目文章管理(联动仅经 categoryId watch,拖拽排序不触发) -->
<ArticleListPanel
<!-- 列表型:只读内容概览(P-D1 结构/内容视图切分;文章操作统一在 /article) -->
<CategoryContentSummary
v-if="selected.type === 1"
class="panel-body"
:category-id="selected.id"
:extra-height="78"
fixed-category
:category-name="selected.name"
:tree="treeData"
/>
<!-- 单页型:正文渲染预览 + 元信息 + 编辑入口 -->
@@ -551,7 +553,7 @@ function openLink(url: string) {
<Select v-model="form.homeStyle" placeholder="默认卡片">
<Option v-for="s in homeStyleDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
</Select>
<p class="form-tip">首页该栏目区块的呈现形态;「双栏新闻卡/媒体聚焦列表」需皮肤包支持(如炫彩校园),旧主题按默认卡展示。也可在「站点设置 → 首页布局」里按区块单独指定形态(以那里为准)。</p>
<p class="form-tip">首页该栏目区块的呈现形态;展示形态由当前皮肤包的主题化实现决定,未实现的形态回落通用骨架渲染。也可在「站点设置 → 首页布局」里按区块单独指定形态(以那里为准)。</p>
</FormItem>
<Divider size="small" orientation="left">SEO 设置</Divider>