4f7819afd6
- 新增 views/approval/todo:ele-pro-table 待办列表(单号/单据类型/标题/发起人/
报送部门/状态/提交时间/当前节点,状态与关键词筛选)
- 详情抽屉:只读字段、附件、处理记录时间线、当前节点;通过/驳回/存档/归档/
改派动作按 permissions 与 v-permission 显隐,驳回意见必填,改派需选人并填原因
- 新增 api/approval/{model,instance,task},统一走 THINK_API_URL
560 lines
16 KiB
Vue
560 lines
16 KiB
Vue
<!-- 审批待办详情抽屉 -->
|
||
<template>
|
||
<a-drawer
|
||
:width="860"
|
||
:visible="visible"
|
||
title="待办详情"
|
||
:body-style="{ padding: '16px' }"
|
||
:footer="null"
|
||
@update:visible="updateVisible"
|
||
>
|
||
<a-spin :spinning="loading">
|
||
<template v-if="detail">
|
||
<!-- 动作按钮:按详情权限与按钮权限显隐 -->
|
||
<div class="approval-detail-actions">
|
||
<a-space>
|
||
<a-button
|
||
v-if="detail.permissions?.canApprove"
|
||
v-permission="'approval:instance:approve'"
|
||
type="primary"
|
||
@click="openOpinion('approve')"
|
||
>
|
||
通过
|
||
</a-button>
|
||
<a-button
|
||
v-if="detail.permissions?.canReject"
|
||
v-permission="'approval:instance:approve'"
|
||
danger
|
||
@click="openOpinion('reject')"
|
||
>
|
||
驳回
|
||
</a-button>
|
||
<a-button
|
||
v-if="detail.permissions?.canConfirm"
|
||
v-permission="'approval:instance:approve'"
|
||
type="primary"
|
||
@click="confirmStore"
|
||
>
|
||
存档
|
||
</a-button>
|
||
<a-button
|
||
v-if="detail.permissions?.canArchive"
|
||
v-permission="'approval:instance:archive'"
|
||
type="primary"
|
||
@click="archive"
|
||
>
|
||
归档
|
||
</a-button>
|
||
<a-button
|
||
v-if="detail.permissions?.canAssign"
|
||
v-permission="'approval:instance:assign'"
|
||
@click="openAssign"
|
||
>
|
||
改派
|
||
</a-button>
|
||
</a-space>
|
||
<a-tag v-if="detail.statusName" :color="statusColor(detail.status)">
|
||
{{ detail.statusName }}
|
||
</a-tag>
|
||
</div>
|
||
|
||
<a-descriptions bordered size="small" :column="2" title="基本信息">
|
||
<a-descriptions-item label="单号">
|
||
{{ detail.bizNo }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="单据类型">
|
||
{{ detail.docTypeName }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="当前节点">
|
||
{{ detail.currentNodeName || '-' }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="发起人">
|
||
{{ detail.reporterName }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="报送部门">
|
||
{{ detail.reportOrgName }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item
|
||
v-if="detail.docType === 'coordination'"
|
||
label="受理部门"
|
||
>
|
||
{{ detail.acceptOrgName }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="提交时间">
|
||
{{ formatApprovalTime(detail.submitTime) }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="审批完成时间">
|
||
{{ formatApprovalTime(detail.approveTime) }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="归档时间">
|
||
{{ formatApprovalTime(detail.archiveTime) }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="归档人">
|
||
{{ detail.archivistName }}
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
|
||
<a-descriptions
|
||
v-if="detail.docType === 'completion'"
|
||
bordered
|
||
size="small"
|
||
:column="2"
|
||
title="单据字段"
|
||
>
|
||
<a-descriptions-item label="名称" :span="2">
|
||
{{ detail.name }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="实施单位">
|
||
{{ detail.implementOrg }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="费用(元)">
|
||
{{ formatFee(detail.fee) }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="服务内容" :span="2">
|
||
{{ detail.serviceContent }}
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
|
||
<a-descriptions
|
||
v-else-if="detail.docType === 'coordination'"
|
||
bordered
|
||
size="small"
|
||
:column="2"
|
||
title="单据字段"
|
||
>
|
||
<a-descriptions-item label="标题" :span="2">
|
||
{{ detail.title }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="协调内容" :span="2">
|
||
{{ detail.content }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="备注" :span="2">
|
||
{{ detail.remark }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="派单对象">
|
||
{{ detail.dispatchTargetName || '-' }}
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
|
||
<a-descriptions bordered size="small" :column="2" title="附件">
|
||
<a-descriptions-item label="附件" :span="2">
|
||
<a-empty
|
||
v-if="!attachments.length"
|
||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||
description="暂无附件"
|
||
/>
|
||
<div
|
||
v-for="file in attachments"
|
||
:key="file.id"
|
||
class="approval-file"
|
||
>
|
||
<a @click="openFile(file)">{{ file.name }}</a>
|
||
<span class="ele-text-secondary">
|
||
{{ file.categoryName }} · {{ formatFileSize(file.length) }} ·
|
||
{{ formatApprovalTime(file.createTime) }}
|
||
</span>
|
||
</div>
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
|
||
<a-descriptions bordered size="small" :column="2" title="当前节点">
|
||
<a-descriptions-item label="节点">
|
||
{{ detail.currentNodeName || '-' }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="存档确认">
|
||
<template v-if="confirms.length">
|
||
<a-tag
|
||
v-for="item in confirms"
|
||
:key="item.userId"
|
||
:color="item.status === 'DONE' ? 'green' : 'blue'"
|
||
>
|
||
{{ item.userName }}·{{ item.statusName }}
|
||
</a-tag>
|
||
</template>
|
||
<span v-else>-</span>
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
|
||
<div class="approval-section-title">处理记录</div>
|
||
<a-empty
|
||
v-if="!records.length"
|
||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||
description="暂无处理记录"
|
||
/>
|
||
<a-timeline v-else>
|
||
<a-timeline-item
|
||
v-for="item in records"
|
||
:key="item.id"
|
||
:color="item.action === 'REJECT' ? 'red' : 'blue'"
|
||
>
|
||
<div>
|
||
<span class="approval-timeline-action">{{
|
||
item.actionName
|
||
}}</span>
|
||
<span class="ele-text-secondary">
|
||
{{ item.operatorName }} ·
|
||
{{ formatApprovalTime(item.createTime) }}
|
||
</span>
|
||
</div>
|
||
<div v-if="item.nodeName" class="ele-text-secondary">
|
||
{{ item.nodeName }}
|
||
</div>
|
||
<div v-if="item.opinion">{{ item.opinion }}</div>
|
||
</a-timeline-item>
|
||
</a-timeline>
|
||
</template>
|
||
<a-empty v-else-if="!loading" description="暂无数据" />
|
||
</a-spin>
|
||
|
||
<!-- 通过/驳回意见 -->
|
||
<a-modal
|
||
v-model:visible="opinionVisible"
|
||
:title="opinionMode === 'approve' ? '通过' : '驳回'"
|
||
:confirm-loading="submitting"
|
||
:mask-closable="false"
|
||
@ok="submitOpinion"
|
||
>
|
||
<a-textarea
|
||
v-model:value="opinion"
|
||
:rows="4"
|
||
:maxlength="500"
|
||
show-count
|
||
:placeholder="
|
||
opinionMode === 'approve'
|
||
? '请输入意见(可选)'
|
||
: '请输入驳回意见(必填)'
|
||
"
|
||
/>
|
||
</a-modal>
|
||
|
||
<!-- 改派 -->
|
||
<a-modal
|
||
v-model:visible="assignVisible"
|
||
title="改派待办"
|
||
:confirm-loading="submitting"
|
||
:mask-closable="false"
|
||
@ok="submitAssign"
|
||
>
|
||
<a-form layout="vertical">
|
||
<a-form-item label="改派对象" required>
|
||
<a-select
|
||
v-model:value="assignForm.targetUserId"
|
||
show-search
|
||
allow-clear
|
||
:filter-option="false"
|
||
:options="userOptions"
|
||
:loading="userLoading"
|
||
placeholder="搜索姓名或账号"
|
||
@search="searchUsers"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="改派原因" required>
|
||
<a-textarea
|
||
v-model:value="assignForm.reason"
|
||
:rows="3"
|
||
:maxlength="500"
|
||
show-count
|
||
placeholder="请输入改派原因"
|
||
/>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
</a-drawer>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { computed, reactive, ref, watch } from 'vue';
|
||
import { Empty, Modal, message } from 'ant-design-vue';
|
||
import type {
|
||
ApprovalAttachment,
|
||
ApprovalInstance
|
||
} from '@/api/approval/model';
|
||
import { getApprovalInstance } from '@/api/approval/instance';
|
||
import {
|
||
approveApprovalTask,
|
||
archiveApprovalTask,
|
||
assignApprovalTask,
|
||
confirmApprovalTask,
|
||
rejectApprovalTask,
|
||
searchApprovalUsers
|
||
} from '@/api/approval/task';
|
||
import {
|
||
approvalStatusColor,
|
||
formatApprovalTime,
|
||
formatFileSize
|
||
} from '../format';
|
||
|
||
const props = defineProps<{
|
||
// 抽屉是否打开
|
||
visible: boolean;
|
||
// 单据id
|
||
instanceId?: number | null;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:visible', visible: boolean): void;
|
||
// 单据状态已变化,需要刷新列表
|
||
(e: 'done'): void;
|
||
}>();
|
||
|
||
// 详情数据
|
||
const detail = ref<ApprovalInstance | null>(null);
|
||
// 加载状态
|
||
const loading = ref(false);
|
||
// 提交状态
|
||
const submitting = ref(false);
|
||
|
||
const attachments = computed<ApprovalAttachment[]>(
|
||
() => detail.value?.attachments ?? []
|
||
);
|
||
const records = computed(() => detail.value?.records ?? []);
|
||
const confirms = computed(() => detail.value?.confirms ?? []);
|
||
|
||
const load = async () => {
|
||
if (!props.instanceId) {
|
||
detail.value = null;
|
||
return;
|
||
}
|
||
loading.value = true;
|
||
try {
|
||
detail.value = await getApprovalInstance(props.instanceId);
|
||
} catch (e: any) {
|
||
detail.value = null;
|
||
message.error(e.message);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
watch(
|
||
() => [props.visible, props.instanceId],
|
||
() => {
|
||
if (props.visible && props.instanceId) {
|
||
load();
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
const updateVisible = (value: boolean) => {
|
||
emit('update:visible', value);
|
||
};
|
||
|
||
// 状态颜色
|
||
const statusColor = approvalStatusColor;
|
||
|
||
const formatFee = (fee?: number) => {
|
||
return fee === undefined || fee === null ? '-' : Number(fee).toFixed(2);
|
||
};
|
||
|
||
const openFile = (file: ApprovalAttachment) => {
|
||
if (file.url) {
|
||
window.open(file.url);
|
||
}
|
||
};
|
||
|
||
/* 通过 / 驳回 */
|
||
const opinionVisible = ref(false);
|
||
const opinionMode = ref<'approve' | 'reject'>('approve');
|
||
const opinion = ref('');
|
||
|
||
const openOpinion = (mode: 'approve' | 'reject') => {
|
||
opinionMode.value = mode;
|
||
opinion.value = '';
|
||
opinionVisible.value = true;
|
||
};
|
||
|
||
const submitOpinion = async () => {
|
||
const taskId = detail.value?.myTask?.id;
|
||
if (!taskId) {
|
||
message.error('待办任务不存在,请刷新');
|
||
return;
|
||
}
|
||
const text = opinion.value.trim();
|
||
if (opinionMode.value === 'reject' && !text) {
|
||
message.error('请填写驳回意见');
|
||
return;
|
||
}
|
||
submitting.value = true;
|
||
try {
|
||
if (opinionMode.value === 'approve') {
|
||
const msg = await approveApprovalTask(taskId, text || undefined);
|
||
if (msg && msg !== '审批成功') {
|
||
message.warning(msg);
|
||
} else {
|
||
message.success('审批成功');
|
||
}
|
||
} else {
|
||
await rejectApprovalTask(taskId, text);
|
||
message.success('驳回成功');
|
||
}
|
||
opinionVisible.value = false;
|
||
await load();
|
||
emit('done');
|
||
} catch (e: any) {
|
||
message.error(e.message);
|
||
} finally {
|
||
submitting.value = false;
|
||
}
|
||
};
|
||
|
||
/* 存档 / 归档 */
|
||
const confirmStore = () => {
|
||
const taskId = detail.value?.myTask?.id;
|
||
if (!taskId) {
|
||
message.error('待办任务不存在,请刷新');
|
||
return;
|
||
}
|
||
Modal.confirm({
|
||
title: '提示',
|
||
content: '确认完成存档?',
|
||
onOk: async () => {
|
||
try {
|
||
await confirmApprovalTask(taskId);
|
||
message.success('存档成功');
|
||
await load();
|
||
emit('done');
|
||
} catch (e: any) {
|
||
message.error(e.message);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const archive = () => {
|
||
const taskId = detail.value?.myTask?.id;
|
||
if (!taskId) {
|
||
message.error('待办任务不存在,请刷新');
|
||
return;
|
||
}
|
||
Modal.confirm({
|
||
title: '提示',
|
||
content: '确认归档该单据?归档后不可再修改。',
|
||
onOk: async () => {
|
||
try {
|
||
await archiveApprovalTask(taskId);
|
||
message.success('归档成功');
|
||
await load();
|
||
emit('done');
|
||
} catch (e: any) {
|
||
message.error(e.message);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
/* 改派 */
|
||
const assignVisible = ref(false);
|
||
const userLoading = ref(false);
|
||
const userOptions = ref<{ label: string; value: number }[]>([]);
|
||
const assignForm = reactive<{ targetUserId?: number; reason: string }>({
|
||
targetUserId: undefined,
|
||
reason: ''
|
||
});
|
||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||
|
||
const doSearchUsers = async (keywords?: string) => {
|
||
userLoading.value = true;
|
||
try {
|
||
const users = await searchApprovalUsers(keywords?.trim() || undefined);
|
||
userOptions.value = users.map((user) => ({
|
||
value: user.userId as number,
|
||
label:
|
||
(user.realName || user.nickname || user.username || '') +
|
||
(user.organizationName ? '(' + user.organizationName + ')' : '')
|
||
}));
|
||
} catch (e: any) {
|
||
message.error(e.message);
|
||
} finally {
|
||
userLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const searchUsers = (keywords?: string) => {
|
||
if (searchTimer) {
|
||
clearTimeout(searchTimer);
|
||
}
|
||
searchTimer = setTimeout(() => doSearchUsers(keywords), 300);
|
||
};
|
||
|
||
const openAssign = () => {
|
||
assignForm.targetUserId = undefined;
|
||
assignForm.reason = '';
|
||
assignVisible.value = true;
|
||
doSearchUsers();
|
||
};
|
||
|
||
const submitAssign = async () => {
|
||
const taskId = detail.value?.myTask?.id;
|
||
if (!taskId) {
|
||
message.error('待办任务不存在,请刷新');
|
||
return;
|
||
}
|
||
if (!assignForm.targetUserId) {
|
||
message.error('请选择改派对象');
|
||
return;
|
||
}
|
||
const reason = assignForm.reason.trim();
|
||
if (!reason) {
|
||
message.error('请填写改派原因');
|
||
return;
|
||
}
|
||
submitting.value = true;
|
||
try {
|
||
await assignApprovalTask(taskId, {
|
||
targetUserId: assignForm.targetUserId,
|
||
reason
|
||
});
|
||
message.success('改派成功');
|
||
assignVisible.value = false;
|
||
emit('done');
|
||
// 改派后待办不再属于当前用户,关闭抽屉
|
||
updateVisible(false);
|
||
} catch (e: any) {
|
||
message.error(e.message);
|
||
} finally {
|
||
submitting.value = false;
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<script lang="ts">
|
||
export default {
|
||
name: 'ApprovalTodoDetail'
|
||
};
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.approval-detail-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.approval-section-title {
|
||
margin: 24px 0 16px;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.approval-file {
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin-bottom: 8px;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.approval-timeline-action {
|
||
margin-right: 8px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
:deep(.ant-descriptions) {
|
||
margin-bottom: 16px;
|
||
}
|
||
</style>
|