feat(resource): 新增急救宣教资源管理、分类标签、审核管理模块
- 资源管理:列表筛选 + 新增/编辑弹窗 + 移动端预览 + 版本历史,已接入后端 RESTful 接口 - 分类与标签:左侧分类树(支持新增子分类/编辑/删除)+ 右侧标签云管理 - 审核管理:待审核/已通过/已驳回/审核记录四个 tab,驳回原因弹窗已接入审核接口 - 开发环境 API 地址切换为同事本地后台
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
<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 }">
|
||||
<div class="tree-node-content">
|
||||
<span class="tree-name">{{ name }}</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="handleAddSubCategory(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 }}
|
||||
</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>
|
||||
|
||||
<!-- 分类新增/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="categoryModalVisible"
|
||||
:title="categoryModalTitle"
|
||||
@ok="handleCategorySubmit"
|
||||
:width="480"
|
||||
:confirm-loading="categorySubmitting"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="分类名称" required>
|
||||
<a-input v-model:value="categoryForm.name" placeholder="请输入分类名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="父级分类">
|
||||
<a-tree-select
|
||||
v-model:value="categoryForm.parentId"
|
||||
:tree-data="categoryTreeSelectData"
|
||||
:field-names="{ label: 'name', value: 'id', children: 'children' }"
|
||||
placeholder="顶级分类(不选)"
|
||||
allow-clear
|
||||
tree-default-expand-all
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="排序号">
|
||||
<a-input-number v-model:value="categoryForm.sortNo" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="categoryForm.description" :rows="2" placeholder="可选" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 标签新增弹窗 -->
|
||||
<a-modal v-model:open="tagModalVisible" title="新增标签" @ok="handleTagSubmit" :width="480" :confirm-loading="tagSubmitting">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="标签名称" required>
|
||||
<a-input v-model:value="tagForm.name" placeholder="请输入标签名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序号">
|
||||
<a-input-number v-model:value="tagForm.sortNo" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="tagForm.description" :rows="2" placeholder="可选" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import {
|
||||
categoryListAllApi,
|
||||
categoryQueryByIdApi,
|
||||
categoryAddApi,
|
||||
categoryEditApi,
|
||||
categoryDeleteApi,
|
||||
tagListAllApi,
|
||||
tagAddApi,
|
||||
tagDeleteApi,
|
||||
buildTree,
|
||||
} from './label.api';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
// ============ 分类树 ============
|
||||
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);
|
||||
});
|
||||
|
||||
const categoryTreeSelectData = computed(() => categoryTree.value);
|
||||
|
||||
function onSelectCategory(keys: string[], info: any) {
|
||||
if (keys.length > 0) {
|
||||
createMessage.info(`已选中分类:${info.node.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddCategory(parentId?: string) {
|
||||
categoryModalTitle.value = '新增分类';
|
||||
categoryForm.id = '';
|
||||
categoryForm.name = '';
|
||||
categoryForm.parentId = parentId || undefined;
|
||||
categoryForm.sortNo = 1;
|
||||
categoryForm.description = '';
|
||||
categoryModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleAddSubCategory(parentId: string) {
|
||||
handleAddCategory(parentId);
|
||||
}
|
||||
|
||||
async function handleEditCategory(record: any) {
|
||||
// 拉详情获取完整字段
|
||||
const detail = await categoryQueryByIdApi({ id: record.id }).catch(() => null);
|
||||
categoryModalTitle.value = '编辑分类';
|
||||
categoryForm.id = detail?.id || record.id;
|
||||
categoryForm.name = detail?.name || record.name;
|
||||
categoryForm.parentId = detail?.parentId || undefined;
|
||||
categoryForm.sortNo = detail?.sortNo ?? 1;
|
||||
categoryForm.description = detail?.description || '';
|
||||
categoryModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleDeleteCategory(record: any) {
|
||||
categoryDeleteApi({ id: record.id }, () => {
|
||||
createMessage.success('已删除分类');
|
||||
loadCategoryTree();
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCategorySubmit() {
|
||||
if (!categoryForm.name) {
|
||||
createMessage.warning('请输入分类名称');
|
||||
return;
|
||||
}
|
||||
categorySubmitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
id: categoryForm.id || undefined,
|
||||
name: categoryForm.name,
|
||||
parentId: categoryForm.parentId,
|
||||
sortNo: categoryForm.sortNo,
|
||||
description: categoryForm.description,
|
||||
status: 1,
|
||||
};
|
||||
if (categoryForm.id) {
|
||||
await categoryEditApi(payload);
|
||||
createMessage.success('已更新分类');
|
||||
} else {
|
||||
await categoryAddApi(payload);
|
||||
createMessage.success('已新增分类');
|
||||
}
|
||||
categoryModalVisible.value = false;
|
||||
loadCategoryTree();
|
||||
} finally {
|
||||
categorySubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 标签 ============
|
||||
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() {
|
||||
tagForm.name = '';
|
||||
tagForm.sortNo = 1;
|
||||
tagForm.description = '';
|
||||
tagModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleDeleteTag(tag: any) {
|
||||
tagDeleteApi({ id: tag.id }, () => {
|
||||
createMessage.success('已删除标签');
|
||||
loadTags();
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTagSubmit() {
|
||||
if (!tagForm.name) {
|
||||
createMessage.warning('请输入标签名称');
|
||||
return;
|
||||
}
|
||||
tagSubmitting.value = true;
|
||||
try {
|
||||
await tagAddApi({
|
||||
name: tagForm.name,
|
||||
sortNo: tagForm.sortNo,
|
||||
description: tagForm.description,
|
||||
status: 1,
|
||||
});
|
||||
createMessage.success('已新增标签');
|
||||
tagModalVisible.value = false;
|
||||
loadTags();
|
||||
} finally {
|
||||
tagSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 权限矩阵(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;
|
||||
}
|
||||
|
||||
// ============ 弹窗状态 ============
|
||||
const categoryModalVisible = ref(false);
|
||||
const categoryModalTitle = ref('新增分类');
|
||||
const categorySubmitting = ref(false);
|
||||
const categoryForm = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
parentId: undefined as string | undefined,
|
||||
sortNo: 1,
|
||||
description: '',
|
||||
});
|
||||
|
||||
const tagModalVisible = ref(false);
|
||||
const tagSubmitting = ref(false);
|
||||
const tagForm = reactive({ name: '', sortNo: 1, description: '' });
|
||||
|
||||
// ============ 加载 ============
|
||||
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];
|
||||
}
|
||||
} finally {
|
||||
categoryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
tagLoading.value = true;
|
||||
try {
|
||||
const res = await tagListAllApi();
|
||||
tagList.value = res || [];
|
||||
} finally {
|
||||
tagLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategoryTree();
|
||||
loadTags();
|
||||
});
|
||||
</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-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;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user