Files
yunzerwebsiteallinone/uniapp/pages/tools/expense/detail.vue
T

681 lines
17 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">
<view v-if="loading" class="state-box">
<text class="state-text">加载中...</text>
</view>
<template v-else-if="detail">
<!-- 单据信息 -->
<view class="detail-card">
<view class="head-row">
<text class="head-amount">¥{{ moneyText(detail.total_amount) }}</text>
<u-tag
:text="getExpenseStatusLabel(detail.status)"
:type="getExpenseStatusType(detail.status)"
size="mini"
/>
</view>
<text v-if="descText" class="head-desc">{{ descText }}</text>
<view class="info-row">
<text class="info-label">申请人</text>
<text class="info-value">{{ detail.user_name || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">申请日期</text>
<text class="info-value">{{ detail.apply_date }}</text>
</view>
<view class="info-row">
<text class="info-label">费用笔数</text>
<text class="info-value">{{ items.length }} 笔</text>
</view>
<view class="meta-row">
<text>创建于 {{ formatTime(detail.create_time) }}</text>
<text v-if="detail.update_time">更新于 {{ formatTime(detail.update_time) }}</text>
</view>
</view>
<!-- 费用明细 -->
<view class="block-card">
<view class="block-head">
<text class="block-title">费用明细</text>
<text class="block-total">¥{{ moneyText(detail.total_amount) }}</text>
</view>
<view v-if="!items.length" class="items-empty">
<text>暂无费用明细</text>
</view>
<view v-for="item in items" :key="item.id" class="exp-item">
<view class="exp-item-main">
<view class="exp-item-line">
<text class="exp-item-type">{{ item.expense_type || '未分类' }}</text>
<text class="exp-item-amount">¥{{ moneyText(item.amount) }}</text>
</view>
<view class="exp-item-sub">
<text>{{ item.expense_date }}{{ item.quantity > 1 ? ` × ${item.quantity}` : '' }}</text>
<text v-if="item.description" class="exp-item-desc">{{ item.description }}</text>
</view>
<view v-if="item.receipts?.length" class="receipt-grid">
<template v-for="(img, index) in item.receipts" :key="img.id || index">
<image
v-if="!isPdfReceipt(img)"
class="receipt-item"
:src="img.url"
mode="aspectFill"
@click="previewReceipt(item.receipts, index)"
/>
<view v-else class="receipt-file" @click="openReceiptFile(img)">
<FaIcon name="file-pdf" color="#e74c3c" :size="26" />
<text class="receipt-file-name">{{ img.name || 'PDF 文件' }}</text>
<text class="receipt-file-hint">点击打开</text>
</view>
</template>
</view>
</view>
</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="!flowNodes.length" class="items-empty">
<text>暂无审批记录</text>
</view>
<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" />
</view>
<view class="footer-btn">
<u-button type="primary" plain icon="edit-pen" text="编辑" @click="openEdit" />
</view>
<view class="footer-btn">
<u-button type="primary" icon="arrow-up" text="提交审批" @click="onSubmit" />
</view>
</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>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import {
getExpense,
deleteExpense,
submitExpense,
confirmExpensePaid,
fetchExpenseRecords,
fetchMyApproveTask,
approveExpense,
rejectExpense,
getExpenseStatusLabel,
getExpenseStatusType,
getExpenseActionLabel,
isPdfReceipt,
moneyText,
EXPENSE_STATUS
} from '@/api/expense.js'
const expenseId = ref('')
const detail = ref(null)
const items = ref([])
const records = ref([])
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
loadDetail()
}
})
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 ''
return String(value).replace(' ', 'T').slice(0, 16).replace('T', ' ')
}
async function loadDetail() {
loading.value = true
try {
const data = await getExpense(expenseId.value)
if (!data) {
uni.showToast({ title: '报销单不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
return
}
detail.value = data
items.value = data.items || []
records.value = await fetchExpenseRecords(expenseId.value)
// 审批人视角:审批中的单据查询自己是否有待办任务(非审批人静默返回 null)
try {
myTask.value = Number(data.status) === EXPENSE_STATUS.APPROVING
? await fetchMyApproveTask(expenseId.value)
: null
} catch (e) {
myTask.value = null
}
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
/** 图片预览:只把图片类发票给 previewImage,PDF 不参与 */
function previewReceipt(receipts, index) {
const current = receipts?.[index]
const urls = (receipts || [])
.filter(img => !isPdfReceipt(img))
.map(img => img.url)
.filter(Boolean)
if (!urls.length) return
uni.previewImage({ urls, current: current && !isPdfReceipt(current) ? current.url : urls[0] })
}
/** PDF 发票:下载后用文档查看器打开(image 组件无法渲染 PDF,会被 ORB 拦截) */
function openReceiptFile(file) {
if (!file?.url) return
uni.showLoading({ title: '打开中...' })
uni.downloadFile({
url: file.url,
success: (res) => {
uni.openDocument({
filePath: res.tempFilePath,
fileType: 'pdf',
showMenu: true,
fail: () => uni.showToast({ title: '当前环境无法打开 PDF', icon: 'none' })
})
},
fail: () => uni.showToast({ title: '文件下载失败', icon: 'none' }),
complete: () => uni.hideLoading()
})
}
function openEdit() {
uni.navigateTo({ url: `/pages/tools/expense/edit?id=${expenseId.value}` })
}
function onSubmit() {
uni.showModal({
title: '提交审批',
content: '提交后将进入审批流程,草稿期间仍可修改,确定提交吗?',
success: async (res) => {
if (!res.confirm) return
try {
await submitExpense(expenseId.value)
uni.showToast({ title: '已提交审批', icon: 'success' })
loadDetail()
} catch (e) {
// request 封装已弹出服务端错误提示
}
}
})
}
/** 报销人确认款项已到账:确认后单据状态变为「已打款」 */
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: '删除报销单',
content: '删除后不可恢复,确定删除吗?',
success: async (res) => {
if (!res.confirm) return
try {
await deleteExpense(expenseId.value)
uni.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (e) {
// request 封装已弹出服务端错误提示
}
}
})
}
/** 通过当前审批节点:成功后按引擎提示刷新(会签等待/流转/完成) */
function onApprove() {
uni.showModal({
title: '通过审批',
content: `确认通过「${myTask.value?.node_name || '审批'}」节点吗?`,
success: async (res) => {
if (!res.confirm) return
try {
const result = await approveExpense(expenseId.value, '')
uni.showToast({ title: result?.message || '已通过', icon: 'none' })
loadDetail()
} catch (e) {
// request 封装已弹出服务端错误提示
}
}
})
}
/** 驳回报销单:填写审批意见(可空) */
function onReject() {
uni.showModal({
title: '驳回',
editable: true,
placeholderText: '请输入驳回原因(选填)',
success: async (res) => {
if (!res.confirm) return
try {
await rejectExpense(expenseId.value, (res.content || '').trim())
uni.showToast({ title: '已驳回', icon: 'none' })
loadDetail()
} catch (e) {
// request 封装已弹出服务端错误提示
}
}
})
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
background: $color-bg-page;
padding: 24rpx $page-padding-x;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
overflow-x: hidden;
}
.detail-card,
.block-card {
@include card;
padding: 32rpx 28rpx;
margin-bottom: 24rpx;
}
.head-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.head-amount {
font-size: 44rpx;
font-weight: 600;
color: $color-text;
}
.head-desc {
display: block;
margin-top: 14rpx;
font-size: 28rpx;
color: $color-text-secondary;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
padding: 20rpx 0 0;
margin-top: 20rpx;
border-top: 1rpx solid $color-divider;
}
.info-label {
font-size: 26rpx;
color: $color-text-muted;
flex-shrink: 0;
}
.info-value {
font-size: 28rpx;
color: $color-text;
text-align: right;
}
.meta-row {
display: flex;
flex-direction: column;
gap: 8rpx;
padding-top: 24rpx;
margin-top: 20rpx;
border-top: 1rpx solid $color-divider;
font-size: 24rpx;
color: $color-text-muted;
}
.block-head {
@include block-head;
}
.block-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.block-total {
font-size: 28rpx;
font-weight: 600;
color: #f56c6c;
}
.items-empty {
padding: 32rpx 0;
font-size: 26rpx;
color: $color-text-muted;
text-align: center;
}
.exp-item {
padding: 20rpx 0;
border-top: 1rpx solid $color-divider;
&:first-of-type {
border-top: none;
padding-top: 4rpx;
}
}
.exp-item-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.exp-item-type {
font-size: 28rpx;
color: $color-text;
}
.exp-item-amount {
font-size: 28rpx;
font-weight: 600;
color: $color-text;
flex-shrink: 0;
}
.exp-item-sub {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 8rpx;
font-size: 24rpx;
color: $color-text-muted;
.exp-item-desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.receipt-grid {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 14rpx;
}
.receipt-item {
width: 120rpx;
height: 120rpx;
border-radius: $radius-md;
background: $color-bg-page;
}
.receipt-file {
width: 200rpx;
height: 120rpx;
border-radius: $radius-md;
background: $color-bg-page;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4rpx;
padding: 0 12rpx;
box-sizing: border-box;
&:active {
opacity: 0.7;
}
}
.receipt-file-name {
max-width: 100%;
font-size: 22rpx;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.receipt-file-hint {
font-size: 20rpx;
color: $color-text-muted;
}
/* 审批记录沿用审批中心的审批流样式 */
@include flow-timeline;
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 16rpx;
padding: 20rpx $page-padding-x;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: $color-card;
border-top: 1rpx solid $color-border;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.04);
}
.footer-btn {
flex: 1;
}
.footer-approve {
flex-direction: column;
gap: 12rpx;
}
.footer-row {
display: flex;
gap: 16rpx;
}
.footer-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
font-size: 24rpx;
color: $color-text-secondary;
}
.state-box {
display: flex;
justify-content: center;
padding: 160rpx 0;
}
.state-text {
font-size: 28rpx;
color: $color-text-muted;
}
</style>