Files
YschcmsFront/src/views/cms/CategoryManage.vue
T
2026-09-07 09:11:37 +08:00

380 lines
12 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 { categoryApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import { CategoryTreeNode } from "@/types/cms";
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除/搜索)+ ztree */
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 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);
}
</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>
<Tooltip content="搜索栏目" placement="top" transfer>
<li class="search-node-li" @click="refTreePanel?.openSearch()">
<Icon type="md-search" />
</li>
</Tooltip>
</ul>
<CategoryTreePanel
ref="refTreePanel"
v-model:curTreeNode="curTreeNode"
:treeApi="loadTreeApi"
:defaultSearchVisible="false"
:showCloseButton="true"
@on-change="onTreeChange"
@on-tree-data="onTreeData"
/>
</div>
<!-- 右侧:选中栏目详情 -->
<div class="main-container">
<div class="info-panel" v-if="selected">
<h3>{{ selected.name }}</h3>
<p>类型:{{ typeDict.find(t => t.value === selected!.type)?.label }}</p>
<p v-if="selected.type === 3">外链:{{ selected.linkUrl }}</p>
<p v-if="selected.type === 2">单页内容:见门户预览</p>
<p>排序号:{{ selected.sortNo }} · 状态:{{ selected.status === 1 ? "显示" : "隐藏" }}</p>
<p v-if="selected.seoTitle">SEO 标题:{{ selected.seoTitle }}</p>
<p v-if="selected.seoDescription">SEO 描述:{{ selected.seoDescription }}</p>
</div>
<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;
}
}
.info-panel {
background: #fff;
border-radius: 4px;
padding: 16px;
h3 {
margin-top: 0;
}
p {
color: #555;
}
}
.empty-tip {
color: #999;
text-align: center;
padding: 60px 0;
}
</style>