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 桩)
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user