Files
YschcmsFront/src/components/article/ArticleListPanel.vue
T
chenxing fc566eed8b feat(B5): 栏目工作台-树+类型化内容面板,ArticleList 组件化薄壳
- 新建 ArticleListPanel(props categoryId/fixedCategory):无props=全局检索行为不变(薄壳红线);
  固定栏目模式隐藏栏目下拉,watch categoryId 联动 reload(拖拽排序不触发,红线)
- CategoryManage 右侧:列表型=本栏目文章面板+新建文章(query.categoryId 预选)+预览;
  单页型=摘要+编辑内容+预览;外链型=URL+打开链接;空态引导;工具栏增树折叠(窄屏)
- ArticleEdit 支持 query.categoryId 新建态预选栏目
- 后端零改动(红线);vitest 32/32 绿(新增 categoryWorkbench.spec 7 例,手写挂载+vi.mock 桩)
2026-09-08 15:14:09 +08:00

294 lines
9.5 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { reactive, ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { Message, Tag } from "view-ui-plus";
import Utils from "@/utils/index";
import MyConfirm from "@/components/MyConfirm";
import { articleApi, categoryApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import { ArticlePageItem, CategoryTreeNode } from "@/types/cms";
/**
* 文章列表面板(B5 栏目工作台组件化,自 ArticleList.vue 原样抽出):
* - 无 props = 全局检索行为(ArticleList.vue 薄壳引用,路由/搜索参数兼容);
* - fixedCategory + categoryId = 固定栏目模式:隐藏栏目下拉、查询强制带 categoryId,
* categoryId 变化 watch 联动 reload(树节点切换;拖拽排序不改变 id 不触发)。
*/
const props = defineProps<{
categoryId?: number;
fixedCategory?: boolean;
}>();
const router = useRouter();
const store = useCmsStore();
const rows = ref<ArticlePageItem[]>([]);
const total = ref(0);
const loading = ref(false);
const pageNum = ref(1);
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
const search = reactive({
categoryId: undefined as number | undefined,
status: undefined as number | undefined,
keyword: "",
});
/** 栏目下拉(树拍平缩进展示;固定栏目模式不加载不显示) */
const categoryOptions = ref<{ id: number; label: string }[]>([]);
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";
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true });
const columns = [
{
title: "标题",
key: "title",
minWidth: 260,
tooltip: true,
render: (h: any, p: any) =>
h("span", null, [
h(
"a",
{
style: { color: "#2d8cf0", cursor: "pointer", marginRight: "6px" },
onClick: () => goEdit(p.row.id),
},
p.row.title
),
p.row.topmost === 1 ? h(Tag, { color: "error" }, () => "置顶") : null,
]),
},
{ title: "栏目", key: "categoryName", width: 140, tooltip: true },
{
title: "状态",
key: "status",
width: 100,
align: "center",
render: (h: any, p: any) =>
h(Tag, { color: statusColor(p.row.status) }, () => statusText(p.row.status)),
},
{
title: "发布时间",
key: "publishTime",
width: 170,
render: (h: any, p: any) => h("span", p.row.publishTime || "-"),
},
{ title: "浏览量", key: "viewCount", width: 90, align: "center" },
{ title: "创建时间", key: "createTime", width: 170 },
{
title: "操作",
key: "action",
width: 190,
fixed: "right",
align: "center",
render: (h: any, p: any) => {
const ops: any[] = [
Utils.operatingHandle(h, "编辑", "icon-edit", () => goEdit(p.row.id)),
];
if (p.row.status !== 1) {
ops.push(Utils.operatingHandle(h, "发布", "icon-apps", () => doPublish(p.row)));
}
if (p.row.status === 1) {
ops.push(Utils.operatingHandle(h, "下架", "icon-stop", () => doOffline(p.row)));
}
ops.push(
Utils.operatingHandle(
h,
p.row.topmost === 1 ? "取消置顶" : "置顶",
"icon-saoba",
() => doTopmost(p.row)
),
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row))
);
return ops;
},
},
];
function flattenCategories(tree: CategoryTreeNode[], depth = 0) {
const list: { id: number; label: string }[] = [];
tree.forEach(node => {
list.push({ id: node.id!, label: `${" ".repeat(depth)}${node.name}` });
if (node.children?.length) {
list.push(...flattenCategories(node.children, depth + 1));
}
});
return list;
}
async function loadCategories() {
if (!store.siteId || props.fixedCategory) return;
const tree = await categoryApi.tree(store.siteId).send();
categoryOptions.value = flattenCategories(tree || []);
}
async function run() {
if (!store.siteId) return;
loading.value = true;
try {
const page = await articleApi
.page({
siteId: store.siteId,
categoryId: props.fixedCategory ? props.categoryId : search.categoryId,
status: search.status,
keyword: search.keyword || undefined,
page: pageNum.value,
pageSize: pageSize.value,
})
.send();
rows.value = page?.list || [];
total.value = page?.total || 0;
} finally {
loading.value = false;
}
}
function pageSizeUpdate(size: number) {
pageSize.value = size;
localStorage.setItem("customPageCount", String(size));
pageNum.value = 1;
run();
}
/** 固定栏目模式下新建文章带 categoryId 预选(ArticleEdit onMounted 回填) */
function goEdit(id?: number) {
const code = store.schoolCode;
if (!code) {
router.push("/workbench");
return;
}
const query = props.fixedCategory && props.categoryId ? `?categoryId=${props.categoryId}` : "";
router.push(id ? `/sch/${code}/article/edit/${id}${query}` : `/sch/${code}/article/edit${query}`);
}
async function doPublish(row: ArticlePageItem) {
await articleApi.publish(store.siteId!, row.id).send();
Message.success("发布成功!");
run();
}
async function doOffline(row: ArticlePageItem) {
await articleApi.offline(store.siteId!, row.id).send();
Message.success("已下架");
run();
}
async function doTopmost(row: ArticlePageItem) {
await articleApi.topmost(store.siteId!, row.id, row.topmost !== 1).send();
Message.success(row.topmost === 1 ? "已取消置顶" : "已置顶");
run();
}
function doDelete(row: ArticlePageItem) {
MyConfirm({
title: "删除文章",
msg: `确定删除「${row.title}」吗?`,
info: "删除后正文图片将转为未使用附件,可在附件管理中清理",
async onOk() {
await articleApi.delete({ id: row.id, siteId: store.siteId! }).send();
Message.success("操作成功!");
if (rows.value.length <= 1 && pageNum.value > 1) pageNum.value -= 1;
run();
},
});
}
onMounted(async () => {
await loadCategories();
await run();
});
// 树节点切换联动(B5 红线:仅 watch categoryId,拖拽排序不经过此通道)
watch(() => props.categoryId, async () => {
pageNum.value = 1;
await run();
});
// 站点/学校上下文切换即时重载(docs/09 §3.3,修复切换不刷新缺陷)
watch(() => store.siteId, async () => {
await loadCategories();
pageNum.value = 1;
await run();
});
</script>
<template>
<div class="article-list-panel">
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="goEdit()">
<Icon type="ios-add" />
新建文章
</Button>
</div>
<div class="search-wrap fix">
<Select
v-if="!fixedCategory"
v-model="search.categoryId"
class="search-wrap_sel"
clearable
placeholder="全部栏目"
@on-change="pageNum = 1; run()"
>
<Option v-for="opt in categoryOptions" :key="opt.id" :value="opt.id">
{{ opt.label }}
</Option>
</Select>
<Select
v-model="search.status"
class="search-wrap_sel"
:class="{ 'ml-12': !fixedCategory }"
clearable
placeholder="全部状态"
@on-change="pageNum = 1; run()"
>
<Option v-for="s in statusDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
</Select>
<Input
search
enter-button
class="fr"
placeholder="标题关键字"
style="width: 220px"
v-model="search.keyword"
@on-search="pageNum = 1; run()"
@on-clear="pageNum = 1; run()"
/>
</div>
<Table
:columns="columns"
:data="rows"
:height="tableHeight"
:loading="loading"
no-data-text="暂无文章"
/>
<div class="page-panel">
<Page
class="page-box fr"
:total="total"
:current="pageNum"
:page-size="pageSize"
:page-size-opts="[10, 20, 30, 40]"
@on-page-size-change="pageSizeUpdate"
@on-change="(p: number) => { pageNum = p; run(); }"
placement="top"
show-total
show-sizer
/>
</div>
</div>
</template>
<style scoped lang="scss">
.search-wrap .search-wrap_sel {
width: 180px;
}
</style>