Files
yunzerwebsiteallinone/uniapp/pages/approval/detail.vue
T
2026-10-16 22:40:02 +08:00

654 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="page">
<!-- 系统导航栏已带返回按钮 + 标题,无需自定义 header -->
<scroll-view class="body" scroll-y :show-scrollbar="false">
<!-- 顶部摘要 -->
<view class="summary">
<view class="summary-main">
<text class="summary-title">{{ item.biz_title || '审批详情' }}</text>
<text class="summary-sub">{{ item.biz_type || '' }}</text>
</view>
<view class="summary-status">
<u-tag
:text="item.status_label || ''"
:type="item.status_tag || 'info'"
size="medium"
/>
</view>
</view>
<!-- 基本信息 -->
<view class="card">
<text class="card-title">基本信息</text>
<view class="info-row">
<text class="info-label">单据编号</text>
<text class="info-value">#{{ item.id || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">单据类型</text>
<text class="info-value">{{ item.biz_type || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">发起人</text>
<text class="info-value">{{ item.submit_user_name || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">提交时间</text>
<text class="info-value">{{ item.submit_time || item.create_time || '-' }}</text>
</view>
<view v-if="item.finish_time" class="info-row">
<text class="info-label">完成时间</text>
<text class="info-value">{{ item.finish_time }}</text>
</view>
<view v-if="item.current_node" class="info-row">
<text class="info-label">当前节点</text>
<text class="info-value">{{ item.current_node }}</text>
</view>
</view>
<!-- 审批流程时间线 -->
<view class="card">
<text class="card-title">审批流程</text>
<view v-if="!timeline.length" class="timeline-empty">暂无流程节点</view>
<view v-else class="timeline-list">
<view
v-for="(task, idx) in timeline"
:key="task.id || idx"
class="timeline-item"
:class="{ current: task.is_current }"
>
<view class="timeline-dot">
<view class="dot-inner" :class="dotClass(task)"></view>
</view>
<view class="timeline-content" v-if="idx === 0 || task.node_key !== timeline[idx - 1].node_key">
<view class="timeline-head">
<text class="node-name">{{ task.node_name || task.node_key }}</text>
<u-tag
:text="task.status_label || ''"
:type="task.status_tag || 'info'"
size="mini"
/>
</view>
<view v-if="task.is_current" class="timeline-row">
<text class="node-meta current">当前节点 · 等待 {{ task.approver_name || '审批人' }} 处理</text>
</view>
<view v-else-if="task.status === 1" class="timeline-row">
<text class="node-meta">{{ task.approver_name || '-' }} · 通过于 {{ task.finish_time }}</text>
<text v-if="task.comment" class="node-comment">{{ task.comment }}</text>
</view>
<view v-else-if="task.status === 2" class="timeline-row">
<text class="node-meta">{{ task.approver_name || '-' }} · 驳回于 {{ task.finish_time }}</text>
<text v-if="task.comment" class="node-comment">{{ task.comment }}</text>
</view>
<view v-else class="timeline-row">
<text class="node-meta">{{ task.approver_name || '-' }} · {{ task.finish_time || task.create_time }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="bottom-space" />
</scroll-view>
<!-- 底部操作栏:只有有操作权限时才显示 -->
<view v-if="showActionBar" class="action-bar">
<!-- 当前审批人:通过 + 驳回 -->
<view v-if="canApprove" class="btn-row">
<view class="btn-reject" hover-class="btn-active" @tap="openApprove(false)">驳回</view>
<view class="btn-pass" hover-class="btn-active" @tap="openApprove(true)">通过</view>
</view>
<!-- 发起人:仅撤回(不能审批自己的单子) -->
<view v-else-if="canWithdraw" class="btn-row">
<view class="btn-withdraw" hover-class="btn-active" @tap="doWithdraw">撤回审批</view>
</view>
</view>
<!-- 审批意见弹窗 -->
<view v-if="showApprove" class="modal-overlay" @tap="hideApprove">
<view class="modal-container" @tap.stop>
<view class="modal-head">
<text class="modal-title">{{ approveForm.pass ? '通过审批' : '驳回审批' }}</text>
<text class="modal-close" @tap="hideApprove">✕</text>
</view>
<view class="approve-summary">
<text class="summary-label">单据</text>
<text class="summary-value">{{ item.biz_title || '-' }}</text>
</view>
<view class="approve-summary">
<text class="summary-label">节点</text>
<text class="summary-value">{{ currentNodeLabel || '-' }}</text>
</view>
<view class="field">
<text class="field-label">审批意见</text>
<textarea
v-model="approveForm.comment"
class="field-textarea"
placeholder="请输入审批意见(选填)"
placeholder-class="field-placeholder"
rows="4"
/>
</view>
<view class="modal-footer">
<view
class="btn-primary"
:class="[approveForm.pass ? 'btn-pass' : 'btn-reject', { disabled: approving }]"
@tap="doApprove"
>
{{ approving ? '提交中...' : (approveForm.pass ? '确认通过' : '确认驳回') }}
</view>
<view class="btn-cancel" hover-class="btn-active" @tap="hideApprove">取消</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import {
fetchApprovalDetail,
approveTask,
withdrawApproval
} from '@/api/approval.js'
import { isLoggedIn } from '@/utils/auth.js'
const item = ref({})
const timeline = ref([])
const canApprove = ref(false)
const canWithdraw = ref(false)
const currentTaskID = ref(0)
const loading = ref(false)
const showApprove = ref(false)
const approving = ref(false)
const approveForm = ref({ pass: true, comment: '' })
const showActionBar = computed(() => canApprove.value || canWithdraw.value)
// 当前节点名(审批弹窗展示用)
const currentNodeLabel = computed(() => {
const cur = timeline.value.find(t => t.is_current)
return cur ? (cur.node_name || cur.node_key) : ''
})
onLoad((options) => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
const id = options && options.id
if (!id) {
uni.showToast({ title: '缺少审批ID', icon: 'none' })
return
}
loadDetail(id)
})
async function loadDetail(id) {
loading.value = true
try {
const data = await fetchApprovalDetail(id)
item.value = data.item || {}
timeline.value = data.timeline || []
canApprove.value = !!data.canApprove
canWithdraw.value = !!data.canWithdraw
currentTaskID.value = data.currentTaskID || 0
} catch (e) {
// request.js 已 toast
} finally {
loading.value = false
}
}
function dotClass(task) {
if (task.is_current) return 'dot-running'
if (task.status === 1) return 'dot-pass'
if (task.status === 2) return 'dot-reject'
return 'dot-default'
}
function openApprove(pass) {
approveForm.value = { pass, comment: '' }
showApprove.value = true
}
function hideApprove() {
showApprove.value = false
}
async function doApprove() {
if (approving.value) return
// currentTaskID 为 0 表示当前用户不是审批人(典型:发起人视角),
// 仍然把请求交给后端判定:后端会返回 403「您无权处理该任务」并由 request.js 弹 toast。
approving.value = true
try {
await approveTask({
taskId: currentTaskID.value || 0,
pass: approveForm.value.pass,
comment: approveForm.value.comment.trim()
})
uni.showToast({
title: approveForm.value.pass ? '已通过' : '已驳回',
icon: 'success'
})
hideApprove()
// 如果是报销单审批,跳转回报销详情页并触发刷新事件
// (biz_type 兼容新写法 reimburse 与早期按模块记录的 oa)
if (['reimburse', 'oa'].includes(item.value.biz_type) && item.value.biz_id) {
uni.$emit('expense-updated', { id: item.value.biz_id })
uni.redirectTo({
url: `/pages/tools/expense/detail?id=${item.value.biz_id}`
})
return
}
loadDetail(item.value.id)
} catch (e) {
// request.js 已 toast
} finally {
approving.value = false
}
}
async function doWithdraw() {
uni.showModal({
title: '确认撤回',
content: '撤回后该审批将立即结束,确认要撤回吗?',
confirmColor: '#f04438',
cancelText: '取消',
confirmText: '撤回',
success: async (res) => {
if (!res.confirm) return
try {
await withdrawApproval(item.value.id)
uni.showToast({ title: '已撤回', icon: 'success' })
loadDetail(item.value.id)
} catch (e) {
// request.js 已 toast
}
}
})
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
}
.body {
flex: 1;
min-height: 0;
padding: 20rpx $page-padding-x;
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
/* ====== 摘要区 ====== */
.summary {
@include card;
margin-bottom: 20rpx;
padding: 28rpx 28rpx 24rpx;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.summary-main {
flex: 1;
min-width: 0;
}
.summary-title {
display: block;
font-size: 34rpx;
font-weight: 600;
color: $color-text;
line-height: 1.4;
word-break: break-all;
}
.summary-sub {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: $color-text-muted;
}
.summary-status {
flex-shrink: 0;
padding-top: 4rpx;
}
/* ====== 卡片 ====== */
.card {
@include card;
margin-bottom: 20rpx;
padding: 28rpx 28rpx 16rpx;
}
.card-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 18rpx;
}
.info-row {
display: flex;
align-items: flex-start;
gap: 16rpx;
padding: 12rpx 0;
font-size: 26rpx;
border-bottom: 1rpx solid $color-divider;
&:last-child { border-bottom: none; }
}
.info-label {
width: 140rpx;
flex-shrink: 0;
color: $color-text-muted;
}
.info-value {
flex: 1;
min-width: 0;
color: $color-text;
word-break: break-all;
}
/* ====== 时间线 ====== */
.timeline-empty {
font-size: 24rpx;
color: $color-text-muted;
padding: 16rpx 0 8rpx;
}
.timeline-list {
display: flex;
flex-direction: column;
padding-left: 12rpx;
}
.timeline-item {
display: flex;
gap: 16rpx;
position: relative;
padding-bottom: 18rpx;
&:not(:last-child)::after {
content: '';
position: absolute;
left: 18rpx;
top: 30rpx;
bottom: 0;
width: 2rpx;
background: $color-divider;
}
&.current {
.node-name { color: $color-primary; }
}
}
.timeline-dot {
width: 36rpx;
height: 36rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dot-inner {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: $color-text-muted;
&.dot-default { background: $color-text-muted; }
&.dot-pass { background: #16a34a; }
&.dot-reject { background: #f04438; }
&.dot-running {
background: $color-primary;
box-shadow: 0 0 0 8rpx rgba(37, 99, 235, 0.16);
}
}
.timeline-content {
flex: 1;
min-width: 0;
padding-top: 4rpx;
}
.timeline-row {
display: flex;
flex-direction: column;
gap: 4rpx;
margin-bottom: 6rpx;
&:last-child { margin-bottom: 0; }
}
/* 节点头部:节点名 + 状态 tag 横排,tag 不要撑满 100% */
.timeline-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-bottom: 6rpx;
}
.node-name {
flex: 1;
min-width: 0;
font-size: 28rpx;
color: $color-text;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-meta {
font-size: 24rpx;
color: $color-text-muted;
word-break: break-all;
&.current {
color: $color-primary;
font-weight: 500;
}
}
.node-comment {
font-size: 24rpx;
color: $color-text-secondary;
background: $color-bg-page;
padding: 10rpx 14rpx;
border-radius: $radius-sm;
margin-top: 6rpx;
line-height: 1.5;
}
.bottom-space {
height: 40rpx;
}
/* ====== 底部操作栏 ====== */
.action-bar {
flex-shrink: 0;
background: $color-card;
padding: 20rpx $page-padding-x;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
box-shadow: 0 -4rpx 16rpx rgba(15, 23, 42, 0.06);
}
.btn-row {
display: flex;
gap: 16rpx;
}
.btn-pass,
.btn-reject,
.btn-withdraw {
flex: 1;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 500;
border-radius: $radius-md;
}
.btn-pass {
background: $color-primary;
color: #ffffff;
}
.btn-reject {
background: #f04438;
color: #ffffff;
}
.btn-withdraw {
background: $color-bg-page;
color: $color-text-secondary;
border: 1rpx solid $color-border;
}
.btn-active { opacity: 0.85; }
/* ====== 审批弹窗 ====== */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.modal-container {
width: 640rpx;
max-height: 80vh;
background: #ffffff;
border-radius: 24rpx;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 24rpx 16rpx;
border-bottom: 1rpx solid $color-border;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: $color-text;
}
.modal-close {
font-size: 28rpx;
color: $color-text-muted;
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
}
.approve-summary {
display: flex;
gap: 8rpx;
padding: 12rpx 24rpx;
font-size: 26rpx;
border-bottom: 1rpx dashed $color-border;
&:last-of-type { border-bottom: 1rpx solid $color-border; }
}
.summary-label {
color: $color-text-muted;
flex-shrink: 0;
width: 80rpx;
}
.summary-value {
color: $color-text;
flex: 1;
min-width: 0;
word-break: break-all;
}
.field {
padding: 20rpx 24rpx 0;
}
.field-label {
display: block;
font-size: 24rpx;
color: $color-text-secondary;
margin-bottom: 10rpx;
font-weight: 500;
}
.field-textarea {
width: 100%;
min-height: 160rpx;
border: 1rpx solid $color-border;
border-radius: $radius-md;
padding: 16rpx 20rpx;
font-size: 26rpx;
color: $color-text;
background: $color-bg-page;
box-sizing: border-box;
}
.modal-footer {
padding: 20rpx 24rpx;
display: flex;
justify-content: flex-end;
gap: 16rpx;
border-top: 1rpx solid $color-border;
}
.btn-primary {
padding: 16rpx 32rpx;
font-size: 28rpx;
color: #ffffff;
border-radius: $radius-md;
&.btn-pass { background: $color-primary; }
&.btn-reject { background: #f04438; }
&.disabled { opacity: 0.6; }
}
.btn-cancel {
padding: 16rpx 32rpx;
font-size: 28rpx;
color: $color-text-secondary;
background: $color-bg-page;
border-radius: $radius-md;
}
</style>