shiyanyu 1 месяц назад
Родитель
Сommit
743251b033

+ 31 - 0
src/views/EntDeclaration/auditTaskManagement/components/CostSurveyTab.vue

@@ -26,6 +26,9 @@
     <fixed-table-dialog
       :visible.sync="fixedTableDialogVisible"
       :survey-data="{ ...currentSurveyRow, fixedHeaders }"
+      :fixed-fields="fixedFields || ''"
+      :fixed-fieldids="fixedFieldids || ''"
+      :columns-meta="columnsMeta"
       :table-items="tableItems"
       :audit-periods="auditPeriods"
       :project-audit-periods="auditPeriods"
@@ -321,6 +324,9 @@
         dynamicDialogKey: 0,
         dynamicTableLoading: false,
         fixedHeaders: null,
+        fixedFields: '',
+        fixedFieldids: '',
+        columnsMeta: [],
         // 上传相关
         pendingDynamicRow: null,
       }
@@ -1200,6 +1206,12 @@
                   children: item.children || [],
                   ...item, // 保留其他字段
                 }))
+                // 若该接口同时提供 fixedFields/fixedFieldids,则同步到弹窗表头
+                if (res.value.fixedFields && res.value.fixedFieldids) {
+                  this.fixedFields = res.value.fixedFields
+                  console.log(this.fixedFields, 'biaogeshuju')
+                  this.fixedFieldids = res.value.fixedFieldids
+                }
               } else {
                 // 如果没有 itemlist,使用假数据
                 this.tableItems = this.getMockTableItems()
@@ -1253,11 +1265,30 @@
 
           if (headerRes && headerRes.code === 200) {
             this.fixedHeaders = headerRes.value || null
+            // 解析并下发 fixedFields/columnsMeta
+            if (Array.isArray(headerRes.value)) {
+              // 数组:作为 columnsMeta 使用;fixedFields 从 getSingleRecordSurveyList 的表头或留空
+              this.columnsMeta = headerRes.value
+            } else if (headerRes.value && typeof headerRes.value === 'object') {
+              const { fixedFields, fixedFieldids, items } = headerRes.value
+              this.fixedFields = fixedFields || ''
+              this.fixedFieldids = fixedFieldids || ''
+              // 如果对象里还有 items 数组,作为 columnsMeta;否则整对象不便直接使用
+              if (Array.isArray(items)) {
+                this.columnsMeta = items
+              } else {
+                this.columnsMeta = []
+              }
+            } else {
+              this.columnsMeta = []
+            }
           } else {
             this.fixedHeaders = null
+            this.columnsMeta = []
           }
         } catch (e) {
           this.fixedHeaders = null
+          this.columnsMeta = []
         }
 
         // 打开弹窗

+ 742 - 38
src/views/EntDeclaration/auditTaskManagement/components/FixedTableDialog.vue

@@ -16,14 +16,14 @@
       :row-class-name="getRowClassName"
     >
       <!-- 序号列 -->
-      <el-table-column prop="seq" label="序号" width="80" align="center">
+      <!-- <el-table-column prop="seq" label="序号" width="80" align="center">
         <template slot-scope="scope">
           <span>{{ scope.row.seq }}</span>
         </template>
-      </el-table-column>
+      </el-table-column> -->
 
       <!-- 项目列(只读) -->
-      <el-table-column
+      <!-- <el-table-column
         prop="itemName"
         label="项目"
         min-width="200"
@@ -35,13 +35,95 @@
           </span>
           <span v-else>{{ scope.row.itemName }}</span>
         </template>
-      </el-table-column>
+      </el-table-column> -->
 
       <!-- 单位列(只读) -->
-      <el-table-column prop="unit" label="单位" width="100" align="center">
+      <!-- <el-table-column prop="unit" label="单位" width="100" align="center">
         <template slot-scope="scope">
           <span v-if="!scope.row.isCategory">{{ scope.row.unit }}</span>
         </template>
+      </el-table-column> -->
+
+      <!-- 固定表头(来自父组件 fixedFields) -->
+      <el-table-column
+        v-for="label in dynamicColumns()"
+        :key="`fixed-col-${label}`"
+        :label="label"
+        :prop="label"
+        :min-width="140"
+        align="center"
+      >
+        <template slot-scope="scope">
+          <el-date-picker
+            v-if="!scope.row.isCategory && getDateType(label) === 'date'"
+            v-model="scope.row[label]"
+            type="date"
+            :placeholder="`请选择${label}`"
+            size="mini"
+            :format="getDateFormat(label) || 'yyyy-MM-dd'"
+            :value-format="getDateFormat(label) || 'yyyy-MM-dd'"
+            style="width: 100%"
+            :disabled="isViewMode"
+          />
+          <el-date-picker
+            v-else-if="
+              !scope.row.isCategory && getDateType(label) === 'datetime'
+            "
+            v-model="scope.row[label]"
+            type="datetime"
+            :placeholder="`请选择${label}`"
+            size="mini"
+            :format="getDateFormat(label) || 'yyyy-MM-dd HH:mm:ss'"
+            :value-format="getDateFormat(label) || 'yyyy-MM-dd HH:mm:ss'"
+            style="width: 100%"
+            :disabled="isViewMode"
+          />
+          <el-date-picker
+            v-else-if="!scope.row.isCategory && getDateType(label) === 'year'"
+            v-model="scope.row[label]"
+            type="year"
+            :placeholder="`请选择${label}`"
+            size="mini"
+            :format="getDateFormat(label) || 'yyyy'"
+            :value-format="getDateFormat(label) || 'yyyy'"
+            style="width: 100%"
+            :disabled="isViewMode"
+          />
+          <el-switch
+            v-else-if="!scope.row.isCategory && isBooleanField(label)"
+            v-model="scope.row[label]"
+            :disabled="isViewMode"
+            active-value="1"
+            inactive-value="0"
+            active-text="是"
+            inactive-text="否"
+          />
+          <el-select
+            v-else-if="!scope.row.isCategory && isDictField(label)"
+            v-model="scope.row[label]"
+            :placeholder="`请选择${label}`"
+            size="mini"
+            style="width: 100%"
+            :disabled="isViewMode"
+            :clearable="true"
+          >
+            <el-option
+              v-for="item in getDictOptions(getDictCode(label))"
+              :key="item.key || item.value"
+              :label="item.name || item.label"
+              :value="item.key || item.value"
+            />
+          </el-select>
+          <el-input
+            v-else
+            v-model="scope.row[label]"
+            :placeholder="`请输入${label}`"
+            :disabled="isViewMode"
+            size="mini"
+            :maxlength="getMaxLength(label)"
+            @input="onFixedFieldInput(label, scope.row, $event)"
+          />
+        </template>
       </el-table-column>
 
       <!-- 动态年份列 -->
@@ -117,9 +199,12 @@
 <script>
   import { Message } from 'element-ui'
   import { saveSingleRecordSurvey, getSurveyDetail } from '@/api/audit/survey'
+  import { getByTypeKey } from '@/api/dictionaryManage'
+  import { dictMixin } from '@/mixins/useDict'
 
   export default {
     name: 'FixedTableDialog',
+    mixins: [dictMixin],
     props: {
       visible: {
         type: Boolean,
@@ -175,7 +260,7 @@
       },
       // 立项信息中的监审期间(字符串,如 '2022,2023,2024' 或 '2022-2024')
       projectAuditPeriod: {
-        type: String,
+        type: [String, Array],
         default: '',
       },
       // 是否查看模式
@@ -213,6 +298,21 @@
         type: [String, Number],
         default: '',
       },
+      // 从父组件传入的固定表头(逗号分隔)
+      fixedFields: {
+        type: String,
+        default: '',
+      },
+      // 从父组件传入的固定表头字段ID(逗号分隔,可缺省)
+      fixedFieldids: {
+        type: String,
+        default: '',
+      },
+      // 表头/校验元数据
+      columnsMeta: {
+        type: Array,
+        default: () => [],
+      },
     },
     data() {
       return {
@@ -222,6 +322,8 @@
         validationErrors: [],
         cellCodeIndex: {}, // cellCode -> row
         dependentsMap: {}, // cellCode -> Set(rows depending on it)
+        // 字典数据容器(供 dictMixin 拉取并本地缓存)
+        dictData: {},
       }
     },
     watch: {
@@ -229,6 +331,10 @@
         async handler(newVal) {
           this.dialogVisible = newVal
           if (newVal) {
+            // 打印父组件传入的 fixedFields
+            console.log('FixedTableDialog fixedFields:', this.fixedFields)
+            // 打印父组件传入的 surveyData
+            console.log('FixedTableDialog surveyData:', this.surveyData)
             // 先初始化表格数据
             this.initTableData()
             // 等待 DOM 更新后,如果有 uploadId,调用接口获取详情数据并回显
@@ -275,11 +381,339 @@
           this.initTableData()
         }
       },
+      // 预加载字典选项:扫描元数据中的 dictCode 等字段(优先 surveyData.fixedHeaders)
+      columnsMeta: {
+        handler() {
+          this.preloadDicts()
+        },
+        immediate: true,
+        deep: true,
+      },
+      surveyData: {
+        handler() {
+          this.preloadDicts()
+        },
+        deep: true,
+      },
+      // 字典数据加载完成后,尝试将已存在的单元格值从“名称”规范化为“字典值/键”,以便下拉正确回显
+      dictData: {
+        handler() {
+          this.normalizeAllDictValues()
+        },
+        deep: true,
+      },
     },
     mounted() {
       this.initYearColumns()
     },
     methods: {
+      // 获取字典选项(与 FixedAssetsTable.vue 一致)
+      getDictOptions(dictType) {
+        if (!dictType || !this.dictData) return []
+        const key = String(dictType)
+        const arr = this.dictData[key]
+        return Array.isArray(arr) ? arr : []
+      },
+      // 元数据统一出口:优先使用 surveyData.fixedHeaders,其次 columnsMeta
+      metaList() {
+        const fromSurvey =
+          this.surveyData && Array.isArray(this.surveyData.fixedHeaders)
+            ? this.surveyData.fixedHeaders
+            : null
+        if (fromSurvey) return fromSurvey
+        return Array.isArray(this.columnsMeta) ? this.columnsMeta : []
+      },
+      preloadDicts() {
+        const metas = this.metaList()
+        const codes = new Set()
+        metas.forEach((m) => {
+          const code =
+            (m && m.dictCode && String(m.dictCode).trim()) ||
+            (m && m.dictType && String(m.dictType).trim()) ||
+            (m && m.typeKey && String(m.typeKey).trim()) ||
+            (m && m.dictId && String(m.dictId).trim()) ||
+            (m && m.dictid && String(m.dictid).trim()) ||
+            ''
+          if (code) codes.add(code)
+        })
+        if (codes.size > 0) {
+          if (!this.dictData) this.dictData = {}
+          const list = Array.from(codes)
+          // 先本地初始化,保证 getDictOptions 可用
+          list.forEach((k) => {
+            if (!this.dictData[k]) this.$set(this.dictData, k, [])
+          })
+          // 直接逐个拉取,兼容接口返回 { value: [] }
+          Promise.all(
+            list.map((typeKey) =>
+              getByTypeKey({ typeKey })
+                .then((res) => {
+                  const arr = Array.isArray(res)
+                    ? res
+                    : Array.isArray(res?.value)
+                    ? res.value
+                    : []
+                  this.$set(this.dictData, typeKey, arr)
+                })
+                .catch(() => {
+                  // 出错保持为空数组
+                  this.$set(this.dictData, typeKey, [])
+                })
+            )
+          ).then(() => {
+            // 拉取完成后触发一次规范化回显
+            this.normalizeAllDictValues()
+          })
+          // 同时调用 mixin 的批量方法作为兜底(部分环境可能返回数组)
+          if (typeof this.getDictType === 'function') {
+            this.getDictType()
+          }
+        }
+      },
+      // 由父组件传入的 fixedFields 生成表头标签数组
+      dynamicColumns() {
+        if (!this.fixedFields) return []
+        const labels = String(this.fixedFields)
+          .split(',')
+          .map((s) => s.trim())
+          .filter(Boolean)
+        // union: 追加任何 meta 中 (showVisible==='1' 或 有 dictCode) 的列
+        const metas = this.metaList()
+        const extras = []
+        metas.forEach((m) => {
+          const label = String(m?.label || m?.fieldName || m?.name || '').trim()
+          if (!label) return
+          const hasDict = !!(
+            (m.dictCode && String(m.dictCode).trim()) ||
+            (m.dictType && String(m.dictType).trim()) ||
+            (m.typeKey && String(m.typeKey).trim()) ||
+            (m.dictId && String(m.dictId).trim()) ||
+            (m.dictid && String(m.dictid).trim())
+          )
+          const sv =
+            m.showVisible != null && String(m.showVisible).trim() === '1'
+          if (
+            (hasDict || sv) &&
+            !labels.includes(label) &&
+            !extras.includes(label)
+          ) {
+            extras.push(label)
+          }
+        })
+        const base = labels.concat(extras)
+        // 基于元数据控制显示:showVisible==='1' 强制显示;否则当 isAuditPeriod==='false' 隐藏
+        const filtered = base.filter((label) => {
+          const m = this.getFieldMeta(label)
+          if (!m) return true
+          // 强制显示优先
+          const sv = m.showVisible
+          if (sv != null && String(sv).trim() === '1') return true
+          const v = m.isAuditPeriod
+          if (v === undefined || v === null || v === '') return true
+          const str = String(v).trim().toLowerCase()
+          return !(str === 'false' || str === '0' || str === 'no')
+        })
+        // 若全部被过滤导致空表头,则回退为全部显示,避免页面空白
+        return filtered.length > 0 ? filtered : base
+      },
+      // ====== fixedFields 渲染辅助(基于 columnsMeta)======
+      getFieldMeta(label) {
+        const metas = this.metaList()
+        const key = String(label || '').trim()
+        if (!key) return null
+        return (
+          metas.find((m) => String(m?.label || '').trim() === key) ||
+          metas.find(
+            (m) => String(m?.fieldName || m?.name || '').trim() === key
+          ) ||
+          null
+        )
+      },
+      isBooleanField(label) {
+        const meta = this.getFieldMeta(label)
+        if (!meta) return false
+        const t = String(meta.fieldType || meta.type || '').toLowerCase()
+        return t === 'boolean' || t === 'bool'
+      },
+      toYesNo(val) {
+        const s = String(val == null ? '' : val)
+          .trim()
+          .toLowerCase()
+        if (!s) return ''
+        const yes = ['1', 'true', 'y', 'yes', '是']
+        const no = ['0', 'false', 'n', 'no', '否']
+        if (yes.includes(s)) return '是'
+        if (no.includes(s)) return '否'
+        return s
+      },
+      getDateType(label) {
+        const meta = this.getFieldMeta(label)
+        if (!meta) return ''
+        const fmtRaw = meta.format ? String(meta.format).trim() : ''
+        const fmtL = fmtRaw.toLowerCase()
+        if (fmtRaw) {
+          if (/(hh|hh:mm|hh:mm:ss)/i.test(fmtRaw) || fmtL.includes('hh'))
+            return 'datetime'
+          if (/^y{4}$/i.test(fmtRaw)) return 'year'
+          if (
+            (/y{4}/i.test(fmtRaw) && /m{2}/i.test(fmtRaw)) ||
+            fmtL.includes('yyyy-mm')
+          )
+            return 'date'
+        }
+        const t = String(meta.type || meta.fieldType || '').toLowerCase()
+        if (t.includes('datetime') || t.includes('timestamp')) return 'datetime'
+        if (t.includes('date') && !t.includes('datetime')) return 'date'
+        if (t.includes('year')) return 'year'
+        return ''
+      },
+      getDateFormat(label) {
+        const meta = this.getFieldMeta(label)
+        if (!meta) return ''
+        const type = this.getDateType(label)
+        const fmt = meta.format ? String(meta.format).trim() : ''
+        if (type === 'year') return fmt || 'yyyy'
+        if (type === 'datetime') return fmt || 'yyyy-MM-dd HH:mm:ss'
+        if (type === 'date') return fmt || 'yyyy-MM-dd'
+        return ''
+      },
+      isDictField(label) {
+        const meta = this.getFieldMeta(label)
+        if (!meta) return false
+        const dictCode =
+          (meta.dictCode && String(meta.dictCode).trim()) ||
+          (meta.dictType && String(meta.dictType).trim()) ||
+          (meta.typeKey && String(meta.typeKey).trim()) ||
+          (meta.dictId && String(meta.dictId).trim()) ||
+          (meta.dictid && String(meta.dictid).trim()) ||
+          ''
+        return !!dictCode
+      },
+      getFieldDictOptions(label) {
+        const meta = this.getFieldMeta(label)
+        if (!meta) return []
+        // 优先字典接口
+        const code =
+          (meta.dictCode && String(meta.dictCode).trim()) ||
+          (meta.dictType && String(meta.dictType).trim()) ||
+          (meta.typeKey && String(meta.typeKey).trim()) ||
+          (meta.dictId && String(meta.dictId).trim()) ||
+          (meta.dictid && String(meta.dictid).trim()) ||
+          ''
+        if (code && typeof this.getDictOptions === 'function') {
+          const list = this.getDictOptions(code)
+          if (Array.isArray(list) && list.length > 0) return list
+        }
+        const opts = meta.options
+        return Array.isArray(opts) ? opts : []
+      },
+      // 取某列的字典编码(供模板渲染时使用)
+      getDictCode(label) {
+        const meta = this.getFieldMeta(label)
+        if (!meta) return ''
+        return (
+          (meta.dictCode && String(meta.dictCode).trim()) ||
+          (meta.dictType && String(meta.dictType).trim()) ||
+          (meta.typeKey && String(meta.typeKey).trim()) ||
+          (meta.dictId && String(meta.dictId).trim()) ||
+          (meta.dictid && String(meta.dictid).trim()) ||
+          ''
+        )
+      },
+      // 将特定 label 列上已有的值,若匹配到字典项的 name/label,则替换为该项的 key/value
+      normalizeDictValuesForLabel(label) {
+        if (!label || !Array.isArray(this.tableData)) return
+        if (!this.isDictField(label)) return
+        const code = this.getDictCode(label)
+        const opts = code
+          ? this.getDictOptions(code)
+          : this.getFieldDictOptions(label)
+        if (!Array.isArray(opts) || opts.length === 0) return
+        const nameToValue = new Map()
+        const nameToKey = new Map()
+        const valueSet = new Set()
+        const keySet = new Set()
+        opts.forEach((o) => {
+          const key = o && (o.key != null ? String(o.key) : undefined)
+          const val = o && (o.value != null ? String(o.value) : undefined)
+          const name = o && (o.name != null ? String(o.name) : undefined)
+          const labelTxt = o && (o.label != null ? String(o.label) : undefined)
+          if (name) {
+            if (val != null) nameToValue.set(name, val)
+            if (key != null) nameToKey.set(name, key)
+          }
+          if (labelTxt) {
+            if (val != null) nameToValue.set(labelTxt, val)
+            if (key != null) nameToKey.set(labelTxt, key)
+          }
+          if (val != null) valueSet.add(val)
+          if (key != null) keySet.add(key)
+        })
+        this.tableData.forEach((row) => {
+          if (!row || row.isCategory) return
+          const cur = row[label]
+          if (cur == null || cur === '') return
+          const curStr = String(cur)
+          if (valueSet.has(curStr) || keySet.has(curStr)) return
+          const mappedVal = nameToValue.get(curStr)
+          const mappedKey = nameToKey.get(curStr)
+          if (mappedVal != null) {
+            this.$set(row, label, mappedVal)
+          } else if (mappedKey != null) {
+            this.$set(row, label, mappedKey)
+          }
+        })
+      },
+      // 规范化所有字典列的值
+      normalizeAllDictValues() {
+        const labels = this.dynamicColumns()
+        if (!Array.isArray(labels)) return
+        labels.forEach((label) => this.normalizeDictValuesForLabel(label))
+      },
+      getMaxLength(label) {
+        const meta = this.getFieldMeta(label)
+        if (!meta) return undefined
+        const fmt = meta.format != null ? String(meta.format).trim() : ''
+        if (/^\d+$/.test(fmt)) return parseInt(fmt)
+        return undefined
+      },
+      onFixedFieldInput(label, row, val) {
+        const meta = this.getFieldMeta(label)
+        if (!meta || row == null) return
+        let s = String(val == null ? '' : val)
+        const t = String(meta.fieldType || meta.type || '').toLowerCase()
+        if (t === 'integer') {
+          s = s.replace(/[^0-9\-]/g, '')
+          s = s.replace(/(?!^)-/g, '')
+          const maxInt = parseInt(meta.fieldTypelen)
+          if (!isNaN(maxInt) && maxInt > 0) {
+            const neg = s.startsWith('-')
+            const digits = neg ? s.slice(1) : s
+            const clipped = digits.slice(0, maxInt)
+            s = neg ? '-' + clipped : clipped
+          }
+        } else if (t === 'double' || t === 'decimal' || t === 'number') {
+          s = s.replace(/[^0-9+\-\.]/g, '')
+          s = s.replace(/(?!^)-/g, '')
+          const firstDot = s.indexOf('.')
+          if (firstDot >= 0) {
+            s =
+              s.slice(0, firstDot + 1) +
+              s.slice(firstDot + 1).replace(/\./g, '')
+          }
+          const dlen =
+            parseInt(meta.fieldTypenointlen) || parseInt(meta.fieldTypeNointLen)
+          if (!isNaN(dlen) && dlen >= 0 && firstDot >= 0) {
+            const parts = s.split('.')
+            parts[1] = (parts[1] || '').slice(0, dlen)
+            s = parts.join('.')
+          }
+        } else {
+          const max = this.getMaxLength(label)
+          if (max && s.length > max) s = s.slice(0, max)
+        }
+        this.$set(row, label, s)
+      },
       // 初始化年份列
       initYearColumns() {
         // 1) 优先使用立项信息中的监审期间(数组)
@@ -292,8 +726,17 @@
           })
         } else if (this.projectAuditPeriod) {
           // 2) 立项信息中的监审期间(字符串)
-          const periods = this.parseAuditPeriod(this.projectAuditPeriod)
-          this.yearColumns = periods
+          if (Array.isArray(this.projectAuditPeriod)) {
+            this.yearColumns = this.projectAuditPeriod.map((period) => {
+              if (typeof period === 'string' && period.includes('-')) {
+                return period.split('-')[0]
+              }
+              return String(period)
+            })
+          } else {
+            const periods = this.parseAuditPeriod(this.projectAuditPeriod)
+            this.yearColumns = periods
+          }
         } else if (this.auditPeriods && this.auditPeriods.length > 0) {
           // 3) 组件入参的监审期间(数组)
           // 如果传入了监审期间,使用监审期间
@@ -453,6 +896,8 @@
         // 构建公式索引并计算一次
         this.buildFormulaEngine()
         this.recomputeAllFormulas()
+        // 初始化后按年份对父项进行汇总
+        this.recomputeAllParentYears()
       },
       // 创建行数据
       createRowData(item, isChild, rowid) {
@@ -473,6 +918,16 @@
           rowData[`year_${year}`] = item[`year_${year}`] || ''
         })
 
+        // 初始化固定表头(fixedFields)对应的列,确保 v-model 有响应式属性
+        const labels = this.dynamicColumns()
+        if (Array.isArray(labels) && labels.length > 0) {
+          labels.forEach((label) => {
+            if (rowData[label] === undefined) {
+              this.$set(rowData, label, '')
+            }
+          })
+        }
+
         // 初始化备注
         rowData.remark = item.remark || ''
 
@@ -552,6 +1007,16 @@
                       row[`year_${year}`] = rowData[year]
                     }
                   })
+
+                  // 回显固定表头字段(与保存时一致,rkey 使用中文表头 label)
+                  const labels = this.dynamicColumns()
+                  if (Array.isArray(labels) && labels.length > 0) {
+                    labels.forEach((label) => {
+                      if (rowData[label] !== undefined) {
+                        this.$set(row, label, rowData[label])
+                      }
+                    })
+                  }
                 }
               })
 
@@ -560,12 +1025,40 @@
 
               // 回显后根据公式重算
               this.recomputeAllFormulas()
+              // 回显后根据子项汇总父项年份
+              this.recomputeAllParentYears()
+              // 回显后再做一次字典值规范化,确保显示选中项
+              this.normalizeAllDictValues()
             }
           } catch (err) {
             console.error('获取固定表详情失败', err)
           }
         }
       },
+      // =========== 年份父项汇总 ==========
+      recomputeAllParentYears() {
+        if (!Array.isArray(this.tableData) || this.tableData.length === 0)
+          return
+        const parents = this.tableData.filter((r) => r && r.isCategory)
+        const years = Array.isArray(this.yearColumns) ? this.yearColumns : []
+        parents.forEach((parent) => {
+          const pid = parent.rowid || parent.id
+          const children = this.tableData.filter(
+            (r) =>
+              r &&
+              !r.isCategory &&
+              (r.parentid === pid || String(r.parentid) === String(pid))
+          )
+          years.forEach((y) => {
+            const field = `year_${y}`
+            const sum = children.reduce(
+              (acc, c) => acc + (Number(c[field]) || 0),
+              0
+            )
+            this.$set(parent, field, sum === 0 ? '' : String(sum))
+          })
+        })
+      },
       // 获取假数据(用于测试)
       getMockTableData() {
         // return [
@@ -653,6 +1146,8 @@
         this.validateLinkage(row, year)
         // 实时联动计算
         this.recomputeDependentsForRow(row, year)
+        // 失焦也触发父项汇总
+        this.recomputeAllParentYears()
       },
       // 仅数字输入:按行规则限制小数位;整数不允许小数
       handleNumericInput(row, year, val) {
@@ -693,6 +1188,8 @@
         // 写回且触发联动
         this.$set(row, field, s)
         this.handleCellInput(row, year)
+        // 年份联动:子项变更后,汇总到父项
+        this.recomputeAllParentYears()
       },
       // 单元格失焦事件
       handleCellBlur(row, year) {
@@ -891,38 +1388,226 @@
       },
       // 保存前验证
       validateBeforeSave() {
-        this.validationErrors = []
-        let isValid = true
+        // 参考 FixedAssetsTable 的校验方式:优先使用 surveyData.fixedHeaders(metaList)
+        const metas = this.metaList()
+        const metaByLabel = new Map()
+        const metaByFieldName = new Map()
+        metas.forEach((m) => {
+          if (!m) return
+          if (m.label) metaByLabel.set(String(m.label).trim(), m)
+          if (m.fieldName) metaByFieldName.set(String(m.fieldName).trim(), m)
+        })
 
-        // 验证所有单元格
-        this.tableData.forEach((row) => {
-          if (row.isCategory) return
+        const isEmpty = (v) => v === undefined || v === null || v === ''
+        const isIntWithLen = (v, len) => {
+          const s = String(v == null ? '' : v)
+          if (!/^[-+]?\d+$/.test(s)) return false
+          const digits = s.replace(/^[-+]?/, '')
+          return len === undefined ? true : digits.length <= Number(len)
+        }
+        const isDecWithLen = (v, intLen, decLen) => {
+          const s = String(v == null ? '' : v)
+          if (!/^[-+]?\d*(?:\.\d+)?$/.test(s)) return false
+          const [i, d = ''] = s.replace(/^[-+]?/, '').split('.')
+          const io = intLen === undefined ? true : i.length <= Number(intLen)
+          const doK = decLen === undefined ? true : d.length <= Number(decLen)
+          return io && doK && (i.length > 0 || d.length > 0)
+        }
+        const inferType = (meta) => {
+          if (!meta) return ''
+          const fmt = meta.format
+            ? String(meta.format).trim().toLowerCase()
+            : ''
+          if (fmt) {
+            if (/(hh|hh:mm|hh:mm:ss)/i.test(fmt) || fmt.includes('hh'))
+              return 'datetime'
+            if (/^y{4}$/.test(fmt)) return 'year'
+            if (
+              (/y{4}/.test(fmt) && /m{2}/.test(fmt)) ||
+              fmt.includes('yyyy-mm')
+            )
+              return 'date'
+          }
+          const t = String(meta.type || meta.fieldType || '').toLowerCase()
+          if (t.includes('datetime') || t.includes('timestamp'))
+            return 'datetime'
+          if (t.includes('date') && !t.includes('datetime')) return 'date'
+          if (t.includes('year')) return 'year'
+          if (
+            t.includes('int') ||
+            t.includes('number') ||
+            t.includes('decimal') ||
+            t.includes('float') ||
+            t.includes('double')
+          )
+            return 'number'
+          return ''
+        }
 
-          this.yearColumns.forEach((year) => {
-            const cellValid = this.validateCell(row, year)
-            if (!cellValid) {
-              isValid = false
-              this.validationErrors.push({
-                row: row.itemName,
-                year,
-                message: `${row.itemName}的${year}年数据验证失败`,
-              })
-            }
-          })
+        const errors = []
+        const fixedLabels = this.dynamicColumns()
 
-          // 验证勾稽关系
-          this.yearColumns.forEach((year) => {
-            const linkageValid = this.validateLinkage(row, year)
-            if (!linkageValid) {
-              isValid = false
-            }
-          })
+        // 校验每行固定表头列
+        this.tableData.forEach((row, rowIndex) => {
+          if (row.isCategory) return
+          if (Array.isArray(fixedLabels)) {
+            fixedLabels.forEach((label) => {
+              const meta =
+                metaByLabel.get(String(label).trim()) ||
+                metaByFieldName.get(String(label).trim())
+              const val = row[label]
+              const reqRaw =
+                meta &&
+                (meta.required != null ? meta.required : meta.isRequired)
+              const req =
+                reqRaw != null &&
+                ['1', 'true', 'y', 'yes', '是'].includes(
+                  String(reqRaw).trim().toLowerCase()
+                )
+              if (meta && req && isEmpty(val)) {
+                errors.push(`第${rowIndex + 1}行【${label}】不能为空`)
+                return
+              }
+              if (isEmpty(val)) return
+              const t = inferType(meta)
+              const intLen =
+                (meta &&
+                  (meta.fieldTypelen ||
+                    meta.fieldTypeLen ||
+                    meta.totalLength)) ??
+                undefined
+              const decLenRaw =
+                (meta &&
+                  (meta.fieldTypenointlen ||
+                    meta.fieldTypeNointLen ||
+                    meta.decimalLength ||
+                    meta.scale)) ??
+                undefined
+              const decLen =
+                t === 'number' &&
+                String(meta?.fieldType || '')
+                  .toLowerCase()
+                  .includes('int')
+                  ? 0
+                  : decLenRaw
+
+              if (t === 'year') {
+                if (!(typeof val === 'string' && /^\d{4}$/.test(val))) {
+                  errors.push(`第${rowIndex + 1}行【${label}】必须为四位年份`)
+                }
+              } else if (t === 'date') {
+                if (
+                  !(typeof val === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(val))
+                ) {
+                  errors.push(
+                    `第${rowIndex + 1}行【${label}】日期格式应为YYYY-MM-DD`
+                  )
+                }
+              } else if (t === 'datetime') {
+                if (
+                  !(
+                    typeof val === 'string' &&
+                    /^(\d{4}-\d{2}-\d{2})\s+\d{2}:\d{2}(:\d{2})?$/.test(val)
+                  )
+                ) {
+                  errors.push(
+                    `第${
+                      rowIndex + 1
+                    }行【${label}】日期时间格式应为YYYY-MM-DD HH:mm:ss`
+                  )
+                }
+              } else if (t === 'number') {
+                const treatAsInt =
+                  String(meta?.type || meta?.fieldType || '')
+                    .toLowerCase()
+                    .includes('int') || decLen === 0
+                if (treatAsInt) {
+                  if (!isIntWithLen(val, intLen)) {
+                    errors.push(
+                      `第${rowIndex + 1}行【${label}】必须为整数且整数位不超过${
+                        intLen || '限定'
+                      }位`
+                    )
+                  }
+                } else if (!isDecWithLen(val, intLen, decLen)) {
+                  errors.push(
+                    `第${rowIndex + 1}行【${label}】必须为数字,整数不超过${
+                      intLen || '限定'
+                    }位,小数不超过${decLen}位`
+                  )
+                }
+              } else {
+                // 普通文本按 format 数字限制最大长度(如 format: '5')
+                const fmt =
+                  meta && meta.format != null ? String(meta.format).trim() : ''
+                if (/^\d+$/.test(fmt)) {
+                  const max = parseInt(fmt)
+                  const s = String(val == null ? '' : val)
+                  if (!isNaN(max) && s.length > max) {
+                    errors.push(
+                      `第${rowIndex + 1}行【${label}】长度不能超过${max}个字符`
+                    )
+                  }
+                }
+              }
+              // 字典/枚举校验:优先字典接口选项,其次 meta.options
+              if (meta) {
+                const collectValues = (list) =>
+                  new Set(
+                    (Array.isArray(list) ? list : [])
+                      .map((o) => {
+                        const v =
+                          o &&
+                          (o.key != null
+                            ? o.key
+                            : o.value != null
+                            ? o.value
+                            : o.label)
+                        return v != null ? String(v) : undefined
+                      })
+                      .filter(Boolean)
+                  )
+                let allowed = new Set()
+                const code =
+                  (meta.dictCode && String(meta.dictCode).trim()) ||
+                  (meta.dictType && String(meta.dictType).trim()) ||
+                  (meta.typeKey && String(meta.typeKey).trim()) ||
+                  (meta.dictId && String(meta.dictId).trim()) ||
+                  (meta.dictid && String(meta.dictid).trim()) ||
+                  ''
+                if (code && typeof this.getDictOptions === 'function') {
+                  const dictList = this.getDictOptions(code)
+                  if (Array.isArray(dictList) && dictList.length > 0) {
+                    allowed = collectValues(dictList)
+                  }
+                }
+                if (allowed.size === 0 && Array.isArray(meta.options)) {
+                  allowed = collectValues(meta.options)
+                }
+                if (allowed.size > 0 && !allowed.has(String(val))) {
+                  errors.push(
+                    `第${rowIndex + 1}行【${label}】不在允许的取值范围内`
+                  )
+                }
+              }
+            })
+          }
         })
 
-        // 验证所有勾稽关系
+        // 同时校验年份列(沿用现有 validateCell 与勾稽关系)
+        // this.tableData.forEach((row) => {
+        //   if (row.isCategory) return
+        //   this.yearColumns.forEach((year) => {
+        //     if (!this.validateCell(row, year)) {
+        //       errors.push(`${row.itemName}的${year}年数据验证失败`)
+        //     }
+        //   })
+        // })
+
+        // 勾稽关系校验
         this.validateAllLinkages()
-
-        return isValid
+        this.validationErrors = errors
+        return errors.length === 0
       },
       // 验证所有勾稽关系
       validateAllLinkages() {
@@ -1033,10 +1718,14 @@
       async handleSave() {
         // 验证数据
         if (!this.validateBeforeSave()) {
-          const errorMessages = this.validationErrors
-            .map((err) => `${err.row}的${err.year}年:${err.message}`)
-            .join('\n')
-          Message.error('数据验证失败:\n' + errorMessages)
+          const msgs = Array.isArray(this.validationErrors)
+            ? this.validationErrors
+            : []
+          // 仅展示前若干条,避免过长
+          const preview = msgs.slice(0, 5).join('\n')
+          Message.error(
+            `数据验证失败:\n${preview}${msgs.length > 5 ? '\n……' : ''}`
+          )
           return
         }
 
@@ -1083,6 +1772,21 @@
               }
             }
 
+            // 保存固定表头字段的值(来自 fixedFields / dynamicColumns)——扩展到所有行
+            const fixedLabels = this.dynamicColumns()
+            if (Array.isArray(fixedLabels) && fixedLabels.length > 0) {
+              fixedLabels.forEach((label) => {
+                const val = row[label]
+                if (val !== undefined && val !== null && val !== '') {
+                  saveData.push({
+                    rowid: rowid,
+                    rkey: label,
+                    rvalue: String(val),
+                  })
+                }
+              })
+            }
+
             // 保存年份数据(所有行都可以有年份数据)
             this.yearColumns.forEach((year) => {
               const yearValue = row[`year_${year}`]