Files
gxtyzx-uniapp/src/packageApproval/pages/detail.vue
T
weicw 7b6e1a36d6 feat(approval): 员工端待办/已办与通过驳回撤回 (#20)
- 审批中心三个 tab 接入待办/已办接口,支持状态+关键词筛选与下拉刷新
- 详情页新增通过(意见可选)、驳回(意见必填)、撤回(二次确认)
- 驳回/撤回后提供修改并重新提交入口,按钮按 permissions 显隐
2026-10-30 20:45:26 +08:00

651 lines
15 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>
<view class="detail-page">
<view v-if="detail" class="content">
<uni-card :border="false" :padding="'16px'">
<view class="head">
<text class="title">{{ detail.title || detail.name || '单据详情' }}</text>
<text class="status" :class="'status-' + detail.status">
{{ detail.statusName }}
</text>
</view>
<view class="rows">
<view class="row">
<text class="label">单号</text>
<text class="value">{{ detail.bizNo || '—' }}</text>
</view>
<view class="row">
<text class="label">单据类型</text>
<text class="value">{{ detail.docTypeName }}</text>
</view>
<view class="row">
<text class="label">报送人</text>
<text class="value">{{ detail.reporterName || '—' }}</text>
</view>
<view class="row">
<text class="label">报送部门</text>
<text class="value">{{ detail.reportOrgName || '—' }}</text>
</view>
<view class="row">
<text class="label">当前节点</text>
<text class="value">{{ detail.currentNodeName || '—' }}</text>
</view>
<view class="row">
<text class="label">提交时间</text>
<text class="value">{{ formatTime(detail.submitTime) }}</text>
</view>
</view>
</uni-card>
<uni-card :border="false" :padding="'16px'" title="单据信息">
<view v-if="detail.docType === 'completion'" class="rows">
<view class="row">
<text class="label">名称</text>
<text class="value">{{ detail.name }}</text>
</view>
<view class="row">
<text class="label">实施单位</text>
<text class="value">{{ detail.implementOrg }}</text>
</view>
<view class="row">
<text class="label">费用(元)</text>
<text class="value">{{ formatFee(detail.fee) }}</text>
</view>
<view class="row">
<text class="label">服务内容</text>
<text class="value multiline">{{ detail.serviceContent }}</text>
</view>
</view>
<view v-else class="rows">
<view class="row">
<text class="label">受理部门</text>
<text class="value">{{ detail.acceptOrgName || '—' }}</text>
</view>
<view class="row">
<text class="label">标题</text>
<text class="value">{{ detail.title }}</text>
</view>
<view class="row">
<text class="label">内容</text>
<text class="value multiline">{{ detail.content }}</text>
</view>
<view class="row">
<text class="label">备注</text>
<text class="value multiline">{{ detail.remark || '—' }}</text>
</view>
</view>
</uni-card>
<uni-card :border="false" :padding="'16px'" title="附件">
<view
v-for="item in detail.attachments"
:key="item.id"
class="attachment"
@click="openAttachment(item.url)"
>
<view class="attachment-main">
<text class="attachment-name">{{ item.name }}</text>
<text class="attachment-meta">
{{ item.categoryName }} · {{ formatSize(item.length) }}
</text>
</view>
<text class="link">查看</text>
</view>
<view v-if="detail.attachments.length === 0" class="empty">暂无附件</view>
</uni-card>
<uni-card :border="false" :padding="'16px'" title="处理记录">
<view v-for="item in detail.records" :key="item.id" class="timeline-item">
<view class="timeline-dot"></view>
<view class="timeline-body">
<view class="timeline-head">
<text class="timeline-operator">{{ item.operatorName || '—' }}</text>
<text class="timeline-action">{{ item.actionName }}</text>
</view>
<text class="timeline-node">{{ item.nodeName || '提交' }}</text>
<text v-if="item.opinion" class="timeline-opinion">意见:{{ item.opinion }}</text>
<text class="timeline-time">{{ formatTime(item.createTime) }}</text>
</view>
</view>
<view v-if="detail.records.length === 0" class="empty">暂无记录</view>
</uni-card>
<view v-if="hasActions" class="actions">
<button
v-if="detail.myTask && detail.permissions.canApprove"
class="action-button primary"
@click="openApprove"
>
通过
</button>
<button
v-if="detail.myTask && detail.permissions.canReject"
class="action-button danger"
@click="openReject"
>
驳回
</button>
<button
v-if="detail.permissions.canWithdraw"
class="action-button ghost"
:loading="submitting"
@click="withdraw"
>
撤回
</button>
<button
v-if="showEditButton"
class="action-button ghost"
@click="editAndResubmit"
>
{{ editLabel }}
</button>
<button
v-if="detail.permissions.canSubmit"
class="action-button primary"
:loading="submitting"
@click="submitDraft"
>
{{ detail.status === 'DRAFT' ? '提交' : '重新提交' }}
</button>
</view>
<view v-if="actionDialog" class="dialog-mask">
<view class="dialog">
<text class="dialog-title">{{ actionDialog === 'approve' ? '审批意见' : '驳回意见' }}</text>
<textarea
v-model="opinion"
class="dialog-input"
:maxlength="500"
:placeholder="actionDialog === 'approve' ? '请输入意见(可选)' : '请输入驳回意见(必填)'"
/>
<view class="dialog-actions">
<button class="dialog-button ghost" @click="closeDialog">取消</button>
<button class="dialog-button primary" :loading="submitting" @click="confirmAction">
确定
</button>
</view>
</view>
</view>
</view>
<view v-else class="loading">
<text>{{ loading ? '加载中...' : '单据不存在' }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { getInstance, submitInstance } from '@/api/approval/instance';
import {
approveTask,
rejectTask,
withdrawInstance
} from '@/api/approval/task';
import type { ApprovalInstance } from '@/api/approval/model';
import { showError } from '@/utils/app';
const detail = ref<ApprovalInstance | null>(null);
const loading = ref(false);
const submitting = ref(false);
const instanceId = ref(0);
const actionDialog = ref<'' | 'approve' | 'reject'>('');
const opinion = ref('');
const hasActions = computed(() => {
const permissions = detail.value?.permissions;
if (!permissions) {
return false;
}
return (
permissions.canApprove ||
permissions.canReject ||
permissions.canWithdraw ||
permissions.canSubmit ||
permissions.canEdit
);
});
const showEditButton = computed(() => !!detail.value?.permissions.canEdit);
const editLabel = computed(() =>
detail.value?.status === 'REJECTED' || detail.value?.status === 'WITHDRAWN'
? '修改并重新提交'
: '编辑'
);
onLoad(options => {
instanceId.value = Number(options?.id) || 0;
load();
});
async function load() {
if (!instanceId.value) {
return;
}
loading.value = true;
try {
detail.value = await getInstance(instanceId.value);
} catch (error: any) {
showError(error?.message || '加载失败', null);
} finally {
loading.value = false;
}
}
function editAndResubmit() {
if (detail.value?.docType !== 'completion') {
showError('该单据类型暂不支持在线修改', null);
return;
}
uni.navigateTo({
url:
'/packageApproval/pages/form-completion?mode=edit&id=' + instanceId.value
});
}
function openApprove() {
opinion.value = '';
actionDialog.value = 'approve';
}
function openReject() {
opinion.value = '';
actionDialog.value = 'reject';
}
function closeDialog() {
if (submitting.value) {
return;
}
actionDialog.value = '';
}
async function confirmAction() {
const taskId = detail.value?.myTask?.id;
if (!taskId) {
showError('任务已处理,请刷新后重试', null);
return;
}
const isApprove = actionDialog.value === 'approve';
if (!isApprove && !opinion.value.trim()) {
uni.showToast({ title: '请填写驳回意见', icon: 'none' });
return;
}
submitting.value = true;
try {
const response = isApprove
? await approveTask(taskId, opinion.value.trim() || undefined)
: await rejectTask(taskId, opinion.value.trim());
actionDialog.value = '';
uni.showToast({
title: response.message || (isApprove ? '已通过' : '已驳回'),
icon: 'none'
});
await load();
} catch (error: any) {
showError(error?.message || '操作失败', null);
} finally {
submitting.value = false;
}
}
function withdraw() {
uni.showModal({
title: '撤回确认',
content: '撤回后单据回到报送人,可修改后重新提交。确认撤回?',
success: async result => {
if (!result.confirm) {
return;
}
submitting.value = true;
try {
const response = await withdrawInstance(instanceId.value);
uni.showToast({ title: response.message || '撤回成功', icon: 'none' });
await load();
} catch (error: any) {
showError(error?.message || '撤回失败', null);
} finally {
submitting.value = false;
}
}
});
}
function submitDraft() {
uni.showModal({
title: '提交确认',
content: '提交后字段与附件将锁定,确认提交?',
success: async result => {
if (!result.confirm) {
return;
}
submitting.value = true;
try {
const response = await submitInstance(instanceId.value);
uni.showToast({
title: response.message || '提交成功',
icon: 'none'
});
await load();
} catch (error: any) {
showError(error?.message || '提交失败', null);
} finally {
submitting.value = false;
}
}
});
}
function openAttachment(url?: string) {
if (!url) {
return;
}
// #ifdef H5
window.open(url, '_blank');
// #endif
// #ifndef H5
uni.downloadFile({
url,
success: result => {
if (result.statusCode === 200) {
uni.openDocument({
filePath: result.tempFilePath,
showMenu: true
});
}
},
fail: () => uni.showToast({ title: '附件打开失败', icon: 'none' })
});
// #endif
}
function formatTime(value?: string) {
if (!value) {
return '—';
}
return value.replace('T', ' ').substring(0, 19);
}
function formatFee(value?: number) {
if (value === undefined || value === null) {
return '—';
}
return Number(value).toFixed(2);
}
function formatSize(value?: number) {
if (!value) {
return '0 B';
}
if (value < 1024) {
return value + ' B';
}
if (value < 1024 * 1024) {
return (value / 1024).toFixed(1) + ' KB';
}
return (value / 1024 / 1024).toFixed(1) + ' MB';
}
</script>
<style lang="scss">
.detail-page {
min-height: 100vh;
background-color: #f5f6f8;
padding-bottom: 180rpx;
}
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16rpx;
}
.title {
flex: 1;
font-size: 32rpx;
font-weight: 600;
color: #303133;
}
.status {
font-size: 24rpx;
padding: 4rpx 14rpx;
border-radius: 999rpx;
background: #f2f3f5;
color: #606266;
}
.status-PENDING {
background: #fff7e6;
color: #d46b08;
}
.status-REJECTED {
background: #fff1f0;
color: #cf1322;
}
.status-APPROVED {
background: #f6ffed;
color: #389e0d;
}
.status-ARCHIVED {
background: #e6f4ff;
color: #0958d9;
}
.rows {
display: flex;
flex-direction: column;
}
.row {
display: flex;
align-items: flex-start;
margin-top: 12rpx;
font-size: 26rpx;
}
.label {
width: 160rpx;
color: #909399;
flex-shrink: 0;
}
.value {
flex: 1;
color: #303133;
word-break: break-all;
}
.multiline {
white-space: pre-wrap;
line-height: 1.6;
}
.attachment {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.attachment-main {
display: flex;
flex-direction: column;
flex: 1;
}
.attachment-name {
font-size: 26rpx;
color: #303133;
word-break: break-all;
}
.attachment-meta {
margin-top: 6rpx;
font-size: 24rpx;
color: #909399;
}
.link {
font-size: 26rpx;
color: #d81e06;
margin-left: 16rpx;
}
.timeline-item {
display: flex;
padding: 12rpx 0;
}
.timeline-dot {
width: 16rpx;
height: 16rpx;
margin: 10rpx 16rpx 0 0;
border-radius: 50%;
background: #d81e06;
flex-shrink: 0;
}
.timeline-body {
flex: 1;
display: flex;
flex-direction: column;
}
.timeline-head {
display: flex;
align-items: center;
gap: 12rpx;
}
.timeline-operator {
font-size: 28rpx;
color: #303133;
font-weight: 600;
}
.timeline-action {
font-size: 24rpx;
color: #d81e06;
}
.timeline-node,
.timeline-opinion,
.timeline-time {
margin-top: 6rpx;
font-size: 24rpx;
color: #909399;
}
.empty {
padding: 24rpx 0;
text-align: center;
font-size: 26rpx;
color: #909399;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 24rpx;
padding: 24rpx 32rpx 48rpx;
}
.action-button {
flex: 1;
min-width: 200rpx;
font-size: 30rpx;
border-radius: 12rpx;
}
.action-button.primary {
background: #d81e06;
color: #ffffff;
}
.action-button.ghost {
background: #ffffff;
color: #303133;
border: 1rpx solid #dcdfe6;
}
.action-button.danger {
background: #ffffff;
color: #cf1322;
border: 1rpx solid #cf1322;
}
.dialog-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
width: 600rpx;
padding: 32rpx;
border-radius: 16rpx;
background: #ffffff;
display: flex;
flex-direction: column;
}
.dialog-title {
font-size: 30rpx;
font-weight: 600;
color: #303133;
}
.dialog-input {
width: 100%;
height: 180rpx;
margin-top: 20rpx;
padding: 16rpx;
box-sizing: border-box;
font-size: 26rpx;
background: #f5f6f8;
border-radius: 12rpx;
}
.dialog-actions {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
.dialog-button {
flex: 1;
font-size: 28rpx;
border-radius: 12rpx;
}
.dialog-button.primary {
background: #d81e06;
color: #ffffff;
}
.dialog-button.ghost {
background: #ffffff;
color: #303133;
border: 1rpx solid #dcdfe6;
}
.loading {
padding: 160rpx 0;
text-align: center;
color: #909399;
font-size: 28rpx;
}
</style>