Merge branch 'master' of https://git.yunzer.cn/yunzerwebsite/yunzerwebsiteallinone
This commit is contained in:
commit
699a117678
28 files changed
+4428
-970
No files matched your search
@@ -0,0 +1,593 @@
|
||||
<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>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"path": "pages/approval/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "审批列表"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 顶部工具栏(系统导航栏已带返回按钮 + 标题,这里只放搜索/筛选) -->
|
||||
<view class="toolbar">
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="keyword"
|
||||
placeholder="搜索单据标题"
|
||||
confirm-type="search"
|
||||
@confirm="onSearch"
|
||||
/>
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<scroll-view class="tab-scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="tab-list">
|
||||
<view
|
||||
v-for="tab in statusTabs"
|
||||
:key="String(tab.value)"
|
||||
class="tab-item"
|
||||
:class="{ active: currentStatus === tab.value }"
|
||||
@tap="onStatusChange(tab.value)"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 说明:审批单据由各业务模块提交后进入此处,这里不提供新增 -->
|
||||
<text class="page-tip">审批由各业务模块提交后在此查看与处理</text>
|
||||
|
||||
<!-- 列表 -->
|
||||
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false" @scrolltolower="loadMore">
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="list" text="暂无审批记录" />
|
||||
</view>
|
||||
<block v-else>
|
||||
<view
|
||||
v-for="row in list"
|
||||
:key="row.id"
|
||||
class="approval-card"
|
||||
>
|
||||
<view class="card-top">
|
||||
<text class="card-title">{{ row.biz_title || '(无标题)' }}</text>
|
||||
<u-tag
|
||||
:text="row.status_label || ''"
|
||||
:type="row.status_tag || 'info'"
|
||||
size="mini"
|
||||
/>
|
||||
</view>
|
||||
<view class="card-meta">
|
||||
<text class="meta-text">{{ row.biz_type || '-' }}</text>
|
||||
<text class="meta-text">发起人:{{ row.submit_user_name || '-' }}</text>
|
||||
</view>
|
||||
<view class="card-meta">
|
||||
<text class="meta-text">提交:{{ row.submit_time || row.create_time || '-' }}</text>
|
||||
<text v-if="row.finish_time" class="meta-text">完成:{{ row.finish_time }}</text>
|
||||
</view>
|
||||
<view v-if="row.current_node" class="card-meta">
|
||||
<text class="meta-text">当前节点:{{ row.current_node }}</text>
|
||||
</view>
|
||||
<view class="card-actions">
|
||||
<view class="op-btn op-view" hover-class="btn-active" @tap.stop="openDetail(row.id)">查看详情</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="finished" class="load-done">
|
||||
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||
</view>
|
||||
<view v-else class="more-tip" @tap="loadMore">
|
||||
<text>加载更多</text>
|
||||
</view>
|
||||
</block>
|
||||
<view class="bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import {
|
||||
fetchApprovalList,
|
||||
APPROVAL_INSTANCE_STATUS_OPTIONS
|
||||
} from '@/api/approval.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
/** 状态 tab(首个为「全部」,不展示「草稿」) */
|
||||
const statusTabs = [
|
||||
{ value: '', label: '全部' },
|
||||
...APPROVAL_INSTANCE_STATUS_OPTIONS.filter(o => o.value !== 0)
|
||||
]
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const finished = ref(false)
|
||||
const keyword = ref('')
|
||||
const currentStatus = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
refresh()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (isLoggedIn()) refresh()
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (finished.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
page.value += 1
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchApprovalList({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
keyword: keyword.value.trim(),
|
||||
status: currentStatus.value
|
||||
})
|
||||
const rows = data.list || []
|
||||
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||
total.value = data.total || 0
|
||||
finished.value = list.value.length >= total.value
|
||||
} catch (e) {
|
||||
if (page.value > 1) page.value -= 1
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
function clearKeyword() {
|
||||
keyword.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onStatusChange(value) {
|
||||
currentStatus.value = value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
if (!id) {
|
||||
uni.showToast({ title: '单据数据异常', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/approval/detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ====== 顶部搜索 ====== */
|
||||
.toolbar {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
padding: 20rpx $page-padding-x;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-bg-page;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
/* ====== 状态筛选 tab ====== */
|
||||
.tab-scroll {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
background: $color-card;
|
||||
padding: 0 $page-padding-x 12rpx;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: inline-flex;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 0 4rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 10rpx 28rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-bg-page;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
&.active {
|
||||
background: $color-primary;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.page-tip {
|
||||
display: block;
|
||||
padding: 12rpx $page-padding-x 0;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
/* ====== 列表 ====== */
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.approval-card {
|
||||
@include card;
|
||||
margin: 16rpx $page-padding-x 0;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.card-active { opacity: 0.85; }
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.meta-text {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16rpx;
|
||||
padding-top: 14rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.op-btn {
|
||||
padding: 14rpx 36rpx;
|
||||
border-radius: $radius-md;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.op-view { background: $color-primary; }
|
||||
|
||||
.btn-active { opacity: 0.85; }
|
||||
|
||||
.more-tip {
|
||||
padding: 12rpx 0 4rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.load-done {
|
||||
padding: 12rpx 0 4rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 140rpx 0;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 顶部:搜索(系统导航栏已带返回按钮 + 标题) -->
|
||||
<view class="toolbar">
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="keyword"
|
||||
placeholder="搜索单据标题"
|
||||
confirm-type="search"
|
||||
@confirm="onSearch"
|
||||
/>
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stat-bar">
|
||||
<text class="stat-text">共 {{ total }} 条待办</text>
|
||||
</view>
|
||||
|
||||
<!-- 待办列表 -->
|
||||
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false" @scrolltolower="loadMore">
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="list" text="暂无待我审批的任务" />
|
||||
</view>
|
||||
<block v-else>
|
||||
<view
|
||||
v-for="task in list"
|
||||
:key="task.id"
|
||||
class="todo-card"
|
||||
hover-class="card-active"
|
||||
@tap="openDetail(task.instance)"
|
||||
>
|
||||
<view class="card-top">
|
||||
<text class="card-title">{{ task.instance && task.instance.biz_title ? task.instance.biz_title : '(无标题)' }}</text>
|
||||
<u-tag
|
||||
:text="task.instance && task.instance.status_label || ''"
|
||||
:type="task.instance && task.instance.status_tag || 'info'"
|
||||
size="mini"
|
||||
/>
|
||||
</view>
|
||||
<view class="card-meta">
|
||||
<text class="meta-text">节点:{{ task.node_name || '-' }}</text>
|
||||
<text class="meta-text">发起人:{{ task.instance && task.instance.submit_user_name || '-' }}</text>
|
||||
</view>
|
||||
<view class="card-meta">
|
||||
<text class="meta-text">单据:{{ task.instance && task.instance.biz_type || '-' }}</text>
|
||||
<text class="meta-text">提交:{{ task.instance && (task.instance.submit_time || task.instance.create_time) || '-' }}</text>
|
||||
</view>
|
||||
<view class="card-meta" v-if="task.deadline">
|
||||
<text class="meta-text">截止:{{ task.deadline }}</text>
|
||||
</view>
|
||||
<!-- card-actions 不接管 tap,只把"查看详情"按钮的 tap 单独拦在按钮层,
|
||||
这样 card 主体其它区域的 tap 仍能正常冒泡到 .todo-card 触发跳转 -->
|
||||
<view class="card-actions">
|
||||
<view class="op-btn op-view" hover-class="btn-active" @tap.stop="openDetail(task.instance)">查看详情</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="finished" class="load-done">
|
||||
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||
</view>
|
||||
<view v-else class="more-tip" @tap="loadMore">
|
||||
<text>加载更多</text>
|
||||
</view>
|
||||
</block>
|
||||
<view class="bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import {
|
||||
fetchApprovalTodoList
|
||||
} from '@/api/approval.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const finished = ref(false)
|
||||
const keyword = ref('')
|
||||
|
||||
onLoad(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
refresh()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (isLoggedIn() && list.value.length === 0) refresh()
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (finished.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
page.value += 1
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchApprovalTodoList({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
keyword: keyword.value.trim()
|
||||
})
|
||||
const rows = (data.list || []).filter(t => {
|
||||
if (!keyword.value) return true
|
||||
const title = t.instance && t.instance.biz_title ? String(t.instance.biz_title) : ''
|
||||
return title.toLowerCase().includes(keyword.value.toLowerCase())
|
||||
})
|
||||
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||
total.value = data.total || 0
|
||||
finished.value = list.value.length >= total.value
|
||||
} catch (e) {
|
||||
if (page.value > 1) page.value -= 1
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { refresh() }
|
||||
|
||||
function clearKeyword() {
|
||||
keyword.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function openDetail(instance) {
|
||||
if (!instance || !instance.id) {
|
||||
uni.showToast({ title: '单据数据异常', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/approval/detail?id=${instance.id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ====== 顶部搜索 ====== */
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 20rpx $page-padding-x;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-bg-page;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
/* ====== 计数条 ====== */
|
||||
.stat-bar {
|
||||
flex-shrink: 0;
|
||||
padding: 16rpx $page-padding-x;
|
||||
background: $color-card;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.stat-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
/* ====== 列表 ====== */
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.todo-card {
|
||||
@include card;
|
||||
margin: 16rpx $page-padding-x 0;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.card-active { opacity: 0.85; }
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.meta-text {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16rpx;
|
||||
padding-top: 14rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.op-btn {
|
||||
padding: 14rpx 36rpx;
|
||||
border-radius: $radius-md;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.op-view {
|
||||
background: $color-primary;
|
||||
}
|
||||
.btn-active { opacity: 0.85; }
|
||||
|
||||
.more-tip {
|
||||
padding: 12rpx 0 4rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.load-done {
|
||||
padding: 12rpx 0 4rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 140rpx 0;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -74,30 +74,45 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 审批记录 -->
|
||||
<!-- 审批记录(与审批中心一致的审批流样式) -->
|
||||
<view class="block-card">
|
||||
<view class="block-head">
|
||||
<text class="block-title">审批记录</text>
|
||||
<text class="block-total">{{ records.length }} 条</text>
|
||||
</view>
|
||||
<view v-if="!records.length" class="items-empty">
|
||||
<view v-if="!flowNodes.length" class="items-empty">
|
||||
<text>暂无审批记录</text>
|
||||
</view>
|
||||
<view v-else class="timeline">
|
||||
<view v-for="record in records" :key="record.id" class="timeline-item">
|
||||
<view class="timeline-dot" />
|
||||
<view class="timeline-content">
|
||||
<text class="timeline-title">
|
||||
{{ getExpenseActionLabel(record.action) }} · {{ record.approver_name || '-' }}
|
||||
</text>
|
||||
<text v-if="record.comment" class="timeline-comment">{{ record.comment }}</text>
|
||||
<text class="timeline-time">{{ formatTime(record.create_time) }}</text>
|
||||
<view v-else class="timeline-list">
|
||||
<view
|
||||
v-for="(node, idx) in flowNodes"
|
||||
:key="node.key"
|
||||
class="tl-item"
|
||||
:class="{ edge: node.isEdge, current: node.isCurrent }"
|
||||
>
|
||||
<view class="tl-rail">
|
||||
<view class="tl-avatar-wrap">
|
||||
<view class="tl-avatar" :class="{ 'avatar-end': node.isEdge }">{{ node.avatar }}</view>
|
||||
<view class="tl-badge" :class="node.badgeClass">{{ node.badgeIcon }}</view>
|
||||
</view>
|
||||
<view v-if="idx !== flowNodes.length - 1" class="tl-line" />
|
||||
</view>
|
||||
<view class="tl-main">
|
||||
<view class="tl-head">
|
||||
<view class="tl-title">
|
||||
<text class="tl-name">{{ node.title }}</text>
|
||||
<text v-if="node.sub" class="tl-sub">{{ node.sub }}</text>
|
||||
</view>
|
||||
<text class="tl-time">{{ node.time }}</text>
|
||||
</view>
|
||||
<view v-if="node.comment" class="tl-comment">{{ node.comment }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 操作按钮(统一放详情页):草稿为创建人操作,审批中为待办审批人操作 -->
|
||||
<!-- 操作按钮:草稿可删除/编辑/提交;审批通过后由报销人确认到账 -->
|
||||
<view v-if="detail && isDraft" class="footer">
|
||||
<view class="footer-btn">
|
||||
<u-button type="error" plain icon="trash" text="删除" @click="onDelete" />
|
||||
@@ -109,18 +124,11 @@
|
||||
<u-button type="primary" icon="arrow-up" text="提交审批" @click="onSubmit" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="detail && myTask" class="footer footer-approve">
|
||||
<view class="footer-tip">
|
||||
<FaIcon name="circle-check" color="#10b981" :size="14" />
|
||||
<text>我是当前节点「{{ myTask.node_name }}」审批人</text>
|
||||
</view>
|
||||
<view class="footer-row">
|
||||
<view class="footer-btn">
|
||||
<u-button type="error" plain icon="close-circle" text="驳回" @click="onReject" />
|
||||
</view>
|
||||
<view class="footer-btn">
|
||||
<u-button type="primary" icon="checkmark" text="通过" @click="onApprove" />
|
||||
</view>
|
||||
|
||||
<!-- 审批通过:等待报销人确认款项是否已到账 -->
|
||||
<view v-else-if="detail && isApproved" class="footer">
|
||||
<view class="footer-btn">
|
||||
<u-button type="success" icon="checkmark-circle" text="确认到账" @click="onConfirmPaid" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -128,11 +136,12 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
|
||||
import {
|
||||
getExpense,
|
||||
deleteExpense,
|
||||
submitExpense,
|
||||
confirmExpensePaid,
|
||||
fetchExpenseRecords,
|
||||
fetchMyApproveTask,
|
||||
approveExpense,
|
||||
@@ -153,12 +162,95 @@ const myTask = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const isDraft = computed(() => Number(detail.value?.status) === EXPENSE_STATUS.DRAFT)
|
||||
// 审批通过:等待报销人本人确认款项到账
|
||||
const isApproved = computed(() => Number(detail.value?.status) === EXPENSE_STATUS.APPROVED)
|
||||
|
||||
const descText = computed(() => {
|
||||
const desc = (detail.value?.description || '').trim()
|
||||
return desc || ''
|
||||
})
|
||||
|
||||
/** 审批记录 -> 审批流节点:每条记录一个节点,末尾补「流程结束」节点展示单据当前状态 */
|
||||
const flowNodes = computed(() => {
|
||||
const nodes = (records.value || []).map((record, index) => {
|
||||
const action = Number(record.action)
|
||||
const step = Number(record.step)
|
||||
return {
|
||||
key: `record-${record.id || index}`,
|
||||
avatar: avatarOf(record.approver_name),
|
||||
badgeClass: badgeClassOfAction(action),
|
||||
badgeIcon: badgeIconOfAction(action),
|
||||
title:
|
||||
action === 1
|
||||
? `${record.approver_name || '发起人'}(申请人)`
|
||||
: record.approver_name || '审批人',
|
||||
sub:
|
||||
step > 0
|
||||
? `${getExpenseActionLabel(action)} · 第 ${step} 步`
|
||||
: getExpenseActionLabel(action),
|
||||
time: shortTime(record.create_time),
|
||||
comment: record.comment ? String(record.comment) : '',
|
||||
isEdge: action === 1,
|
||||
isCurrent: false,
|
||||
}
|
||||
})
|
||||
if (!detail.value) return nodes
|
||||
nodes.push(buildEndNode())
|
||||
return nodes
|
||||
})
|
||||
|
||||
/** 末尾「流程结束」节点:显示单据当前状态,让申请人一眼看出流程是否走完 */
|
||||
function buildEndNode() {
|
||||
const status = Number(detail.value?.status)
|
||||
const map = {
|
||||
0: { label: '草稿', cls: 'badge-skip', icon: '—' },
|
||||
1: { label: '审批中', cls: 'badge-running', icon: '·' },
|
||||
2: { label: '已通过', cls: 'badge-pass', icon: '✓' },
|
||||
3: { label: '已驳回', cls: 'badge-reject', icon: '✕' },
|
||||
4: { label: '已撤回', cls: 'badge-skip', icon: '—' },
|
||||
5: { label: '已打款', cls: 'badge-pass', icon: '✓' },
|
||||
}
|
||||
const hit = map[status] || { label: '未知', cls: 'badge-skip', icon: '—' }
|
||||
return {
|
||||
key: 'flow-end',
|
||||
avatar: '结',
|
||||
badgeClass: hit.cls,
|
||||
badgeIcon: hit.icon,
|
||||
title: '流程结束',
|
||||
sub: hit.label,
|
||||
time: shortTime(detail.value?.update_time),
|
||||
comment: '',
|
||||
isEdge: true,
|
||||
isCurrent: status === 1,
|
||||
}
|
||||
}
|
||||
|
||||
function avatarOf(name) {
|
||||
const text = String(name || '').trim()
|
||||
return text ? text.slice(0, 1) : '系'
|
||||
}
|
||||
|
||||
/** 动作角标:1提交/2通过 -> 绿✓;3驳回 -> 红✕;4转审 -> 橙;5撤回 -> 灰 */
|
||||
function badgeClassOfAction(action) {
|
||||
if (action === 3) return 'badge-reject'
|
||||
if (action === 1 || action === 2) return 'badge-pass'
|
||||
if (action === 4) return 'badge-running'
|
||||
return 'badge-skip'
|
||||
}
|
||||
|
||||
function badgeIconOfAction(action) {
|
||||
if (action === 3) return '✕'
|
||||
if (action === 1 || action === 2) return '✓'
|
||||
if (action === 4) return '·'
|
||||
return '—'
|
||||
}
|
||||
|
||||
/** 时间只展示到分钟 */
|
||||
function shortTime(value) {
|
||||
if (!value) return ''
|
||||
return String(value).slice(0, 16)
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
if (query?.id) {
|
||||
expenseId.value = query.id
|
||||
@@ -170,6 +262,20 @@ onShow(() => {
|
||||
if (expenseId.value) loadDetail()
|
||||
})
|
||||
|
||||
// 监听审批通过 / 确认到账后的刷新事件。
|
||||
// 新版 uni-app 已不再导出 onUniAppEvent,改用全局事件总线并在页面卸载时解绑。
|
||||
function handleExpenseUpdated(data) {
|
||||
if (data?.id === expenseId.value) {
|
||||
// 延迟一点刷新,确保后端数据已同步
|
||||
setTimeout(() => loadDetail(), 300)
|
||||
}
|
||||
}
|
||||
uni.$on('expense-updated', handleExpenseUpdated)
|
||||
|
||||
onUnload(() => {
|
||||
uni.$off('expense-updated', handleExpenseUpdated)
|
||||
})
|
||||
|
||||
/** 后端时间字段可能为 "YYYY-MM-DD HH:mm:ss" 格式,iOS 需转 T 分隔 */
|
||||
function formatTime(value) {
|
||||
if (!value) return ''
|
||||
@@ -254,6 +360,29 @@ function onSubmit() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 报销人确认款项已到账:确认后单据状态变为「已打款」 */
|
||||
async function onConfirmPaid() {
|
||||
const confirmed = await new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: '确认到账',
|
||||
content: `确认已收到 ¥${moneyText(detail.value?.total_amount)} 的报销款吗?确认后单据状态变为「已打款」。`,
|
||||
confirmText: '确认已到账',
|
||||
cancelText: '暂未到账',
|
||||
success: (res) => resolve(!!res.confirm),
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
})
|
||||
if (!confirmed) return
|
||||
try {
|
||||
await confirmExpensePaid(expenseId.value)
|
||||
uni.showToast({ title: '已确认到账', icon: 'success' })
|
||||
await loadDetail()
|
||||
uni.$emit('expense-updated', { id: expenseId.value })
|
||||
} catch (e) {
|
||||
// request 封装已弹出服务端错误提示
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
uni.showModal({
|
||||
title: '删除报销单',
|
||||
@@ -498,71 +627,8 @@ function onReject() {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
padding-top: 4rpx;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding-bottom: 28rpx;
|
||||
position: relative;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 4rpx;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 9rpx;
|
||||
top: 24rpx;
|
||||
bottom: 0;
|
||||
width: 2rpx;
|
||||
background: $color-divider;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
background: #2563eb;
|
||||
flex-shrink: 0;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeline-comment {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
/* 审批记录沿用审批中心的审批流样式 */
|
||||
@include flow-timeline;
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
|
||||
Reference in new issue
Block a user