返回博客

Excel 导出列显示 UUID / 枚举 key,页面却显示正常——两条数据通路撕裂了

用户反馈说导出的 Excel「类型列显示 preset_enum 不是汉字」,但页面上看着一切正常。打开代码一看,两边对着同一份数据,问题出在哪?这是一个经典的「双路径静默退化」坑。

现象

  • 表格页面上每一列都有 columns[i].render() 做格式化:枚举 key 映射成中文 label,FK id 解析成角色名,状态码变成彩色标签。
  • 点「导出 Excel」,下载下来一看:readonlyeditabled4078bed-edd0-...——全是原始值。
  • 两份数据来源相同,没有 loading 问题,也没有报错。

根因

表格页面存在两条互相独立的「数据 → 展示」路径:

  1. 渲染路径(用户肉眼看到的):columns[].render(value, row) 或 cell formatter 从 options 常量 / roles state 做反查,把原始值翻译成人类可读文本。
  2. 导出路径(用户下载到的):handleExport() 遍历 rows,把 row[key] 直接塞进 xlsx/csv 生成器,完全绕过 render。

初次写的时候两边都「通」,所以测试通过。此后字典不断扩充,新枚举值、新角色陆续进来——只有渲染路径被日常使用覆盖到,导出路径悄悄退化,却从不报错。

坑:export handler 直接 push raw value,绕过所有 formatter
// 看起来没问题,实际上全是原始值
rows.push([col.type, col.interaction, col.viewRoles.join('、')])

col.type"preset_enum"col.viewRoles 是 UUID 数组——这些原始值在 render 里会被翻译,但 export 走了另一条路,字典查询从未被调用。

正解

快速修(Pattern A):在 export handler 内复用已有字典查找

不要重新请求接口,不要新建副本——就用组件 scope 里已经存在的 options 常量和 roles state:

// 修复后
rows.push([
  typeOptions.find(t => t.value === col.type)?.label ?? col.type,
  interactionOptions.find(i => i.value === col.interaction)?.label ?? col.interaction,
  (col.viewRoles ?? [])
    .map(id => roles.find(r => r.id === id)?.name ?? id)
    .join('、'),
])

两个细节缺一不可:

  • ?? col.type 兜底——字典里查不到时保留原始值,避免导出文件出现 undefined
  • 如果 handleExportuseCallback,新增对 roles / options 的引用必须同步加进 deps 数组,否则 eslint react-hooks/exhaustive-deps 会报警,且导出结果可能读到过期闭包。
正解:export handler 与 render 路径共享同一份字典反查逻辑

渲染侧怎么翻译枚举,导出侧就怎么翻译枚举。options.find().label ?? raw 是最小改动,改动范围精确到出问题的列,无需动后端 API。

架构修(Pattern B):给 ColumnDef 增加 exportValue 字段

新建表格页面时,在列定义里强制区分「渲染」和「导出」两个语义:

type ColumnDef<Row> = {
  key: string
  label: string
  render?: (value: unknown, row: Row) => ReactNode   // UI 渲染
  exportValue?: (value: unknown, row: Row) => string // 导出专用,永远是字符串
}
 
function toExportRow<R>(cols: ColumnDef<R>[], row: R): string[] {
  return cols.map(c => {
    if (c.exportValue) return c.exportValue(row[c.key as keyof R], row)
    const rendered = c.render?.(row[c.key as keyof R], row)
    return typeof rendered === 'string' ? rendered : String(row[c.key as keyof R] ?? '')
  })
}

新增列时作者必须同时考虑 renderexportValue,从结构上杜绝两条路径分裂。

排查速查

在 export handler 里 grep 这几个 red flag:

rows.push([row.type, row.status, ...])            // 直接推原始值
rows.push([col.viewRoles.join('、'), ...])         // join 了 ID 数组
xlsx.utils.aoa_to_sheet(data.map(r => [r.x, r.y])) // 直接属性访问

再对比 render 侧,若两边没有共享任何 helper,基本确诊。

一句话外卖

导出路径和渲染路径来自同一张表,但从不共享同一套字典翻译——这是一个结构性漏洞,不是 bug,会随着字典增长持续静默扩大。