You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cz-hjjc-budget/src/components/PlannedExpenditureTemplateR...

1762 lines
53 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div v-if="template && template.config" class="template-readonly">
<!-- 遍历模板配置的各个区域 -->
<div
v-for="(section, sectionKey) in template.config"
:key="sectionKey"
class="template-section"
v-show="shouldRenderSection(section)"
>
<!-- 区域标题 -->
<h5 v-if="section.title" class="section-title">{{ section.title }}</h5>
<!-- 普通字段区域 -->
<template v-if="section.fields && Array.isArray(section.fields)">
<table class="info-table">
<template v-for="field in getVisibleFields(section)" :key="field.key || field.element_id">
<tr>
<td class="label">{{ field.label || field.name || '-' }}</td>
<td class="value">
<template v-if="getFieldValue(field)">
<!-- 文本类型 -->
<span v-if="field.element_type === 'text' || field.element_type === 'textarea'">
{{ getFieldValue(field) }}
</span>
<!-- 数字类型 -->
<span v-else-if="field.element_type === 'number'">
{{ formatNumber(getFieldValue(field)) }}
</span>
<!-- 日期类型 -->
<span v-else-if="field.element_type === 'date'">
{{ formatDate(getFieldValue(field)) }}
</span>
<!-- 勾选清单类型 -->
<div v-else-if="field.element_type === 'checklist'" class="readonly-checklist">
<!-- 有配置 options按"勾选/备注"过滤展示 -->
<template v-if="Array.isArray(getChecklistOptions(field)) && getChecklistOptions(field).length">
<div
v-for="(opt, idx) in (getChecklistOptions(field) || [])"
:key="opt.value || idx"
v-show="isChecklistChecked(getFieldValue(field), opt.value) || getChecklistRemark(field, opt.value)"
class="checklist-option-item"
>
<div class="checklist-option-wrapper">
<el-checkbox
:model-value="isChecklistChecked(getFieldValue(field), opt.value)"
disabled
class="checklist-checkbox"
>
{{ `${idx + 1}、${opt.label}` }}
</el-checkbox>
<span
v-if="getChecklistRemark(field, opt.value)"
class="checklist-remark-text"
>
{{ getChecklistRemark(field, opt.value) }}
</span>
</div>
</div>
</template>
<!-- 没有 options兜底直接展示已选值避免"空白" -->
<template v-else>
<div v-if="getChecklistFallbackItems(field).length" class="checklist-fallback">
<div
v-for="(v, idx) in getChecklistFallbackItems(field)"
:key="`${v}_${idx}`"
class="checklist-fallback-item"
>
{{ `${idx + 1}、${v}` }}
</div>
</div>
<span v-else class="empty-value">-</span>
</template>
</div>
<!-- 会议纪要类型 -->
<div v-else-if="field.element_type === 'meeting_minutes'">
<MeetingMinutesField
:model-value="getFieldValue(field)"
readonly
:title-only="getMeetingMinutesUsageState(field).isDuplicate"
:repeat-notice="getMeetingMinutesUsageState(field).isDuplicate ? REPEATED_MEETING_MINUTES_MESSAGE : ''"
/>
</div>
<!-- 明细表格类型(卡片渲染) -->
<div v-else-if="field.element_type === 'detail_table'" class="detail-table-readonly">
<div v-if="Array.isArray(getFieldValue(field)) && getFieldValue(field).length" class="detail-table-cards">
<div
v-for="(row, rowIdx) in getDetailTableData(field)"
:key="`row_${rowIdx}`"
class="detail-table-card"
>
<div class="detail-table-card__header">
<span class="detail-table-card__index">第 {{ rowIdx + 1 }} 条</span>
</div>
<div class="detail-table-card__body">
<template v-if="getDetailTableFields(field).length">
<div
v-for="col in getDetailTableFields(field)"
:key="col.field_key"
v-show="!isEmptyDetailTableValue(row[col.field_key])"
class="detail-table-card__field"
>
<span class="detail-table-card__label">{{ col.field_name }}</span>
<span class="detail-table-card__value">
<span v-if="col.field_type === 'department'">
{{ getDepartmentName(row[col.field_key]) }}
</span>
<span v-else-if="col.field_type === 'user'">
{{ getUserName(row[col.field_key]) }}
</span>
<span v-else>
{{ row[col.field_key] ?? '-' }}
</span>
</span>
</div>
</template>
<template v-else>
<div
v-for="k in getAutoKeysForDetailTable(field)"
:key="k"
v-show="!isEmptyDetailTableValue(row[k])"
class="detail-table-card__field"
>
<span class="detail-table-card__label">{{ k }}</span>
<span class="detail-table-card__value">{{ row[k] ?? '-' }}</span>
</div>
</template>
</div>
</div>
</div>
<span v-else class="empty-value">-</span>
</div>
<!-- 流程类型 -->
<div v-else-if="field.element_type === 'oa_custom_model'" class="approval-flow-display">
<div v-if="getFlowItems(field).length" class="flow-info">
<template v-for="(it, idx) in getFlowItems(field)" :key="`${it.id || it.display_name}_${idx}`">
<span v-if="idx > 0">、</span>
<el-link
v-if="it.id"
type="primary"
:underline="false"
class="flow-info-clickable"
@click.prevent="openFlowDrawer(it.id, it.custom_model_id)"
>
{{ it.display_name || `流程ID: ${it.id}` }}
</el-link>
<span v-else class="flow-info-clickable">{{ it.display_name || '-' }}</span>
</template>
</div>
<span v-else class="muted">-</span>
</div>
<!-- 附件类型 -->
<div v-else-if="field.element_type === 'file' || field.element_type === 'attachment'" class="attachment-display">
<!-- 调试信息:显示字段信息和原始值 -->
<div v-if="config.isDevelopment" style="font-size: 11px; color: #909399; margin-bottom: 4px; padding: 4px; background: #f5f7fa; border-radius: 4px;">
<div><strong>🔍 调试信息 [attachment/file字段]</strong></div>
<div>字段ID: {{ field.element_id || field.id || 'N/A' }}</div>
<div>字段Key: {{ field.key || field.field_key || 'N/A' }}</div>
<div>字段类型: {{ field.element_type }}</div>
<div>原始值类型: {{ typeof getFieldValue(field) }}</div>
<div>原始值: {{ JSON.stringify(getFieldValue(field)) }}</div>
<div>处理后的文件项数: {{ getFileItems(field).length }}</div>
</div>
<div v-if="getFileItems(field).length" class="file-list">
<template v-for="(f, idx) in getFileItems(field)" :key="`${f.url || f.name}_${idx}`">
<!-- 调试信息:显示每个文件项的详细信息 -->
<div v-if="config.isDevelopment" style="font-size: 10px; color: #67c23a; margin-bottom: 2px;">
[文件{{ idx + 1 }}] name: {{ f.name }}, url: {{ f.url || '无URL' }}
</div>
<el-link
v-if="f.url"
:href="f.url"
target="_blank"
type="primary"
:underline="false"
class="file-link"
@click="handleFileClick(f)"
>
{{ f.name || `附件${idx + 1}` }}
</el-link>
<el-link
v-else
type="primary"
:underline="false"
class="file-link file-link--no-url"
@click="handleFileClick(f)"
>
{{ f.name || `附件${idx + 1}` }}
</el-link>
<span v-if="idx < getFileItems(field).length - 1" class="file-separator"></span>
</template>
</div>
<span v-else class="muted">-</span>
</div>
<!-- 默认显示 -->
<span v-else>
{{ formatDefaultValue(getFieldValue(field)) }}
</span>
</template>
<span v-else class="empty-value">-</span>
</td>
</tr>
<!-- 会议纪要:若唯一附件为 PDF则在本行后追加一个通栏行展示 PDF尽量拉宽 -->
<tr
v-if="field.element_type === 'meeting_minutes' && !getMeetingMinutesUsageState(field).isDuplicate && getMeetingMinutesPdfUrl(field)"
class="meeting-minutes-pdf-row"
>
<td class="value meeting-minutes-pdf-td" colspan="2">
<div
class="print-file-item print-pdf meeting-minutes-pdf"
:data-pdf-id="`planned_meeting_minutes_${field.element_id || field.key}`"
:data-pdf-url="getMeetingMinutesPdfUrl(field)"
>
<div class="pdf-pages-container">
<div class="pdf-loading">PDF 加载中...</div>
</div>
</div>
</td>
</tr>
</template>
</table>
</template>
<!-- 支付轮次区域 -->
<template v-else-if="section.rounds && Array.isArray(section.rounds)">
<div
v-for="(round, roundIndex) in section.rounds"
:key="roundIndex"
class="payment-round"
v-show="getVisibleRoundFields(round).length > 0"
>
<h6 class="round-title">第 {{ round.round }} 轮支付</h6>
<table class="info-table">
<tr
v-for="field in getVisibleRoundFields(round)"
:key="field.key || field.element_id"
>
<td class="label">{{ field.label || field.name || '-' }}</td>
<td class="value">
<template v-if="getFieldValue(field)">
<span v-if="field.element_type === 'text' || field.element_type === 'textarea'">
{{ getFieldValue(field) }}
</span>
<span v-else-if="field.element_type === 'number'">
{{ formatNumber(getFieldValue(field)) }}
</span>
<span v-else-if="field.element_type === 'date'">
{{ formatDate(getFieldValue(field)) }}
</span>
<span v-else>
{{ formatDefaultValue(getFieldValue(field)) }}
</span>
</template>
<span v-else class="empty-value">-</span>
</td>
</tr>
</table>
</div>
</template>
</div>
</div>
<div v-else class="no-template">
<span class="empty-value">暂无模板配置</span>
</div>
<!-- oa_custom_model右侧抽屉 + iframe 打开 OA 查看页 -->
<el-drawer
v-model="flowDrawerVisible"
title="流程查看"
direction="rtl"
size="72%"
:modal="true"
:close-on-click-modal="false"
:close-on-press-escape="true"
:show-close="true"
class="flow-drawer"
@close="closeFlowDrawer"
>
<div class="drawer-content" v-loading="flowIframeLoading">
<iframe
v-if="flowIframeUrl"
ref="flowIframeRef"
:src="flowIframeUrl"
class="oa-flow-iframe"
frameborder="0"
scrolling="auto"
@load="handleFlowIframeLoad"
@error="handleFlowIframeError"
></iframe>
<div v-else class="empty-value">...</div>
</div>
</el-drawer>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import MeetingMinutesField from './MeetingMinutesField.vue'
import { departmentAPI, detailTableFieldAPI, userAPI } from '@/utils/api'
import { plannedExpenditureAPI, oaFlowAPI, templateElementAPI } from '@/utils/api'
import { getToken } from '@/utils/auth'
import { ElMessage } from 'element-plus'
import config from '@/config'
const props = defineProps({
template: {
type: Object,
default: null
},
elementValues: {
type: Object,
default: () => ({})
},
flowBindings: {
type: Array,
default: () => ([])
},
hideEmpty: {
type: Boolean,
default: false
},
meetingMinutesContextPrefix: {
type: String,
default: ''
},
resolveMeetingMinutesUsage: {
type: Function,
default: null
}
})
const emit = defineEmits(['collect-flow-id'])
const REPEATED_MEETING_MINUTES_MESSAGE = '会议纪要再次使用,仅展示标题,不重复打印'
/**
* 兼容两种 elementValues 结构:
* 1) “值对象”模式(打印页 PaymentController 等):{ [elementId]: { value, field_key, ... } }
* 2) “字段键值”模式PlannedExpenditureController{ [field_key]: any },例如 { element_123: 'xxx' }
*/
const getElementValueRawByKey = (key) => {
if (!key || !props.elementValues) return undefined
// 允许 key 为数字/字符串
return Object.prototype.hasOwnProperty.call(props.elementValues, key) ? props.elementValues[key] : undefined
}
// 获取字段值
const getFieldValue = (field) => {
if (!field || !props.elementValues) {
if (config.isDevelopment && (field?.element_type === 'file' || field?.element_type === 'attachment')) {
console.log('[getFieldValue] 字段或elementValues为空', { field, elementValues: props.elementValues })
}
return null
}
// 1) 优先用 field_key 直接命中(兼容 element_values 为 { field_key: value } 的后端返回)
const fieldKey = field.key || field.field_key
if (fieldKey) {
const direct = getElementValueRawByKey(fieldKey)
if (direct !== undefined) {
if (config.isDevelopment && (field.element_type === 'file' || field.element_type === 'attachment')) {
console.log('[getFieldValue] 通过fieldKey找到值', { fieldKey, direct, fieldType: typeof direct, isObject: typeof direct === 'object' })
}
// 可能是对象模式,也可能是原始值
if (direct && typeof direct === 'object' && direct.value !== undefined) return direct.value
return direct
}
}
// 2) 使用 element_id兼容 element_values 为 { [elementId]: { value } } 的后端返回)
const elementId = field.element_id
if (elementId) {
const ev = getElementValueRawByKey(elementId)
if (ev !== undefined) {
if (config.isDevelopment && (field.element_type === 'file' || field.element_type === 'attachment')) {
console.log('[getFieldValue] 通过elementId找到值', { elementId, ev, fieldType: typeof ev, isObject: typeof ev === 'object' })
}
if (ev && typeof ev === 'object' && ev.value !== undefined) return ev.value
// 某些场景 elementId 也可能直接对应原始值
return ev
}
// 3) 兜底field_key 可能是 element_{id}
const fallbackKey = `element_${elementId}`
const fallback = getElementValueRawByKey(fallbackKey)
if (fallback !== undefined) {
if (config.isDevelopment && (field.element_type === 'file' || field.element_type === 'attachment')) {
console.log('[getFieldValue] 通过fallbackKey找到值', { fallbackKey, fallback, fieldType: typeof fallback })
}
if (fallback && typeof fallback === 'object' && fallback.value !== undefined) return fallback.value
return fallback
}
}
// 4) 兼容elementValues 为"值对象"模式但未按 elementId 索引(遍历查 field_key
if (fieldKey) {
for (const ev of Object.values(props.elementValues)) {
if (!ev || typeof ev !== 'object') continue
if (ev.field_key === fieldKey) {
if (config.isDevelopment && (field.element_type === 'file' || field.element_type === 'attachment')) {
console.log('[getFieldValue] 通过遍历找到值', { fieldKey, ev, value: ev.value })
}
return ev.value !== undefined ? ev.value : null
}
}
}
if (config.isDevelopment && (field.element_type === 'file' || field.element_type === 'attachment')) {
console.warn('[getFieldValue] 未找到字段值', {
fieldKey: field.key || field.field_key,
elementId: field.element_id,
field,
elementValuesKeys: Object.keys(props.elementValues || {}),
elementValuesSample: JSON.stringify(props.elementValues).substring(0, 500)
})
}
return null
}
// 会议纪要:识别唯一附件是否为 PDF并返回 URL字段值可能是对象或 JSON 字符串)
const getMeetingMinutesPdfUrl = (field) => {
if (!field || field.element_type !== 'meeting_minutes') return null
const rawVal = getFieldValue(field)
if (rawVal === null || rawVal === undefined || rawVal === '') return null
let v = rawVal
if (typeof v === 'string') {
try {
const parsed = JSON.parse(v)
if (parsed && typeof parsed === 'object') v = parsed
} catch {
// ignore
}
}
if (!v || typeof v !== 'object') return null
const url = (v.file_url || v.url || '').toString().trim()
if (!url) return null
const name = (v.file_name || v.name || '').toString().trim()
const extFromName = name.includes('.') ? name.split('.').pop() : ''
const extFromUrl = url.split('?')[0].split('#')[0].split('.').pop()
const ext = String(extFromName || extFromUrl || '').toLowerCase()
return ext === 'pdf' ? url : null
}
const getMeetingMinutesUsageState = (field) => {
if (!field || field.element_type !== 'meeting_minutes') {
return { isDuplicate: false, title: null, fingerprint: null }
}
if (typeof props.resolveMeetingMinutesUsage !== 'function') {
return { isDuplicate: false, title: null, fingerprint: null }
}
const suffix = field.key || field.field_key || field.element_id || field.id || 'unknown'
const occurrenceKey = props.meetingMinutesContextPrefix
? `${props.meetingMinutesContextPrefix}:${suffix}`
: `planned-meeting-minutes:${suffix}`
return props.resolveMeetingMinutesUsage(occurrenceKey, getFieldValue(field))
}
// 检查勾选清单是否选中
const isChecklistChecked = (value, optionValue) => {
if (!value) return false
const arr = Array.isArray(value) ? value : (value ? [value] : [])
const norm = (v) => (v === null || v === undefined) ? '' : String(v)
const target = norm(optionValue)
return arr.map(norm).includes(target)
}
// 获取勾选清单的备注
const getChecklistRemark = (field, optionValue) => {
if (!field || !optionValue) return null
const elementId = field.element_id
if (!elementId) return null
// 查找备注字段element_{id}_remark_{value}
const remarkKey = `element_${elementId}_remark_${optionValue}`
// 1) 直接命中(兼容 element_values 为 { field_key: value }
const direct = getElementValueRawByKey(remarkKey)
if (direct !== undefined) {
if (direct && typeof direct === 'object' && direct.value !== undefined) return direct.value || null
return direct || null
}
// 2) 遍历命中(兼容值对象模式)
for (const ev of Object.values(props.elementValues)) {
if (!ev || typeof ev !== 'object') continue
if (ev.field_key === remarkKey) {
return ev.value || null
}
}
return null
}
const hasAnyChecklistContent = (field) => {
const value = getFieldValue(field)
const hasChecked = Array.isArray(value) ? value.length > 0 : !!value
if (hasChecked) return true
const opts = field?.options || []
return opts.some(opt => !!getChecklistRemark(field, opt.value))
}
const shouldShowChecklistOption = (field, optionValue) => {
return isChecklistChecked(getFieldValue(field), optionValue) || !!getChecklistRemark(field, optionValue)
}
const getChecklistFallbackItems = (field) => {
const v = getFieldValue(field)
const arr = Array.isArray(v) ? v : (v ? [v] : [])
return arr
.map(x => (x === null || x === undefined) ? '' : String(x))
.map(s => s.trim())
.filter(Boolean)
}
const shouldRenderField = (field) => {
if (!props.hideEmpty) return true
if (!field) return false
if (field.element_type === 'checklist') {
// checklist即使没有勾选/备注,也要展示全部选项(用户要求)
const opts = field?.options || []
if (Array.isArray(opts) && opts.length) return true
return hasAnyChecklistContent(field)
}
const v = getFieldValue(field)
if (v === null || v === undefined) return false
if (typeof v === 'string') return v.trim() !== ''
if (Array.isArray(v)) return v.length > 0
if (typeof v === 'object') return Object.keys(v).length > 0
return true
}
const getVisibleFields = (section) => {
const fields = Array.isArray(section?.fields) ? section.fields : []
return fields.filter(shouldRenderField)
}
const getVisibleRoundFields = (round) => {
const fields = Array.isArray(round?.fields) ? round.fields : []
return fields.filter(shouldRenderField)
}
const shouldRenderSection = (section) => {
if (!props.hideEmpty) return true
if (Array.isArray(section?.fields)) return getVisibleFields(section).length > 0
if (Array.isArray(section?.rounds)) {
return section.rounds.some(r => getVisibleRoundFields(r).length > 0)
}
return true
}
// 格式化数字
const formatNumber = (num) => {
if (num === null || num === undefined || num === '') return '-'
return Number(num).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
if (typeof date === 'string') {
const d = new Date(date)
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('zh-CN')
}
return date
}
// oa_custom_model可能多条优先用后端 flow_bindingsdisplay_name 更可靠)
const normalizeFlowId = (v) => {
if (v === null || v === undefined || v === '') return null
const s = String(v).trim()
if (!s) return null
if (!s.match(/^\d+$/)) return null
return Number(s)
}
const extractFlowIds = (value) => {
if (value === null || value === undefined || value === '') return []
if (Array.isArray(value)) {
return value.map(normalizeFlowId).filter(Boolean)
}
if (typeof value === 'string') {
// 兼容 “1,2,3” / “123”
return value
.split(/[,]/g)
.map(s => normalizeFlowId(s))
.filter(Boolean)
}
if (typeof value === 'number') {
return [normalizeFlowId(value)].filter(Boolean)
}
if (typeof value === 'object') {
const id = normalizeFlowId(value.instance_id ?? value.id)
return id ? [id] : []
}
return []
}
const getFlowItems = (field) => {
const fieldKey = field?.key || field?.field_key
const elementId = field?.element_id ? Number(field.element_id) : null
const bindings = Array.isArray(props.flowBindings) ? props.flowBindings : []
let flowIds = []
// 1) 优先按 element_id / field_key 精确匹配(可能多条)
const direct = bindings.filter(b => {
if (!b) return false
if (elementId && b.element_id && Number(b.element_id) === elementId) return true
if (fieldKey && b.field_key === fieldKey) return true
return false
})
if (direct.length) {
flowIds = direct.map(b => b.flow_instance_id).filter(Boolean)
} else {
// 2) 再从字段值里解析 ID
flowIds = extractFlowIds(getFieldValue(field))
}
if (!flowIds.length) return []
// 批量加载流程详情(如果还没有加载)
loadFlowDetailsBatch(flowIds)
// 构建返回项
const items = flowIds.map(id => {
const hit = bindings.find(b => Number(b?.flow_instance_id) === Number(id))
const detail = flowDetailsMap.value[id]
// 构建显示文本:{流程编号} - {flow_title}(去掉状态)
let displayText = ''
if (detail) {
const parts = []
if (detail.no) parts.push(detail.no)
if (detail.title) {
if (parts.length) parts.push(' - ')
parts.push(detail.title)
}
displayText = parts.length ? parts.join('') : `流程ID: ${id}`
} else {
// 如果没有详情使用流程ID
displayText = `流程ID: ${id}`
}
// 向父组件emit流程ID用于收集打印模版
if (id) {
emit('collect-flow-id', id)
}
return {
id,
custom_model_id: hit?.flow_custom_model_id ?? null,
display_name: displayText,
detail // 保存详情以便后续使用
}
})
return items
}
const loadFlowDetailsBatch = async (ids) => {
const need = (ids || []).filter(Boolean).filter(id => !flowDetailsMap.value[id])
if (!need.length) return flowDetailsMap.value
try {
const res = await plannedExpenditureAPI.getOaFlowDetails(need)
if (res.code === 0 && res.data) {
// 合并到缓存中
Object.keys(res.data).forEach(id => {
if (res.data[id]) {
flowDetailsMap.value[id] = res.data[id]
}
})
}
} catch {
// ignore
}
return flowDetailsMap.value
}
// 获取流程状态文本(中文)
const getFlowStatusText = (status) => {
if (!status) return '未知'
const statusStr = String(status).toLowerCase()
const statusMap = {
'pending': '待审批',
'processing': '审批中',
'approved': '已通过',
'rejected': '已拒绝',
'cancelled': '已取消',
'draft': '草稿',
'completed': '已完成'
}
return statusMap[statusStr] || String(status)
}
// flow 抽屉
const flowDrawerVisible = ref(false)
const flowIframeUrl = ref('')
const flowIframeLoading = ref(false)
const flowIframeRef = ref(null)
let flowIframeLoadTimeout = null
const closeFlowDrawer = () => {
flowDrawerVisible.value = false
setTimeout(() => {
flowIframeUrl.value = ''
flowIframeLoading.value = false
if (flowIframeRef.value) {
flowIframeRef.value.src = 'about:blank'
}
if (flowIframeLoadTimeout) {
clearTimeout(flowIframeLoadTimeout)
flowIframeLoadTimeout = null
}
}, 200)
}
const handleFlowIframeLoad = () => {
if (flowIframeLoadTimeout) {
clearTimeout(flowIframeLoadTimeout)
flowIframeLoadTimeout = null
}
flowIframeLoading.value = false
}
const handleFlowIframeError = () => {
if (flowIframeLoadTimeout) {
clearTimeout(flowIframeLoadTimeout)
flowIframeLoadTimeout = null
}
flowIframeLoading.value = false
ElMessage.error('加载流程查看页失败')
}
const setFlowIframeLoadTimeout = () => {
if (flowIframeLoadTimeout) clearTimeout(flowIframeLoadTimeout)
flowIframeLoadTimeout = setTimeout(() => {
if (flowIframeLoading.value) {
flowIframeLoading.value = false
ElMessage.warning('流程页面加载超时,请重试')
}
}, 30000)
}
const buildOaFlowDetailUrl = (customModelId, flowId) => {
const token = getToken()
const base = '/oa/#/flow/detail'
const params = new URLSearchParams({
module_id: String(customModelId),
flow_id: String(flowId),
isSinglePage: '1',
module_name: 'oa',
form_canal: 'budget'
})
if (token) params.set('auth_token', token)
return `${base}?${params.toString()}`
}
const openFlowDrawer = async (flowId, customModelId) => {
const id = Number(flowId)
if (!id || Number.isNaN(id)) return
let cmid = customModelId ? Number(customModelId) : null
// plannedExpenditureAPI.getOaFlowDetails 不返回 custom_model_id缺失时改用 OA 的 view 接口补齐
if (!cmid || Number.isNaN(cmid)) {
try {
const res = await oaFlowAPI.view(id)
if (res.code === 0 && res.data) {
cmid = Number(res.data?.customModel?.id || res.data?.flow?.custom_model_id || res.data?.flow?.customModel?.id)
}
} catch {
// ignore
}
}
if (!cmid || Number.isNaN(cmid)) {
ElMessage.warning('无法打开流程:缺少 custom_model_id')
return
}
flowIframeUrl.value = buildOaFlowDetailUrl(cmid, id)
flowDrawerVisible.value = true
flowIframeLoading.value = true
setFlowIframeLoadTimeout()
}
// 构建完整的文件URL如果是相对路径自动拼接当前域名
const buildFileUrl = (path) => {
if (!path) return null
const pathStr = String(path).trim()
if (!pathStr) return null
// 如果已经是完整URLhttp:// 或 https://),直接返回
if (pathStr.startsWith('http://') || pathStr.startsWith('https://')) {
return pathStr
}
// 如果是相对路径(以 / 开头),拼接当前域名
if (pathStr.startsWith('/')) {
const origin = window.location.origin
const fullUrl = `${origin}${pathStr}`
if (config.isDevelopment) {
console.log('[buildFileUrl] 拼接相对路径为完整URL', {
original: pathStr,
origin,
fullUrl
})
}
return fullUrl
}
// 其他情况,尝试拼接(可能是 storage/files/xxx 这种格式)
if (pathStr.includes('/')) {
const origin = window.location.origin
const fullUrl = `${origin}/${pathStr.replace(/^\//, '')}`
if (config.isDevelopment) {
console.log('[buildFileUrl] 拼接路径为完整URL', {
original: pathStr,
origin,
fullUrl
})
}
return fullUrl
}
// 无法处理的格式,返回原始值
if (config.isDevelopment) {
console.warn('[buildFileUrl] 无法处理的路径格式', { path: pathStr })
}
return pathStr
}
// 附件:兼容字符串/对象/数组
const getFileItems = (field) => {
const v = getFieldValue(field)
if (config.isDevelopment && (field.element_type === 'file' || field.element_type === 'attachment')) {
console.log('[getFileItems] 开始处理文件字段', {
fieldId: field.element_id || field.id,
fieldKey: field.key || field.field_key,
rawValue: v,
rawValueStringified: JSON.stringify(v),
valueType: typeof v,
isArray: Array.isArray(v),
valueLength: Array.isArray(v) ? v.length : (v ? 1 : 0)
})
}
if (!v) {
if (config.isDevelopment && (field.element_type === 'file' || field.element_type === 'attachment')) {
console.warn('[getFileItems] 字段值为空', { field })
}
return []
}
const toItem = (x, idx) => {
if (!x) {
if (config.isDevelopment) {
console.warn('[getFileItems.toItem] 文件项为空', { idx, x })
}
return null
}
if (config.isDevelopment) {
console.log('[getFileItems.toItem] 开始处理文件项', {
idx,
x,
xType: typeof x,
xStringified: JSON.stringify(x),
xKeys: typeof x === 'object' ? Object.keys(x) : null,
xValues: typeof x === 'object' ? Object.values(x) : null
})
}
if (typeof x === 'string') {
const s = x.trim()
if (!s) {
if (config.isDevelopment) {
console.warn('[getFileItems.toItem] 字符串为空', { idx, x })
}
return null
}
const name = s.split('/').pop()
// 构建完整URL如果是相对路径会自动拼接域名
const url = buildFileUrl(s)
const result = { name: name || `附件${idx + 1}`, url }
if (config.isDevelopment) {
console.log('[getFileItems.toItem] 处理字符串类型', { idx, original: x, result })
}
return result
}
if (typeof x === 'object') {
if (config.isDevelopment) {
console.log('[getFileItems.toItem] 处理对象类型 - 开始', {
idx,
x,
xStringified: JSON.stringify(x, null, 2),
allKeys: Object.keys(x),
allEntries: Object.entries(x)
})
}
// 尝试从多个可能的字段中获取路径
let rawPath = x.url || x.path || x.file_url || x.download_url || x.href || x.preview_url
if (config.isDevelopment) {
console.log('[getFileItems.toItem] 第一轮路径查找', {
idx,
rawPath,
checkedFields: {
url: x.url,
path: x.path,
file_url: x.file_url,
download_url: x.download_url,
href: x.href,
preview_url: x.preview_url
}
})
}
// 如果对象中没有路径字段,但对象本身可能就是路径字符串(被包装成对象的情况)
// 或者检查是否有其他可能包含路径的字段
if (!rawPath) {
// 尝试从其他可能的字段中获取
rawPath = x.value || x.file_path || x.filePath || x.storage_path || x.storagePath || x.folder || x.dir
if (config.isDevelopment) {
console.log('[getFileItems.toItem] 第二轮路径查找', {
idx,
rawPath,
checkedFields: {
value: x.value,
file_path: x.file_path,
filePath: x.filePath,
storage_path: x.storage_path,
storagePath: x.storagePath,
folder: x.folder,
dir: x.dir
}
})
}
// 如果还是没有,检查对象的所有值,看是否有字符串类型的路径
if (!rawPath) {
for (const [key, value] of Object.entries(x)) {
if (typeof value === 'string' && (value.includes('/') || value.includes('storage') || value.includes('files'))) {
rawPath = value
if (config.isDevelopment) {
console.log('[getFileItems.toItem] 从对象字段中找到可能的路径', { key, value, rawPath })
}
break
}
}
}
}
// 构建完整URL如果是相对路径会自动拼接域名
const url = rawPath ? buildFileUrl(rawPath) : null
// 尽量还原原始名称
const name =
x.original_name ||
x.file_name ||
x.name ||
x.filename ||
x.originalName ||
(url ? String(url).split('/').pop() : null) ||
(rawPath ? String(rawPath).split('/').pop() : null)
if (config.isDevelopment) {
console.log('[getFileItems.toItem] 提取名称和URL', {
idx,
name,
rawPath,
url,
nameSources: {
original_name: x.original_name,
file_name: x.file_name,
name: x.name,
filename: x.filename,
originalName: x.originalName
}
})
}
// 如果有名称但没有URL尝试根据名称构建可能的路径
let finalUrl = url
if (!finalUrl && name && rawPath) {
// 如果 rawPath 是目录路径,尝试拼接文件名
if (rawPath.endsWith('/') || (!rawPath.includes('.') && (rawPath.includes('storage') || rawPath.includes('files')))) {
finalUrl = buildFileUrl(`${rawPath.replace(/\/$/, '')}/${name}`)
if (config.isDevelopment) {
console.log('[getFileItems.toItem] 尝试拼接目录路径和文件名', {
idx,
rawPath,
name,
finalUrl
})
}
}
}
// 如果还是没有URL但原始值本身可能是路径检查原始值的字符串表示
if (!finalUrl && name) {
// 检查原始值vgetFieldValue返回的值是否包含路径信息
const originalValueStr = JSON.stringify(v)
if (originalValueStr.includes('/storage/') || originalValueStr.includes('/files/')) {
// 尝试从原始值中提取路径
const pathMatch = originalValueStr.match(/["']([^"']*\/storage\/[^"']*|.*\/files\/[^"']*)["']/)
if (pathMatch && pathMatch[1]) {
finalUrl = buildFileUrl(pathMatch[1])
if (config.isDevelopment) {
console.log('[getFileItems.toItem] 从原始值字符串中提取路径', {
idx,
originalValueStr,
pathMatch: pathMatch[1],
finalUrl
})
}
}
}
}
const result = (finalUrl || name) ? { name: name || `附件${idx + 1}`, url: finalUrl } : null
if (config.isDevelopment) {
console.log('[getFileItems.toItem] 处理对象类型 - 完成', {
idx,
original: x,
rawPath,
url: finalUrl,
name,
result,
availableKeys: Object.keys(x),
allValues: Object.values(x)
})
}
if (!result) {
console.warn('[getFileItems.toItem] 对象中未找到URL或名称', {
idx,
x,
xStringified: JSON.stringify(x, null, 2),
availableKeys: Object.keys(x),
allEntries: Object.entries(x)
})
} else if (!result.url) {
console.warn('[getFileItems.toItem] ⚠️ 文件有名称但无URL', {
idx,
x,
xStringified: JSON.stringify(x, null, 2),
result,
availableKeys: Object.keys(x),
allValues: Object.values(x),
rawValue: v,
rawValueStringified: JSON.stringify(v, null, 2)
})
}
return result
}
if (config.isDevelopment) {
console.warn('[getFileItems.toItem] 不支持的类型', { idx, x, type: typeof x })
}
return null
}
const arr = Array.isArray(v) ? v : [v]
const result = arr.map(toItem).filter(Boolean)
if (config.isDevelopment && (field.element_type === 'file' || field.element_type === 'attachment')) {
console.log('[getFileItems] 处理完成', {
fieldId: field.element_id || field.id,
inputArrayLength: arr.length,
outputLength: result.length,
result
})
}
return result
}
// 处理文件点击
const handleFileClick = (file) => {
console.log('[handleFileClick] 文件点击事件', {
file,
hasUrl: !!file.url,
url: file.url,
name: file.name,
allKeys: Object.keys(file || {})
})
if (file.url) {
// 如果有 URL在新窗口打开
console.log('[handleFileClick] 打开文件URL', file.url)
try {
window.open(file.url, '_blank')
} catch (error) {
console.error('[handleFileClick] 打开文件失败', { error, url: file.url })
ElMessage.error('打开文件失败:' + error.message)
}
} else {
// 如果没有 URL直接提示用户
console.warn('[handleFileClick] 文件没有URL', file)
ElMessage.warning('文件链接不可用')
}
}
// 格式化默认值
const formatDefaultValue = (value) => {
if (value === null || value === undefined || value === '') return '-'
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
return String(value)
}
// -------- checklist从元素定义取“原始 options”确保渲染全量可选项 --------
const checklistOptionsMap = ref({}) // elementId -> options[]
const checklistElementIds = computed(() => {
const cfg = props.template?.config
const ids = new Set()
if (!cfg || typeof cfg !== 'object') return []
Object.values(cfg).forEach(section => {
if (!section) return
const pushField = (f) => {
if (f?.element_type === 'checklist' && f?.element_id) ids.add(Number(f.element_id))
}
;(section.fields || []).forEach(pushField)
;(section.rounds || []).forEach(r => (r?.fields || []).forEach(pushField))
})
return Array.from(ids).filter(Boolean)
})
const loadChecklistOptions = async (elementId) => {
const id = Number(elementId)
if (!id || Number.isNaN(id)) return
if (checklistOptionsMap.value[id]) return
try {
const res = await templateElementAPI.getDetail(id)
if (res.code === 0 && res.data) {
checklistOptionsMap.value = {
...checklistOptionsMap.value,
[id]: Array.isArray(res.data.options) ? res.data.options : []
}
}
} catch {
// ignore
}
}
watch(
checklistElementIds,
async (ids) => {
for (const id of ids) {
await loadChecklistOptions(id)
}
},
{ immediate: true }
)
const getChecklistOptions = (field) => {
const id = field?.element_id ? Number(field.element_id) : null
if (id && checklistOptionsMap.value[id] && checklistOptionsMap.value[id].length) {
return checklistOptionsMap.value[id]
}
return Array.isArray(field?.options) ? field.options : []
}
// -------- detail_table 支持(字段定义 + 人员/部门映射)--------
const detailTableFieldsMap = ref({})
const departmentMap = ref({})
const userMap = ref({})
const loadedDepts = ref(false)
const loadedUsers = ref(false)
const flowDetailsMap = ref({}) // 流程详情缓存flowId -> { no, title, status, ... }
const loadDepartmentList = async () => {
if (loadedDepts.value) return
try {
const res = await departmentAPI.getTree()
if (res.code === 0 && Array.isArray(res.data)) {
const flatten = (depts, out = []) => {
depts.forEach(d => {
out.push({ id: d.id, name: d.name })
if (d.children && d.children.length) flatten(d.children, out)
})
return out
}
const list = flatten(res.data)
const map = {}
list.forEach(d => { map[d.id] = d.name })
departmentMap.value = map
loadedDepts.value = true
}
} catch {
// ignore
}
}
const loadUserList = async () => {
if (loadedUsers.value) return
try {
const res = await userAPI.getSimpleList({ rows: 10000 })
if (res.code === 0) {
const users = Array.isArray(res.data) ? res.data : (res.data?.data || [])
const map = {}
users.forEach(u => { map[u.id] = u.name })
userMap.value = map
loadedUsers.value = true
}
} catch {
// ignore
}
}
const getDepartmentName = (id) => {
if (!id) return '-'
return departmentMap.value[id] || `部门ID: ${id}`
}
const getUserName = (id) => {
if (!id) return '-'
return userMap.value[id] || `用户ID: ${id}`
}
const loadDetailTableFields = async (elementId) => {
if (!elementId) return
if (detailTableFieldsMap.value[elementId]) return
try {
const res = await detailTableFieldAPI.getList(elementId)
if (res.code === 0 && Array.isArray(res.data)) {
const sorted = res.data.slice().sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))
detailTableFieldsMap.value[elementId] = sorted
const hasDepartment = sorted.some(f => f.field_type === 'department')
const hasUser = sorted.some(f => f.field_type === 'user')
if (hasDepartment) await loadDepartmentList()
if (hasUser) await loadUserList()
} else {
detailTableFieldsMap.value[elementId] = []
}
} catch {
detailTableFieldsMap.value[elementId] = []
}
}
const detailTableElementIds = computed(() => {
const cfg = props.template?.config
const ids = new Set()
if (!cfg || typeof cfg !== 'object') return []
Object.values(cfg).forEach(section => {
if (!section) return
const pushField = (f) => {
if (f?.element_type === 'detail_table' && f?.element_id) ids.add(f.element_id)
}
;(section.fields || []).forEach(pushField)
;(section.rounds || []).forEach(r => (r?.fields || []).forEach(pushField))
})
return Array.from(ids)
})
watch(
detailTableElementIds,
async (ids) => {
for (const id of ids) {
await loadDetailTableFields(id)
}
},
{ immediate: true }
)
// 监听所有流程字段,批量加载流程详情
const allFlowIds = computed(() => {
const ids = new Set()
const cfg = props.template?.config
if (!cfg || typeof cfg !== 'object') return []
Object.values(cfg).forEach(section => {
if (!section) return
const collectFlowIds = (f) => {
if (f?.element_type === 'oa_custom_model') {
const fieldKey = f?.key || f?.field_key
const elementId = f?.element_id ? Number(f.element_id) : null
const bindings = Array.isArray(props.flowBindings) ? props.flowBindings : []
// 从 bindings 中收集流程ID
const direct = bindings.filter(b => {
if (!b) return false
if (elementId && b.element_id && Number(b.element_id) === elementId) return true
if (fieldKey && b.field_key === fieldKey) return true
return false
})
direct.forEach(b => {
if (b.flow_instance_id) ids.add(b.flow_instance_id)
})
// 从字段值中提取流程ID
const value = getFieldValue(f)
const extractedIds = extractFlowIds(value)
extractedIds.forEach(id => ids.add(id))
}
}
;(section.fields || []).forEach(collectFlowIds)
;(section.rounds || []).forEach(r => (r?.fields || []).forEach(collectFlowIds))
})
return Array.from(ids).filter(Boolean)
})
watch(
allFlowIds,
async (ids) => {
if (ids.length > 0) {
await loadFlowDetailsBatch(ids)
}
},
{ immediate: true }
)
const getDetailTableFields = (field) => {
const elementId = field?.element_id
return (elementId && detailTableFieldsMap.value[elementId]) ? detailTableFieldsMap.value[elementId] : []
}
const getDetailTableData = (field) => {
const v = getFieldValue(field)
return Array.isArray(v) ? v : []
}
const getAutoKeysForDetailTable = (field) => {
const rows = getDetailTableData(field)
const keys = new Set()
rows.forEach(r => {
if (r && typeof r === 'object' && !Array.isArray(r)) {
Object.keys(r).forEach(k => keys.add(k))
}
})
return Array.from(keys)
}
// 判断明细表格值是否为空
const isEmptyDetailTableValue = (val) => {
if (val === null || val === undefined) return true
if (typeof val === 'string') return val.trim() === ''
if (Array.isArray(val)) return val.length === 0
if (typeof val === 'object') return Object.keys(val).length === 0
return false
}
</script>
<style scoped>
.template-readonly {
width: 100%;
}
.template-section {
margin-bottom: 20px;
}
.section-title {
font-size: 14px;
font-weight: bold;
margin: 0 0 10px 0;
color: #333;
padding-bottom: 5px;
border-bottom: 1px solid #eee;
}
.template-field {
display: flex;
margin-bottom: 10px;
padding: 8px 0;
border-bottom: 1px dotted #eee;
}
.field-label {
min-width: 120px;
font-weight: 500;
color: #666;
flex-shrink: 0;
}
.field-value {
flex: 1;
color: #333;
word-break: break-word;
overflow-x: auto;
}
.empty-value {
color: #999;
font-style: italic;
}
/* info-table 样式(与 ContractInfoCard 保持一致) */
.info-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-bottom: 0;
}
.info-table tr {
border-bottom: 1px solid #f0f2f4;
}
.info-table td {
padding: 10px 12px;
vertical-align: top;
word-break: break-word;
}
.info-table .label {
width: 120px;
font-weight: 700;
color: #111827;
background: #f9fafb;
}
.info-table .value {
color: #374151;
}
/* 勾选清单只读样式(与付款基本信息保持一致) */
.readonly-checklist {
width: 100%;
}
.readonly-checklist .checklist-option-item {
margin: 1px 0;
}
.readonly-checklist .checklist-option-wrapper {
display: flex;
gap: 6px;
align-items: flex-start;
}
.readonly-checklist .checklist-remark-text {
color: #6b7280;
font-size: 12px;
line-height: 14px;
padding-top: 0;
}
/* 加深disabled checkbox的颜色兼容打印使用黑白灰配色 */
.readonly-checklist :deep(.el-checkbox.is-disabled .el-checkbox__label) {
color: #111827 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.readonly-checklist :deep(.el-checkbox.is-disabled .el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #111827 !important;
border-color: #111827 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.readonly-checklist :deep(.el-checkbox.is-disabled .el-checkbox__input.is-checked .el-checkbox__inner::after) {
border-color: #ffffff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.readonly-checklist :deep(.el-checkbox.is-disabled .el-checkbox__inner) {
border-color: #909399 !important;
background-color: #ffffff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.checklist-label {
color: #111827;
line-height: 18px;
word-break: break-word;
}
.checklist-text {
flex: 1;
min-width: 0;
line-height: 18px;
word-break: break-word;
}
/* 纯展示的 checkbox 样式(非表单控件,无 disabled 灰态) */
.checklist-box {
width: 14px;
height: 14px;
border: 1px solid #111827;
border-radius: 3px;
margin-top: 2px;
flex-shrink: 0;
position: relative;
background: transparent;
}
.checklist-box.checked {
border-color: #111827;
background: transparent;
}
.checklist-box.checked::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 10px;
border-right: 2px solid #111827;
border-bottom: 2px solid #111827;
/* 先居中再旋转Y 方向略上提一点,视觉更“正中” */
transform: translate(-50%, -58%) rotate(45deg);
}
.checklist-fallback-item {
margin: 4px 0;
color: #333;
}
/* 明细表格只读样式 */
.detail-table-readonly {
width: 100%;
}
/* 明细表格卡片样式 */
.detail-table-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-table-card {
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #f9fafb;
overflow: hidden;
page-break-inside: avoid;
}
.detail-table-card__header {
padding: 8px 12px;
background: #f3f4f6;
border-bottom: 1px solid #e5e7eb;
font-weight: 600;
font-size: 13px;
color: #374151;
}
.detail-table-card__index {
color: #6b7280;
}
.detail-table-card__body {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-table-card__field {
display: flex;
align-items: flex-start;
line-height: 1.5;
}
.detail-table-card__label {
min-width: 100px;
font-weight: normal;
color: #6b7280;
flex-shrink: 0;
margin-right: 8px;
}
.detail-table-card__value {
flex: 1;
color: #374151;
word-break: break-word;
font-weight: normal;
}
/* 打印时优化卡片样式 */
@media print {
.detail-table-card {
border: 1px solid #d1d5db;
margin-bottom: 8px;
page-break-inside: avoid;
}
.detail-table-card__header {
background: #f3f4f6;
border-bottom: 1px solid #d1d5db;
}
}
.flow-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.flow-tag {
max-width: 100%;
}
.flow-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.flow-item {
display: flex;
align-items: center;
gap: 10px;
}
.flow-item__title {
flex: 1;
min-width: 0;
color: #111827;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.flow-item__title--link {
cursor: pointer;
}
.flow-detail__fields {
margin-top: 12px;
}
/* 审批流程显示样式(与付款基本信息保持一致) */
.approval-flow-display {
width: 100%;
}
.approval-flow-display .flow-info {
display: inline;
}
/* 流程信息可点击样式(复用付款基本信息的样式) */
.flow-info-clickable {
cursor: pointer;
color: #409eff;
text-decoration: none;
transition: color 0.2s;
}
.flow-info-clickable:hover {
color: #66b1ff;
text-decoration: underline;
}
/* 打印时隐藏hover效果 */
@media print {
.flow-info-clickable {
cursor: default;
color: inherit;
text-decoration: none;
}
}
/* 附件显示样式(与付款基本信息保持一致) */
.attachment-display {
width: 100%;
}
.file-separator {
margin: 0 4px;
color: #909399;
}
.muted {
color: #909399;
}
.file-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.file-item {
line-height: 18px;
}
.file-link {
cursor: pointer;
}
.file-link--no-url {
cursor: pointer;
}
.file-link--no-url:hover {
text-decoration: underline;
}
.file-name {
color: #374151;
}
.drawer-content {
min-height: 60vh;
}
.oa-flow-iframe {
width: 100%;
height: 75vh;
border: 0;
}
/* 支付轮次样式 */
.payment-round {
margin-bottom: 15px;
padding: 10px;
background: #fafafa;
border-radius: 4px;
}
.round-title {
font-size: 13px;
font-weight: bold;
margin: 0 0 10px 0;
color: #409eff;
}
/* 打印样式 */
@media print {
.template-section {
page-break-inside: avoid;
}
.payment-round {
page-break-inside: avoid;
}
}
</style>