- 新建 ArticleListPanel(props categoryId/fixedCategory):无props=全局检索行为不变(薄壳红线); 固定栏目模式隐藏栏目下拉,watch categoryId 联动 reload(拖拽排序不触发,红线) - CategoryManage 右侧:列表型=本栏目文章面板+新建文章(query.categoryId 预选)+预览; 单页型=摘要+编辑内容+预览;外链型=URL+打开链接;空态引导;工具栏增树折叠(窄屏) - ArticleEdit 支持 query.categoryId 新建态预选栏目 - 后端零改动(红线);vitest 32/32 绿(新增 categoryWorkbench.spec 7 例,手写挂载+vi.mock 桩)
574 lines
18 KiB
Vue
574 lines
18 KiB
Vue
<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>
|