Files
tuanwei-admin/src/views/cms/category/index.vue
T
weicw-dshsa da14a1b45c fix(cms): 分类树防环——上级候选剔除自身子树,列表跳过自指向脏数据
与后端 checkParentValid 配套的前端侧防御(分类是自关联结构,成环会让分类树整个渲染不出来):

- category-edit:新增 parentOptions 计算属性,修改分类时把「自己及自己的整棵子树」
  从上级候选中剔除,避免在界面上就能选出环。
- index:parseData 先过滤掉 categoryId === parentId 的记录(toTreeData 遇到这类数据
  会直接 throw,拖垮整个列表),并把被跳过的分类 id 提示给用户,让其去库里修正。
2026-10-13 20:00:37 +08:00

303 lines
8.5 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.
<template>
<div class="ele-body">
<a-card :bordered="false">
<!-- 表格 -->
<ele-pro-table
ref="tableRef"
row-key="categoryId"
:columns="columns"
:datasource="datasource"
:parse-data="parseData"
:need-page="false"
:expand-icon-column-index="1"
:expanded-row-keys="expandedRowKeys"
:scroll="{ x: 1200 }"
cache-key="proArticleCategoryTable"
@done="onDone"
@expand="onExpand"
>
<template #toolbar>
<a-space>
<a-button type="primary" class="ele-btn-icon" @click="openEdit()">
<template #icon>
<plus-outlined />
</template>
<span>新建</span>
</a-button>
<a-button type="dashed" class="ele-btn-icon" @click="expandAll">
展开全部
</a-button>
<a-button type="dashed" class="ele-btn-icon" @click="foldAll">
折叠全部
</a-button>
<!-- 搜索表单 -->
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入搜索关键词"
@search="search"
@pressEnter="search"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'menuType'">
<a-tag v-if="isExternalLink(record.path)" color="red">外链</a-tag>
<a-tag v-else-if="isExternalLink(record.component)" color="orange">
内链
</a-tag>
<a-tag v-else-if="isDirectory(record)" color="blue">目录</a-tag>
<a-tag v-else-if="record.menuType === 0" color="green">分类</a-tag>
<a-tag v-else-if="record.menuType === 1">按钮</a-tag>
</template>
<template v-else-if="column.key === 'title'">
<a-avatar
:size="20"
v-if="record.categoryAvatar"
shape="square"
:src="`https://file.wsdns.cn/${record.categoryAvatar}`"
/>
<a-tooltip :title="`分类ID:${record.categoryId}`">
<span>{{ record.title }}</span>
</a-tooltip>
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 0" color="green">显示</a-tag>
<a-tag v-if="record.status === 1" color="orange">隐藏</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openEdit(null, record.categoryId)">添加</a>
<a-divider type="vertical" />
<a @click="openEdit(record)">修改</a>
<a-divider type="vertical" />
<a-popconfirm
placement="topRight"
title="确定要删除此分类吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<category-edit
v-model:visible="showEdit"
:data="current"
:parent-id="parentId"
:category-list="categoryData"
@done="reload"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue/es';
import { PlusOutlined } from '@ant-design/icons-vue';
import type {
DatasourceFunction,
ColumnItem,
EleProTableDone
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
messageLoading,
toDateString,
isExternalLink,
toTreeData,
eachTreeData
} from 'ele-admin-pro/es';
import type { EleProTable } from 'ele-admin-pro/es';
import CategoryEdit from './components/category-edit.vue';
import {
listArticleCategory,
removeArticleCategory
} from '@/api/cms/category';
import type {
ArticleCategory,
ArticleCategoryParam
} from '@/api/cms/category/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格列配置
const columns = ref<ColumnItem[]>([
{
key: 'index',
width: 48,
align: 'center',
fixed: 'left',
hideInSetting: true,
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
},
{
title: '分类名称',
key: 'title',
showSorterTooltip: false,
ellipsis: true
},
{
title: '排序',
dataIndex: 'sortNumber',
sorter: true,
width: 180,
showSorterTooltip: false
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
sorter: true,
width: 180,
showSorterTooltip: false,
customRender: ({ text }) => ['显示', '隐藏'][text]
},
{
title: '创建时间',
dataIndex: 'createTime',
sorter: true,
showSorterTooltip: false,
ellipsis: true,
width: 180,
customRender: ({ text }) => toDateString(text)
},
{
title: '操作',
key: 'action',
width: 200,
align: 'center'
}
]);
// 当前编辑数据
const current = ref<ArticleCategory | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 上级分类id
const parentId = ref<number>();
// 分类数据
const categoryData = ref<ArticleCategory[]>([]);
// 表格展开的行
const expandedRowKeys = ref<number[]>([]);
// 表格数据源
const datasource: DatasourceFunction = ({ where }) => {
return listArticleCategory({ ...where });
};
/* 数据转为树形结构 */
const parseData = (data: ArticleCategory[]) => {
/*
* 防御性过滤:分类是自关联结构,如果库里存在「自己指向自己」的脏数据,
* toTreeData 会直接 throw,导致整个页面无法渲染(报 data error: {categoryId: x, parentId: x})。
* 这里先剔除这类记录,只影响异常的那一行,不拖垮整个列表。
*/
const validData = (data ?? []).filter((d) => d.categoryId !== d.parentId);
if (validData.length !== (data ?? []).length) {
const badIds = (data ?? [])
.filter((d) => d.categoryId === d.parentId)
.map((d) => d.categoryId)
.join('、');
message.error(
`分类 ${badIds} 的上级分类是它自己,已跳过显示,请在数据库中修正`
);
}
return toTreeData({
data: validData.map((d) => {
return { ...d, key: d.categoryId, value: d.categoryId };
}),
idField: 'categoryId',
parentIdField: 'parentId'
});
};
/* 表格渲染完成回调 */
const onDone: EleProTableDone<ArticleCategory> = ({ data }) => {
categoryData.value = data;
};
/* 刷新表格 */
const reload = (where?: ArticleCategoryParam) => {
tableRef?.value?.reload({ where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: ArticleCategory | null, id?: number) => {
current.value = row ?? null;
parentId.value = id;
showEdit.value = true;
};
const search = (searchText) => {
reload({ title: searchText });
};
/* 删除单个 */
const remove = (row: ArticleCategory) => {
if (row.children?.length) {
message.error('请先删除子节点');
return;
}
const hide = messageLoading('请求中..', 0);
removeArticleCategory(row.categoryId)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 展开全部 */
const expandAll = () => {
let keys: number[] = [];
eachTreeData(categoryData.value, (d) => {
if (d.children && d.children.length && d.categoryId) {
keys.push(d.categoryId);
}
});
expandedRowKeys.value = keys;
};
/* 折叠全部 */
const foldAll = () => {
expandedRowKeys.value = [];
};
/* 点击展开图标时触发 */
const onExpand = (expanded: boolean, record: ArticleCategory) => {
if (expanded) {
expandedRowKeys.value = [
...expandedRowKeys.value,
record.categoryId as number
];
} else {
expandedRowKeys.value = expandedRowKeys.value.filter(
(d) => d !== record.categoryId
);
}
};
/* 判断是否是目录 */
const isDirectory = (d: ArticleCategory) => {
return !!d.children?.length;
};
</script>
<script lang="ts">
export default {
name: 'ArticleCategory'
};
</script>