<!--OA模块:费用报销-->
|
<template>
|
<div class="app-container">
|
<div class="search_form mb20">
|
<div class="search_fields">
|
<span class="search_title">申请人:</span>
|
<el-input
|
v-model="searchForm.applicantKeyword"
|
style="width: 220px"
|
placeholder="姓名或编号"
|
clearable
|
:prefix-icon="Search"
|
@keyup.enter="handleQuery"
|
/>
|
<span class="search_title" style="margin-left: 12px">申请时间:</span>
|
<el-date-picker
|
v-model="searchForm.applyTimeFrom"
|
type="date"
|
placeholder="开始日期"
|
format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
style="width: 150px"
|
clearable
|
/>
|
<span class="search_title" style="margin-left: 8px">至</span>
|
<el-date-picker
|
v-model="searchForm.applyTimeTo"
|
type="date"
|
placeholder="结束日期"
|
format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
style="width: 150px; margin-left: 8px"
|
clearable
|
/>
|
<el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
|
<el-button @click="resetSearch">重置</el-button>
|
</div>
|
<div class="search_actions">
|
<el-button type="success" plain @click="handleImportClick">导入</el-button>
|
<el-button type="warning" plain @click="handleExport">导出</el-button>
|
<el-button type="primary" @click="openFormDialog('add')">新增费用报销</el-button>
|
</div>
|
</div>
|
|
<input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" />
|
|
<div class="table_list">
|
<PIMTable
|
rowKey="id"
|
:column="tableColumn"
|
:tableData="tableData"
|
:page="page"
|
:isSelection="false"
|
:tableLoading="tableLoading"
|
:total="page.total"
|
@pagination="pagination"
|
/>
|
</div>
|
|
<!-- 新增 / 编辑 -->
|
<el-dialog
|
v-model="formDialog.visible"
|
:title="formDialog.title"
|
width="1120px"
|
append-to-body
|
destroy-on-close
|
class="cost-reimburse-form-dialog"
|
@closed="onFormClosed"
|
>
|
<el-alert type="info" show-icon :closable="false" class="mb16">
|
<template #title>全品类费用报销 · 分类模板一键填报</template>
|
<template #default>
|
支持差旅、办公采购、业务招待、交通费、通讯费等;按金额自动匹配审批链(500元内直属上级,超5000元财务总监复核)。
|
</template>
|
</el-alert>
|
|
<div v-if="!formDialog.readonly" class="template-bar mb16">
|
<span class="template-label">分类模板:</span>
|
<el-button
|
v-for="(tpl, key) in CATEGORY_TEMPLATES"
|
:key="key"
|
size="small"
|
:type="form.expenseCategory === key ? 'primary' : 'default'"
|
plain
|
@click="applyTemplate(key)"
|
>
|
{{ tpl.label }}
|
</el-button>
|
</div>
|
|
<el-form
|
ref="formRef"
|
:model="form"
|
:rules="formRules"
|
label-width="120px"
|
class="cost-reimburse-form"
|
:disabled="formDialog.readonly"
|
>
|
<el-card class="form-section" shadow="never">
|
<template #header><span class="card-header-title">基本信息</span></template>
|
<el-row :gutter="20">
|
<el-col :span="12">
|
<el-form-item label="员工编号">
|
<el-input v-model="form.employeeNo" readonly placeholder="选择员工后自动带出" />
|
</el-form-item>
|
</el-col>
|
<el-col :span="12">
|
<el-form-item label="员工姓名" prop="applicantId">
|
<el-select
|
v-model="form.applicantId"
|
filterable
|
remote
|
clearable
|
reserve-keyword
|
placeholder="请选择或搜索员工"
|
style="width: 100%"
|
:remote-method="remoteSearchApplicantForm"
|
:loading="applicantFormSearchLoading"
|
@change="onApplicantChange"
|
>
|
<el-option
|
v-for="u in applicantFormOptions"
|
:key="u.userId"
|
:label="userSelectLabel(u)"
|
:value="u.userId"
|
/>
|
</el-select>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
<el-row :gutter="20">
|
<el-col :span="12">
|
<el-form-item label="费用类型" prop="expenseCategory">
|
<el-select
|
v-model="form.expenseCategory"
|
placeholder="请选择费用类型"
|
style="width: 100%"
|
@change="onExpenseCategoryChange"
|
>
|
<el-option
|
v-for="opt in EXPENSE_CATEGORY_OPTIONS"
|
:key="opt.value"
|
:label="opt.label"
|
:value="opt.value"
|
/>
|
</el-select>
|
</el-form-item>
|
</el-col>
|
<el-col :span="12">
|
<el-form-item label="报销状态">
|
<el-tag
|
:type="form.approvalResult === 'approved' ? 'success' : form.approvalResult === 'rejected' ? 'danger' : 'warning'"
|
effect="plain"
|
>
|
{{
|
form.approvalResult === "approved"
|
? "已通过"
|
: form.approvalResult === "rejected"
|
? "已驳回"
|
: "审核中"
|
}}
|
</el-tag>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
<el-row :gutter="20">
|
<el-col :span="24">
|
<el-form-item label="报销原因" prop="reimburseReason">
|
<el-input
|
v-model="form.reimburseReason"
|
type="textarea"
|
:rows="3"
|
placeholder="请填写报销原因"
|
maxlength="2000"
|
show-word-limit
|
/>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
<el-row :gutter="20">
|
<el-col :span="12">
|
<el-form-item label="报销金额" prop="applyAmount">
|
<div class="amount-row">
|
<el-input-number
|
v-model="form.applyAmount"
|
:min="0"
|
:precision="2"
|
controls-position="right"
|
class="amount-input"
|
@change="autoAssignApprovalFlow"
|
/>
|
<el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails">
|
按明细汇总 {{ detailTotalAmount }} 元
|
</el-button>
|
</div>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
</el-card>
|
|
<el-card class="form-section" shadow="never">
|
<template #header>
|
<div class="card-header-row">
|
<span class="card-header-title">报销明细</span>
|
<el-button v-if="!formDialog.readonly" type="primary" plain size="small" @click="addExpenseDetail">
|
新增明细
|
</el-button>
|
</div>
|
</template>
|
|
<el-table :data="form.expenseDetails" border size="small" class="detail-table">
|
<el-table-column type="index" label="序号" width="55" align="center" />
|
<el-table-column label="发票日期" width="150">
|
<template #default="{ row }">
|
<el-date-picker
|
v-if="!formDialog.readonly"
|
v-model="row.invoiceDate"
|
type="date"
|
value-format="YYYY-MM-DD"
|
size="small"
|
style="width: 100%"
|
/>
|
<span v-else>{{ row.invoiceDate || "—" }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="费用科目" width="130">
|
<template #default="{ row }">
|
<el-select v-if="!formDialog.readonly" v-model="row.expenseSubject" size="small" style="width: 100%">
|
<el-option
|
v-for="opt in EXPENSE_SUBJECT_OPTIONS"
|
:key="opt.value"
|
:label="opt.label"
|
:value="opt.value"
|
/>
|
</el-select>
|
<span v-else>{{ expenseSubjectLabel(row.expenseSubject) }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="金额" width="120">
|
<template #default="{ row }">
|
<el-input-number
|
v-if="!formDialog.readonly"
|
v-model="row.amount"
|
:min="0"
|
:precision="2"
|
size="small"
|
controls-position="right"
|
style="width: 100%"
|
@change="onDetailAmountChange"
|
/>
|
<span v-else>{{ row.amount ?? "—" }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="描述" min-width="140">
|
<template #default="{ row }">
|
<el-input v-if="!formDialog.readonly" v-model="row.description" size="small" placeholder="说明" />
|
<span v-else>{{ row.description || "—" }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column v-if="!formDialog.readonly" label="操作" width="70" align="center">
|
<template #default="{ $index }">
|
<el-button type="danger" link size="small" @click="removeExpenseDetail($index)">删除</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
</el-card>
|
|
<el-card class="form-section" shadow="never">
|
<template #header><span class="card-header-title">收款信息</span></template>
|
<el-row :gutter="20">
|
<el-col :span="8">
|
<el-form-item label="收款人" prop="payee">
|
<el-input v-model="form.payee" placeholder="请输入收款人" maxlength="50" />
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="收款账号" prop="payeeAccount">
|
<el-input v-model="form.payeeAccount" placeholder="银行卡号" maxlength="30" />
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="开户支行" prop="bankBranch">
|
<el-input v-model="form.bankBranch" placeholder="开户支行全称" maxlength="100" />
|
</el-form-item>
|
</el-col>
|
</el-row>
|
</el-card>
|
|
<el-card class="form-section" shadow="never">
|
<template #header><span class="card-header-title">附件(发票)</span></template>
|
<el-form-item label-width="0" class="attachment-form-item">
|
<div class="upload-block">
|
<FileUpload v-model:file-list="form.attachmentList" :limit="20" button-text="点击选择文件" />
|
</div>
|
</el-form-item>
|
</el-card>
|
|
<el-card class="form-section" shadow="never">
|
<template #header>
|
<div class="card-header-row">
|
<span class="card-header-title">审批流程</span>
|
<el-button v-if="!formDialog.readonly" type="primary" link size="small" @click="autoAssignApprovalFlow">
|
按规则重新分配
|
</el-button>
|
</div>
|
</template>
|
<el-alert type="success" :title="approvalRuleHint" show-icon :closable="false" class="mb12" />
|
<el-form-item prop="approvalFlowNodes" label-width="0">
|
<ApprovalFlowEditor
|
v-if="!formDialog.readonly"
|
v-model="form.approvalFlowNodes"
|
:user-options="flowUserOptions"
|
@update:model-value="onApprovalFlowChange"
|
/>
|
<ApprovalFlowProgress v-else :nodes="form.approvalFlowNodes" :current-index="form.currentNodeIndex" />
|
<p v-if="!formDialog.readonly" class="flow-tip">系统已按金额与费用类型自动分配审批人,可手动调整。</p>
|
</el-form-item>
|
</el-card>
|
</el-form>
|
<template #footer>
|
<el-button v-if="!formDialog.readonly" type="primary" @click="submitForm">提 交</el-button>
|
<el-button @click="formDialog.visible = false">{{ formDialog.readonly ? "关 闭" : "取 消" }}</el-button>
|
</template>
|
</el-dialog>
|
|
<!-- 详情 -->
|
<el-dialog v-model="detailDialog.visible" title="费用报销详情" width="900px" append-to-body destroy-on-close>
|
<DetailPanel :row="detailRow" />
|
<el-divider content-position="left">审批流程</el-divider>
|
<ApprovalFlowProgress :nodes="detailRow.approvalFlowNodes" :current-index="detailRow.currentNodeIndex ?? 0" />
|
<el-divider content-position="left">审批记录</el-divider>
|
<el-timeline v-if="detailRow.approvalRecords?.length">
|
<el-timeline-item
|
v-for="(rec, i) in detailRow.approvalRecords"
|
:key="i"
|
:type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
|
:timestamp="rec.time"
|
>
|
{{ rec.operatorName }} — {{ approvalActionLabel(rec.result) }}:{{ rec.opinion || "无意见" }}
|
</el-timeline-item>
|
</el-timeline>
|
<el-empty v-else description="暂无审批记录" :image-size="60" />
|
<template #footer>
|
<el-button type="primary" @click="detailDialog.visible = false">关 闭</el-button>
|
</template>
|
</el-dialog>
|
|
<!-- 审批 -->
|
<el-dialog
|
v-model="approveDialog.visible"
|
title="费用报销审批"
|
width="1000px"
|
append-to-body
|
destroy-on-close
|
@closed="approveOpinion = ''"
|
>
|
<DetailPanel :row="approveDialog.row" />
|
<el-divider content-position="left">流程进度</el-divider>
|
<ApprovalFlowProgress
|
:nodes="approveDialog.row?.approvalFlowNodes"
|
:current-index="approveDialog.row?.currentNodeIndex ?? 0"
|
/>
|
<el-form label-width="100px" class="mt16">
|
<el-form-item label="审批意见" required>
|
<el-input
|
v-model="approveOpinion"
|
type="textarea"
|
:rows="3"
|
maxlength="500"
|
show-word-limit
|
placeholder="通过可留空;驳回请填写具体原因(如:发票模糊需重传)"
|
/>
|
</el-form-item>
|
</el-form>
|
<template #footer>
|
<el-button type="success" @click="submitApprove('approved')">通 过</el-button>
|
<el-button type="danger" @click="submitApprove('rejected')">驳 回</el-button>
|
<el-button @click="approveDialog.visible = false">取 消</el-button>
|
</template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup>
|
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
|
import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
|
import ApprovalFlowProgress from "../travel-reimburse/components/ApprovalFlowProgress.vue";
|
import DetailPanel from "./components/DetailPanel.vue";
|
import { useCostReimburse } from "./useCostReimburse.js";
|
|
const cr = useCostReimburse();
|
const {
|
Search,
|
EXPENSE_CATEGORY_OPTIONS,
|
CATEGORY_TEMPLATES,
|
EXPENSE_SUBJECT_OPTIONS,
|
expenseSubjectLabel,
|
searchForm,
|
tableLoading,
|
page,
|
tableData,
|
tableColumn,
|
importInputRef,
|
formRef,
|
form,
|
formDialog,
|
formRules,
|
detailDialog,
|
detailRow,
|
approveDialog,
|
approveOpinion,
|
applicantFormSearchLoading,
|
applicantFormOptions,
|
flowUserOptions,
|
detailTotalAmount,
|
approvalRuleHint,
|
handleQuery,
|
resetSearch,
|
pagination,
|
remoteSearchApplicantForm,
|
userSelectLabel,
|
onApplicantChange,
|
onExpenseCategoryChange,
|
applyTemplate,
|
onDetailAmountChange,
|
onApprovalFlowChange,
|
addExpenseDetail,
|
removeExpenseDetail,
|
syncApplyAmountFromDetails,
|
autoAssignApprovalFlow,
|
openFormDialog,
|
onFormClosed,
|
submitForm,
|
approvalActionLabel,
|
submitApprove,
|
handleExport,
|
handleImportClick,
|
onImportFile,
|
} = cr;
|
</script>
|
|
<style scoped>
|
.mb20 {
|
margin-bottom: 20px;
|
}
|
.mb16 {
|
margin-bottom: 16px;
|
}
|
.mb12 {
|
margin-bottom: 12px;
|
}
|
.mt16 {
|
margin-top: 16px;
|
}
|
.search_form {
|
display: flex;
|
flex-wrap: wrap;
|
align-items: center;
|
justify-content: space-between;
|
gap: 12px;
|
}
|
.search_fields {
|
display: flex;
|
flex-wrap: wrap;
|
align-items: center;
|
gap: 4px;
|
}
|
.search_actions {
|
display: flex;
|
flex-wrap: wrap;
|
gap: 8px;
|
}
|
.search_title {
|
font-size: 14px;
|
color: var(--el-text-color-regular);
|
}
|
.sr-only-input {
|
position: absolute;
|
width: 1px;
|
height: 1px;
|
padding: 0;
|
margin: -1px;
|
overflow: hidden;
|
clip: rect(0, 0, 0, 0);
|
white-space: nowrap;
|
border: 0;
|
}
|
.template-bar {
|
display: flex;
|
flex-wrap: wrap;
|
align-items: center;
|
gap: 8px;
|
}
|
.template-label {
|
font-size: 14px;
|
color: var(--el-text-color-secondary);
|
flex-shrink: 0;
|
}
|
.form-section {
|
margin-bottom: 16px;
|
border: 1px solid var(--el-border-color-lighter);
|
}
|
.form-section :deep(.el-card__header) {
|
padding: 12px 16px;
|
background: var(--el-fill-color-lighter);
|
}
|
.form-section :deep(.el-card__body) {
|
padding: 16px 16px 4px;
|
}
|
.card-header-title {
|
font-size: 15px;
|
font-weight: 600;
|
}
|
.card-header-row {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 12px;
|
}
|
.amount-row {
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
width: 100%;
|
}
|
.amount-input {
|
flex: 1;
|
min-width: 160px;
|
}
|
.attachment-form-item {
|
margin-bottom: 0;
|
}
|
.detail-table {
|
margin-bottom: 0;
|
}
|
.upload-block {
|
width: 100%;
|
}
|
.flow-tip {
|
font-size: 12px;
|
color: var(--el-text-color-secondary);
|
margin-top: 8px;
|
}
|
.cost-reimburse-form-dialog :deep(.el-dialog__body) {
|
padding-top: 12px;
|
}
|
.cost-reimburse-form :deep(.el-form-item) {
|
margin-bottom: 18px;
|
}
|
.cost-reimburse-form :deep(.el-input-number) {
|
width: 100%;
|
}
|
.cost-reimburse-form :deep(.el-row) {
|
margin-bottom: 0;
|
}
|
</style>
|