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

684 lines
19 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-detail">
<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">{{ detail.leaveType || '请假申请' }}</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">{{ detail.createBy || '-' }}<span v-if="detail.empNo" class="text-muted">({{ detail.empNo }})</span></div>
</div>
<div class="summary-item">
<div class="k">请假时长</div>
<div class="v">{{ detail.hours || '0' }} 小时</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="date-time">{{ formatDate(detail.startTime) }}</span>
</el-descriptions-item>
<el-descriptions-item label="结束时间">
<span class="date-time">{{ formatDate(detail.endTime) }}</span>
</el-descriptions-item>
<el-descriptions-item label="请假类型">{{ detail.leaveType || '-' }}</el-descriptions-item>
<el-descriptions-item label="时长(小时)">{{ detail.hours || '0' }} 小时</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 v-if="flowInstance" class="flow-status">
<div class="status-item">
<div class="status-label">流程状态</div>
<div class="status-value">
<el-tag :type="statusType" size="small">{{ statusText }}</el-tag>
</div>
</div>
<div v-if="currentNode" class="status-item">
<div class="status-label">当前节点</div>
<div class="status-value">{{ currentNode.nodeName || currentNode.nodeId || '未知节点' }}</div>
</div>
<div v-if="currentTask" class="status-item">
<div class="status-label">当前审批人</div>
<div class="status-value">
<el-tag type="warning" size="small">{{ currentTask.assigneeUserName || currentTask.assigneeUserId || '待分配' }}</el-tag>
</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.handover" class="reason-section">
<div class="reason-label">工作交接</div>
<div class="reason-content">{{ detail.handover }}</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" v-loading="attachmentLoading">
<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 v-if="currentTask" class="approve-section">
<div class="section-title">审批意见</div>
<el-input
v-model="approveForm.comment"
type="textarea"
:rows="3"
placeholder="请输入审批意见(可选)"
/>
</div>
<!-- 流转历史 -->
<div class="flow-history">
<div class="section-title">流转历史</div>
<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.createBy || '系统' }}</p>
<p v-if="item.comment">意见: {{ item.comment }}</p>
</el-card>
</el-timeline-item>
</el-timeline>
<div v-else class="no-data">暂无流转记录</div>
</div>
</div>
</el-card>
</div>
</template>
<script>
import { getLeaveReq } from '@/api/hrm/leave'
import { getTodoTaskByBiz, approveFlowTask, rejectFlowTask, withdrawFlowTask, listFlowAction, queryInstanceByBiz, getFlowInstance, listFlowNode } from '@/api/hrm/flow'
import { listByIds } from '@/api/system/oss'
export default {
name: 'LeaveDetail',
props: {
bizId: { type: [String, Number], default: null },
embedded: { type: Boolean, default: false }
},
data() {
return {
loading: false,
actionLoading: false,
detail: {},
currentTask: null,
flowHistory: [],
approveForm: {
comment: ''
},
attachmentList: [],
attachmentLoading: false,
flowInstance: null, // 流程实例信息
flowNodes: [], // 流程节点列表
currentNode: null // 当前节点信息
}
},
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'
},
canWithdraw() {
// 只有待审批状态且是当前用户提交的才能撤回
return this.detail.status === 'pending' && this.detail.createBy === this.$store.getters.name
}
},
created() {
this.loadDetail()
},
methods: {
async loadDetail() {
const bizId = this.currentBizId
if (!bizId) return
this.loading = true
try {
// 加载请假单详情
const detailRes = await getLeaveReq(bizId)
this.detail = detailRes.data || {}
// 加载流程实例信息
await this.loadFlowInstance()
// 加载当前待办任务
await this.loadCurrentTask()
// 加载流转历史和附件
await Promise.all([
this.loadFlowHistory(),
this.loadAttachments()
])
} catch (error) {
console.error('加载详情失败:', error)
this.$message.error('加载详情失败')
} finally {
this.loading = false
}
},
async loadCurrentTask() {
const bizId = this.currentBizId
if (!bizId) {
this.currentTask = null
return
}
try {
const res = await getTodoTaskByBiz('leave', bizId)
this.currentTask = res?.data || null
} catch (error) {
console.error('加载待办任务失败:', error)
this.currentTask = null
}
},
async loadFlowInstance() {
if (!this.detail.instId) {
// 如果没有instId尝试通过bizType和bizId查询
try {
const res = await queryInstanceByBiz('leave', this.currentBizId)
const instances = res.data || []
if (instances.length > 0) {
this.flowInstance = instances[0]
this.detail.instId = instances[0].instId
// 加载流程节点信息
await this.loadFlowNodes()
// 根据当前节点ID查找节点信息
if (this.flowInstance.currentNodeId) {
this.currentNode = this.flowNodes.find(n => n.nodeId === this.flowInstance.currentNodeId) || null
}
}
} catch (e) {
console.error('加载流程实例失败:', e)
}
} else {
// 如果有instId直接加载
try {
const res = await getFlowInstance(this.detail.instId)
this.flowInstance = res.data || null
// 加载流程节点信息
await this.loadFlowNodes()
// 根据当前节点ID查找节点信息
if (this.flowInstance && this.flowInstance.currentNodeId) {
this.currentNode = this.flowNodes.find(n => n.nodeId === this.flowInstance.currentNodeId) || null
}
} catch (e) {
console.error('加载流程实例失败:', e)
}
}
},
async loadFlowNodes() {
if (!this.flowInstance || !this.flowInstance.tplId) {
this.flowNodes = []
return
}
try {
const res = await listFlowNode({ tplId: this.flowInstance.tplId, pageNum: 1, pageSize: 500 })
this.flowNodes = res.rows || res.data || []
} catch (e) {
console.error('加载流程节点失败:', e)
this.flowNodes = []
}
},
async loadFlowHistory() {
// 基于 instId 拉取流转历史(优先用 instId无则不查
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 handleApprove() {
await this.handleAction('approve', '通过成功', '通过')
},
async handleReject() {
await this.handleAction('reject', '已驳回', '驳回')
},
async handleWithdraw() {
await this.handleAction('withdraw', '已撤回', '撤回')
},
async handleAction(action, successMsg, 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'
},
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')
}
}
}
</script>
<style lang="scss" scoped>
.request-detail {
padding: 20px;
.form-card {
max-width: 1000px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
.actions {
> * {
margin-left: 10px;
}
}
}
.detail-loading {
min-height: 300px;
}
.section-title {
font-size: 16px;
font-weight: bold;
margin: 20px 0 10px;
color: #303133;
padding-left: 10px;
border-left: 4px solid #409EFF;
}
.approve-section {
margin-top: 20px;
background: #f8f9fa;
padding: 15px;
border-radius: 4px;
}
.flow-history {
margin-top: 30px;
.no-data {
text-align: center;
color: #909399;
padding: 20px 0;
}
}
.text-muted {
color: #909399;
margin-left: 5px;
font-size: 12px;
}
.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;
}
.block-title {
margin: 16px 0 8px;
padding-left: 10px;
font-weight: 700;
color: #2f3440;
border-left: 3px solid #9aa3b2;
}
.inner-card {
border: 1px solid #e6e8ed;
margin-bottom: 12px;
}
.date-time {
font-weight: 600;
color: #2b2f36;
}
.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;
}
.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;
}
.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>