欢迎光临
我们一直在努力

Vue 104 ,AI + ECharts + Word:大模型数据可视化报告生成实战(前端导出图文并茂 Word 文档)

前言

大模型能吐出结构化 JSON,前端能画 ECharts,Word 能装图片——这三件事分开都不难,串成一条流水线就全是坑。我在一个专利导航系统里把这条链路走通了:用户描述需求,大模型返回六组统计数据,前端渲染成图表,最后一键导出一份图文并茂的 Word 报告。

过程中踩的坑比写的代码还多:图表一张都渲染不出来但控制台不报错、图片插进 Word 只显示左边三分之一、收起侧边栏后图表右侧留一片空白、章节标题在 Word 里被套上一圈厚边框。这篇把完整链路拆开讲,包括每个坑的现场、根因和解法。读完你能照着搭出同样的流程,并且避开我踩过的这些。

技术栈:Vue 3.4 + ECharts 5.5 / 6.x + Element Plus,导出走纯前端方案,零额外依赖。

在这里插入图片描述 首先,看下系统页面及代码实现的前端效果图: 在这里插入图片描述 然后,以下是导出的图文并茂 Word 文档的部分效果展示: 在这里插入图片描述

在这里插入图片描述

一、场景拆解与链路设计

1.1 一句自然语言到一份图文报告

先说清楚要做什么。用户在对话框里写一段技术描述,大模型抽取要素、生成检索式,后端并行调度几个服务做统计分析,返回一坨 JSON。前端要把这坨 JSON 变成两样东西:页面上可交互的报告,和一份可下载的 Word 文档。

后端返回的核心是六组统计数据,结构完全同构:

{
"trendAnalysis": [
{ "analysisCol": "appDateYear", "analysisName": "申请年", "analysisTotal": 42,
"list": [{ "key": "2020", "value": 170724 }, { "key": "2023", "value": 168044 }] }
],
"applicantAnalysis": [{ "analysisCol": "applicantName", "list": […] }],
"inventorAnalysis": [{ "analysisCol": "inventorName", "list": […] }],
"technicalClassAnalysis": [{ "analysisCol": "ipcSection", "list": […] }],
"chinaSpecialTopicAnalysis":[{ "analysisCol": "patType", "list": […] }],
"regionalAnalysis": [{ "analysisCol": "pubCountryCode", "list": […] }]
}

六个字段长得一模一样,这是后面能用一个构建器覆盖所有图表的前提。看到这种同构结构,第一反应就该是抽象成通用适配器,而不是写六份图表配置。

1.2 三段式分层与职责边界

整条链路我拆成三段,各自职责明确、互不越界,如图1所示。

在这里插入图片描述

图1 大模型数据到图文报告的三段式链路

数据层只做一件事:把大模型返回的原始 JSON 洗成结构稳定的领域模型。排序、去重、拆分、码值映射全在这里做完,出口的数据保证组件可以直接消费,不需要再判断 null、不需要再 JSON.parse。

适配层把领域模型翻译成 ECharts option。这一层不碰 DOM,输出的是纯配置对象,谁来渲染它不关心。

渲染与导出层分两条通道。页面通道要自适应容器宽度,导出通道要固定画布尺寸——这两个需求天然冲突,所以必须分开走,共用同一份 option 但各自初始化实例。这个设计后面第五章会展开讲,它解决了导出图片尺寸失控的问题。

1.3 为什么配置必须与渲染载体解耦

很多人写 ECharts 会把 echarts.init() 和 setOption() 写在一起,配置对象直接内联在初始化代码里。功能上没问题,但只要出现「同一张图要在两个地方以不同尺寸渲染」的需求,就会被迫复制一份配置。

把 option 提取成纯函数的返回值——buildTrendOption(data) 进去数据、出来配置,不产生任何副作用——页面渲染和离屏导出就能共用。这是后面能实现导出通道的关键,不然导出逻辑要把六份图表配置重写一遍。

二、大模型返回数据的归一化

2.1 六类必然遇到的脏数据

大模型链路返回的数据和传统后端接口很不一样。传统接口的字段名、类型、顺序都由代码写死,稳定可预期;而大模型链路中间经过了模型生成、服务聚合、数据库落表几道工序,每一道都可能引入不一致。我遇到的六类问题如图2所示。

在这里插入图片描述

图2 大模型返回数据的六类典型问题

逐个说说现场。

顺序不保证最隐蔽。趋势数据的 list 是按 value 倒序返回的,也就是专利最多的年份排第一。直接丢给折线图,X 轴就成了「2020、2023、2021、2022、2019……」,曲线来回折返,看着像心电图。这个问题不会报错,只会让图表变得莫名其妙。

单字段混装两类内容最坑。dataSet 字段前半段是反斜杠分隔的数据集名称,\\n\\n 之后跟着三千多字的政策原文,里面还夹着 PDF 转文本残留的页码标记 — 3 —。这显然是 RAG 检索结果和生成结果被拼在了一起。直接渲染就是一整屏乱码般的长文。

嵌套 JSON 字符串在落库接口里出现。图表数据存在 chartDataJson 字段中,值是一个 JSON 字符串而不是对象,取 list 永远是 undefined。而且同一份数据在实时接口叫 chartData、在历史接口叫 chartDataJson,字段名都不统一。

2.2 归一化层放在哪一层

我把归一化放在 API 层,也就是 src/api/main/index.js 里,紧贴请求响应,而不是放在组件里。

理由有三个。第一,同一份数据可能被多个组件消费,放组件里就要洗好几遍。第二,实时接口和历史接口返回结构不同,但业务含义一样,在 API 层归一化后,页面代码完全不用区分数据来源。第三,脏数据处理逻辑很琐碎,塞进组件会让组件迅速膨胀——我这个页面本来就有 3000 行,再往里塞就没法维护了。

// src/api/main/index.js
export function generateReport({ sessionId, exp, …params }) {
return post(`/api/chat/green/carbon/net/report/${sessionId}`, params, {
timeout: 300000 // 后端并行调度四路服务,耗时较长
}).then((res) => {
if (res.code === 200 && res.data) return normalizeReport(res.data) // ← 出口即干净
return Promise.reject(buildReportError(res))
})
}

2.3 关键实现:排序、去重、拆分、映射

归一化函数本身没什么魔法,就是一堆纯函数的组合。挑几个有代表性的说。

趋势数据按年份升序重排。注意 key 是字符串,用 localeCompare 而不是减法:

function sortTrendAnalysis(list) {
if (!Array.isArray(list)) return []
return list.map((group) => ({
…group,
// 原始按 value 倒序返回,画折线图前必须按年份(key)升序重排
list: […(group.list || [])].sort((a, b) =>
String(a.key).localeCompare(String(b.key)))
}))
}

按业务主键去重。后端返回的高价值专利列表里,同一个公开号出现过两次,内容完全相同。写成通用工具,支持字段名或取值函数两种传参:

function dedupeBy(list, keyOrFn) {
if (!Array.isArray(list)) return []
const fn = typeof keyOrFn === 'function' ? keyOrFn : (item) => item?.[keyOrFn]
const seen = new Set()
const result = []
list.forEach((item) => {
const k = fn(item)
if (k == null || seen.has(k)) return
seen.add(k)
result.push(item)
})
return result
}

// 单字段去重
dedupeBy(raw.highValuePatents, 'appNumber')
// 复合键去重
dedupeBy(raw.ftoConflictText, (i) => `${i.patentNumber}|${i.conflictFeature}`)

混装字段拆分。以首个空行为界切开,前半段按反斜杠 split 成列表,后半段清洗掉 PDF 页码残留后作为「知识库来源依据」折叠展示:

export function parseDataSet(str) {
if (!str || typeof str !== 'string') return { datasets: [], source: '' }

const splitIndex = str.search(/\\r?\\n\\s*\\r?\\n/) // 首个空行
const head = splitIndex === –1 ? str : str.slice(0, splitIndex)
const tail = splitIndex === –1 ? '' : str.slice(splitIndex)

const datasets = head.split(/\\\\+/).map(s => s.trim()).filter(Boolean)
return { datasets, source: cleanSourceText(tail) }
}

function cleanSourceText(text) {
return text
.replace(/^[\\s\\t]*[—\\-–]\\s*\\d+\\s*[—\\-–][\\s\\t]*$/gm, '') // 去 PDF 页码 — 3 —
.replace(/\\t+/g, ' ')
.replace(/\\n{3,}/g, '\\n\\n')
.trim()
}

码值映射用常量表,顺手也给数据字典页面复用:

export const PAT_TYPE_MAP = {
1: '发明专利', 2: '实用新型', 3: '外观专利',
4: 'PCT外观', 8: 'PCT发明', 9: 'PCT实用新型'
}
export const IPC_SECTION_MAP = {
A: '人类生活必需', B: '作业、运输', C: '化学、冶金', D: '纺织、造纸',
E: '固定建筑物', F: '机械工程', G: '物理', H: '电学'
}

还有一个容易忽略的:专利摘要里带着 <Sup>2</Sup> 这类上下标标签。原文是「平方米质量在 400~1500g/m<Sup>2</Sup>」,直接渲染页面上就会露出标签文本。转成上标字符再剥掉残余标签:

function cleanAbstract(abs = '') {
const supMap = { '0':'⁰','1':'¹','2':'²','3':'³','4':'⁴',
'5':'⁵','6':'⁶','7':'⁷','8':'⁸','9':'⁹' }
return abs
.replace(/<\\s*sup\\s*>(.*?)<\\s*\\/\\s*sup\\s*>/gi, (_, t) =>
String(t).split('').map(c => supMap[c] || c).join(''))
.replace(/<\\s*sub\\s*>(.*?)<\\s*\\/\\s*sub\\s*>/gi, '$1')
.replace(/<[^>]+>/g, '')
.trim()
}

三、图表适配器:六张图一个构建器

3.1 同构数据的通用构建思路

六个字段结构相同,图表类型却不同:趋势用折线、申请人和发明人用横向条形、IPC 用玫瑰图、专利类型用环形图、区域用柱状图。共性在数据形态,差异在呈现方式。

我的做法是:按图表类型抽通用构建器,按业务字段做薄封装。横向条形图这一类的构建器长这样:

export function buildRankBarOption(group, options = {}) {
if (isEmptyGroup(group)) return null
const { title, subtext = '', topN = 10, color = '#4CAF50',
filterAnonymous = false } = options

let list = group.list || []
// 发明人榜里「不公告发明人」「请求不公布姓名」常年霸榜,得过滤掉
if (filterAnonymous) {
list = list.filter(i => !/请求不公布|不公布姓名|未公开/.test(String(i.key)))
}
list = list.slice(0, topN)
if (!list.length) return null

const reversed = […list].reverse() // 横向条形图需倒序,最大值才在顶部

return {
title: { text: title, subtext, left: 'center' },
tooltip: {
trigger: 'axis', axisPointer: { type: 'shadow' },
formatter: (params) => {
const p = params[0]
const full = reversed[p.dataIndex]?.key ?? p.name // 用全称,不用截断后的
return `<div style="font-weight:600">${full}</div>数量:<b>${p.value}</b> 件`
}
},
grid: { left: 130, right: 60, top: 70, bottom: 24 },
xAxis: { type: 'value' },
yAxis: { type: 'category', data: reversed.map(i => truncate(i.key, 14)) },
series: [{
type: 'bar',
data: reversed.map(i => i.value),
barMaxWidth: 18,
itemStyle: { borderRadius: [0, 6, 6, 0] },
label: { show: true, position: 'right', formatter: '{c}' }
}]
}
}

// 薄封装:申请人
export function buildApplicantOption(analysis, topN = 10) {
const group = pickGroup(analysis, 'applicantName') || analysis?.[0]
return buildRankBarOption(group, { title: '主要竞争主体分布', topN, color: '#4CAF50' })
}
// 薄封装:发明人(多一个匿名过滤)
export function buildInventorOption(analysis, topN = 10) {
const group = pickGroup(analysis, 'inventorName') || analysis?.[0]
return buildRankBarOption(group, { title: '核心发明人分布', topN,
color: '#26A69A', filterAnonymous: true })
}

Y 轴标签截断但 tooltip 显示全称这个细节值得注意。申请人名称动辄「中国联合网络通信集团有限公司」十几个字,不截断会把 grid 挤没;但截断后用户又看不全,所以 tooltip 里要从原始数据取全称,而不是从 params.name 拿(那是截断过的)。

3.2 空数据降级与统一出口

有个场景很典型:区域分析字段只返回了一条数据 { key: 'CN', value: 1658000 }。给单条数据画柱状图,出来就是孤零零一根柱子,非常难看。

处理办法是让构建器返回一个带降级标记的结构,交给页面决定怎么展示:

export function buildRegionalOption(regionalAnalysis) {
const group = pickGroup(regionalAnalysis, 'pubCountryCode') || regionalAnalysis?.[0]
if (isEmptyGroup(group)) return null

const items = group.list.map(i => ({
code: i.key, name: COUNTRY_MAP[i.key] || i.key, value: i.value
}))

// 单一区域画图没有意义,降级为指标卡
if (items.length < 2) return { degraded: true, items }

return { degraded: false, option: { /* 正常柱状图配置 */ } }
}

页面侧根据 degraded 分支渲染,一个渲染成图表,一个渲染成大数字指标卡。这比画一根孤柱好看得多,也比直接隐藏该模块更诚实。

最后给一个统一出口,页面拿到数组直接 v-for:

export function buildAllChartOptions(report = {}) {
const regional = buildRegionalOption(report.regionalAnalysis)
const charts = [
{ key: 'trend', title: '专利申请与公开趋势', option: buildTrendOption(report.trendAnalysis) },
{ key: 'applicant', title: '主要竞争主体分布', option: buildApplicantOption(report.applicantAnalysis) },
{ key: 'ipc', title: 'IPC 技术分类构成', option: buildIpcClassOption(report.technicalClassAnalysis) },
{ key: 'patType', title: '专利类型构成', option: buildPatTypeOption(report.chinaSpecialTopicAnalysis) },
{ key: 'inventor', title: '核心发明人分布', option: buildInventorOption(report.inventorAnalysis) },
{ key: 'regional', title: '公开国家 / 地区分布',
option: regional && !regional.degraded ? regional.option : null,
degraded: !!regional?.degraded, items: regional?.items || [] }
]
// 过滤掉既无 option 又非降级展示的空图表
return charts.filter(c => c.option || (c.degraded && c.items.length))
}

3.3 按需引入与主题常量

ECharts 5 之后按需引入的写法变了,全量 import * as echarts from 'echarts' 会把包体积拉大一大截。实际只用到三种图表类型的话:

import * as echarts from 'echarts/core'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, GridComponent,
LegendComponent, DataZoomComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'

echarts.use([BarChart, LineChart, PieChart, TitleComponent, TooltipComponent,
GridComponent, LegendComponent, DataZoomComponent, CanvasRenderer])

export { echarts }

注意 echarts.graphic 在按需引入下依然可用(渐变色要用到 LinearGradient),但 DataZoomComponent 如果不引入,配置里写了 dataZoom 是静默失效的,不报错——这个我排查了十几分钟才反应过来。

配色、字号、坐标轴样式抽成模块级常量,六张图复用同一份,保证风格统一:

const TEXT_DARK = '#2C3E2F'
const AXIS_LINE = 'rgba(76, 175, 80, 0.2)'
const SPLIT_LINE = 'rgba(76, 175, 80, 0.08)'

const BASE_TOOLTIP = {
backgroundColor: 'rgba(255, 255, 255, 0.96)',
borderColor: 'rgba(76, 175, 80, 0.25)',
padding: [10, 14],
extraCssText: 'box-shadow: 0 4px 16px rgba(76,175,80,0.15); border-radius: 8px;'
}

四、页面渲染的两个时序陷阱

4.1 一个 finally 引发的全空白

这个坑我调了快一个小时。现象是:数据拿到了、chartList 里六个 option 都在、renderCharts() 也执行了,但页面上一张图都没有,控制台干干净净没有任何报错。

最初的代码长这样:

// ✗ 错误写法
async function goToStep3() {
reporting.value = true
try {
const data = await generateReport({ … })
report.value = data
chartList.value = buildAllChartOptions(data)

await nextTick()
renderCharts() // ← 此刻 reporting 仍然是 true
} catch (e) {
handleReportError(e)
} finally {
reporting.value = false // ← 太晚了
}
}

问题出在模板结构上。报告区域是这么写的:

<div v-if="reporting"> 思考动画 </div>
<div v-else> 报告主体 + 六个图表容器 </div>

renderCharts() 执行时 reporting 还是 true,走的是 v-if 分支,报告主体连同图表容器整棵子树根本没挂载到 DOM。chartRefs 全是空的,六次循环全部命中 if (!el) return,静默跳过。finally 里才把 reporting 置 false,DOM 这时候才开始渲染——但渲染函数已经跑完了。

正确的时序是把 loading 结束提到 nextTick 之前,如图3所示。

在这里插入图片描述

图3 图表渲染的时序陷阱与两种时序对比

// ✓ 正确写法
report.value = data
chartList.value = buildAllChartOptions(data)

// 关键:必须先结束 loading,报告主体(含图表容器)才会进入 DOM
reporting.value = false
stopTimers()

await nextTick()
renderCharts()

教训是:v-if 控制的子树里如果有需要手动初始化的 DOM 节点,就必须确认渲染函数执行时该分支是激活的。这类问题不会抛异常,只会静默失败,比报错难查得多。

4.2 容器宽高为 0 的静默失败

还有一种更隐蔽的情况:容器确实在 DOM 里,但此刻宽高是 0。过渡动画没结束、父级 display:none 刚解除、CSS Grid 还没完成布局,都会出现这种状态。

echarts.init() 拿到 0×0 的容器不会报错,会正常返回一个实例,只是画布是空的。后续即使数据变化也不会自动恢复。加个显式判断和重试:

let chartRetry = 0

function renderCharts() {
let pending = 0
chartList.value.forEach((c) => {
if (!c.option) return
const el = chartRefs[c.key]
if (!el) { pending++; return }
// 容器尚未完成布局(宽高为 0)时 init 会得到空图,稍后重试
if (!el.clientWidth || !el.clientHeight) { pending++; return }

if (chartInstances[c.key]) chartInstances[c.key].dispose()
const inst = echarts.init(el)
inst.setOption(c.option)
chartInstances[c.key] = inst
observeChartBox(el)
})

// 仍有未渲染成功的 → 下一帧重试,最多 5 次,避免死循环
if (pending > 0 && chartRetry < 5) {
chartRetry++
requestAnimationFrame(() => setTimeout(renderCharts, 120))
} else {
chartRetry = 0
}
}

重试次数一定要设上限。我最早写的是无限重试,结果某次接口返回空数据、容器高度确实是 0,直接把主线程占满,页面卡死。

4.3 ResizeObserver 处理容器级尺寸变化

图表渲染出来之后,新问题来了:收起左侧历史面板,图表右侧留下一片空白。

原因很直白——我只监听了 window.resize。但折叠侧边栏、收起系统主菜单、展开某个面板,这些操作根本不会触发 window 的 resize 事件,而图表容器的宽度实实在在变了。ECharts 不重绘,画布就还是旧尺寸。

解法是用 ResizeObserver 直接盯容器本身:

let chartResizeObserver = null
let resizeRaf = null

function observeChartBox(el) {
if (!el || typeof ResizeObserver === 'undefined') return
if (!chartResizeObserver) {
chartResizeObserver = new ResizeObserver(() => {
// 合并同一帧内的多次回调,避免 ResizeObserver loop 警告
if (resizeRaf) cancelAnimationFrame(resizeRaf)
resizeRaf = requestAnimationFrame(() => {
resizeRaf = null
resizeCharts()
})
})
}
chartResizeObserver.observe(el)
}

function resizeCharts() {
Object.values(chartInstances).forEach((inst) => {
if (inst && !inst.isDisposed()) inst.resize()
})
}

// 组件卸载时务必断开,否则内存泄漏
function disposeCharts() {
if (resizeRaf) { cancelAnimationFrame(resizeRaf); resizeRaf = null }
if (chartResizeObserver) { chartResizeObserver.disconnect(); chartResizeObserver = null }
Object.keys(chartInstances).forEach((k) => {
if (chartInstances[k] && !chartInstances[k].isDisposed()) chartInstances[k].dispose()
delete chartInstances[k]
})
}

回调里的 requestAnimationFrame 合帧不是可选项。侧边栏折叠有 0.3 秒 CSS 过渡,过渡期间 ResizeObserver 会连续触发几十次回调,每次都调 resize() 会明显卡顿,浏览器还会抛 ResizeObserver loop completed with undelivered notifications 警告。合帧之后一帧最多执行一次。

另外过渡结束后再补一次校正,因为最后一帧的尺寸可能还没稳定:

watch(sidebarCollapsed, () => {
nextTick(() => resizeCharts())
setTimeout(resizeCharts, 340) // CSS 过渡 0.3s,留 40ms 余量
})

五、图表转图片:离屏固定尺寸渲染

5.1 getDataURL 的三个前置条件

ECharts 实例导出图片就一个 API:

const base64 = inst.getDataURL({
type: 'png',
pixelRatio: 2, // 超采样倍率
backgroundColor: '#ffffff' // 必须显式指定,默认透明
})

看着简单,但有三个前置条件容易忽略。

必须用 Canvas 渲染器。如果初始化时指定了 renderer: 'svg',getDataURL 返回的是 SVG 的 data URI,塞进 Word 是显示不出来的。

背景色必须显式给。不指定的话导出的 PNG 是透明底,插进 Word 看起来还行,但如果用户把文档背景设成深色,图表的深色文字就糊在一起了。

图表必须已经渲染完成。动画没跑完就导出,会截到中间帧——柱子只长了一半那种。导出前把动画关掉最稳妥。

5.2 为什么不能直接抓页面上的实例

最直觉的做法是遍历页面上已有的实例挨个 getDataURL。我一开始就是这么写的,结果导出的文档里图片大小完全失控。

根源在于页面上的图表宽度是自适应的。同一张图,1920 屏幕下容器可能 700px,通栏的趋势图能到 1400px;用户折叠了侧边栏又会变宽。乘上 pixelRatio: 2,导出的 PNG 固有像素从 1400px 到 2800px 不等。每次导出的图片尺寸都不一样,长宽比也不一样,文档里六张图大小参差不齐。

更糟的是,如果某张图当前被折叠面板藏起来了,或者滚动到了视口外面,抓出来可能是空白。

5.3 离屏固定尺寸渲染实现

解法是导出时不碰页面实例,另起一个脱离文档流的容器,按固定尺寸重新渲染一遍:

/** 导出用固定画布尺寸(CSS px),保证六张图比例统一 */
const EXPORT_CHART_SIZE = {
trend: { w: 1080, h: 400 }, // 趋势图信息密度高,给宽一点
applicant: { w: 960, h: 430 },
inventor: { w: 960, h: 430 },
ipc: { w: 960, h: 400 },
patType: { w: 960, h: 400 },
regional: { w: 960, h: 380 },
default: { w: 960, h: 400 }
}
const EXPORT_PIXEL_RATIO = 2
const DOC_IMG_WIDTH = 600 // 文档中的显示宽度,下一章解释这个数字

function renderChartsForExport() {
const images = {}
const holder = document.createElement('div')
// 脱离文档流:不能用 display:none,那样容器宽高会是 0
holder.style.cssText =
'position:fixed;left:-99999px;top:0;opacity:0;pointer-events:none;z-index:-1;'
document.body.appendChild(holder)

try {
chartList.value.forEach((c) => {
if (!c.option) return
const size = EXPORT_CHART_SIZE[c.key] || EXPORT_CHART_SIZE.default

const box = document.createElement('div')
box.style.cssText = `width:${size.w}px;height:${size.h}px;background:#ffffff;`
holder.appendChild(box)

let inst = null
try {
inst = echarts.init(box, null, { width: size.w, height: size.h })
// 导出版去掉缩放滑块与动画:静态图上滑块无意义且占位,
// 动画会导致截图不完整
const opt = { …c.option, dataZoom: undefined, animation: false }
inst.setOption(opt, true)

const src = inst.getDataURL({ type: 'png',
pixelRatio: EXPORT_PIXEL_RATIO,
backgroundColor: '#ffffff' })
if (src) {
images[c.key] = {
src,
w: DOC_IMG_WIDTH,
h: Math.round(DOC_IMG_WIDTH * size.h / size.w) // 按画布比例算高度
}
}
} catch (e) {
console.warn('[导出] 图表渲染失败:', c.key, e) // 单图失败不阻断整份文档
} finally {
if (inst && !inst.isDisposed()) inst.dispose() // 必须释放
}
})
} finally {
document.body.removeChild(holder)
}
return images
}

几个要点。离屏容器不能用 display:none,那样子元素的 clientWidth 是 0,回到了 4.2 节那个坑。用 position:fixed; left:-99999px 挪出视口,元素依然参与布局。

echarts.init 的第三个参数显式指定宽高,不依赖容器计算,更稳妥。

每个实例用完必须 dispose()。六张图不释放的话,每次导出都会泄漏六个 canvas 和对应的事件监听,用户连点几次导出内存就上去了。

六、生成图文并茂的 Word 文档

6.1 三种方案的取舍

前端生成 Word 主要有三条路,各有适用场景,如图4所示。

在这里插入图片描述

图4 三种导出方案对比与 Word 专有属性速查

我最终选了 HTML + Blob 这条路,理由很实际:这是个验收阶段的项目,需要快速出效果;零依赖意味着不用担心构建配置和包体积;而且报告内容结构相对固定,用 HTML 描述比用 docx 库逐段构建段落树快得多。

如果是长期维护的正式产品,我会选 docxtemplater + 模板文件——设计同事可以直接在 Word 里改模板,前端只负责填数据,分工更清晰。

6.2 HTML + Blob 完整实现

核心原理是:Word 能直接打开 HTML 文件并按 HTML 的样式渲染。把文件后缀改成 .doc、MIME 类型设成 application/msword,双击就会用 Word 打开。

async function handleDownloadReport() {
downloading.value = true
try {
await nextTick()
const images = renderChartsForExport() // 先拿到六张图的 base64

const html = buildReportHtml(report.value, images)

// '\\ufeff' 是 BOM 头,不加中文会整篇乱码
const blob = new Blob(['\\ufeff', html], { type: 'application/msword' })

const ts = formatDate(new Date(), 'YYYYMMDD')
const filename = `${report.value.companyName || '企业'}_导航报告_${ts}.doc`

const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url) // 必须释放,否则内存不回收
ElMessage.success('报告已开始下载')
} catch (e) {
ElMessage.error('报告导出失败:' + (e.message || '未知错误'))
} finally {
downloading.value = false
}
}

BOM 头这个坑我第一次做导出时踩过。不加 \\ufeff,Word 会按 GBK 猜测编码,整篇中文变成乱码,而且看不出任何规律,非常迷惑。

HTML 模板部分,图片用 base64 内联,不需要额外的图片文件:

function buildReportHtml(r, images) {
return `<!DOCTYPE html>
<html xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:w="urn:schemas-microsoft-com:office:word">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4; margin: 2.2cm 2.4cm; mso-page-orientation: portrait; }
body { font-family: '宋体', SimSun, serif; line-height: 1.75; font-size: 12pt; }
.chapter { font-size: 15pt; font-weight: bold; color: #2E7D32;
font-family: '黑体', SimHei; margin: 26px 0 12px;
padding: 7px 12px; background: #F1F8E9;
border-left: 4px solid #4CAF50;
page-break-after: avoid; /* 标题不落在页尾 */
mso-outline-level: 1; } /* 导航窗格一级 */
.sec { font-size: 13pt; font-weight: bold; color: #2E7D32;
margin: 18px 0 8px; page-break-after: avoid;
mso-outline-level: 2; } /* 导航窗格二级 */
.chart-wrap { margin: 16px 0 22px; text-align: center;
page-break-inside: avoid; } /* 图与图题不被分页截断 */
table { width: 100%; border-collapse: collapse; font-size: 10.5pt; }
td, th { border: 1px solid #C8E6C9; padding: 6px 10px; }
</style>
</head>
<body>
<h1>企业专属专利与数据知识产权双轨导航报告</h1>

<div class="chapter">第一章 创新需求与技术全景解析</div>
<div class="sec">一、宏观技术演进态势</div>
<p>在 ${esc(r.technologyField)} 领域,全球专利申请呈现
<b>
${esc(r.trend)}</b> 趋势……</p>
${imgTag(images.trend, '图1 专利申请与公开趋势')}
${imgTag(images.ipc, '图2 IPC 技术分类构成')}
…
</body>
</html>
`
}

6.3 Word 对图片尺寸的处理规则

这是整个链路里最反直觉的一环。第一版导出的文档里,图片全都超出版心,只能看到左边三分之一左右。

我最初的写法是给图片加 CSS 限宽:

<img src="data:image/png;base64,…" style="width:100%; max-width:620px;" />

在浏览器里这样写完全没问题。但 Word 的 HTML 导入器不解析 img 上的 width 和 max-width 样式声明,这条 CSS 被整个丢弃,回退到按图片固有像素以 96dpi 换算实际尺寸。

算一笔账就明白了。A4 纸宽 21cm,左右边距各 2.4cm,版心可用宽度 16.2cm,约合 612 像素(96dpi 下 1 英寸 = 96px,1 英寸 = 2.54cm)。而通栏趋势图容器 1400 CSS px 乘以 pixelRatio: 2,固有像素是 2800px,换算过来是 74cm——版心只有 16.2cm,能看到的就只有 22%。真实比例如图5所示。

在这里插入图片描述

图5 Word 中图片超宽的真实比例对比

解法是改用 HTML 原生的 width / height 属性。这是属性不是样式,Word 优先采信:

/**
* Word 的 HTML 导入会忽略 img 上的 CSS width / max-width,
* 仅按图片固有像素以 96dpi 换算,不加原生属性就会撑破版心
*/

function imgTag(img, title) {
if (!img || !img.src) return ''
return `<div class="chart-wrap">
<div class="chart-title">
${esc(title)}</div>
<img src="
${img.src}" width="${img.w}" height="${img.h}"
style="width:
${img.w}px;height:${img.h}px;display:block;margin:0 auto;" />
</div>
`

}

width="600" 加上之后,Word 会把固有 2160px 的图缩放到 600px 显示位。固有像素保留反而是好事——相当于 3.6 倍超采样,屏幕上不占地方,打印和放大时依然锐利。CSS 那份也留着,方便有人把 HTML 直接在浏览器里预览。

6.4 导航窗格与几个 mso 开关

文档生成出来之后,甲方提了个需求:希望在 Word 里点「视图 → 导航窗格」,左侧能出现章节目录,方便跳转。

第一反应是把章标题改成 <h1> / <h2> 标签。结果每个章标题外面都多了一圈很厚的边框,非常难看。

查下来是这样:Word 见到 <h1> 会套用内置的「标题 1」样式,这个样式再遇上我给标题设的 padding + 背景底纹 + 左边框,就会把整段包装成一个 para-border-div,并自动补齐四周边框。我试过用 mso-border-alt: none 逐边关闭,压不住。

正确做法是:不用标题标签,用普通 <div> 加 mso-outline-level 属性。Word 的导航窗格是按段落大纲级别列条目的,跟标签本身无关:

.chapter {
font-size: 15pt; font-weight: bold; color: #2E7D32;
padding: 7px 12px; background: #F1F8E9; border-left: 4px solid #4CAF50;
page-break-after: avoid;
mso-outline-level: 1; /* ← 只加这一行,导航窗格就认了 */
}
.sec {
font-size: 13pt; font-weight: bold;
mso-outline-level: 2;
}

mso-outline-level 是纯段落属性,等同于在 Word 里手动打开「段落 → 常规 → 大纲级别」选「1 级」,不参与盒模型渲染,所以不会触发套框。加完之后视觉样式一点没变,导航窗格里的章节结构也齐了。

几个常用的 mso 开关列在一起:

声明作用不写会怎样
mso-outline-level: 1 / 2 段落大纲级别,驱动导航窗格与自动目录 章节不出现在导航,无法生成目录
page-break-inside: avoid 图与图题不被分页截断 图片上半页、图题下半页
page-break-after: avoid 标题不落在页面末行 标题孤零零留在页尾
@page { size: A4; margin } 页面尺寸与页边距 默认 Letter,版心宽度算错
mso-page-orientation 纸张方向 宽表格排不下

七、注意事项

7.1 版本与兼容性

ECharts 5.x 与 6.x 的按需引入路径一致,但 6.x 移除了几个 3D 相关的内置类型,如果你从旧项目迁移要检查一遍。另外 echarts.graphic.LinearGradient 在按需引入下依然可用,不需要额外 use。

ResizeObserver 在 iOS Safari 13.3 以下不支持。我的写法里做了 typeof ResizeObserver === 'undefined' 判断,降级到只监听 window.resize——功能会退化但不会报错。如果必须支持老设备,可以引 resize-observer-polyfill。

HTML 转 Word 方案在 WPS 上表现明显更差。mso-outline-level 在 WPS 里不生效,导航窗格是空的;部分 CSS 的解析也和 Word 有出入。如果目标用户大量使用 WPS,建议直接上 docxtemplater 生成真 OOXML。

7.2 性能与内存

URL.createObjectURL 之后必须 revokeObjectURL。这个 URL 持有整个 Blob 的引用,一份带六张图的报告 base64 之后有好几兆,不释放的话用户点几次导出,内存就明显上去了。

离屏渲染的实例一定要 dispose()。同理,每次导出泄漏六个 canvas,加上 ECharts 内部的事件监听和动画定时器,累积得很快。

base64 会让文档体积膨胀约 33%。六张 1920×1080 的 PNG,原始大概 1.2MB,转成 base64 内联进 HTML 大约 1.6MB。如果图表更多,或者要发邮件附件,考虑把 pixelRatio 降到 1.5,或者改用后端渲染直接嵌二进制。

7.3 大模型数据特有的坑

永远不要相信字段一定存在。同一个接口,模型在不同输入下可能返回 null、空数组、空字符串,甚至字段整个缺失。我的做法是归一化函数里每个字段都给默认值,组件里再也不写 ?. 链式判断。

业务级错误可能伪装成 HTTP 500。我们后端返回过这样一个响应:

{ "code": 500,
"msg": "IP AGENT REST CODE:{200},MSG:{当前技术描述不涉及算法、模型。}" }

这其实不是系统故障,而是上游给出的明确业务结论——用户提交的是纯机械结构方案,没有算法要素,所以某一路分析走不下去。这种情况弹一个「导出失败,请重试」毫无帮助。我的处理是在 API 层剥离外层包装、提取真实原因,再按关键词归类成几种业务错误类型,页面据此给出针对性引导:

function buildReportError(res = {}) {
const rawMsg = res.msg || ''
// 提取 MSG:{…} 中的真实原因
const inner = rawMsg.match(/MSG:\\s*\\{([\\s\\S]*?)\\}\\s*$/)
const reason = (inner ? inner[1] : rawMsg).trim() || '未知原因'

const err = new Error(reason)
err.rawMsg = rawMsg
err.reason = reason

if (/不涉及算法|不涉及模型/.test(reason)) err.bizType = 'NO_ALGORITHM'
else if (/未检索到|结果为空/.test(reason)) err.bizType = 'NO_RESULT'
else if (/检索式|语法/.test(reason)) err.bizType = 'BAD_EXPRESSION'
else err.bizType = 'UNKNOWN'
return err
}

页面拿到 bizType 之后渲染不同的失败页,告诉用户「你的技术描述里缺少算法要素,建议补充 LSTM、多元回归这类具体算法名称」,并把主按钮指向该去的那一步。这比一个红色 toast 有用得多。

7.4 交付前的自检清单

导出功能很容易「看起来没问题」,实际打开一堆毛病。我固定检查这几项:

  • 文档用 Word 打开而不是浏览器——确认 MIME 类型生效
  • 中文没有乱码——确认 BOM 头加了
  • 六张图都在,且宽度没有超出版心——确认原生 width 属性生效
  • 图片和图题在同一页——确认 page-break-inside 生效
  • 导航窗格有章节——确认 mso-outline-level 生效
  • 连续点五次导出,观察内存曲线是否持续上涨——确认实例和 URL 都释放了
  • 折叠侧边栏、缩放窗口,图表跟随重绘——确认 ResizeObserver 挂上了

八、本文总结

整条链路的核心思路可以压缩成三句话。

数据要在进入组件前洗干净。大模型链路的数据不确定性远高于传统接口,把排序、去重、拆分、映射统一收敛到 API 层,组件才能保持简单。这一层写好了,后面所有环节都轻松。

图表配置要与渲染载体解耦。option 是纯数据,谁渲染、渲染成多大都不是它该关心的事。做到这一点,页面自适应渲染和导出固定尺寸渲染才能共用同一份配置。

Word 不是浏览器。它对 HTML 和 CSS 的支持是个有限子集,img 的尺寸只认原生属性、大纲级别要靠 mso-outline-level、标题标签会触发内置样式导致意外的边框。把这几条记住,HTML 转 Word 这条路就基本平了。

什么场景适合这套方案:报告结构相对固定、需要快速交付、用户主要用 Microsoft Word。什么场景不适合:需要精确套用设计好的 Word 模板、目标用户大量使用 WPS、或者要批量后台生成归档——这三种情况应该走 docxtemplater 或后端渲染。

最后提一句,导出功能的验收最好让真实用户在真实机器上跑一遍。我在 Chrome 里测得好好的文档,同事用 WPS 打开发现导航窗格是空的——这类问题只有换环境才暴露得出来。

九、更多操作

​ 更多前端实战内容,请看,Vue 个人专栏

本文属于 Vue 企业级实战系列,持续更新 Vue2/Vue3、工程化、性能优化、跨域解决方案等干货,欢迎关注我的 CSDN 专栏:

👉 Vue Develop 实战专栏

本专栏聚焦 Vue(Vue2/Vue3)企业级实战开发。涵盖组件封装、组合式 API、前端工程化、状态管理、性能优化、项目架构,搭配 Element、ECharts 业务实战。汇集真实项目开发经验与避坑方案,帮你搭建完整 Vue 技术体系,持续更新硬核实战内容。

如果本文对你有帮助,欢迎点赞、收藏、评论,你的支持是我持续输出实战干货的动力!

如果你在项目中也遇到类似问题,欢迎留言交流,分享你的场景与解决方案。

​

在这里插入图片描述

赞(0)
未经允许不得转载:171主机测评 » Vue 104 ,AI + ECharts + Word:大模型数据可视化报告生成实战(前端导出图文并茂 Word 文档)
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址