Files
gxtyzx-admin/src/views/approval/todo/components/detail-drawer.vue
T
weicw 4f7819afd6 feat(approval): PC 待办审批页面与改派 (#22)
- 新增 views/approval/todo:ele-pro-table 待办列表(单号/单据类型/标题/发起人/
  报送部门/状态/提交时间/当前节点,状态与关键词筛选)
- 详情抽屉:只读字段、附件、处理记录时间线、当前节点;通过/驳回/存档/归档/
  改派动作按 permissions 与 v-permission 显隐,驳回意见必填,改派需选人并填原因
- 新增 api/approval/{model,instance,task},统一走 THINK_API_URL
2026-10-30 21:03:07 +08:00

560 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 审批待办详情抽屉 -->
<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>