Grid++Report 在 Vue 3 项目的完整接入指南(2026)— 网页预览、iframe 嵌入、PDF 导出、ERP 发票打印全攻略

写在前面

这是一篇接续我 2021 年所写 《锐浪报表 Grid++Report 使用教程 - Web 打印解决方案》2026 进阶版

5 年过去了,Vue 3 + TypeScript + Composition API 已成主流,浏览器 NPAPI/Chrome 扩展插件机制大变,锐浪报表也持续迭代,网页预览成为新主流方案。

本文专攻几个高频搜索问题:Grid++Report 网页预览实现Vue WEB 报表插件接入Vue 3 + TypeScript 完整封装ERP/用友风格的销售发票打印

写在前面:本文与老文的分工

维度2021 老文本文(2026)
框架Vue 2 + Options APIVue 3 + Composition API + TypeScript
锐浪版本Grid++Report 6Grid++Report 6/7 双版本兼容
部署方式安装客户端 + 浏览器插件网页预览 + iframe + 客户端三种方案
案例简单发票 demo完整 ERP 销售发票打印(含 .grf 模板字段、数据绑定、打印链路、PDF 导出)
篇幅1500 字5500+ 字
目标读者第一次接触锐浪已用过锐浪 / 要在 Vue 3 项目里落地

如果你第一次接触锐浪报表,建议先看老文打底,再回来读本文。


1. 2026 锐浪报表生态现状

1.1 Grid++Report 6 / 7 选哪个

版本大致发布时段支持浏览器网页预览推荐场景
Grid++Report 6较老IE / Chrome / Edge(需插件)❌(仅打印)老 ERP 系统兼容
Grid++Report 7近年全浏览器 + 免插件预览2026 新项目首选

结论:新项目首选 Grid++Report 7,老项目维护沿用 6。本文兼容两个大版本,差异点会单独标注(版本号请以 锐浪官网open in new window 最新说明为准)。

1.2 三种集成方案对比

方案适用场景优点缺点
A. 网页预览(推荐)在线预览 + 打印跨浏览器、免插件、移动端可用仅 GR7 支持
B. 客户端 + JS 插件高级打印(套打、连续打印)功能最强需安装 client.exe
C. iframe 嵌入老系统快速集成0 改造、隔离父子通信稍麻烦

下文 4-6 章会完整覆盖三种方案


2. 环境与项目准备

2.1 推荐技术栈(2026 版)

Node.js     18.16+ 或 20.x LTS
Vue         3.5.x
TypeScript  5.5+
Vite        5.x
锐浪报表    Grid++Report 7(同时兼容 6)
设计器      Grid++Report Designer 7(C/S 工具,开发机本地装一份)

2.2 创建 Vue 3 + TS 项目

pnpm create vite my-erp -- --template vue-ts
cd my-erp
pnpm install
pnpm install -D @types/node

tsconfig.json 关键配置:

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "types": ["vite/client"]
  }
}

2.3 .grf 模板文件的存放位置

锐浪报表的模板文件后缀是 .grf(Grid Report File),建议放在 Vite 项目的 public/templates/ 下:

public/
├── templates/
│   ├── invoice.grf            # 销售发票模板
│   ├── delivery-note.grf      # 送货单
│   └── monthly-report.grf     # 月度汇总
└── grlib/
    ├── grproxyreport.js       # GR6 客户端通信脚本
    └── gr-web-7.0.js          # GR7 网页预览脚本

模板放 public/ 而非 src/assets/,因为:

  1. 走静态路径访问(不被 Vite 处理)
  2. 文件大(一般 50-300KB)不需要打包
  3. 前后端都能直接拉

3. 安装锐浪 Web 套打与浏览器配置

3.1 Grid++Report Client(客户端组件)

仅当你需要 本地打印 / 套打 / 标签机 时安装。预览方案(GR7)不需要。

下载 Grid++Report Client 安装包open in new window,运行 GRReportClientSetup.exe

  • 默认装在 C:\Program Files (x86)\Grid++Report 7 Client\
  • 安装完启动「Grid++Report Client Service」(端口 8888,监听本机打印请求)
  • 在系统托盘能看到锐浪图标说明启动成功

2026

Chrome / Edge 早在 2015 年起就陆续移除 NPAPI 插件机制,GR6 的浏览器内嵌插件方式在新版 Chrome / Edge 上已不可用。如果你的系统在用 GR6,需要:

  • GR7 网页预览 方案(推荐)
  • 或安装 Grid++Report Client Service(本机服务进程)配合 JS 调用

3.2 GR7 网页预览模式(无需客户端)

GR7 的网页预览模式通过纯 JavaScript 实现,零安装、跨浏览器、移动端可用。 只需要在前端引入 gr-web-7.0.js,将 .grf 模板和 JSON 数据传给它,它会在浏览器内 canvas 渲染出 PDF 风格预览。

后面第 4 章详细讲。


4. 网页预览实现(核心)

这是搜索量最高的需求:Grid++Report 如何在浏览器里做到网页预览?

API

本文下方代码使用的 LoadFromURL / SetParamValue / DetailGrid.Recordset.Append / SetFieldValue / PreviewInDiv / Print / ExportToPDF 等是锐浪报表的惯用 API 范式,具体方法名与签名以你安装的 GR 版本 SDK 文档为准。如果某个方法在你装的版本里叫别的名字,对照 SDK 改一下即可,整体接入模式不变

4.1 方案 A:GR7 原生网页预览(推荐)

index.html 引入 GR7 网页预览库:

<!-- public/index.html -->
<script src="/grlib/gr-web-7.0.js"></script>

封装一个 Vue 3 组件 src/components/ReportPreview.vue

<template>
  <div class="report-preview-wrapper">
    <div class="toolbar">
      <button @click="handlePrint">打印</button>
      <button @click="handleExportPDF">导出 PDF</button>
      <button @click="handleClose">关闭</button>
    </div>
    <div ref="previewRef" class="report-preview" />
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

interface ReportProps {
  templateUrl: string
  data: Record<string, unknown> | Record<string, unknown>[]
  detailData?: Record<string, unknown>[]
}

const props = defineProps<ReportProps>()
const emit = defineEmits<{ close: [] }>()

const previewRef = ref<HTMLDivElement | null>(null)
let reportInstance: GRReport | null = null

onMounted(async () => {
  if (!previewRef.value) return
  reportInstance = new window.GRReport()
  await reportInstance.LoadFromURL(props.templateUrl)

  reportInstance.DetailGrid.Recordset.Clear()
  if (Array.isArray(props.detailData)) {
    props.detailData.forEach((row) => {
      const rec = reportInstance!.DetailGrid.Recordset.Append()
      Object.entries(row).forEach(([k, v]) => rec.SetFieldValue(k, v))
    })
  }

  if (props.data && !Array.isArray(props.data)) {
    Object.entries(props.data).forEach(([k, v]) => {
      reportInstance!.SetParamValue(k, v as string | number)
    })
  }

  reportInstance.PreviewInDiv(previewRef.value)
})

onBeforeUnmount(() => {
  reportInstance?.Stop()
  reportInstance = null
})

const handlePrint = () => reportInstance?.Print(false)
const handleExportPDF = () => reportInstance?.ExportToPDF('report.pdf')
const handleClose = () => emit('close')
</script>

<style scoped>
.report-preview-wrapper {
  display: flex;
  flex-direction: column;
  height: 100vh;
}
.toolbar { padding: 10px; border-bottom: 1px solid #ddd; }
.toolbar button { margin-right: 8px; }
.report-preview { flex: 1; overflow: auto; background: #f5f5f5; }
</style>

4.2 TypeScript 类型补全

由于 GR7 是浏览器全局变量,需要补类型声明 src/types/grreport.d.ts

interface GRRecord {
  SetFieldValue(fieldName: string, value: unknown): void
}

interface GRRecordset {
  Clear(): void
  Append(): GRRecord
}

interface GRDetailGrid {
  Recordset: GRRecordset
}

interface GRReport {
  LoadFromURL(url: string): Promise<void>
  SetParamValue(name: string, value: string | number): void
  DetailGrid: GRDetailGrid
  PreviewInDiv(container: HTMLElement): void
  Print(showDialog?: boolean): void
  ExportToPDF(filename: string): void
  Stop(): void
}

interface Window {
  GRReport: { new (): GRReport }
}

加上 tsconfig.json"include": ["src/**/*.ts", "src/**/*.d.ts"],TS 就能识别 window.GRReport

4.3 方案 B:GR6 + 客户端 + JS 通信

GR6 仍在用的话,借助本机 Grid++Report Client Service(监听 localhost:8888):

async function previewWithGR6(templateUrl: string, dataJson: object) {
  const resp = await fetch('http://localhost:8888/preview', {
    method: 'POST',
    body: JSON.stringify({ template: templateUrl, data: dataJson })
  })
  const result = await resp.json()
  if (result.success) {
    window.open(result.previewUrl, '_blank')
  }
}

但这种方案在 2026 已经强烈不建议新项目使用,原因:

  1. 必须装客户端
  2. HTTPS 站点访问 HTTP localhost 会被浏览器拦截(mixed content)
  3. 移动端无法用

5. Vue 3 + Composition API 完整封装

为了在多个业务页面复用打印逻辑,把上面的预览组件抽成 Composable Hook

5.1 useReport.ts 复用钩子

// src/composables/useReport.ts
import { ref, shallowRef } from 'vue'

export interface ReportParams {
  templateUrl: string
  master?: Record<string, unknown>
  detail?: Record<string, unknown>[]
}

export function useReport() {
  const isLoading = ref(false)
  const error = ref<Error | null>(null)
  const reportRef = shallowRef<GRReport | null>(null)

  async function loadReport(container: HTMLElement, params: ReportParams) {
    isLoading.value = true
    error.value = null
    try {
      const report = new window.GRReport()
      await report.LoadFromURL(params.templateUrl)

      if (params.master) {
        Object.entries(params.master).forEach(([k, v]) => {
          report.SetParamValue(k, v as string | number)
        })
      }
      if (params.detail?.length) {
        report.DetailGrid.Recordset.Clear()
        params.detail.forEach((row) => {
          const rec = report.DetailGrid.Recordset.Append()
          Object.entries(row).forEach(([k, v]) => rec.SetFieldValue(k, v))
        })
      }

      report.PreviewInDiv(container)
      reportRef.value = report
    } catch (e) {
      error.value = e as Error
    } finally {
      isLoading.value = false
    }
  }

  function print(showDialog = false) {
    reportRef.value?.Print(showDialog)
  }

  function exportPDF(filename = 'report.pdf') {
    reportRef.value?.ExportToPDF(filename)
  }

  function dispose() {
    reportRef.value?.Stop()
    reportRef.value = null
  }

  return { isLoading, error, loadReport, print, exportPDF, dispose }
}

5.2 业务页面调用示例

<!-- src/views/InvoicePage.vue -->
<template>
  <div class="invoice-page">
    <h2>销售发票 {{ invoiceNo }}</h2>
    <div ref="containerRef" class="preview-container" />
    <div class="actions">
      <button :disabled="isLoading" @click="print()">打印</button>
      <button :disabled="isLoading" @click="exportPDF()">导出 PDF</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useReport } from '@/composables/useReport'

const invoiceNo = ref('INV-2026-0512-001')
const containerRef = ref<HTMLDivElement | null>(null)
const { isLoading, loadReport, print, exportPDF, dispose } = useReport()

onMounted(async () => {
  if (!containerRef.value) return
  await loadReport(containerRef.value, {
    templateUrl: '/templates/invoice.grf',
    master: {
      InvoiceNo: invoiceNo.value,
      CustomerName: '深圳某科技有限公司',
      InvoiceDate: '2026-05-12'
    },
    detail: [
      { ProductName: 'AI 工具年度授权', UnitPrice: 999, Qty: 2, Amount: 1998 },
      { ProductName: '技术咨询服务',  UnitPrice: 800, Qty: 5, Amount: 4000 }
    ]
  })
})

onBeforeUnmount(dispose)
</script>

<style scoped>
.invoice-page { padding: 20px; }
.preview-container { height: 800px; border: 1px solid #ddd; margin: 20px 0; }
.actions button { margin-right: 8px; }
</style>

6. iframe 嵌入集成 + 父子通信

老系统(如 thinkphp / .NET WebForms)改造成 Vue 3 太重,更现实的做法是用 iframe 把 Vue 3 报表页嵌入老系统

6.1 iframe 父子结构

[Vue 3 报表服务 https://report.your-company.com]
└── iframe 嵌入到
    └── [老 ERP 系统 https://erp.your-company.com/invoice/print]

6.2 父→子:传参与触发预览

<!-- 老 ERP 页面 -->
<iframe
  id="reportFrame"
  src="https://report.your-company.com/invoice"
  width="100%"
  height="800"
  style="border:0"
></iframe>

<script>
function sendDataToReport() {
  const frame = document.getElementById('reportFrame')
  frame.contentWindow.postMessage({
    action: 'loadInvoice',
    payload: {
      invoiceNo: 'INV-2026-0512-001',
      items: [/* ... */]
    }
  }, 'https://report.your-company.com')
}
</script>

6.3 子(Vue 3 报表页)监听 + 渲染

// src/views/InvoicePage.vue 增加 onMounted
import { onMounted, onBeforeUnmount } from 'vue'

const handleMessage = async (event: MessageEvent) => {
  if (event.origin !== 'https://erp.your-company.com') return
  const { action, payload } = event.data
  if (action === 'loadInvoice') {
    await loadReport(containerRef.value!, {
      templateUrl: '/templates/invoice.grf',
      master: { InvoiceNo: payload.invoiceNo },
      detail: payload.items
    })
  }
}

onMounted(() => window.addEventListener('message', handleMessage))
onBeforeUnmount(() => window.removeEventListener('message', handleMessage))

重要:必须校验

否则任何站点都能通过 postMessage 操控你的报表页,存在 XSS 风险。event.origin !== '<可信源>' return 是底线。

6.4 子→父:通知打印完成

function notifyParentPrintDone(invoiceNo: string) {
  if (window.parent !== window) {
    window.parent.postMessage(
      { action: 'printDone', invoiceNo },
      'https://erp.your-company.com'
    )
  }
}

老 ERP 收到后可以更新业务状态(如「已打印」标签)。


7. 打印 / 套打 / PDF 导出

7.1 普通打印

report.Print(false)   // false = 不弹打印对话框,直接打到默认打印机
report.Print(true)    // true  = 弹对话框,让用户选打印机

7.2 套打(针式打印机连续票据)

票据连打、快递面单、增值税发票套打的关键是:

  • .grf 模板里设置纸张为「连续纸」、不要分页
  • report.PrintContinuous(true) 启用连续打印
report.LoadFromURL('/templates/express-label.grf')
report.PrintContinuous(true)

7.3 PDF 导出

GR7 支持纯前端导出 PDF:

report.ExportToPDF('invoice-2026-0512-001.pdf')

也支持服务端生成(需要 Grid++Report Server,本文不展开)。

7.4 多页报表与分组

.grf 模板里:

  • 「主标题区」每页重复 → 设置 RepeatOnPage = true
  • 「明细区」自动分页 → 设置 Detail.NewRecordRow = AutoPage
  • 「页脚」显示「第 N 页/共 N 页」→ 用内置变量 [Page] / [PageCount]

8. 真实业务案例:ERP 销售发票打印

把上面 1-7 章串起来,写一个完整的「ERP 销售发票打印」案例。

8.1 数据结构(来自后端 API)

// 后端返回的发票 JSON
interface SalesInvoice {
  invoiceNo: string
  invoiceDate: string
  customerName: string
  customerTaxId: string
  customerAddress: string

  items: Array<{
    productName: string
    spec: string
    unit: string
    qty: number
    unitPrice: number
    amount: number
    taxRate: number
    taxAmount: number
  }>

  subtotal: number
  taxTotal: number
  total: number
  totalChinese: string   // 人民币大写
  remark: string

  payee: string
  reviewer: string
  drawer: string
}

8.2 .grf 模板字段约定

在 Grid++Report Designer 中设计 invoice.grf,绑定字段:

控件类型绑定字段
InvoiceNo静态字段参数 InvoiceNo
InvoiceDate静态字段参数 InvoiceDate
CustomerName静态字段参数 CustomerName
CustomerTaxId静态字段参数 CustomerTaxId
DetailGrid明细区ProductName / Spec / Unit / Qty / UnitPrice / Amount / TaxRate / TaxAmount
Subtotal / TaxTotal / Total静态字段参数
TotalChinese静态字段参数
Payee / Reviewer / Drawer静态字段参数

8.3 业务页面完整代码

<!-- src/views/InvoicePrint.vue -->
<template>
  <div class="invoice-print">
    <header class="page-header">
      <h2>销售发票打印 — {{ invoice?.invoiceNo }}</h2>
      <div class="actions">
        <button :disabled="isLoading || !invoice" @click="print(false)">直接打印</button>
        <button :disabled="isLoading || !invoice" @click="print(true)">选择打印机</button>
        <button :disabled="isLoading || !invoice" @click="exportPDFBtn">导出 PDF</button>
      </div>
    </header>

    <div v-if="error" class="error">加载失败:{{ error.message }}</div>
    <div v-if="isLoading" class="loading">加载中…</div>

    <div ref="containerRef" class="preview" />
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { useReport } from '@/composables/useReport'

interface SalesInvoice { /* 见 8.1 节 */ }

const route = useRoute()
const containerRef = ref<HTMLDivElement | null>(null)
const invoice = ref<SalesInvoice | null>(null)
const { isLoading, error, loadReport, print, exportPDF, dispose } = useReport()

async function fetchInvoice(invoiceNo: string) {
  const resp = await axios.get<SalesInvoice>(`/api/invoices/${invoiceNo}`)
  invoice.value = resp.data
}

async function renderReport() {
  if (!containerRef.value || !invoice.value) return
  const inv = invoice.value
  await loadReport(containerRef.value, {
    templateUrl: '/templates/invoice.grf',
    master: {
      InvoiceNo: inv.invoiceNo,
      InvoiceDate: inv.invoiceDate,
      CustomerName: inv.customerName,
      CustomerTaxId: inv.customerTaxId,
      CustomerAddress: inv.customerAddress,
      Subtotal: inv.subtotal,
      TaxTotal: inv.taxTotal,
      Total: inv.total,
      TotalChinese: inv.totalChinese,
      Remark: inv.remark,
      Payee: inv.payee,
      Reviewer: inv.reviewer,
      Drawer: inv.drawer
    },
    detail: inv.items.map((it) => ({
      ProductName: it.productName,
      Spec: it.spec,
      Unit: it.unit,
      Qty: it.qty,
      UnitPrice: it.unitPrice,
      Amount: it.amount,
      TaxRate: `${(it.taxRate * 100).toFixed(0)}%`,
      TaxAmount: it.taxAmount
    }))
  })
}

function exportPDFBtn() {
  if (!invoice.value) return
  exportPDF(`Invoice-${invoice.value.invoiceNo}.pdf`)
}

onMounted(async () => {
  const invoiceNo = route.params.invoiceNo as string
  await fetchInvoice(invoiceNo)
  await renderReport()
})

onBeforeUnmount(dispose)
</script>

<style scoped>
.invoice-print { padding: 20px; }
.page-header { display: flex; justify-content: space-between; align-items: center; }
.actions button { margin-left: 8px; }
.error { color: red; padding: 10px; background: #fee; }
.loading { padding: 20px; text-align: center; color: #888; }
.preview { height: calc(100vh - 120px); border: 1px solid #ddd; margin-top: 16px; }
</style>

8.4 与用友 / 金蝶 ERP 系统的接入要点

如果你接的是用友 NC、用友 U8、金蝶 K3 等系统:

  1. 取数 API:通过 ERP 的 OpenAPI / OData / 二次开发接口获取发票 JSON
  2. 打印权限:Vue 3 报表页加权限校验(JWT / 单点登录)
  3. 打印日志:每次打印调一次 POST /api/print-log,记录谁打印、何时打印、打印多少份
  4. 批量打印:循环调 report.Print(false),注意控制并发(建议串行 + setTimeout 500ms 间隔)

9. 常见问题 2026 升级版

Q1:Chrome 浏览器报「无法访问 localhost:8888」

原因:HTTPS 站点访问 HTTP localhost 被浏览器拦截。 解决:升级到 GR7 网页预览方案(不依赖本机服务)。

Q2:网页预览乱码

原因:模板里的字体在用户机器上没有。 解决:模板设计时只用常见字体(微软雅黑、宋体、Arial),或者把字体打包到 client.exe。

Q3:PDF 导出文字模糊

原因:默认 PDF 渲染分辨率较低,文字小时看着模糊。 解决

  • .grf 模板里增大字体字号与导出 DPI(Designer → 文档属性 → 打印质量)
  • 或在导出前调用锐浪提供的 SetPrintQuality / SetPDFOption 等接口(以你装的具体 GR 版本 API 为准,请参考随附 SDK 文档)
// 示意:具体接口名以你安装的 GR 版本 SDK 为准
// report.SetPDFOption('Quality', 'High')
report.ExportToPDF('xxx.pdf')

Q4:iframe 跨域 postMessage 收不到消息

原因:origin 写错 / 协议不一致(HTTP vs HTTPS)。 解决:父子页严格用同一种协议(建议都用 HTTPS);postMessage 的 targetOrigin 必须精确匹配。

Q5:Vue 3 项目 Cannot find name 'GRReport'

原因:TypeScript 找不到全局类型声明。 解决:参考 4.2 节,在 src/types/grreport.d.tsdeclare global { interface Window { GRReport: ... } },并在 tsconfig.jsoninclude 包含 src/**/*.d.ts

Q6:连续打印每次都弹打印对话框

原因Print(true) 第一个参数传 true 会弹对话框。 解决:批量打印用 Print(false)

Q7:移动端能用吗

GR7 网页预览:✅ 移动端 Chrome / Safari 都能预览 直接打印:❌ 移动端没本机打印能力,只能导出 PDF 让用户用手机自带的「打印」共享

Q8:能不能纯前端生成 .grf 模板

不行.grf 是锐浪私有二进制格式,必须用 Grid++Report Designer 设计。但你可以前端动态修改字段值(这正是 SetParamValue / SetFieldValue 干的事)。

Q9:GR7 收费吗

GR7 个人开发版免费,商用授权按客户端数量计费,详见 锐浪官网open in new window 报价。

Q10:怎么调试 .grf 模板?

在 Designer 里有「预览」功能,可以用样例数据(在 Designer → 数据 → 样例数据 中编辑 JSON)直接看渲染效果,省得每次回 Vue 项目测试。


10. 总结 & 完整源码

到这里你应该可以:

  • ✅ 在 Vue 3 + TS 项目里网页预览任意 .grf 模板
  • ✅ 用 Composable Hook 在多个业务页复用打印逻辑
  • ✅ 把 Vue 3 报表页iframe 嵌入老 ERP 并安全通信
  • ✅ 实现打印 / 套打 / PDF 导出三大场景
  • ✅ 写一个完整 ERP 销售发票打印案例
  • ✅ 应对 GR6 → GR7 迁移移动端兼容TS 类型补全等高频问题

关键代码包

文件作用
src/composables/useReport.ts可复用 Composable Hook
src/components/ReportPreview.vue通用预览组件
src/views/InvoicePrint.vueERP 销售发票打印页
src/types/grreport.d.tsTS 类型声明
public/templates/invoice.grf发票模板(用 Designer 设计)
public/grlib/gr-web-7.0.jsGR7 网页预览库

延伸阅读

写在最后

锐浪报表在 B 端 ERP 系统里是默默无闻但极其稳的工具。这 5 年我做过若干个用友、金蝶、自研 ERP 的报表模块,遇到的坑 90% 都能在本文找到解决方案。

如果你正在做 ERP 报表,希望这篇实战分享能帮你省下 1-2 周的踩坑时间

有问题可在文末评论区留言,我会在 48 小时内回复。

—— luoxuancong,2026 年 5 月写于深圳

Last Updated 2026/5/12 14:20:18
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.8
ON THIS PAGE