From 135158c4b7ffb8bb961d2af60a1910db9b595a6f Mon Sep 17 00:00:00 2001 From: weizong song Date: Thu, 5 Mar 2026 12:03:40 +0800 Subject: [PATCH] up --- docs/付款详情打印页-数据与渲染流程.md | 201 +++++++++++++++++++++ src/components/BudgetSourcePickerField.vue | 14 +- src/views/funds/Budget.vue | 91 +++++++++- src/views/funds/BudgetManagement.vue | 32 +++- src/views/payment/IndirectPayment.vue | 182 +++++++++++-------- src/views/payment/PaymentDetailPrint.vue | 56 ++++++ src/views/payment/PaymentQuery.vue | 32 +++- 7 files changed, 516 insertions(+), 92 deletions(-) create mode 100644 docs/付款详情打印页-数据与渲染流程.md diff --git a/docs/付款详情打印页-数据与渲染流程.md b/docs/付款详情打印页-数据与渲染流程.md new file mode 100644 index 0000000..02c07b9 --- /dev/null +++ b/docs/付款详情打印页-数据与渲染流程.md @@ -0,0 +1,201 @@ +# 付款详情打印页:数据与渲染流程梳理 + +**页面 URL**: `http://czemc.localhost/budget/#/payment/payment-detail-print/161`(示例 id=161) +**组件**: `czemc-budget-execution-frontend/src/views/payment/PaymentDetailPrint.vue` +**路由**: `/payment/payment-detail-print/:id`(`src/router/index.js`) + +--- + +## 一、入口与路由 + +| 项目 | 说明 | +|------|------| +| **路由定义** | `path: '/payment/payment-detail-print/:id'`,`name: 'PaymentDetailPrint'` | +| **组件** | `() => import('@/views/payment/PaymentDetailPrint.vue')`,懒加载 | +| **meta** | `title: '付款详情打印'`,`hidden: true`(不显示在菜单) | +| **访问方式** | 直接访问 URL,或从预算执行等页面通过 `router.push({ path: \`/payment/payment-detail-print/${row.payment_id}\` })` 跳转 | + +URL 中的 `161` 为付款 id(`Payment.id`),从 `route.params.id` 读取。 + +--- + +## 二、整体数据与渲染流程概览 + +``` +onMounted + └─ loadPaymentDetail() + ├─ 1) paymentAPI.getDetail(id) → GET /budget/payments/{id} + ├─ 2) payment 赋值,collectFlowId(payment.flow_info.id) // 付款主流程 + ├─ 3) loadPaymentTemplateElements() // 付款分类模板元素 + ├─ 4) loadApprovalFlowDetails() // 审批流程类型字段的流程详情 + ├─ 5) loadRelatedPlannedExpenditure() // 若 related_type=planned_expenditure + ├─ 6) nextTick + setTimeout(300) → renderPrintTemplates() + └─ 7) renderedPrintTemplates 更新后 → 触发 renderPdfAsImages(会议纪要 PDF 等) + +子组件挂载(ContractInfoCard / PlannedExpenditureInfoCard): + └─ PlannedExpenditureTemplateReadonly + └─ 从 flow_bindings / element_values 解析流程 ID → emit('collect-flow-id', id) + └─ 父层 collectFlowId(id) → collectedFlowIds + +watch(collectedFlowIds.size) → renderPrintTemplates() + └─ oaFlowAPI.renderPrintTemplates(flowIds) → POST /oa/flow/render-print-templates + └─ renderedPrintTemplates = res.data → 渲染「相关流程详情」区块 +``` + +--- + +## 三、主数据源:付款详情接口 + +### 3.1 请求 + +- **调用**: `paymentAPI.getDetail(paymentId)`,其中 `paymentId = route.params.id`(如 161) +- **接口**: `GET /budget/payments/{id}` +- **后端**: `backend/Modules/Budget/app/Http/Controllers/Api/PaymentController.php` → `show($id)` + +### 3.2 后端加载与格式化 + +- `Payment::with(['paymentCategory.parent', 'oaFlow', 'elementValues.element'])->findOrFail($id)` +- 使用 `formatPayment($payment, true)` 输出,其中会: + - 按 `oa_flow_id` 查 OA 流程,得到 `flow_info`(id、no、title、status_text、custom_model_id、created_at 等) + - 按 `payment_category_id` 算支付类型与面包屑,得到 `payment_type_info` + - 把 `fields_data` 作为 `fields` 返回(与 element_values 双写一致) + - `includeDetails === true` 时附加 `details: [ formatPaymentLineItemFromPayment($payment) ]`,用于兼容前端对「明细」的引用 + +### 3.3 返回数据结构(与页面用法对应) + +| 字段 | 说明 | 页面中的主要用途 | +|------|------|------------------| +| id, serial_number, status, status_text | 付款标识与状态 | 标题区、状态展示、表格 | +| total_amount, updated_at | 金额与更新时间 | 本次支付、付款确认日期 | +| payment_type_info | { payment_type_text, breadcrumb } | 支付类型一行 | +| flow_info | { id, no, title, status_text, custom_model_id, … } | 流程信息展示、collectFlowId、抽屉跳转 OA | +| description, remarks | 说明、备注 | 表格两行 | +| fields | 模板字段键值(element_id → value) | 支付模板区所有「元素」的取值来源 | +| related_type, related_id | 关联类型与 id | 决定关联内容分支(见下) | +| contract_id | 合同 id(可与 related 并存) | 合同信息卡片、关联合同事前流程 | +| details | 长度 1 的数组,兼容用 | currentDetail 等(打印页用到不多) | + +`payment` 写入 `ref(null)`,请求成功后再赋值为接口返回的 `response.data`,此后整页以 `payment` 为单一数据根进行渲染。 + +--- + +## 四、页面渲染结构(从上到下) + +### 4.1 打印工具栏 + +- 固定于页面右上,内含「打印」「关闭」。 +- 打印前会执行 `renderPdfAsImages()`,把本页所有会议纪要等 PDF 占位渲染成图片,再 `window.print()`。 +- 仅在屏幕显示,`@media print` 下隐藏。 + +### 4.2 页眉 + +- 标题:「付款详情」 +- 打印/归档时间:`printTime`(`new Date().toLocaleString('zh-CN')`) + +### 4.3 付款基本信息(`.section.payment-basic-info`) + +数据一律来自 `payment`: + +1. **固定行** + - 编号、状态、本次支付、付款确认日期(仅当 `status === 'completed'`)、支付类型、流程信息、说明、备注。 +2. **支付模板区(visiblePaymentTemplateElements)** + - 数据:`paymentTemplateElements` 来自 `loadPaymentTemplateElements()`,过滤后得到 `visiblePaymentTemplateElements`。 + - 取值:`payment.fields[el.id]`(及勾选清单备注键 `el.id_remark_${optionValue}`)。 + - 按元素类型分别渲染: + - **checklist**:只读勾选列表 + 备注 + - **meeting_minutes**:`MeetingMinutesField` 只读;若唯一附件为 PDF,下方通栏用 PDF 占位,由 `renderPdfAsImages` 转成图片 + - **detail_table**:表格列来自 `detailTableFieldsMap`(`detailTableFieldAPI.getList(elementId)`),行数据来自 `payment.fields[elementId]`,部门/用户通过 `departmentMap`、`userMap` 转名称 + - **approval_flow**:用 `flowDetailsCache` 展示流程简述,`flowDetailsCache` 由 `loadApprovalFlowDetails()` 调用 `plannedExpenditureAPI.getOaFlowDetails(flowIds)` 填充 + - **附件**:从 `payment.fields[elementId]` 解析为 `{ name, url }` 列表展示 + - 其余:`formatTemplateFieldValue(el, payment.fields[el.id])` 文本或默认展示 + +`loadPaymentTemplateElements` 使用 `payment.payment_category_id` 调 `paymentCategoryAPI.getTemplateElements(categoryId)`,得到该分类下模板元素列表,并预加载所有 `detail_table` 的字段配置与部门/用户列表(若需要)。 + +### 4.4 关联内容区(按 related_type 三分支) + +分支仅由 `relatedTypeCase` 决定,`relatedTypeCase` 来自 `payment.related_type`(`'planned_expenditure' | 'contract' | null/undefined/''` 等视为 `'null'`)。 + +- **relatedTypeCase === 'null'** + - 仅再判断 `payment.contract_id`。 + - 若有合同 id,渲染 **ContractInfoCard**,`contract-id=payment.contract_id`,`embed-planned-expenditures="true"`,用于展示合同信息与「关联合同的事前流程」。 + +- **relatedTypeCase === 'contract'** + - 渲染 **ContractInfoCard**,`contract-id=payment.related_id`,当前付款 id 传入,同样嵌入合同事前流程。 + +- **relatedTypeCase === 'planned_expenditure'** + - 先渲染 **PlannedExpenditureInfoCard**: + - `expenditure`、`template` 来自 `loadRelatedPlannedExpenditure()`(按 `payment.related_id` 调 `plannedExpenditureAPI.getDetail(relatedId)`,再按 `category_id` 拉模板并写入 `relatedPlannedExpenditure`、`relatedPlannedExpenditureTemplate`)。 + - 内部用 `PlannedExpenditureTemplateReadonly`,会 emit `collect-flow-id`。 + - 再若存在 `payment.contract_id`,渲染 **ContractInfoCard**,`embed-planned-expenditures="false"`,只展示合同信息与合同相关支付列表,不再嵌入事前流程。 + +三个分支里,凡是出现 **ContractInfoCard** 且 `embedPlannedExpenditures === true`,或出现 **PlannedExpenditureInfoCard**,其内的 **PlannedExpenditureTemplateReadonly** 都会从模板配置、`flow_bindings`、`element_values` 中解析出流程 id,并向父级 emit `collect-flow-id`,由页面 `collectFlowId` 汇总到 `collectedFlowIds`。 + +### 4.5 相关流程详情(renderedPrintTemplates) + +- **显示条件**: `renderedPrintTemplates.length > 0` +- **数据来源**: + - 流程 id 来自 `collectedFlowIds`(付款主流程 + 合同/事前流程子组件上报的流程 id)。 + - 请求:`oaFlowAPI.renderPrintTemplates(Array.from(collectedFlowIds))` → `POST /oa/flow/render-print-templates`,body 为 `{ flow_ids: number[] }`。 + - 后端对每个 flow_id 用 OA 的打印模版(CustomModel.print_format)渲染 HTML,返回 `{ flow_id, flow_title, flow_info: { no, title, creator_name, created_at }, html }` 等。 +- **渲染方式**: + - 标题:「相关流程详情」 + - 列表:`v-for="(template, idx) in renderedPrintTemplates"`,每条显示「序号、流程编号 - 流程标题(创建人 创建时间)」,正文 `v-html="template.html"`。 + - 分页:整块前 `page-break-before: always`;从第二条起每条前也 `page-break-before: always`。 + +流程 id 收集与「相关流程详情」的详细说明见:`付款详情打印页-相关流程详情渲染说明.md`。 + +--- + +## 五、关键辅助请求一览 + +| 用途 | 调用链 | 接口 | +|------|--------|------| +| 付款主数据 | paymentAPI.getDetail(id) | GET /budget/payments/{id} | +| 付款分类模板元素 | paymentCategoryAPI.getTemplateElements(categoryId) | 见 api 中 paymentCategory 相关 | +| 明细表列配置 | detailTableFieldAPI.getList(elementId) | 见 api 中 detailTableField | +| 部门/用户 | departmentAPI.getTree()、userAPI.getSimpleList() | 部门树、用户简单列表 | +| 审批流程简述 | plannedExpenditureAPI.getOaFlowDetails(flowIds) | 批量取 OA 流程展示信息 | +| 事前流程实体+模板 | plannedExpenditureAPI.getDetail(relatedId)、plannedExpenditureTemplateAPI.getByCategory(categoryId) | 事前流程详情、按分类取模板 | +| 合同相关 | ContractInfoCard 内 contractAPI、paymentAPI、plannedExpenditureAPI 等 | 合同详情、合同下支付列表、关联事前流程等 | +| 相关流程 HTML | oaFlowAPI.renderPrintTemplates(flowIds) | POST /oa/flow/render-print-templates | + +--- + +## 六、状态与缓存(前端) + +- `payment`:当前付款详情,整页主数据源。 +- `paymentTemplateElements` / `visiblePaymentTemplateElements`:模板元素列表与「有值才展示」的过滤结果。 +- `templatesCache`:按事前分类 id 缓存的打印用模板,避免重复请求。 +- `flowDetailsCache`:按流程 id 缓存的流程简述,供审批流程元素展示。 +- `detailTableFieldsMap`:按明细表元素 id 缓存的列配置。 +- `departmentMap` / `userMap`:部门、用户 id→名称。 +- `collectedFlowIds`:当前页及子组件上报的流程 id 集合,用于拉取「相关流程详情」。 +- `renderedPrintTemplates`:接口返回的流程打印 HTML 列表,驱动「相关流程详情」区块。 +- `relatedPlannedExpenditure` / `relatedPlannedExpenditureTemplate`:仅当 `related_type === 'planned_expenditure'` 时有效,用于 PlannedExpenditureInfoCard。 + +--- + +## 七、与 URL 中 id 的对应关系 + +对 `http://czemc.localhost/budget/#/payment/payment-detail-print/161`: + +- `161` 即 `route.params.id`,作为 `paymentId` 传入 `paymentAPI.getDetail(161)`。 +- 若存在,返回的 `Payment` 即 id=161 的付款记录,整页展示、关联内容、流程收集与「相关流程详情」都基于这一条付款数据派生而来。 + +--- + +## 八、相关文件索引 + +| 角色 | 路径 | +|------|------| +| 页面组件 | `czemc-budget-execution-frontend/src/views/payment/PaymentDetailPrint.vue` | +| 路由 | `czemc-budget-execution-frontend/src/router/index.js` | +| 付款接口 | `paymentAPI.getDetail` → `GET /budget/payments/{id}` | +| 付款详情后端 | `backend/Modules/Budget/app/Http/Controllers/Api/PaymentController.php`(show、formatPayment) | +| 合同信息卡片 | `czemc-budget-execution-frontend/src/components/payment-print/ContractInfoCard.vue` | +| 事前流程信息卡片 | `czemc-budget-execution-frontend/src/components/payment-print/PlannedExpenditureInfoCard.vue` | +| 事前模板只读(含 collect-flow-id) | `czemc-budget-execution-frontend/src/components/PlannedExpenditureTemplateReadonly.vue` | +| 相关流程详情渲染说明 | `czemc-budget-execution-frontend/docs/付款详情打印页-相关流程详情渲染说明.md` | +| 流程打印模版 API / 后端 | `oaFlowAPI.renderPrintTemplates`、`backend/Modules/Oa/.../FlowController.php` :: renderPrintTemplates | + +以上为付款详情打印页(含 `/payment/payment-detail-print/161`)的数据与渲染流程梳理,便于从 URL → 接口 → 状态 → 区块逐层对照。 diff --git a/src/components/BudgetSourcePickerField.vue b/src/components/BudgetSourcePickerField.vue index 13f7977..8da9172 100644 --- a/src/components/BudgetSourcePickerField.vue +++ b/src/components/BudgetSourcePickerField.vue @@ -257,9 +257,17 @@ const confirmPick = () => { ? '自有账户' : leaf?.budget_type === 'department' ? '部门预算' - : leaf?.budget_type - ? String(leaf.budget_type) - : '' + : leaf?.budget_type === 'special_fund' + ? '专项资金' + : leaf?.budget_type === 'last_year_carryover' + ? '上一年结转资金' + : leaf?.budget_type === 'offset_prepaid' + ? '抵消预付账款' + : leaf?.budget_type === 'trade_union' + ? '工会' + : leaf?.budget_type + ? String(leaf.budget_type) + : '' const yearLabel = getYearLabel() const display = [yearLabel, typeText, ...names].filter(Boolean).join(' / ') diff --git a/src/views/funds/Budget.vue b/src/views/funds/Budget.vue index 16f75f2..82c7a80 100644 --- a/src/views/funds/Budget.vue +++ b/src/views/funds/Budget.vue @@ -155,7 +155,7 @@ - +
@@ -171,11 +171,25 @@
待使用
¥{{ formatNumber(summary.offsetPrepaidBudget - summary.offsetPrepaidUsed) }}
+
+ +
+ + + +
+
-
执行率
-
- {{ offsetPrepaidExecutionRate }}% -
+
工会
+
¥{{ formatNumber(summary.tradeUnionBudget) }}
+
+
+
已使用
+
¥{{ formatNumber(summary.tradeUnionUsed) }}
+
+
+
待使用
+
¥{{ formatNumber(summary.tradeUnionBudget - summary.tradeUnionUsed) }}
@@ -539,11 +553,13 @@ const summary = ref({ lastYearCarryoverBudget: 0, // 上一年结转资金 projectBudget: 0, // 自有账户 offsetPrepaidBudget: 0, // 抵消预付账款 + tradeUnionBudget: 0, // 工会 departmentExecuted: 0, specialFundUsed: 0, lastYearCarryoverUsed: 0, projectUsed: 0, - offsetPrepaidUsed: 0 + offsetPrepaidUsed: 0, + tradeUnionUsed: 0 }) const budgetTree = ref([]) const loading = ref(false) @@ -597,6 +613,12 @@ const offsetPrepaidExecutionRate = computed(() => { return ((summary.value.offsetPrepaidUsed / summary.value.offsetPrepaidBudget) * 100).toFixed(2) }) +// 计算工会执行率 +const tradeUnionExecutionRate = computed(() => { + if (summary.value.tradeUnionBudget === 0) return '0.00' + return ((summary.value.tradeUnionUsed / summary.value.tradeUnionBudget) * 100).toFixed(2) +}) + // 根据执行率返回颜色 const getSummaryRateColor = (rate) => { const numRate = Number(rate) @@ -747,7 +769,8 @@ const budgetTypeLabel = (type) => { project: '自有账户', special_fund: '专项资金', last_year_carryover: '上一年结转资金', - offset_prepaid: '抵消预付账款' + offset_prepaid: '抵消预付账款', + trade_union: '工会' } return map[type] || '未指定' } @@ -771,13 +794,17 @@ const lastYearCarryoverBudget = computed(() => const offsetPrepaidBudget = computed(() => (budgetTree.value || []).filter(item => (item.budget_type || 'department') === 'offset_prepaid') ) +const tradeUnionBudget = computed(() => + (budgetTree.value || []).filter(item => (item.budget_type || 'department') === 'trade_union') +) const budgetSections = computed(() => [ { key: 'department', label: '部门预算', data: departmentBudget.value, tagType: 'success' }, { key: 'special_fund', label: '专项资金', data: specialFundBudget.value, tagType: 'warning' }, { key: 'last_year_carryover', label: '上一年结转资金', data: lastYearCarryoverBudget.value, tagType: 'danger' }, { key: 'project', label: '自有账户', data: projectBudget.value, tagType: 'info' }, - { key: 'offset_prepaid', label: '抵消预付账款', data: offsetPrepaidBudget.value, tagType: 'primary' } + { key: 'offset_prepaid', label: '抵消预付账款', data: offsetPrepaidBudget.value, tagType: 'primary' }, + { key: 'trade_union', label: '工会', data: tradeUnionBudget.value, tagType: 'info' } ]) const expandAll = (typeKey = null) => { @@ -793,6 +820,8 @@ const expandAll = (typeKey = null) => { list = lastYearCarryoverBudget.value } else if (typeKey === 'offset_prepaid') { list = offsetPrepaidBudget.value + } else if (typeKey === 'trade_union') { + list = tradeUnionBudget.value } else { list = budgetTree.value } @@ -817,6 +846,8 @@ const collapseAll = (typeKey = null) => { list = lastYearCarryoverBudget.value } else if (typeKey === 'offset_prepaid') { list = offsetPrepaidBudget.value + } else if (typeKey === 'trade_union') { + list = tradeUnionBudget.value } else { list = budgetTree.value } @@ -928,12 +959,16 @@ const updateSummary = async () => { // 计算抵消预付账款(budget_type === 'offset_prepaid' 的第一层节点) const offsetPrepaidTotal = offsetPrepaidBudget.value.reduce((sum, n) => sum + Number(n.budget_amount || 0), 0) + // 计算工会(budget_type === 'trade_union' 的第一层节点) + const tradeUnionTotal = tradeUnionBudget.value.reduce((sum, n) => sum + Number(n.budget_amount || 0), 0) + summary.value.totalBudget = total summary.value.departmentBudget = departmentTotal summary.value.specialFundBudget = specialFundTotal summary.value.lastYearCarryoverBudget = lastYearCarryoverTotal summary.value.projectBudget = projectTotal summary.value.offsetPrepaidBudget = offsetPrepaidTotal + summary.value.tradeUnionBudget = tradeUnionTotal // 获取执行统计 await loadExecutionStatistics() @@ -955,6 +990,7 @@ const loadExecutionStatistics = async () => { summary.value.lastYearCarryoverUsed = res.data.last_year_carryover_used || 0 summary.value.projectUsed = res.data.project_used || 0 summary.value.offsetPrepaidUsed = res.data.offset_prepaid_used || 0 + summary.value.tradeUnionUsed = res.data.trade_union_used || 0 } } catch (e) { console.error('加载执行统计失败:', e) @@ -1167,6 +1203,40 @@ onMounted(async () => { font-weight: 700; } +/* 工会通栏 - 青色渐变风格 */ +.summary-row-card-trade-union { + background: linear-gradient(135deg, #0d9488 0%, #99f6e4 100%); + border: none; + box-shadow: 0 4px 12px rgba(13, 148, 136, 0.2); +} + +.summary-row-card-trade-union :deep(.el-card__body) { + padding: 24px 20px; +} + +.summary-row-card-trade-union .summary-row-label { + color: #0f766e; + font-size: 15px; + font-weight: 500; +} + +.summary-row-card-trade-union .summary-row-value { + color: #115e59; + font-size: 24px; + font-weight: 700; +} + +/* 抵消预付账款明细项表头 - 紫色渐变风格(调淡) */ +.budget-level-1-header-trade_union { + background: linear-gradient(135deg, #0d9488 0%, #99f6e4 100%) !important; + color: #115e59 !important; +} + +.budget-level-1-header-trade_union.collapsed { + background: #99f6e4 !important; + color: #115e59 !important; +} + .summary-row { display: flex; align-items: center; @@ -1334,6 +1404,11 @@ onMounted(async () => { border-color: #8b5cf6 !important; } +.type-section-card-trade_union :deep(.type-title .el-tag--dark) { + background-color: #0d9488 !important; + border-color: #0d9488 !important; +} + .type-header { display: flex; justify-content: space-between; diff --git a/src/views/funds/BudgetManagement.vue b/src/views/funds/BudgetManagement.vue index 2519ce1..fad744f 100644 --- a/src/views/funds/BudgetManagement.vue +++ b/src/views/funds/BudgetManagement.vue @@ -330,10 +330,11 @@ import { budgetDataAPI } from '@/utils/api' const budgetTypeOptions = [ { value: 'department', label: '部门预算' }, - { value: 'project', label: '自有账户' }, { value: 'special_fund', label: '专项资金' }, { value: 'last_year_carryover', label: '上一年结转资金' }, - { value: 'offset_prepaid', label: '抵消预付账款' } + { value: 'offset_prepaid', label: '抵消预付账款' }, + { value: 'project', label: '自有账户' }, + { value: 'trade_union', label: '工会' } ] const loading = ref(false) @@ -418,6 +419,7 @@ const formatNumber = (num) => { const getBudgetTypeLabel = (type) => { const map = { department: '部门预算', + trade_union: '工会', project: '自有账户', special_fund: '专项资金', last_year_carryover: '上一年结转资金', @@ -441,13 +443,17 @@ const lastYearCarryoverTree = computed(() => const offsetPrepaidTree = computed(() => (treeData.value || []).filter(item => (item.budget_type || 'department') === 'offset_prepaid') ) +const tradeUnionTree = computed(() => + (treeData.value || []).filter(item => (item.budget_type || 'department') === 'trade_union') +) const typeSections = computed(() => [ { key: 'department', label: '部门预算', data: departmentTree.value, tagType: 'success' }, { key: 'special_fund', label: '专项资金', data: specialFundTree.value, tagType: 'warning' }, { key: 'last_year_carryover', label: '上一年结转资金', data: lastYearCarryoverTree.value, tagType: 'danger' }, { key: 'offset_prepaid', label: '抵消预付账款', data: offsetPrepaidTree.value, tagType: 'primary' }, - { key: 'project', label: '自有账户', data: projectTree.value, tagType: 'info' } + { key: 'project', label: '自有账户', data: projectTree.value, tagType: 'info' }, + { key: 'trade_union', label: '工会', data: tradeUnionTree.value, tagType: 'info' } ]) const setTreeRef = (key, el) => { @@ -699,9 +705,25 @@ const handleSubmit = async () => { submitting.value = true try { const isEdit = !!formData.id + let payload + if (isEdit) { + payload = formData + } else { + // 新增时显式传参,根节点必须带 budget_type,避免丢失「工会」等类型 + payload = { + year_id: formData.year_id, + parent_id: formData.parent_id ?? null, + name: formData.name, + code: formData.code ?? '', + budget_amount: formData.budget_amount, + description: formData.description ?? '', + sort_order: formData.sort_order ?? 0, + budget_type: formData.parent_id ? (formData.budget_type || budgetTypeOptions[0].value) : (formData.budget_type || budgetTypeOptions[0].value) + } + } const response = isEdit - ? await budgetDataAPI.update(formData.id, formData) - : await budgetDataAPI.create(formData) + ? await budgetDataAPI.update(formData.id, payload) + : await budgetDataAPI.create(payload) if (response.code === 0) { ElMessage.success(isEdit ? '更新成功' : '创建成功') diff --git a/src/views/payment/IndirectPayment.vue b/src/views/payment/IndirectPayment.vue index b6d210e..ca8ca9d 100644 --- a/src/views/payment/IndirectPayment.vue +++ b/src/views/payment/IndirectPayment.vue @@ -278,7 +278,8 @@ :on-error="(err) => handleFileUploadError(field.key, err)" :on-remove="(file) => handleFileRemove(field.key, file)" :before-upload="beforeUpload" - :limit="1" + multiple + :limit="10" class="file-upload-component" > @@ -287,7 +288,7 @@ @@ -3419,8 +3420,8 @@ const initializeFormDataFromTemplate = (config) => { }) } } else if (field.element_type === 'file') { - // 文件字段初始化为空字符串 - formData.value[field.key] = '' + // 文件字段初始化为空数组(支持多附件) + formData.value[field.key] = [] } else if (field.element_type === 'meeting_minutes') { // 会议纪要类型初始化为 null formData.value[field.key] = null @@ -3457,8 +3458,8 @@ const initializeFormDataFromTemplate = (config) => { }) } } else if (field.element_type === 'file') { - // 文件字段初始化为空字符串 - formData.value[field.key] = '' + // 文件字段初始化为空数组(支持多附件) + formData.value[field.key] = [] } else if (field.element_type === 'meeting_minutes') { // 会议纪要类型初始化为 null formData.value[field.key] = null @@ -3624,56 +3625,89 @@ const normalizeChecklistData = () => { }) } -// 获取文件列表(用于 el-upload 组件显示) -const getFileList = (fieldKey) => { - const value = formData.value[fieldKey] +// 将各种历史格式统一为附件列表(用于编辑、回显、提交) +const normalizeFileUploadValue = (value) => { if (!value) return [] - - // 如果已经是数组格式(el-upload 需要的格式) + + const normalizeItem = (raw, idx = 0) => { + if (!raw) return null + + if (typeof raw === 'string') { + const s = raw.trim() + if (!s) return null + const fileName = s.split('/').pop() || `附件${idx + 1}` + return { + uid: `legacy-${Date.now()}-${idx}`, + name: fileName, + url: s + } + } + + if (typeof raw === 'object') { + const id = raw.id ?? raw.uid ?? `legacy-${Date.now()}-${idx}` + const name = raw.original_name || raw.file_name || raw.name || `附件${idx + 1}` + const filePath = raw.file_path || (raw.folder && raw.name ? `${raw.folder}/${raw.name}` : '') + const url = raw.url || raw.path || raw.file_url || filePath || '' + return { + uid: id, + name, + url, + response: raw + } + } + + return null + } + if (Array.isArray(value)) { - return value + return value.map((item, idx) => normalizeItem(item, idx)).filter(Boolean) } - - // 如果是字符串,尝试解析为 JSON + if (typeof value === 'string') { try { const parsed = JSON.parse(value) - // 如果是对象,转换为 el-upload 需要的格式 - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return [{ - name: parsed.original_name || parsed.name || '文件', - url: parsed.url || (parsed.folder && parsed.name ? `${parsed.folder}/${parsed.name}` : ''), - uid: parsed.id || Date.now(), - response: parsed // 保存完整的后端数据 - }] - } - // 如果解析后是数组,直接返回 if (Array.isArray(parsed)) { - return parsed + return parsed.map((item, idx) => normalizeItem(item, idx)).filter(Boolean) } - } catch (e) { - // 解析失败,可能是文件路径字符串 - // 尝试从路径中提取文件名 - const fileName = value.split('/').pop() || value - return [{ - name: fileName, - url: value, - uid: Date.now() - }] + const one = normalizeItem(parsed, 0) + return one ? [one] : [] + } catch (_) { + const one = normalizeItem(value, 0) + return one ? [one] : [] } } - - // 如果是对象,转换为 el-upload 需要的格式 - if (value && typeof value === 'object') { - return [{ - name: value.original_name || value.name || '文件', - url: value.url || (value.folder && value.name ? `${value.folder}/${value.name}` : ''), - uid: value.id || Date.now(), - response: value // 保存完整的后端数据 - }] + + const one = normalizeItem(value, 0) + return one ? [one] : [] +} + +// 规范化模板中 file 字段,确保 formData 始终是附件列表 +const normalizeFileUploadData = () => { + if (!templateConfig.value) return + + const normalizeField = (field) => { + if (field?.element_type !== 'file' || !field.key) return + formData.value[field.key] = normalizeFileUploadValue(formData.value[field.key]) } - - return [] + + Object.keys(templateConfig.value).forEach(sectionKey => { + const section = templateConfig.value[sectionKey] + if (section.fields && Array.isArray(section.fields)) { + section.fields.forEach(normalizeField) + } + if (section.rounds && Array.isArray(section.rounds)) { + section.rounds.forEach(round => { + if (round.fields && Array.isArray(round.fields)) { + round.fields.forEach(normalizeField) + } + }) + } + }) +} + +// 获取文件列表(用于 el-upload 组件显示) +const getFileList = (fieldKey) => { + return normalizeFileUploadValue(formData.value[fieldKey]) } // 文件上传成功处理 @@ -3698,8 +3732,22 @@ const handleFileUploadSuccess = (fieldKey, response) => { file_path: fileData.folder && fileData.name ? `${fileData.folder}/${fileData.name}` : null } - // 保存为 JSON 字符串(便于存储和传输) - formData.value[fieldKey] = JSON.stringify(fileInfo) + // 统一存储为数组,便于多附件回显、二次编辑和后续展示 + const currentFiles = normalizeFileUploadValue(formData.value[fieldKey]) + const exists = currentFiles.some(item => { + const idA = item?.response?.id ?? item?.uid + const idB = fileInfo.id + return idA && idB && String(idA) === String(idB) + }) + if (!exists) { + currentFiles.push({ + uid: fileInfo.id || `upload-${Date.now()}`, + name: fileInfo.original_name || fileInfo.name || '文件', + url: fileInfo.file_path || '', + response: fileInfo + }) + } + formData.value[fieldKey] = currentFiles ElMessage.success('上传成功') } else { @@ -3712,8 +3760,15 @@ const handleFileUploadSuccess = (fieldKey, response) => { // 文件移除处理 const handleFileRemove = (fieldKey, file) => { - // 清空该字段的值 - formData.value[fieldKey] = '' + const currentFiles = normalizeFileUploadValue(formData.value[fieldKey]) + const removedUid = file?.uid + const removedName = file?.name + const nextFiles = currentFiles.filter(item => { + if (removedUid && String(item.uid) === String(removedUid)) return false + if (removedName && item.name === removedName && item.url === file?.url) return false + return true + }) + formData.value[fieldKey] = nextFiles ElMessage.success('已移除文件') } @@ -3935,18 +3990,9 @@ const formatFieldValue = (value, elementType) => { return value } if (elementType === 'file') { - // 文件字段:尝试解析 JSON 字符串获取文件名 - try { - const fileInfo = typeof value === 'string' ? JSON.parse(value) : value - if (fileInfo && typeof fileInfo === 'object') { - return fileInfo.original_name || fileInfo.name || '文件' - } - } catch (e) { - // 解析失败,可能是文件路径字符串 - const fileName = value.split('/').pop() || value - return fileName - } - return value + const files = normalizeFileUploadValue(value) + if (files.length === 0) return '-' + return files.map(file => file.name || '文件').join(',') } return value } @@ -3964,16 +4010,7 @@ const formatFieldValueByType = (value, type) => { case 'number': return typeof value === 'number' ? value.toLocaleString('zh-CN') : value case 'file': - // 如果是 JSON 字符串,解析后显示文件名 - try { - const files = JSON.parse(value) - if (Array.isArray(files)) { - return files.map(f => f.name || f).join(', ') - } - } catch (e) { - return value - } - return value + return normalizeFileUploadValue(value).map(f => f.name || '文件').join(', ') || '-' default: return value } @@ -6738,9 +6775,10 @@ const loadDraftData = async (id) => { } } }) - // 规范化勾选清单数据格式(确保数组格式正确,补充缺失的备注字段) + // 规范化勾选清单与文件上传数据(兼容旧值,确保可二次编辑) await nextTick() normalizeChecklistData() + normalizeFileUploadData() // related_contract 已移除,无需恢复合同字段 } diff --git a/src/views/payment/PaymentDetailPrint.vue b/src/views/payment/PaymentDetailPrint.vue index 2b67c70..e995ca6 100644 --- a/src/views/payment/PaymentDetailPrint.vue +++ b/src/views/payment/PaymentDetailPrint.vue @@ -1319,6 +1319,56 @@ onMounted(async () => { height: auto; } +/* 相关流程详情内 relation 类型:每行数据做成卡片,参照本页「关联合同的事前流程」related-expenditure-block */ +.flow-template-content :deep(.relation-cards) { + width: 100%; +} + +.flow-template-content :deep(.relation-row-card) { + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 14px 14px 10px; + margin-top: 12px; +} + +.flow-template-content :deep(.relation-row-card:first-child) { + margin-top: 0; +} + +.flow-template-content :deep(.relation-row-card .relation-card-table) { + width: 100%; + border-collapse: collapse; + margin: 0; + border: none; +} + +.flow-template-content :deep(.relation-row-card .relation-card-table tr) { + border-bottom: 1px solid #f0f2f4; +} + +.flow-template-content :deep(.relation-row-card .relation-card-table tr:last-child) { + border-bottom: none; +} + +.flow-template-content :deep(.relation-row-card .relation-card-table td) { + padding: 8px 12px; + vertical-align: top; + word-break: break-word; + border: none; + line-height: 1.4; +} + +.flow-template-content :deep(.relation-row-card .relation-card-table .label) { + width: 120px; + font-weight: 700; + color: #111827; + background: #f9fafb; +} + +.flow-template-content :deep(.relation-row-card .relation-card-table .value) { + color: #374151; +} + .subsection-title { font-size: 14px; font-weight: 700; @@ -1648,6 +1698,12 @@ onMounted(async () => { height: auto; } + /* relation 每行卡片:打印时保留边框与布局 */ + .flow-template-content :deep(.relation-row-card) { + border: 1px solid #000; + page-break-inside: avoid; + } + /* 打印文件展示样式 */ .flow-template-content :deep(.print-image img) { max-width: 100%; diff --git a/src/views/payment/PaymentQuery.vue b/src/views/payment/PaymentQuery.vue index 2aaf198..efc6f48 100644 --- a/src/views/payment/PaymentQuery.vue +++ b/src/views/payment/PaymentQuery.vue @@ -582,8 +582,24 @@ @check-change="onConfirmTreeCheckChange" /> +
+
工会
+ +
暂无数据 @@ -714,12 +730,14 @@ const confirmProjectTreeRef = ref(null) const confirmSpecialFundTreeRef = ref(null) const confirmLastYearCarryoverTreeRef = ref(null) const confirmOffsetPrepaidTreeRef = ref(null) +const confirmTradeUnionTreeRef = ref(null) const confirmTreeRefs = { department: confirmDepartmentTreeRef, project: confirmProjectTreeRef, specialFund: confirmSpecialFundTreeRef, lastYear: confirmLastYearCarryoverTreeRef, - offsetPrepaid: confirmOffsetPrepaidTreeRef + offsetPrepaid: confirmOffsetPrepaidTreeRef, + tradeUnion: confirmTradeUnionTreeRef } // 以下旧的预算来源修改对话框相关状态已废弃 @@ -760,6 +778,9 @@ const confirmLastYearCarryoverTree = computed(() => { const confirmOffsetPrepaidTree = computed(() => { return (confirmTreeData.value || []).filter((n) => n?.budget_type === 'offset_prepaid') }) +const confirmTradeUnionTree = computed(() => { + return (confirmTreeData.value || []).filter((n) => n?.budget_type === 'trade_union') +}) const confirmAllocationTotal = computed(() => { return (confirmPendingAllocations.value || []).reduce((sum, item) => { return sum + (parseFloat(item.allocated_amount) || 0) @@ -1369,6 +1390,7 @@ const getBudgetTypeText = (budgetType) => { if (budgetType === 'special_fund') return '专项资金' if (budgetType === 'last_year_carryover') return '上一年结转资金' if (budgetType === 'offset_prepaid') return '抵消预付账款' + if (budgetType === 'trade_union') return '工会' return '部门预算' } @@ -1462,7 +1484,8 @@ const setConfirmCheckedIds = (ids) => { confirmTreeRefs.project.value, confirmTreeRefs.specialFund.value, confirmTreeRefs.lastYear.value, - confirmTreeRefs.offsetPrepaid.value + confirmTreeRefs.offsetPrepaid.value, + confirmTreeRefs.tradeUnion.value ].filter(Boolean) refs.forEach((tree) => { if (typeof tree.setCheckedKeys === 'function') { @@ -1481,7 +1504,8 @@ const collectConfirmCheckedLeafNodes = () => { confirmTreeRefs.project.value, confirmTreeRefs.specialFund.value, confirmTreeRefs.lastYear.value, - confirmTreeRefs.offsetPrepaid.value + confirmTreeRefs.offsetPrepaid.value, + confirmTreeRefs.tradeUnion.value ].filter(Boolean) const nodes = [] refs.forEach((tree) => {