Files
klp-oa/klp-ui/src/views/hrm/requests/travel.vue
2025-12-30 17:49:45 +08:00

610 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="request-page">
<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.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-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%"
: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%"
:picker-options="pickerOptions" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="目的地" prop="destination">
<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="4" placeholder="请说明出差目的、任务目标、预期成果等" show-word-limit
maxlength="200" />
</el-form-item>
<el-form-item label="交通/住宿/行程附件" prop="accessoryApplyIds">
<file-upload 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>
</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-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支行" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="银行账号" prop="bankAccount">
<el-input v-model="form.bankAccount" placeholder="汇款账号" />
</el-form-item>
</el-col>
</el-row>
<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="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 class="line"></div>
<div 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 { 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: {
UserSelect,
FileUpload
},
data() {
return {
currentEmp: null,
submitting: false,
flowLoading: false,
flowTpl: null,
flowNodes: [],
approverMode: 'manual',
availableTpls: [],
tplId: null,
assigneeUserId: null,
assigneeUserName: '',
travelTypeOptions: ['客户拜访', '项目支持', '培训学习', '会议会展', '验收交付', '其他'],
pickerOptions: { disabledDate: () => false },
form: {
empId: '',
travelType: '',
startTime: '',
endTime: '',
destination: '',
reason: '',
accessoryApplyIds: '',
accessoryReceiptIds: '',
payeeName: '',
estimatedCost: 0,
bankName: '',
bankAccount: '',
remark: ''
},
rules: {
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' }],
accessoryApplyIds: [{ required: true, message: '请上传交通/住宿/行程附件', trigger: 'change' }],
payeeName: [{ required: true, message: '请输入收款人', trigger: 'blur' }],
bankName: [{ required: true, message: '请输入开户行', trigger: 'blur' }],
bankAccount: [{ required: true, message: '请输入银行账号', trigger: 'blur' }]
}
}
},
created() {
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: {
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(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 = {
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')
} catch (e) {
this.$message.error('提交失败,请稍后重试')
} 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;
}
.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: 24px;
}
@media (max-width: 1200px) {
.summary-right {
display: none;
}
}
</style>