feat(resource): 新增急救宣教资源管理、分类标签、审核管理模块
- 资源管理:列表筛选 + 新增/编辑弹窗 + 移动端预览 + 版本历史,已接入后端 RESTful 接口 - 分类与标签:左侧分类树(支持新增子分类/编辑/删除)+ 右侧标签云管理 - 审核管理:待审核/已通过/已驳回/审核记录四个 tab,驳回原因弹窗已接入审核接口 - 开发环境 API 地址切换为同事本地后台
This commit is contained in:
+4
-2
@@ -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
|
||||
# 接口前缀
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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 },
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user