init local code

This commit is contained in:
chenxing
2026-09-07 09:11:37 +08:00
commit a3a75c3680
112 changed files with 24837 additions and 0 deletions
+379
View File
@@ -0,0 +1,379 @@
<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>