657 lines
15 KiB
Vue
657 lines
15 KiB
Vue
<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() {
|
||
const docType = detail.value?.docType;
|
||
const page =
|
||
docType === 'completion'
|
||
? 'form-completion'
|
||
: docType === 'coordination'
|
||
? 'form-coordination'
|
||
: '';
|
||
if (!page) {
|
||
showError('该单据类型暂不支持在线修改', null);
|
||
return;
|
||
}
|
||
uni.navigateTo({
|
||
url: `/packageApproval/pages/${page}?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>
|