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
+4 -2
View File
@@ -11,12 +11,14 @@ VITE_PUBLIC_PATH = /
VITE_DROP_CONSOLE = false
#后台接口父地址(必填)
VITE_GLOB_API_URL=https://xj-api.mcrm.vip:8888
VITE_GLOB_API_URL=http://10.10.20.18
#VITE_GLOB_API_URL=https://xj-api.mcrm.vip:8888
#VITE_GLOB_API_URL=http://192.168.1.223
#VITE_GLOB_API_URL=http://cqyt.test.yg.dt.io
#后台接口全路径地址(必填)
VITE_GLOB_DOMAIN_URL=https://xj-api.mcrm.vip:8888
VITE_GLOB_DOMAIN_URL=http://10.10.20.18
#VITE_GLOB_DOMAIN_URL=https://xj-api.mcrm.vip:8888
#VITE_GLOB_DOMAIN_URL=http://192.168.1.223
#VITE_GLOB_DOMAIN_URL=http://cqyt.test.yg.dt.io
# 接口前缀
+15
View File
@@ -0,0 +1,15 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
pendingList = '/health-emergency/emergency/firstAid/audit/pending',
passedList = '/health-emergency/emergency/firstAid/audit/passed',
rejectedList = '/health-emergency/emergency/firstAid/audit/rejected',
approve = '/health-emergency/emergency/firstAid/audit/approve',
reject = '/health-emergency/emergency/firstAid/audit/reject',
}
export const pendingListApi = (params: any) => defHttp.get({ url: Api.pendingList, params });
export const passedListApi = (params: any) => defHttp.get({ url: Api.passedList, params });
export const rejectedListApi = (params: any) => defHttp.get({ url: Api.rejectedList, params });
export const approveApi = (params: any) => defHttp.post({ url: Api.approve, params });
export const rejectApi = (params: any) => defHttp.post({ url: Api.reject, params });
@@ -0,0 +1,43 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" title="填写驳回原因" :width="520" :minHeight="300" @ok="handleSubmit">
<a-form layout="vertical">
<a-form-item label="驳回原因" required>
<a-textarea v-model:value="reason" :rows="5" placeholder="请填写驳回原因,将通知内容编辑人员修改..." />
</a-form-item>
</a-form>
</BasicModal>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { rejectApi } from '../check.api';
const emit = defineEmits(['success', 'register']);
const { createMessage } = useMessage();
const reason = ref('');
const record = ref<any>({});
const submitting = ref(false);
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
record.value = data?.record || {};
reason.value = '';
});
async function handleSubmit() {
if (!reason.value.trim()) {
createMessage.warning('请填写驳回原因');
return;
}
setModalProps({ confirmLoading: true });
try {
await rejectApi({ id: record.value.id, reason: reason.value });
createMessage.success('已驳回,通知已发送');
emit('success');
closeModal();
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>
+308
View File
@@ -0,0 +1,308 @@
<template>
<div class="check-page">
<a-tabs v-model:activeKey="activeTab" @change="onTabChange">
<a-tab-pane key="pending">
<template #tab>
待审核
<a-badge :count="pendingTotal" :number-style="{ backgroundColor: '#fa8c16' }" :overflow-count="999" />
</template>
<div class="filter-bar">
<a-input v-model:value="pendingFilter.title" placeholder="搜索标题" allow-clear style="width: 240px" @press-enter="loadPending" />
<a-button type="primary" @click="loadPending">查询</a-button>
</div>
<a-spin :spinning="pendingLoading">
<div class="review-list">
<div v-for="item in pendingList" :key="item.id" class="review-card">
<div class="review-card-top">
<div class="review-card-main">
<div class="review-title">{{ item.title }}</div>
<div class="review-meta">
提交人{{ item.createBy || '-' }} | 创建时间{{ item.createTime || '-' }} | 分类{{ item.categoryName || '-' }} | 类型{{ getTypeLabel(item.contentType) }}
</div>
</div>
<div class="review-actions">
<a-button size="small" @click="handlePreview(item)">预览</a-button>
<a-button type="primary" size="small" @click="handlePass(item)">通过</a-button>
<a-button danger size="small" @click="handleReject(item)">驳回</a-button>
</div>
</div>
</div>
<a-empty v-if="!pendingLoading && pendingList.length === 0" description="暂无待审核资源" />
</div>
</a-spin>
</a-tab-pane>
<a-tab-pane key="passed" tab="已通过">
<div class="filter-bar">
<a-input v-model:value="passedFilter.title" placeholder="搜索标题" allow-clear style="width: 240px" @press-enter="loadPassed" />
<a-button type="primary" @click="loadPassed">查询</a-button>
</div>
<a-table
:columns="passedColumns"
:data-source="passedList"
:loading="passedLoading"
:pagination="false"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handlePreview(record)">查看</a-button>
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="rejected" tab="已驳回">
<div class="filter-bar">
<a-input v-model:value="rejectedFilter.title" placeholder="搜索标题" allow-clear style="width: 240px" @press-enter="loadRejected" />
<a-button type="primary" @click="loadRejected">查询</a-button>
</div>
<a-table
:columns="rejectedColumns"
:data-source="rejectedList"
:loading="rejectedLoading"
:pagination="false"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleReEdit(record)">重新编辑</a-button>
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="records" tab="审核记录">
<a-alert message="审核记录接口后端尚未提供独立列表,当前合并展示已通过+已驳回数据" type="info" show-icon style="margin-bottom: 12px" />
<a-table
:columns="recordColumns"
:data-source="recordsList"
:loading="recordsLoading"
:pagination="false"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-tag :color="record._action === 'pass' ? 'green' : 'red'">
{{ record._action === 'pass' ? '通过' : '驳回' }}
</a-tag>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
<PreviewModal @register="registerPreviewModal" />
<RejectReasonModal @register="registerRejectModal" @success="loadPending" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { useModal } from '/@/components/Modal';
import {
pendingListApi,
passedListApi,
rejectedListApi,
approveApi,
rejectApi,
} from './check.api';
import { CONTENT_TYPE } from '/@/views/resource/resource.data';
import PreviewModal from '/@/views/resource/components/PreviewModal.vue';
import RejectReasonModal from './components/RejectReasonModal.vue';
const { createMessage, createConfirm } = useMessage();
const activeTab = ref('pending');
const pendingList = ref<any[]>([]);
const pendingTotal = ref(0);
const pendingLoading = ref(false);
const pendingFilter = reactive({ title: '' });
const passedList = ref<any[]>([]);
const passedLoading = ref(false);
const passedFilter = reactive({ title: '' });
const rejectedList = ref<any[]>([]);
const rejectedLoading = ref(false);
const rejectedFilter = reactive({ title: '' });
const recordsList = ref<any[]>([]);
const recordsLoading = ref(false);
const [registerPreviewModal, { openModal: openPreviewModal }] = useModal();
const [registerRejectModal, { openModal: openRejectModal }] = useModal();
const passedColumns = [
{ title: '标题', dataIndex: 'title' },
{ title: '分类', dataIndex: 'categoryName', align: 'center', width: 120 },
{ title: '审核人', dataIndex: 'auditBy', align: 'center', width: 120 },
{ title: '审核时间', dataIndex: 'auditTime', align: 'center', width: 170 },
{ title: '审核意见', dataIndex: 'auditOpinion' },
{ title: '操作', dataIndex: 'action', align: 'center', width: 100 },
];
const rejectedColumns = [
{ title: '标题', dataIndex: 'title' },
{ title: '分类', dataIndex: 'categoryName', align: 'center', width: 120 },
{ title: '驳回人', dataIndex: 'auditBy', align: 'center', width: 120 },
{ title: '驳回时间', dataIndex: 'auditTime', align: 'center', width: 170 },
{ title: '驳回原因', dataIndex: 'auditRejectReason' },
{ title: '操作', dataIndex: 'action', align: 'center', width: 120 },
];
const recordColumns = [
{ title: '资源名称', dataIndex: 'title' },
{ title: '操作', dataIndex: 'action', align: 'center', width: 100 },
{ title: '审核人', dataIndex: 'auditBy', align: 'center', width: 120 },
{ title: '时间', dataIndex: 'auditTime', align: 'center', width: 170 },
{ title: '意见', dataIndex: 'auditOpinion' },
];
function getTypeLabel(type: string) {
return CONTENT_TYPE[type as keyof typeof CONTENT_TYPE]?.label || type || '-';
}
function handlePreview(record: any) {
openPreviewModal(true, { record });
}
function handlePass(record: any) {
createConfirm({
iconType: 'info',
title: '确认审核通过',
content: `是否通过《${record.title}》?`,
onOk: async () => {
await approveApi({ id: record.id });
createMessage.success('已审核通过');
loadPending();
},
});
}
function handleReject(record: any) {
openRejectModal(true, { record });
}
function handleReEdit(record: any) {
createMessage.info(`重新编辑:${record.title}(跳转到资源管理编辑)`);
}
async function loadPending() {
pendingLoading.value = true;
try {
const res: any = await pendingListApi({ pageNo: 1, pageSize: 100, title: pendingFilter.title || undefined });
pendingList.value = res?.records || [];
pendingTotal.value = res?.total || 0;
} finally {
pendingLoading.value = false;
}
}
async function loadPassed() {
passedLoading.value = true;
try {
const res: any = await passedListApi({ pageNo: 1, pageSize: 100, title: passedFilter.title || undefined });
passedList.value = res?.records || [];
} finally {
passedLoading.value = false;
}
}
async function loadRejected() {
rejectedLoading.value = true;
try {
const res: any = await rejectedListApi({ pageNo: 1, pageSize: 100, title: rejectedFilter.title || undefined });
rejectedList.value = res?.records || [];
} finally {
rejectedLoading.value = false;
}
}
async function loadRecords() {
recordsLoading.value = true;
try {
const [passed, rejected]: any = await Promise.all([
passedListApi({ pageNo: 1, pageSize: 100 }),
rejectedListApi({ pageNo: 1, pageSize: 100 }),
]);
const passedRecords = (passed?.records || []).map((item: any) => ({ ...item, _action: 'pass' }));
const rejectedRecords = (rejected?.records || []).map((item: any) => ({ ...item, _action: 'reject' }));
recordsList.value = [...passedRecords, ...rejectedRecords];
} finally {
recordsLoading.value = false;
}
}
function onTabChange(key: string) {
if (key === 'records' && recordsList.value.length === 0) {
loadRecords();
}
}
onMounted(() => {
loadPending();
loadPassed();
loadRejected();
});
</script>
<style lang="less" scoped>
.check-page {
background: #fff;
padding: 16px 20px;
}
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.review-list {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
}
.review-card {
border: 1px solid #f0f0f0;
border-radius: 2px;
padding: 16px 20px;
transition: all 0.2s;
background: #fff;
&:hover {
border-color: #1890ff;
box-shadow: 0 1px 2px rgba(24, 144, 255, 0.08);
}
}
.review-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.review-card-main {
flex: 1;
}
.review-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
color: rgba(0, 0, 0, 0.85);
}
.review-meta {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.review-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
margin-left: 16px;
}
</style>
+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>
+118
View File
@@ -0,0 +1,118 @@
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
const { createConfirm } = useMessage();
enum Api {
// 分类
categoryList = '/health-emergency/emergency/firstAid/category/list',
categoryListAll = '/health-emergency/emergency/firstAid/category/listAll',
categoryListRoot = '/health-emergency/emergency/firstAid/category/listRoot',
categoryListChildren = '/health-emergency/emergency/firstAid/category/listChildren',
categoryQueryById = '/health-emergency/emergency/firstAid/category/queryById',
categoryAdd = '/health-emergency/emergency/firstAid/category/add',
categoryEdit = '/health-emergency/emergency/firstAid/category/edit',
categoryDelete = '/health-emergency/emergency/firstAid/category/delete',
categoryDeleteBatch = '/health-emergency/emergency/firstAid/category/deleteBatch',
// 标签
tagList = '/health-emergency/emergency/firstAid/tag/list',
tagListAll = '/health-emergency/emergency/firstAid/tag/listAll',
tagQueryById = '/health-emergency/emergency/firstAid/tag/queryById',
tagAdd = '/health-emergency/emergency/firstAid/tag/add',
tagEdit = '/health-emergency/emergency/firstAid/tag/edit',
tagDelete = '/health-emergency/emergency/firstAid/tag/delete',
tagDeleteBatch = '/health-emergency/emergency/firstAid/tag/deleteBatch',
}
// ============ 分类 ============
export const categoryListApi = (params: any) => defHttp.get({ url: Api.categoryList, params });
export const categoryListAllApi = (params?: any) => defHttp.get({ url: Api.categoryListAll, params });
export const categoryListRootApi = (params?: any) => defHttp.get({ url: Api.categoryListRoot, params });
export const categoryListChildrenApi = (params: any) => defHttp.get({ url: Api.categoryListChildren, params });
export const categoryQueryByIdApi = (params: any) => defHttp.get({ url: Api.categoryQueryById, params });
export const categoryAddApi = (params: any) => defHttp.post({ url: Api.categoryAdd, params });
export const categoryEditApi = (params: any) => defHttp.post({ url: Api.categoryEdit, params });
export const categoryDeleteApi = (params: any, handleSuccess: any) => {
createConfirm({
iconType: 'warning',
title: '确认删除',
content: '是否删除该分类?',
onOk: async () => {
await defHttp.post({ url: Api.categoryDelete, params });
handleSuccess();
},
});
};
export const categoryDeleteBatchApi = (params: any, handleSuccess: any) => {
createConfirm({
iconType: 'warning',
title: '批量删除',
content: `是否删除选中的 ${params?.length || 0} 个分类?`,
onOk: async () => {
await defHttp.post({ url: Api.categoryDeleteBatch, params });
handleSuccess();
},
});
};
// ============ 标签 ============
export const tagListApi = (params: any) => defHttp.get({ url: Api.tagList, params });
export const tagListAllApi = (params?: any) => defHttp.get({ url: Api.tagListAll, params });
export const tagQueryByIdApi = (params: any) => defHttp.get({ url: Api.tagQueryById, params });
export const tagAddApi = (params: any) => defHttp.post({ url: Api.tagAdd, params });
export const tagEditApi = (params: any) => defHttp.post({ url: Api.tagEdit, params });
export const tagDeleteApi = (params: any, handleSuccess: any) => {
createConfirm({
iconType: 'warning',
title: '确认删除',
content: '是否删除该标签?',
onOk: async () => {
await defHttp.post({ url: Api.tagDelete, params });
handleSuccess();
},
});
};
export const tagDeleteBatchApi = (params: any, handleSuccess: any) => {
createConfirm({
iconType: 'warning',
title: '批量删除',
content: `是否删除选中的 ${params?.length || 0} 个标签?`,
onOk: async () => {
await defHttp.post({ url: Api.tagDeleteBatch, params });
handleSuccess();
},
});
};
// ============ 工具:扁平列表转树 ============
export function buildTree(list: any[]): any[] {
const map = new Map<string, any>();
const roots: any[] = [];
list.forEach((item) => {
map.set(item.id, { ...item, children: [] });
});
list.forEach((item) => {
const node = map.get(item.id);
if (item.parentId && map.has(item.parentId)) {
map.get(item.parentId).children.push(node);
} else {
roots.push(node);
}
});
// 移除空 children
const clean = (nodes: any[]) => {
nodes.forEach((n) => {
if (n.children.length === 0) {
delete n.children;
} else {
clean(n.children);
}
});
return nodes;
};
return clean(roots);
}
@@ -0,0 +1,143 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" title="移动端预览" :width="420" :minHeight="500" :showOkBtn="false" cancelText="关闭">
<div class="preview-wrap">
<div class="preview-phone">
<div class="phone-bar">
<span class="phone-status">急救宣教资源库</span>
<span class="phone-time">9:41</span>
</div>
<div class="phone-content">
<div class="phone-cover">
<img v-if="record.coverImage" :src="record.coverImage" class="cover-img" />
<span v-else>{{ typeIcon }}</span>
</div>
<div class="phone-tags">
<a-tag v-if="record.categoryName" color="blue">{{ record.categoryName }}</a-tag>
<a-tag v-if="record.contentType">{{ typeLabel }}</a-tag>
</div>
<div class="phone-title">{{ record.title }}</div>
<div class="phone-meta">
<span>🕐 {{ record.createTime || '-' }}</span>
<span style="margin-left: 8px">👁 {{ record.viewCount || 0 }}次阅读</span>
</div>
<div class="phone-body">
{{ record.summary || '暂无摘要' }}
</div>
<div class="phone-action">
<a-button type="primary" block> 播放视频</a-button>
</div>
<div class="phone-footer">
<span>👍 {{ Math.floor((record.viewCount || 0) / 6) }}</span>
<span> {{ record.favoriteCount || 0 }}</span>
<span>🔗 分享</span>
</div>
</div>
</div>
</div>
</BasicModal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { CONTENT_TYPE } from '../resource.data';
const record = ref<any>({});
const typeIcon = computed(() => {
return CONTENT_TYPE[record.value.contentType as keyof typeof CONTENT_TYPE]?.icon || '📄';
});
const typeLabel = computed(() => {
return CONTENT_TYPE[record.value.contentType as keyof typeof CONTENT_TYPE]?.label || record.value.contentType;
});
const [registerModal] = useModalInner(async (data) => {
record.value = data?.record || {};
});
</script>
<style lang="less" scoped>
.preview-wrap {
background: #f5f5f5;
padding: 24px;
display: flex;
justify-content: center;
}
.preview-phone {
width: 300px;
border: 10px solid #001529;
border-radius: 32px;
background: #fff;
overflow: hidden;
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px rgba(0, 0, 0, 0.08);
}
.phone-bar {
background: #001529;
height: 40px;
display: flex;
align-items: center;
padding: 0 16px;
}
.phone-status {
color: rgba(255, 255, 255, 0.65);
font-size: 12px;
flex: 1;
}
.phone-time {
color: #fff;
font-size: 12px;
}
.phone-content {
padding: 16px;
}
.phone-cover {
width: 100%;
height: 140px;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 32px;
margin-bottom: 12px;
overflow: hidden;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.phone-tags {
margin-bottom: 8px;
}
.phone-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 8px;
color: rgba(0, 0, 0, 0.85);
}
.phone-meta {
font-size: 11px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 12px;
}
.phone-body {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.8;
}
.phone-action {
margin-top: 16px;
}
.phone-footer {
display: flex;
gap: 20px;
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid #f0f0f0;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
@@ -0,0 +1,258 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" :title="title" :width="900" :minHeight="500" :useDefaultFooter="false" @ok="handleSubmit">
<div class="resource-form">
<a-form layout="vertical" :model="formState">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="标题" required>
<a-input v-model:value="formState.title" placeholder="请输入资源标题" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="内容类型" required>
<a-select v-model:value="formState.contentType" :options="typeOptions" placeholder="请选择内容类型" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="摘要">
<a-input v-model:value="formState.summary" placeholder="请输入资源摘要(一句话简介)" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属分类" required>
<a-select v-model:value="formState.categoryId" :options="categoryOptions" placeholder="请选择分类" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="适用场景">
<a-select v-model:value="formState.applicableScenario" :options="sceneOptions" placeholder="请选择适用场景" allow-clear />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="标签">
<a-select
v-model:value="formState.tagIdList"
mode="multiple"
:options="tagOptions"
placeholder="请选择标签"
:field-names="{ label: 'name', value: 'id' }"
allow-clear
/>
</a-form-item>
<a-form-item label="正文内容" required>
<a-textarea v-model:value="formState.content" :rows="5" placeholder="请输入急救知识内容,支持富文本编辑..." />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="封面图">
<div class="upload-area">
<div class="upload-icon">🖼</div>
<div class="upload-text">点击或拖拽上传封面图</div>
<div class="upload-hint">支持 JPG/PNG建议尺寸 750×420px</div>
</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="附件/媒体文件">
<div class="upload-area">
<div class="upload-icon">📎</div>
<div class="upload-text">点击或拖拽上传文件</div>
<div class="upload-hint">支持 MP4/MP3/PDF单文件最大 500MB</div>
</div>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="生效时间">
<a-date-picker v-model:value="formState.effectiveTime" showTime valueFormat="YYYY-MM-DD HH:mm:ss" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="失效时间">
<a-date-picker v-model:value="formState.expiryTime" showTime valueFormat="YYYY-MM-DD HH:mm:ss" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
<template #appendFooter>
<a-button @click="closeModal">取消</a-button>
<a-button @click="handleSaveDraft">保存草稿</a-button>
<a-button type="primary" @click="handleSubmit">提交审核</a-button>
</template>
</BasicModal>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { TYPE_OPTIONS, SCENE_OPTIONS } from '../resource.data';
import { addApi, editApi, submitForReviewApi, categoryListAllApi, tagListAllApi } from '../resource.api';
const emit = defineEmits(['success', 'register']);
const { createMessage } = useMessage();
const title = ref('新增急救宣教资源');
const categoryOptions = ref<any[]>([]);
const tagOptions = ref<any[]>([]);
const formState = reactive({
id: '',
title: '',
summary: '',
contentType: 'IMAGE_TEXT',
categoryId: '' as string,
applicableScenario: '' as string,
tagIdList: [] as string[],
content: '',
coverImage: '',
fileUrl: '',
effectiveTime: null as any,
expiryTime: null as any,
});
const typeOptions = TYPE_OPTIONS;
const sceneOptions = SCENE_OPTIONS;
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
if (data?.isUpdate) {
title.value = '编辑急救宣教资源';
Object.assign(formState, data.record);
// 后端 tags 是逗号分隔字符串,前端需要拆成数组
if (formState.tagIdList && typeof (formState as any).tags === 'string') {
formState.tagIdList = ((formState as any).tags || '').split(',').filter(Boolean);
}
} else {
title.value = '新增急救宣教资源';
resetForm();
}
});
function resetForm() {
formState.id = '';
formState.title = '';
formState.summary = '';
formState.contentType = 'IMAGE_TEXT';
formState.categoryId = '';
formState.applicableScenario = '';
formState.tagIdList = [];
formState.content = '';
formState.coverImage = '';
formState.fileUrl = '';
formState.effectiveTime = null;
formState.expiryTime = null;
}
function buildPayload(): any {
return {
id: formState.id || undefined,
title: formState.title,
summary: formState.summary,
contentType: formState.contentType,
categoryId: formState.categoryId,
applicableScenario: formState.applicableScenario,
tags: formState.tagIdList.join(','),
content: formState.content,
coverImage: formState.coverImage,
fileUrl: formState.fileUrl,
effectiveTime: formState.effectiveTime,
expiryTime: formState.expiryTime,
};
}
function validate(): boolean {
if (!formState.title) {
createMessage.warning('请输入资源标题');
return false;
}
if (!formState.categoryId) {
createMessage.warning('请选择所属分类');
return false;
}
if (!formState.content) {
createMessage.warning('请输入正文内容');
return false;
}
return true;
}
async function handleSaveDraft() {
if (!formState.title) {
createMessage.warning('请输入资源标题');
return;
}
setModalProps({ confirmLoading: true });
try {
if (formState.id) {
await editApi(buildPayload());
} else {
await addApi(buildPayload());
}
createMessage.success('已保存为草稿');
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
async function handleSubmit() {
if (!validate()) return;
setModalProps({ confirmLoading: true });
try {
const id = formState.id;
if (id) {
await editApi(buildPayload());
await submitForReviewApi({ id });
} else {
const res: any = await addApi(buildPayload());
await submitForReviewApi({ id: res?.id || res });
}
createMessage.success('已提交审核');
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
onMounted(async () => {
categoryOptions.value = (await categoryListAllApi()) || [];
tagOptions.value = (await tagListAllApi()) || [];
});
</script>
<style lang="less" scoped>
.resource-form {
padding: 4px 4px 0;
}
.upload-area {
border: 1px dashed #d9d9d9;
border-radius: 2px;
padding: 24px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #fafafa;
&:hover {
border-color: #1890ff;
background: #e6f7ff;
}
}
.upload-icon {
font-size: 32px;
color: rgba(0, 0, 0, 0.25);
}
.upload-text {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
margin-top: 8px;
}
.upload-hint {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
}
</style>
@@ -0,0 +1,120 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" title="版本历史" :width="560" :minHeight="400" :showOkBtn="false" cancelText="关闭">
<a-spin :spinning="loading">
<div class="version-list">
<div v-for="(item, idx) in versions" :key="item.id" class="version-item">
<div class="version-num" :class="{ 'version-old': idx > 0 }">v{{ item.versionNo }}</div>
<div class="version-main">
<div class="version-op">{{ item.changeDescription || item.title }}</div>
<div class="version-meta">
{{ item.createBy }} · {{ item.createTime }}
<a @click="handleDiff(item)">查看差异</a>
<a v-if="idx > 0" @click="handleRollback(item)">回滚</a>
</div>
</div>
</div>
<a-empty v-if="!loading && versions.length === 0" description="暂无版本记录" />
</div>
</a-spin>
</BasicModal>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { versionListApi, versionRollbackApi } from '../resource.api';
const { createMessage, createConfirm } = useMessage();
const record = ref<any>({});
const versions = ref<any[]>([]);
const loading = ref(false);
const [registerModal] = useModalInner(async (data) => {
record.value = data?.record || {};
await loadVersions();
});
async function loadVersions() {
if (!record.value.id) return;
loading.value = true;
try {
const res = await versionListApi({ resourceId: record.value.id });
versions.value = res || [];
} finally {
loading.value = false;
}
}
function handleDiff(item: any) {
createMessage.info(`查看差异:v${item.versionNo}`);
}
function handleRollback(item: any) {
createConfirm({
iconType: 'warning',
title: '确认回滚',
content: `是否回滚至 v${item.versionNo}?回滚前会自动保存当前状态为新版本。`,
onOk: async () => {
await versionRollbackApi({ versionId: item.id });
createMessage.success('已回滚');
await loadVersions();
},
});
}
</script>
<style lang="less" scoped>
.version-list {
padding: 4px 0;
}
.version-item {
display: flex;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.version-num {
font-size: 12px;
background: #e6f7ff;
color: #1890ff;
padding: 0 7px;
border-radius: 2px;
white-space: nowrap;
border: 1px solid #91d5ff;
line-height: 20px;
height: fit-content;
}
.version-old {
background: #fafafa;
color: rgba(0, 0, 0, 0.45);
border-color: #d9d9d9;
}
.version-main {
flex: 1;
}
.version-op {
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
}
.version-meta {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 3px;
a {
color: #1890ff;
cursor: pointer;
margin-left: 8px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
</style>
+348
View File
@@ -0,0 +1,348 @@
<template>
<BasicTable @register="registerTable" :rowSelection="{ type: 'checkbox', onChange: onSelectChange }">
<template #tableTitle>
<a-button type="primary" preIcon="ant-design:plus-outlined" @click="handleAdd"> 新增资源 </a-button>
<a-button type="primary" preIcon="ant-design:export-outlined" @click="handleExport"> 导出 </a-button>
<template v-if="selectedRowKeys.length > 0">
<a-button type="primary" preIcon="ant-design:cloud-upload-outlined" @click="handleBatchPublish"> 批量发布 </a-button>
<a-button preIcon="ant-design:cloud-download-outlined" @click="handleBatchOffline"> 批量下架 </a-button>
<a-button danger preIcon="ant-design:delete-outlined" @click="handleBatchDelete"> 批量删除 </a-button>
<span class="batch-tip">已选中 {{ selectedRowKeys.length }} </span>
</template>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'coverImage'">
<div class="cover-box" :style="coverStyle(record)">
<img v-if="record.coverImage" :src="record.coverImage" class="cover-img" />
<span v-else>{{ typeIcon(record.contentType) }}</span>
</div>
</template>
<template v-if="column.dataIndex === 'title'">
<div class="title-cell">
<div class="title-main">{{ record.title }}</div>
<div class="title-sub">{{ record.summary }}</div>
</div>
</template>
<template v-if="column.dataIndex === 'categoryName'">
<a-tag v-if="record.categoryName" color="blue">{{ record.categoryName }}</a-tag>
</template>
<template v-if="column.dataIndex === 'contentType'">
<a-tag :color="typeColor(record.contentType)">{{ typeIcon(record.contentType) }} {{ typeLabel(record.contentType) }}</a-tag>
</template>
<template v-if="column.dataIndex === 'tagNameList'">
<a-tag v-for="tag in (record.tagNameList || [])" :key="tag" color="default">{{ tag }}</a-tag>
</template>
<template v-if="column.dataIndex === 'statusDisplay'">
<a-badge :status="getStatusInfo(record).color" :text="getStatusInfo(record).text" />
</template>
<template v-if="column.dataIndex === 'viewFavorite'">
<span>{{ record.viewCount || 0 }} / {{ record.favoriteCount || 0 }}</span>
</template>
</template>
<template #action="{ record }">
<TableAction :actions="getTableAction(record)" :dropDownActions="getDropDownAction(record)" />
</template>
</BasicTable>
<ResourceFormModal @register="registerFormModal" @success="reload" />
<PreviewModal @register="registerPreviewModal" />
<VersionHistoryModal @register="registerVersionModal" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import BasicTable from '/@/components/Table/src/BasicTable.vue';
import { TableAction } from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import { useModal } from '/@/components/Modal';
import {
columns,
searchSchema,
CONTENT_TYPE,
STATUS,
AUDIT_STATUS,
getResourceDisplayStatus,
} from './resource.data';
import {
listApi,
deleteApi,
batchDeleteApi,
submitForReviewApi,
takeDownApi,
publishApi,
copyApi,
batchPublishApi,
batchTakeDownApi,
} from './resource.api';
import ResourceFormModal from './components/ResourceFormModal.vue';
import PreviewModal from './components/PreviewModal.vue';
import VersionHistoryModal from './components/VersionHistoryModal.vue';
const { createMessage, createConfirm } = useMessage();
const selectedRowKeys = ref<string[]>([]);
const [registerFormModal, { openModal: openFormModal }] = useModal();
const [registerPreviewModal, { openModal: openPreviewModal }] = useModal();
const [registerVersionModal, { openModal: openVersionModal }] = useModal();
const { tableContext } = useListPage({
tableProps: {
title: '急救宣教资源列表',
api: listApi,
columns,
canResize: false,
showIndexColumn: false,
rowKey: 'id',
formConfig: {
schemas: searchSchema,
autoSubmitOnEnter: true,
showAdvancedButton: false,
},
actionColumn: {
width: 280,
fixed: 'right',
},
afterFetch: (data) => {
data.forEach((item: any) => {
const display = getResourceDisplayStatus(item.status, item.auditStatus);
item.statusDisplay = display;
});
return data;
},
},
});
const [registerTable, { reload }] = tableContext;
function onSelectChange(keys: string[]) {
selectedRowKeys.value = keys;
}
function coverStyle(record: Recordable) {
const t = CONTENT_TYPE[record.contentType as keyof typeof CONTENT_TYPE];
const bgMap: Record<string, string> = {
IMAGE_TEXT: '#fa8c16',
VIDEO: '#001529',
AUDIO: '#8c8c8c',
PDF: '#434343',
};
return { background: bgMap[record.contentType] || '#001529' };
}
function typeColor(type: string) {
return CONTENT_TYPE[type as keyof typeof CONTENT_TYPE]?.color || 'default';
}
function typeIcon(type: string) {
return CONTENT_TYPE[type as keyof typeof CONTENT_TYPE]?.icon || '';
}
function typeLabel(type: string) {
return CONTENT_TYPE[type as keyof typeof CONTENT_TYPE]?.label || type;
}
function getStatusInfo(record: Recordable) {
return getResourceDisplayStatus(record.status, record.auditStatus);
}
// 状态判断辅助
const isPublished = (r: Recordable) => r.status === STATUS.PUBLISHED;
const isOffline = (r: Recordable) => r.status === STATUS.OFFLINE;
const isPending = (r: Recordable) => r.auditStatus === AUDIT_STATUS.PENDING;
const isRejected = (r: Recordable) => r.auditStatus === AUDIT_STATUS.REJECTED;
const isDraft = (r: Recordable) => r.status === STATUS.DRAFT && r.auditStatus == null;
function getTableAction(record: Recordable) {
const actions: any[] = [
{
label: '预览',
onClick: () => openPreviewModal(true, { record }),
},
{
label: '编辑',
onClick: () => openFormModal(true, { isUpdate: true, record }),
},
];
if (isPublished(record)) {
actions.push({
label: '下架',
color: 'error',
popConfirm: {
title: `是否下架《${record.title}》?`,
confirm: () => handleOffline(record),
},
});
} else if (isOffline(record)) {
actions.push({
label: '上架',
onClick: () => handlePublish(record),
});
} else if (isPending(record)) {
// 待审核状态不显示额外按钮
} else if (isRejected(record)) {
actions.push({
label: '提交审核',
onClick: () => handleSubmitReview(record),
});
} else if (isDraft(record)) {
actions.push({
label: '提交审核',
onClick: () => handleSubmitReview(record),
});
actions.push({
label: '删除',
color: 'error',
popConfirm: {
title: `是否删除《${record.title}》?`,
confirm: () => handleDelete(record),
},
});
} else if (record.status === STATUS.DRAFT && record.auditStatus === AUDIT_STATUS.PASSED) {
// 已通过的草稿状态(编辑后自动退回草稿),可重新提交
actions.push({
label: '提交审核',
onClick: () => handleSubmitReview(record),
});
}
return actions;
}
function getDropDownAction(record: Recordable) {
const dropdown: any[] = [
{
label: '版本历史',
onClick: () => openVersionModal(true, { record }),
},
];
// 非草稿状态可复制
if (!isDraft(record)) {
dropdown.push({
label: '复制',
onClick: () => handleCopy(record),
});
}
// 已驳回或已下架可删除
if (isRejected(record) || isOffline(record)) {
dropdown.push({
label: '删除',
color: 'error',
popConfirm: {
title: `是否删除《${record.title}》?`,
confirm: () => handleDelete(record),
},
});
}
return dropdown;
}
function handleAdd() {
openFormModal(true, { isUpdate: false });
}
function handleExport() {
createMessage.info('导出功能待后端提供接口');
}
async function handleOffline(record: Recordable) {
await takeDownApi({ id: record.id });
createMessage.success('已下架');
reload();
}
async function handlePublish(record: Recordable) {
await publishApi({ id: record.id });
createMessage.success('已上架');
reload();
}
async function handleSubmitReview(record: Recordable) {
await submitForReviewApi({ id: record.id });
createMessage.success('已提交审核');
reload();
}
async function handleCopy(record: Recordable) {
await copyApi({ id: record.id });
createMessage.success('已复制为草稿');
reload();
}
function handleDelete(record: Recordable) {
deleteApi({ id: record.id }, () => {
createMessage.success('已删除');
reload();
});
}
function handleBatchPublish() {
createConfirm({
iconType: 'info',
title: '批量发布',
content: `是否发布选中的 ${selectedRowKeys.value.length} 条资源?`,
onOk: async () => {
await batchPublishApi(selectedRowKeys.value);
createMessage.success('批量发布成功');
selectedRowKeys.value = [];
reload();
},
});
}
function handleBatchOffline() {
createConfirm({
iconType: 'warning',
title: '批量下架',
content: `是否下架选中的 ${selectedRowKeys.value.length} 条资源?`,
onOk: async () => {
await batchTakeDownApi(selectedRowKeys.value);
createMessage.success('批量下架成功');
selectedRowKeys.value = [];
reload();
},
});
}
function handleBatchDelete() {
batchDeleteApi(selectedRowKeys.value, () => {
createMessage.success('已批量删除');
selectedRowKeys.value = [];
reload();
});
}
</script>
<style lang="less" scoped>
.batch-tip {
margin-left: 8px;
color: #1890ff;
font-size: 13px;
}
.cover-box {
width: 50px;
height: 36px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin: 0 auto;
overflow: hidden;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.title-cell {
.title-main {
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.title-sub {
font-size: 11px;
color: #999;
margin-top: 2px;
}
}
</style>
+71
View File
@@ -0,0 +1,71 @@
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
const { createConfirm } = useMessage();
enum Api {
// 资源 CRUD
list = '/health-emergency/emergency/firstAid/resource/list',
queryById = '/health-emergency/emergency/firstAid/resource/queryById',
add = '/health-emergency/emergency/firstAid/resource/add',
edit = '/health-emergency/emergency/firstAid/resource/edit',
delete = '/health-emergency/emergency/firstAid/resource/delete',
deleteBatch = '/health-emergency/emergency/firstAid/resource/deleteBatch',
submitForReview = '/health-emergency/emergency/firstAid/resource/submitForReview',
takeDown = '/health-emergency/emergency/firstAid/resource/takeDown',
publish = '/health-emergency/emergency/firstAid/resource/publish',
batchTakeDown = '/health-emergency/emergency/firstAid/resource/batchTakeDown',
batchPublish = '/health-emergency/emergency/firstAid/resource/batchPublish',
incrementView = '/health-emergency/emergency/firstAid/resource/incrementView',
copy = '/health-emergency/emergency/firstAid/resource/copy',
// 版本
versionList = '/health-emergency/emergency/firstAid/version/list',
versionRollback = '/health-emergency/emergency/firstAid/version/rollback',
// 分类 & 标签(下拉用)
categoryListAll = '/health-emergency/emergency/firstAid/category/listAll',
tagListAll = '/health-emergency/emergency/firstAid/tag/listAll',
}
export const listApi = (params: any) => defHttp.get({ url: Api.list, params });
export const queryByIdApi = (params: any) => defHttp.get({ url: Api.queryById, params });
export const addApi = (params: any) => defHttp.post({ url: Api.add, params });
export const editApi = (params: any) => defHttp.post({ url: Api.edit, params });
export const submitForReviewApi = (params: any) => defHttp.post({ url: Api.submitForReview, params });
export const takeDownApi = (params: any) => defHttp.post({ url: Api.takeDown, params });
export const publishApi = (params: any) => defHttp.post({ url: Api.publish, params });
export const batchTakeDownApi = (params: any) => defHttp.post({ url: Api.batchTakeDown, params });
export const batchPublishApi = (params: any) => defHttp.post({ url: Api.batchPublish, params });
export const incrementViewApi = (params: any) => defHttp.post({ url: Api.incrementView, params });
export const copyApi = (params: any) => defHttp.post({ url: Api.copy, params });
export const versionListApi = (params: any) => defHttp.get({ url: Api.versionList, params });
export const versionRollbackApi = (params: any) => defHttp.post({ url: Api.versionRollback, params });
export const categoryListAllApi = (params?: any) => defHttp.get({ url: Api.categoryListAll, params });
export const tagListAllApi = (params?: any) => defHttp.get({ url: Api.tagListAll, params });
export const deleteApi = (params: any, handleSuccess: any) => {
createConfirm({
iconType: 'warning',
title: '确认删除',
content: '是否将选中资源移入回收站?',
okText: '确认',
cancelText: '取消',
onOk: async () => {
await defHttp.post({ url: Api.delete, params });
handleSuccess();
},
});
};
export const batchDeleteApi = (params: any, handleSuccess: any) => {
createConfirm({
iconType: 'warning',
title: '批量删除',
content: `是否将选中的 ${params?.length || 0} 条资源移入回收站?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
await defHttp.post({ url: Api.deleteBatch, params });
handleSuccess();
},
});
};
+166
View File
@@ -0,0 +1,166 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
// 发布状态
export const STATUS = {
DRAFT: 0,
PUBLISHED: 1,
OFFLINE: 2,
};
// 审核状态
export const AUDIT_STATUS = {
PENDING: 10,
PASSED: 11,
REJECTED: 12,
};
// 复合状态(搜索用)
export const COMBINED_STATUS = {
DRAFT: '0',
PUBLISHED: '1',
OFFLINE: '2',
PENDING: '10',
PASSED: '11',
REJECTED: '12',
};
// 内容类型
export const CONTENT_TYPE = {
IMAGE_TEXT: { label: '图文', color: 'orange', icon: '📄' },
VIDEO: { label: '视频', color: 'purple', icon: '🎬' },
AUDIO: { label: '音频', color: 'green', icon: '🎧' },
PDF: { label: 'PDF', color: 'red', icon: '📑' },
};
// 根据发布状态+审核状态返回展示信息
export function getResourceDisplayStatus(status: number, auditStatus: number | null) {
if (status === STATUS.PUBLISHED) return { text: '已发布', color: 'success' };
if (status === STATUS.OFFLINE) return { text: '已下架', color: 'error' };
if (auditStatus === AUDIT_STATUS.PENDING) return { text: '待审核', color: 'warning' };
if (auditStatus === AUDIT_STATUS.REJECTED) return { text: '已驳回', color: 'error' };
return { text: '草稿', color: 'default' };
}
// 内容类型选项
export const TYPE_OPTIONS = [
{ label: '图文', value: 'IMAGE_TEXT' },
{ label: '视频', value: 'VIDEO' },
{ label: '音频', value: 'AUDIO' },
{ label: 'PDF', value: 'PDF' },
];
// 复合状态选项
export const STATUS_OPTIONS = [
{ label: '草稿', value: '0' },
{ label: '已发布', value: '1' },
{ label: '已下架', value: '2' },
{ label: '待审核', value: '10' },
{ label: '已通过', value: '11' },
{ label: '已驳回', value: '12' },
];
// 适用场景
export const SCENE_OPTIONS = [
{ label: '油田现场', value: '油田现场' },
{ label: '日常生活', value: '日常生活' },
{ label: '高原环境', value: '高原环境' },
{ label: '高温环境', value: '高温环境' },
];
// 表格列定义
export const columns: BasicColumn[] = [
{
title: '封面',
dataIndex: 'coverImage',
width: 80,
align: 'center',
},
{
title: '标题',
dataIndex: 'title',
align: 'left',
},
{
title: '分类',
dataIndex: 'categoryName',
width: 100,
align: 'center',
},
{
title: '类型',
dataIndex: 'contentType',
width: 90,
align: 'center',
},
{
title: '标签',
dataIndex: 'tagNameList',
width: 160,
align: 'left',
},
{
title: '状态',
dataIndex: 'statusDisplay',
width: 100,
align: 'center',
},
{
title: '阅读/收藏',
dataIndex: 'viewFavorite',
width: 110,
align: 'center',
},
{
title: '创建时间',
dataIndex: 'createTime',
width: 170,
align: 'center',
},
];
// 搜索表单
export const searchSchema: FormSchema[] = [
{
field: 'title',
label: '标题',
component: 'Input',
componentProps: {
placeholder: '搜索标题',
},
colProps: { span: 6 },
},
{
field: 'categoryId',
label: '分类',
component: 'ApiSelect',
componentProps: {
api: () => import('./resource.api').then((m) => m.categoryListAllApi()),
labelField: 'name',
valueField: 'id',
placeholder: '全部分类',
},
colProps: { span: 6 },
},
{
field: 'combinedStatus',
label: '状态',
component: 'Select',
componentProps: {
options: STATUS_OPTIONS,
placeholder: '全部状态',
allowClear: true,
},
colProps: { span: 6 },
},
{
field: 'contentType',
label: '类型',
component: 'Select',
componentProps: {
options: TYPE_OPTIONS,
placeholder: '全部类型',
allowClear: true,
},
colProps: { span: 6 },
},
];