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 文档

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

json 复制代码
{
  "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 行,再往里塞就没法维护了。

javascript 复制代码
// 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 而不是减法:

javascript 复制代码
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)))
  }))
}

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

javascript 复制代码
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 页码残留后作为「知识库来源依据」折叠展示:

javascript 复制代码
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()
}

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

javascript 复制代码
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>」,直接渲染页面上就会露出标签文本。转成上标字符再剥掉残余标签:

javascript 复制代码
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 用玫瑰图、专利类型用环形图、区域用柱状图。共性在数据形态,差异在呈现方式。

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

javascript 复制代码
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 }给单条数据画柱状图,出来就是孤零零一根柱子,非常难看。

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

javascript 复制代码
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

javascript 复制代码
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' 会把包体积拉大一大截。实际只用到三种图表类型的话:

javascript 复制代码
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 是静默失效的,不报错------这个我排查了十几分钟才反应过来。

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

javascript 复制代码
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() 也执行了,但页面上一张图都没有,控制台干干净净没有任何报错

最初的代码长这样:

javascript 复制代码
// ✗ 错误写法
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 // ← 太晚了
  }
}

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

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

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

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


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

javascript 复制代码
// ✓ 正确写法
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 的容器不会报错,会正常返回一个实例,只是画布是空的。后续即使数据变化也不会自动恢复。加个显式判断和重试:

javascript 复制代码
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 直接盯容器本身:

javascript 复制代码
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 警告。合帧之后一帧最多执行一次。

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

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

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

5.1 getDataURL 的三个前置条件

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

javascript 复制代码
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 离屏固定尺寸渲染实现

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

javascript 复制代码
/** 导出用固定画布尺寸(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 打开。

javascript 复制代码
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 内联,不需要额外的图片文件:

javascript 复制代码
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 限宽:

html 复制代码
<img src="data:image/png;base64,..." style="width:100%; max-width:620px;" />

在浏览器里这样写完全没问题。但 Word 的 HTML 导入器不解析 img 上的 widthmax-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 优先采信:

javascript 复制代码
/**
 * 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 的导航窗格是按段落大纲级别列条目的,跟标签本身无关:

css 复制代码
.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。我们后端返回过这样一个响应:

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

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

javascript 复制代码
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 技术体系,持续更新硬核实战内容。

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

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

相关推荐
创新技术阁1 小时前
FastapiAdmin 实战:二次开发前的准备(环境配置与项目启动)
前端·后端·fastapi
志尊宝1 小时前
Vue3 零基础每日笔记(010):watchEffect——用到谁就自动听谁的“懒人侦听器“
前端·vue.js·笔记
狗哥哥1 小时前
从“看对方向”到“做出行动”:投资决策卡
前端
拖孩2 小时前
一个人 + AI 做的小程序,一个月赚了 36 块
前端·后端·微信小程序
weixin_440730502 小时前
playwright实战-渠道应用操作
开发语言·前端·python
lhldsg2 小时前
从零构建智慧场馆解决方案小程序:开发全流程实战
java·前端·小程序
IMPYLH2 小时前
HTML 的 <rp> 元素
前端·javascript·html
晴天162 小时前
npm install -f(--force)深度解析:作用原理、报错根源与风险避坑指南
前端·npm·node.js
IT_陈寒2 小时前
React的状态更新竟然不是同步的?!坑了我一整天
前端·人工智能·后端