feat(resource): 新增急救宣教资源管理、分类标签、审核管理模块

- 资源管理:列表筛选 + 新增/编辑弹窗 + 移动端预览 + 版本历史,已接入后端 RESTful 接口
- 分类与标签:左侧分类树(支持新增子分类/编辑/删除)+ 右侧标签云管理
- 审核管理:待审核/已通过/已驳回/审核记录四个 tab,驳回原因弹窗已接入审核接口
- 开发环境 API 地址切换为同事本地后台
This commit is contained in:
wanghao
2026-06-23 17:54:28 +08:00
parent a47c2c7400
commit a2a0e274f4
12 changed files with 2016 additions and 2 deletions
+422
View File
@@ -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>