Files
im-uniapp/pages/workbench/hrm/leave/leave.vue
砂糖 955d20413b feat(HRM): 新增HRM办公审批模块及相关功能组件
新增HRM办公审批模块,包括审批中心、抄送我的、我的申请等功能页面和组件。主要变更包括:

1. 添加审批相关API接口文件
2. 新增审批详情展示组件
3. 实现审批流程操作功能
4. 添加Vuex状态管理
5. 新增相关静态资源图片
6. 配置页面路由
7. 实现审批列表展示和筛选功能
8. 添加审批操作弹窗和状态管理
2026-02-05 10:42:50 +08:00

946 lines
24 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="request-detail">
<!-- 页面标题栏 -->
<view class="page-header" v-if="!embedded">
<uni-nav-bar
left-icon="back"
title="请假详情"
@clickLeft="navigateBack"
right-text="刷新"
@clickRight="loadDetail"
></uni-nav-bar>
</view>
<!-- 主内容区 -->
<scroll-view class="content-scroll" scroll-y>
<!-- 加载中状态 -->
<view v-if="loading" class="loading-container">
<uni-load-more type="loading" color="#409EFF"></uni-load-more>
</view>
<view v-else class="content-wrapper">
<!-- 顶部摘要 -->
<view class="form-summary">
<view class="summary-left">
<view class="summary-title">{{ detail.leaveType || '请假申请' }}</view>
<view class="summary-sub">
申请编号{{ detail.bizId || '-' }} ·
状态<view class="status-tag" :class="statusType">{{ statusText }}</view>
</view>
</view>
<view class="summary-right">
<view class="summary-item">
<view class="k">申请人</view>
<view class="v">{{ detail.createBy || '-' }}<text v-if="detail.empNo" class="text-muted">({{ detail.empNo }})</text></view>
</view>
<view class="summary-item">
<view class="k">请假时长</view>
<view class="v">{{ detail.hours || '0' }} 小时</view>
</view>
</view>
</view>
<!-- 请假日期信息 -->
<view class="block-title">请假日期</view>
<view class="inner-card">
<view class="descriptions-list">
<view class="descriptions-item">
<view class="label">开始时间</view>
<view class="value date-time">{{ formatDate(detail.startTime) }}</view>
</view>
<view class="descriptions-item">
<view class="label">结束时间</view>
<view class="value date-time">{{ formatDate(detail.endTime) }}</view>
</view>
<view class="descriptions-item">
<view class="label">请假类型</view>
<view class="value">{{ detail.leaveType || '-' }}</view>
</view>
<view class="descriptions-item">
<view class="label">时长(小时)</view>
<view class="value">{{ detail.hours || '0' }} 小时</view>
</view>
<view class="descriptions-item">
<view class="label">创建时间</view>
<view class="value">{{ formatDate(detail.createTime) }}</view>
</view>
<view class="descriptions-item">
<view class="label">更新时间</view>
<view class="value">{{ formatDate(detail.updateTime) }}</view>
</view>
</view>
</view>
<!-- 请假理由说明 -->
<view class="block-title">请假理由说明</view>
<view class="inner-card">
<view class="reason-section">
<view class="reason-label">事由</view>
<view class="reason-content">{{ detail.reason || '未填写' }}</view>
</view>
<view v-if="detail.handover" class="reason-section">
<view class="reason-label">工作交接</view>
<view class="reason-content">{{ detail.handover }}</view>
</view>
<view v-if="detail.remark" class="reason-section">
<view class="reason-label">备注</view>
<view class="reason-content">{{ detail.remark }}</view>
</view>
</view>
<!-- 申请附件 -->
<view class="block-title">申请附件</view>
<view class="inner-card" v-if="attachmentLoading">
<view class="loading-small">
<uni-load-more type="loading" color="#409EFF" size="small"></uni-load-more>
</view>
</view>
<view class="inner-card" v-else>
<view v-if="attachmentList.length > 0" class="attachment-list">
<view v-for="file in attachmentList" :key="file.ossId" class="attachment-item">
<view class="file-info">
<uni-icons type="paper" size="24" color="#9aa3b2"></uni-icons>
<view class="file-details">
<view class="file-name">{{ file.originalName || file.fileName || `文件${file.ossId}` }}</view>
<view class="file-meta">
<text v-if="file.fileSize">{{ formatFileSize(file.fileSize) }}</text>
<text v-if="file.createTime" class="file-time">{{ formatDate(file.createTime) }}</text>
</view>
</view>
</view>
<view class="file-actions">
<button class="file-btn" size="mini" @click="previewFile(file)">预览</button>
<button class="file-btn" size="mini" @click="downloadFile(file.ossId)">下载</button>
</view>
</view>
</view>
<view v-else class="empty">暂无附件</view>
</view>
<!-- 审批意见 -->
<view v-if="currentTask" class="approve-section">
<view class="section-title">审批意见</view>
<textarea
v-model="approveForm.comment"
placeholder="请输入审批意见(可选)"
class="approve-textarea"
></textarea>
</view>
<!-- 审批操作按钮 -->
<view v-if="!embedded && (canApprove || canWithdraw)" class="action-buttons">
<button
v-if="canApprove"
class="btn approve-btn"
:loading="actionLoading"
@click="handleApprove"
>
通过
</button>
<button
v-if="canApprove"
class="btn reject-btn"
:loading="actionLoading"
@click="handleReject"
>
驳回
</button>
<button
v-if="canWithdraw"
class="btn withdraw-btn"
:loading="actionLoading"
@click="handleWithdraw"
>
撤回
</button>
</view>
<!-- 流转历史 -->
<view class="flow-history">
<view class="section-title">流转历史</view>
<view v-if="flowHistory.length > 0" class="timeline">
<view
v-for="(item, index) in flowHistory"
:key="index"
class="timeline-item"
>
<!-- 时间轴点 -->
<view class="timeline-dot" :class="getTimelineType(item.action)"></view>
<!-- 时间轴内容 -->
<view class="timeline-content">
<view class="timeline-time">{{ formatDate(item.createTime) }}</view>
<view class="timeline-card">
<view class="action-text">{{ getActionText(item.action) }}</view>
<view class="operator">处理人: {{ item.createBy || '系统' }}</view>
<view v-if="item.comment" class="comment">意见: {{ item.comment }}</view>
</view>
</view>
</view>
</view>
<view v-else class="no-data">暂无流转记录</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
// 导入API需适配Uniapp的request请求此处保留原API结构实际需替换为uni.request
import { getLeaveReq } from '@/api/hrm/leave'
import { getTodoTaskByBiz, approveFlowTask, rejectFlowTask, withdrawFlowTask, listFlowAction, queryInstanceByBiz, getFlowInstance, listFlowNode } from '@/api/hrm/flow'
import { listByIds } from '@/api/oa/oss'
export default {
name: 'LeaveDetail',
props: {
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, // 当前节点信息
bizId: null // 从onLoad获取的业务ID
}
},
computed: {
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
},
canApprove() {
// 只有待审批状态且是当前用户待审批的才能审批
return this.detail.status === 'pending' && (this.currentTask?.assigneeUserName === this.$store.getters.name || this.currentTask?.assigneeUserId === this.$store.getters.id)
}
},
onLoad(options) {
// Uniapp通过onLoad获取页面参数
this.bizId = options.bizId || options.id
this.loadDetail()
},
methods: {
// 返回上一页适配Uniapp路由
navigateBack() {
uni.navigateBack({
delta: 1
})
},
async loadDetail() {
const bizId = this.bizId
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)
uni.showToast({
title: '加载详情失败',
icon: 'error'
})
} finally {
this.loading = false
}
},
async loadCurrentTask() {
const bizId = this.bizId
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.bizId)
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) {
uni.showToast({
title: '未找到待办任务',
icon: 'none'
})
return
}
// Uniapp确认弹窗
uni.showModal({
title: '提示',
content: `确定${actionName}该申请吗?`,
success: async (res) => {
if (res.confirm) {
this.actionLoading = true
try {
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)
}
uni.showToast({
title: successMsg,
icon: 'success'
})
await this.loadDetail()
// 清空审批意见
this.approveForm.comment = ''
} catch (error) {
console.error(`${actionName}失败:`, error)
uni.showToast({
title: error.message || `${actionName}失败`,
icon: 'error'
})
} 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) {
uni.showToast({
title: '加载附件失败:' + (e.message || '未知错误'),
icon: 'error'
})
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) {
// 图片类文件直接预览,其他文件提示下载
const fileExt = (file.originalName || '').split('.').pop().toLowerCase()
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp']
if (imageExts.includes(fileExt)) {
uni.previewImage({
urls: [file.url],
current: file.url
})
} else {
uni.showModal({
title: '提示',
content: '该文件不支持预览,是否下载?',
success: (res) => {
if (res.confirm) {
this.downloadFile(file.ossId)
}
}
})
}
} else {
uni.showToast({
title: '文件URL不存在',
icon: 'none'
})
}
},
// 移动端附件下载
downloadFile(ossId) {
uni.showLoading({
title: '下载中...'
})
// Uniapp下载文件API
uni.downloadFile({
url: `/system/oss/download/${ossId}`,
success: (res) => {
uni.hideLoading()
if (res.statusCode === 200) {
// 打开下载的文件
uni.openDocument({
filePath: res.tempFilePath,
showMenu: true,
fail: (err) => {
uni.showToast({
title: '打开文件失败',
icon: 'error'
})
}
})
} else {
uni.showToast({
title: '下载失败',
icon: 'error'
})
}
},
fail: (err) => {
uni.hideLoading()
uni.showToast({
title: '下载失败:' + err.message,
icon: 'error'
})
}
})
}
}
}
</script>
<style lang="scss" scoped>
.request-detail {
min-height: 100vh;
background-color: #f5f5f5;
.page-header {
background-color: #fff;
}
.content-scroll {
height: calc(100vh - 44px);
padding: 16rpx;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 400rpx;
}
.content-wrapper {
gap: 16rpx;
display: flex;
flex-direction: column;
}
.form-summary {
display: flex;
flex-direction: column;
gap: 12rpx;
padding: 16rpx;
border: 1px solid #e6e8ed;
border-radius: 10rpx;
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
}
.summary-title {
font-size: 32rpx;
font-weight: 800;
color: #2b2f36;
}
.summary-sub {
font-size: 24rpx;
color: #8a8f99;
}
.status-tag {
padding: 4rpx 8rpx;
border-radius: 4rpx;
font-size: 22rpx;
color: #fff;
&.success {
background-color: #67c23a;
}
&.danger {
background-color: #f56c6c;
}
&.warning {
background-color: #e6a23c;
}
&.info {
background-color: #909399;
}
}
.summary-right {
display: flex;
gap: 24rpx;
margin-top: 12rpx;
}
.summary-item .k {
font-size: 24rpx;
color: #8a8f99;
}
.summary-item .v {
margin-top: 4rpx;
font-weight: 700;
color: #2b2f36;
font-size: 26rpx;
}
.text-muted {
color: #909399;
margin-left: 8rpx;
font-size: 22rpx;
}
.block-title {
margin: 16rpx 0 8rpx;
padding-left: 10rpx;
font-weight: 700;
color: #2f3440;
border-left: 6rpx solid #9aa3b2;
font-size: 28rpx;
}
.inner-card {
border: 1px solid #e6e8ed;
border-radius: 8rpx;
background-color: #fff;
padding: 16rpx;
}
.descriptions-list {
display: flex;
flex-wrap: wrap;
}
.descriptions-item {
width: 50%;
margin-bottom: 16rpx;
display: flex;
flex-direction: column;
&:last-child {
margin-bottom: 0;
}
}
.descriptions-item .label {
font-size: 24rpx;
color: #8a8f99;
margin-bottom: 4rpx;
}
.descriptions-item .value {
font-size: 26rpx;
color: #2b2f36;
}
.date-time {
font-weight: 600;
color: #2b2f36;
}
.reason-section {
margin-bottom: 16rpx;
}
.reason-section:last-child {
margin-bottom: 0;
}
.reason-label {
font-size: 26rpx;
font-weight: 600;
color: #606266;
margin-bottom: 8rpx;
}
.reason-content {
padding: 16rpx;
background: #f8f9fa;
border-radius: 6rpx;
color: #2b2f36;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
font-size: 26rpx;
}
.loading-small {
padding: 20rpx 0;
text-align: center;
}
.attachment-list {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.attachment-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10rpx 12rpx;
border: 1px solid #e6e8ed;
border-radius: 8rpx;
background: #fafbfc;
}
.file-info {
display: flex;
align-items: center;
gap: 10rpx;
flex: 1;
}
.file-details {
flex: 1;
}
.file-name {
font-weight: 600;
color: #2b2f36;
margin-bottom: 4rpx;
font-size: 26rpx;
}
.file-meta {
font-size: 22rpx;
color: #8a8f99;
display: flex;
gap: 12rpx;
}
.file-time {
margin-left: 8rpx;
}
.file-actions {
display: flex;
gap: 8rpx;
}
.file-btn {
font-size: 22rpx;
padding: 4rpx 8rpx;
background-color: #f5f7fa;
color: #409eff;
border: none;
border-radius: 4rpx;
}
.empty {
color: #a0a3ad;
font-size: 24rpx;
padding: 20rpx 0;
text-align: center;
}
.approve-section {
margin-top: 20rpx;
background: #f8f9fa;
padding: 15rpx;
border-radius: 8rpx;
}
.section-title {
font-size: 28rpx;
font-weight: bold;
margin-bottom: 10rpx;
color: #303133;
}
.approve-textarea {
width: 100%;
padding: 12rpx;
border: 1px solid #e6e8ed;
border-radius: 8rpx;
background-color: #fff;
min-height: 120rpx;
font-size: 26rpx;
}
.action-buttons {
display: flex;
gap: 16rpx;
margin-top: 20rpx;
}
.btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
border-radius: 8rpx;
font-size: 28rpx;
border: none;
&.approve-btn {
background-color: #67c23a;
color: #fff;
}
&.reject-btn {
background-color: #f56c6c;
color: #fff;
}
&.withdraw-btn {
background-color: #409eff;
color: #fff;
}
}
.flow-history {
margin-top: 30rpx;
}
.no-data {
text-align: center;
color: #909399;
padding: 40rpx 0;
font-size: 24rpx;
}
.timeline {
position: relative;
padding-left: 30rpx;
margin-left: 10rpx;
&::before {
content: '';
position: absolute;
left: 10rpx;
top: 10rpx;
bottom: 10rpx;
width: 2rpx;
background-color: #e6e8ed;
}
}
.timeline-item {
position: relative;
margin-bottom: 30rpx;
&:last-child {
margin-bottom: 0;
}
}
.timeline-dot {
position: absolute;
left: -36rpx;
top: 0;
width: 20rpx;
height: 20rpx;
border-radius: 50%;
&.primary {
background-color: #409eff;
}
&.success {
background-color: #67c23a;
}
&.danger {
background-color: #f56c6c;
}
&.info {
background-color: #909399;
}
}
.timeline-content {
background-color: #fff;
border-radius: 8rpx;
padding: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.timeline-time {
font-size: 22rpx;
color: #909399;
margin-bottom: 8rpx;
}
.action-text {
font-size: 26rpx;
font-weight: 600;
color: #303133;
margin-bottom: 8rpx;
}
.operator {
font-size: 24rpx;
color: #606266;
margin-bottom: 4rpx;
}
.comment {
font-size: 24rpx;
color: #606266;
padding-top: 8rpx;
border-top: 1px solid #f0f0f0;
}
}
</style>