办公V3
This commit is contained in:
47
klp-ui/src/views/hrm/requests/_manualApproverMixin.js
Normal file
47
klp-ui/src/views/hrm/requests/_manualApproverMixin.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Mixin:支持“模板审批”与“手动审批人”二选一
|
||||
// 依赖:
|
||||
// 1. 引用 user-select 组件 (已有 src/components/userSelect)
|
||||
// 2. 表单中需提供 tplId (工作流模板ID) 与 assigneeUserId (手动审批人ID)
|
||||
//
|
||||
// 使用方式:
|
||||
// import manualApproverMixin from './_manualApproverMixin'
|
||||
// export default { mixins: [manualApproverMixin], ... }
|
||||
// 你的表单中绑定 approverMode / tplId / assigneeUserId
|
||||
// approverMode: 'template' | 'manual'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
approverMode: 'template', // 默认使用流程模板
|
||||
tplId: null, // 选中的模板ID
|
||||
assigneeUserId: null // 手动审批人
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
useTemplate() {
|
||||
return this.approverMode === 'template'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 在提交之前,调用该方法获取流程相关字段
|
||||
* @returns {{tplId: number|null, assigneeUserId: number|null}}
|
||||
*/
|
||||
buildFlowFields() {
|
||||
if (this.useTemplate) {
|
||||
// 模板审批:必须选择 tplId
|
||||
if (!this.tplId) {
|
||||
this.$message.warning('请选择审批流程模板')
|
||||
throw new Error('缺少 tplId')
|
||||
}
|
||||
return { tplId: this.tplId, assigneeUserId: null }
|
||||
}
|
||||
// 手动审批:必须选择审批人
|
||||
if (!this.assigneeUserId) {
|
||||
this.$message.warning('请选择审批人')
|
||||
throw new Error('缺少 assigneeUserId')
|
||||
}
|
||||
return { tplId: 0, assigneeUserId: this.assigneeUserId }
|
||||
}
|
||||
}
|
||||
}
|
||||
432
klp-ui/src/views/hrm/requests/apply.vue
Normal file
432
klp-ui/src/views/hrm/requests/apply.vue
Normal file
@@ -0,0 +1,432 @@
|
||||
<template>
|
||||
<div class="hrm-page">
|
||||
<!-- 发起申请模块 -->
|
||||
<section class="apply-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">发起申请</h3>
|
||||
<p class="section-desc">选择申请类型,快速发起新的申请流程</p>
|
||||
</div>
|
||||
<div class="apply-cards">
|
||||
<el-card
|
||||
v-for="item in applyTypes"
|
||||
:key="item.key"
|
||||
class="apply-card"
|
||||
shadow="hover"
|
||||
@click.native="goCreate(item.key)"
|
||||
>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="card-title">{{ item.title }}</div>
|
||||
<div class="card-desc">{{ item.desc }}</div>
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<el-button type="primary" size="small" icon="el-icon-plus">发起</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 申请历史模块 -->
|
||||
<section class="history-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">我的申请</h3>
|
||||
<div class="section-actions">
|
||||
<el-select
|
||||
v-model="historyQuery.type"
|
||||
size="small"
|
||||
placeholder="申请类型"
|
||||
clearable
|
||||
style="width: 120px; margin-right: 8px"
|
||||
@change="loadHistory"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="请假" value="leave" />
|
||||
<el-option label="出差" value="travel" />
|
||||
<el-option label="用印" value="seal" />
|
||||
<el-option label="报销" value="reimburse" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="historyQuery.status"
|
||||
size="small"
|
||||
placeholder="状态"
|
||||
clearable
|
||||
style="width: 120px; margin-right: 8px"
|
||||
@change="loadHistory"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="审批中" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
</el-select>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="loadHistory">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="history-card" shadow="never">
|
||||
<el-table
|
||||
:data="historyList"
|
||||
v-loading="historyLoading"
|
||||
stripe
|
||||
@row-dblclick="handleRowClick"
|
||||
>
|
||||
<el-table-column label="申请类型" min-width="100">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="getTypeTagType(scope.row.procDefKey)">{{ getTypeText(scope.row.procDefKey) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请人" min-width="120">
|
||||
<template slot-scope="scope">
|
||||
{{ formatEmpDisplay(scope.row.startUserId) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型/目的" min-width="140">
|
||||
<template slot-scope="scope">
|
||||
{{ getTypeDetail(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开始时间" prop="startTime" min-width="160">
|
||||
<template slot-scope="scope">{{ formatDate(scope.row.startTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结束时间" prop="endTime" min-width="160">
|
||||
<template slot-scope="scope">{{ formatDate(scope.row.endTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时长" min-width="100">
|
||||
<template slot-scope="scope">{{ formatDuration(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" prop="status" min-width="110">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="statusType(scope.row.procStatus)">{{ statusText(scope.row.procStatus) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请时间" prop="createTime" min-width="160">
|
||||
<template slot-scope="scope">{{ formatDate(scope.row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="120" fixed="right">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" @click="goDetail(scope.row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
:current-page="historyQuery.pageNum"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:page-size="historyQuery.pageSize"
|
||||
:total="historyTotal"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listMyFlowInstance } from '@/api/hrm/flow'
|
||||
import { getEmployeeByUserId } from '@/api/hrm/employee'
|
||||
|
||||
export default {
|
||||
name: 'HrmApply',
|
||||
data() {
|
||||
return {
|
||||
currentEmp: null,
|
||||
applyTypes: [
|
||||
{
|
||||
key: 'leave',
|
||||
title: '请假申请',
|
||||
desc: '申请各类假期,包括年假、病假、事假等',
|
||||
icon: 'el-icon-calendar',
|
||||
color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
},
|
||||
{
|
||||
key: 'travel',
|
||||
title: '出差申请',
|
||||
desc: '申请出差,包括目的地、时间、费用等信息',
|
||||
icon: 'el-icon-location',
|
||||
color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)'
|
||||
},
|
||||
{
|
||||
key: 'seal',
|
||||
title: '用印申请',
|
||||
desc: '申请使用印章,上传文件并指定盖章位置',
|
||||
icon: 'el-icon-stamp',
|
||||
color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)'
|
||||
},
|
||||
{
|
||||
key: 'reimburse',
|
||||
title: '报销申请',
|
||||
desc: '申请费用报销,包括单据、理由、金额等信息',
|
||||
icon: 'el-icon-money',
|
||||
color: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)'
|
||||
}
|
||||
],
|
||||
historyList: [],
|
||||
historyLoading: false,
|
||||
historyTotal: 0,
|
||||
historyQuery: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
type: '',
|
||||
status: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadCurrentEmployee()
|
||||
},
|
||||
methods: {
|
||||
formatEmpLabel(emp) {
|
||||
if (!emp) return '未指定'
|
||||
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()
|
||||
},
|
||||
formatEmpDisplay(userId) {
|
||||
// The API returns startUserId, which is a user ID, not an empId.
|
||||
// We can display the current user's name if the ID matches.
|
||||
if (this.currentEmp && String(this.currentEmp.userId) === String(userId)) {
|
||||
return this.formatEmpLabel(this.currentEmp)
|
||||
}
|
||||
return userId ? `用户ID:${userId}` : '未指定'
|
||||
},
|
||||
formatDuration(row) {
|
||||
if (row.hours) return `${row.hours}h`
|
||||
if (row.startTime && row.endTime) {
|
||||
const ms = new Date(row.endTime).getTime() - new Date(row.startTime).getTime()
|
||||
if (ms > 0) return `${(ms / 3600000).toFixed(1)}h`
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
statusText(status) {
|
||||
const map = { pending: '审批中', draft: '草稿', approved: '已通过', rejected: '已驳回', finished: '已完成' }
|
||||
return map[status] || status || '-'
|
||||
},
|
||||
statusType(status) {
|
||||
if (!status) return 'info'
|
||||
const map = { pending: 'warning', draft: 'info', approved: 'success', rejected: 'danger', finished: 'success' }
|
||||
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())}`
|
||||
},
|
||||
getTypeText(type) {
|
||||
const map = { leave: '请假', travel: '出差', seal: '用印', reimburse: '报销' }
|
||||
return map[type] || type || '-'
|
||||
},
|
||||
getTypeTagType(type) {
|
||||
const map = { leave: 'primary', travel: 'success', seal: 'warning', reimburse: 'danger' }
|
||||
return map[type] || 'info'
|
||||
},
|
||||
getTypeDetail(row) {
|
||||
// This detail might need to be fetched or be part of the process variables
|
||||
// For now, we display the process name as a fallback.
|
||||
return row.procDefName || '-'
|
||||
},
|
||||
goCreate(key) {
|
||||
const pathMap = {
|
||||
leave: '/hrm/HrmLeaveRequest',
|
||||
travel: '/hrm/HrmTravelRequest',
|
||||
seal: '/hrm/HrmSealRequest',
|
||||
reimburse: '/hrm/HrmReimburseRequest'
|
||||
}
|
||||
const path = pathMap[key]
|
||||
if (path) {
|
||||
this.$router.push(path)
|
||||
} else {
|
||||
this.$message.warning('该申请类型暂未实现')
|
||||
}
|
||||
},
|
||||
goDetail(row) {
|
||||
if (!row || !row.businessKey) {
|
||||
this.$message.warning('缺少businessKey,无法打开详情')
|
||||
return
|
||||
}
|
||||
const [type, bizId] = row.businessKey.split(':')
|
||||
if (!bizId) {
|
||||
this.$message.warning('businessKey格式不正确,无法解析业务ID')
|
||||
return
|
||||
}
|
||||
|
||||
const pathMap = {
|
||||
leave: '/hrm/HrmLeaveDetail',
|
||||
travel: '/hrm/HrmTravelDetail',
|
||||
seal: '/hrm/HrmSealDetail',
|
||||
reimburse: '/hrm/HrmReimburseDetail'
|
||||
}
|
||||
const routePath = pathMap[type]
|
||||
|
||||
if (routePath) {
|
||||
this.$router.push({
|
||||
path: routePath,
|
||||
query: { bizId: bizId }
|
||||
})
|
||||
} else {
|
||||
this.$message.warning('无法确定申请类型对应的详情页面')
|
||||
}
|
||||
},
|
||||
handleRowClick(row) {
|
||||
this.goDetail(row)
|
||||
},
|
||||
async loadCurrentEmployee() {
|
||||
try {
|
||||
const userId = this.$store?.state?.user?.id
|
||||
if (!userId) {
|
||||
this.$message.warning('无法获取当前用户信息,请重新登录')
|
||||
this.loadHistory() // Still try to load history if user is not found
|
||||
return
|
||||
}
|
||||
|
||||
const res = await getEmployeeByUserId(userId)
|
||||
if (res.code === 200 && res.data) {
|
||||
this.currentEmp = res.data
|
||||
} else {
|
||||
this.$message.warning('未找到当前用户对应的员工信息')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载员工信息失败', err)
|
||||
this.$message.error('加载员工信息失败')
|
||||
} finally {
|
||||
this.loadHistory()
|
||||
}
|
||||
},
|
||||
async loadHistory() {
|
||||
this.historyLoading = true
|
||||
try {
|
||||
const params = {
|
||||
pageNum: this.historyQuery.pageNum,
|
||||
pageSize: this.historyQuery.pageSize,
|
||||
bizType: this.historyQuery.type || undefined, // 业务类型:leave/travel/seal/reimburse
|
||||
status: this.historyQuery.status || undefined // 状态:draft/pending/approved/rejected
|
||||
}
|
||||
const res = await listMyFlowInstance(params)
|
||||
if (res.code === 200) {
|
||||
this.historyList = res.rows || []
|
||||
this.historyTotal = res.total || 0
|
||||
} else {
|
||||
this.historyList = []
|
||||
this.historyTotal = 0
|
||||
this.$message.error(res.msg || '加载申请历史失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载申请历史失败:', err)
|
||||
this.$message.error('加载申请历史失败')
|
||||
this.historyList = []
|
||||
this.historyTotal = 0
|
||||
} finally {
|
||||
this.historyLoading = false
|
||||
}
|
||||
},
|
||||
handleSizeChange(val) {
|
||||
this.historyQuery.pageSize = val
|
||||
this.historyQuery.pageNum = 1
|
||||
this.loadHistory()
|
||||
},
|
||||
handlePageChange(val) {
|
||||
this.historyQuery.pageNum = val
|
||||
this.loadHistory()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hrm-page {
|
||||
padding: 20px 24px 32px;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.section-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
.section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.apply-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.apply-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.apply-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
border-color: #409eff;
|
||||
}
|
||||
.card-content {
|
||||
margin-bottom: 16px;
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
.card-action {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.history-section {
|
||||
.history-card {
|
||||
border: 1px solid #d7d9df;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 1600px) {
|
||||
.apply-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.apply-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,204 +1,207 @@
|
||||
<template>
|
||||
<div class="hrm-page">
|
||||
<section class="panel-grid quad">
|
||||
<el-card v-for="item in requestBlocks" :key="item.key" class="metal-panel" shadow="hover">
|
||||
<div slot="header" class="panel-header">
|
||||
<span>{{ item.title }}</span>
|
||||
<div class="actions-inline">
|
||||
<el-button size="mini" type="primary" plain icon="el-icon-plus" @click="goCreate(item.key)">新增</el-button>
|
||||
<section class="summary-bar">
|
||||
<div class="summary-left">
|
||||
<div class="page-title">审批中心</div>
|
||||
<div class="page-desc">集中查看与处理待办审批</div>
|
||||
</div>
|
||||
<div class="summary-right">
|
||||
<div class="metric">
|
||||
<div class="metric-value">{{ todoCount }}</div>
|
||||
<div class="metric-label">待审批</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">{{ todayCount }}</div>
|
||||
<div class="metric-label">今日处理</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="approval-main">
|
||||
<el-card class="approval-card" shadow="never">
|
||||
<div slot="header" class="card-header">
|
||||
<span class="header-title">待审批任务</span>
|
||||
<div class="header-actions">
|
||||
<el-select
|
||||
v-if="item.statusField"
|
||||
v-model="item.query.status"
|
||||
size="mini"
|
||||
placeholder="状态"
|
||||
v-model="query.bizType"
|
||||
size="small"
|
||||
placeholder="申请类型"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
@change="item.loader"
|
||||
style="width: 120px; margin-right: 8px"
|
||||
@change="loadTodoList"
|
||||
>
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="审批中" value="pending" />
|
||||
<el-option label="通过" value="approved" />
|
||||
<el-option label="驳回" value="rejected" />
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="请假" value="leave" />
|
||||
<el-option label="出差" value="travel" />
|
||||
<el-option label="用印" value="seal" />
|
||||
<el-option label="报销" value="reimburse" />
|
||||
</el-select>
|
||||
<el-button size="mini" icon="el-icon-refresh" @click="item.loader">刷新</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="loadTodoList">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="item.list" v-loading="item.loading" height="300" stripe>
|
||||
<el-table-column label="员工" prop="empId" min-width="100" />
|
||||
<el-table-column label="类型/目的" :prop="item.typeField" min-width="120" />
|
||||
<el-table-column label="开始" prop="startTime" min-width="140">
|
||||
<template slot-scope="scope">{{ formatDate(scope.row.startTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结束" prop="endTime" min-width="140">
|
||||
<template slot-scope="scope">{{ formatDate(scope.row.endTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" prop="status" min-width="110">
|
||||
|
||||
<el-table
|
||||
:data="todoList"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
@row-dblclick="handleRowClick"
|
||||
>
|
||||
<el-table-column label="申请类型" min-width="100">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="statusType(scope.row.status)">{{ scope.row.status || '-' }}</el-tag>
|
||||
<el-tag :type="getBizTypeTagType(scope.row.bizType)">
|
||||
{{ getBizTypeText(scope.row.bizType) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="附件" prop="accessoryApplyIds" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column
|
||||
v-if="item.key === 'seal'"
|
||||
label="操作"
|
||||
min-width="220"
|
||||
fixed="right"
|
||||
>
|
||||
<el-table-column label="申请人" min-width="140">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" @click="approveSeal(scope.row)">通过</el-button>
|
||||
<el-button size="mini" type="text" @click="rejectSeal(scope.row)">驳回</el-button>
|
||||
<el-button size="mini" type="text" @click="cancelSeal(scope.row)">撤销</el-button>
|
||||
<el-button size="mini" type="text" @click="openStamp(scope.row)">盖章</el-button>
|
||||
{{ formatApplicant(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请信息" min-width="200">
|
||||
<template slot-scope="scope">
|
||||
{{ formatRequestInfo(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前节点" min-width="120">
|
||||
<template slot-scope="scope">
|
||||
{{ formatNodeName(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="创建时间" prop="createTime" min-width="160">
|
||||
<template slot-scope="scope">{{ formatDate(scope.row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" prop="status" min-width="100">
|
||||
<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="操作" min-width="200" fixed="right">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" @click="goDetail(scope.row)">查看详情</el-button>
|
||||
<el-button size="mini" type="text" @click="handleApprove(scope.row)">通过</el-button>
|
||||
<el-button size="mini" type="text" @click="handleReject(scope.row)">驳回</el-button>
|
||||
<el-button size="mini" type="text" @click="handleTransfer(scope.row)">转发</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</section>
|
||||
|
||||
<!-- 审批操作对话框 -->
|
||||
<el-dialog
|
||||
title="盖章"
|
||||
:visible.sync="stampDialogVisible"
|
||||
width="720px"
|
||||
:title="actionDialog.title"
|
||||
:visible.sync="actionDialog.visible"
|
||||
width="500px"
|
||||
append-to-body
|
||||
>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form :model="stampForm" label-width="110px" size="small">
|
||||
<el-form-item label="待盖章文件" prop="targetFileUrl">
|
||||
<file-upload
|
||||
v-model="stampForm.targetFileOssId"
|
||||
:limit="1"
|
||||
:file-size="20"
|
||||
:file-type="['pdf', 'png', 'jpg', 'jpeg', 'bmp', 'webp']"
|
||||
:is-show-tip="false"
|
||||
@success="handleTargetUploadSuccess"
|
||||
<el-form :model="actionForm" label-width="100px" size="small">
|
||||
<el-form-item label="审批意见">
|
||||
<el-input
|
||||
v-model="actionForm.remark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入审批意见(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="章图片" prop="stampImageUrl">
|
||||
<el-select
|
||||
v-model="stampForm.stampImageUrl"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择章图"
|
||||
@change="preloadStampImage"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in dict.type.hrm_stamp_image || []"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="页码">
|
||||
<el-input-number v-model="stampForm.pageNo" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="坐标/尺寸">
|
||||
<div class="readonly-row">
|
||||
<span>X: {{ stampForm.xPx || '-' }} px</span>
|
||||
<span>Y: {{ stampForm.yPx || '-' }} px</span>
|
||||
</div>
|
||||
<div class="readonly-row">
|
||||
<span>宽: {{ stampForm.widthPx || stampImageNatural.width || '-' }} px</span>
|
||||
<span>高: {{ stampForm.heightPx || stampImageNatural.height || '-' }} px</span>
|
||||
</div>
|
||||
<div class="hint-text">点击右侧预览即可定位,尺寸默认取章图原始大小</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="preview-card">
|
||||
<div class="preview-title">图形化定位(点击预览设置坐标)</div>
|
||||
<div
|
||||
class="preview-area"
|
||||
ref="previewArea"
|
||||
v-if="stampForm.targetFileUrl"
|
||||
>
|
||||
<img
|
||||
:src="stampForm.targetFileUrl"
|
||||
class="preview-img"
|
||||
@load="handlePreviewLoad"
|
||||
@click="handlePreviewClick"
|
||||
alt="preview"
|
||||
>
|
||||
<div
|
||||
v-if="marker.visible"
|
||||
class="stamp-marker"
|
||||
:style="markerStyle"
|
||||
></div>
|
||||
</div>
|
||||
<div v-else class="preview-placeholder">请先填写待盖章文件URL(建议提供图片预览)</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="stampDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="stampSubmitting" @click="submitStamp">盖章</el-button>
|
||||
<el-button @click="actionDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionSubmitting" @click="submitAction">确认</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 转发功能复用 UserSelect 组件 -->
|
||||
<UserSelect ref="userSelect" @onSelected="onManualApproverConfirmed" />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
listLeaveReq,
|
||||
listTravelReq,
|
||||
listSealReq,
|
||||
approveSealReq,
|
||||
rejectSealReq,
|
||||
cancelSealReq,
|
||||
stampSealJava,
|
||||
stampSealPython
|
||||
} from '@/api/hrm'
|
||||
import FileUpload from '@/components/FileUpload'
|
||||
import { listTodoFlowTask, approveFlowTask, rejectFlowTask, transferFlowTask } from '@/api/hrm/flow'
|
||||
import { listEmployee } from '@/api/hrm'
|
||||
import UserSelect from '@/components/userSelect/single.vue'
|
||||
|
||||
export default {
|
||||
name: 'HrmRequests',
|
||||
dicts: ['hrm_stamp_image'],
|
||||
components: { FileUpload },
|
||||
name: 'HrmApproval',
|
||||
components: { UserSelect },
|
||||
data() {
|
||||
return {
|
||||
requestBlocks: [],
|
||||
stampDialogVisible: false,
|
||||
stampSubmitting: false,
|
||||
stampForm: {
|
||||
targetFileUrl: '',
|
||||
targetFileOssId: '',
|
||||
stampImageUrl: '',
|
||||
pageNo: 1,
|
||||
xPx: 0,
|
||||
yPx: 0,
|
||||
widthPx: undefined,
|
||||
heightPx: undefined
|
||||
employees: [],
|
||||
todoList: [],
|
||||
loading: false,
|
||||
todoCount: 0,
|
||||
todayCount: 0,
|
||||
query: {
|
||||
bizType: ''
|
||||
},
|
||||
currentSeal: null,
|
||||
previewNatural: { width: 0, height: 0 },
|
||||
marker: { visible: false, x: 0, y: 0, width: 0, height: 0 },
|
||||
stampImageNatural: { width: 0, height: 0 }
|
||||
actionDialog: {
|
||||
visible: false,
|
||||
title: '',
|
||||
type: '', // approve/reject
|
||||
task: null
|
||||
},
|
||||
actionForm: {
|
||||
remark: ''
|
||||
},
|
||||
actionSubmitting: false,
|
||||
transferTask: null
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.initRequests()
|
||||
},
|
||||
computed: {
|
||||
applicantDisplay() {
|
||||
const user = this.$store?.state?.user || {}
|
||||
const name = user.nickName || user.userName || ''
|
||||
const id = user.userId || user.userId === 0 ? user.userId : ''
|
||||
return name ? `${name}${id ? ` (${id})` : ''}` : id || '当前登录人'
|
||||
}
|
||||
this.loadEmployees()
|
||||
this.loadTodoList()
|
||||
},
|
||||
methods: {
|
||||
goCreate(key) {
|
||||
const routeNameMap = {
|
||||
leave: 'HrmLeaveRequest',
|
||||
travel: 'HrmTravelRequest',
|
||||
seal: 'HrmSealRequest'
|
||||
formatEmpLabel(emp) {
|
||||
if (!emp) return ''
|
||||
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()
|
||||
},
|
||||
formatApplicant(task) {
|
||||
if (!task.bizData) return '加载中...'
|
||||
const empId = task.bizData.empId
|
||||
const emp = this.employees.find(e => String(e.empId) === String(empId))
|
||||
if (emp) {
|
||||
return this.formatEmpLabel(emp)
|
||||
}
|
||||
const name = routeNameMap[key]
|
||||
if (name) this.$router.push({ name })
|
||||
return empId ? `员工ID:${empId}` : '未指定'
|
||||
},
|
||||
formatRequestInfo(task) {
|
||||
if (!task.bizData) return '加载中...'
|
||||
const biz = task.bizData
|
||||
if (task.bizType === 'leave') {
|
||||
return `${biz.leaveType || '请假'} · ${this.formatDuration(biz)}`
|
||||
} else if (task.bizType === 'travel') {
|
||||
return `${biz.travelType || '出差'} · ${biz.destination || ''}`
|
||||
} else if (task.bizType === 'seal') {
|
||||
return `${biz.sealType || '用印'} · ${biz.applyFileIds ? '已上传文件' : '未上传'}`
|
||||
} else if (task.bizType === 'reimburse') {
|
||||
const amt = biz.totalAmount != null ? biz.totalAmount : 0
|
||||
return `${biz.reimburseType || '报销'} · 金额: ${amt}元`
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
formatNodeName(task) {
|
||||
// TODO: 节点名称需要通过 nodeId 查询节点信息
|
||||
return `节点 #${task.nodeId}`
|
||||
},
|
||||
getBizTypeText(type) {
|
||||
const map = { leave: '请假', travel: '出差', seal: '用印', reimburse: '报销' }
|
||||
return map[type] || type || '-'
|
||||
},
|
||||
getBizTypeTagType(type) {
|
||||
const map = { leave: 'primary', travel: 'success', seal: 'warning', reimburse: 'danger' }
|
||||
return map[type] || 'info'
|
||||
},
|
||||
statusText(status) {
|
||||
const map = { pending: '待审批', draft: '草稿', approved: '已通过', rejected: '已驳回' }
|
||||
return map[status] || status || '-'
|
||||
},
|
||||
statusType(status) {
|
||||
if (!status) return 'info'
|
||||
@@ -211,144 +214,122 @@ export default {
|
||||
const p = n => (n < 10 ? `0${n}` : n)
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
},
|
||||
initRequests() {
|
||||
this.requestBlocks = [
|
||||
{ key: 'leave', title: '请假单', typeField: 'leaveType', statusField: 'status', query: { pageNum: 1, pageSize: 10, status: undefined }, list: [], loading: false, loader: this.loadLeaveReq },
|
||||
{ key: 'travel', title: '出差单', typeField: 'travelType', statusField: 'status', query: { pageNum: 1, pageSize: 10, status: undefined }, list: [], loading: false, loader: this.loadTravelReq },
|
||||
{ key: 'seal', title: '用印申请', typeField: 'sealType', statusField: 'status', query: { pageNum: 1, pageSize: 10, status: undefined }, list: [], loading: false, loader: this.loadSealReq }
|
||||
]
|
||||
this.requestBlocks.forEach(b => b.loader())
|
||||
formatDuration(biz) {
|
||||
if (biz.hours) return `${biz.hours}h`
|
||||
if (biz.startTime && biz.endTime) {
|
||||
const ms = new Date(biz.endTime).getTime() - new Date(biz.startTime).getTime()
|
||||
if (ms > 0) return `${(ms / 3600000).toFixed(1)}h`
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
loadLeaveReq() {
|
||||
const block = this.requestBlocks.find(i => i.key === 'leave')
|
||||
block.loading = true
|
||||
listLeaveReq(block.query)
|
||||
.then(res => {
|
||||
block.list = res.rows || []
|
||||
})
|
||||
.finally(() => {
|
||||
block.loading = false
|
||||
})
|
||||
},
|
||||
loadTravelReq() {
|
||||
const block = this.requestBlocks.find(i => i.key === 'travel')
|
||||
block.loading = true
|
||||
listTravelReq(block.query)
|
||||
.then(res => {
|
||||
block.list = res.rows || []
|
||||
})
|
||||
.finally(() => {
|
||||
block.loading = false
|
||||
})
|
||||
},
|
||||
loadSealReq() {
|
||||
const block = this.requestBlocks.find(i => i.key === 'seal')
|
||||
block.loading = true
|
||||
listSealReq(block.query)
|
||||
.then(res => {
|
||||
block.list = res.rows || []
|
||||
})
|
||||
.finally(() => {
|
||||
block.loading = false
|
||||
})
|
||||
},
|
||||
approveSeal(row) {
|
||||
approveSealReq(row.bizId).then(() => {
|
||||
this.$message.success('已通过')
|
||||
this.loadSealReq()
|
||||
loadEmployees() {
|
||||
listEmployee({ pageNum: 1, pageSize: 1000 }).then(res => {
|
||||
this.employees = res.rows || res.data || []
|
||||
}).catch(() => {
|
||||
this.employees = []
|
||||
})
|
||||
},
|
||||
rejectSeal(row) {
|
||||
rejectSealReq(row.bizId).then(() => {
|
||||
this.$message.success('已驳回')
|
||||
this.loadSealReq()
|
||||
})
|
||||
async loadTodoList() {
|
||||
this.loading = true
|
||||
try {
|
||||
const userId = this.$store?.state?.user?.id
|
||||
if (!userId) {
|
||||
this.$message.error('无法获取当前用户信息,请重新登录')
|
||||
this.loading = false
|
||||
return
|
||||
}
|
||||
const res = await listTodoFlowTask(userId)
|
||||
let list = res.data || []
|
||||
// 前端过滤 bizType
|
||||
if (this.query.bizType) {
|
||||
list = list.filter(item => item.bizType === this.query.bizType)
|
||||
}
|
||||
|
||||
// 后端 /hrm/flow/task/todo 已返回 bizData,无需前端再联查
|
||||
this.todoList = list
|
||||
this.todoCount = list.length
|
||||
// TODO: 计算今日处理数量
|
||||
this.todayCount = 0
|
||||
} catch (err) {
|
||||
console.error('加载待办任务失败:', err)
|
||||
this.$message.error('加载待办任务失败')
|
||||
this.todoList = []
|
||||
this.todoCount = 0
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
cancelSeal(row) {
|
||||
cancelSealReq(row.bizId).then(() => {
|
||||
this.$message.success('已撤销')
|
||||
this.loadSealReq()
|
||||
})
|
||||
},
|
||||
openStamp(row) {
|
||||
this.currentSeal = row
|
||||
this.stampDialogVisible = true
|
||||
this.marker.visible = false
|
||||
},
|
||||
submitStamp() {
|
||||
if (!this.currentSeal) return
|
||||
if (!this.stampForm.targetFileUrl) {
|
||||
this.$message.warning('请先上传待盖章文件')
|
||||
goDetail(task) {
|
||||
if (!task || !task.bizId) {
|
||||
this.$message.warning('缺少bizId,无法打开详情')
|
||||
return
|
||||
}
|
||||
this.stampSubmitting = true
|
||||
stampSealJava(this.currentSeal.bizId, this.stampForm)
|
||||
const routeNameMap = {
|
||||
leave: 'HrmLeaveDetail',
|
||||
travel: 'HrmTravelDetail',
|
||||
seal: 'HrmSealDetail',
|
||||
reimburse: 'HrmReimburseDetail'
|
||||
}
|
||||
const name = routeNameMap[task.bizType]
|
||||
if (name) {
|
||||
this.$router.push({ name, params: { bizId: task.bizId } })
|
||||
} else {
|
||||
this.$message.warning('未知的申请类型')
|
||||
}
|
||||
},
|
||||
handleRowClick(task) {
|
||||
this.goDetail(task)
|
||||
},
|
||||
handleApprove(task) {
|
||||
this.actionDialog = {
|
||||
visible: true,
|
||||
title: '审批通过',
|
||||
type: 'approve',
|
||||
task
|
||||
}
|
||||
this.actionForm.remark = ''
|
||||
},
|
||||
handleReject(task) {
|
||||
this.actionDialog = {
|
||||
visible: true,
|
||||
title: '审批驳回',
|
||||
type: 'reject',
|
||||
task
|
||||
}
|
||||
this.actionForm.remark = ''
|
||||
},
|
||||
submitAction() {
|
||||
if (!this.actionDialog.task) return
|
||||
this.actionSubmitting = true
|
||||
const { task, type } = this.actionDialog
|
||||
const action = type === 'approve' ? approveFlowTask : rejectFlowTask
|
||||
action(task.taskId, { remark: this.actionForm.remark })
|
||||
.then(() => {
|
||||
this.$message.success('盖章指令已提交')
|
||||
this.stampDialogVisible = false
|
||||
this.loadSealReq()
|
||||
this.$message.success(type === 'approve' ? '审批通过' : '已驳回')
|
||||
this.actionDialog.visible = false
|
||||
this.loadTodoList()
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('审批操作失败:', err)
|
||||
this.$message.error('操作失败')
|
||||
})
|
||||
.finally(() => {
|
||||
this.stampSubmitting = false
|
||||
this.actionSubmitting = false
|
||||
})
|
||||
},
|
||||
handlePreviewLoad(e) {
|
||||
const img = e.target
|
||||
this.previewNatural = { width: img.naturalWidth, height: img.naturalHeight }
|
||||
this.updateMarkerStyle()
|
||||
handleTransfer(task) {
|
||||
this.transferTask = task
|
||||
this.$refs.userSelect.open()
|
||||
},
|
||||
handlePreviewClick(event) {
|
||||
if (!this.previewNatural.width || !this.previewNatural.height) return
|
||||
const rect = this.$refs.previewArea.getBoundingClientRect()
|
||||
const displayWidth = rect.width
|
||||
const displayHeight = rect.height
|
||||
const clickX = event.clientX - rect.left
|
||||
const clickY = event.clientY - rect.top
|
||||
const xRatio = clickX / displayWidth
|
||||
const yRatio = clickY / displayHeight
|
||||
const xPx = Math.round(xRatio * this.previewNatural.width)
|
||||
// 注意 PDF 坐标原点左下,这里预览原点左上,需要转换
|
||||
const yPx = Math.round((1 - yRatio) * this.previewNatural.height)
|
||||
this.stampForm.xPx = xPx
|
||||
this.stampForm.yPx = yPx
|
||||
// 默认尺寸取章图天然尺寸
|
||||
if (this.stampImageNatural.width) {
|
||||
this.stampForm.widthPx = this.stampForm.widthPx || this.stampImageNatural.width
|
||||
this.stampForm.heightPx = this.stampForm.heightPx || this.stampImageNatural.height
|
||||
}
|
||||
this.updateMarkerStyle()
|
||||
},
|
||||
preloadStampImage() {
|
||||
if (!this.stampForm.stampImageUrl) return
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
this.stampImageNatural = { width: img.width, height: img.height }
|
||||
}
|
||||
img.src = this.stampForm.stampImageUrl
|
||||
},
|
||||
updateMarkerStyle() {
|
||||
if (!this.previewNatural.width || !this.previewNatural.height) return
|
||||
const rect = this.$refs.previewArea?.getBoundingClientRect?.()
|
||||
if (!rect) return
|
||||
const displayWidth = rect.width
|
||||
const displayHeight = rect.height
|
||||
const xRatio = this.stampForm.xPx / this.previewNatural.width
|
||||
const yRatio = 1 - this.stampForm.yPx / this.previewNatural.height
|
||||
const wRatio = (this.stampForm.widthPx || this.stampImageNatural.width || 0) / this.previewNatural.width
|
||||
const hRatio = (this.stampForm.heightPx || this.stampImageNatural.height || 0) / this.previewNatural.height
|
||||
this.marker = {
|
||||
visible: true,
|
||||
x: xRatio * displayWidth,
|
||||
y: yRatio * displayHeight,
|
||||
width: wRatio * displayWidth,
|
||||
height: hRatio * displayHeight
|
||||
}
|
||||
},
|
||||
handleTargetUploadSuccess(fileList) {
|
||||
const first = (fileList && fileList[0]) || {}
|
||||
this.stampForm.targetFileUrl = first.url || ''
|
||||
this.stampForm.targetFileOssId = first.ossId || ''
|
||||
this.marker.visible = false
|
||||
async onManualApproverConfirmed(user) {
|
||||
if (!this.transferTask || !this.transferTask.taskId || !user || !user.userId) return
|
||||
await transferFlowTask(this.transferTask.taskId, {
|
||||
newAssigneeUserId: user.userId,
|
||||
remark: ''
|
||||
})
|
||||
this.$message.success('已转发')
|
||||
this.transferTask = null
|
||||
this.loadTodoList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,76 +340,74 @@ export default {
|
||||
padding: 16px 20px 32px;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.metal-panel {
|
||||
border: 1px solid #d7d9df;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.panel-header {
|
||||
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.actions-inline {
|
||||
.summary-left .page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.summary-left .page-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
.summary-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.coord-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
.metric {
|
||||
min-width: 92px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #fcfdff;
|
||||
text-align: center;
|
||||
}
|
||||
.preview-card {
|
||||
border: 1px dashed #e6e8ed;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
min-height: 340px;
|
||||
.metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.preview-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
.metric-label {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
.preview-area {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
overflow: hidden;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.preview-placeholder {
|
||||
color: #a0a3ad;
|
||||
font-size: 13px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #ebeef5;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.stamp-marker {
|
||||
position: absolute;
|
||||
border: 2px dashed #409eff;
|
||||
background: rgba(64, 158, 255, 0.08);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.panel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
.approval-main {
|
||||
.approval-card {
|
||||
border: 1px solid #d7d9df;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,102 +7,390 @@
|
||||
<el-button size="mini" icon="el-icon-arrow-left" @click="$router.back()">返回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" size="small">
|
||||
<el-form-item label="申请人" prop="empId">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" size="small" class="metal-form">
|
||||
<!-- 顶部摘要 -->
|
||||
<div class="form-summary">
|
||||
<div class="summary-left">
|
||||
<div class="summary-title">发起请假</div>
|
||||
<div class="summary-sub">请完善信息后提交,系统将按流程节点流转</div>
|
||||
</div>
|
||||
<div class="summary-right">
|
||||
<div class="summary-item">
|
||||
<div class="k">申请人</div>
|
||||
<div class="v">{{ currentApplicantText }}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="k">时长</div>
|
||||
<div class="v">{{ durationText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="请假类型" prop="leaveType">
|
||||
<el-select
|
||||
v-model="form.empId"
|
||||
v-model="form.leaveType"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder="选择申请人"
|
||||
placeholder="选择或输入(如:年假/事假/病假/调休)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="emp in employees"
|
||||
:key="emp.empId"
|
||||
:label="emp.empName || emp.empNo || emp.empId"
|
||||
:value="emp.empId"
|
||||
/>
|
||||
<el-option v-for="t in leaveTypeOptions" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
<div class="hint-text">优先选择;若公司类型未配置,可直接输入</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="请假类型" prop="leaveType">
|
||||
<el-input v-model="form.leaveType" placeholder="年假/事假/病假等" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="block-title">请假时间</div>
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开始时间" prop="startTime">
|
||||
<el-date-picker v-model="form.startTime" type="datetime" placeholder="请选择开始时间" style="width: 100%" />
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
placeholder="请选择开始时间"
|
||||
style="width: 100%"
|
||||
:picker-options="pickerOptions"
|
||||
@change="recalcHoursByTime"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="结束时间" prop="endTime">
|
||||
<el-date-picker v-model="form.endTime" type="datetime" placeholder="请选择结束时间" style="width: 100%" />
|
||||
<el-date-picker
|
||||
v-model="form.endTime"
|
||||
type="datetime"
|
||||
placeholder="请选择结束时间"
|
||||
style="width: 100%"
|
||||
:picker-options="pickerOptions"
|
||||
@change="recalcHoursByTime"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="时长(小时)" prop="hours">
|
||||
<el-input-number v-model="form.hours" :min="0.5" :step="0.5" style="width: 180px" />
|
||||
<el-input-number v-model="form.hours" :min="0.5" :step="0.5" style="width: 100%" />
|
||||
<div class="hint-text">可自动按开始/结束时间估算,也可手动微调(0.5小时步进)</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="紧急程度" prop="urgentLevel">
|
||||
<el-radio-group v-model="form.urgentLevel" size="small">
|
||||
<el-radio-button :label="'normal'">普通</el-radio-button>
|
||||
<el-radio-button :label="'urgent'">紧急</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="hint-text">仅影响审批提醒强度,不改变流程规则</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="block-title">请假说明</div>
|
||||
<el-form-item label="事由" prop="reason">
|
||||
<el-input v-model="form.reason" type="textarea" :rows="3" placeholder="请填写请假事由" />
|
||||
<el-input v-model="form.reason" type="textarea" :rows="4" placeholder="请简要说明请假原因、是否可联系、工作交接情况等" show-word-limit maxlength="200" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="工作交接" prop="handover">
|
||||
<el-input v-model="form.handover" type="textarea" :rows="3" placeholder="示例:已与张三完成客户A对接;紧急事项请联系李四" show-word-limit maxlength="200" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选" />
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选:补充说明、证明材料说明等" show-word-limit maxlength="200" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 审批方式(模板/自选审批人) -->
|
||||
<div class="block-title">审批方式</div>
|
||||
<div class="approve-mode">
|
||||
<el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange">
|
||||
<el-radio-button label="template">使用模板流程</el-radio-button>
|
||||
<el-radio-button label="manual">手动选择审批人(一次审批)</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<div class="approve-panel">
|
||||
<div v-if="approverMode === 'template'">
|
||||
<div class="approve-row">
|
||||
<div class="k">流程模板</div>
|
||||
<div class="v">
|
||||
<el-select
|
||||
v-model="tplId"
|
||||
size="small"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="请选择流程模板"
|
||||
style="width: 360px"
|
||||
@change="onTplChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="t in availableTpls"
|
||||
:key="t.tplId"
|
||||
:label="`${t.tplName}${t.version ? ' (v' + t.version + ')' : ''}`"
|
||||
:value="t.tplId"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-text">提示:选择模板后,将按模板节点自动流转(含抄送节点)。</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="approve-row">
|
||||
<div class="k">审批人</div>
|
||||
<div class="v" style="max-width: 520px">
|
||||
<el-button size="mini" type="primary" plain @click="openUserSelect">选择审批人</el-button>
|
||||
<span style="margin-left: 10px; font-weight: 600; color: #2b2f36">
|
||||
{{ assigneeUserName || '未选择' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-text">提示:手动选择审批人将创建一次性审批流程,审批通过后流程立即结束。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交流程提示 -->
|
||||
<div class="flow-preview" v-loading="flowLoading">
|
||||
<div class="flow-title">流程预览</div>
|
||||
<div class="flow-sub">
|
||||
<template v-if="approverMode === 'template'">
|
||||
<span v-if="flowTpl">当前模板:{{ flowTpl.tplName }}(v{{ flowTpl.version || 1 }})</span>
|
||||
<span v-else>请选择流程模板</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>一次性审批(手动指定审批人)</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 模板模式 -->
|
||||
<div v-if="approverMode === 'template'">
|
||||
<div v-if="flowNodes && flowNodes.length" class="flow-steps">
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">提交</div></div>
|
||||
<template v-for="(n, idx) in flowNodes">
|
||||
<div :key="`line-${n.nodeId || idx}`" class="line"></div>
|
||||
<div :key="`node-${n.nodeId || idx}`" class="flow-step">
|
||||
<div class="dot" :class="{ success: idx === flowNodes.length - 1 }"></div>
|
||||
<div class="txt">{{ nodePreviewText(n, idx) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flow-fallback">
|
||||
<div class="hint-text">提示:请选择一个模板后将展示对应节点预览。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动审批模式 -->
|
||||
<div v-else class="flow-steps">
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">提交审批({{ assigneeUserName || '请选择' }})</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step"><div class="dot success"></div><div class="txt">审批结束</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button @click="$router.back()">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">提交申请</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 用户选择组件始终挂载,仅通过 v-show 控制,避免 ref 失效 -->
|
||||
<UserSelect ref="userSelect" @onSelected="onUserSelected" />
|
||||
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listEmployee, addLeaveReq } from '@/api/hrm'
|
||||
import { addLeaveReq, listFlowTemplate, listFlowNode } from '@/api/hrm'
|
||||
import UserSelect from '@/components/userSelect/single.vue'
|
||||
import { getEmployeeByUserId } from '@/api/hrm/employee'
|
||||
|
||||
export default {
|
||||
name: 'HrmLeaveRequest',
|
||||
components: { UserSelect },
|
||||
data() {
|
||||
return {
|
||||
employees: [],
|
||||
currentEmp: null,
|
||||
submitting: false,
|
||||
flowLoading: false,
|
||||
flowTpl: null,
|
||||
flowNodes: [],
|
||||
approverMode: 'template',
|
||||
availableTpls: [],
|
||||
tplId: null,
|
||||
assigneeUserId: null,
|
||||
assigneeUserName: '',
|
||||
leaveTypeOptions: ['年假', '事假', '病假', '调休', '产假', '陪产假', '婚假', '丧假', '工伤假', '其他'],
|
||||
pickerOptions: { disabledDate: () => false },
|
||||
form: {
|
||||
empId: '',
|
||||
leaveType: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
hours: 0,
|
||||
urgentLevel: 'normal',
|
||||
reason: '',
|
||||
handover: '',
|
||||
remark: ''
|
||||
},
|
||||
rules: {
|
||||
empId: [{ required: true, message: '请选择申请人', trigger: 'change' }],
|
||||
leaveType: [{ required: true, message: '请输入请假类型', trigger: 'blur' }],
|
||||
leaveType: [{ required: true, message: '请选择/输入请假类型', trigger: 'change' }],
|
||||
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
||||
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
|
||||
hours: [{ required: true, message: '请输入时长', trigger: 'blur' }],
|
||||
reason: [{ required: true, message: '请输入事由', trigger: 'blur' }]
|
||||
hours: [{ required: true, message: '请填写请假时长', trigger: 'blur' }],
|
||||
reason: [{ required: true, message: '请填写请假事由', trigger: 'blur' }],
|
||||
handover: [{ required: true, message: '请填写工作交接说明', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadEmployees()
|
||||
const userId = this.$store?.state?.user?.userId
|
||||
if (userId) this.form.empId = userId
|
||||
this.loadCurrentEmployee()
|
||||
this.loadTemplates()
|
||||
},
|
||||
computed: {
|
||||
currentApplicantText() {
|
||||
if (this.currentEmp) return this.formatEmpLabel(this.currentEmp)
|
||||
const user = this.$store?.state?.user || {}
|
||||
return user.nickName || user.userName || '加载中...'
|
||||
},
|
||||
durationText() {
|
||||
if (this.form.hours > 0) return `${this.form.hours} 小时`
|
||||
if (this.form.startTime && this.form.endTime) {
|
||||
const ms = new Date(this.form.endTime).getTime() - new Date(this.form.startTime).getTime()
|
||||
if (ms > 0) return `${(ms / 3600000).toFixed(1)} 小时(估算)`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'form.startTime': { handler: 'recalcHoursByTime' },
|
||||
'form.endTime': { handler: 'recalcHoursByTime' }
|
||||
},
|
||||
methods: {
|
||||
loadEmployees() {
|
||||
listEmployee({ pageNum: 1, pageSize: 500 }).then(res => {
|
||||
this.employees = res.rows || res.data || []
|
||||
})
|
||||
async loadTemplates() {
|
||||
try {
|
||||
const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'leave', enabled: 1 })
|
||||
this.availableTpls = res.rows || res.data || []
|
||||
if (!this.tplId && this.availableTpls.length) {
|
||||
this.tplId = this.availableTpls[0].tplId
|
||||
}
|
||||
await this.refreshFlowPreview()
|
||||
} catch (err) {
|
||||
this.availableTpls = []
|
||||
}
|
||||
},
|
||||
async refreshFlowPreview() {
|
||||
this.flowLoading = true
|
||||
try {
|
||||
if (this.approverMode === 'manual') {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
if (!this.tplId) {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null
|
||||
const nodeRes = await listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.tplId })
|
||||
this.flowNodes = (nodeRes.rows || nodeRes.data || []).filter(n => n.nodeType !== 'end').sort((a, b) => (a.orderNo || 0) - (b.orderNo || 0))
|
||||
} finally {
|
||||
this.flowLoading = false
|
||||
}
|
||||
},
|
||||
nodePreviewText(n, idx) {
|
||||
const typeMap = { approve: '审批', cc: '抄送' }
|
||||
const ruleMap = { fixed_user: '指定人员', role: '指定角色', position: '指定岗位', leader: '直属上级' }
|
||||
const nodeType = typeMap[n.nodeType] || '节点'
|
||||
const rule = ruleMap[n.approverRule] || '规则'
|
||||
let detail = ''
|
||||
try {
|
||||
const arr = Array.isArray(n.approverValue) ? n.approverValue : JSON.parse(n.approverValue || '[]')
|
||||
if (arr.length) detail = `:${arr.join('、')}`
|
||||
} catch (e) { detail = n.approverValue ? `:${n.approverValue}` : '' }
|
||||
const text = `${nodeType}(${rule}${detail})`
|
||||
return idx === this.flowNodes.length - 1 ? `${text} → 结束` : text
|
||||
},
|
||||
formatEmpLabel(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()
|
||||
},
|
||||
async loadCurrentEmployee() {
|
||||
try {
|
||||
const res = await getEmployeeByUserId(this.$store?.state?.user?.id)
|
||||
if (res.code === 200 && res.data) {
|
||||
this.currentEmp = res.data
|
||||
this.form.empId = res.data.empId
|
||||
} else {
|
||||
this.$message.error('未找到当前用户对应的员工信息')
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error('加载员工信息失败')
|
||||
}
|
||||
},
|
||||
recalcHoursByTime() {
|
||||
if (!this.form.startTime || !this.form.endTime) return
|
||||
const s = new Date(this.form.startTime).getTime()
|
||||
const e = new Date(this.form.endTime).getTime()
|
||||
if (e <= s) {
|
||||
this.form.hours = 0
|
||||
return
|
||||
}
|
||||
this.form.hours = Math.round(((e - s) / 3600000) * 2) / 2
|
||||
},
|
||||
async onTplChange(val) {
|
||||
this.tplId = val
|
||||
await this.refreshFlowPreview()
|
||||
},
|
||||
onApproverModeChange(val) {
|
||||
this.approverMode = val
|
||||
if (val === 'manual') this.tplId = null
|
||||
this.refreshFlowPreview()
|
||||
},
|
||||
openUserSelect() {
|
||||
this.$refs.userSelect.open()
|
||||
},
|
||||
onUserSelected(row) {
|
||||
if (row) {
|
||||
this.assigneeUserId = row.userId
|
||||
this.assigneeUserName = row.nickName || row.userName || row.userId
|
||||
this.refreshFlowPreview()
|
||||
}
|
||||
},
|
||||
submit() {
|
||||
this.$refs.formRef.validate(valid => {
|
||||
this.$refs.formRef.validate(async valid => {
|
||||
if (!valid) return
|
||||
this.submitting = true
|
||||
const payload = { ...this.form }
|
||||
addLeaveReq(payload)
|
||||
.then(() => {
|
||||
this.$message.success('提交成功')
|
||||
this.$router.push('/hrm/requests')
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
if (this.approverMode === 'template' && !this.tplId) {
|
||||
return this.$message.warning('请选择一个流程模板')
|
||||
}
|
||||
if (this.approverMode === 'manual' && !this.assigneeUserId) {
|
||||
return this.$message.warning('请选择审批人')
|
||||
}
|
||||
this.submitting = true
|
||||
const payload = {
|
||||
...this.form,
|
||||
status: 'pending',
|
||||
tplId: this.tplId,
|
||||
manualAssigneeUserId: this.assigneeUserId
|
||||
}
|
||||
try {
|
||||
await addLeaveReq(payload)
|
||||
this.$message.success('提交成功')
|
||||
this.$router.push('/hrm/requests')
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -115,23 +403,102 @@ export default {
|
||||
background: #f8f9fb;
|
||||
}
|
||||
.form-card {
|
||||
max-width: 720px;
|
||||
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: 600;
|
||||
font-weight: 700;
|
||||
color: #2b2f36;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.metal-form { padding-right: 8px; }
|
||||
.block-title {
|
||||
margin: 20px 0 12px;
|
||||
padding-left: 10px;
|
||||
font-weight: 700;
|
||||
color: #2f3440;
|
||||
border-left: 3px solid #9aa3b2;
|
||||
}
|
||||
.hint-text {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.form-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 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; }
|
||||
.approve-mode {
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
.approve-panel { margin-top: 12px; }
|
||||
.approve-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.approve-row .k { font-size: 14px; color: #606266; }
|
||||
.flow-preview {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
.flow-title { font-weight: 800; color: #2b2f36; }
|
||||
.flow-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
|
||||
.flow-steps {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e6e8ed;
|
||||
background: #fff;
|
||||
}
|
||||
.flow-step .dot { width: 8px; height: 8px; border-radius: 50%; background: #9aa3b2; }
|
||||
.flow-step .dot.success { background: #67c23a; }
|
||||
.flow-step .txt { font-size: 12px; color: #2b2f36; font-weight: 600; }
|
||||
.flow-steps .line { width: 26px; height: 1px; background: #e6e8ed; }
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.summary-right { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
686
klp-ui/src/views/hrm/requests/leaveDetail.vue
Normal file
686
klp-ui/src/views/hrm/requests/leaveDetail.vue
Normal file
@@ -0,0 +1,686 @@
|
||||
<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.empName || '-' }}<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.operatorName || '系统' }}</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: {
|
||||
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.userId
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadDetail()
|
||||
},
|
||||
computed: {
|
||||
currentBizId() {
|
||||
return this.bizId || this.$route?.params?.bizId || this.$route?.query?.bizId || this.$route?.params?.id
|
||||
}
|
||||
},
|
||||
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>
|
||||
465
klp-ui/src/views/hrm/requests/reimburse.vue
Normal file
465
klp-ui/src/views/hrm/requests/reimburse.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div class="request-page">
|
||||
<el-card class="form-card" shadow="never">
|
||||
<div slot="header" class="card-header">
|
||||
<span>日常报销申请</span>
|
||||
<div class="actions">
|
||||
<el-button size="mini" icon="el-icon-arrow-left" @click="$router.back()">返回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" size="small" class="metal-form">
|
||||
<div class="form-summary">
|
||||
<div class="summary-left">
|
||||
<div class="summary-title">发起日常报销</div>
|
||||
<div class="summary-sub">请完善信息后提交,系统将按流程节点流转</div>
|
||||
</div>
|
||||
<div class="summary-right">
|
||||
<div class="summary-item">
|
||||
<div class="k">申请人</div>
|
||||
<div class="v">{{ currentApplicantText }}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="k">报销金额</div>
|
||||
<div class="v">{{ form.totalAmount != null ? '¥' + form.totalAmount : '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="报销类型" prop="reimburseType">
|
||||
<el-select v-model="form.reimburseType" filterable allow-create default-first-option clearable placeholder="选择或输入(如:差旅费/招待费/办公费)" style="width: 100%">
|
||||
<el-option v-for="t in reimburseTypeOptions" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="报销总金额" prop="totalAmount">
|
||||
<el-input-number v-model="form.totalAmount" :min="0" :step="100" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="报销事由" prop="reason">
|
||||
<el-input v-model="form.reason" type="textarea" :rows="4" placeholder="请说明报销事由、费用用途等" show-word-limit maxlength="500" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="报销单据附件" prop="accessoryApplyIds">
|
||||
<file-upload
|
||||
v-model="form.accessoryApplyIds"
|
||||
:limit="10"
|
||||
:file-size="50"
|
||||
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']"
|
||||
multiple
|
||||
/>
|
||||
<div class="hint-text">上传发票、收据、付款截图等(必填)</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="回执附件(可选)" prop="accessoryReceiptIds">
|
||||
<file-upload
|
||||
v-model="form.accessoryReceiptIds"
|
||||
:limit="10"
|
||||
:file-size="50"
|
||||
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']"
|
||||
multiple
|
||||
/>
|
||||
<div class="hint-text">可选:上传回执、对账单等(归档用)</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选:补充说明" show-word-limit maxlength="200" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 审批方式(模板/自选审批人) -->
|
||||
<div class="block-title">审批方式</div>
|
||||
<div class="approve-mode">
|
||||
<el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange">
|
||||
<el-radio-button label="template">使用模板流程</el-radio-button>
|
||||
<el-radio-button label="manual">手动选择审批人(一次审批)</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<div class="approve-panel">
|
||||
<div v-if="approverMode === 'template'">
|
||||
<div class="approve-row">
|
||||
<div class="k">流程模板</div>
|
||||
<div class="v">
|
||||
<el-select
|
||||
v-model="tplId"
|
||||
size="small"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="请选择流程模板"
|
||||
style="width: 360px"
|
||||
@change="onTplChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="t in availableTpls"
|
||||
:key="t.tplId"
|
||||
:label="`${t.tplName}${t.version ? ' (v' + t.version + ')' : ''}`"
|
||||
:value="t.tplId"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-text">提示:选择模板后,将按模板节点自动流转(含抄送节点)。</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="approve-row">
|
||||
<div class="k">审批人</div>
|
||||
<div class="v" style="max-width: 520px">
|
||||
<el-button size="mini" type="primary" plain @click="openUserSelect">选择审批人</el-button>
|
||||
<span style="margin-left: 10px; font-weight: 600; color: #2b2f36">
|
||||
{{ assigneeUserName || '未选择' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-text">提示:手动选择审批人将创建一次性审批流程,审批通过后流程立即结束。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-preview" v-loading="flowLoading">
|
||||
<div class="flow-title">流程预览</div>
|
||||
<div class="flow-sub">
|
||||
<template v-if="approverMode === 'template'">
|
||||
<span v-if="flowTpl">当前模板:{{ flowTpl.tplName }}(v{{ flowTpl.version || 1 }})</span>
|
||||
<span v-else>请选择流程模板</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>一次性审批(手动指定审批人)</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 模板模式 -->
|
||||
<div v-if="approverMode === 'template'">
|
||||
<div v-if="flowNodes && flowNodes.length" class="flow-steps">
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">提交</div></div>
|
||||
<template v-for="(n, idx) in flowNodes">
|
||||
<div :key="`line-${n.nodeId || idx}`" class="line"></div>
|
||||
<div :key="`node-${n.nodeId || idx}`" class="flow-step">
|
||||
<div class="dot" :class="{ success: idx === flowNodes.length - 1 }"></div>
|
||||
<div class="txt">{{ nodePreviewText(n, idx) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flow-fallback">
|
||||
<div class="hint-text">提示:请选择一个模板后将展示对应节点预览。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动审批模式 -->
|
||||
<div v-else class="flow-steps">
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">提交审批({{ assigneeUserName || '请选择' }})</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step"><div class="dot success"></div><div class="txt">审批结束</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button @click="$router.back()">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">提交申请</el-button>
|
||||
</div>
|
||||
|
||||
<UserSelect ref="userSelect" @onSelected="onUserSelected" />
|
||||
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { addReimburseReq } from '@/api/hrm'
|
||||
import { listFlowTemplate, listFlowNode } from '@/api/hrm/flow'
|
||||
import { getEmployeeByUserId } from '@/api/hrm/employee'
|
||||
import FileUpload from '@/components/FileUpload'
|
||||
import UserSelect from '@/components/userSelect/single.vue'
|
||||
|
||||
export default {
|
||||
name: 'HrmReimburseRequest',
|
||||
components: { FileUpload, UserSelect },
|
||||
data() {
|
||||
return {
|
||||
currentEmp: null,
|
||||
submitting: false,
|
||||
flowLoading: false,
|
||||
flowTpl: null,
|
||||
flowNodes: [],
|
||||
approverMode: 'template',
|
||||
availableTpls: [],
|
||||
tplId: null,
|
||||
assigneeUserId: null,
|
||||
assigneeUserName: '',
|
||||
reimburseTypeOptions: ['差旅费', '招待费', '办公费', '交通费', '通讯费', '其他'],
|
||||
form: {
|
||||
empId: '',
|
||||
reimburseType: '',
|
||||
totalAmount: 0,
|
||||
reason: '',
|
||||
accessoryApplyIds: '',
|
||||
accessoryReceiptIds: '',
|
||||
remark: ''
|
||||
},
|
||||
rules: {
|
||||
reimburseType: [{ required: true, message: '请选择/输入报销类型', trigger: 'change' }],
|
||||
totalAmount: [{ required: true, message: '请填写报销总金额', trigger: 'blur' }],
|
||||
reason: [{ required: true, message: '请填写报销事由', trigger: 'blur' }],
|
||||
accessoryApplyIds: [{ required: true, message: '请上传报销单据附件', trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadCurrentEmployee()
|
||||
this.loadTemplates()
|
||||
},
|
||||
computed: {
|
||||
currentApplicantText() {
|
||||
if (this.currentEmp) {
|
||||
const emp = this.currentEmp
|
||||
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()
|
||||
}
|
||||
const user = this.$store?.state?.user || {}
|
||||
return user.nickName || user.userName || '加载中...'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadTemplates() {
|
||||
try {
|
||||
const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'reimburse', enabled: 1 })
|
||||
this.availableTpls = res.rows || res.data || []
|
||||
if (!this.tplId && this.availableTpls.length) {
|
||||
this.tplId = this.availableTpls[0].tplId
|
||||
}
|
||||
await this.refreshFlowPreview()
|
||||
} catch (err) {
|
||||
this.availableTpls = []
|
||||
}
|
||||
},
|
||||
async refreshFlowPreview() {
|
||||
this.flowLoading = true
|
||||
try {
|
||||
if (this.approverMode === 'manual') {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
if (!this.tplId) {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null
|
||||
const nodeRes = await listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.tplId })
|
||||
this.flowNodes = (nodeRes.rows || nodeRes.data || []).filter(n => n.nodeType !== 'end').sort((a, b) => (a.orderNo || 0) - (b.orderNo || 0))
|
||||
} finally {
|
||||
this.flowLoading = false
|
||||
}
|
||||
},
|
||||
nodePreviewText(n, idx) {
|
||||
const typeMap = { approve: '审批', cc: '抄送' }
|
||||
const ruleMap = { fixed_user: '指定人员', role: '指定角色', position: '指定岗位', leader: '直属上级' }
|
||||
const nodeType = typeMap[n.nodeType] || '节点'
|
||||
const rule = ruleMap[n.approverRule] || '规则'
|
||||
let detail = ''
|
||||
try {
|
||||
const arr = Array.isArray(n.approverValue) ? n.approverValue : JSON.parse(n.approverValue || '[]')
|
||||
if (arr.length) detail = `:${arr.join('、')}`
|
||||
} catch (e) { detail = n.approverValue ? `:${n.approverValue}` : '' }
|
||||
const text = `${nodeType}(${rule}${detail})`
|
||||
return idx === this.flowNodes.length - 1 ? `${text} → 结束` : text
|
||||
},
|
||||
normalizeOssIds(val) {
|
||||
if (!val) return ''
|
||||
if (typeof val === 'string') return val
|
||||
if (Array.isArray(val)) {
|
||||
const ids = val.map(x => (x && typeof x === 'object') ? (x.ossId ?? x.id ?? x.value) : x).filter(Boolean)
|
||||
return ids.join(',')
|
||||
}
|
||||
return String(val)
|
||||
},
|
||||
async loadCurrentEmployee() {
|
||||
const userId = this.$store?.state?.user?.id
|
||||
if (!userId) {
|
||||
this.$message.error('无法获取当前用户信息,请重新登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getEmployeeByUserId(userId)
|
||||
if (res.code === 200 && res.data) {
|
||||
this.currentEmp = res.data
|
||||
this.form.empId = res.data.empId
|
||||
} else {
|
||||
this.$message.error('未找到当前用户对应的员工信息,请在员工管理中关联系统用户')
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('加载员工信息失败')
|
||||
}
|
||||
},
|
||||
async onTplChange(val) {
|
||||
this.tplId = val
|
||||
await this.refreshFlowPreview()
|
||||
},
|
||||
onApproverModeChange(val) {
|
||||
this.approverMode = val
|
||||
if (val === 'manual') this.tplId = null
|
||||
this.refreshFlowPreview()
|
||||
},
|
||||
openUserSelect() {
|
||||
this.$refs.userSelect.open()
|
||||
},
|
||||
onUserSelected(row) {
|
||||
if (row) {
|
||||
this.assigneeUserId = row.userId
|
||||
this.assigneeUserName = row.nickName || row.userName || row.userId
|
||||
this.refreshFlowPreview()
|
||||
}
|
||||
},
|
||||
async submit() {
|
||||
try {
|
||||
await this.$refs.formRef.validate()
|
||||
if (this.approverMode === 'template' && !this.tplId) {
|
||||
return this.$message.warning('请选择一个流程模板')
|
||||
}
|
||||
if (this.approverMode === 'manual' && !this.assigneeUserId) {
|
||||
return this.$message.warning('请选择审批人')
|
||||
}
|
||||
|
||||
this.submitting = true
|
||||
const payload = {
|
||||
empId: this.form.empId,
|
||||
reimburseType: this.form.reimburseType,
|
||||
totalAmount: this.form.totalAmount,
|
||||
reason: this.form.reason,
|
||||
accessoryApplyIds: this.normalizeOssIds(this.form.accessoryApplyIds),
|
||||
accessoryReceiptIds: this.normalizeOssIds(this.form.accessoryReceiptIds),
|
||||
remark: this.form.remark,
|
||||
status: 'pending',
|
||||
tplId: this.tplId,
|
||||
manualAssigneeUserId: this.assigneeUserId
|
||||
}
|
||||
await addReimburseReq(payload)
|
||||
this.$message.success('提交成功')
|
||||
this.$router.push('/hrm/requests')
|
||||
} catch (err) {
|
||||
// no-op
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</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;
|
||||
}
|
||||
.metal-form { padding-right: 8px; }
|
||||
.hint-text {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.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; }
|
||||
.flow-preview {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
.flow-title { font-weight: 800; color: #2b2f36; }
|
||||
.flow-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
|
||||
.flow-steps {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e6e8ed;
|
||||
background: #fff;
|
||||
}
|
||||
.flow-step .dot { width: 8px; height: 8px; border-radius: 50%; background: #9aa3b2; }
|
||||
.flow-step .dot.success { background: #67c23a; }
|
||||
.flow-step .txt { font-size: 12px; color: #2b2f36; font-weight: 600; }
|
||||
.flow-steps .line { width: 26px; height: 1px; background: #e6e8ed; }
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.summary-right { display: none; }
|
||||
}
|
||||
.block-title {
|
||||
margin: 20px 0 12px;
|
||||
padding-left: 10px;
|
||||
font-weight: 700;
|
||||
color: #2f3440;
|
||||
border-left: 3px solid #9aa3b2;
|
||||
}
|
||||
.approve-mode {
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
.approve-panel { margin-top: 12px; }
|
||||
.approve-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.approve-row .k { font-size: 14px; color: #606266; }
|
||||
</style>
|
||||
598
klp-ui/src/views/hrm/requests/reimburseDetail.vue
Normal file
598
klp-ui/src/views/hrm/requests/reimburseDetail.vue
Normal file
@@ -0,0 +1,598 @@
|
||||
<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>
|
||||
|
||||
@@ -7,99 +7,395 @@
|
||||
<el-button size="mini" icon="el-icon-arrow-left" @click="$router.back()">返回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" size="small">
|
||||
<el-form-item label="申请人" prop="empId">
|
||||
<el-select v-model="form.empId" filterable placeholder="选择申请人" style="width: 100%">
|
||||
<el-option
|
||||
v-for="emp in employees"
|
||||
:key="emp.empId"
|
||||
:label="emp.empName || emp.empNo || emp.empId"
|
||||
:value="emp.empId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="用印类型" prop="sealType">
|
||||
<el-input v-model="form.sealType" placeholder="合同章/法人章/财务章等" />
|
||||
</el-form-item>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" size="small" class="metal-form">
|
||||
<!-- 顶部摘要 -->
|
||||
<div class="form-summary">
|
||||
<div class="summary-left">
|
||||
<div class="summary-title">发起用印</div>
|
||||
<div class="summary-sub">请完善信息后提交,系统将按流程节点流转</div>
|
||||
</div>
|
||||
<div class="summary-right">
|
||||
<div class="summary-item">
|
||||
<div class="k">申请人</div>
|
||||
<div class="v">{{ currentApplicantText }}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="k">用印类型</div>
|
||||
<div class="v">{{ form.sealType || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用印类型" prop="sealType">
|
||||
<el-select
|
||||
v-model="form.sealType"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder="选择或输入用印类型"
|
||||
style="width: 100%"
|
||||
|
||||
>
|
||||
<el-option
|
||||
v-for="dictItem in (dict.type.hrm_stamp_image || [])"
|
||||
:key="dictItem.value"
|
||||
:label="dictItem.label"
|
||||
:value="dictItem.label"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="hint-text">优先从字典选择;若字典未配置,可直接输入</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="block-title">用途与材料</div>
|
||||
<el-form-item label="用途说明" prop="purpose">
|
||||
<el-input v-model="form.purpose" type="textarea" :rows="3" placeholder="填写用印用途与背景" />
|
||||
<el-input v-model="form.purpose" type="textarea" :rows="4" placeholder="请说明用印用途、对象、份数、是否对外、是否加急等" show-word-limit maxlength="200" />
|
||||
</el-form-item>
|
||||
<el-form-item label="申请材料附件" prop="applyFileIds">
|
||||
|
||||
<el-form-item label="待盖章PDF" prop="applyFileIds">
|
||||
<file-upload
|
||||
v-model="form.applyFileIds"
|
||||
:limit="5"
|
||||
:limit="1"
|
||||
:file-size="50"
|
||||
:file-type="['pdf']"
|
||||
/>
|
||||
<div class="hint-text">仅支持 PDF,最多 5 个,单个不超过 50MB</div>
|
||||
<div class="hint-text">仅支持 PDF,单个文件不超过 50MB(请上传需要盖章的PDF文件)</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="需要回执">
|
||||
<el-switch v-model="form.receiptRequired" :active-value="1" :inactive-value="0" />
|
||||
|
||||
<el-form-item label="盖章页码" prop="pageNo" v-if="form.applyFileIds">
|
||||
<el-input-number
|
||||
v-model="form.pageNo"
|
||||
:min="1"
|
||||
:max="999"
|
||||
controls-position="right"
|
||||
placeholder="请输入需要盖章的页码(从第1页开始)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="hint-text">请输入需要盖章的页码,从第1页开始计数</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="需要回执" prop="receiptRequired">
|
||||
<el-switch v-model="form.receiptRequired" :active-value="1" :inactive-value="0" />
|
||||
<div class="hint-text">开启后,盖章完成通常需要回传扫描件/回执文件</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="紧急程度" prop="urgentLevel">
|
||||
<el-radio-group v-model="form.urgentLevel" size="small">
|
||||
<el-radio-button label="normal">普通</el-radio-button>
|
||||
<el-radio-button label="urgent">紧急</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="hint-text">仅影响审批提醒强度,不改变流程规则</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选" />
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选:补充说明、材料说明等" show-word-limit maxlength="200" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 审批方式(模板/自选审批人) -->
|
||||
<div class="block-title">审批方式</div>
|
||||
<div class="approve-mode">
|
||||
<el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange">
|
||||
<el-radio-button label="template">使用模板流程</el-radio-button>
|
||||
<el-radio-button label="manual">手动选择审批人(一次审批)</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<div class="approve-panel">
|
||||
<div v-if="approverMode === 'template'">
|
||||
<div class="approve-row">
|
||||
<div class="k">流程模板</div>
|
||||
<div class="v">
|
||||
<el-select
|
||||
v-model="tplId"
|
||||
size="small"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="请选择流程模板"
|
||||
style="width: 360px"
|
||||
@change="onTplChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="t in availableTpls"
|
||||
:key="t.tplId"
|
||||
:label="`${t.tplName}${t.version ? ' (v' + t.version + ')' : ''}`"
|
||||
:value="t.tplId"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-text">提示:选择模板后,将按模板节点自动流转(含抄送节点)。</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="approve-row">
|
||||
<div class="k">审批人</div>
|
||||
<div class="v" style="max-width: 520px">
|
||||
<el-button size="mini" type="primary" plain @click="openUserSelect">选择审批人</el-button>
|
||||
<span style="margin-left: 10px; font-weight: 600; color: #2b2f36">
|
||||
{{ assigneeUserName || '未选择' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-text">提示:手动选择审批人将创建一次性审批流程,审批通过后流程立即结束。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交流程提示:按"真实节点配置"预览 -->
|
||||
<div class="flow-preview" v-loading="flowLoading">
|
||||
<div class="flow-title">流程预览</div>
|
||||
<div class="flow-sub">
|
||||
<template v-if="approverMode === 'template'">
|
||||
<span v-if="flowTpl">当前模板:{{ flowTpl.tplName }}(v{{ flowTpl.version || 1 }})</span>
|
||||
<span v-else>请选择流程模板</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>一次性审批(手动指定审批人)</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 模板模式 -->
|
||||
<div v-if="approverMode === 'template'">
|
||||
<div v-if="flowNodes && flowNodes.length" class="flow-steps">
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">提交</div></div>
|
||||
<template v-for="(n, idx) in flowNodes">
|
||||
<div :key="`line-${n.nodeId || idx}`" class="line"></div>
|
||||
<div :key="`node-${n.nodeId || idx}`" class="flow-step">
|
||||
<div class="dot" :class="{ success: idx === flowNodes.length - 1 }"></div>
|
||||
<div class="txt">{{ nodePreviewText(n, idx) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flow-fallback">
|
||||
<div class="hint-text">提示:请选择一个模板后将展示对应节点预览。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动审批模式 -->
|
||||
<div v-else class="flow-steps">
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">提交审批({{ assigneeUserName || '请选择' }})</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step"><div class="dot success"></div><div class="txt">审批结束</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button @click="$router.back()">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">提交申请</el-button>
|
||||
</div>
|
||||
|
||||
<UserSelect ref="userSelect" @onSelected="onUserSelected" />
|
||||
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listEmployee, addSealReq } from '@/api/hrm'
|
||||
import { addSealReq } from '@/api/hrm'
|
||||
import { listFlowTemplate, listFlowNode } from '@/api/hrm/flow'
|
||||
import { getEmployeeByUserId } from '@/api/hrm/employee'
|
||||
import FileUpload from '@/components/FileUpload'
|
||||
import UserSelect from '@/components/userSelect/single.vue'
|
||||
|
||||
export default {
|
||||
name: 'HrmSealRequest',
|
||||
components: { FileUpload },
|
||||
dicts: ['hrm_stamp_image'],
|
||||
components: { FileUpload, UserSelect },
|
||||
data() {
|
||||
return {
|
||||
employees: [],
|
||||
currentEmp: null,
|
||||
submitting: false,
|
||||
flowLoading: false,
|
||||
flowTpl: null,
|
||||
flowNodes: [],
|
||||
approverMode: 'template',
|
||||
availableTpls: [],
|
||||
tplId: null,
|
||||
assigneeUserId: null,
|
||||
assigneeUserName: '',
|
||||
|
||||
form: {
|
||||
empId: '',
|
||||
sealType: '',
|
||||
purpose: '',
|
||||
applyFileIds: '',
|
||||
pageNo: 1,
|
||||
receiptRequired: 0,
|
||||
urgentLevel: 'normal',
|
||||
remark: ''
|
||||
},
|
||||
rules: {
|
||||
empId: [{ required: true, message: '请选择申请人', trigger: 'change' }],
|
||||
sealType: [{ required: true, message: '请输入用印类型', trigger: 'blur' }],
|
||||
purpose: [{ required: true, message: '请输入用途说明', trigger: 'blur' }],
|
||||
applyFileIds: [{ required: true, message: '请上传 PDF 附件', trigger: 'change' }]
|
||||
sealType: [{ required: true, message: '请选择/输入用印类型', trigger: 'change' }],
|
||||
purpose: [{ required: true, message: '请填写用途说明', trigger: 'blur' }],
|
||||
applyFileIds: [{ required: true, message: '请上传 PDF 附件', trigger: 'change' }],
|
||||
pageNo: [{ required: true, message: '请输入盖章页码', trigger: 'blur' }],
|
||||
receiptRequired: [{ required: true, message: '请选择是否需要回执', trigger: 'change' }],
|
||||
urgentLevel: [{ required: true, message: '请选择紧急程度', trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadEmployees()
|
||||
const userId = this.$store?.state?.user?.userId
|
||||
if (userId) this.form.empId = userId
|
||||
this.loadCurrentEmployee()
|
||||
this.loadTemplates()
|
||||
},
|
||||
computed: {
|
||||
currentApplicantText() {
|
||||
if (this.currentEmp) {
|
||||
return this.formatEmpLabel(this.currentEmp)
|
||||
}
|
||||
const user = this.$store?.state?.user || {}
|
||||
return user.nickName || user.userName || '加载中...'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadEmployees() {
|
||||
listEmployee({ pageNum: 1, pageSize: 500 }).then(res => {
|
||||
this.employees = res.rows || res.data || []
|
||||
})
|
||||
async loadTemplates() {
|
||||
try {
|
||||
const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'seal', enabled: 1 })
|
||||
this.availableTpls = res.rows || res.data || []
|
||||
if (!this.tplId && this.availableTpls.length) {
|
||||
this.tplId = this.availableTpls[0].tplId
|
||||
}
|
||||
await this.refreshFlowPreview()
|
||||
} catch (err) {
|
||||
this.availableTpls = []
|
||||
}
|
||||
},
|
||||
submit() {
|
||||
this.$refs.formRef.validate(valid => {
|
||||
if (!valid) return
|
||||
async refreshFlowPreview() {
|
||||
this.flowLoading = true
|
||||
try {
|
||||
if (this.approverMode === 'manual') {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
if (!this.tplId) {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null
|
||||
const nodeRes = await listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.tplId })
|
||||
this.flowNodes = (nodeRes.rows || nodeRes.data || []).filter(n => n.nodeType !== 'end').sort((a, b) => (a.orderNo || 0) - (b.orderNo || 0))
|
||||
} finally {
|
||||
this.flowLoading = false
|
||||
}
|
||||
},
|
||||
async onTplChange(val) {
|
||||
this.tplId = val
|
||||
await this.refreshFlowPreview()
|
||||
},
|
||||
onApproverModeChange(val) {
|
||||
this.approverMode = val
|
||||
if (val === 'manual') this.tplId = null
|
||||
this.refreshFlowPreview()
|
||||
},
|
||||
openUserSelect() {
|
||||
this.$refs.userSelect.open()
|
||||
},
|
||||
normalizeUserId(val) {
|
||||
if (val === undefined || val === null || val === '') return null
|
||||
// 兼容:"sys_user:1" / "user:1" / "1" / 1
|
||||
const s = String(val)
|
||||
const parts = s.split(':')
|
||||
const last = parts[parts.length - 1]
|
||||
const num = Number(last)
|
||||
return Number.isNaN(num) ? null : num
|
||||
},
|
||||
|
||||
onUserSelected(row) {
|
||||
if (row) {
|
||||
// 兼容 userId 可能为 "sys_user:1" 的情况
|
||||
this.assigneeUserId = this.normalizeUserId(row.userId)
|
||||
this.assigneeUserName = row.nickName || row.userName || String(row.userId)
|
||||
this.refreshFlowPreview()
|
||||
}
|
||||
},
|
||||
formatEmpLabel(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()
|
||||
},
|
||||
nodePreviewText(n, idx) {
|
||||
const typeMap = { approve: '审批', cc: '抄送' }
|
||||
const ruleMap = { fixed_user: '指定人员', role: '指定角色', position: '指定岗位', leader: '直属上级' }
|
||||
const nodeType = typeMap[n.nodeType] || '节点'
|
||||
const rule = ruleMap[n.approverRule] || '规则'
|
||||
let detail = ''
|
||||
try {
|
||||
const arr = Array.isArray(n.approverValue) ? n.approverValue : JSON.parse(n.approverValue || '[]')
|
||||
if (arr.length) detail = `:${arr.join('、')}`
|
||||
} catch (e) { detail = n.approverValue ? `:${n.approverValue}` : '' }
|
||||
const text = `${nodeType}(${rule}${detail})`
|
||||
return idx === this.flowNodes.length - 1 ? `${text} → 结束` : text
|
||||
},
|
||||
async loadCurrentEmployee() {
|
||||
const userId = this.$store?.state?.user?.id
|
||||
if (!userId) {
|
||||
this.$message.error('无法获取当前用户信息,请重新登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getEmployeeByUserId(userId)
|
||||
if (res.code === 200 && res.data) {
|
||||
this.currentEmp = res.data
|
||||
this.form.empId = res.data.empId
|
||||
} else {
|
||||
this.$message.error('未找到当前用户对应的员工信息,请在员工管理中关联系统用户')
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error('加载员工信息失败,请稍后重试')
|
||||
}
|
||||
},
|
||||
async submit() {
|
||||
try {
|
||||
await this.$refs.formRef.validate()
|
||||
if (this.approverMode === 'template' && !this.tplId) {
|
||||
return this.$message.warning('请选择一个流程模板')
|
||||
}
|
||||
if (this.approverMode === 'manual' && !this.assigneeUserId) {
|
||||
return this.$message.warning('请选择审批人')
|
||||
}
|
||||
|
||||
this.submitting = true
|
||||
const payload = { ...this.form }
|
||||
addSealReq(payload)
|
||||
.then(() => {
|
||||
this.$message.success('提交成功')
|
||||
this.$router.push('/hrm/requests')
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
})
|
||||
|
||||
let remark = this.form.remark || ''
|
||||
if (this.form.pageNo) {
|
||||
remark = remark ? `${remark}\n[盖章页码:第${this.form.pageNo}页]` : `[盖章页码:第${this.form.pageNo}页]`
|
||||
}
|
||||
|
||||
const payload = {
|
||||
empId: this.form.empId,
|
||||
sealType: this.form.sealType,
|
||||
purpose: this.form.purpose,
|
||||
applyFileIds: this.form.applyFileIds,
|
||||
receiptRequired: this.form.receiptRequired,
|
||||
urgentLevel: this.form.urgentLevel,
|
||||
remark,
|
||||
status: 'pending',
|
||||
tplId: this.tplId,
|
||||
manualAssigneeUserId: this.normalizeUserId(this.assigneeUserId)
|
||||
}
|
||||
|
||||
await addSealReq(payload)
|
||||
this.$message.success('提交成功')
|
||||
this.$router.push('/hrm/requests')
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,23 +407,98 @@ export default {
|
||||
background: #f8f9fb;
|
||||
}
|
||||
.form-card {
|
||||
max-width: 720px;
|
||||
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: 600;
|
||||
font-weight: 700;
|
||||
color: #2b2f36;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.metal-form { padding-right: 8px; }
|
||||
.block-title {
|
||||
margin: 20px 0 12px;
|
||||
padding-left: 10px;
|
||||
font-weight: 700;
|
||||
color: #2f3440;
|
||||
border-left: 3px solid #9aa3b2;
|
||||
}
|
||||
.hint-text {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.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; }
|
||||
.approve-mode {
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
.approve-panel { margin-top: 12px; }
|
||||
.approve-row { display: flex; align-items: center; gap: 12px; }
|
||||
.approve-row .k { font-size: 14px; color: #606266; }
|
||||
.flow-preview {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
.flow-title { font-weight: 800; color: #2b2f36; }
|
||||
.flow-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
|
||||
.flow-steps {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e6e8ed;
|
||||
background: #fff;
|
||||
}
|
||||
.flow-step .dot { width: 8px; height: 8px; border-radius: 50%; background: #9aa3b2; }
|
||||
.flow-step .dot.success { background: #67c23a; }
|
||||
.flow-step .txt { font-size: 12px; color: #2b2f36; font-weight: 600; }
|
||||
.flow-steps .line { width: 26px; height: 1px; background: #e6e8ed; }
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.summary-right { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
992
klp-ui/src/views/hrm/requests/sealDetail.vue
Normal file
992
klp-ui/src/views/hrm/requests/sealDetail.vue
Normal file
@@ -0,0 +1,992 @@
|
||||
<template>
|
||||
<div class="request-page">
|
||||
<el-card class="form-card" shadow="never">
|
||||
<div slot="header" class="card-header">
|
||||
<span>用印详情</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-summary" v-loading="loading">
|
||||
<div class="summary-left">
|
||||
<div class="summary-title">{{ seal.sealType || '用印申请' }}</div>
|
||||
<div class="summary-sub">申请编号:{{ seal.bizId || '-' }} · 状态:<el-tag size="mini" :type="statusType(seal.status)">{{ statusText(seal.status) }}</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">{{ seal.receiptRequired === 1 ? '是' : '否' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流程状态 -->
|
||||
<div class="block-title">流程状态</div>
|
||||
<el-card class="inner-card" shadow="never" v-loading="actionLoading">
|
||||
<div v-if="flowInstance" class="flow-status">
|
||||
<div class="status-item">
|
||||
<div class="status-label">流程状态</div>
|
||||
<div class="status-value">
|
||||
<el-tag :type="statusType(flowInstance.status)" size="small">
|
||||
{{ statusText(flowInstance.status) }}
|
||||
</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-descriptions :column="2" border size="small" v-loading="loading">
|
||||
<el-descriptions-item label="用印类型">{{ seal.sealType || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="申请人">{{ applicantText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用途说明" :span="2">{{ seal.purpose || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="盖章页码">
|
||||
<span v-if="stampForm.pageNo" class="page-no-text">第 {{ stampForm.pageNo }} 页</span>
|
||||
<span v-else class="text-muted">未指定</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="需要回执">{{ seal.receiptRequired === 1 ? '是' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ seal.remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="block-title">申请材料附件</div>
|
||||
<el-card class="inner-card" shadow="never" v-loading="attachmentLoading">
|
||||
<!-- 回执文件(盖章后生成) -->
|
||||
<div v-if="seal.receiptFileIds" class="receipt-panel">
|
||||
<div class="receipt-title">回执文件</div>
|
||||
<div class="receipt-actions">
|
||||
<el-button size="mini" type="primary" plain @click="previewReceipt">预览回执</el-button>
|
||||
<el-button size="mini" type="success" plain @click="downloadReceipt">下载回执</el-button>
|
||||
</div>
|
||||
<div class="hint-text" style="margin: 6px 0 0;">说明:回执文件为盖章后生成的新PDF,当前后端以 URL 形式存储在 receiptFileIds 字段。</div>
|
||||
</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 v-if="canStamp" class="block-title">盖章操作</div>
|
||||
<el-card v-if="canStamp" class="inner-card" shadow="never">
|
||||
<div class="stamp-section">
|
||||
<div class="stamp-config">
|
||||
<el-form :model="stampForm" label-width="120px" size="small">
|
||||
<el-form-item label="待盖章PDF">
|
||||
<div v-if="targetPdfFile" class="pdf-file-info">
|
||||
<i class="el-icon-document"></i>
|
||||
<span>{{ targetPdfFile.originalName || targetPdfFile.fileName || 'PDF文件' }}</span>
|
||||
<el-button size="mini" type="text" @click="openPdfPreview">预览PDF</el-button>
|
||||
</div>
|
||||
<div v-else class="empty">未找到待盖章PDF文件</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="盖章页码">
|
||||
<el-input-number
|
||||
v-model="stampForm.pageNo"
|
||||
:min="1"
|
||||
:max="999"
|
||||
controls-position="right"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<div class="hint-text">从第1页开始计数</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择印章" prop="stampImageUrl">
|
||||
<el-select v-model="stampForm.stampImageUrl" placeholder="选择印章" filterable style="width: 100%">
|
||||
<el-option
|
||||
v-for="dict in dict.type.hrm_stamp_image"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<img v-if="dict.value" :src="dict.value" style="width: 40px; height: 40px; object-fit: contain; border: 1px solid #e6e8ed;" onerror="this.style.display='none'" />
|
||||
<span>{{ dict.label }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div class="hint-text">从字典 hrm_stamp_image 加载印章列表(label为章名,value为URL)</div>
|
||||
<div v-if="!dict.type.hrm_stamp_image || dict.type.hrm_stamp_image.length === 0" class="hint-text" style="color: #e6a23c;">
|
||||
提示:未找到印章配置,请在系统字典中配置 hrm_stamp_image 字典类型
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="印章图片URL" v-if="!stampForm.stampImageUrl || !dict.type.hrm_stamp_image || dict.type.hrm_stamp_image.length === 0">
|
||||
<el-input
|
||||
v-model="stampForm.stampImageUrl"
|
||||
placeholder="手动输入印章图片的完整OSS URL(如:https://oss.example.com/stamp/seal.png)"
|
||||
/>
|
||||
<div class="hint-text">如果下拉列表中没有印章,可手动输入印章图片的完整URL</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="盖章位置">
|
||||
<div class="position-hint">
|
||||
<el-button size="mini" type="primary" icon="el-icon-view" @click="openPositionSelector">预览PDF并点击选择位置</el-button>
|
||||
<span class="hint-text">在预览中点击PDF即可自动回填坐标</span>
|
||||
</div>
|
||||
<div class="hint-text" style="margin-top: 4px; color: #606266;">
|
||||
坐标说明:左下角为原点(0,0),X向右为正,Y向上为正,单位为像素(px)
|
||||
</div>
|
||||
<el-row :gutter="12" style="margin-top: 12px;">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="X坐标" label-width="80px">
|
||||
<el-input-number
|
||||
v-model="stampForm.xPx"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
placeholder="点击PDF自动获取(可微调)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Y坐标" label-width="80px">
|
||||
<el-input-number
|
||||
v-model="stampForm.yPx"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
placeholder="点击PDF自动获取(可微调)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="宽度" label-width="80px">
|
||||
<el-input-number
|
||||
v-model="stampForm.widthPx"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
placeholder="宽度(px,可选)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="高度" label-width="80px">
|
||||
<el-input-number
|
||||
v-model="stampForm.heightPx"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
placeholder="高度(px,可选)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="hint-text" style="margin-top: 4px;">
|
||||
提示:宽度和高度为可选,不填写时使用印章图片的原始尺寸
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="stamping" @click="doStamp">执行盖章</el-button>
|
||||
<el-button @click="resetStampForm">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div class="block-title">流转历史</div>
|
||||
<el-card class="inner-card" shadow="never" v-loading="actionLoading">
|
||||
<el-timeline v-if="actionList.length">
|
||||
<el-timeline-item
|
||||
v-for="(a, idx) in actionList"
|
||||
:key="idx"
|
||||
:timestamp="formatDate(a.createTime)"
|
||||
:type="actionType(a.action)"
|
||||
>
|
||||
<div class="timeline-row">
|
||||
<div class="t-main">
|
||||
<span class="t-action">{{ actionText(a.action) }}</span>
|
||||
<span class="t-user">· 办理人:{{ a.actionUserId || '-' }}</span>
|
||||
</div>
|
||||
<div class="t-remark" v-if="a.remark">{{ a.remark }}</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<div v-else class="empty">暂无流转记录</div>
|
||||
</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" :loading="actionSubmitting" @click="submitTaskAction('approve')">通过</el-button>
|
||||
<el-button type="danger" :loading="actionSubmitting" @click="submitTaskAction('reject')">驳回</el-button>
|
||||
<el-button :loading="actionSubmitting" @click="submitTaskAction('withdraw')">撤回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty">当前无待办任务(可能已处理完成,或你不是当前审批人)</div>
|
||||
</el-card>
|
||||
|
||||
</el-card>
|
||||
|
||||
<!-- PDF盖章定位对话框(点击PDF直接获取坐标) -->
|
||||
<el-dialog
|
||||
title="PDF预览 - 点击选择盖章位置"
|
||||
:visible.sync="positionSelectorVisible"
|
||||
width="1200px"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
class="pdf-preview-dialog"
|
||||
>
|
||||
<div class="pdf-preview-container">
|
||||
<div v-if="targetPdfFile && targetPdfFile.url" class="pdf-viewer">
|
||||
<div class="pdf-controls">
|
||||
<span class="hint-text" style="margin:0;">提示:在PDF上单击选择盖章位置(左下为原点),选中后会自动回填坐标</span>
|
||||
<div style="flex:1;"></div>
|
||||
<el-button size="mini" @click="openPdfPreview">在新窗口打开</el-button>
|
||||
</div>
|
||||
|
||||
<PdfStamper
|
||||
:pdf-url="targetPdfFile.url"
|
||||
:initial-page="stampForm.pageNo || 1"
|
||||
@change="onStampChange"
|
||||
/>
|
||||
|
||||
<div class="pdf-hint">
|
||||
<div class="hint-text">当前选择:页码 {{ stampForm.pageNo || '-' }},x={{ stampForm.xPx !== null ? stampForm.xPx : '-' }},y={{ stampForm.yPx !== null ? stampForm.yPx : '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty">PDF文件加载失败</div>
|
||||
</div>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="positionSelectorVisible = false">关闭</el-button>
|
||||
<el-button type="primary" :disabled="stampForm.xPx === null || stampForm.yPx === null" @click="positionSelectorVisible = false">确定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getSealReq,
|
||||
listFlowAction,
|
||||
getTodoTaskByBiz,
|
||||
approveFlowTask,
|
||||
rejectFlowTask,
|
||||
withdrawFlowTask,
|
||||
listEmployee,
|
||||
stampSealJava
|
||||
} from '@/api/hrm'
|
||||
import { queryInstanceByBiz, getFlowInstance, listFlowNode } from '@/api/hrm/flow'
|
||||
import { listByIds } from '@/api/system/oss'
|
||||
import PdfStamper from '@/components/PdfStamper/index.vue'
|
||||
|
||||
export default {
|
||||
name: 'HrmSealDetail',
|
||||
components: { PdfStamper },
|
||||
dicts: ['hrm_stamp_image'],
|
||||
props: {
|
||||
bizId: { type: [String, Number], default: null },
|
||||
embedded: { type: Boolean, default: false }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
seal: {},
|
||||
employees: [],
|
||||
loading: false,
|
||||
actionLoading: false,
|
||||
actionList: [],
|
||||
currentTask: null,
|
||||
actionRemark: '',
|
||||
actionSubmitting: false,
|
||||
attachmentList: [],
|
||||
attachmentLoading: false,
|
||||
flowInstance: null, // 流程实例信息
|
||||
flowNodes: [], // 流程节点列表
|
||||
currentNode: null, // 当前节点信息
|
||||
targetPdfFile: null,
|
||||
stampForm: {
|
||||
pageNo: 1,
|
||||
stampImageUrl: '',
|
||||
xPx: null,
|
||||
yPx: null,
|
||||
widthPx: null,
|
||||
heightPx: null,
|
||||
viewportWidth: null,
|
||||
viewportHeight: null
|
||||
},
|
||||
stamping: false,
|
||||
pdfPreviewVisible: false,
|
||||
positionSelectorVisible: false,
|
||||
selectedPosition: { x: null, y: null }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentBizId() {
|
||||
return this.bizId || this.$route?.params?.bizId || this.$route?.query?.bizId || this.$route?.params?.id
|
||||
},
|
||||
applicantText() {
|
||||
const empId = this.seal.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}` : '-'
|
||||
},
|
||||
canStamp() {
|
||||
// 审批通过后,且尚未生成回执时,可以盖章
|
||||
return this.seal.status === 'approved' && !this.seal.receiptFileIds && this.targetPdfFile && this.attachmentList.length > 0
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadEmployees()
|
||||
this.loadDetail()
|
||||
},
|
||||
methods: {
|
||||
loadEmployees() {
|
||||
listEmployee({ pageNum: 1, pageSize: 1000 }).then(res => {
|
||||
this.employees = res.rows || res.data || []
|
||||
})
|
||||
},
|
||||
statusText(status) {
|
||||
const map = { pending: '审批中', draft: '草稿', approved: '已通过', rejected: '已驳回', canceled: '已撤销' }
|
||||
return map[status] || status || '-'
|
||||
},
|
||||
statusType(status) {
|
||||
const map = { pending: 'warning', draft: 'info', approved: 'success', rejected: 'danger', canceled: 'info' }
|
||||
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: '撤销', stamp: '盖章' }
|
||||
return map[action] || action || '-'
|
||||
},
|
||||
actionType(action) {
|
||||
const map = { submit: 'primary', approve: 'success', reject: 'danger', withdraw: 'info', cancel: 'info', stamp: 'primary' }
|
||||
return map[action] || 'info'
|
||||
},
|
||||
async loadDetail() {
|
||||
const bizId = this.currentBizId
|
||||
if (!bizId) {
|
||||
this.$message.warning('缺少bizId')
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await getSealReq(bizId)
|
||||
this.seal = res.data || {}
|
||||
this.loadActionsByInstId(this.seal.instId)
|
||||
await this.loadCurrentTask()
|
||||
this.loadAttachments()
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async loadAttachments() {
|
||||
const fileIds = this.seal.applyFileIds
|
||||
if (!fileIds) {
|
||||
this.attachmentList = []
|
||||
this.targetPdfFile = null
|
||||
return
|
||||
}
|
||||
// 解析ID串(可能是逗号分隔的字符串)
|
||||
const ids = String(fileIds).split(',').map(id => id.trim()).filter(Boolean)
|
||||
if (ids.length === 0) {
|
||||
this.attachmentList = []
|
||||
this.targetPdfFile = null
|
||||
return
|
||||
}
|
||||
this.attachmentLoading = true
|
||||
try {
|
||||
const res = await listByIds(ids)
|
||||
this.attachmentList = res.data || []
|
||||
// 取第一个PDF文件作为待盖章文件
|
||||
this.targetPdfFile = this.attachmentList.find(f => f.fileName && f.fileName.toLowerCase().endsWith('.pdf')) || this.attachmentList[0] || null
|
||||
// 从备注中解析pageNo(格式:[盖章页码:第X页])
|
||||
let pageNo = null
|
||||
if (this.seal.pageNo) {
|
||||
pageNo = this.seal.pageNo
|
||||
} else if (this.seal.remark) {
|
||||
// 尝试从备注中解析:格式 "[盖章页码:第X页]"
|
||||
const match = this.seal.remark.match(/\[盖章页码:第(\d+)页\]/)
|
||||
if (match) {
|
||||
pageNo = parseInt(match[1])
|
||||
}
|
||||
}
|
||||
if (pageNo) {
|
||||
this.stampForm.pageNo = pageNo
|
||||
} else {
|
||||
// 默认第1页
|
||||
this.stampForm.pageNo = 1
|
||||
}
|
||||
// 如果有申请时的用印类型,尝试匹配对应的印章
|
||||
const sealType = this.seal.sealType
|
||||
if (sealType && this.dict.type.hrm_stamp_image && this.dict.type.hrm_stamp_image.length > 0) {
|
||||
const matched = this.dict.type.hrm_stamp_image.find(d => d.label === sealType || d.label.includes(sealType))
|
||||
if (matched) {
|
||||
this.stampForm.stampImageUrl = matched.value
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('加载附件失败:' + (e.message || '未知错误'))
|
||||
this.attachmentList = []
|
||||
this.targetPdfFile = null
|
||||
} 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')
|
||||
},
|
||||
// 回执(盖章后生成的新文件)
|
||||
previewReceipt() {
|
||||
if (!this.seal || !this.seal.receiptFileIds) return
|
||||
window.open(this.seal.receiptFileIds, '_blank')
|
||||
},
|
||||
downloadReceipt() {
|
||||
// 目前 receiptFileIds 存的是 URL(非 ossId),直接打开即可触发浏览器下载/另存为
|
||||
if (!this.seal || !this.seal.receiptFileIds) return
|
||||
window.open(this.seal.receiptFileIds, '_blank')
|
||||
},
|
||||
loadActionsByInstId(instId) {
|
||||
if (!instId) {
|
||||
this.actionList = []
|
||||
return
|
||||
}
|
||||
this.actionLoading = true
|
||||
listFlowAction({ instId, pageNum: 1, pageSize: 200 })
|
||||
.then(res => {
|
||||
this.actionList = res.rows || res.data || []
|
||||
})
|
||||
.finally(() => {
|
||||
this.actionLoading = false
|
||||
})
|
||||
},
|
||||
async loadCurrentTask() {
|
||||
try {
|
||||
const res = await getTodoTaskByBiz('seal', this.currentBizId)
|
||||
this.currentTask = res?.data || null
|
||||
} catch (e) {
|
||||
this.currentTask = null
|
||||
}
|
||||
},
|
||||
submitTaskAction(type) {
|
||||
if (!this.currentTask || !this.currentTask.taskId) return
|
||||
this.actionSubmitting = true
|
||||
const payload = { remark: this.actionRemark }
|
||||
const apiMap = { approve: approveFlowTask, reject: rejectFlowTask, withdraw: withdrawFlowTask }
|
||||
apiMap[type](this.currentTask.taskId, payload)
|
||||
.then(() => {
|
||||
this.$message.success('操作成功')
|
||||
this.loadDetail()
|
||||
})
|
||||
.finally(() => {
|
||||
this.actionSubmitting = false
|
||||
})
|
||||
},
|
||||
openPdfPreview() {
|
||||
if (this.targetPdfFile && this.targetPdfFile.url) {
|
||||
window.open(this.targetPdfFile.url, '_blank')
|
||||
} else {
|
||||
this.$message.warning('PDF文件URL不存在')
|
||||
}
|
||||
},
|
||||
openPositionSelector() {
|
||||
if (!this.targetPdfFile || !this.targetPdfFile.url) {
|
||||
this.$message.warning('请先加载PDF文件')
|
||||
return
|
||||
}
|
||||
this.positionSelectorVisible = true
|
||||
this.selectedPosition = { x: null, y: null }
|
||||
},
|
||||
onStampChange(params) {
|
||||
if (!params) return
|
||||
// 确保值是有效的数字,避免 null/undefined
|
||||
if (params.pageNo !== null && params.pageNo !== undefined) {
|
||||
this.stampForm.pageNo = Number(params.pageNo) || 1
|
||||
}
|
||||
if (params.xPx !== null && params.xPx !== undefined && !isNaN(Number(params.xPx))) {
|
||||
this.stampForm.xPx = Math.floor(Number(params.xPx))
|
||||
}
|
||||
if (params.yPx !== null && params.yPx !== undefined && !isNaN(Number(params.yPx))) {
|
||||
this.stampForm.yPx = Math.floor(Number(params.yPx))
|
||||
}
|
||||
if (params.widthPx !== null && params.widthPx !== undefined && !isNaN(Number(params.widthPx))) {
|
||||
this.stampForm.widthPx = Math.floor(Number(params.widthPx))
|
||||
}
|
||||
if (params.heightPx !== null && params.heightPx !== undefined && !isNaN(Number(params.heightPx))) {
|
||||
this.stampForm.heightPx = Math.floor(Number(params.heightPx))
|
||||
}
|
||||
if (params.viewportWidth !== null && params.viewportWidth !== undefined && !isNaN(Number(params.viewportWidth))) {
|
||||
this.stampForm.viewportWidth = Math.floor(Number(params.viewportWidth))
|
||||
}
|
||||
if (params.viewportHeight !== null && params.viewportHeight !== undefined && !isNaN(Number(params.viewportHeight))) {
|
||||
this.stampForm.viewportHeight = Math.floor(Number(params.viewportHeight))
|
||||
}
|
||||
},
|
||||
resetStampForm() {
|
||||
this.stampForm = {
|
||||
pageNo: this.seal.pageNo || 1,
|
||||
stampImageUrl: '',
|
||||
xPx: null,
|
||||
yPx: null,
|
||||
widthPx: null,
|
||||
heightPx: null
|
||||
}
|
||||
},
|
||||
async doStamp() {
|
||||
if (!this.targetPdfFile || !this.targetPdfFile.url) {
|
||||
this.$message.warning('请先加载PDF文件')
|
||||
return
|
||||
}
|
||||
if (!this.stampForm.stampImageUrl) {
|
||||
this.$message.warning('请选择印章')
|
||||
return
|
||||
}
|
||||
// 验证坐标:确保是有效的数字,且不为 null/undefined
|
||||
const rawXPx = this.stampForm.xPx
|
||||
const rawYPx = this.stampForm.yPx
|
||||
|
||||
if (rawXPx === null || rawXPx === undefined || rawXPx === '' || isNaN(Number(rawXPx))) {
|
||||
this.$message.warning('请选择或输入有效的X坐标')
|
||||
return
|
||||
}
|
||||
const xPx = Math.floor(Number(rawXPx))
|
||||
if (xPx < 0 || !isFinite(xPx)) {
|
||||
this.$message.warning('X坐标必须是非负整数')
|
||||
return
|
||||
}
|
||||
|
||||
if (rawYPx === null || rawYPx === undefined || rawYPx === '' || isNaN(Number(rawYPx))) {
|
||||
this.$message.warning('请选择或输入有效的Y坐标')
|
||||
return
|
||||
}
|
||||
const yPx = Math.floor(Number(rawYPx))
|
||||
if (yPx < 0 || !isFinite(yPx)) {
|
||||
this.$message.warning('Y坐标必须是非负整数')
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.stampForm.pageNo || this.stampForm.pageNo < 1) {
|
||||
this.$message.warning('请输入有效的页码')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.$confirm('确定执行盖章操作吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
this.stamping = true
|
||||
// 构建 payload,确保所有必需字段都是有效值
|
||||
const payload = {
|
||||
targetFileUrl: this.targetPdfFile.url,
|
||||
stampImageUrl: this.stampForm.stampImageUrl,
|
||||
pageNo: Number(this.stampForm.pageNo),
|
||||
xPx: xPx,
|
||||
yPx: yPx,
|
||||
viewportWidth: this.stampForm.viewportWidth,
|
||||
viewportHeight: this.stampForm.viewportHeight
|
||||
}
|
||||
|
||||
// 可选字段:只在有值且有效时添加
|
||||
if (this.stampForm.widthPx !== null && this.stampForm.widthPx !== undefined && this.stampForm.widthPx !== '') {
|
||||
const width = Math.floor(Number(this.stampForm.widthPx))
|
||||
if (width > 0 && isFinite(width)) {
|
||||
payload.widthPx = width
|
||||
}
|
||||
}
|
||||
if (this.stampForm.heightPx !== null && this.stampForm.heightPx !== undefined && this.stampForm.heightPx !== '') {
|
||||
const height = Math.floor(Number(this.stampForm.heightPx))
|
||||
if (height > 0 && isFinite(height)) {
|
||||
payload.heightPx = height
|
||||
}
|
||||
}
|
||||
|
||||
// 最终验证:确保必需字段都不是 null/undefined,并强制转换为整数
|
||||
if (payload.xPx === null || payload.xPx === undefined || !isFinite(payload.xPx)) {
|
||||
console.error('Payload validation failed - xPx:', payload.xPx, 'full payload:', payload)
|
||||
this.$message.error('X坐标数据验证失败,请重新填写')
|
||||
this.stamping = false
|
||||
return
|
||||
}
|
||||
if (payload.yPx === null || payload.yPx === undefined || !isFinite(payload.yPx)) {
|
||||
console.error('Payload validation failed - yPx:', payload.yPx, 'full payload:', payload)
|
||||
this.$message.error('Y坐标数据验证失败,请重新填写')
|
||||
this.stamping = false
|
||||
return
|
||||
}
|
||||
|
||||
// 强制转换为整数,确保类型正确(包括 0 值)
|
||||
const finalXPx = parseInt(payload.xPx, 10)
|
||||
const finalYPx = parseInt(payload.yPx, 10)
|
||||
const finalPageNo = parseInt(payload.pageNo, 10)
|
||||
|
||||
// 最后一次检查(包括 0 值的验证)
|
||||
if (isNaN(finalXPx) || finalXPx < 0) {
|
||||
console.error('Final validation failed - xPx:', finalXPx, 'original:', payload.xPx)
|
||||
this.$message.error('X坐标数据格式错误,请重新填写')
|
||||
this.stamping = false
|
||||
return
|
||||
}
|
||||
if (isNaN(finalYPx) || finalYPx < 0) {
|
||||
console.error('Final validation failed - yPx:', finalYPx, 'original:', payload.yPx)
|
||||
this.$message.error('Y坐标数据格式错误,请重新填写')
|
||||
this.stamping = false
|
||||
return
|
||||
}
|
||||
if (isNaN(finalPageNo) || finalPageNo < 1) {
|
||||
console.error('Final validation failed - pageNo:', finalPageNo)
|
||||
this.$message.error('页码数据格式错误,请重新填写')
|
||||
this.stamping = false
|
||||
return
|
||||
}
|
||||
|
||||
// 重新构建 payload,确保所有值都是明确的数字类型(包括 0)
|
||||
const finalPayload = {
|
||||
targetFileUrl: String(payload.targetFileUrl),
|
||||
stampImageUrl: String(payload.stampImageUrl),
|
||||
pageNo: finalPageNo,
|
||||
xPx: finalXPx,
|
||||
yPx: finalYPx
|
||||
}
|
||||
|
||||
// 可选字段
|
||||
if (payload.widthPx !== undefined && payload.widthPx !== null) {
|
||||
finalPayload.widthPx = parseInt(payload.widthPx, 10)
|
||||
}
|
||||
if (payload.heightPx !== undefined && payload.heightPx !== null) {
|
||||
finalPayload.heightPx = parseInt(payload.heightPx, 10)
|
||||
}
|
||||
|
||||
// 最终验证:确保 yPx 不是 null(即使是 0 也要确保是数字 0)
|
||||
if (finalPayload.yPx === null || finalPayload.yPx === undefined) {
|
||||
console.error('Critical: yPx is null/undefined in finalPayload:', finalPayload)
|
||||
this.$message.error('Y坐标验证失败,请重新填写')
|
||||
this.stamping = false
|
||||
return
|
||||
}
|
||||
|
||||
console.log('发送盖章请求,finalPayload:', JSON.stringify(finalPayload, null, 2))
|
||||
console.log('yPx type:', typeof finalPayload.yPx, 'value:', finalPayload.yPx)
|
||||
|
||||
const res = await stampSealJava(this.currentBizId, finalPayload)
|
||||
this.$message.success('盖章成功!已生成新文件:' + (res.data || '已保存'))
|
||||
await this.loadDetail()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('盖章失败:', error)
|
||||
this.$message.error(error.message || '盖章失败,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
this.stamping = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.muted {
|
||||
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;
|
||||
}
|
||||
.empty {
|
||||
color: #a0a3ad;
|
||||
font-size: 13px;
|
||||
padding: 10px 4px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.file-time {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.receipt-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px dashed #d7d9df;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.receipt-title {
|
||||
font-weight: 700;
|
||||
color: #2b2f36;
|
||||
}
|
||||
.page-no-text {
|
||||
font-weight: 600;
|
||||
color: #2b2f36;
|
||||
}
|
||||
.text-muted {
|
||||
color: #8a8f99;
|
||||
font-size: 12px;
|
||||
}
|
||||
.stamp-section {
|
||||
padding: 12px 0;
|
||||
}
|
||||
.stamp-config {
|
||||
padding: 12px;
|
||||
background: #fafbfc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.pdf-file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.position-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.pdf-preview-container {
|
||||
padding: 12px 0;
|
||||
}
|
||||
.pdf-viewer {
|
||||
position: relative;
|
||||
}
|
||||
.pdf-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.page-hint {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
.pdf-hint {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.pdf-hint .hint-text {
|
||||
margin-top: 6px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.pdf-hint .hint-text:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.pdf-hint .hint-text i {
|
||||
margin-right: 4px;
|
||||
color: #409eff;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.summary-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,49 +7,156 @@
|
||||
<el-button size="mini" icon="el-icon-arrow-left" @click="$router.back()">返回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" size="small">
|
||||
<el-form-item label="申请人" prop="empId">
|
||||
<el-select v-model="form.empId" filterable placeholder="选择申请人" style="width: 100%">
|
||||
<el-option
|
||||
v-for="emp in employees"
|
||||
:key="emp.empId"
|
||||
:label="emp.empName || emp.empNo || emp.empId"
|
||||
:value="emp.empId"
|
||||
/>
|
||||
</el-select>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" size="small" class="metal-form">
|
||||
<!-- 顶部摘要 -->
|
||||
<div class="form-summary">
|
||||
<div class="summary-left">
|
||||
<div class="summary-title">发起出差</div>
|
||||
<div class="summary-sub">请完善信息后提交,系统将按流程节点流转</div>
|
||||
</div>
|
||||
<div class="summary-right">
|
||||
<div class="summary-item">
|
||||
<div class="k">申请人</div>
|
||||
<div class="v">{{ currentApplicantText }}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="k">目的地</div>
|
||||
<div class="v">{{ form.destination || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="出差类型" prop="travelType">
|
||||
<el-select
|
||||
v-model="form.travelType"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder="选择或输入(如:客户拜访/项目支持/培训学习)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="t in travelTypeOptions" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
<div class="hint-text">优先选择;若公司类型未配置,可直接输入</div>
|
||||
</el-form-item>
|
||||
<el-row :gutter="12">
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="block-title">出差时间</div>
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开始时间" prop="startTime">
|
||||
<el-date-picker v-model="form.startTime" type="datetime" placeholder="开始时间" style="width: 100%" />
|
||||
<el-date-picker v-model="form.startTime" type="datetime" placeholder="请选择开始时间" style="width: 100%" :picker-options="pickerOptions" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="结束时间" prop="endTime">
|
||||
<el-date-picker v-model="form.endTime" type="datetime" placeholder="结束时间" style="width: 100%" />
|
||||
<el-date-picker v-model="form.endTime" type="datetime" placeholder="请选择结束时间" style="width: 100%" :picker-options="pickerOptions" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="目的地" prop="destination">
|
||||
<el-input v-model="form.destination" placeholder="城市/地址" />
|
||||
<el-input v-model="form.destination" placeholder="城市/地址/项目现场" />
|
||||
<div class="hint-text">请填写具体目的地,便于审批人判断出差必要性</div>
|
||||
</el-form-item>
|
||||
|
||||
<div class="block-title">出差说明</div>
|
||||
<el-form-item label="事由" prop="reason">
|
||||
<el-input v-model="form.reason" type="textarea" :rows="3" placeholder="填写出差事由" />
|
||||
<el-input v-model="form.reason" type="textarea" :rows="4" placeholder="请说明出差目的、任务目标、预期成果等" show-word-limit maxlength="200" />
|
||||
</el-form-item>
|
||||
<el-form-item label="差旅附件" prop="travelFileIds">
|
||||
|
||||
<el-form-item label="交通/住宿/行程附件" prop="accessoryApplyIds">
|
||||
<file-upload
|
||||
v-model="form.travelFileIds"
|
||||
v-model="form.accessoryApplyIds"
|
||||
:limit="8"
|
||||
:file-size="50"
|
||||
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']"
|
||||
multiple
|
||||
/>
|
||||
<div class="hint-text">上传机票、酒店、行程单等(pdf/jpg/png/doc/docx)</div>
|
||||
<div class="hint-text">上传机票、酒店、行程单等(pdf/jpg/png/doc/docx),便于审批与后续报销</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 审批方式(模板/自选审批人) -->
|
||||
<div class="block-title">审批方式</div>
|
||||
<div class="approve-mode">
|
||||
<el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange">
|
||||
<el-radio-button label="template">使用模板流程</el-radio-button>
|
||||
<el-radio-button label="manual">手动选择审批人(一次审批)</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<div class="approve-panel">
|
||||
<div v-if="approverMode === 'template'">
|
||||
<div class="approve-row">
|
||||
<div class="k">流程模板</div>
|
||||
<div class="v">
|
||||
<el-select
|
||||
v-model="tplId"
|
||||
size="small"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="请选择流程模板"
|
||||
style="width: 360px"
|
||||
@change="onTplChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="t in availableTpls"
|
||||
:key="t.tplId"
|
||||
:label="`${t.tplName}${t.version ? ' (v' + t.version + ')' : ''}`"
|
||||
:value="t.tplId"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-text">提示:选择模板后,将按模板节点自动流转(含抄送节点)。</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="approve-row">
|
||||
<div class="k">审批人</div>
|
||||
<div class="v" style="max-width: 520px">
|
||||
<el-button size="mini" type="primary" plain @click="openUserSelect">选择审批人</el-button>
|
||||
<span style="margin-left: 10px; font-weight: 600; color: #2b2f36">
|
||||
{{ assigneeUserName || '未选择' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-text">提示:手动选择审批人将创建一次性审批流程,审批通过后流程立即结束。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form-item label="回执附件(可选)" prop="accessoryReceiptIds">
|
||||
<file-upload
|
||||
v-model="form.accessoryReceiptIds"
|
||||
:limit="8"
|
||||
:file-size="50"
|
||||
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']"
|
||||
multiple
|
||||
/>
|
||||
<div class="hint-text">可选:上传回执、发票、盖章回单等(审核/归档使用)</div>
|
||||
</el-form-item>
|
||||
|
||||
<div class="block-title">费用信息</div>
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="收款人" prop="payeeName">
|
||||
<el-input v-model="form.payeeName" placeholder="收款人姓名/公司" />
|
||||
<div class="hint-text">出差费用报销收款方</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="预估费用">
|
||||
<el-input-number v-model="form.estimatedCost" :min="0" :step="100" style="width: 100%" />
|
||||
<div class="hint-text">预估总费用(元),便于预算控制</div>
|
||||
</el-form-item>
|
||||
<el-row :gutter="12">
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开户行" prop="bankName">
|
||||
<el-input v-model="form.bankName" placeholder="XX银行XX支行" />
|
||||
@@ -61,48 +168,115 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选" />
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选:补充说明、特殊要求等" show-word-limit maxlength="200" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 提交流程提示:按“真实节点配置 / 手动一次性审批”预览 -->
|
||||
<div class="flow-preview" v-loading="flowLoading">
|
||||
<div class="flow-title">流程预览</div>
|
||||
<div class="flow-sub">
|
||||
<template v-if="approverMode === 'template'">
|
||||
<span v-if="flowTpl">当前模板:{{ flowTpl.tplName }}(v{{ flowTpl.version || 1 }})</span>
|
||||
<span v-else>请选择流程模板</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>一次性审批(手动指定审批人)</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 模板模式 -->
|
||||
<div v-if="approverMode === 'template'">
|
||||
<div v-if="flowNodes && flowNodes.length" class="flow-steps">
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">提交</div></div>
|
||||
<template v-for="(n, idx) in flowNodes">
|
||||
<div :key="`line-${n.nodeId || idx}`" class="line"></div>
|
||||
<div :key="`node-${n.nodeId || idx}`" class="flow-step">
|
||||
<div class="dot" :class="{ success: idx === flowNodes.length - 1 }"></div>
|
||||
<div class="txt">{{ nodePreviewText(n, idx) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flow-fallback">
|
||||
<div class="hint-text">提示:请选择一个模板后将展示对应节点预览。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动审批模式 -->
|
||||
<div v-else class="flow-steps">
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">提交审批({{ assigneeUserName || '请选择' }})</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step"><div class="dot success"></div><div class="txt">审批结束</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button @click="$router.back()">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">提交申请</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 用户选择组件始终挂载 -->
|
||||
<UserSelect ref="userSelect" @onSelected="onUserSelected" />
|
||||
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listEmployee, addTravelReq } from '@/api/hrm'
|
||||
import { addTravelReq } from '@/api/hrm'
|
||||
import { listFlowTemplate, listFlowNode } from '@/api/hrm/flow'
|
||||
import UserSelect from '@/components/userSelect/single.vue'
|
||||
import FileUpload from '@/components/FileUpload'
|
||||
import { getEmployeeByUserId } from '@/api/hrm/employee'
|
||||
|
||||
export default {
|
||||
name: 'HrmTravelRequest',
|
||||
components: { FileUpload },
|
||||
components: {
|
||||
UserSelect,
|
||||
FileUpload
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
employees: [],
|
||||
currentEmp: null,
|
||||
submitting: false,
|
||||
flowLoading: false,
|
||||
flowTpl: null,
|
||||
flowNodes: [],
|
||||
approverMode: 'template',
|
||||
availableTpls: [],
|
||||
tplId: null,
|
||||
assigneeUserId: null,
|
||||
assigneeUserName: '',
|
||||
travelTypeOptions: ['客户拜访', '项目支持', '培训学习', '会议会展', '验收交付', '其他'],
|
||||
pickerOptions: { disabledDate: () => false },
|
||||
form: {
|
||||
empId: '',
|
||||
travelType: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
destination: '',
|
||||
reason: '',
|
||||
travelFileIds: '',
|
||||
accessoryApplyIds: '',
|
||||
accessoryReceiptIds: '',
|
||||
payeeName: '',
|
||||
estimatedCost: 0,
|
||||
bankName: '',
|
||||
bankAccount: '',
|
||||
remark: ''
|
||||
},
|
||||
rules: {
|
||||
empId: [{ required: true, message: '请选择申请人', trigger: 'change' }],
|
||||
travelType: [{ required: true, message: '请选择/输入出差类型', trigger: 'change' }],
|
||||
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
||||
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
|
||||
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
|
||||
reason: [{ required: true, message: '请输入事由', trigger: 'blur' }],
|
||||
travelFileIds: [{ required: true, message: '请上传差旅附件', trigger: 'change' }],
|
||||
accessoryApplyIds: [{ required: true, message: '请上传交通/住宿/行程附件', trigger: 'change' }],
|
||||
payeeName: [{ required: true, message: '请输入收款人', trigger: 'blur' }],
|
||||
bankName: [{ required: true, message: '请输入开户行', trigger: 'blur' }],
|
||||
bankAccount: [{ required: true, message: '请输入银行账号', trigger: 'blur' }]
|
||||
@@ -110,29 +284,151 @@ export default {
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadEmployees()
|
||||
const userId = this.$store?.state?.user?.userId
|
||||
if (userId) this.form.empId = userId
|
||||
this.loadCurrentEmployee()
|
||||
this.loadTemplates()
|
||||
},
|
||||
computed: {
|
||||
currentApplicantText() {
|
||||
if (this.currentEmp) return this.formatEmpLabel(this.currentEmp)
|
||||
const user = this.$store?.state?.user || {}
|
||||
return user.nickName || user.userName || '加载中...'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadEmployees() {
|
||||
listEmployee({ pageNum: 1, pageSize: 500 }).then(res => {
|
||||
this.employees = res.rows || res.data || []
|
||||
})
|
||||
async loadTemplates() {
|
||||
try {
|
||||
const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'travel', enabled: 1 })
|
||||
this.availableTpls = res.rows || res.data || []
|
||||
if (!this.tplId && this.availableTpls.length) {
|
||||
this.tplId = this.availableTpls[0].tplId
|
||||
}
|
||||
await this.refreshFlowPreview()
|
||||
} catch (err) {
|
||||
this.availableTpls = []
|
||||
}
|
||||
},
|
||||
async refreshFlowPreview() {
|
||||
this.flowLoading = true
|
||||
try {
|
||||
if (this.approverMode === 'manual') {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
if (!this.tplId) {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null
|
||||
const nodeRes = await listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.tplId })
|
||||
this.flowNodes = (nodeRes.rows || nodeRes.data || []).filter(n => n.nodeType !== 'end').sort((a, b) => (a.orderNo || 0) - (b.orderNo || 0))
|
||||
} finally {
|
||||
this.flowLoading = false
|
||||
}
|
||||
},
|
||||
async onTplChange(val) {
|
||||
this.tplId = val
|
||||
await this.refreshFlowPreview()
|
||||
},
|
||||
onApproverModeChange(val) {
|
||||
this.approverMode = val
|
||||
if (val === 'manual') this.tplId = null
|
||||
this.refreshFlowPreview()
|
||||
},
|
||||
openUserSelect() {
|
||||
this.$refs.userSelect.open()
|
||||
},
|
||||
onUserSelected(row) {
|
||||
if (row) {
|
||||
this.assigneeUserId = row.userId
|
||||
this.assigneeUserName = row.nickName || row.userName || row.userId
|
||||
this.refreshFlowPreview()
|
||||
}
|
||||
},
|
||||
nodePreviewText(n, idx) {
|
||||
const typeMap = { approve: '审批', cc: '抄送' }
|
||||
const ruleMap = { fixed_user: '指定人员', role: '指定角色', position: '指定岗位', leader: '直属上级' }
|
||||
const nodeType = typeMap[n.nodeType] || '节点'
|
||||
const rule = ruleMap[n.approverRule] || '规则'
|
||||
let detail = ''
|
||||
try {
|
||||
const arr = Array.isArray(n.approverValue) ? n.approverValue : JSON.parse(n.approverValue || '[]')
|
||||
if (arr.length) detail = `:${arr.join('、')}`
|
||||
} catch (e) { detail = n.approverValue ? `:${n.approverValue}` : '' }
|
||||
const text = `${nodeType}(${rule}${detail})`
|
||||
return idx === this.flowNodes.length - 1 ? `${text} → 结束` : text
|
||||
},
|
||||
normalizeOssIds(val) {
|
||||
if (!val) return ''
|
||||
if (typeof val === 'string') return val
|
||||
if (Array.isArray(val)) {
|
||||
const ids = val.map(x => (x && typeof x === 'object') ? (x.ossId ?? x.id ?? x.value) : x).filter(Boolean)
|
||||
return ids.join(',')
|
||||
}
|
||||
return String(val)
|
||||
},
|
||||
formatEmpLabel(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()
|
||||
},
|
||||
async loadCurrentEmployee() {
|
||||
const userId = this.$store?.state?.user?.id
|
||||
if (!userId) {
|
||||
this.$message.error('无法获取当前用户信息,请重新登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getEmployeeByUserId(userId)
|
||||
if (res.code === 200 && res.data) {
|
||||
this.currentEmp = res.data
|
||||
this.form.empId = res.data.empId
|
||||
} else {
|
||||
this.$message.error('未找到当前用户对应的员工信息,请在员工管理中关联系统用户')
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error('加载员工信息失败,请稍后重试')
|
||||
}
|
||||
},
|
||||
submit() {
|
||||
this.$refs.formRef.validate(valid => {
|
||||
this.$refs.formRef.validate(async valid => {
|
||||
if (!valid) return
|
||||
if (this.approverMode === 'template' && !this.tplId) {
|
||||
return this.$message.warning('请选择一个流程模板')
|
||||
}
|
||||
if (this.approverMode === 'manual' && !this.assigneeUserId) {
|
||||
return this.$message.warning('请选择审批人')
|
||||
}
|
||||
this.submitting = true
|
||||
const payload = { ...this.form }
|
||||
addTravelReq(payload)
|
||||
.then(() => {
|
||||
const payload = {
|
||||
empId: this.form.empId,
|
||||
travelType: this.form.travelType,
|
||||
startTime: this.form.startTime,
|
||||
endTime: this.form.endTime,
|
||||
destination: this.form.destination,
|
||||
reason: this.form.reason,
|
||||
accessoryApplyIds: this.normalizeOssIds(this.form.accessoryApplyIds),
|
||||
accessoryReceiptIds: this.normalizeOssIds(this.form.accessoryReceiptIds),
|
||||
payeeName: this.form.payeeName,
|
||||
estimatedCost: this.form.estimatedCost,
|
||||
status: 'pending',
|
||||
bankName: this.form.bankName,
|
||||
bankAccount: this.form.bankAccount,
|
||||
remark: this.form.remark,
|
||||
tplId: this.tplId,
|
||||
manualAssigneeUserId: this.assigneeUserId
|
||||
}
|
||||
try {
|
||||
await addTravelReq(payload)
|
||||
this.$message.success('提交成功')
|
||||
this.$router.push('/hrm/requests')
|
||||
})
|
||||
.finally(() => {
|
||||
} catch (e) {
|
||||
this.$message.error('提交失败,请稍后重试')
|
||||
} finally {
|
||||
this.submitting = false
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -145,23 +441,98 @@ export default {
|
||||
background: #f8f9fb;
|
||||
}
|
||||
.form-card {
|
||||
max-width: 800px;
|
||||
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: 600;
|
||||
font-weight: 700;
|
||||
color: #2b2f36;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.metal-form { padding-right: 8px; }
|
||||
.block-title {
|
||||
margin: 20px 0 12px;
|
||||
padding-left: 10px;
|
||||
font-weight: 700;
|
||||
color: #2f3440;
|
||||
border-left: 3px solid #9aa3b2;
|
||||
}
|
||||
.hint-text {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.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; }
|
||||
.approve-mode {
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
.approve-panel { margin-top: 12px; }
|
||||
.approve-row { display: flex; align-items: center; gap: 12px; }
|
||||
.approve-row .k { font-size: 14px; color: #606266; }
|
||||
.flow-preview {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
.flow-title { font-weight: 800; color: #2b2f36; }
|
||||
.flow-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
|
||||
.flow-steps {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e6e8ed;
|
||||
background: #fff;
|
||||
}
|
||||
.flow-step .dot { width: 8px; height: 8px; border-radius: 50%; background: #9aa3b2; }
|
||||
.flow-step .dot.success { background: #67c23a; }
|
||||
.flow-step .txt { font-size: 12px; color: #2b2f36; font-weight: 600; }
|
||||
.flow-steps .line { width: 26px; height: 1px; background: #e6e8ed; }
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.summary-right { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
678
klp-ui/src/views/hrm/requests/travelDetail.vue
Normal file
678
klp-ui/src/views/hrm/requests/travelDetail.vue
Normal file
@@ -0,0 +1,678 @@
|
||||
<template>
|
||||
<div class="request-page">
|
||||
<el-card class="form-card" shadow="never">
|
||||
<div slot="header" class="card-header">
|
||||
<span>出差详情</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-summary" v-loading="loading">
|
||||
<div class="summary-left">
|
||||
<div class="summary-title">{{ travel.travelType || '出差申请' }}</div>
|
||||
<div class="summary-sub">申请编号:{{ travel.bizId || '-' }} · 状态:<el-tag size="mini" :type="statusType(travel.status)">{{ statusText(travel.status) }}</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">{{ travel.destination || '-' }}</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(travel.startTime) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间">
|
||||
<span class="date-time">{{ formatDate(travel.endTime) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="出差类型">{{ travel.travelType || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="申请人">{{ applicantText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目的地" :span="2">
|
||||
<span class="destination-text">{{ travel.destination || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="出差事由" :span="2">
|
||||
<div class="reason-content">{{ travel.reason || '未填写' }}</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 费用与收款信息 -->
|
||||
<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">{{ travel.estimatedCost != null ? '¥' + travel.estimatedCost : '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="收款人">{{ travel.payeeName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开户行">{{ travel.bankName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="银行账号">{{ travel.bankAccount || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</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 class="block-title">回执附件</div>
|
||||
<el-card class="inner-card" shadow="never" v-loading="receiptAttachmentLoading">
|
||||
<div v-if="receiptAttachmentList.length > 0" class="attachment-list">
|
||||
<div v-for="file in receiptAttachmentList" :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" v-loading="actionLoading">
|
||||
<div v-if="flowInstance" class="flow-status">
|
||||
<div class="status-item">
|
||||
<div class="status-label">流程状态</div>
|
||||
<div class="status-value">
|
||||
<el-tag :type="statusType(flowInstance.status)" size="small">
|
||||
{{ statusText(flowInstance.status) }}
|
||||
</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" v-loading="actionLoading">
|
||||
<el-timeline v-if="actionList.length">
|
||||
<el-timeline-item
|
||||
v-for="(a, idx) in actionList"
|
||||
:key="idx"
|
||||
:timestamp="formatDate(a.createTime)"
|
||||
:type="actionType(a.action)"
|
||||
>
|
||||
<div class="timeline-row">
|
||||
<div class="t-main">
|
||||
<span class="t-action">{{ actionText(a.action) }}</span>
|
||||
<span class="t-user">· 办理人:{{ a.actionUserId || '-' }}</span>
|
||||
</div>
|
||||
<div class="t-remark" v-if="a.remark">{{ a.remark }}</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<div v-else class="empty">暂无流转记录</div>
|
||||
</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" :loading="actionSubmitting" @click="submitTaskAction('approve')">通过</el-button>
|
||||
<el-button type="danger" :loading="actionSubmitting" @click="submitTaskAction('reject')">驳回</el-button>
|
||||
<el-button :loading="actionSubmitting" @click="submitTaskAction('withdraw')">撤回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty">当前无待办任务(可能已处理完成,或你不是当前审批人)</div>
|
||||
</el-card>
|
||||
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getTravelReq,
|
||||
listFlowAction,
|
||||
getTodoTaskByBiz,
|
||||
approveFlowTask,
|
||||
rejectFlowTask,
|
||||
withdrawFlowTask,
|
||||
listEmployee
|
||||
} from '@/api/hrm'
|
||||
import { queryInstanceByBiz, getFlowInstance, listFlowNode } from '@/api/hrm/flow'
|
||||
import { listByIds } from '@/api/system/oss'
|
||||
|
||||
export default {
|
||||
name: 'TravelDetail',
|
||||
props: {
|
||||
bizId: { type: [String, Number], default: null },
|
||||
embedded: { type: Boolean, default: false }
|
||||
},
|
||||
name: 'HrmTravelDetail',
|
||||
data() {
|
||||
return {
|
||||
travel: {},
|
||||
employees: [],
|
||||
loading: false,
|
||||
actionLoading: false,
|
||||
actionList: [],
|
||||
currentTask: null,
|
||||
actionRemark: '',
|
||||
actionSubmitting: false,
|
||||
attachmentList: [],
|
||||
attachmentLoading: false,
|
||||
receiptAttachmentList: [],
|
||||
receiptAttachmentLoading: false,
|
||||
flowInstance: null, // 流程实例信息
|
||||
flowNodes: [], // 流程节点列表
|
||||
currentNode: null // 当前节点信息
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentBizId() {
|
||||
return this.bizId || this.$route?.params?.bizId || this.$route?.query?.bizId || this.$route?.params?.id
|
||||
},
|
||||
applicantText() {
|
||||
const empId = this.travel.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}` : '-'
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadEmployees()
|
||||
this.loadDetail()
|
||||
},
|
||||
methods: {
|
||||
loadEmployees() {
|
||||
listEmployee({ pageNum: 1, pageSize: 1000 }).then(res => {
|
||||
this.employees = res.rows || res.data || []
|
||||
})
|
||||
},
|
||||
statusText(status) {
|
||||
const map = { pending: '审批中', draft: '草稿', approved: '已通过', rejected: '已驳回', canceled: '已撤销' }
|
||||
return map[status] || status || '-'
|
||||
},
|
||||
statusType(status) {
|
||||
const map = { pending: 'warning', draft: 'info', approved: 'success', rejected: 'danger', canceled: 'info' }
|
||||
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 {
|
||||
const res = await getTravelReq(bizId)
|
||||
this.travel = res.data || {}
|
||||
// 加载流程实例信息
|
||||
await this.loadFlowInstance()
|
||||
this.loadActionsByInstId(this.travel.instId)
|
||||
await this.loadCurrentTask()
|
||||
this.loadAttachments()
|
||||
this.loadReceiptAttachments()
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async loadFlowInstance() {
|
||||
if (!this.travel.instId) {
|
||||
// 如果没有instId,尝试通过bizType和bizId查询
|
||||
try {
|
||||
const res = await queryInstanceByBiz('travel', this.currentBizId)
|
||||
const instances = res.data || []
|
||||
if (instances.length > 0) {
|
||||
this.flowInstance = instances[0]
|
||||
this.travel.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.travel.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 loadAttachments() {
|
||||
// 申请附件:accessoryApplyIds
|
||||
const fileIds = this.travel.accessoryApplyIds
|
||||
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
|
||||
}
|
||||
},
|
||||
async loadReceiptAttachments() {
|
||||
// 回执附件:accessoryReceiptIds
|
||||
const fileIds = this.travel.accessoryReceiptIds
|
||||
if (!fileIds) {
|
||||
this.receiptAttachmentList = []
|
||||
return
|
||||
}
|
||||
const ids = String(fileIds).split(',').map(id => id.trim()).filter(Boolean)
|
||||
if (ids.length === 0) {
|
||||
this.receiptAttachmentList = []
|
||||
return
|
||||
}
|
||||
this.receiptAttachmentLoading = true
|
||||
try {
|
||||
const res = await listByIds(ids)
|
||||
this.receiptAttachmentList = res.data || []
|
||||
} catch (e) {
|
||||
this.$message.error('加载回执附件失败:' + (e.message || '未知错误'))
|
||||
this.receiptAttachmentList = []
|
||||
} finally {
|
||||
this.receiptAttachmentLoading = 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')
|
||||
},
|
||||
loadActionsByInstId(instId) {
|
||||
if (!instId) {
|
||||
this.actionList = []
|
||||
return
|
||||
}
|
||||
this.actionLoading = true
|
||||
listFlowAction({ instId, pageNum: 1, pageSize: 200 })
|
||||
.then(res => {
|
||||
this.actionList = res.rows || res.data || []
|
||||
})
|
||||
.finally(() => {
|
||||
this.actionLoading = false
|
||||
})
|
||||
},
|
||||
async loadCurrentTask() {
|
||||
try {
|
||||
const res = await getTodoTaskByBiz('travel', this.currentBizId)
|
||||
this.currentTask = res?.data || null
|
||||
} catch (e) {
|
||||
this.currentTask = null
|
||||
}
|
||||
},
|
||||
submitTaskAction(type) {
|
||||
if (!this.currentTask || !this.currentTask.taskId) return
|
||||
this.actionSubmitting = true
|
||||
const payload = { remark: this.actionRemark }
|
||||
const apiMap = { approve: approveFlowTask, reject: rejectFlowTask, withdraw: withdrawFlowTask }
|
||||
apiMap[type](this.currentTask.taskId, payload)
|
||||
.then(() => {
|
||||
this.$message.success('操作成功')
|
||||
this.loadDetail()
|
||||
})
|
||||
.finally(() => {
|
||||
this.actionSubmitting = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user