src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
@@ -1,12 +1,8 @@ import { matchBusinessTypeValue } from "../approve-list/approveListConstants.js"; /** * 各业务模块与审批模板类型的映射(配置化入口) * * 使用方式: * 1. 在业务页引入 ApprovalTemplateBindDialog,传入 moduleKey * 2. 或在表单内嵌 ApprovalTemplateFormSection + useApprovalTemplateBinding({ moduleKey }) * * businessType:若后端 TypeEnums 已固定 code,可直接写死 value;否则用 typeLabels 按名称匹配 * 各业务模块与审批模板类型的映射(配置化入口) * businessType 与后端 TypeEnums / listPage 约定一致(写死枚举值) */ export const APPROVAL_MODULE_KEYS = { REGULAR: "regular", @@ -20,16 +16,30 @@ ENTERPRISE_NEWS: "enterprise_news", }; /** 审批实例 listPage / 保存 使用的 businessType 枚举 */ export const APPROVAL_BUSINESS_TYPE = { [APPROVAL_MODULE_KEYS.REGULAR]: 10, [APPROVAL_MODULE_KEYS.TRANSFER]: 11, [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: 13, [APPROVAL_MODULE_KEYS.LEAVE]: 14, [APPROVAL_MODULE_KEYS.OVERTIME]: 15, [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: 16, [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: 17, [APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS]: 18, }; /** @type {Record<string, import('./approvalModuleRegistry.js').ApprovalModuleConfig>} */ export const APPROVAL_MODULE_REGISTRY = { [APPROVAL_MODULE_KEYS.REGULAR]: { label: "转正申请", approvalType: "regular", businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.REGULAR], typeLabels: ["转正", "转正申请"], }, [APPROVAL_MODULE_KEYS.TRANSFER]: { label: "调岗申请", approvalType: "transfer", businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.TRANSFER], typeLabels: ["调岗", "调动", "调岗申请", "调动申请"], }, [APPROVAL_MODULE_KEYS.RESIGN]: { @@ -40,31 +50,37 @@ [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: { label: "工作交接", approvalType: "work_handover", businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.WORK_HANDOVER], typeLabels: ["工作交接", "交接", "工作交接审批"], }, [APPROVAL_MODULE_KEYS.LEAVE]: { label: "请假申请", approvalType: "leave", businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.LEAVE], typeLabels: ["请假", "请假申请", "请假审批"], }, [APPROVAL_MODULE_KEYS.OVERTIME]: { label: "加班申请", approvalType: "overtime", businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.OVERTIME], typeLabels: ["加班", "加班申请", "加班审批"], }, [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: { label: "差旅报销", approvalType: "travel_reimburse", typeLabels: ["差旅", "差旅报销"], businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE], typeLabels: ["差旅", "差旅报销", "出差报销"], }, [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: { label: "费用报销", approvalType: "cost_reimburse", businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.COST_REIMBURSE], typeLabels: ["费用", "费用报销"], }, [APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS]: { label: "企业新闻", approvalType: "enterprise_news", businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS], typeLabels: ["企业新闻", "新闻", "新闻发布"], }, }; @@ -72,9 +88,9 @@ /** * @typedef {object} ApprovalModuleConfig * @property {string} label * @property {string} [approvalType] 列表样式用 * @property {string|number} [businessType] 与 TypeEnums value 一致时可写死 * @property {string[]} [typeLabels] 与 TypeEnums label 模糊匹配 * @property {string} [approvalType] * @property {string|number} [businessType] * @property {string[]} [typeLabels] */ export function getApprovalModuleConfig(moduleKey) { @@ -82,23 +98,25 @@ return APPROVAL_MODULE_REGISTRY[moduleKey] || null; } /** 列表查询默认 businessType(与审批列表 listPage 约定一致) */ /** 列表查询 businessType(优先配置枚举,不再回退 approvalType 字符串) */ export function getModuleListBusinessType(moduleKey) { const cfg = getApprovalModuleConfig(moduleKey); if (!cfg) return ""; if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType; return cfg.approvalType || ""; return APPROVAL_BUSINESS_TYPE[moduleKey] ?? ""; } /** 从 TypeEnums 选项中解析本模块的 businessType(与审批列表下拉一致) */ /** 从 TypeEnums 解析本模块 businessType;已配置枚举时直接返回 */ export function resolveModuleBusinessType(moduleKey, typeOptions = []) { const cfg = getApprovalModuleConfig(moduleKey); if (!cfg) return null; if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType; const fixed = getModuleListBusinessType(moduleKey); if (fixed != null && fixed !== "") return fixed; const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean); const hitByLabel = (typeOptions || []).find((opt) => { const optLabel = String(opt?.label || "").trim(); const optLabel = String(opt?.label || opt?.name || "").trim(); if (!optLabel) return false; return labels.some( (l) => optLabel === l || optLabel.includes(l) || l.includes(optLabel) @@ -106,31 +124,24 @@ }); if (hitByLabel?.value != null && hitByLabel.value !== "") return hitByLabel.value; if (cfg.approvalType) { const hitByValue = (typeOptions || []).find( (opt) => matchBusinessTypeValue(opt?.value, cfg.approvalType) || matchBusinessTypeValue(opt?.code, cfg.approvalType) ); if (hitByValue?.value != null && hitByValue.value !== "") return hitByValue.value; } return cfg.approvalType || null; } /** 收集与模块相关的全部 businessType 取值(枚举值 + approvalType),用于模板列表过滤 */ /** 列表/模板过滤用的 businessType 集合 */ export function getModuleMatchingBusinessTypes(moduleKey, typeOptions = []) { const cfg = getApprovalModuleConfig(moduleKey); if (!cfg) return []; const fixed = getModuleListBusinessType(moduleKey); if (fixed != null && fixed !== "") return [fixed]; const values = new Set(); const primary = resolveModuleBusinessType(moduleKey, typeOptions); if (primary != null && primary !== "") values.add(primary); if (cfg.approvalType) values.add(cfg.approvalType); const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean); for (const opt of typeOptions || []) { const optLabel = String(opt?.label || "").trim(); const optLabel = String(opt?.label || opt?.name || "").trim(); if (!optLabel) continue; const matched = labels.some( (l) => optLabel === l || optLabel.includes(l) || l.includes(optLabel) src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalInstanceModule.js
@@ -57,11 +57,13 @@ const moduleConfig = computed(() => getApprovalModuleConfig(moduleKey)); const businessTypeOptions = ref([]); /** 与审批列表一致:优先用 TypeEnums 的 value,匹配不到再回退 approvalType */ /** 列表查询 businessType:优先 registry 写死枚举,再回退 TypeEnums */ const defaultListBusinessType = computed(() => { const fixed = getModuleListBusinessType(moduleKey); if (fixed != null && fixed !== "") return fixed; const resolved = resolveModuleBusinessType(moduleKey, businessTypeOptions.value); if (resolved != null && resolved !== "") return resolved; return getModuleListBusinessType(moduleKey); return ""; }); async function loadBusinessTypeOptions() { src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
@@ -1,8 +1,8 @@ <!--OA模块:费用报销--> <!--OA模块:费用报销(审批实例 listPage,businessType=17)--> <template> <div class="app-container"> <div class="search_form mb20"> <div class="search_fields"> <div> <span class="search_title">申请人:</span> <el-input v-model="searchForm.applicantKeyword" @@ -10,40 +10,15 @@ placeholder="姓名或编号" clearable :prefix-icon="Search" @keyup.enter="handleQuery" @keyup.enter="onSearch" /> <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 type="primary" style="margin-left: 10px" @click="onSearch">搜索</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> <el-button type="primary" @click="openAddWithTemplate">新增费用报销</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" @@ -53,404 +28,123 @@ :isSelection="false" :tableLoading="tableLoading" :total="page.total" @pagination="pagination" @pagination="onPagination" /> </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> <ApprovalInstanceSubmitDialog v-model="submitDialog.visible" :title="submitDialogTitle" :form="submitForm" :rules="submitFormRules" :fields="submitFormFields" :active-template="activeTemplate" :user-options="flowUserOptions" :is-edit="isSubmitEdit" :saving="submitSaving" :form-ref="submitFormRef" flow-attachments-only @submit="onSubmit" /> <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> <ApprovalTemplateBindDialog v-model:visible="templateBindVisible" :module-key="APPROVAL_MODULE_KEYS.COST_REIMBURSE" skip-form-confirm @confirm="onTemplateBound" @closed="onTemplateBindClosed" /> <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> <ApprovalInstanceDetailDialog v-model="detailDialog.visible" title="费用报销详情" :row="detailRow" @edit="openEditFromDetail" /> </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"; import { Search } from "@element-plus/icons-vue"; import { ElMessage } from "element-plus"; import { onMounted, reactive } from "vue"; import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue"; import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue"; import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue"; import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js"; import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js"; import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js"; import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js"; const cr = useCostReimburse(); const searchForm = reactive({ applicantKeyword: "" }); const mod = useApprovalInstanceModule({ moduleKey: APPROVAL_MODULE_KEYS.COST_REIMBURSE, buildExtraListParams(sf) { const extra = {}; const kw = (sf?.applicantKeyword || "").trim(); if (kw && /[\u4e00-\u9fa5]/.test(kw)) extra.applicantName = kw; return extra; }, }); const { Search, EXPENSE_CATEGORY_OPTIONS, CATEGORY_TEMPLATES, EXPENSE_SUBJECT_OPTIONS, expenseSubjectLabel, searchForm, tableData, 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, submitDialog, submitForm, approvalActionLabel, submitApprove, handleExport, handleImportClick, onImportFile, } = cr; submitFormRef, submitSaving, isSubmitEdit, activeTemplate, submitFormFields, submitFormRules, submitDialogTitle, templateBindVisible, handleQuery, initModuleList, pagination, openAddWithTemplate, onTemplateBound, onTemplateBindClosed, openEditFromDetail, submitInstanceForm, buildTableActions, } = mod; const { flowUserOptions, loadFlowUsers } = useFlowUserOptions(); const tableColumn = buildInstanceTableColumns(tableData, buildTableActions); function onSearch() { handleQuery(searchForm); } function resetSearch() { searchForm.applicantKeyword = ""; onSearch(); } function onPagination(obj) { pagination(obj, searchForm); } async function onSubmit() { const ok = await submitInstanceForm({ skipValidate: true }); if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功"); } onMounted(async () => { loadFlowUsers(); await initModuleList(searchForm); }); </script> <style scoped> .mb20 { margin-bottom: 20px; } .mb16 { margin-bottom: 16px; } .mb12 { margin-bottom: 12px; } .mt16 { margin-top: 16px; } .search_form { display: flex; @@ -459,98 +153,8 @@ 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> src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
@@ -1,4 +1,4 @@ <!--OA模块:差旅报销--> <!--OA模块:差旅报销(审批实例 listPage,businessType=16)--> <template> <div class="app-container"> <div class="search_form mb20"> @@ -10,40 +10,15 @@ placeholder="姓名或编号" clearable :prefix-icon="Search" @keyup.enter="handleQuery" @keyup.enter="onSearch" /> <span class="search_title" style="margin-left: 12px">出差开始:</span> <el-date-picker v-model="searchForm.travelStartFrom" 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.travelEndTo" type="date" placeholder="结束日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD" style="width: 150px" clearable /> <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button> <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</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> <el-button type="primary" @click="openAddWithTemplate">新增差旅报销</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" @@ -53,462 +28,123 @@ :isSelection="false" :tableLoading="tableLoading" :total="page.total" @pagination="pagination" @pagination="onPagination" /> </div> <!-- 新增 / 编辑 --> <el-dialog v-model="formDialog.visible" :title="formDialog.title" width="1120px" append-to-body destroy-on-close class="travel-reimburse-form-dialog" @closed="onFormClosed" > <el-alert v-if="budgetHint.visible" :title="budgetHint.title" :type="budgetHint.type" :description="budgetHint.description" show-icon :closable="false" class="mb16" /> <el-alert v-if="overBudgetWarnings.length" type="warning" show-icon :closable="false" class="mb16"> <template #title>差旅标准超支提醒(需特批)</template> <ul class="warn-list"> <li v-for="(w, i) in overBudgetWarnings" :key="i">{{ w }}</li> </ul> </el-alert> <ApprovalInstanceSubmitDialog v-model="submitDialog.visible" :title="submitDialogTitle" :form="submitForm" :rules="submitFormRules" :fields="submitFormFields" :active-template="activeTemplate" :user-options="flowUserOptions" :is-edit="isSubmitEdit" :saving="submitSaving" :form-ref="submitFormRef" flow-attachments-only @submit="onSubmit" /> <el-form ref="formRef" :model="form" :rules="formRules" label-width="120px" class="travel-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="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="travelStartTime"> <el-date-picker v-model="form.travelStartTime" type="datetime" placeholder="开始时间" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width: 100%" @change="onTravelRangeChange" /> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="出差结束" prop="travelEndTime"> <el-date-picker v-model="form.travelEndTime" type="datetime" placeholder="结束时间" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width: 100%" @change="onTravelRangeChange" /> </el-form-item> </el-col> </el-row> <el-row :gutter="20"> <el-col :span="8"> <el-form-item label="出差天数"> <el-input :model-value="travelDaysDisplay" readonly> <template #append>天</template> </el-input> </el-form-item> </el-col> <el-col :span="8"> <el-form-item label="出差地" prop="departurePlace"> <el-input v-model="form.departurePlace" placeholder="出发城市" @blur="recalcTravelStandards" /> </el-form-item> </el-col> <el-col :span="8"> <el-form-item label="目的地" prop="destination"> <el-input v-model="form.destination" placeholder="目的城市" @blur="recalcTravelStandards" /> </el-form-item> </el-col> </el-row> </el-card> <ApprovalTemplateBindDialog v-model:visible="templateBindVisible" :module-key="APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE" skip-form-confirm @confirm="onTemplateBound" @closed="onTemplateBindClosed" /> <el-card class="form-section" shadow="never"> <template #header> <div class="card-header-row"> <span class="card-header-title">差旅标准</span> <el-text type="info" size="small">{{ travelTierLabel }} · 生活补贴建议 {{ suggestedLivingSubsidy }} 元</el-text> </div> </template> <el-row :gutter="20"> <el-col :span="8"> <el-form-item label="酒店标准"> <el-input-number v-model="form.hotelStandard" :min="0" :precision="2" controls-position="right" style="width: 100%" @change="recalcTravelStandards" /> </el-form-item> </el-col> <el-col :span="8"> <el-form-item label="住宿天数"> <el-input-number v-model="form.hotelDays" :min="0" :max="365" :precision="0" controls-position="right" style="width: 100%" @change="recalcTravelStandards" /> </el-form-item> </el-col> <el-col :span="8"> <el-form-item label="生活补贴"> <el-input-number v-model="form.livingSubsidy" :min="0" :precision="2" controls-position="right" style="width: 100%" @change="recalcTravelStandards" /> </el-form-item> </el-col> </el-row> <el-row :gutter="20"> <el-col :span="8"> <el-form-item label="交通补贴"> <el-input :model-value="String(suggestedTransportSubsidy)" readonly><template #append>元</template></el-input> </el-form-item> </el-col> <el-col :span="8"> <el-form-item label="住宿限额"> <el-input :model-value="String(suggestedHotelLimit)" readonly><template #append>元</template></el-input> </el-form-item> </el-col> <el-col :span="8"> <el-form-item label="特批标记"> <el-tag :type="form.needSpecialApproval ? 'danger' : 'success'" effect="plain"> {{ form.needSpecialApproval ? "超支需特批" : "在标准范围内" }} </el-tag> </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-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" /> <el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails"> 按明细汇总 {{ detailTotalAmount }} 元 </el-button> </div> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="收款人" prop="payee"> <el-input v-model="form.payee" placeholder="请输入收款人" maxlength="50" /> </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%" @change="recalcTravelStandards" > <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-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><span class="card-header-title">审批流程</span></template> <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" /> <ApprovalFlowProgress class="mt16" :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="审批意见"> <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> <ApprovalInstanceDetailDialog v-model="detailDialog.visible" title="差旅报销详情" :row="detailRow" @edit="openEditFromDetail" /> </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 "./components/ApprovalFlowProgress.vue"; import DetailPanel from "./components/DetailPanel.vue"; import { useTravelReimburse } from "./useTravelReimburse.js"; import { Search } from "@element-plus/icons-vue"; import { ElMessage } from "element-plus"; import { onMounted, reactive } from "vue"; import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue"; import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue"; import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue"; import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js"; import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js"; import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js"; import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js"; const tr = useTravelReimburse(); const searchForm = reactive({ applicantKeyword: "" }); const mod = useApprovalInstanceModule({ moduleKey: APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE, buildExtraListParams(sf) { const extra = {}; const kw = (sf?.applicantKeyword || "").trim(); if (kw && /[\u4e00-\u9fa5]/.test(kw)) extra.applicantName = kw; return extra; }, }); const { Search, EXPENSE_SUBJECT_OPTIONS, expenseSubjectLabel, searchForm, tableData, tableLoading, page, tableData, tableColumn, importInputRef, formRef, form, formDialog, formRules, detailDialog, detailRow, approveDialog, approveOpinion, applicantFormSearchLoading, applicantFormOptions, flowUserOptions, travelDaysDisplay, travelTierLabel, suggestedLivingSubsidy, suggestedTransportSubsidy, suggestedHotelLimit, detailTotalAmount, overBudgetWarnings, budgetHint, handleQuery, resetSearch, pagination, remoteSearchApplicantForm, userSelectLabel, onApplicantChange, recalcTravelStandards, onTravelRangeChange, onDetailAmountChange, onApprovalFlowChange, addExpenseDetail, removeExpenseDetail, syncApplyAmountFromDetails, openFormDialog, onFormClosed, submitDialog, submitForm, openDetail, approvalActionLabel, submitApprove, handleExport, handleImportClick, onImportFile, } = tr; submitFormRef, submitSaving, isSubmitEdit, activeTemplate, submitFormFields, submitFormRules, submitDialogTitle, templateBindVisible, handleQuery, initModuleList, pagination, openAddWithTemplate, onTemplateBound, onTemplateBindClosed, openEditFromDetail, submitInstanceForm, buildTableActions, } = mod; const { flowUserOptions, loadFlowUsers } = useFlowUserOptions(); const tableColumn = buildInstanceTableColumns(tableData, buildTableActions); function onSearch() { handleQuery(searchForm); } function resetSearch() { searchForm.applicantKeyword = ""; onSearch(); } function onPagination(obj) { pagination(obj, searchForm); } async function onSubmit() { const ok = await submitInstanceForm({ skipValidate: true }); if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功"); } onMounted(async () => { loadFlowUsers(); await initModuleList(searchForm); }); </script> <style scoped> .mb20 { margin-bottom: 20px; } .mb16 { margin-bottom: 16px; } .mb8 { margin-bottom: 8px; } .mt16 { margin-top: 16px; } .search_form { display: flex; @@ -517,107 +153,8 @@ justify-content: space-between; gap: 12px; } .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; } .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; } .w-full { width: 100%; } .attachment-form-item { margin-bottom: 0; } .detail-table { margin-bottom: 0; } .section-title { font-size: 15px; font-weight: 600; margin: 8px 0 12px; color: var(--el-text-color-primary); border-left: 3px solid var(--el-color-primary); padding-left: 8px; } .field-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; } .warn-list { margin: 0; padding-left: 18px; } .detail-toolbar { margin-bottom: 8px; } .upload-block { width: 100%; } .flow-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 8px; } .sync-btn { margin-top: 4px; } .travel-reimburse-form-dialog :deep(.el-dialog__body) { padding-top: 12px; } .travel-reimburse-form :deep(.el-form-item) { margin-bottom: 18px; } .travel-reimburse-form :deep(.el-input-number) { width: 100%; } .travel-reimburse-form :deep(.el-row) { margin-bottom: 0; } </style>