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

637 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.amount != null ? '¥' + form.amount : '-' }}</div>
</div>
</div>
</div>
<el-row :gutter="14">
<el-col :span="12">
<el-form-item label="拨款类型" prop="appropriationType">
<el-select v-model="form.appropriationType" filterable allow-create default-first-option clearable
placeholder="选择或输入(如:差旅费/招待费/办公费)" style="width: 100%">
<el-option v-for="t in appropriationTypeOptions" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="拨款总金额" prop="amount">
<el-input-number v-model="form.amount" :min="0" :step="100" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<div class="block-title">绑定项目</div>
<el-row>
<el-col>
<el-form-item label="项目" prop="projectId">
<project-select v-model="form.projectId" placeholder="请选择项目" style="width: 100%" />
<!-- <el-input v-model="form.projectId" placeholder="请输入项目ID" 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="1000" />
</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-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="拨款单据附件" prop="accessoryApplyIds">
<file-upload v-model="form.accessoryApplyIds" :limit="50" :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>
<el-form-item label="抄送目标" prop="ccUserIds">
<el-button size="mini" type="primary" plain icon="el-icon-user" @click="openUserMultiSelect">选择抄送人</el-button>
<div class="selected-users" v-if="ccForm.selectedUsers && ccForm.selectedUsers.length">
<el-tag v-for="u in ccForm.selectedUsers" :key="u.userId" size="mini" closable @close="removeCcUser(u)">
{{ u.nickName || u.userName || ('ID:' + u.userId) }}
</el-tag>
</div>
</el-form-item>
<!-- 抄送备注 -->
<el-form-item label="抄送备注" prop="ccRemark">
<el-input v-model="ccForm.remark" type="textarea" :rows="2" placeholder="可以填写抄送的目的或原因等信息" show-word-limit
maxlength="1000" />
</el-form-item>
<UserMultiSelect ref="userMultiSelect" :init="ccUserIds" @onSelected="onCcUsersSelected" />
<!-- 审批方式模板/自选审批人 -->
<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 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 { addAppropriationReq, listFlowNode, listFlowTemplate } from '@/api/hrm';
import { getEmployeeByUserId } from '@/api/hrm/employee';
import { ccFlowTask } from '@/api/hrm/flow';
import FileUpload from '@/components/FileUpload';
import UserMultiSelect from '@/components/UserSelect/multi.vue';
import UserSelect from '@/components/UserSelect/single.vue';
export default {
name: 'HrmAppropriationRequest',
components: { FileUpload, UserSelect, UserMultiSelect },
data () {
return {
currentEmp: null,
submitting: false,
flowLoading: false,
flowTpl: null,
flowNodes: [],
approverMode: 'template',
availableTpls: [],
tplId: null,
assigneeUserId: null,
assigneeUserName: '',
appropriationTypeOptions: ['差旅费', '招待费', '办公费', '交通费', '通讯费', '其他'],
form: {
empId: '',
appropriationType: '',
amount: 0,
reason: '',
accessoryApplyIds: '',
accessoryReceiptIds: '',
remark: '',
payeeName: '',
bankName: '',
bankAccount: ''
},
ccForm: {
// 默认抄送胡雪娇
selectedUsers: [{
userId: '1859249502579310593',
nickName: '胡雪娇',
userName: 'huangxuejiao'
}, {
userId: '1859252208375152641',
nickName: '高伟',
userName: 'gaowei'
}],
remark: ''
},
rules: {
appropriationType: [{ required: true, message: '请选择/输入拨款类型', trigger: 'change' }],
amount: [{ 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 || '加载中...'
},
ccUserIds () {
return this.ccForm.selectedUsers?.map(u => u.userId) || []
},
},
methods: {
openUserMultiSelect () { this.$refs.userMultiSelect.open() },
onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] },
removeCcUser (user) { this.ccForm.selectedUsers = this.ccForm.selectedUsers.filter(u => u.userId !== user.userId) },
async loadTemplates () {
try {
const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'appropriation', 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 () {
console.log('提交申请')
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,
appropriationType: this.form.appropriationType,
amount: this.form.amount,
reason: this.form.reason,
accessoryApplyIds: this.normalizeOssIds(this.form.accessoryApplyIds),
accessoryReceiptIds: this.normalizeOssIds(this.form.accessoryReceiptIds),
remark: this.form.remark,
status: 'pending',
projectId: this.form.projectId,
// tplId: this.tplId,
manualAssigneeUserId: this.assigneeUserId,
payeeName: this.form.payeeName,
bankName: this.form.bankName,
bankAccount: this.form.bankAccount,
}
if (this.approverMode === 'template') {
payload.tplId = this.tplId
}
const { data: instance } = await addAppropriationReq(payload)
console.log(instance, this.ccForm)
if (this.ccForm.selectedUsers.length && instance?.instId) {
const ccUserIds = this.ccForm.selectedUsers.map(u => u.userId)
const fromUserId = this.$store?.state?.user?.id
const payload = {
instId: instance.instId,
bizId: instance.bizId,
bizType: 'appropriation',
ccUserIds: ccUserIds,
remark: this.ccForm.remark,
fromUserId,
nodeId: 0,
readFlag: 0,
nodeName: '节点#0'
}
await ccFlowTask(payload)
}
this.$message.success('提交成功')
this.$router.push('/hrm/apply')
} catch (err) {
// no-op
console.log(err)
} 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>