新增MES审批流设计功能,包括审批流定义、审批实例管理及审批办理接口,支持可视化设计与业务单据联动,提升审批流程的灵活性与用户体验。

This commit is contained in:
geht
2026-05-29 15:49:10 +08:00
parent 94132ea8da
commit aefa44b8a9
48 changed files with 5603 additions and 261 deletions

View File

@@ -1,442 +1,426 @@
<template>
<div class="im-biz-record-message">
<div v-if="showNoPermission" class="im-biz-record-no-permission">暂无当前消息权限</div>
<template v-else>
<!-- 单条详情表 -->
<template v-if="isSingleItem">
<div class="im-biz-record-item">
<div class="im-biz-record-table-wrap">
<table class="im-biz-record-table im-biz-record-table--detail">
<tbody>
<tr v-for="field in resolveItemFields(singleItem)" :key="field.label">
<th>{{ field.label }}</th>
<td>{{ field.value }}</td>
</tr>
</tbody>
</table>
</div>
<template v-if="isSingleItem">
<div class="im-biz-record-item">
<div class="im-biz-record-table-wrap">
<table class="im-biz-record-table im-biz-record-table--detail">
<tbody>
<tr v-for="field in resolveItemFields(singleItem)" :key="field.label">
<th>{{ field.label }}</th>
<td>{{ field.value }}</td>
</tr>
</tbody>
</table>
<!-- 审批卡片底部操作按钮 -->
<div v-if="isApprovalCard" class="im-biz-record-actions">
<a-button size="small" @click="handleDetail(singleItem)">
<Icon icon="ant-design:file-search-outlined" />
<span>查看详情</span>
</a-button>
<template v-if="liveActionable">
<a-button size="small" type="primary" :loading="approving" @click="handleApprove(singleItem)">
<Icon icon="ant-design:check-outlined" />
<span>{{ singleItem.actionLabel || '审批' }}</span>
</a-button>
<a-button size="small" danger @click="openReject(singleItem)">
<Icon icon="ant-design:close-outlined" />
<span>拒绝</span>
</a-button>
</template>
<!-- 不可办理(已处理/已流转/非当前处理人)置灰提示 -->
<span v-else-if="!props.mine && disabledText" class="im-biz-record-disabled">{{ disabledText }}</span>
<a v-if="canLocate" class="im-biz-record-link" @click.prevent="handleLinkClick(singleItem.linkPath)">
<Icon icon="ant-design:unordered-list-outlined" />
<span>跳转至列表</span>
</a>
</div>
<!-- 普通分享卡片定位链接 -->
<a v-else class="im-biz-record-link" @click.prevent="handleLinkClick(singleItem.linkPath)">
<Icon icon="ant-design:link-outlined" />
<span>查看并定位到此数据</span>
</a>
</div>
</template>
<a class="im-biz-record-link" @click.prevent="handleLinkClick(singleItem.linkPath)">
<Icon icon="ant-design:link-outlined" />
<span>查看并定位到此数据</span>
</a>
</div>
<!-- 多条列表表第一列为定位链接 -->
<template v-else>
<div class="im-biz-record-table-wrap im-biz-record-table-wrap--list">
<table class="im-biz-record-table im-biz-record-table--list">
<thead>
<tr>
<th class="im-biz-record-link-col">链接</th>
<th v-for="columnLabel in listColumnLabels" :key="columnLabel">{{ columnLabel }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in payload.items" :key="item.recordId || index">
<td class="im-biz-record-link-col">
<a class="im-biz-record-link" @click.prevent="handleLinkClick(item.linkPath)">
<Icon icon="ant-design:link-outlined" />
<span>定位</span>
</a>
</td>
<td v-for="columnLabel in listColumnLabels" :key="columnLabel">
{{ getFieldValue(item, columnLabel) }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div v-if="showPeerNoPermissionTip" class="im-biz-record-peer-tip">对方无此功能权限</div>
</template>
<!-- 查看详情弹窗 -->
<ImApprovalDetailModal ref="detailModalRef" />
<!-- 多条列表表第一列为定位链接 -->
<template v-else>
<div class="im-biz-record-table-wrap im-biz-record-table-wrap--list">
<table class="im-biz-record-table im-biz-record-table--list">
<thead>
<tr>
<th class="im-biz-record-link-col">链接</th>
<th v-for="columnLabel in listColumnLabels" :key="columnLabel">{{ columnLabel }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in payload.items" :key="item.recordId || index">
<td class="im-biz-record-link-col">
<a class="im-biz-record-link" @click.prevent="handleLinkClick(item.linkPath)">
<Icon icon="ant-design:link-outlined" />
<span>定位</span>
</a>
</td>
<td v-for="columnLabel in listColumnLabels" :key="columnLabel">
{{ getFieldValue(item, columnLabel) }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div v-if="showPeerNoPermissionTip" class="im-biz-record-peer-tip">对方无此功能权限</div>
</template>
<!-- 驳回理由弹窗 -->
<a-modal v-model:open="rejectOpen" title="驳回审批" :confirmLoading="rejecting" okText="确认驳回" @ok="confirmReject">
<a-form layout="vertical">
<a-form-item label="驳回理由" required>
<a-textarea v-model:value="rejectReason" :rows="3" placeholder="请填写驳回理由" :maxlength="500" show-count />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, ref, onMounted, watch } from 'vue';
import type { ImBizRecordItem, ImBizRecordPayload } from './imBizRecordMessage';
import {
getImBizRecordFieldValueByLabel,
resolveImBizRecordItemFields,
resolveImBizRecordListColumnLabels,
} from './imBizRecordMessage';
import { getImBizRecordFieldValueByLabel, resolveImBizRecordItemFields, resolveImBizRecordListColumnLabels } from './imBizRecordMessage';
import { navigateImBizRecordLink } from './imRecordLocate';
import { hasImBizRecordPagePermission } from './imBizRecordPermission';
import { useMessage } from '/@/hooks/web/useMessage';
import { approveApproval, rejectApproval, getApprovalStatus } from '/@/views/approval/flow/approvalHandle.api';
import ImApprovalDetailModal from './ImApprovalDetailModal.vue';
defineOptions({ name: 'ImBizRecordMessageContent' });
const props = defineProps<{
payload: ImBizRecordPayload;
mine?: boolean;
receiverHasBizPagePermission?: boolean;
}>();
// 办理成功后通知父级(ImChat)刷新当前会话,使下一节点卡片/结果通知即时出现
const emit = defineEmits(['handled']);
const { createMessage } = useMessage();
const detailModalRef = ref();
const approving = ref(false);
const rejecting = ref(false);
const rejectOpen = ref(false);
const rejectReason = ref('');
const rejectItem = ref<ImBizRecordItem | null>(null);
// 本地办理结果approved / rejected卡片消息为静态办理后本地标记
const actionDone = ref<'' | 'approved' | 'rejected'>('');
// 审批实例实时状态(用于旧节点卡片置灰)
interface LiveStatus {
exists: boolean;
status?: string;
statusText?: string;
currentNodeId?: string;
currentHandlersText?: string;
canApprove?: boolean;
}
const liveStatus = ref<LiveStatus | null>(null);
const liveLoaded = ref(false);
const isSingleItem = computed(() => props.payload.items.length === 1);
const singleItem = computed(() => props.payload.items[0]);
const listColumnLabels = computed(() => resolveImBizRecordListColumnLabels(props.payload.items));
// 审批卡片单条且带审批实例ID
const isApprovalCard = computed(() => isSingleItem.value && !!singleItem.value?.instanceId);
// 是否仍可办理:本地未办理 且 实例审批中 且 卡片节点==当前节点 且 本人为当前处理人
const liveActionable = computed(() => {
if (!isApprovalCard.value || actionDone.value || props.mine) {
return false;
}
const s = liveStatus.value;
if (!s || !s.exists || s.status !== '0' || !s.canApprove) {
return false;
}
// 携带 nodeId 时严格比对当前节点,区分同一实例的新旧卡片
const cardNodeId = singleItem.value?.nodeId;
if (cardNodeId) {
return s.currentNodeId === cardNodeId;
}
return true;
});
// 不可办理时的置灰提示文案
const disabledText = computed(() => {
if (actionDone.value) {
return actionDone.value === 'rejected' ? '已驳回' : '已处理';
}
const s = liveStatus.value;
if (!s) {
return liveLoaded.value ? '加载失败' : '';
}
if (!s.exists) {
return '审批已失效';
}
if (s.status === '1') return '已通过';
if (s.status === '2') return '已驳回';
if (s.status === '3') return '已撤销';
// 审批中但本卡片不可办理
const cardNodeId = singleItem.value?.nodeId;
if (cardNodeId && s.currentNodeId !== cardNodeId) {
return '已流转,无需处理';
}
return '等待他人处理';
});
async function loadLiveStatus() {
const id = singleItem.value?.instanceId;
if (!isApprovalCard.value || props.mine || !id) {
return;
}
try {
const res: any = await getApprovalStatus(id);
liveStatus.value = (res || { exists: false }) as LiveStatus;
} catch {
liveStatus.value = null;
} finally {
liveLoaded.value = true;
}
}
onMounted(loadLiveStatus);
watch(() => singleItem.value?.instanceId, loadLiveStatus);
const hasPagePermission = computed(() => hasImBizRecordPagePermission(props.payload.pagePath));
// 审批卡片即使无列表页权限也应可办理/查看详情,仅"跳转至列表"受页面权限约束
const showNoPermission = computed(() => !props.mine && !hasPagePermission.value && !isApprovalCard.value);
const canLocate = computed(() => props.mine || hasPagePermission.value);
const showNoPermission = computed(() => !props.mine && !hasPagePermission.value);
const showPeerNoPermissionTip = computed(
() => !!props.mine && props.receiverHasBizPagePermission === false,
);
const showPeerNoPermissionTip = computed(() => !!props.mine && props.receiverHasBizPagePermission === false);
function resolveItemFields(item: ImBizRecordItem) {
return resolveImBizRecordItemFields(item);
}
function getFieldValue(item: ImBizRecordItem, label: string) {
return getImBizRecordFieldValueByLabel(item, label);
}
async function handleLinkClick(linkPath: string) {
if (!linkPath || showNoPermission.value) {
return;
}
await navigateImBizRecordLink(linkPath);
}
function handleDetail(item: ImBizRecordItem) {
if (!item.instanceId) return;
detailModalRef.value?.openModal(item.instanceId);
}
async function handleApprove(item: ImBizRecordItem) {
if (!item.instanceId || approving.value) return;
try {
approving.value = true;
const res: any = await approveApproval({ instanceId: item.instanceId });
createMessage.success(typeof res === 'string' ? res : '已审批');
actionDone.value = 'approved';
// 立即刷新本卡片状态(置灰),再通知父级刷新会话
await loadLiveStatus();
emit('handled');
} finally {
approving.value = false;
}
}
function openReject(item: ImBizRecordItem) {
rejectItem.value = item;
rejectReason.value = '';
rejectOpen.value = true;
}
async function confirmReject() {
const item = rejectItem.value;
if (!item?.instanceId) return;
if (!rejectReason.value.trim()) {
createMessage.warning('请填写驳回理由');
return;
}
try {
rejecting.value = true;
await rejectApproval({ instanceId: item.instanceId, reason: rejectReason.value.trim() });
createMessage.success('已驳回');
actionDone.value = 'rejected';
rejectOpen.value = false;
await loadLiveStatus();
emit('handled');
} finally {
rejecting.value = false;
}
}
</script>
<style lang="less" scoped>
.im-biz-record-message {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 280px;
max-width: 420px;
}
.im-biz-record-no-permission {
padding: 12px 10px;
font-size: 13px;
line-height: 1.5;
color: #8c8c8c;
text-align: center;
}
.im-biz-record-peer-tip {
display: inline-flex;
align-items: center;
align-self: flex-start;
margin-top: 4px;
padding: 2px 8px;
border-radius: 10px;
background: #fff7e6;
border: 1px solid #ffd591;
font-size: 12px;
line-height: 1.5;
color: #d46b08;
}
.im-biz-record-item {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 审批办理按钮栏 */
.im-biz-record-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding-top: 4px;
border-top: 1px dashed #f0f0f0;
.im-biz-record-done {
font-size: 12px;
color: #52c41a;
font-weight: 500;
}
/* 不可办理置灰提示 */
.im-biz-record-disabled {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 10px;
background: #f5f5f5;
border: 1px solid #e0e0e0;
font-size: 12px;
color: #bfbfbf;
}
}
.im-biz-record-table-wrap {
overflow: hidden;
border: 1px solid #f0f0f0;
border-radius: 6px;
background: #fff;
&--list {
overflow-x: auto;
}
}
.im-biz-record-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
line-height: 1.5;
th,
td {
padding: 8px 10px;
border-bottom: 1px solid #f0f0f0;
vertical-align: top;
word-break: break-word;
}
tr:last-child {
th,
td {
border-bottom: none;
}
}
&--detail {
table-layout: fixed;
th {
width: 38%;
background: #fafafa;
color: #595959;
font-weight: 500;
text-align: left;
}
td {
color: #262626;
background: #fff;
}
}
&--list {
min-width: 100%;
table-layout: auto;
thead th {
background: #fafafa;
color: #595959;
font-weight: 500;
text-align: left;
white-space: nowrap;
}
tbody td {
color: #262626;
background: #fff;
}
}
}
.im-biz-record-link-col {
width: 72px;
min-width: 72px;
white-space: nowrap;
}
.im-biz-record-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #1677ff;
text-decoration: underline;
cursor: pointer;
&:hover {
color: #0958d9;
}
}
</style>