weizong song 4 months ago
parent 8f917dc082
commit 135158c4b7

@ -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 → 接口 → 状态 → 区块逐层对照。

@ -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(' / ')

@ -155,7 +155,7 @@
</div>
</el-card>
<!-- 抵消预付账款统计通栏放在最后 -->
<!-- 抵消预付账款统计通栏 -->
<el-card v-if="section.key === 'offset_prepaid'" class="summary-row-card summary-row-card-offset-prepaid" shadow="never">
<div class="summary-row">
<div class="summary-row-items">
@ -171,11 +171,25 @@
<div class="summary-row-label">待使用</div>
<div class="summary-row-value">¥{{ formatNumber(summary.offsetPrepaidBudget - summary.offsetPrepaidUsed) }}</div>
</div>
</div>
</div>
</el-card>
<!-- 工会统计通栏排在现有大类最后 -->
<el-card v-if="section.key === 'trade_union'" class="summary-row-card summary-row-card-trade-union" shadow="never">
<div class="summary-row">
<div class="summary-row-items">
<div class="summary-row-item">
<div class="summary-row-label">执行率</div>
<div class="summary-row-value" :style="{ color: getSummaryRateColor(offsetPrepaidExecutionRate) }">
{{ offsetPrepaidExecutionRate }}%
</div>
<div class="summary-row-label">工会</div>
<div class="summary-row-value">¥{{ formatNumber(summary.tradeUnionBudget) }}</div>
</div>
<div class="summary-row-item">
<div class="summary-row-label">已使用</div>
<div class="summary-row-value">¥{{ formatNumber(summary.tradeUnionUsed) }}</div>
</div>
<div class="summary-row-item">
<div class="summary-row-label">待使用</div>
<div class="summary-row-value">¥{{ formatNumber(summary.tradeUnionBudget - summary.tradeUnionUsed) }}</div>
</div>
</div>
</div>
@ -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;

@ -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 ? '更新成功' : '创建成功')

@ -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"
>
<el-button type="primary">
@ -287,7 +288,7 @@
</el-button>
<template #tip>
<div class="el-upload__tip">
支持上传单个文件文件大小不超过10MB
支持上传多个文件单个文件大小不超过10MB
</div>
</template>
</el-upload>
@ -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
}

@ -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%;

@ -582,8 +582,24 @@
@check-change="onConfirmTreeCheckChange"
/>
</div>
<div v-if="confirmTradeUnionTree.length" class="tree-block" style="margin-top:16px;">
<div class="tree-title">工会</div>
<el-tree
ref="confirmTradeUnionTreeRef"
:data="confirmTradeUnionTree"
node-key="id"
:props="confirmTreeProps"
highlight-current
default-expand-all
:expand-on-click-node="false"
show-checkbox
check-strictly
:check-on-click-node="true"
@check-change="onConfirmTreeCheckChange"
/>
</div>
<div
v-if="!confirmTreeLoading && confirmDepartmentTree.length === 0 && confirmProjectTree.length === 0 && confirmSpecialFundTree.length === 0 && confirmLastYearCarryoverTree.length === 0 && confirmOffsetPrepaidTree.length === 0"
v-if="!confirmTreeLoading && confirmDepartmentTree.length === 0 && confirmProjectTree.length === 0 && confirmSpecialFundTree.length === 0 && confirmLastYearCarryoverTree.length === 0 && confirmOffsetPrepaidTree.length === 0 && confirmTradeUnionTree.length === 0"
style="text-align: center; color: #909399; padding: 32px 0"
>
暂无数据
@ -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) => {

Loading…
Cancel
Save