Files
YschcmsFront/src/views/cms/CategoryManage.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

574 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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 { computed, nextTick, reactive, ref, useTemplateRef, watch } from "vue";
import { Message } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import CategoryTreePanel from "@/components/CategoryTreePanel.vue";
import CategorySortModal from "@/components/CategorySortModal.vue";
import SvgIcon from "@/components/SvgIcon.vue";
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
import { categoryApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
import { CategoryTreeNode } from "@/types/cms";
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除/搜索)+ ztree */
const store = useCmsStore();
/** 树折叠(窄屏/专注内容时收起左树,右侧面板占满;不触碰 ztree 内部交互) */
const treeCollapsed = ref(false);
const refTreePanel = useTemplateRef("refTreePanel");
const refFormData = useTemplateRef("refFormData");
const refNodeNameInput = useTemplateRef("refNodeNameInput");
const curTreeNode = ref<any>({});
const treeData = ref<any[]>([]);
const selected = computed(() => (curTreeNode.value?.id ? curTreeNode.value : null));
const typeDict = [
{ value: 1, label: "文章列表" },
{ value: 2, label: "单页" },
{ value: 3, label: "外链" },
];
const sortModal = ref(false);
const sortParentId = ref<number | null>(null);
const sortParentName = ref("");
const sortChildrenSource = ref<any[]>([]);
const modalVisible = ref(false);
const saving = ref(false);
/** 编辑表单 */
const form = reactive({
id: undefined as number | undefined,
parentId: 0 as number,
name: "",
type: 1,
linkUrl: "",
content: "",
sortNo: 0,
seoTitle: "",
seoDescription: "",
status: 1,
});
const rules = {
name: [{ required: true, message: "栏目名称不得为空", trigger: "blur" }],
type: [{ required: true, type: "number", message: "请选择类型", trigger: "change" }],
};
/** 树数据源(CategoryTreePanel 拉取用;站点切换即时重载) */
function loadTreeApi(): Promise<CategoryTreeNode[]> {
return categoryApi.tree(store.siteId!).send() as Promise<CategoryTreeNode[]>;
}
// 站点上下文切换即时重载(docs/09 §3.3,修复切换不刷新缺陷)
watch(
() => store.siteId,
() => {
curTreeNode.value = {};
nextTick(() => refTreePanel.value?.loadTree());
}
);
function onTreeData(tree: any[]) {
treeData.value = tree;
}
function onTreeChange(node: any) {
curTreeNode.value = node || {};
}
/** 在全量树中找节点(子组排序取 children 用) */
function findNode(list: any[], id: number): any {
for (const n of list || []) {
if (n.id === id) return n;
if (n.children?.length) {
const r = findNode(n.children, id);
if (r) return r;
}
}
return null;
}
function currentNode() {
return selected.value || {};
}
async function showAddNode() {
const node = currentNode();
refFormData.value?.resetFields();
await nextTick();
modalVisible.value = true;
form.id = undefined;
form.name = "";
form.parentId = node?.id ?? 0;
form.type = 1;
form.linkUrl = "";
form.content = "";
form.sortNo = 0;
form.seoTitle = "";
form.seoDescription = "";
form.status = 1;
setTimeout(() => refNodeNameInput.value?.focus(), 300);
}
function showEditNode() {
const node = currentNode();
if (!node?.id) {
Message.warning("请先选择左侧栏目");
return;
}
refFormData.value?.resetFields();
modalVisible.value = true;
Object.assign(form, {
id: node.id,
parentId: node.parentId ?? 0,
name: node.name ?? "",
type: node.type ?? 1,
linkUrl: node.linkUrl || "",
content: node.content || "",
sortNo: node.sortNo ?? 0,
seoTitle: node.seoTitle || "",
seoDescription: node.seoDescription || "",
status: node.status ?? 1,
});
nextTick().then(() => refNodeNameInput.value?.focus());
}
function showSortNode() {
const node = currentNode();
if (!node?.id) {
Message.warning("请先选择左侧栏目");
return;
}
const found = findNode(treeData.value, node.id);
sortChildrenSource.value = found?.children ?? [];
sortParentId.value = node.id;
sortParentName.value = node.name ?? "";
sortModal.value = true;
}
function showDeleteNode() {
const node = currentNode();
if (!node?.id) {
Message.warning("请先选择左侧栏目");
return;
}
MyConfirm({
title: "删除栏目",
msg: `确定删除「${node.name}」吗?`,
info: "仅允许删除空栏目(无子栏目且无文章)",
async onOk() {
await categoryApi.delete({ id: node.id, siteId: store.siteId! }).send();
Message.success("操作成功!");
curTreeNode.value = {};
await refTreePanel.value?.refreshTree();
},
});
}
async function save() {
const valid = await refFormData.value?.validate();
if (!valid) return;
saving.value = true;
try {
await categoryApi
.save({
id: form.id,
siteId: store.siteId!,
parentId: form.parentId,
name: form.name,
type: form.type,
linkUrl: form.linkUrl || undefined,
content: form.content || undefined,
sortNo: form.sortNo,
seoTitle: form.seoTitle || undefined,
seoDescription: form.seoDescription || undefined,
status: form.status,
})
.send();
Message.success("保存成功!");
modalVisible.value = false;
await refTreePanel.value?.refreshTree(form.id ?? form.parentId);
} finally {
saving.value = false;
}
}
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 || "(暂无内容,点击「编辑内容」补充)";
}
/** 新建文章:带 categoryId 预选跳编辑页(ArticleEdit onMounted 回填) */
function goNewArticle(categoryId: number) {
window.location.hash = `#/sch/${store.schoolCode}/article/edit?categoryId=${categoryId}`;
}
/** 门户预览:新开 /portal/{siteCode}/c/{id} */
function openPreview(node: any) {
const code = store.currentSite?.code || store.schoolCode;
if (!code) {
Message.warning("当前无站点上下文,无法预览");
return;
}
window.open(`${config.serviceHost}/portal/${code}/c/${node.id}`, "_blank");
}
/** 外链栏目直达 */
function openLink(url: string) {
window.open(url, "_blank");
}
</script>
<template>
<div class="tree-wrapper category-page" :class="{ 'tree-collapsed': treeCollapsed }">
<!-- 左侧栏目树(userbase rule-tree 风格) -->
<div class="tree-container category-tree-box">
<ul class="opera-ul">
<li class="add-node-li" @click="showAddNode">
<Tooltip content="新增子栏目" placement="top" transfer>
<SvgIcon name="tree-icon-1" />
</Tooltip>
</li>
<Tooltip content="编辑栏目" placement="top" transfer>
<li @click="showEditNode">
<SvgIcon name="tree-icon-2" />
</li>
</Tooltip>
<Tooltip content="子栏目排序" placement="top" transfer>
<li @click="showSortNode">
<SvgIcon name="tree-icon-3" />
</li>
</Tooltip>
<Tooltip content="删除栏目" placement="top" transfer>
<li @click="showDeleteNode">
<SvgIcon name="tree-icon-5" />
</li>
</Tooltip>
<Tooltip content="搜索栏目" placement="top" transfer>
<li class="search-node-li" @click="refTreePanel?.openSearch()">
<Icon type="md-search" />
</li>
</Tooltip>
<Tooltip content="收起/展开栏目树" placement="top" transfer>
<li @click="treeCollapsed = !treeCollapsed">
<Icon :type="treeCollapsed ? 'ios-arrow-forward' : 'ios-arrow-back'" />
</li>
</Tooltip>
</ul>
<CategoryTreePanel
ref="refTreePanel"
v-model:curTreeNode="curTreeNode"
:treeApi="loadTreeApi"
:defaultSearchVisible="false"
:showCloseButton="true"
@on-change="onTreeChange"
@on-tree-data="onTreeData"
/>
</div>
<!-- 右侧:类型化内容面板(B5 栏目工作台) -->
<div class="main-container workbench-main">
<template v-if="selected">
<div class="panel-header">
<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 }}
</Tag>
<span class="panel-meta">排序 {{ selected.sortNo }} · {{ selected.status === 1 ? "显示" : "隐藏" }}</span>
</div>
<div class="panel-tools">
<Button v-if="selected.type === 1" type="primary" @click="goNewArticle(selected.id)">
<Icon type="ios-add" />
新建文章
</Button>
<Button v-if="selected.type !== 3" @click="openPreview(selected)">
<Icon type="ios-eye" />
预览
</Button>
<Button @click="showEditNode">
<Icon type="ios-create" />
编辑栏目
</Button>
</div>
</div>
<!-- 列表型:本栏目文章管理(联动仅经 categoryId watch,拖拽排序不触发) -->
<ArticleListPanel
v-if="selected.type === 1"
class="panel-body"
:category-id="selected.id"
fixed-category
/>
<!-- 单页型:内容摘要 + 编辑入口 -->
<div v-else-if="selected.type === 2" class="single-summary">
<p class="summary-text">{{ summaryText(selected) }}</p>
<div class="summary-actions">
<Button type="primary" @click="showEditNode">
<Icon type="ios-create" />
编辑内容
</Button>
<Button @click="openPreview(selected)">
<Icon type="ios-eye" />
预览单页
</Button>
</div>
<p v-if="selected.seoTitle" class="seo-line">SEO 标题:{{ selected.seoTitle }}</p>
<p v-if="selected.seoDescription" class="seo-line">SEO 描述:{{ selected.seoDescription }}</p>
</div>
<!-- 外链型:URL 展示 + 直达 -->
<div v-else class="link-info">
<p class="link-url-label">跳转地址</p>
<p class="link-url">{{ selected.linkUrl }}</p>
<div class="summary-actions">
<Button type="primary" @click="openLink(selected.linkUrl)">
<Icon type="ios-open-outline" />
打开链接
</Button>
</div>
</div>
</template>
<div v-else class="empty-tip">选择左侧栏目查看内容</div>
</div>
<!-- 编辑弹窗 -->
<Modal
v-model="modalVisible"
:title="form.id ? '编辑栏目' : '新增子栏目'"
:mask-closable="false"
width="560px"
>
<Form ref="refFormData" :model="form" :rules="rules" :label-width="100">
<FormItem label="上级栏目">
<Input :value="form.parentId === 0 ? '根栏目' : '当前选中栏目下'" disabled />
</FormItem>
<FormItem label="栏目名称" prop="name">
<Input ref="refNodeNameInput" v-model="form.name" :maxlength="64" placeholder="栏目名称" />
</FormItem>
<FormItem label="栏目类型" prop="type">
<RadioGroup v-model="form.type">
<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>
</Form>
<template #footer>
<Button @click="modalVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="save">保存</Button>
</template>
</Modal>
<!-- 子栏目排序(userbase GroupSort 同款拖拽) -->
<CategorySortModal
v-model:visible="sortModal"
:parent-id="sortParentId"
:parent-name="sortParentName"
:children="sortChildrenSource"
@saved="onSortSaved"
/>
</div>
</template>
<style scoped lang="scss">
/* UserBase group/index 同款工具栏样式 */
.opera-ul {
display: flex;
justify-content: space-around;
align-items: center;
border-bottom: 1px solid #ebeef5;
padding-bottom: 10px;
li {
padding: 10px;
height: 38px;
border-radius: 50%;
cursor: pointer;
&.active,
&:hover {
background-color: #f5f5f5;
}
svg {
width: 18px;
height: 18px;
}
.ivu-icon {
font-size: 18px;
}
}
.add-node-li,
.search-node-li {
position: relative;
}
}
.category-tree-box {
padding: 10px;
display: flex;
flex-direction: column;
.group-tree-search-panel {
flex: 1;
min-height: 0;
}
}
/* 树折叠态:隐藏左树,右侧面板占满(覆盖全局 .tree-wrapper .main-container 的 264px 边距) */
.category-page.tree-collapsed {
.tree-container {
display: none;
}
.main-container {
margin-left: 24px;
}
}
.info-panel {
background: #fff;
border-radius: 4px;
padding: 16px;
h3 {
margin-top: 0;
}
p {
color: #555;
}
}
/* ===== B5 栏目工作台右侧面板 ===== */
.workbench-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.panel-header {
background: #fff;
border-radius: 4px;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
.panel-title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
h3 {
margin: 0;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panel-meta {
color: #999;
font-size: 12px;
}
}
.panel-tools {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
}
.panel-body {
flex: 1;
min-height: 0;
margin-top: 10px;
overflow: auto;
}
.single-summary,
.link-info {
background: #fff;
border-radius: 4px;
padding: 16px;
margin-top: 10px;
.summary-text {
color: #333;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-all;
margin-bottom: 14px;
}
.summary-actions {
display: flex;
gap: 8px;
}
.seo-line {
color: #999;
font-size: 12px;
margin-top: 10px;
}
}
.link-info {
.link-url-label {
color: #999;
font-size: 12px;
margin-bottom: 4px;
}
.link-url {
color: #2d8cf0;
word-break: break-all;
margin-bottom: 14px;
}
}
.empty-tip {
color: #999;
text-align: center;
padding: 60px 0;
}
</style>