Excel 导出列显示 UUID / 枚举 key,页面却显示正常——两条数据通路撕裂了
用户反馈说导出的 Excel「类型列显示 preset_enum 不是汉字」,但页面上看着一切正常。打开代码一看,两边对着同一份数据,问题出在哪?这是一个经典的「双路径静默退化」坑。
现象
- 表格页面上每一列都有
columns[i].render()做格式化:枚举 key 映射成中文 label,FK id 解析成角色名,状态码变成彩色标签。 - 点「导出 Excel」,下载下来一看:
readonly、editable、d4078bed-edd0-...——全是原始值。 - 两份数据来源相同,没有 loading 问题,也没有报错。
根因
表格页面存在两条互相独立的「数据 → 展示」路径:
- 渲染路径(用户肉眼看到的):
columns[].render(value, row)或 cell formatter 从options常量 /rolesstate 做反查,把原始值翻译成人类可读文本。 - 导出路径(用户下载到的):
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。- 如果
handleExport是useCallback,新增对roles/options的引用必须同步加进 deps 数组,否则 eslintreact-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] ?? '')
})
}新增列时作者必须同时考虑 render 和 exportValue,从结构上杜绝两条路径分裂。
排查速查
在 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,会随着字典增长持续静默扩大。