feat(emergency): 实现急救宣教推送到 IM 会话功能
- 新增 FirstAidPush 组件:分类筛选 + 搜索 + 单条/一键推送 - IM 端切换会话时 postMessage 携带 orderId 通知父页面 - 父页面监听 sessionChanged 联动推送按钮与后端调用 - 推送调后端 push/send 记录状态 + IM postMessage 推卡片 - 资源管理正文改富文本编辑器,修批量操作重复提示 - 重建 iweb 产物
This commit is contained in:
@@ -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
@@ -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();
|
||||
// 当前应急工单ID(IM 会话 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
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user