feat(emergency): 实现急救宣教推送到 IM 会话功能

- 新增 FirstAidPush 组件:分类筛选 + 搜索 + 单条/一键推送
- IM 端切换会话时 postMessage 携带 orderId 通知父页面
- 父页面监听 sessionChanged 联动推送按钮与后端调用
- 推送调后端 push/send 记录状态 + IM postMessage 推卡片
- 资源管理正文改富文本编辑器,修批量操作重复提示
- 重建 iweb 产物
This commit is contained in:
wanghao
2026-06-24 17:23:36 +08:00
parent a2a0e274f4
commit 6bb4b97ff4
21 changed files with 993 additions and 234 deletions
+2
View File
@@ -4,6 +4,7 @@ enum Api {
pendingList = '/health-emergency/emergency/firstAid/audit/pending',
passedList = '/health-emergency/emergency/firstAid/audit/passed',
rejectedList = '/health-emergency/emergency/firstAid/audit/rejected',
recordsList = '/health-emergency/emergency/firstAid/audit/records',
approve = '/health-emergency/emergency/firstAid/audit/approve',
reject = '/health-emergency/emergency/firstAid/audit/reject',
}
@@ -11,5 +12,6 @@ enum Api {
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 recordsListApi = (params: any) => defHttp.get({ url: Api.recordsList, params });
export const approveApi = (params: any) => defHttp.post({ url: Api.approve, params });
export const rejectApi = (params: any) => defHttp.post({ url: Api.reject, params });
+29 -10
View File
@@ -77,7 +77,16 @@
</a-tab-pane>
<a-tab-pane key="records" tab="审核记录">
<a-alert message="审核记录接口后端尚未提供独立列表,当前合并展示已通过+已驳回数据" type="info" show-icon style="margin-bottom: 12px" />
<div class="filter-bar">
<a-input v-model:value="recordsFilter.title" placeholder="搜索标题" allow-clear style="width: 200px" @press-enter="loadRecords" />
<a-select v-model:value="recordsFilter.auditStatus" placeholder="审核操作" allow-clear style="width: 140px">
<a-select-option :value="10">全部</a-select-option>
<a-select-option :value="11">已通过</a-select-option>
<a-select-option :value="12">已驳回</a-select-option>
</a-select>
<a-range-picker v-model:value="recordsFilter.dateRange" style="width: 240px" />
<a-button type="primary" @click="loadRecords">查询</a-button>
</div>
<a-table
:columns="recordColumns"
:data-source="recordsList"
@@ -88,8 +97,8 @@
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-tag :color="record._action === 'pass' ? 'green' : 'red'">
{{ record._action === 'pass' ? '通过' : '驳回' }}
<a-tag :color="record.auditStatus === 11 ? 'green' : 'red'">
{{ record.auditStatus === 11 ? '通过' : '驳回' }}
</a-tag>
</template>
</template>
@@ -110,9 +119,11 @@
pendingListApi,
passedListApi,
rejectedListApi,
recordsListApi,
approveApi,
rejectApi,
} from './check.api';
import dayjs from 'dayjs';
import { CONTENT_TYPE } from '/@/views/resource/resource.data';
import PreviewModal from '/@/views/resource/components/PreviewModal.vue';
import RejectReasonModal from './components/RejectReasonModal.vue';
@@ -135,6 +146,11 @@
const recordsList = ref<any[]>([]);
const recordsLoading = ref(false);
const recordsFilter = reactive({
title: '',
auditStatus: undefined as number | undefined,
dateRange: undefined as any,
});
const [registerPreviewModal, { openModal: openPreviewModal }] = useModal();
const [registerRejectModal, { openModal: openRejectModal }] = useModal();
@@ -228,13 +244,16 @@
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];
const params: any = {
pageNo: 1,
pageSize: 100,
title: recordsFilter.title || undefined,
auditStatus: recordsFilter.auditStatus ?? undefined,
startDate: recordsFilter.dateRange?.[0] ? dayjs(recordsFilter.dateRange[0]).format('YYYY-MM-DD') : undefined,
endDate: recordsFilter.dateRange?.[1] ? dayjs(recordsFilter.dateRange[1]).format('YYYY-MM-DD') : undefined,
};
const res: any = await recordsListApi(params);
recordsList.value = res?.records || [];
} finally {
recordsLoading.value = false;
}
@@ -0,0 +1,429 @@
<template>
<div class="firstaid-section">
<div class="firstaid-header">
<span class="firstaid-title">急救宣教推送</span>
<button
class="firstaid-push-all-btn"
:disabled="list.length === 0 || batchLoading"
@click="pushAll"
>
{{ batchLoading ? '推送中...' : '一键推送' }}
</button>
</div>
<div class="firstaid-search-wrap">
<input
v-model="keyword"
placeholder="搜索宣教内容、分类或关键词…"
@input="onSearch"
@keyup.enter="loadList"
/>
<button v-if="keyword" class="firstaid-search-clear" title="清空" @click="clearSearch">×</button>
</div>
<!-- 分类标签 -->
<div class="firstaid-tabs">
<span
class="firstaid-tab"
:class="{ active: !currentCategoryId }"
@click="switchCategory('')"
>
全部
</span>
<span
v-for="cat in categoryList"
:key="cat.id"
class="firstaid-tab"
:class="{ active: currentCategoryId === cat.id }"
@click="switchCategory(cat.id)"
>
{{ cat.name }}
</span>
</div>
<!-- 宣教列表 -->
<div class="firstaid-list">
<a-spin :spinning="loading">
<div v-for="item in list" :key="item.id" class="firstaid-item">
<div class="firstaid-item-info">
<div class="firstaid-item-title" :title="item.title">{{ item.title }}</div>
<div class="firstaid-item-meta">
<span class="firstaid-type-tag" :style="getTypeTagStyle(item.contentType)">
{{ getTypeLabel(item.contentType) }}
</span>
<span v-if="item.categoryName">{{ item.categoryName }}</span>
</div>
</div>
<button
class="firstaid-push-btn"
:class="{ pushed: item.pushed }"
:disabled="item.pushed || pushingId === item.id"
@click="pushSingle(item)"
>
{{ item.pushed ? '已推送' : '推送' }}
</button>
</div>
<div v-if="!loading && list.length === 0" class="firstaid-empty">未找到相关宣教内容</div>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, type PropType, type Ref } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { pushResourceListApi, pushSendApi, pushBatchSendApi } from './firstAidPush.api';
import { categoryListAllApi } from '/@/views/resource/resource.api';
import { CONTENT_TYPE } from '/@/views/resource/resource.data';
const props = defineProps({
orderId: { type: String, default: '' },
iframeRef: { type: Object as PropType<Ref<HTMLIFrameElement>>, default: null },
hasInteractedWithIm: { type: Boolean, default: false },
});
const { createMessage } = useMessage();
const keyword = ref('');
const currentCategoryId = ref('');
const categoryList = ref<any[]>([]);
const list = ref<any[]>([]);
const loading = ref(false);
const pushingId = ref('');
const batchLoading = ref(false);
let searchTimer: any = null;
function getTypeLabel(type: string) {
return CONTENT_TYPE[type as keyof typeof CONTENT_TYPE]?.label || type;
}
function getTypeTagStyle(type: string) {
const color = CONTENT_TYPE[type as keyof typeof CONTENT_TYPE]?.color || 'default';
const colorMap: Record<string, string> = {
blue: '#1890ff',
red: '#f5222d',
orange: '#fa8c16',
default: '#8c8c8c',
};
const c = colorMap[color] || colorMap.default;
return {
color: c,
borderColor: c,
};
}
async function loadList() {
loading.value = true;
try {
const res: any = await pushResourceListApi({
orderId: props.orderId || undefined,
keyword: keyword.value || undefined,
categoryId: currentCategoryId.value || undefined,
pageNo: 1,
pageSize: 50,
});
list.value = res?.records || [];
} finally {
loading.value = false;
}
}
function onSearch() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(loadList, 300);
}
function clearSearch() {
keyword.value = '';
loadList();
}
function switchCategory(id: string) {
currentCategoryId.value = id;
loadList();
}
// 通过 IM iframe 的 postMessage 发送自定义消息到当前会话
// 复用 iweb 端 message-custom.vue 中 businessID='system' 的卡片渲染
function postMessageToIm(item: any) {
// Vue 3 中父组件传 ref 给子组件 prop 会自动解包,props.iframeRef 已是 iframe DOM 元素
const iframe = props.iframeRef as any;
if (!iframe?.contentWindow) {
return false;
}
const payload = {
code: 'custom',
data: {
businessID: 'system',
title: item.title,
messageImageUrl: item.coverImage || '',
content: item.summary || '',
// 唯一时间戳,避免 IM SDK 对相同 payload 去重导致重复推送不显示
_ts: Date.now(),
},
};
iframe.contentWindow.postMessage(JSON.stringify(payload), '*');
return true;
}
async function pushSingle(item: any) {
if (!props.hasInteractedWithIm) {
createMessage.warning('请先在左侧选择会话');
return;
}
if (!props.orderId) {
createMessage.warning('当前会话未关联工单,无法记录推送状态');
return;
}
pushingId.value = item.id;
try {
// 1. 调后端接口写入推送记录(后端会触发推送通知给目标用户)
await pushSendApi({ resourceId: item.id, orderId: props.orderId });
// 2. 同时通过 IM iframe 推送卡片到当前会话(即时展示)
postMessageToIm(item);
item.pushed = true;
createMessage.success(`已推送:${item.title}`);
} finally {
pushingId.value = '';
}
}
async function pushAll() {
if (!props.hasInteractedWithIm) {
createMessage.warning('请先在左侧选择会话');
return;
}
if (!props.orderId) {
createMessage.warning('当前会话未关联工单,无法记录推送状态');
return;
}
const unpushed = list.value.filter((item) => !item.pushed);
if (unpushed.length === 0) {
createMessage.info('当前列表已全部推送');
return;
}
batchLoading.value = true;
try {
// 1. 调后端接口批量推送(后端按条件查出资源并触发推送通知)
await pushBatchSendApi({
orderId: props.orderId,
resourceIds: unpushed.map((item) => item.id),
});
// 2. 同时通过 IM iframe 推送卡片到当前会话(即时展示)
for (const item of unpushed) {
postMessageToIm(item);
await new Promise((resolve) => setTimeout(resolve, 100));
}
list.value.forEach((item) => (item.pushed = true));
createMessage.success(`已推送 ${unpushed.length} 条宣教内容`);
} finally {
batchLoading.value = false;
}
}
watch(
() => props.orderId,
() => {
// 切换对话后重新拉取,刷新每条资源的 pushed 状态
loadList();
}
);
onMounted(async () => {
try {
categoryList.value = (await categoryListAllApi()) || [];
} catch {
categoryList.value = [];
}
loadList();
});
</script>
<style lang="less" scoped>
.firstaid-section {
margin: 10px 15px 0 15px;
border-top: 1px solid #e8e8e8;
padding-top: 10px;
}
.firstaid-header {
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.firstaid-title {
color: rgba(0, 0, 0, 0.85);
}
.firstaid-push-all-btn {
font-size: 12px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
padding: 3px 10px;
cursor: pointer;
transition: background 0.2s;
&:hover:not(:disabled) {
background: #096dd9;
}
&:disabled {
background: #d9d9d9;
color: rgba(255, 255, 255, 0.8);
cursor: not-allowed;
}
}
.firstaid-search-wrap {
display: flex;
align-items: center;
margin-bottom: 8px;
background: #f7f7f7;
border-radius: 6px;
padding: 0 8px;
border: 1px solid #ddd;
input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 13px;
padding: 6px 0;
color: #333;
&::placeholder {
color: #bbb;
}
}
}
.firstaid-search-clear {
border: none;
background: none;
cursor: pointer;
padding: 0;
color: #bbb;
font-size: 18px;
line-height: 1;
&:hover {
color: #999;
}
}
.firstaid-tabs {
display: flex;
gap: 6px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.firstaid-tab {
font-size: 11px;
padding: 2px 8px;
border-radius: 12px;
border: 1px solid #d9d9d9;
background: #fff;
cursor: pointer;
color: #666;
transition: all 0.2s;
user-select: none;
&:hover {
border-color: #1890ff;
color: #1890ff;
}
&.active {
background: #1890ff;
color: #fff;
border-color: #1890ff;
}
}
.firstaid-list {
max-height: 260px;
overflow-y: auto;
border: 1px solid #e8e8e8;
border-radius: 6px;
background: #fafafa;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
}
&::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 2px;
}
}
.firstaid-item {
display: flex;
align-items: center;
padding: 7px 10px;
border-bottom: 1px solid #f0f0f0;
font-size: 12px;
background: #fff;
transition: background 0.15s;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f0f7ff;
}
}
.firstaid-item-info {
flex: 1;
overflow: hidden;
}
.firstaid-item-title {
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
}
.firstaid-item-meta {
color: #999;
font-size: 11px;
margin-top: 2px;
display: flex;
align-items: center;
gap: 4px;
}
.firstaid-type-tag {
border-radius: 3px;
padding: 0 4px;
font-size: 10px;
border: 1px solid;
background: #fff;
}
.firstaid-push-btn {
flex-shrink: 0;
margin-left: 8px;
background: #52c41a;
color: white;
border: none;
border-radius: 4px;
padding: 3px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
min-width: 42px;
&:hover:not(:disabled) {
background: #389e0d;
}
&:disabled {
background: #b7eb8f;
color: #389e0d;
cursor: default;
}
}
.firstaid-empty {
text-align: center;
padding: 20px;
color: #bbb;
font-size: 13px;
}
</style>
@@ -0,0 +1,19 @@
import { defHttp } from '/@/utils/http/axios';
/**
* 急救宣教推送 - 应急工单场景下推送给求助者
*/
enum Api {
resourceList = '/health-emergency/emergency/firstAid/push/resourceList',
send = '/health-emergency/emergency/firstAid/push/send',
batchSend = '/health-emergency/emergency/firstAid/push/batchSend',
}
// 可推送资源列表(只返回已发布+审核通过+时间有效,每条带 pushed 字段)
export const pushResourceListApi = (params: any) => defHttp.get({ url: Api.resourceList, params });
// 推送单条 { resourceId, orderId }
export const pushSendApi = (params: any) => defHttp.post({ url: Api.send, params }, { successNeedMessage: false });
// 批量推送 { resourceIds: [], orderId } 或 { orderId, filterParams: {...} }
export const pushBatchSendApi = (params: any) => defHttp.post({ url: Api.batchSend, params }, { successNeedMessage: false });
@@ -153,16 +153,18 @@
</a-row>
<forHelpAdv :isSpecialized="true" ref="forHelpAdvRef" />
<detail ref="detailRef" />
<FirstAidPush :orderId="orderId" :iframeRef="iframeRef" :hasInteractedWithIm="hasInteractedWithIm" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, onMounted } from 'vue';
import { Dayjs } from 'dayjs';
import detail from './detail.vue';
import { stat, scheduleRecordApi, scheduleRecordNewApi } from './commApi';
import forHelpAdv from './forHelpAdv.vue';
import FirstAidPush from './FirstAidPush.vue';
import moment from 'moment';
import qs from 'qs';
import { imAddressSrc } from '/@/utils/imAddressSrc';
@@ -175,6 +177,20 @@
});
const route = useRoute();
// 当前应急工单IDIM 会话 sessionId),用于急救宣教推送
// 初始值取 URL 中的 sessionId;后续用户在 IM 内切换会话时由 postMessage 更新
const orderId = ref<string>((route.query?.sessionId as string) || '');
// 用户是否已与 IM iframe 交互(粗略判断是否已选会话)
// 跨域 iframe 内部点击事件无法冒泡到父页面,用 window blur + activeElement 近似判断
const hasInteractedWithIm = ref(false);
onMounted(() => {
window.addEventListener('blur', () => {
if (document.activeElement && (document.activeElement as HTMLElement).tagName === 'IFRAME') {
hasInteractedWithIm.value = true;
}
});
});
moment.updateLocale('en', {
weekdaysMin: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
@@ -184,6 +200,7 @@
const statInfo = ref<Object>({});
const dayS = ref<Array>();
const loadingVisible = ref<Boolean>(true); // loading
const iframeRef = ref<HTMLIFrameElement>();
const forHelpDetail = (res) => {
detailRef.value.showDrawer(res);
};
@@ -217,6 +234,10 @@
case 'forHelpDetail':
forHelpDetail(result.userId);
break;
case 'sessionChanged':
// 用户在 IM 内切换会话,更新当前工单 ID
orderId.value = result.orderId || '';
break;
case 'closeLoading':
closeLoading();
break;
@@ -0,0 +1,103 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" :title="title" :width="520" :minHeight="300" @ok="handleSubmit">
<a-form layout="vertical">
<a-form-item label="分类名称" required>
<a-input v-model:value="formState.name" placeholder="请输入分类名称" :maxlength="50" />
</a-form-item>
<a-form-item label="父级分类">
<a-tree-select
v-model:value="formState.parentId"
:tree-data="categoryTreeData"
:field-names="{ label: 'name', value: 'id', children: 'children' }"
placeholder="顶级分类(不选)"
allow-clear
tree-default-expand-all
:disabled="!!formState.id"
/>
</a-form-item>
<a-form-item label="排序号">
<a-input-number v-model:value="formState.sortNo" :min="0" style="width: 100%" />
</a-form-item>
<a-form-item label="描述">
<a-textarea v-model:value="formState.description" :rows="3" placeholder="可选" :maxlength="200" />
</a-form-item>
</a-form>
</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 { categoryListAllApi, categoryAddApi, categoryEditApi, buildTree } from '../label.api';
const emit = defineEmits(['success', 'register']);
const { createMessage } = useMessage();
const title = ref('新增分类');
const categoryTreeData = ref<any[]>([]);
const formState = reactive({
id: '',
name: '',
parentId: undefined as string | undefined,
sortNo: 1,
description: '',
});
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
const isUpdate = data?.isUpdate;
title.value = isUpdate ? '编辑分类' : '新增分类';
formState.id = data?.record?.id || '';
formState.name = data?.record?.name || '';
formState.parentId = data?.record?.parentId || (data?.parentId || undefined);
formState.sortNo = data?.record?.sortNo ?? 1;
formState.description = data?.record?.description || '';
// 编辑时父级分类禁用(避免循环引用)
if (isUpdate) {
formState.parentId = data?.record?.parentId || undefined;
}
await loadCategoryTree();
});
async function loadCategoryTree() {
const list = (await categoryListAllApi()) || [];
categoryTreeData.value = buildTree(list);
}
function validate(): boolean {
if (!formState.name) {
createMessage.warning('请输入分类名称');
return false;
}
return true;
}
async function handleSubmit() {
if (!validate()) return;
setModalProps({ confirmLoading: true });
try {
const params = {
id: formState.id || undefined,
name: formState.name,
parentId: formState.parentId || undefined,
sortNo: formState.sortNo,
description: formState.description,
};
if (formState.id) {
await categoryEditApi(params);
createMessage.success('已更新分类');
} else {
await categoryAddApi(params);
createMessage.success('已新增分类');
}
emit('success');
closeModal();
} finally {
setModalProps({ confirmLoading: false });
}
}
onMounted(() => {
loadCategoryTree();
});
</script>
@@ -0,0 +1,74 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" :title="title" :width="480" :minHeight="260" @ok="handleSubmit">
<a-form layout="vertical">
<a-form-item label="标签名称" required>
<a-input v-model:value="formState.name" placeholder="请输入标签名称" :maxlength="20" />
</a-form-item>
<a-form-item label="排序号">
<a-input-number v-model:value="formState.sortNo" :min="0" style="width: 100%" />
</a-form-item>
<a-form-item label="描述">
<a-textarea v-model:value="formState.description" :rows="3" placeholder="可选" :maxlength="200" />
</a-form-item>
</a-form>
</BasicModal>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { tagAddApi, tagEditApi } from '../label.api';
const emit = defineEmits(['success', 'register']);
const { createMessage } = useMessage();
const title = ref('新增标签');
const formState = reactive({
id: '',
name: '',
sortNo: 1,
description: '',
});
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
const isUpdate = data?.isUpdate;
title.value = isUpdate ? '编辑标签' : '新增标签';
formState.id = data?.record?.id || '';
formState.name = data?.record?.name || '';
formState.sortNo = data?.record?.sortNo ?? 1;
formState.description = data?.record?.description || '';
});
function validate(): boolean {
if (!formState.name) {
createMessage.warning('请输入标签名称');
return false;
}
return true;
}
async function handleSubmit() {
if (!validate()) return;
setModalProps({ confirmLoading: true });
try {
const params = {
id: formState.id || undefined,
name: formState.name,
sortNo: formState.sortNo,
description: formState.description,
};
if (formState.id) {
await tagEditApi(params);
createMessage.success('已更新标签');
} else {
await tagAddApi(params);
createMessage.success('已新增标签');
}
emit('success');
closeModal();
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>
+28 -122
View File
@@ -22,9 +22,10 @@
block-node
@select="onSelectCategory"
>
<template #title="{ name, id, status }">
<template #title="{ name, id, status, resourceCount }">
<div class="tree-node-content">
<span class="tree-name">{{ name }}</span>
<span v-if="resourceCount != null" class="tree-count">({{ resourceCount }})</span>
<a-tag v-if="status === 2" color="red" style="margin-left: 4px">冻结</a-tag>
<span class="tree-actions">
<a-button type="link" size="small" @click.stop="handleEditCategory({ id, name })"></a-button>
@@ -61,6 +62,7 @@
@close.prevent="handleDeleteTag(tag)"
>
{{ tag.name }}
<span v-if="tag.resourceCount != null" class="tag-count">{{ tag.resourceCount }}</span>
</a-tag>
<a-empty v-if="filteredTags.length === 0 && !tagLoading" description="暂无标签" />
</div>
@@ -68,7 +70,7 @@
</a-card>
<a-card title="分类权限配置" :bordered="false" class="card-section">
<a-alert message="权限矩阵接口后端尚未提供,当前为占位展示" type="info" show-icon style="margin-bottom: 12px" />
<!-- <a-alert message="权限矩阵接口后端尚未提供,当前为占位展示" type="info" show-icon style="margin-bottom: 12px" />-->
<a-table
:columns="permissionColumns"
:data-source="permissionList"
@@ -89,50 +91,9 @@
</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>
<CategoryFormModal @register="registerCategoryModal" @success="loadCategoryTree" />
<!-- 标签新增/编辑弹窗 -->
<TagFormModal @register="registerTagModal" @success="loadTags" />
</div>
</template>
@@ -140,20 +101,22 @@
import { ref, reactive, computed, onMounted } from 'vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { useModal } from '/@/components/Modal';
import {
categoryListAllApi,
categoryQueryByIdApi,
categoryAddApi,
categoryEditApi,
categoryDeleteApi,
tagListAllApi,
tagAddApi,
tagDeleteApi,
buildTree,
} from './label.api';
import CategoryFormModal from './components/CategoryFormModal.vue';
import TagFormModal from './components/TagFormModal.vue';
const { createMessage } = useMessage();
const [registerCategoryModal, { openModal: openCategoryModal }] = useModal();
const [registerTagModal, { openModal: openTagModal }] = useModal();
// ============ 分类树 ============
const categoryList = ref<any[]>([]);
const categoryLoading = ref(false);
@@ -188,29 +151,15 @@
}
function handleAddCategory(parentId?: string) {
categoryModalTitle.value = '新增分类';
categoryForm.id = '';
categoryForm.name = '';
categoryForm.parentId = parentId || undefined;
categoryForm.sortNo = 1;
categoryForm.description = '';
categoryModalVisible.value = true;
openCategoryModal(true, { isUpdate: false, parentId });
}
function handleAddSubCategory(parentId: string) {
handleAddCategory(parentId);
openCategoryModal(true, { isUpdate: false, 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 handleEditCategory(record: any) {
openCategoryModal(true, { isUpdate: true, record });
}
function handleDeleteCategory(record: any) {
@@ -220,35 +169,6 @@
});
}
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);
@@ -269,10 +189,7 @@
}
function handleAddTag() {
tagForm.name = '';
tagForm.sortNo = 1;
tagForm.description = '';
tagModalVisible.value = true;
openTagModal(true, { isUpdate: false });
}
function handleDeleteTag(tag: any) {
@@ -282,27 +199,6 @@
});
}
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'] },
@@ -400,6 +296,11 @@
.tree-name {
flex: 1;
}
.tree-count {
font-size: 11px;
color: #999;
margin-left: 4px;
}
.tree-actions {
display: none;
margin-left: auto;
@@ -419,4 +320,9 @@
padding: 4px 14px;
cursor: default;
}
.tag-count {
opacity: 0.5;
font-size: 11px;
margin-left: 4px;
}
</style>
@@ -20,7 +20,7 @@
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属分类" required>
<a-select v-model:value="formState.categoryId" :options="categoryOptions" placeholder="请选择分类" />
<a-select v-model:value="formState.categoryId" :options="categoryOptions" placeholder="请选择分类" :field-names="{ label: 'name', value: 'id' }" />
</a-form-item>
</a-col>
<a-col :span="12">
@@ -40,25 +40,28 @@
/>
</a-form-item>
<a-form-item label="正文内容" required>
<a-textarea v-model:value="formState.content" :rows="5" placeholder="请输入急救知识内容,支持富文本编辑..." />
<JEditor v-model:value="formState.content" />
</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>
<JImageUpload
v-model:value="formState.coverImage"
bizPath="firstAid/cover"
text="上传封面图"
tipText="支持 JPG/PNG,建议尺寸 750×420px"
/>
</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>
<JUpload
v-model:value="fileList"
bizPath="firstAid/file"
:multiple="true"
text="上传文件"
tipText="支持 MP4/MP3/PDF,单文件最大 500MB"
/>
</a-form-item>
</a-col>
</a-row>
@@ -90,12 +93,17 @@
import { useMessage } from '/@/hooks/web/useMessage';
import { TYPE_OPTIONS, SCENE_OPTIONS } from '../resource.data';
import { addApi, editApi, submitForReviewApi, categoryListAllApi, tagListAllApi } from '../resource.api';
import JImageUpload from '/@/components/Form/src/jeecg/components/JImageUpload.vue';
import JUpload from '/@/components/Form/src/jeecg/components/JUpload/JUpload.vue';
import JEditor from '/@/components/Form/src/jeecg/components/JEditor.vue';
const emit = defineEmits(['success', 'register']);
const { createMessage } = useMessage();
const title = ref('新增急救宣教资源');
const categoryOptions = ref<any[]>([]);
const tagOptions = ref<any[]>([]);
// 附件列表(JUpload 用 array 绑定,提交时 JSON.stringify 为后端 fileUrl 字段)
const fileList = ref<any[]>([]);
const formState = reactive({
id: '',
@@ -123,6 +131,12 @@
if (formState.tagIdList && typeof (formState as any).tags === 'string') {
formState.tagIdList = ((formState as any).tags || '').split(',').filter(Boolean);
}
// 后端 fileUrl 是 JSON 数组字符串,前端需要 parse 成数组给 JUpload
try {
fileList.value = formState.fileUrl ? JSON.parse(formState.fileUrl) : [];
} catch {
fileList.value = [];
}
} else {
title.value = '新增急救宣教资源';
resetForm();
@@ -142,6 +156,7 @@
formState.fileUrl = '';
formState.effectiveTime = null;
formState.expiryTime = null;
fileList.value = [];
}
function buildPayload(): any {
@@ -155,7 +170,7 @@
tags: formState.tagIdList.join(','),
content: formState.content,
coverImage: formState.coverImage,
fileUrl: formState.fileUrl,
fileUrl: JSON.stringify(fileList.value || []),
effectiveTime: formState.effectiveTime,
expiryTime: formState.expiryTime,
};
@@ -1,6 +1,7 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" title="版本历史" :width="560" :minHeight="400" :showOkBtn="false" cancelText="关闭">
<a-spin :spinning="loading">
<BasicModal v-bind="$attrs" @register="registerModal" :title="modalTitle" :width="modalWidth" :minHeight="400" :showOkBtn="false" cancelText="关闭">
<!-- 版本列表视图 -->
<a-spin :spinning="loading" v-if="!diffVisible">
<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>
@@ -8,7 +9,7 @@
<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 < versions.length - 1" @click="handleDiff(item, idx)">查看差异</a>
<a v-if="idx > 0" @click="handleRollback(item)">回滚</a>
</div>
</div>
@@ -16,22 +17,65 @@
<a-empty v-if="!loading && versions.length === 0" description="暂无版本记录" />
</div>
</a-spin>
<!-- 差异对比视图 -->
<div v-else>
<div class="diff-toolbar">
<a-button size="small" @click="diffVisible = false"> 返回版本列表</a-button>
<span class="diff-toolbar-tip">v{{ diffCurrentVersionNo }} v{{ diffOlderVersionNo }}</span>
</div>
<a-spin :spinning="diffLoading">
<a-row :gutter="16" v-if="diffData">
<a-col :span="12">
<div class="diff-panel diff-old">
<div class="diff-panel-title">
v{{ diffData.version1?.versionNo }} · {{ diffData.version1?.createTime }}
</div>
<div class="diff-panel-sub">{{ diffData.version1?.title }}</div>
<div class="diff-panel-desc">{{ diffData.version1?.changeDescription }}</div>
<div class="diff-panel-body" v-html="diffData.version1?.content || '无内容'"></div>
</div>
</a-col>
<a-col :span="12">
<div class="diff-panel diff-new">
<div class="diff-panel-title">
v{{ diffData.version2?.versionNo }} · {{ diffData.version2?.createTime }}
</div>
<div class="diff-panel-sub">{{ diffData.version2?.title }}</div>
<div class="diff-panel-desc">{{ diffData.version2?.changeDescription }}</div>
<div class="diff-panel-body" v-html="diffData.version2?.content || '无内容'"></div>
</div>
</a-col>
</a-row>
<a-empty v-else description="无差异数据" />
</a-spin>
</div>
</BasicModal>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { versionListApi, versionRollbackApi } from '../resource.api';
import { versionListApi, versionRollbackApi, versionDiffApi } from '../resource.api';
const { createMessage, createConfirm } = useMessage();
const record = ref<any>({});
const versions = ref<any[]>([]);
const loading = ref(false);
const diffVisible = ref(false);
const diffLoading = ref(false);
const diffData = ref<any>(null);
const diffCurrentVersionNo = ref('');
const diffOlderVersionNo = ref('');
const modalTitle = computed(() => (diffVisible.value ? '版本差异对比' : '版本历史'));
const modalWidth = computed(() => (diffVisible.value ? 900 : 560));
const [registerModal] = useModalInner(async (data) => {
record.value = data?.record || {};
diffVisible.value = false;
await loadVersions();
});
@@ -46,8 +90,39 @@
}
}
function handleDiff(item: any) {
createMessage.info(`查看差异:v${item.versionNo}`);
async function handleDiff(item: any, idx: number) {
const older = versions.value[idx + 1];
if (!older) {
createMessage.info('无更早版本可对比');
return;
}
diffCurrentVersionNo.value = item.versionNo;
diffOlderVersionNo.value = older.versionNo;
diffVisible.value = true;
diffLoading.value = true;
diffData.value = null;
try {
const res: any = await versionDiffApi({ versionId1: item.id, versionId2: older.id });
// 兼容后端不同返回结构:{version1, version2} / [v1, v2] / {old, new}
let v1: any = null;
let v2: any = null;
if (Array.isArray(res)) {
[v1, v2] = res;
} else if (res?.version1 || res?.version2) {
v1 = res.version1;
v2 = res.version2;
} else if (res?.old || res?.new) {
v1 = res.old;
v2 = res.new;
} else if (res?.result) {
const inner = res.result;
if (Array.isArray(inner)) [v1, v2] = inner;
else { v1 = inner.version1 || inner.old; v2 = inner.version2 || inner.new; }
}
diffData.value = { version1: v1, version2: v2 };
} finally {
diffLoading.value = false;
}
}
function handleRollback(item: any) {
@@ -56,7 +131,7 @@
title: '确认回滚',
content: `是否回滚至 v${item.versionNo}?回滚前会自动保存当前状态为新版本。`,
onOk: async () => {
await versionRollbackApi({ versionId: item.id });
await versionRollbackApi({ id: item.id });
createMessage.success('已回滚');
await loadVersions();
},
@@ -117,4 +192,61 @@
}
}
}
.diff-wrap {
margin-top: 8px;
}
.diff-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.diff-toolbar-tip {
font-size: 13px;
color: #1890ff;
font-weight: 500;
}
.diff-panel {
border: 1px solid #f0f0f0;
border-radius: 2px;
padding: 12px;
max-height: 500px;
overflow-y: auto;
&.diff-old {
background: #fafafa;
}
&.diff-new {
background: #f6ffed;
border-color: #b7eb8f;
}
}
.diff-panel-title {
font-size: 13px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 6px;
}
.diff-panel-sub {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 4px;
}
.diff-panel-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px dashed #e8e8e8;
}
.diff-panel-body {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.8;
:deep(img) {
max-width: 100%;
}
}
</style>
+17 -3
View File
@@ -74,6 +74,7 @@
copyApi,
batchPublishApi,
batchTakeDownApi,
exportXlsApi,
} from './resource.api';
import ResourceFormModal from './components/ResourceFormModal.vue';
import PreviewModal from './components/PreviewModal.vue';
@@ -113,7 +114,7 @@
},
});
const [registerTable, { reload }] = tableContext;
const [registerTable, { reload, getForm }] = tableContext;
function onSelectChange(keys: string[]) {
selectedRowKeys.value = keys;
@@ -240,8 +241,21 @@
openFormModal(true, { isUpdate: false });
}
function handleExport() {
createMessage.info('导出功能待后端提供接口');
async function handleExport() {
const formValues = getForm().getFieldsValue() || {};
const params: any = {
title: formValues.title || undefined,
categoryId: formValues.categoryId || undefined,
contentType: formValues.contentType || undefined,
// combinedStatus 拆分为 status/auditStatus:≤9 查 status,≥10 查 auditStatus
status: formValues.combinedStatus != null && formValues.combinedStatus <= 9 ? formValues.combinedStatus : undefined,
auditStatus: formValues.combinedStatus != null && formValues.combinedStatus >= 10 ? formValues.combinedStatus : undefined,
// 勾选行优先导出指定资源
selections: selectedRowKeys.value.length > 0 ? selectedRowKeys.value.join(',') : undefined,
};
Object.keys(params).forEach((k) => params[k] === undefined && delete params[k]);
await exportXlsApi(params);
createMessage.success('导出成功');
}
async function handleOffline(record: Recordable) {
+23 -11
View File
@@ -1,5 +1,6 @@
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
import { downloadFile } from '/@/api/common/api';
const { createConfirm } = useMessage();
@@ -18,9 +19,11 @@ enum Api {
batchPublish = '/health-emergency/emergency/firstAid/resource/batchPublish',
incrementView = '/health-emergency/emergency/firstAid/resource/incrementView',
copy = '/health-emergency/emergency/firstAid/resource/copy',
exportXls = '/health-emergency/emergency/firstAid/resource/exportXls',
// 版本
versionList = '/health-emergency/emergency/firstAid/version/list',
versionRollback = '/health-emergency/emergency/firstAid/version/rollback',
versionDiff = '/health-emergency/emergency/firstAid/version/diff',
// 分类 & 标签(下拉用)
categoryListAll = '/health-emergency/emergency/firstAid/category/listAll',
tagListAll = '/health-emergency/emergency/firstAid/tag/listAll',
@@ -28,17 +31,26 @@ enum Api {
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 addApi = (params: any) => defHttp.post({ url: Api.add, params }, { successNeedMessage: false });
export const editApi = (params: any) => defHttp.post({ url: Api.edit, params }, { successNeedMessage: false });
export const submitForReviewApi = (params: any) => defHttp.post({ url: Api.submitForReview, params }, { successNeedMessage: false });
export const takeDownApi = (params: any) => defHttp.post({ url: Api.takeDown, params }, { successNeedMessage: false });
export const publishApi = (params: any) => defHttp.post({ url: Api.publish, params }, { successNeedMessage: false });
export const batchTakeDownApi = (params: any) => defHttp.post({ url: Api.batchTakeDown, params }, { successNeedMessage: false });
export const batchPublishApi = (params: any) => defHttp.post({ url: Api.batchPublish, params }, { successNeedMessage: false });
export const incrementViewApi = (params: any) => defHttp.post({ url: Api.incrementView, params });
export const copyApi = (params: any) => defHttp.post({ url: Api.copy, params });
export const copyApi = (params: any) => defHttp.post({ url: Api.copy, params }, { successNeedMessage: false });
/**
* 导出资源 Excel
* 同步导出,返回文件流。参数支持 selections(勾选ID,逗号分隔)+ 筛选条件
*/
export const exportXlsApi = (params: any) => {
return downloadFile(Api.exportXls, '急救宣教资源报表.xls', 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 versionRollbackApi = (params: any) => defHttp.post({ url: Api.versionRollback, params }, { successNeedMessage: false });
export const versionDiffApi = (params: any) => defHttp.get({ url: Api.versionDiff, params });
export const categoryListAllApi = (params?: any) => defHttp.get({ url: Api.categoryListAll, params });
export const tagListAllApi = (params?: any) => defHttp.get({ url: Api.tagListAll, params });
@@ -50,7 +62,7 @@ export const deleteApi = (params: any, handleSuccess: any) => {
okText: '确认',
cancelText: '取消',
onOk: async () => {
await defHttp.post({ url: Api.delete, params });
await defHttp.post({ url: Api.delete, params }, { successNeedMessage: false });
handleSuccess();
},
});
@@ -64,7 +76,7 @@ export const batchDeleteApi = (params: any, handleSuccess: any) => {
okText: '确认',
cancelText: '取消',
onOk: async () => {
await defHttp.post({ url: Api.deleteBatch, params });
await defHttp.post({ url: Api.deleteBatch, params }, { successNeedMessage: false });
handleSuccess();
},
});