xzc il y a 2 jours
Parent
commit
bc210140ea

+ 215 - 0
yudao-ui-admin-vue3/SYNBOY_INTEGRATION.md

@@ -0,0 +1,215 @@
+# 葡萄城渲染组件库接入说明
+
+> 本文件说明 `yudao-ui-admin-vue3` 如何通过本地 `file:` 方式引用仓库内 `葡萄城渲染/` 下的两个前端组件库。
+> 最后更新:2026-07-07
+
+---
+
+## 1. 背景
+
+主前端项目 `yudao-admin/yudao-ui-admin-vue3/` 引用了同仓库下两个本地组件库:
+
+| 包名 | 本地路径 | 用途 |
+|------|----------|------|
+| `@synboy/image-editor` | `葡萄城渲染/image-editor/` | 基于 Fabric.js 的 Vue 3 图片编辑器组件 |
+| `@synboy/spreadjs-custom-render` | `葡萄城渲染/spreadjs-custom-render/` | 基于 SpreadJS 数据结构的 Vue 3 模板渲染组件(依赖 image-editor) |
+
+引用方式:在 [package.json](package.json) 中以 `file:` 相对路径声明,pnpm 会把对应目录的 `dist/` 安装到主项目 `node_modules/@synboy/` 下。
+
+```json
+"@synboy/image-editor": "file:../../葡萄城渲染/image-editor",
+"@synboy/spreadjs-custom-render": "file:../../葡萄城渲染/spreadjs-custom-render"
+```
+
+> 另:image-editor 构建时把 `fabric` 设为 external,宿主必须提供 fabric。主项目已新增 `"fabric": "5.3.0"` 直接依赖(**版本必须与组件库一致,勿升到 7.x**)。
+
+---
+
+## 2. 首次拉取代码后的初始化步骤(重要)
+
+组件库发布的是构建产物(`dist/`),`dist/` 不一定入库。**克隆代码后必须先构建两个组件库**,否则主项目 `pnpm dev` 会报找不到 `@synboy/.../dist/index.mjs`。
+
+### 2.1 构建 image-editor
+
+```bash
+cd d:\xzc_work\tjy\code\葡萄城渲染\image-editor
+pnpm install
+pnpm build
+```
+
+产物:`葡萄城渲染/image-editor/dist/`(`index.mjs`、`index.cjs`、`style.css`、`index.d.ts`)。
+
+### 2.2 构建 spreadjs-custom-render
+
+```bash
+cd d:\xzc_work\tjy\code\葡萄城渲染\spreadjs-custom-render
+pnpm install
+pnpm build
+```
+
+产物:`葡萄城渲染/spreadjs-custom-render/dist/`(`index.mjs`、`index.cjs`、`style.css`、`index.d.ts`)。
+
+> 两个组件库的 `pnpm install` 均要求 Node ≥ 18 + pnpm ≥ 8。若 Node 17+ 报「消息加密失败」之类的 OpenSSL 错误,按 `AGENTS.md` 第 7 章给 `node --openssl-legacy-provider`。
+
+### 2.3 主项目安装依赖
+
+```bash
+cd d:\xzc_work\tjy\code\yudao-admin\yudao-ui-admin-vue3
+pnpm install --no-frozen-lockfile
+```
+
+⚠️ **必须加 `--no-frozen-lockfile`**。本项目 CI/默认配置启用了 `frozen-lockfile`,新增 `file:` 依赖后 lockfile 需要更新,不加该参数会报 `ERR_PNPM_OUTDATED_LOCKFILE`。
+
+### 2.4 启动验证
+
+```bash
+pnpm dev
+```
+
+访问示例页验证:`http://localhost:8086/#/synboy-demo`(路由在 [src/router/modules/remaining.ts](src/router/modules/remaining.ts) 末尾,hidden 路由,需手输 URL)。
+
+---
+
+## 3. 日常开发流程
+
+### 3.1 只使用组件库(不改动组件库源码)
+
+直接在主项目开发即可,无需重复构建。
+
+### 3.2 改动组件库源码后
+
+`file:` 引用的是构建产物,**源码改动不会热更新**,必须重新构建:
+
+```bash
+# 改了 image-editor
+cd d:\xzc_work\tjy\code\葡萄城渲染\image-editor
+pnpm build
+
+# 改了 spreadjs-custom-render
+cd d:\xzc_work\tjy\code\葡萄城渲染\spreadjs-custom-render
+pnpm build
+```
+
+构建完成后回到主项目,**重启 `pnpm dev`**(vite 对 `node_modules` 内文件变化不会自动重载库本身)。
+
+> 如果频繁改组件库源码,建议改用 `pnpm link --global` 软链方式,或把两个库纳入 pnpm workspace,可避免反复 build。当前接入方式以稳定优先。
+
+---
+
+## 4. 使用示例
+
+### 4.1 示例页
+
+- 文件:[src/views/synboy-demo/index.vue](src/views/synboy-demo/index.vue)
+- 路由:`/synboy-demo`(hidden,需手输 URL)
+- 内容:两个 Tab,分别演示 `ImageEditor` 和 `OptimizedTemplatePreview`
+- 模板数据:`public/synboy-demo/template-config.json`(从组件库 `__mock__/config.json` 复制,1.3MB,运行时 fetch 加载)
+
+### 4.2 在业务代码中使用
+
+```vue
+<script setup lang="ts">
+// 图片编辑器
+import { ImageEditor } from '@synboy/image-editor'
+import type { ExportEvent } from '@synboy/image-editor'
+import '@synboy/image-editor/dist/style.css'   // 必须引入样式
+
+// SpreadJS 模板渲染
+import { OptimizedTemplatePreview } from '@synboy/spreadjs-custom-render'
+import '@synboy/spreadjs-custom-render/dist/style.css'  // 必须引入样式
+</script>
+```
+
+注意点:
+
+1. **样式必须手动引入**:两个库都导出 `dist/style.css`,不引入会没有样式。
+2. **OptimizedTemplatePreview 通过 ref 调 `register()` 注入配置**,不接受 props 传 config。示例:
+   ```ts
+   const spreadRef = ref<InstanceType<typeof OptimizedTemplatePreview>>()
+   spreadRef.value?.register({
+     config,          // SpreadWorkbookConfig,来自后端或 mock
+     formData: {},    // 表单初始数据
+     showToolbar: true,
+     readonly: false
+   })
+   ```
+3. **ImageEditor 的 fabric 由宿主提供**:主项目已安装 `fabric@5.3.0`,业务代码无需再 import fabric,除非要手动操作画布(此时从 `fabric` 包直接 import 即可)。
+
+---
+
+## 5. 常见问题
+
+### Q1:`pnpm dev` 报 `ERR_PNPM_IGNORED_BUILDS`
+
+这是 pnpm 的安全机制警告,提示部分依赖(canvas、esbuild、@synboy/* 等)的安装脚本被忽略。**不影响组件库使用**(组件库已构建好 dist,不需要 postinstall)。
+
+- 如果 `pnpm dev` 因此退出码 1 而启动失败(本项目 dev 脚本会先跑 deps check),执行:
+  ```bash
+  pnpm approve-builds
+  ```
+  按提示选择允许的包(一般选 esbuild 即可,其余可不动),或直接:
+  ```bash
+  vite --mode dev
+  ```
+  跳过 deps check 直接起服务。
+
+### Q2:`Cannot find module '@synboy/.../dist/index.mjs'`
+
+组件库未构建。回到第 2.1 / 2.2 节执行 `pnpm build`。
+
+### Q3:`ERR_PNPM_OUTDATED_LOCKFILE`
+
+主项目用了 frozen-lockfile。安装时加 `--no-frozen-lockfile`:
+```bash
+pnpm install --no-frozen-lockfile
+```
+
+### Q4:image-editor 报 `fabric is not defined` / `Cannot find module 'fabric'`
+
+主项目 `package.json` 必须有 `"fabric": "5.3.0"`。若误删,恢复后重新 `pnpm install --no-frozen-lockfile`。
+
+### Q5:组件库改了代码但主项目看不到变化
+
+`file:` 引用的是 `dist/` 产物,源码改动需重新 `pnpm build` 组件库,再重启主项目 dev server。详见第 3.2 节。
+
+### Q6:`pnpm install` 报 OpenSSL「消息加密失败」
+
+Node 17+ 的 OpenSSL 3 与 dmdb/旧版加密算法不兼容。该问题主要出现在 `mcp-menu-server`,组件库本身一般无此问题。如遇到,给 node 加 `--openssl-legacy-provider` 标志。
+
+---
+
+## 6. 目录关系示意
+
+```
+d:\xzc_work\tjy\code\
+├── 葡萄城渲染\
+│   ├── image-editor\          # @synboy/image-editor 源码
+│   │   ├── dist\              # 构建产物(被主项目引用)
+│   │   ├── src\
+│   │   └── package.json
+│   └── spreadjs-custom-render\ # @synboy/spreadjs-custom-render 源码
+│       ├── dist\
+│       ├── src\
+│       └── package.json
+└── yudao-admin\
+    └── yudao-ui-admin-vue3\    # 主前端项目
+        ├── package.json        # 声明 file: 依赖 + fabric@5.3.0
+        ├── public\synboy-demo\ # 示例页用的 mock 模板配置
+        └── src\
+            ├── router\modules\remaining.ts   # /synboy-demo 路由
+            └── views\synboy-demo\index.vue   # 示例页
+```
+
+---
+
+## 7. 相关文件速查
+
+| 用途 | 路径 |
+|------|------|
+| 主项目 package.json(file: 依赖声明) | `yudao-admin/yudao-ui-admin-vue3/package.json` |
+| 示例页 | `yudao-admin/yudao-ui-admin-vue3/src/views/synboy-demo/index.vue` |
+| 示例路由 | `yudao-admin/yudao-ui-admin-vue3/src/router/modules/remaining.ts`(末尾 `/synboy-demo`) |
+| 示例模板数据 | `yudao-admin/yudao-ui-admin-vue3/public/synboy-demo/template-config.json` |
+| image-editor 源码 | `葡萄城渲染/image-editor/` |
+| spreadjs-custom-render 源码 | `葡萄城渲染/spreadjs-custom-render/` |
+| 组件库 API 文档 | 各自 `README.md` |

+ 3 - 0
yudao-ui-admin-vue3/package.json

@@ -48,6 +48,8 @@
     "@grapecity-software/spread-sheets-tablesheet": "18.0.4",
     "@iconify/iconify": "^3.1.1",
     "@microsoft/fetch-event-source": "^2.0.1",
+    "@synboy/image-editor": "file:../../葡萄城渲染/image-editor",
+    "@synboy/spreadjs-custom-render": "file:../../葡萄城渲染/spreadjs-custom-render",
     "@videojs-player/vue": "^1.0.0",
     "@vue-office/docx": "^1.6.3",
     "@vue-office/excel": "^1.7.14",
@@ -71,6 +73,7 @@
     "echarts": "^5.5.0",
     "echarts-wordcloud": "^2.1.0",
     "element-plus": "2.8.4",
+    "fabric": "5.3.0",
     "fast-xml-parser": "^4.3.2",
     "highlight.js": "^11.9.0",
     "jsencrypt": "^3.3.2",

Fichier diff supprimé car celui-ci est trop grand
+ 27562 - 0
yudao-ui-admin-vue3/public/synboy-demo/template-config.json


+ 397 - 0
yudao-ui-admin-vue3/src/components/SsjsonReportViewer/index.vue

@@ -0,0 +1,397 @@
+<template>
+  <div class="ssjson-report-viewer" v-loading="loading" element-loading-text="正在加载模板...">
+    <OptimizedTemplatePreview ref="previewRef" @ready="handleReady" @save="handleSave" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onBeforeUnmount } from 'vue'
+import * as GC from '@grapecity-software/spread-sheets'
+import {
+  OptimizedTemplatePreview,
+  type SpreadWorkbookConfig
+} from '@synboy/spreadjs-custom-render'
+import '@synboy/spreadjs-custom-render/dist/style.css'
+import { getStandardTemplateInfo } from '@/api/pressure2/standard/template'
+import { ExcelApi } from '@/api/pressure2/excel'
+import { DynamicTbValApi } from '@/api/pressure2/dynamictbval'
+import { buildFileUrl } from '@/utils'
+import axios from 'axios'
+import { ElMessage } from 'element-plus'
+
+defineOptions({ name: 'SsjsonReportViewer' })
+
+const emit = defineEmits<{
+  ready: []
+  save: [data: Record<string, any>]
+}>()
+
+const previewRef = ref<InstanceType<typeof OptimizedTemplatePreview>>()
+const loading = ref(false)
+
+// SpreadJS 授权(仅设置一次,避免反复赋值)
+const licenseKey = import.meta.env.VITE_SPREADJS_LICENSE_KEY
+if (licenseKey) {
+  GC.Spread.Sheets.LicenseKey = licenseKey
+}
+
+// 临时 Workbook 宿主元素(懒创建,复用避免反复创建销毁)
+let tempHost: HTMLDivElement | null = null
+const getTempHost = (): HTMLDivElement => {
+  if (!tempHost) {
+    tempHost = document.createElement('div')
+    tempHost.style.cssText =
+      'position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;'
+    document.body.appendChild(tempHost)
+  }
+  return tempHost
+}
+
+interface LoadOptions {
+  formData?: Record<string, any>
+  showToolbar?: boolean
+  readonly?: boolean
+}
+
+/**
+ * 解析 config 中所有字符串形式的 style 引用(命名样式)
+ *
+ * SpreadJS toJSON() 输出中,cell.style / table.headerStyle 等字段
+ * 可能是字符串(如 '__builtInStyle1'),引用 config.namedStyles 中的命名样式。
+ * OptimizedTemplatePreview 的 convertStyle 无法处理字符串,会导致样式全部丢失。
+ * 此函数把字符串引用替换为命名样式对象的深拷贝,保留完整样式信息。
+ *
+ * 不调用 OptimizedTemplateExtractor,避免:
+ * 1. normalizeCellStyle 在字符串上设属性导致 foreColor 报错
+ * 2. normalizeCellStyle 的 fontSize 单位转换等处理与 convertStyle 重复或冲突
+ *    (convertStyle 自身已做 pt→px 转换)
+ */
+const resolveNamedStyleRefs = (config: SpreadWorkbookConfig): void => {
+  const namedStyles = (config as any).namedStyles as any[] | undefined
+  if (!namedStyles || !namedStyles.length) return
+
+  // 建立命名样式映射(支持 parentName 继承链)
+  const styleMap = new Map<string, any>()
+  namedStyles.forEach(ns => {
+    if (ns.name) styleMap.set(ns.name, ns)
+  })
+
+  // 解析单个 style 引用:字符串 → 沿 parentName 链合并后的深拷贝对象
+  const resolve = (style: any): any => {
+    if (typeof style !== 'string') return style
+    const named = styleMap.get(style)
+    if (!named) return null
+    // 沿 parentName 链合并(父在前,子覆盖父)
+    const chain: any[] = []
+    let current: any = named
+    const visited = new Set<string>()
+    while (current && current.name && !visited.has(current.name)) {
+      visited.add(current.name)
+      chain.unshift(current)
+      const parentName: string | undefined = current.parentName
+      current = parentName ? styleMap.get(parentName) : undefined
+    }
+    const merged: Record<string, any> = {}
+    chain.forEach(ns => {
+      Object.keys(ns).forEach(key => {
+        if (key === 'name' || key === 'parentName') return
+        const val = ns[key]
+        if (val !== null && val !== undefined) {
+          merged[key] = val
+        }
+      })
+    })
+    return merged
+  }
+
+  // 统一处理:resolve 后要么是对象要么删除
+  const resolveField = (obj: any, field: string) => {
+    if (obj?.[field]) {
+      const resolved = resolve(obj[field])
+      if (resolved) {
+        obj[field] = resolved
+      } else {
+        delete obj[field]
+      }
+    }
+  }
+
+  Object.values(config.sheets || {}).forEach((sheet: any) => {
+    // 单元格样式
+    const dataTable = sheet.data?.dataTable
+    if (dataTable) {
+      Object.values(dataTable).forEach((row: any) => {
+        Object.values(row).forEach((cell: any) => {
+          resolveField(cell, 'style')
+        })
+      })
+    }
+    // 表格样式
+    sheet.tables?.forEach((table: any) => {
+      resolveField(table, 'headerStyle')
+      resolveField(table, 'dataStyle')
+      resolveField(table, 'alternateRowStyle')
+      table.columns?.forEach((col: any) => {
+        resolveField(col, 'headerStyle')
+        resolveField(col, 'dataStyle')
+      })
+    })
+    // 行样式
+    sheet.rows?.forEach((row: any) => resolveField(row, 'style'))
+    // 列样式
+    sheet.columns?.forEach((col: any) => resolveField(col, 'style'))
+  })
+}
+
+/**
+ * 根据模板ID加载标准模板(.sjs/.ssjson 均可),自动转换为 SpreadWorkbookConfig
+ * 新版后端只有 fileUrl,没有 quickEntryJson 字段,因此需要本地用 SpreadJS 解析后提取
+ *
+ * @param templateId 标准模板ID
+ * @param options 渲染选项
+ */
+const loadByTemplateId = async (templateId: string, options: LoadOptions = {}) => {
+  if (!templateId) {
+    ElMessage.warning('当前检验项目没有模板ID')
+    return
+  }
+  loading.value = true
+  try {
+    const templateRes = await getStandardTemplateInfo({ id: templateId })
+    if (!templateRes || !templateRes.fileUrl) {
+      ElMessage.warning('未找到模板文件')
+      return
+    }
+    const fileUrl = buildFileUrl(templateRes.fileUrl)
+    // 模板文件可能是 .sjs 二进制或 .ssjson JSON,统一用 blob 拉取后交给 SpreadJS 识别
+    const response = await axios.get(fileUrl, { responseType: 'blob' })
+    const blob = new Blob([response.data], { type: response.headers['content-type'] })
+
+    // 用临时 workbook 加载 blob,再 toJSON 转为组件所需的 SpreadWorkbookConfig
+    const host = getTempHost()
+    const workbook = new GC.Spread.Sheets.Workbook(host)
+    await new Promise<void>((resolve, reject) => {
+      workbook.open(
+        blob,
+        () => resolve(),
+        (err: any) => reject(err)
+      )
+    })
+
+    const config = workbook.toJSON() as SpreadWorkbookConfig
+    workbook.destroy()
+
+    // 解析命名样式字符串引用,保留完整样式(不调用 OptimizedTemplateExtractor,
+    // 避免 normalizeCellStyle 在字符串上设属性报错,以及与 convertStyle 重复转换)
+    resolveNamedStyleRefs(config)
+
+    previewRef.value?.register({
+      config,
+      formData: options.formData || {},
+      showToolbar: options.showToolbar ?? true,
+      readonly: options.readonly ?? false
+    })
+  } catch (error) {
+    console.error('加载 ssjson 模板失败:', error)
+    ElMessage.error('加载 ssjson 模板失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+/**
+ * 直接传入已处理好的 SpreadWorkbookConfig 进行渲染
+ * 适用于配置已经过 extract 处理、或从其他来源获取的场景
+ */
+const loadFromConfig = (
+  config: SpreadWorkbookConfig,
+  formData: Record<string, any> = {},
+  options: Omit<LoadOptions, 'formData'> = {}
+) => {
+  previewRef.value?.register({
+    config,
+    formData,
+    showToolbar: options.showToolbar ?? true,
+    readonly: options.readonly ?? false
+  })
+}
+
+// 表格行样式同步(参考 SpreadViewer.handleTableRowsAfterDataBind)
+// 数据绑定后,将第一行(模板行)的样式、行高复制到所有自动扩展的数据行
+const tableCopyOptions = GC.Spread.Sheets.CopyToOptions.style | GC.Spread.Sheets.CopyToOptions.formula
+const handleTableRowsAfterDataBind = (sheets: any[]) => {
+  for (const sheet of sheets) {
+    const tables = sheet.tables?.all() || []
+    for (const table of tables) {
+      const range = table.range()
+      const { row, rowCount, col, colCount } = range
+      if (rowCount <= 1) continue
+      // 批量设置自动换行
+      sheet.getRange(row, col, rowCount, colCount).wordWrap(true)
+      // 复制第一行样式和行高到所有数据行
+      const firstRowHeight = sheet.getRowHeight(row)
+      for (let r = 1; r < rowCount; r++) {
+        sheet.copyTo(row, col, row + r, col, 1, colCount, tableCopyOptions)
+        sheet.setRowHeight(row + r, firstRowHeight)
+      }
+    }
+  }
+}
+
+/**
+ * 根据 templateId + refId 加载后端处理好的 SJS(含图片、可编辑字段、续页)
+ * 参考 SpreadViewer.vue 的核心数据准备逻辑:
+ * 1. 调用 ExcelApi.excel 获取后端处理好的 SJS blob(已含图片、续页、可编辑字段)
+ * 2. 调用 DynamicTbValApi 获取实例数据,作为 formData 传给渲染组件
+ *    (OptimizedTemplatePreview 自身根据 bindingPath + formData 渲染,无需在 workbook 上 setDataSource)
+ * 3. 用 extractor 提取配置(保留背景图片)后渲染
+ *
+ * @param initData 同 SpreadViewer 的 initData(含 templateId、refId、dataSource 等)
+ * @param options 渲染选项
+ */
+const loadByRefId = async (
+  initData: Record<string, any>,
+  options: LoadOptions = {}
+) => {
+  const templateId = initData.templateId
+  const refId = initData.refId
+  if (!templateId || !refId) {
+    ElMessage.warning('缺少模板ID或实例ID,无法加载 ssjson')
+    return
+  }
+  loading.value = true
+  try {
+    // 1. 调用后端获取处理好的 SJS(已含图片、续页、可编辑字段)
+    const sjsBlob = await ExcelApi.excel({ templateId, refId })
+
+    // 2. 用临时 workbook 打开
+    const host = getTempHost()
+    const workbook = new GC.Spread.Sheets.Workbook(host)
+    await new Promise<void>((resolve, reject) => {
+      workbook.open(
+        sjsBlob,
+        () => resolve(),
+        (err: any) => reject(err)
+      )
+    })
+
+    // 3. 获取实例数据并构建 formData(参考 SpreadViewer 的 sheetData 构建逻辑)
+    //    OptimizedTemplatePreview 根据 bindingPath + formData 渲染,不需要在 workbook 上 setDataSource
+    let sheetData: Record<string, any> = {}
+    const res = await DynamicTbValApi.getDynamicTbInsAndValByRefId(initData)
+    if (res.dynamicTbValRespVOList?.length) {
+      res.dynamicTbValRespVOList.forEach((i: any) => {
+        const val = i.valValue
+        if (typeof val === 'string') {
+          const trimmed = val.trim()
+          // 后端已将图片路径值处理为背景图片,跳过避免文字覆盖
+          if (trimmed.endsWith('.jpg') || trimmed.endsWith('.png')) {
+            // 跳过
+          } else if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
+            if (i.colCode === 'Illustration') {
+              sheetData[i.colCode] = null
+            } else {
+              try { sheetData[i.colCode] = JSON.parse(val) } catch { sheetData[i.colCode] = val }
+            }
+          } else if (val == 'false' || val == 'true') {
+            sheetData[i.colCode] = val == 'true' ? 'true' : null
+          } else {
+            sheetData[i.colCode] = val
+          }
+        } else {
+          sheetData[i.colCode] = val
+        }
+      })
+    }
+    // 优先使用 initData.dataSource(外部传入),否则用实例数据
+    const formData = initData.dataSource || sheetData
+
+    // 4. toJSON 转为 SpreadWorkbookConfig(不调用 OptimizedTemplateExtractor,
+    //    避免 normalizeCellStyle 在字符串 style 引用上报 foreColor 错误,
+    //    以及与 convertStyle 重复做 fontSize 单位转换导致样式错乱)
+    //    表格行扩展、数据填充均由 OptimizedTemplatePreview 根据 bindingPath + formData 完成
+    const config = workbook.toJSON() as SpreadWorkbookConfig
+
+    // 4.1 从 live workbook 读取打印方向,注入到 config
+    //     原因:workbook.toJSON() 不一定输出 orientation 字段,
+    //     但 sheet.printInfo().orientation() 可以从 live 对象获取
+    if (config.sheets) {
+      const sheetNames = Object.keys(config.sheets)
+      sheetNames.forEach((sheetName) => {
+        const sheet = workbook.getSheetFromName(sheetName)
+        if (sheet) {
+          try {
+            const printInfo = sheet.printInfo()
+            const orientation = printInfo?.orientation?.()
+            if (orientation !== undefined && orientation !== null) {
+              const sheetConfig = config.sheets[sheetName] as any
+              if (!sheetConfig.printInfo) {
+                sheetConfig.printInfo = {}
+              }
+              // SpreadJS PrintOrientation: 0 = portrait, 1 = landscape
+              sheetConfig.printInfo.orientation = orientation
+            }
+          } catch (e) {
+            // 忽略读取失败的 sheet
+          }
+        }
+      })
+    }
+
+    workbook.destroy()
+
+    // 4.2 解析命名样式字符串引用,保留完整样式
+    resolveNamedStyleRefs(config)
+
+    // 5. 传给预览组件(formData 提供数据,config 提供模板结构 + bindingPath)
+    previewRef.value?.register({
+      config,
+      formData,
+      showToolbar: options.showToolbar ?? true,
+      readonly: options.readonly ?? false
+    })
+  } catch (error) {
+    console.error('加载 ssjson (refId) 失败:', error)
+    ElMessage.error('加载 ssjson 失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+// 以下方法透传给内部的 OptimizedTemplatePreview,便于父组件调用
+const getBoundFormData = () => previewRef.value?.getBoundFormData?.()
+const setFormData = (data: Record<string, any>) => previewRef.value?.setFormData?.(data)
+const clearFormData = () => previewRef.value?.clearFormData?.()
+const getUploadFields = () => previewRef.value?.getUploadFields?.()
+
+const handleReady = () => emit('ready')
+const handleSave = (data: Record<string, any>) => emit('save', data)
+
+onBeforeUnmount(() => {
+  if (tempHost) {
+    document.body.removeChild(tempHost)
+    tempHost = null
+  }
+})
+
+defineExpose({
+  loadByTemplateId,
+  loadByRefId,
+  loadFromConfig,
+  getBoundFormData,
+  setFormData,
+  clearFormData,
+  getUploadFields,
+  // 原始 register 仍保留,供高级用法使用
+  register: (options: Parameters<InstanceType<typeof OptimizedTemplatePreview>['register']>[0]) =>
+    previewRef.value?.register(options)
+})
+</script>
+
+<style lang="scss" scoped>
+.ssjson-report-viewer {
+  width: 100%;
+  height: 100%;
+  min-height: 400px;
+}
+</style>

+ 21 - 0
yudao-ui-admin-vue3/src/router/modules/remaining.ts

@@ -431,6 +431,27 @@ const remainingRouter: AppRouteRecordRaw[] = [
       }
     ]
   },
+  {
+    path: '/synboy-demo',
+    component: Layout,
+    name: 'SynboyDemo',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/synboy-demo/index.vue'),
+        name: 'SynboyDemoIndex',
+        meta: {
+          title: '葡萄城组件演示',
+          icon: 'ep:picture',
+          canTo: true,
+          noCache: true
+        }
+      }
+    ]
+  },
   {
     path: '/:pathMatch(.*)*',
     component: () => import('@/views/Error/404.vue'),

+ 59 - 3
yudao-ui-admin-vue3/src/views/pressure2/boilerchecker/components/StatusOperationPanel.vue

@@ -34,6 +34,14 @@
           >
             <span>报告</span>
           </div>
+          <div
+            class="tab-item"
+            :class="{ active: showReportPdfType === 'ssjson' }"
+            @click="handleChangeShowReportPdf('ssjson')"
+            v-if="isCanEditSsjson"
+          >
+            <span>ssjson</span>
+          </div>
         </div>
       </div>
       <div class="info-item" style="margin: 0 auto;">
@@ -54,7 +62,7 @@
           <Icon icon="ep:edit" />
         </el-button>
       </div>
-      <div class="info-item" style="flex-basis: 300px; text-align: end;font-size: 16px">
+      <div class="info-item" style="flex-basis: 300px; text-align: end;">
         <span class="label">{{ selectedItem?.reportType === 100 ? '主检人' : '检验员' }}:</span>
         <span
           class="value"
@@ -113,8 +121,12 @@
         />
         <!-- <div class="pdf-panel" :style="{ maxWidth: !onlyShowPdf ? '1200px' : 'unset' }"> -->
         <div ref="pdfPanelRef" class="pdf-panel" >
+          <!-- ssjson 模式:自定义渲染葡萄城组件 -->
+          <div v-if="showReportPdfType === 'ssjson'" class="!h-full" :style="{ width: pdfContentWidth + 'px'}">
+            <SsjsonReportViewer ref="ssjsonPreviewRef" @save="handleSsjsonSave" />
+          </div>
           <!-- PDF预览区域 -->
-          <div class="!h-full" :style="{ width: pdfContentWidth + 'px'}">
+          <div v-else class="!h-full" :style="{ width: pdfContentWidth + 'px'}">
             <SpreadViewer :initData="initData" ref="spreadRef" isFullscreen  @saveSuccess="saveSuccess"/>
           </div>
         </div>
@@ -897,6 +909,8 @@ import { cloneDeep, debounce } from 'lodash-es'
 import {PipeTaskOrderApi} from "@/api/pressure2/pipetaskorder";
 import {BoilerConnectRecordReportApi} from "@/api/pressure2/boilerconnectrecordreport";
 import * as UserGroupApi from '@/api/bpm/userGroup'
+// 自定义渲染葡萄城组件(封装后统一处理 ssjson 模板的加载与样式后处理)
+import SsjsonReportViewer from '@/components/SsjsonReportViewer/index.vue'
 
 interface Props {
   selectedItem: ReportItemVO | null
@@ -1041,6 +1055,8 @@ const getRecordResultButtonType = (record) => {
 }
 const reportSpreadRef=ref();
 const spreadRef=ref();
+// 自定义渲染葡萄城组件 ref(ssjson 模式)
+const ssjsonPreviewRef = ref<InstanceType<typeof SsjsonReportViewer>>()
 const initData=ref<InitParams>(
   {
     templateId: '',
@@ -1602,6 +1618,14 @@ const isCanEditReport = computed(
     !isSingleReportWithoutInstruction.value
 )
 
+// 判断 "ssjson" 调试按钮是否显示:仅 dev 环境 + 可编辑状态(填写记录或编制报告)
+const isCanEditSsjson = computed(() => {
+  // 仅 dev 环境显示
+  if (import.meta.env.VITE_NODE_ENV !== 'dev') return false
+  if (!props.selectedItem) return false
+  return isCanEditTestRecord.value || isCanEditReport.value
+})
+
 // 判断"撤销流程"按钮是否显示:报告审核或审批阶段,且有OA流程ID,且当前用户是发起人
 const isCanCancelFlow = computed(() => {
   if (!props.selectedItem) return false
@@ -2553,6 +2577,15 @@ const handleUploadItem = () => {
 //切换记录和报告的显示
 const handleChangeShowReportPdf = (showType: string) => {
 
+  // ssjson 标签页:切换到自定义渲染葡萄城组件
+  if (showType === 'ssjson') {
+    // 记录切换前的类型,用于决定加载哪个模板
+    const prevType = showReportPdfType.value === 'report' ? 'report' : 'record'
+    showReportPdfType.value = 'ssjson'
+    nextTick(() => loadSsjsonConfig(prevType))
+    return
+  }
+
   if (showType === 'report' || showType === 'result'){
     if (![PressureCheckerMyTaskStatus.REPORT_INPUT,PressureCheckerMyTaskStatus.REPORT_AUDIT,PressureCheckerMyTaskStatus.REPORT_APPROVE,PressureCheckerMyTaskStatus.REPORT_END].includes(props.selectedItem.taskStatus)){
       ElMessage.error('该检验项目暂未生成报告!')
@@ -2575,6 +2608,29 @@ const handleChangeShowReportPdf = (showType: string) => {
 
 }
 
+// 加载 ssjson 模板配置到自定义渲染葡萄城组件
+// sourceType: 切换前所在的标签页类型(record/report),决定使用哪个模板ID
+// 复用 initData(已由 initPreview 设置好 templateId 和 refId),调用 loadByRefId
+// 获取后端处理好的 SJS(含数据绑定、图片、续页)
+const loadSsjsonConfig = async (sourceType: 'record' | 'report' = 'record') => {
+  if (!props.selectedItem) return
+  // initData.value 已由 initPreview 根据 showReportPdfType 设置好对应的 templateId/refId
+  await ssjsonPreviewRef.value?.loadByRefId(
+    { ...initData.value },
+    {
+      formData: {},
+      showToolbar: true,
+      readonly: false
+    }
+  )
+}
+
+// ssjson 自定义渲染组件保存回调
+const handleSsjsonSave = (data: Record<string, any>) => {
+  console.log('ssjson 保存数据:', data)
+  ElMessage.success('保存成功(数据已打印到控制台)')
+}
+
 const isCanSyncReportData = computed(() => {
   return true
   //return [PressureCheckerMyTaskStatus.CONFIRMED,PressureCheckerMyTaskStatus.RECORD_INPUT].includes(props.selectedItem.taskStatus)
@@ -2930,7 +2986,7 @@ watch(() => props.isFullscreen, () => {
 
   .info-panel {
     display: flex;
-    height: 36px;
+    height: 32px;
     padding: 0 8px 0 22px;
     font-size: 14px;
     color: #41475c;

+ 59 - 3
yudao-ui-admin-vue3/src/views/pressure2/pipechecker/components/StatusOperationPanel.vue

@@ -34,6 +34,14 @@
           >
             <span>报告</span>
           </div>
+          <div
+            class="tab-item"
+            :class="{ active: showReportPdfType === 'ssjson' }"
+            @click="handleChangeShowReportPdf('ssjson')"
+            v-if="isCanEditSsjson"
+          >
+            <span>ssjson</span>
+          </div>
         </div>
       </div>
       <div class="info-item" style="margin: 0 auto;">
@@ -54,7 +62,7 @@
           <Icon icon="ep:edit" />
         </el-button>
       </div>
-      <div class="info-item" style="flex-basis: 300px; text-align: end;font-size: 16px">
+      <div class="info-item" style="flex-basis: 300px; text-align: end;">
         <span class="label">{{ selectedItem?.reportType === 100 ? '主检人' : '检验员' }}:</span>
         <span
           class="value"
@@ -113,8 +121,12 @@
         />
         <!-- <div class="pdf-panel" :style="{ maxWidth: !onlyShowPdf ? '1200px' : 'unset' }"> -->
         <div ref="pdfPanelRef" class="pdf-panel" >
+          <!-- ssjson 模式:自定义渲染葡萄城组件 -->
+          <div v-if="showReportPdfType === 'ssjson'" class="!h-full" :style="{ width: pdfContentWidth + 'px'}">
+            <SsjsonReportViewer ref="ssjsonPreviewRef" @save="handleSsjsonSave" />
+          </div>
           <!-- PDF预览区域 -->
-          <div class="!h-full" :style="{ width: pdfContentWidth + 'px'}">
+          <div v-else class="!h-full" :style="{ width: pdfContentWidth + 'px'}">
             <SpreadViewer :initData="initData" ref="spreadRef" isFullscreen  @saveSuccess="saveSuccess"/>
           </div>
         </div>
@@ -888,6 +900,8 @@ import {BoilerTaskOrderApi} from "@/api/pressure2/boilertaskorder";
 import {BoilerConnectRecordReportApi} from "@/api/pressure2/boilerconnectrecordreport";
 import { OrderReportApi } from "@/api/pressure2/orderreport";
 import * as UserGroupApi from '@/api/bpm/userGroup'
+// 自定义渲染葡萄城组件(封装后统一处理 ssjson 模板的加载与样式后处理)
+import SsjsonReportViewer from '@/components/SsjsonReportViewer/index.vue'
 
 const CustomDialog = defineAsyncComponent(() => import('@/components/CustomDialog/index.vue'))
 const AuditUserDialog = defineAsyncComponent(
@@ -1014,6 +1028,8 @@ const getRecordResultButtonType = (record) => {
 
 const spreadRef=ref();
 const reportSpreadRef=ref();
+// 自定义渲染葡萄城组件 ref(ssjson 模式)
+const ssjsonPreviewRef = ref<InstanceType<typeof SsjsonReportViewer>>()
 
 const initData=ref<InitParams>(
   {
@@ -1557,6 +1573,14 @@ const isCanEditReport = computed(
     !isSingleReportWithoutInstruction.value
 )
 
+// 判断 "ssjson" 调试按钮是否显示:仅 dev 环境 + 可编辑状态(填写记录或编制报告)
+const isCanEditSsjson = computed(() => {
+  // 仅 dev 环境显示
+  if (import.meta.env.VITE_NODE_ENV !== 'dev') return false
+  if (!props.selectedItem) return false
+  return isCanEditTestRecord.value || isCanEditReport.value
+})
+
 // 判断"撤销流程"按钮是否显示:报告审核或审批阶段,且有OA流程ID,且当前用户是发起人
 const isCanCancelFlow = computed(() => {
   if (!props.selectedItem) return false
@@ -2418,6 +2442,15 @@ const handleUploadItem = () => {
 //切换记录和报告的显示
 const handleChangeShowReportPdf = (showType: string) => {
 
+  // ssjson 标签页:切换到自定义渲染葡萄城组件
+  if (showType === 'ssjson') {
+    // 记录切换前的类型,用于决定加载哪个模板
+    const prevType = showReportPdfType.value === 'report' ? 'report' : 'record'
+    showReportPdfType.value = 'ssjson'
+    nextTick(() => loadSsjsonConfig(prevType))
+    return
+  }
+
   if (showType === 'report' || showType === 'result'){
     if (![PressureCheckerMyTaskStatus.REPORT_INPUT,PressureCheckerMyTaskStatus.REPORT_AUDIT,PressureCheckerMyTaskStatus.REPORT_APPROVE,PressureCheckerMyTaskStatus.REPORT_END].includes(props.selectedItem.taskStatus)){
       ElMessage.error('该检验项目暂未生成报告!')
@@ -2439,6 +2472,29 @@ const handleChangeShowReportPdf = (showType: string) => {
 
 }
 
+// 加载 ssjson 模板配置到自定义渲染葡萄城组件
+// sourceType: 切换前所在的标签页类型(record/report),决定使用哪个模板ID
+// 复用 initData(已由 initPreview 设置好 templateId 和 refId),调用 loadByRefId
+// 获取后端处理好的 SJS(含数据绑定、图片、续页)
+const loadSsjsonConfig = async (sourceType: 'record' | 'report' = 'record') => {
+  if (!props.selectedItem) return
+  // initData.value 已由 initPreview 根据 showReportPdfType 设置好对应的 templateId/refId
+  await ssjsonPreviewRef.value?.loadByRefId(
+    { ...initData.value },
+    {
+      formData: {},
+      showToolbar: true,
+      readonly: false
+    }
+  )
+}
+
+// ssjson 自定义渲染组件保存回调
+const handleSsjsonSave = (data: Record<string, any>) => {
+  console.log('ssjson 保存数据:', data)
+  ElMessage.success('保存成功(数据已打印到控制台)')
+}
+
 const isCanSyncReportData = computed(() => {
   return true
   //return [PressureCheckerMyTaskStatus.CONFIRMED,PressureCheckerMyTaskStatus.RECORD_INPUT].includes(props.selectedItem.taskStatus)
@@ -2803,7 +2859,7 @@ watch(() => props.isFullscreen, () => {
 
   .info-panel {
     display: flex;
-    height: 36px;
+    height: 32px;
     padding: 0 8px 0 22px;
     font-size: 14px;
     color: #41475c;

+ 153 - 0
yudao-ui-admin-vue3/src/views/synboy-demo/index.vue

@@ -0,0 +1,153 @@
+<template>
+  <ContentWrap>
+    <ElTabs v-model="activeTab" class="synboy-demo-tabs">
+      <!-- ImageEditor 图片编辑器 -->
+      <ElTabPane label="ImageEditor 图片编辑器" name="image">
+        <div class="demo-section">
+          <div class="editor-wrapper">
+            <ImageEditor
+              :export-width-ratio="4"
+              :export-height-ratio="3"
+              :export-scale="1"
+              background-color="#ffffff"
+              @export="handleImageExport"
+              @ready="handleEditorReady"
+            />
+          </div>
+          <div v-if="exportedImage" class="result-block">
+            <div class="result-title">导出结果:</div>
+            <img :src="exportedImage" class="preview-img" />
+          </div>
+        </div>
+      </ElTabPane>
+
+      <!-- OptimizedTemplatePreview 模板渲染 -->
+      <ElTabPane label="OptimizedTemplatePreview 模板渲染" name="spread">
+        <div class="demo-section">
+          <ElAlert
+            v-if="loadError"
+            type="error"
+            :title="loadError"
+            :closable="false"
+            show-icon
+          />
+          <div class="spread-wrapper">
+            <OptimizedTemplatePreview ref="spreadRef" @save="handleSpreadSave" />
+          </div>
+          <div v-if="savedData" class="result-block">
+            <div class="result-title">保存数据:</div>
+            <pre class="data-pre">{{ JSON.stringify(savedData, null, 2) }}</pre>
+          </div>
+        </div>
+      </ElTabPane>
+    </ElTabs>
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { ImageEditor } from '@synboy/image-editor'
+import { OptimizedTemplatePreview } from '@synboy/spreadjs-custom-render'
+import type { ExportEvent } from '@synboy/image-editor'
+import '@synboy/image-editor/dist/style.css'
+import '@synboy/spreadjs-custom-render/dist/style.css'
+
+defineOptions({ name: 'SynboyDemo' })
+
+const activeTab = ref('image')
+
+// ImageEditor:监听 export 事件拿到编辑后的图片 dataURL
+const exportedImage = ref('')
+const handleImageExport = (data: ExportEvent) => {
+  exportedImage.value = data.dataURL
+}
+const handleEditorReady = (canvas: any) => {
+  console.log('ImageEditor 画布就绪', canvas)
+}
+
+// OptimizedTemplatePreview:通过 ref.register 注入模板配置
+const spreadRef = ref<InstanceType<typeof OptimizedTemplatePreview>>()
+const loadError = ref('')
+const savedData = ref<Record<string, any> | null>(null)
+
+const loadTemplateConfig = async () => {
+  try {
+    const res = await fetch('/synboy-demo/template-config.json')
+    if (!res.ok) throw new Error(`HTTP ${res.status}`)
+    const config = await res.json()
+    spreadRef.value?.register({
+      config,
+      formData: {},
+      showToolbar: true,
+      readonly: false
+    })
+  } catch (e: any) {
+    loadError.value = `模板配置加载失败:${e?.message || e}`
+  }
+}
+
+const handleSpreadSave = (data: Record<string, any>) => {
+  savedData.value = data
+}
+
+onMounted(() => {
+  nextTick(() => loadTemplateConfig())
+})
+</script>
+
+<style lang="scss" scoped>
+.synboy-demo-tabs {
+  :deep(.el-tabs__content) {
+    padding: 0 16px 16px;
+  }
+}
+
+.demo-section {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.editor-wrapper {
+  height: 520px;
+  border: 1px solid var(--el-border-color);
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.spread-wrapper {
+  min-height: 520px;
+  border: 1px solid var(--el-border-color);
+  border-radius: 4px;
+}
+
+.result-block {
+  padding: 12px;
+  background: var(--el-fill-color-light);
+  border-radius: 4px;
+}
+
+.result-title {
+  font-size: 14px;
+  font-weight: 600;
+  margin-bottom: 8px;
+  color: var(--el-text-color-primary);
+}
+
+.preview-img {
+  max-width: 100%;
+  max-height: 320px;
+  border: 1px solid var(--el-border-color);
+  border-radius: 4px;
+}
+
+.data-pre {
+  margin: 0;
+  max-height: 320px;
+  overflow: auto;
+  padding: 8px;
+  background: var(--el-fill-color);
+  border-radius: 4px;
+  font-size: 12px;
+  line-height: 1.6;
+}
+</style>