594 lines
14 KiB
Vue
594 lines
14 KiB
Vue
<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.node_key || 'node'}-${idx}`"
|
||
class="tl-item"
|
||
:class="{ current: task.is_current, edge: task.node_type === 1 || task.node_type === 3 }"
|
||
>
|
||
<view class="tl-rail">
|
||
<view class="tl-avatar-wrap">
|
||
<view class="tl-avatar" :class="avatarClass(task)">{{ avatarText(task) }}</view>
|
||
<view class="tl-badge" :class="badgeClass(task)">{{ badgeIcon(task) }}</view>
|
||
</view>
|
||
<view v-if="idx !== timeline.length - 1" class="tl-line" />
|
||
</view>
|
||
<view class="tl-main">
|
||
<view class="tl-head">
|
||
<view class="tl-title">
|
||
<text class="tl-name">{{ nodeTitle(task) }}</text>
|
||
<text v-if="nodeSub(task)" class="tl-sub">{{ nodeSub(task) }}</text>
|
||
</view>
|
||
<text class="tl-time">{{ nodeTime(task) }}</text>
|
||
</view>
|
||
<view v-if="task.comment" class="tl-comment">{{ task.comment }}</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 avatarText(task) {
|
||
const name = String(task.approver_name || '').trim()
|
||
return name ? name.slice(0, 1) : '系'
|
||
}
|
||
|
||
/** 头像底色:审批人蓝色,结束节点灰色 */
|
||
function avatarClass(task) {
|
||
return task.node_type === 3 ? 'avatar-end' : ''
|
||
}
|
||
|
||
/** 状态角标配色:进行中橙 / 已通过绿 / 已驳回红 / 其它灰 */
|
||
function badgeClass(task) {
|
||
if (task.is_current) return 'badge-running'
|
||
if (task.status === 1) return 'badge-pass'
|
||
if (task.status === 2) return 'badge-reject'
|
||
return 'badge-skip'
|
||
}
|
||
|
||
function badgeIcon(task) {
|
||
if (task.is_current) return '·'
|
||
if (task.status === 1) return '✓'
|
||
if (task.status === 2) return '✕'
|
||
return '—'
|
||
}
|
||
|
||
/** 主标题:申请人带「(申请人)」标识,末尾固定「流程结束」 */
|
||
function nodeTitle(task) {
|
||
if (task.node_type === 1) return `${task.approver_name || '发起人'}(申请人)`
|
||
if (task.node_type === 3) return '流程结束'
|
||
return task.approver_name || task.node_name || '审批节点'
|
||
}
|
||
|
||
/** 副标题:部门/角色,缺失时回退节点名 */
|
||
function nodeSub(task) {
|
||
if (task.node_type === 1) return '提交申请'
|
||
if (task.node_type === 3) return task.status_label || ''
|
||
return task.approver_sub || task.node_name || '审批'
|
||
}
|
||
|
||
/** 时间只展示到分钟,保持与设计稿一致的紧凑排版 */
|
||
function nodeTime(task) {
|
||
const value = task.finish_time || task.create_time || ''
|
||
return value ? String(value).slice(0, 16) : ''
|
||
}
|
||
|
||
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;
|
||
position: relative;
|
||
padding-left: 18rpx;
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: $color-text;
|
||
margin-bottom: 24rpx;
|
||
|
||
/* 左侧蓝色竖条标识(与设计稿一致) */
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 8rpx;
|
||
height: 26rpx;
|
||
border-radius: 4rpx;
|
||
background: $color-primary;
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 审批流时间线(公共 mixin,与报销详情的审批记录同一套样式) */
|
||
@include flow-timeline;
|
||
|
||
.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>
|