Files
YschcmsFront/src/views/cms/CategoryManage.vue
T

793 lines
27 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 { computed, nextTick, reactive, ref, shallowRef, useTemplateRef, watch } from "vue";
import { Message } from "view-ui-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import MyConfirm from "@/components/MyConfirm";
import CategoryTreePanel from "@/components/CategoryTreePanel.vue";
import CategorySortModal from "@/components/CategorySortModal.vue";
import BatchAddCategory from "@/components/BatchAddCategory.vue";
import SvgIcon from "@/components/SvgIcon.vue";
import CategoryContentSummary from "@/components/category/CategoryContentSummary.vue";
import { categoryApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
import { CategoryTreeNode } from "@/types/cms";
import { rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree";
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除)。
B5 栏目工作台:右侧类型化面板(单页=富文本预览 / 外链=直达)。
P-D1 内容管理 IA 切分:列表型栏目右侧降级为「栏目内容概览」(只读统计 + 最近 5 条),
文章的检索与操作统一在 /article(内容视图)。 */
const store = useCmsStore();
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 typeLabel = (t?: number) => typeDict.find(i => i.value === t)?.label || "未知";
// F3(docs/20 §四):「首页展示形态」配置入口已收敛到「站点设置 → 首页布局」区块属性,
// 栏目管理只管数据(名称/类型/排序/状态/SEO);存量 homeStyle 值由后端保存时保留。
const sortModal = ref(false);
const sortParentId = ref<number | null>(null);
const sortParentName = ref("");
const sortChildrenSource = ref<any[]>([]);
const batchModal = ref(false);
const batchDefaultType = ref<number | null>(null);
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" }],
linkUrl: [{ required: true, message: "外链地址不得为空", trigger: "blur" }],
};
/** 根节点 label(W2 虚拟根,D5):主站=学校名(缺省回退 mainSiteName),子站=子站名;随站点上下文响应 */
const rootLabel = computed(() => rootLabelOf({ currentSchool: store.currentSchool, currentSite: store.currentSite }));
/** 树数据源(CategoryTreePanel 拉取用;站点切换即时重载;W2:包装虚拟根后返回) */
async function loadTreeApi(): Promise<CategoryTreeNode[]> {
const tree = await categoryApi.tree(store.siteId!).send();
return [wrapTreeWithRoot(tree as any[], rootLabel.value)];
}
// 站点上下文切换即时重载(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;
}
/** target 是否 node 自身或其子孙(成环判定:把栏目挂到自己子树下会形成环) */
function isSelfOrDescendant(node: any, targetId: number | undefined): boolean {
if (!node || targetId == null) return false;
if (node.id === targetId) return true;
return (node.children || []).some((c: any) => isSelfOrDescendant(c, targetId));
}
/**
* 上级栏目选项:虚拟根(0=rootLabel)+ 真实树拍平缩进。
*
* 2026-09-11 口径调整:**不再置灰任何选项**(含外链型栏目、含自身及其子栏目)——
* 类型与"能否作父"无关,而且灰色项只显示一个无解释的锁,用户看到的往往是
* "办事大厅被禁用了"这类误解。成环改为保存前校验(见 save())并给出明确原因,
* 后端 CategoryService.validateParent 同步升级为沿祖先链防任意层级成环。
*/
const parentOptions = computed(() => {
const list: { id: number; label: string }[] = [
{ id: 0, label: rootLabel.value || "根栏目" },
];
const walk = (nodes: any[], depth: number) => {
(nodes || []).forEach((n: any) => {
list.push({ id: n.id, label: `${" ".repeat(depth)}${n.name}` });
walk(n.children || [], depth + 1);
});
};
walk(treeData.value[0]?.children || [], 1);
return list;
});
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();
},
});
}
/**
* 保存前成环校验:上级栏目不能是自身或自己的子栏目。
* 返回提示文案(命中)或空串(通过)。
*/
function findCycleParent(): string {
const parentId = Number(form.parentId);
if (!parentId) return "";
if (form.id != null && parentId === Number(form.id)) {
return "上级栏目不能是自身,请另选一个上级栏目";
}
const parentNode = findNode(treeData.value, parentId);
if (parentNode && isSelfOrDescendant(parentNode, form.id)) {
return `「${parentNode.name}」是当前栏目的子栏目,挂到它下面会形成循环,请另选上级栏目`;
}
return "";
}
async function save() {
const valid = await refFormData.value?.validate();
if (!valid) {
// bsch 规范 §4:校验失败统一提示
Message.warning("请填写红色星号内容!");
return;
}
// 成环校验(下拉已不再置灰,这里给出可读原因;后端 CategoryService.validateParent 同口径兜底)
const cycleHit = findCycleParent();
if (cycleHit) {
Message.warning(cycleHit);
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);
}
/** 批量添加完成:刷新树并定位到最后创建成功的节点 */
function onBatchSuccess(lastSavedId?: number) {
refTreePanel.value?.refreshTree(lastSavedId ?? curTreeNode.value?.id);
}
/** 新增模态框 → 批量添加(UserBase switchToBatch 同款,仅新增态开放) */
function switchToBatch() {
batchDefaultType.value = form.type;
modalVisible.value = false;
batchModal.value = true;
}
/* ===== 单页富文本编辑(wangEditor,配置对齐 ArticleEdit;贴图 scene=category 标记引用) ===== */
const editorRef = shallowRef();
const editorReady = ref(false);
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
const serviceBase = config.isMock ? "" : config.serviceHost;
const editorConfig = {
placeholder: "请输入单页内容,支持图文混排……",
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
const fd = new FormData();
fd.append("file", file);
fd.append("siteId", String(store.siteId));
// scene=category:单页贴图计入栏目引用,附件管理不计为「未使用」(后端白名单)
fd.append("scene", "category");
try {
const resp = await fetch(`${serviceBase}/api/attachment/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
const res = await resp.json();
if (res.code === 0 && res.data) {
insertFn(res.data.url, res.data.fileName || "", res.data.url);
} else {
Message.error(res.msg || "图片上传失败");
}
} catch {
Message.error("图片上传失败");
}
},
},
},
};
function handleCreated(editor: any) {
editorRef.value = editor;
}
// wangEditor 不能在 display:none 容器内创建(宽度塌陷):模态框展开动画结束后再挂载,
// 关闭即卸载销毁(@wangeditor/editor-for-vue 组件卸载时自动 destroy)
watch(modalVisible, v => {
if (v) {
editorReady.value = false;
setTimeout(() => {
editorReady.value = form.type === 2;
}, 320);
} else {
editorReady.value = false;
}
});
/* ===== B5 栏目工作台:右侧类型化内容面板 ===== */
/** 单页正文纯文本(字数统计/空态判断用) */
function plainText(html: string): string {
return String(html || "")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
}
/** 新建文章:带 categoryId 预选跳编辑页(ArticleEdit onMounted 回填);站点段跟随当前上下文 */
function goNewArticle(categoryId: number) {
const code = store.schoolCode;
if (!code) return;
window.location.hash = `#${adminPathOf(code, store.siteSegCode, "article/edit")}?categoryId=${categoryId}`;
}
/** 门户预览:新开 /portal/{path}/c/{id}(W4 嵌套路径) */
function openPreview(node: any) {
if (!store.currentSite) {
Message.warning("当前无站点上下文,无法预览");
return;
}
window.open(`${config.serviceHost}${portalUrlOf(store.currentSite)}c/${node.id}`, "_blank");
}
/** 外链栏目直达 */
function openLink(url: string) {
window.open(url, "_blank");
}
</script>
<template>
<div class="tree-wrapper category-page">
<!-- 左侧栏目树(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>
</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'">
{{ typeLabel(selected.type) }}
</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>
<!-- 列表型:只读内容概览(P-D1 结构/内容视图切分;文章操作统一在 /article) -->
<CategoryContentSummary
v-if="selected.type === 1"
class="panel-body"
:category-id="selected.id"
:category-name="selected.name"
:tree="treeData"
/>
<!-- 单页型:正文渲染预览 + 元信息 + 编辑入口 -->
<div v-else-if="selected.type === 2" class="single-summary">
<div class="summary-meta">
<span>共 {{ plainText(selected.content).length }} 字</span>
<span>{{ selected.status === 1 ? "已显示" : "已隐藏" }}</span>
</div>
<!-- content 入库前经后端 jsoup 白名单清洗,回显渲染安全 -->
<div v-if="plainText(selected.content)" class="rich-preview" v-html="selected.content"></div>
<div v-else class="summary-empty">
<p>暂无内容</p>
<Button type="primary" ghost @click="showEditNode">
<Icon type="ios-create" />
去编写
</Button>
</div>
<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="640px"
>
<Form ref="refFormData" :model="form" :rules="rules" :label-width="100">
<Divider size="small" orientation="left">基本信息</Divider>
<FormItem label="上级栏目" prop="parentId">
<Select v-model="form.parentId" transfer>
<Option v-for="opt in parentOptions" :key="opt.id" :value="opt.id">
{{ opt.label }}
</Option>
</Select>
<p class="form-tip">任何类型的栏目都可作为上级(外链型也可以);不能选自身或自己的子栏目(会成环)。</p>
</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 label="排序">
<InputNumber v-model="form.sortNo" :min="0" />
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="form.status">
<Radio :label="1">显示</Radio>
<Radio :label="0">隐藏</Radio>
</RadioGroup>
</FormItem>
<Divider size="small" orientation="left">内容设置</Divider>
<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="单页内容">
<div class="editor-wrap">
<template v-if="editorReady">
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
<Editor
class="editor-content"
v-model="form.content"
:defaultConfig="editorConfig"
mode="default"
@onCreated="handleCreated"
/>
</template>
<div v-else class="editor-placeholder">编辑器加载中……</div>
</div>
</FormItem>
<Divider size="small" orientation="left">SEO 设置</Divider>
<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>
</Form>
<template #footer>
<Button v-if="!form.id" type="text" @click="switchToBatch">切换至批量添加</Button>
<Button @click="modalVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="save">保存</Button>
</template>
</Modal>
<!-- 批量添加栏目(UserBase BatchAddGroup 同款:一行一个名称,前端循环调 save) -->
<BatchAddCategory
v-model:visible="batchModal"
:default-pid="curTreeNode?.id ?? 0"
:parent-name="curTreeNode?.id ? curTreeNode?.name : rootLabel"
:default-type="batchDefaultType"
@success="onBatchSuccess"
/>
<!-- 子栏目排序(userbase GroupSort 同款拖拽) -->
<CategorySortModal
v-model:visible="sortModal"
:parent-id="sortParentId"
:parent-name="sortParentName"
:children="sortChildrenSource"
@saved="onSortSaved"
/>
</div>
</template>
<style scoped lang="scss">
.form-tip {
color: #999;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
/* 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 {
position: relative;
}
}
.category-tree-box {
padding: 10px;
display: flex;
flex-direction: column;
.group-tree-search-panel {
flex: 1;
min-height: 0;
}
}
/* ===== 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;
flex-shrink: 0;
.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;
flex: 1;
min-height: 0;
overflow: auto;
.summary-meta {
display: flex;
gap: 16px;
color: #999;
font-size: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 12px;
}
.rich-preview {
line-height: 1.8;
word-break: break-all;
:deep(img) {
max-width: 100%;
}
:deep(table) {
border-collapse: collapse;
td,
th {
border: 1px solid #ccc;
padding: 4px 8px;
}
}
}
.summary-empty {
text-align: center;
color: #999;
padding: 40px 0;
p {
margin-bottom: 12px;
}
}
.summary-actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.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;
}
}
/* 单页富文本编辑器(模态框内):wangEditor v5 高度打在内层 .w-e-text-container */
.editor-wrap {
border: 1px solid #dcdee2;
border-radius: 4px;
z-index: 100;
.editor-toolbar {
border-bottom: 1px solid #dcdee2;
}
.editor-content {
:deep(.w-e-text-container) {
height: 320px !important;
}
overflow-y: hidden;
}
.editor-placeholder {
height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
}
}
.empty-tip {
color: #999;
text-align: center;
padding: 60px 0;
}
</style>