303 lines
12 KiB
Vue
303 lines
12 KiB
Vue
<template>
|
||
<div class="label-page">
|
||
<a-row :gutter="16">
|
||
<!-- 左侧:分类树 -->
|
||
<a-col :span="7">
|
||
<a-card title="分类管理" :bordered="false" class="card-section">
|
||
<template #extra>
|
||
<a-button type="primary" size="small" @click="handleAddCategory()">
|
||
<template #icon><PlusOutlined /></template>
|
||
新增
|
||
</a-button>
|
||
</template>
|
||
<a-input-search v-model:value="categorySearch" placeholder="搜索分类" style="margin-bottom: 12px" allow-clear />
|
||
<a-spin :spinning="categoryLoading">
|
||
<div class="tree-wrap">
|
||
<a-tree
|
||
v-if="filteredTreeData.length > 0"
|
||
v-model:expandedKeys="expandedKeys"
|
||
v-model:selectedKeys="selectedKeys"
|
||
:tree-data="filteredTreeData"
|
||
:field-names="{ title: 'name', key: 'id', children: 'children' }"
|
||
block-node
|
||
@select="onSelectCategory"
|
||
>
|
||
<template #title="{ name, id, status, resourceCount }">
|
||
<div class="tree-node-content">
|
||
<span class="tree-name">{{ name }}</span>
|
||
<span v-if="resourceCount != null" class="tree-count">({{ resourceCount }})</span>
|
||
<a-tag v-if="status === 2" color="red" style="margin-left: 4px">冻结</a-tag>
|
||
<span class="tree-actions">
|
||
<a-button type="link" size="small" @click.stop="handleEditCategory({ id, name })">编</a-button>
|
||
<a-button type="link" size="small" @click.stop="handleAddTagUnderCategory(id)">+标签</a-button>
|
||
<a-button type="link" size="small" danger @click.stop="handleDeleteCategory({ id, name })">删</a-button>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</a-tree>
|
||
<a-empty v-else description="暂无分类" />
|
||
</div>
|
||
</a-spin>
|
||
</a-card>
|
||
</a-col>
|
||
|
||
<!-- 右侧:标签云 + 权限矩阵 -->
|
||
<a-col :span="17">
|
||
<a-card title="标签管理" :bordered="false" class="card-section">
|
||
<template #extra>
|
||
<a-button type="primary" size="small" @click="handleAddTag">
|
||
<template #icon><PlusOutlined /></template>
|
||
新增标签
|
||
</a-button>
|
||
</template>
|
||
<a-input-search v-model:value="tagSearch" placeholder="搜索标签" style="width: 240px; margin-bottom: 12px" allow-clear />
|
||
<a-spin :spinning="tagLoading">
|
||
<div class="tag-cloud">
|
||
<a-tag
|
||
v-for="tag in filteredTags"
|
||
:key="tag.id"
|
||
:color="getTagColor(tag.id)"
|
||
class="tag-item-cloud"
|
||
closable
|
||
@close.prevent="handleDeleteTag(tag)"
|
||
>
|
||
{{ tag.name }}
|
||
<span v-if="tag.resourceCount != null" class="tag-count">{{ tag.resourceCount }}</span>
|
||
</a-tag>
|
||
<a-empty v-if="filteredTags.length === 0 && !tagLoading" description="暂无标签" />
|
||
</div>
|
||
</a-spin>
|
||
</a-card>
|
||
|
||
<a-card title="分类权限配置" :bordered="false" class="card-section">
|
||
<!-- <a-alert message="权限矩阵接口后端尚未提供,当前为占位展示" type="info" show-icon style="margin-bottom: 12px" />-->
|
||
<a-table :columns="permissionColumns" :data-source="permissionList" :pagination="false" size="middle" bordered>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.dataIndex !== 'role'">
|
||
<a-tag :color="getPermissionColor(record.permissions[column.idx])">
|
||
{{ getPermissionText(record.permissions[column.idx]) }}
|
||
</a-tag>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</a-card>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<!-- 分类新增/编辑弹窗 -->
|
||
<CategoryFormModal @register="registerCategoryModal" @success="loadCategoryTree" />
|
||
<!-- 标签新增/编辑弹窗 -->
|
||
<TagFormModal @register="registerTagModal" @success="loadCategoryTree" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue';
|
||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||
import { useMessage } from '/@/hooks/web/useMessage';
|
||
import { useModal } from '/@/components/Modal';
|
||
import { categoryListAllApi, categoryDeleteApi, tagDeleteApi, buildTree } from './label.api';
|
||
import CategoryFormModal from './components/CategoryFormModal.vue';
|
||
import TagFormModal from './components/TagFormModal.vue';
|
||
|
||
const { createMessage } = useMessage();
|
||
|
||
const [registerCategoryModal, { openModal: openCategoryModal }] = useModal();
|
||
const [registerTagModal, { openModal: openTagModal }] = useModal();
|
||
|
||
// ============ 分类树 ============
|
||
const categoryList = ref<any[]>([]);
|
||
const categoryLoading = ref(false);
|
||
const categorySearch = ref('');
|
||
const expandedKeys = ref<string[]>([]);
|
||
const selectedKeys = ref<string[]>([]);
|
||
|
||
const categoryTree = computed(() => buildTree(categoryList.value));
|
||
|
||
const filteredTreeData = computed(() => {
|
||
if (!categorySearch.value) return categoryTree.value;
|
||
const kw = categorySearch.value.toLowerCase();
|
||
const filter = (list: any[]): any[] =>
|
||
list
|
||
.map((item) => {
|
||
const children = item.children ? filter(item.children) : undefined;
|
||
if (item.name.toLowerCase().includes(kw) || (children && children.length > 0)) {
|
||
return { ...item, children };
|
||
}
|
||
return null;
|
||
})
|
||
.filter(Boolean);
|
||
return filter(categoryTree.value);
|
||
});
|
||
|
||
function onSelectCategory(keys: string[]) {
|
||
// V3:标签数据从分类的 tags 字段取,不再单独调接口
|
||
if (keys.length > 0) {
|
||
const category = categoryList.value.find((c) => c.id === keys[0]);
|
||
tagList.value = category?.tags || [];
|
||
} else {
|
||
tagList.value = [];
|
||
}
|
||
}
|
||
|
||
function handleAddCategory(parentId?: string) {
|
||
openCategoryModal(true, { isUpdate: false, parentId });
|
||
}
|
||
|
||
// V3:分类项后面的「+标签」按钮,新增该分类下的标签
|
||
function handleAddTagUnderCategory(parentId: string) {
|
||
openTagModal(true, { isUpdate: false, parentId });
|
||
}
|
||
|
||
function handleEditCategory(record: any) {
|
||
openCategoryModal(true, { isUpdate: true, record });
|
||
}
|
||
|
||
function handleDeleteCategory(record: any) {
|
||
categoryDeleteApi({ id: record.id }, () => {
|
||
createMessage.success('已删除分类');
|
||
loadCategoryTree();
|
||
});
|
||
}
|
||
|
||
// ============ 标签 ============
|
||
const tagList = ref<any[]>([]);
|
||
const tagLoading = ref(false);
|
||
const tagSearch = ref('');
|
||
|
||
const filteredTags = computed(() => {
|
||
if (!tagSearch.value) return tagList.value;
|
||
return tagList.value.filter((t) => t.name.includes(tagSearch.value));
|
||
});
|
||
|
||
const tagColorPool = ['blue', 'green', 'orange', 'purple', 'red', 'default'];
|
||
function getTagColor(id: string) {
|
||
let hash = 0;
|
||
for (let i = 0; i < id.length; i++) {
|
||
hash = id.charCodeAt(i) + ((hash << 5) - hash);
|
||
}
|
||
return tagColorPool[Math.abs(hash) % tagColorPool.length];
|
||
}
|
||
|
||
function handleAddTag() {
|
||
openTagModal(true, { isUpdate: false });
|
||
}
|
||
|
||
function handleDeleteTag(tag: any) {
|
||
tagDeleteApi({ id: tag.id }, () => {
|
||
createMessage.success('已删除标签');
|
||
loadCategoryTree();
|
||
});
|
||
}
|
||
|
||
// ============ 权限矩阵(mock,后端未提供接口) ============
|
||
const permissionList = ref<any[]>([
|
||
{ role: '系统管理员', permissions: ['rw', 'rw', 'rw', 'rw', 'rw'] },
|
||
{ role: '内容编辑', permissions: ['r', 'rw', 'rw', 'r', 'rw'] },
|
||
{ role: '审核员', permissions: ['r', 'r', 'r', 'r', 'r'] },
|
||
{ role: '普通用户', permissions: ['n', 'n', 'n', 'n', 'n'] },
|
||
]);
|
||
const permissionColumns = [
|
||
{ title: '角色', dataIndex: 'role', align: 'center', width: 120 },
|
||
{ title: '心肺复苏', dataIndex: 'p0', align: 'center', idx: 0 },
|
||
{ title: '创伤处理', dataIndex: 'p1', align: 'center', idx: 1 },
|
||
{ title: '中暑急救', dataIndex: 'p2', align: 'center', idx: 2 },
|
||
{ title: '骨折固定', dataIndex: 'p3', align: 'center', idx: 3 },
|
||
{ title: '烧烫伤', dataIndex: 'p4', align: 'center', idx: 4 },
|
||
];
|
||
|
||
function getPermissionColor(code: string) {
|
||
if (code === 'rw') return 'blue';
|
||
if (code === 'r') return 'default';
|
||
if (code === 'n') return 'red';
|
||
return 'default';
|
||
}
|
||
|
||
function getPermissionText(code: string) {
|
||
if (code === 'rw') return '读写';
|
||
if (code === 'r') return '只读';
|
||
if (code === 'n') return '无权限';
|
||
return code;
|
||
}
|
||
|
||
// ============ 加载 ============
|
||
async function loadCategoryTree() {
|
||
categoryLoading.value = true;
|
||
try {
|
||
const res = await categoryListAllApi();
|
||
categoryList.value = res || [];
|
||
// 默认展开第一层
|
||
if (expandedKeys.value.length === 0 && categoryTree.value.length > 0) {
|
||
expandedKeys.value = [categoryTree.value[0].id];
|
||
}
|
||
// V3:默认选中第一个分类,标签从分类的 tags 字段取
|
||
if (selectedKeys.value.length === 0 && categoryList.value.length > 0) {
|
||
const first = categoryList.value[0];
|
||
selectedKeys.value = [first.id];
|
||
tagList.value = first?.tags || [];
|
||
} else if (selectedKeys.value.length > 0) {
|
||
// 已选中分类,重新拉数据后刷新该分类的标签(新增/编辑/删除标签后回显最新数据)
|
||
const selected = categoryList.value.find((c) => c.id === selectedKeys.value[0]);
|
||
tagList.value = selected?.tags || [];
|
||
}
|
||
} finally {
|
||
categoryLoading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadCategoryTree();
|
||
});
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.label-page {
|
||
padding: 0;
|
||
}
|
||
.card-section {
|
||
margin-bottom: 16px;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||
}
|
||
.tree-wrap {
|
||
min-height: 200px;
|
||
}
|
||
.tree-node-content {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
width: 100%;
|
||
}
|
||
.tree-name {
|
||
flex: 1;
|
||
}
|
||
.tree-count {
|
||
font-size: 11px;
|
||
color: #999;
|
||
margin-left: 4px;
|
||
}
|
||
.tree-actions {
|
||
display: none;
|
||
margin-left: auto;
|
||
}
|
||
:deep(.ant-tree-node-content-wrapper:hover) .tree-actions,
|
||
:deep(.ant-tree-treenode-selected) .tree-actions {
|
||
display: inline-flex;
|
||
}
|
||
.tag-cloud {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
min-height: 60px;
|
||
}
|
||
.tag-item-cloud {
|
||
font-size: 13px;
|
||
padding: 4px 14px;
|
||
cursor: default;
|
||
}
|
||
.tag-count {
|
||
opacity: 0.5;
|
||
font-size: 11px;
|
||
margin-left: 4px;
|
||
}
|
||
</style>
|