|
|
<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_bindings(display_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” / “1,2,3”
|
|
|
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
|
|
|
|
|
|
// 如果已经是完整URL(http:// 或 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) {
|
|
|
// 检查原始值v(getFieldValue返回的值)是否包含路径信息
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|