Files
klp-oa/klp-ui/src/views/hrm/requests/reimburseDetail.vue
砂糖 46d584ef99 refactor(hrm): 优化流程相关页面显示和功能
移除不必要的流程状态显示和撤回按钮
调整任务列表和抄送页面的列显示和操作逻辑
简化用户信息显示方式,统一使用createBy字段
2026-01-03 14:32:47 +08:00

599 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>
<div class="request-page">
<el-card class="form-card" shadow="never">
<div slot="header" class="card-header">
<span>报销详情</span>
<div class="actions" v-if="!embedded">
<el-button size="mini" icon="el-icon-arrow-left" @click="$router.back()">返回</el-button>
<el-button size="mini" icon="el-icon-refresh" @click="loadDetail">刷新</el-button>
<!-- 审批操作按钮 -->
<el-button
v-if="currentTask"
type="success"
size="mini"
:loading="actionLoading"
@click="handleApprove"
>
通过
</el-button>
<el-button
v-if="currentTask"
type="danger"
size="mini"
:loading="actionLoading"
@click="handleReject"
>
驳回
</el-button>
<!-- <el-button
v-if="canWithdraw"
size="mini"
:loading="actionLoading"
@click="handleWithdraw"
>
撤回
</el-button> -->
</div>
</div>
<div v-loading="loading" class="detail-loading">
<!-- 顶部摘要 -->
<div class="form-summary">
<div class="summary-left">
<div class="summary-title">报销申请</div>
<div class="summary-sub">
申请编号{{ detail.bizId || '-' }} ·
状态<el-tag size="mini" :type="statusType">{{ statusText }}</el-tag>
</div>
</div>
<div class="summary-right">
<div class="summary-item">
<div class="k">申请人</div>
<div class="v">{{ applicantText }}</div>
</div>
<div class="summary-item">
<div class="k">报销金额</div>
<div class="v cost-text">{{ detail.totalAmount ? '¥' + detail.totalAmount : '-' }}</div>
</div>
</div>
</div>
<!-- 报销金额信息 -->
<div class="block-title">报销金额信息</div>
<el-card class="inner-card" shadow="never">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="报销总金额">
<span class="cost-text-large">{{ detail.totalAmount ? '¥' + detail.totalAmount : '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="报销类型">{{ detail.reimburseType || '-' }}</el-descriptions-item>
<el-descriptions-item label="申请时间">{{ formatDate(detail.createTime) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(detail.updateTime) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 报销单据 -->
<div class="block-title">报销单据</div>
<el-card class="inner-card" shadow="never">
<div class="hint-text">请上传相关报销单据发票收据凭证等</div>
<div v-if="attachmentList.length > 0" class="attachment-list">
<div v-for="file in attachmentList" :key="file.ossId" class="attachment-item">
<div class="file-info">
<i class="el-icon-document file-icon"></i>
<div class="file-details">
<div class="file-name">{{ file.originalName || file.fileName || `文件${file.ossId}` }}</div>
<div class="file-meta">
<span v-if="file.fileSize">{{ formatFileSize(file.fileSize) }}</span>
<span v-if="file.createTime" class="file-time">{{ formatDate(file.createTime) }}</span>
</div>
</div>
</div>
<div class="file-actions">
<el-button size="mini" type="text" @click="previewFile(file)">预览</el-button>
<el-button size="mini" type="text" @click="downloadFile(file.ossId)">下载</el-button>
</div>
</div>
</div>
<div v-else class="empty">暂无单据附件</div>
</el-card>
<!-- 报销理由说明 -->
<div class="block-title">报销理由说明</div>
<el-card class="inner-card" shadow="never">
<div class="reason-section">
<div class="reason-label">报销事由</div>
<div class="reason-content">{{ detail.reason || '未填写' }}</div>
</div>
<div v-if="detail.remark" class="reason-section">
<div class="reason-label">备注说明</div>
<div class="reason-content">{{ detail.remark }}</div>
</div>
</el-card>
<!-- 审批人信息 -->
<div class="block-title">审批信息</div>
<el-card class="inner-card" shadow="never">
<div v-if="currentTask" class="approver-info">
<div class="approver-item">
<div class="approver-label">当前审批人</div>
<div class="approver-value">
<el-tag type="warning" size="small">{{ currentTask.assigneeUserName || currentTask.assigneeUserId || '待分配' }}</el-tag>
</div>
</div>
<div class="approver-item">
<div class="approver-label">审批节点</div>
<div class="approver-value">{{ currentTask.nodeName || currentTask.nodeId || '-' }}</div>
</div>
</div>
<div v-else class="empty">当前无待办任务可能已处理完成或已撤回</div>
</el-card>
<!-- 审批意见 -->
<div v-if="currentTask" class="block-title">审批意见</div>
<el-card v-if="currentTask" class="inner-card" shadow="never">
<el-input
v-model="approveForm.comment"
type="textarea"
:rows="3"
placeholder="请输入审批意见(可选)"
/>
</el-card>
<!-- 流转历史 -->
<div class="block-title">流转历史</div>
<el-card class="inner-card" shadow="never" v-loading="actionLoading">
<el-timeline v-if="flowHistory.length > 0">
<el-timeline-item
v-for="(item, index) in flowHistory"
:key="index"
:timestamp="formatDate(item.createTime)"
:type="getTimelineType(item.action)"
placement="top"
>
<el-card>
<h4>{{ getActionText(item.action) }}</h4>
<p>处理人: {{ item.operatorName || '系统' }}</p>
<p v-if="item.comment">意见: {{ item.comment }}</p>
</el-card>
</el-timeline-item>
</el-timeline>
<div v-else class="empty">暂无流转记录</div>
</el-card>
</div>
</el-card>
</div>
</template>
<script>
import { getTodoTaskByBiz, approveFlowTask, rejectFlowTask, withdrawFlowTask, listFlowAction } from '@/api/hrm/flow'
import { listByIds } from '@/api/system/oss'
import { listEmployee, getReimburseReq } from '@/api/hrm'
export default {
name: 'ReimburseDetail',
props: {
bizId: { type: [String, Number], default: null },
embedded: { type: Boolean, default: false }
},
data() {
return {
loading: false,
actionLoading: false,
detail: {
bizId: null,
empId: null,
totalAmount: null,
reimburseType: null,
reason: null,
remark: null,
accessoryApplyIds: null,
status: 'draft',
createTime: null,
updateTime: null
},
employees: [],
currentTask: null,
flowHistory: [],
approveForm: {
comment: ''
},
attachmentList: [],
attachmentLoading: false
}
},
computed: {
currentBizId() {
return this.bizId || this.$route?.params?.bizId || this.$route?.query?.bizId || this.$route?.params?.id
},
statusText() {
const statusMap = {
'draft': '草稿',
'pending': '审批中',
'approved': '已通过',
'rejected': '已驳回',
'withdrawn': '已撤回'
}
return statusMap[this.detail.status] || this.detail.status || '未知'
},
statusType() {
const typeMap = {
'draft': 'info',
'pending': 'warning',
'approved': 'success',
'rejected': 'danger',
'withdrawn': 'info'
}
return typeMap[this.detail.status] || 'info'
},
applicantText() {
const empId = this.detail.empId
const emp = this.employees.find(e => String(e.empId) === String(empId))
if (emp) {
const name = emp.empName || emp.nickName || emp.userName || ''
const no = emp.empNo ? ` · ${emp.empNo}` : ''
const dept = emp.deptName ? ` · ${emp.deptName}` : ''
return `${name || '员工'}${no}${dept}`.trim()
}
return empId ? `员工ID:${empId}` : '-'
},
canWithdraw() {
return this.detail.status === 'pending' && this.detail.createBy === this.$store.getters.userId
}
},
created() {
this.loadEmployees()
this.loadDetail()
},
methods: {
loadEmployees() {
listEmployee({ pageNum: 1, pageSize: 1000 }).then(res => {
this.employees = res.rows || res.data || []
})
},
async loadDetail() {
const bizId = this.currentBizId
if (!bizId) return
this.loading = true
try {
// 调用报销详情接口
const res = await getReimburseReq(bizId)
this.detail = res.data || {}
await Promise.all([
this.loadCurrentTask(),
this.loadFlowHistory(),
this.loadAttachments()
])
} catch (error) {
console.error('加载详情失败:', error)
this.$message.error('加载详情失败')
} finally {
this.loading = false
}
},
async loadCurrentTask() {
const bizId = this.currentBizId
if (!bizId) return null
try {
const res = await getTodoTaskByBiz('reimburse', bizId)
this.currentTask = res?.data || null
} catch (error) {
this.currentTask = null
}
},
async loadFlowHistory() {
const instId = this.detail?.instId
if (!instId) {
this.flowHistory = []
return
}
try {
const res = await listFlowAction({ instId, pageNum: 1, pageSize: 200 })
this.flowHistory = res.rows || res.data || []
} catch (error) {
this.flowHistory = []
}
},
async loadAttachments() {
const fileIds = this.detail.accessoryApplyIds || this.detail.applyFileIds
if (!fileIds) {
this.attachmentList = []
return
}
const ids = String(fileIds).split(',').map(id => id.trim()).filter(Boolean)
if (ids.length === 0) {
this.attachmentList = []
return
}
this.attachmentLoading = true
try {
const res = await listByIds(ids)
this.attachmentList = res.data || []
} catch (e) {
this.$message.error('加载附件失败:' + (e.message || '未知错误'))
this.attachmentList = []
} finally {
this.attachmentLoading = false
}
},
formatFileSize(bytes) {
if (!bytes) return '-'
const units = ['B', 'KB', 'MB', 'GB']
let size = Number(bytes)
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`
},
previewFile(file) {
if (file.url) {
window.open(file.url, '_blank')
} else {
this.$message.warning('文件URL不存在')
}
},
downloadFile(ossId) {
window.open(`/system/oss/download/${ossId}`, '_blank')
},
async handleApprove() {
await this.handleAction('approve', '通过')
},
async handleReject() {
await this.handleAction('reject', '驳回')
},
async handleWithdraw() {
await this.handleAction('withdraw', '撤回')
},
async handleAction(action, actionName) {
if (!this.currentTask?.taskId) {
this.$message.warning('未找到待办任务')
return
}
try {
await this.$confirm(`确定${actionName}该申请吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
this.actionLoading = true
const payload = { remark: this.approveForm.comment }
if (action === 'approve') {
await approveFlowTask(this.currentTask.taskId, payload)
} else if (action === 'reject') {
await rejectFlowTask(this.currentTask.taskId, payload)
} else if (action === 'withdraw') {
await withdrawFlowTask(this.currentTask.taskId, payload)
}
this.$message.success(`${actionName}成功`)
await this.loadDetail()
} catch (error) {
if (error !== 'cancel') {
console.error(`${actionName}失败:`, error)
this.$message.error(error.message || `${actionName}失败`)
}
} finally {
this.actionLoading = false
}
},
formatDate(val) {
if (!val) return '-'
const d = new Date(val)
const p = n => (n < 10 ? `0${n}` : n)
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
},
getActionText(action) {
const map = {
'submit': '提交申请',
'approve': '通过',
'reject': '驳回',
'withdraw': '撤回',
'cancel': '取消'
}
return map[action] || action
},
getTimelineType(action) {
const map = {
'submit': 'primary',
'approve': 'success',
'reject': 'danger',
'withdraw': 'info',
'cancel': 'info'
}
return map[action] || 'info'
}
}
}
</script>
<style lang="scss" scoped>
.request-page {
padding: 16px 20px 32px;
background: #f8f9fb;
}
.form-card {
max-width: 980px;
margin: 0 auto;
border: 1px solid #d7d9df;
border-radius: 12px;
background: #ffffff;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 700;
color: #2b2f36;
}
.actions {
display: flex;
gap: 8px;
}
.detail-loading {
min-height: 300px;
}
.form-summary {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin-bottom: 16px;
border: 1px solid #e6e8ed;
border-radius: 10px;
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
}
.summary-title {
font-size: 16px;
font-weight: 800;
color: #2b2f36;
}
.summary-sub {
margin-top: 4px;
font-size: 12px;
color: #8a8f99;
}
.summary-right {
display: flex;
gap: 16px;
}
.summary-item .k {
font-size: 12px;
color: #8a8f99;
}
.summary-item .v {
margin-top: 2px;
font-weight: 700;
color: #2b2f36;
}
.cost-text {
font-weight: 700;
color: #e6a23c;
font-size: 16px;
}
.cost-text-large {
font-weight: 800;
color: #e6a23c;
font-size: 20px;
}
.block-title {
margin: 16px 0 8px;
padding-left: 10px;
font-weight: 700;
color: #2f3440;
border-left: 3px solid #9aa3b2;
}
.hint-text {
margin: 6px 0 10px;
font-size: 12px;
color: #8a8f99;
}
.inner-card {
border: 1px solid #e6e8ed;
margin-bottom: 12px;
}
.reason-section {
margin-bottom: 16px;
}
.reason-section:last-child {
margin-bottom: 0;
}
.reason-label {
font-size: 13px;
font-weight: 600;
color: #606266;
margin-bottom: 8px;
}
.reason-content {
padding: 12px;
background: #f8f9fa;
border-radius: 6px;
color: #2b2f36;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.approver-info {
display: flex;
gap: 24px;
padding: 12px 0;
}
.approver-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.approver-label {
font-size: 12px;
color: #8a8f99;
}
.approver-value {
font-weight: 600;
color: #2b2f36;
}
.attachment-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.attachment-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border: 1px solid #e6e8ed;
border-radius: 8px;
background: #fafbfc;
}
.file-info {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.file-icon {
font-size: 24px;
color: #9aa3b2;
}
.file-details {
flex: 1;
}
.file-name {
font-weight: 600;
color: #2b2f36;
margin-bottom: 4px;
}
.file-meta {
font-size: 12px;
color: #8a8f99;
display: flex;
gap: 12px;
}
.file-time {
margin-left: 8px;
}
.file-actions {
display: flex;
gap: 8px;
}
.empty {
color: #a0a3ad;
font-size: 13px;
padding: 10px 4px;
text-align: center;
}
@media (max-width: 1200px) {
.summary-right {
display: none;
}
}
</style>