This commit is contained in:
2025-12-30 13:47:53 +08:00
parent f1637501b2
commit a623c5673f
137 changed files with 11031 additions and 4043 deletions

View 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 }
}
}
}

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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 URLhttps://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>

View File

@@ -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>

View 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>