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 复跑同绿
This commit is contained in:
chenxing
2026-09-22 21:55:23 +08:00
parent 5d048abdae
commit 31cbec152a
5 changed files with 311 additions and 22 deletions
+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>