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