Files
fad_oa/ruoyi-ui/src/views/hrm/components/BizDetailContainer/index.vue
2026-04-13 17:04:38 +08:00

861 lines
21 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" v-loading="loading">
<el-card class="form-card" shadow="never">
<div slot="header" class="card-header">
<span>{{ bizTitle }}</span>
<div class="actions">
<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="canApprove" type="success" size="mini" :loading="actionLoading" @click="handleApprove">
通过
</el-button>
<el-button v-if="canApprove" type="danger" size="mini" :loading="actionLoading" @click="handleReject">
驳回
</el-button>
</div>
</div>
<div class="form-summary">
<div class="summary-left">
<div class="summary-title">{{ bizTitle }}</div>
<div class="summary-sub">申请编号{{ flowInstance.instId || '-' }} · 状态<el-tag size="mini"
:type="statusType(flowInstance.status)">{{ statusText(flowInstance.status) }}</el-tag></div>
</div>
<div class="summary-right">
<div class="summary-item">
<div class="k">申请人</div>
<div class="v">{{ applicantText }}</div>
</div>
</div>
</div>
<slot :detail="detail"></slot>
<div class="block-title">审批信息</div>
<el-card class="inner-card" shadow="never">
<!-- 审批记录 -->
<el-table :data="assignTasks">
<el-table-column label="审批人" prop="assigneeNickName" />
<el-table-column label="审批状态" prop="status">
<template slot-scope="scope">
<el-tag :type="statusType(scope.row.status)" size="small">
{{ statusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<!-- <el-table-column label="审批时间" prop="createTime" /> -->
</el-table>
</el-card>
<div class="block-title" v-if="detail.projectId">项目信息</div>
<el-card v-if="detail.projectId" class="inner-card" shadow="never">
<ProjectInfo :info="detail" />
</el-card>
<div class="block-title">审批操作</div>
<el-card class="inner-card" shadow="never">
<div class="hint-text">系统将自动识别你在该单据上的当前待办任务若你不是当前办理人将不会显示办理按钮</div>
<div v-if="currentTask" class="btn-row">
<el-input v-model="actionRemark" type="textarea" :rows="3" placeholder="填写审批意见(可选)" />
<div class="btn-row mt10">
<el-button type="success" v-if="canApprove" :loading="actionSubmitting"
@click="submitTaskAction('approve')">通过</el-button>
<el-button type="danger" v-if="canApprove" :loading="actionSubmitting"
@click="submitTaskAction('reject')">驳回</el-button>
</div>
</div>
<div v-else class="empty">当前无待办任务可能已处理完成或你不是当前审批人</div>
</el-card>
</el-card>
<el-card class="report-card" shadow="never">
<div slot="header" class="card-header">
<span>操作汇报</span>
</div>
<div class="comment-form">
<editor v-model="commentForm.commentContent" placeholder="填写操作汇报(可选)" />
<file-upload v-model="commentForm.attachments" />
<div class="form-actions">
<el-button type="primary" @click="exportComment" :loading="buttonLoading">保存</el-button>
</div>
</div>
<div class="comment-list">
<div v-for="item in commentList" :key="item.id" class="comment-item">
<div class="comment-header">
<div class="comment-meta">
<span class="comment-operator">{{ item.createByName }}</span>
<span class="comment-time">{{ item.createTime }}</span>
<el-button v-if="isSelf(item)" type="danger" size="mini" @click="handleDeleteComment(item.commentId)"
:loading="buttonLoading">删除</el-button>
</div>
</div>
<div class="comment-body">
<div class="comment-content" v-if="item.commentContent">
<div class="content-value" v-html="item.commentContent"></div>
</div>
<div class="comment-attachments" v-if="item.attachments">
<div class="content-value attachment-text">
<file-preview v-model="item.attachments"></file-preview>
</div>
</div>
</div>
</div>
<div v-if="commentList.length === 0" class="empty-comment">暂无操作汇报</div>
</div>
</el-card>
</div>
</template>
<script>
import {
addFlowComment,
approveFlowTask,
delFlowComment,
getAppropriationReq,
getLeaveReq,
getReimburseReq,
getSealReq,
getTravelReq,
listAssignTask,
listEmployee,
listFlowComment,
rejectFlowTask
} from '@/api/hrm';
import { getFlowInstance, getTodoTaskByBiz, listFlowNode, queryInstanceByBiz } from '@/api/hrm/flow';
import FilePreview from '@/components/FilePreview/index.vue';
export default {
name: 'BizDetailContainer',
props: {
bizId: { type: String, required: true },
bizType: { type: String, required: true }
},
components: {
FilePreview,
},
data () {
return {
detail: {},
actionLoading: false,
employees: [],
loading: false,
currentTask: null,
actionRemark: '',
actionSubmitting: false,
assignTasks: [], // 该流程需要经过的审批
flowInstance: {}, // 流程实例信息
flowNodes: [], // 流程节点列表
currentNode: null, // 当前节点信息
commentForm: {
commentContent: '',
attachments: '',
},
commentList: [],
buttonLoading: false,
}
},
computed: {
currentBizId () {
return this.bizId
},
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}` : '-'
},
canApprove () {
// 只有待审批状态且是当前用户待审批的才能审批
return this.currentTask?.status === 'pending' && (this.currentTask?.assigneeUserName === this.$store.getters.name || this.currentTask?.assigneeUserId === this.$store.getters.id)
},
bizTitle () {
if (this.bizType === 'travel') {
return this.detail.travelType || '出差申请'
}
if (this.bizType === 'seal') {
return '用印申请'
}
if (this.bizType === 'reimburse') {
return '报销申请'
}
if (this.bizType === 'leave') {
return '请假申请'
}
if (this.bizType === 'appropriation') {
return '拨款申请'
}
return '未知申请类型'
},
},
watch: {
currentBizId: {
handler (newVal, oldVal) {
if (newVal !== oldVal) {
this.loadDetail()
}
},
immediate: true
},
},
created () {
this.loadEmployees()
this.loadDetail()
},
methods: {
loadEmployees () {
listEmployee({ pageNum: 1, pageSize: 1000 }).then(res => {
this.employees = res.rows || res.data || []
})
},
isSelf (comment) {
return comment.createBy === this.$store.getters.name
},
statusText (status) {
const map = { pending: '审批中', running: '审批中', draft: '草稿', approved: '已通过', rejected: '已驳回', revoked: '已撤销' }
return map[status] || status || '-'
},
statusType (status) {
const map = { pending: 'warning', running: 'warning', draft: 'info', approved: 'success', rejected: 'danger', revoked: 'danger' }
return map[status] || 'info'
},
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())}`
},
actionText (action) {
const map = { submit: '提交', approve: '通过', reject: '驳回', withdraw: '撤回', cancel: '撤销' }
return map[action] || action || '-'
},
actionType (action) {
const map = { submit: 'primary', approve: 'success', reject: 'danger', withdraw: 'info', cancel: 'info' }
return map[action] || 'info'
},
async loadDetail () {
const bizId = this.currentBizId
if (!bizId) {
this.$message.warning('缺少bizId')
return
}
this.loading = true
try {
let api = null;
if (this.bizType === 'travel') {
api = getTravelReq
} else if (this.bizType === 'seal') {
api = getSealReq
} else if (this.bizType === 'reimburse') {
api = getReimburseReq
} else if (this.bizType === 'leave') {
api = getLeaveReq
} else if (this.bizType === 'appropriation') {
api = getAppropriationReq
}
console.log(api, this.bizType)
if (!api) {
this.$message.error('不支持的业务类型')
return
}
const res = await api(bizId)
this.detail = res.data || {}
// 加载流程实例信息
await this.loadFlowInstance()
// 加载审批任务
await this.loadAssignTask()
// 加载当前任务
await this.loadCurrentTask()
// 加载操作汇报
await this.loadComment()
} finally {
this.loading = false
}
},
async loadCurrentTask () {
const bizId = this.currentBizId
if (!bizId) return null
try {
const res = await getTodoTaskByBiz(this.bizType, bizId)
this.currentTask = res?.data || null
} catch (error) {
this.currentTask = null
}
},
async loadComment () {
try {
const res = await listFlowComment({ instId: this.flowInstance.instId })
this.commentList = res.rows
} catch (e) {
console.error('加载操作汇报失败:', e)
this.commentList = []
}
},
async loadFlowInstance () {
if (!this.detail.instId) {
// 如果没有instId尝试通过bizType和bizId查询
try {
const res = await queryInstanceByBiz(this.bizType, 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 loadAssignTask () {
try {
const res = await listAssignTask(this.detail.instId)
this.assignTasks = res?.data || []
} catch (e) {
this.assignTasks = []
}
},
async handleApprove () {
await this.handleAction('approve', '通过')
},
async handleReject () {
await this.handleAction('reject', '驳回')
},
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.actionRemark }
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
}
},
submitTaskAction (type) {
if (!this.currentTask || !this.currentTask.taskId) return
this.actionSubmitting = true
const payload = { remark: this.actionRemark }
const apiMap = { approve: approveFlowTask, reject: rejectFlowTask }
apiMap[type](this.currentTask.taskId, payload)
.then(() => {
this.$message.success('操作成功')
this.loadDetail()
})
.finally(() => {
this.actionSubmitting = false
})
},
async exportComment () {
try {
this.buttonLoading = true
const res = await addFlowComment({
instId: this.flowInstance.instId,
commentContent: this.commentForm.commentContent,
attachments: this.commentForm.attachments
})
this.buttonLoading = false
this.commentForm.commentContent = ''
this.commentForm.attachments = ''
this.$message.success('操作汇报成功')
this.loadComment()
} catch (e) {
console.error('操作汇报失败:', e)
this.$message.error('操作汇报失败')
}
},
async handleDeleteComment (commentId) {
try {
await this.$confirm(`确定删除操作汇报吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
this.buttonLoading = true
await delFlowComment(commentId)
this.buttonLoading = false
this.$message.success('删除成功')
this.loadComment()
} catch (e) {
console.error('删除失败:', e)
this.$message.error('删除失败')
}
},
}
}
</script>
<style lang="scss" scoped>
.request-page {
padding: 16px 20px 32px;
background: #f8f9fb;
display: flex;
justify-content: center;
gap: 20px;
}
.form-card {
max-width: 980px;
// margin: 0 auto;
border: 1px solid #d7d9df;
border-radius: 12px;
background: #ffffff;
}
.report-card {
width: 600px;
// position: fixed;
// top: 104px;
// right: 0;
// 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;
}
.block-title {
margin: 12px 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;
}
.form-summary {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 12px;
margin-bottom: 12px;
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;
}
.inner-card {
border: 1px solid #e6e8ed;
}
.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;
}
.empty {
color: #a0a3ad;
font-size: 13px;
padding: 10px 4px;
}
.timeline-row .t-main {
font-weight: 600;
color: #2b2f36;
}
.timeline-row .t-remark {
margin-top: 4px;
color: #606266;
font-size: 13px;
}
.btn-row {
display: flex;
gap: 10px;
}
.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;
}
.date-time {
font-weight: 600;
color: #2b2f36;
}
.destination-text {
font-weight: 600;
color: #2b2f36;
font-size: 14px;
}
.cost-text {
font-weight: 700;
color: #e6a23c;
font-size: 16px;
}
.reason-content {
padding: 8px 12px;
background: #f8f9fa;
border-radius: 6px;
color: #2b2f36;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.info-section {
padding: 12px;
border: 1px solid #e6e8ed;
border-radius: 8px;
background: #fafbfc;
}
.info-label {
font-size: 13px;
font-weight: 600;
color: #606266;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.info-label i {
color: #9aa3b2;
}
.info-content {
min-height: 40px;
}
.info-text {
color: #2b2f36;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.info-placeholder {
color: #c0c4cc;
font-size: 12px;
font-style: italic;
}
.remark-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e6e8ed;
}
.remark-label {
font-size: 13px;
font-weight: 600;
color: #606266;
margin-bottom: 8px;
}
.remark-content {
padding: 12px;
background: #f8f9fa;
border-radius: 6px;
color: #2b2f36;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.file-time {
margin-left: 8px;
}
.file-actions {
display: flex;
gap: 8px;
}
.flow-status {
display: flex;
flex-direction: column;
gap: 12px;
}
.status-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: 1px solid #e6e8ed;
border-radius: 6px;
background: #fafbfc;
}
.status-label {
font-size: 13px;
font-weight: 600;
color: #606266;
min-width: 80px;
}
.status-value {
flex: 1;
color: #2b2f36;
font-weight: 500;
}
@media (max-width: 1200px) {
.summary-right {
display: none;
}
}
.comment-form {
padding: 16px;
border: 1px solid #e6e8ed;
border-radius: 8px;
background: #fafbfc;
margin-bottom: 20px;
.form-actions {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
}
.comment-list {
margin-top: 16px;
.comment-item {
padding: 16px;
border: 1px solid #e6e8ed;
border-radius: 8px;
background: #ffffff;
margin-bottom: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
.comment-header {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f2f5;
.comment-meta {
display: flex;
justify-content: space-between;
align-items: center;
.comment-operator {
font-weight: 600;
color: #2b2f36;
}
.comment-time {
font-size: 12px;
color: #8a8f99;
}
}
}
.comment-body {
.comment-content,
.comment-attachments {
margin-bottom: 12px;
.content-label {
font-size: 13px;
font-weight: 600;
color: #606266;
margin-bottom: 4px;
}
.content-value {
color: #2b2f36;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.attachment-text {
font-size: 13px;
color: #409eff;
text-decoration: underline;
cursor: pointer;
}
}
}
}
.empty-comment {
text-align: center;
padding: 40px 20px;
color: #a0a3ad;
font-size: 14px;
background: #fafbfc;
border: 1px solid #e6e8ed;
border-radius: 8px;
}
}
</style>