Преглед изворни кода

验证H5录入组件移植,添加H5录入示例(安全检查记录)

yangguanjin пре 23 часа
родитељ
комит
5088e164f2

+ 226 - 0
src/components/SpreadCustomRender/Readme.md

@@ -0,0 +1,226 @@
+# SpreadCustomRender
+
+基于 SpreadJS 数据结构的 Vue3 模板渲染组件(uniapp 版本),从独立类库 `@synboy/spreadjs-custom-render` 改造而来。
+
+使用 **wot-design-uni** 替代 Element Plus,使用 **uni-app API** 替代浏览器 DOM 操作,支持表单控件、表格动态渲染、条件格式、公式评估等功能。
+
+## 项目结构
+
+```
+SpreadCustomRender/
+├── index.ts                        # 统一导出入口
+├── SpreadCustomRender.vue          # 主组件(渲染 SpreadJS 模板 + 表单交互)
+├── types.ts                        # 全部 TypeScript 类型定义
+├── components/                     # 表单子组件
+│   ├── FormCellRenderer.vue        #   单元格类型分发器(根据 cellType 映射到对应组件)
+│   ├── FormSelect.vue              #   下拉选择(wd-picker)
+│   ├── FormRadioGroup.vue          #   单选组(wd-radio-group)
+│   ├── FormCheckboxGroup.vue       #   复选组(wd-checkbox-group)
+│   ├── FormDatePicker.vue          #   日期选择(wd-datetime-picker)
+│   ├── FormDateTimePicker.vue     #   日期时间选择(wd-datetime-picker)
+│   ├── FormFileUpload.vue         #   文件/图片上传(uni.chooseImage)
+│   └── FormImageEditor.vue         #   图片编辑/上传(简化版,移除 fabric.js)
+├── composables/
+│   └── useComputed.ts              #   可传参的计算属性 Hook(缓存 + 精准响应式)
+└── utils/
+    ├── extractor.ts                #   模板配置提取器(清理字段、标准化样式、富文本处理)
+    ├── formatUtils.ts              #   格式转换(SpreadJS 日期格式 -> dayjs、主题色解析)
+    └── imageUtils.ts               #   跨平台图片工具(选择、base64 转换、预览)
+```
+
+## 核心概念
+
+### 数据流
+
+```
+SpreadJS 模板文件 (xlsx/ssjson)
+    │
+    ▼  GC.Spread.Sheets.IO.open()
+原始 JSON 配置 (workbook.toJSON 格式)
+    │
+    ▼  extractConfig()  ← 清理无用字段、标准化样式、处理富文本
+SpreadWorkbookConfig
+    │
+    ▼  SpreadCustomRender.register({ config, formData })
+组件内部渲染(表格 + 表单控件)
+    │
+    ▼  SpreadCustomRender.getBoundFormData()
+用户填写后的表单数据
+```
+
+### SpreadJS CellType 映射
+
+| typeName | 类型 | 渲染组件 |
+|----------|------|---------|
+| `0` / `1` | Text | 原生 `<textarea>` |
+| `6` | Button | `wd-button` |
+| `7` | ComboBox | `FormSelect` (wd-picker) |
+| `8` | HyperLink | `<text>` 链接 |
+| `11` | RadioButtonList | `FormRadioGroup` (wd-radio-group) |
+| `12` | CheckBoxList | `FormCheckboxGroup` (wd-checkbox-group) |
+| `13` | ButtonList | `FormRadioGroup`(单选)/ `FormCheckboxGroup`(多选) |
+| `16` | DatePicker | `FormDatePicker` (wd-datetime-picker) |
+| `17` | TimePicker | `FormDateTimePicker` (wd-datetime-picker) |
+| `18` | ColorPicker | 颜色值展示 |
+| `19` | FileUpload | `FormImageEditor` (图片上传) |
+
+## 使用方式
+
+### 基础用法
+
+```vue
+<template>
+  <SpreadCustomRender
+    ref="renderRef"
+    @ready="onReady"
+    @save="onSave"
+  />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { SpreadCustomRender } from '@/components/SpreadCustomRender'
+import type { SpreadWorkbookConfig } from '@/components/SpreadCustomRender'
+
+const renderRef = ref<InstanceType<typeof SpreadCustomRender>>()
+
+// 组件准备就绪后注册配置
+function onReady() {
+  renderRef.value?.register({
+    config: workbookConfig,  // SpreadWorkbookConfig(来自 extractConfig 或 workbook.toJSON())
+    formData: {              // 表单数据(key 对应模板中的 bindingPath)
+      name: '张三',
+      date: '2024-01-15',
+      items: [
+        { name: '项目1', value: '合格' },
+      ],
+    },
+    showToolbar: true,       // 是否显示工具栏(含保存按钮)
+    readonly: false,         // 是否只读
+    customButtons: [         // 自定义工具栏按钮
+      {
+        text: '导出',
+        type: 'primary',
+        handler: (data) => console.log('导出数据:', data),
+      },
+    ],
+  })
+}
+
+function onSave(data: Record<string, any>) {
+  console.log('保存的数据:', data)
+}
+</script>
+```
+
+### 配合 spreadDesignerCustomGeneric 使用
+
+`spreadDesignerCustomGeneric` 封装了模板加载(IO.open -> extractConfig)+ NavBar + SpreadCustomRender 的完整流程:
+
+```vue
+<template>
+  <SpreadDesignerCustomGeneric
+    :businessConfig="businessConfig"
+    :templateData="templateData"
+    :templateBlob="templateBlob"
+    @save="handleSave"
+    @cancel="handleCancel"
+  />
+</template>
+
+<script setup lang="ts">
+import SpreadDesignerCustomGeneric
+  from '@/components/SpreadDesigner/spreadDesignerCustomGeneric.vue'
+
+const businessConfig = {
+  businessType: 'AQJC',
+  ui: {
+    title: '安全检查记录',
+    saveButtonText: '保存',
+    cancelButtonText: '取消',
+  },
+}
+
+const templateData = {
+  data: {                         // 表单数据(key = bindingPath)
+    checkType: '内部检验',
+    equipName: '锅炉',
+    // ...
+  },
+}
+
+const templateBlob = ref('')       // base64 编码的 xlsx/ssjson
+
+// templateBlob 从后端获取后,组件自动加载并渲染
+
+function handleSave(data: any) {
+  // data.dataJSON 包含用户填写的数据
+  console.log('保存:', data.dataJSON)
+}
+
+function handleCancel() {
+  uni.navigateBack()
+}
+</script>
+```
+
+### 单独使用 extractConfig
+
+如果已有 Workbook 实例或 toJSON() 输出,可单独调用 `extractConfig` 做后处理:
+
+```ts
+import { extractConfig } from '@/components/SpreadCustomRender'
+
+// 方式 1: 从 IO.open 获取的 JSON
+GC.Spread.Sheets.IO.open(blob, (json) => {
+  const config = extractConfig(json)
+  // config 已清理无用字段、标准化样式
+})
+
+// 方式 2: 从 workbook.toJSON() 获取
+const rawConfig = workbook.toJSON()
+const config = extractConfig(rawConfig, {
+  includeEmptyCells: false,   // 清理空单元格
+  compressStyles: true,       // 压缩默认值
+  includeFormulas: true,      // 保留公式
+})
+```
+
+## 暴露的方法
+
+通过 `ref` 调用主组件的方法:
+
+| 方法 | 说明 |
+|------|------|
+| `register(options)` | 注册配置和表单数据(统一入口) |
+| `initConfig(config)` | 仅设置模板配置 |
+| `initFormData(data)` | 仅设置表单数据 |
+| `setFormData(data)` | 合并更新表单数据 |
+| `clearFormData()` | 清空表单数据 |
+| `setReadonly(readonly)` | 设置只读模式 |
+| `getFormData()` | 获取全部表单数据 |
+| `getBoundFormData()` | 获取模板绑定的表单数据(仅含 bindingPath 的字段) |
+| `getUploadFields()` | 获取所有图片上传字段的值 |
+| `getConfig()` | 获取当前配置 |
+
+## 事件
+
+| 事件 | 参数 | 说明 |
+|------|------|------|
+| `ready` | - | 组件挂载完成,可调用 `register()` |
+| `save` | `Record<string, any>` | 用户点击保存按钮时触发 |
+
+## 与原项目 (spreadjs-custom-render) 的差异
+
+| 维度 | 原项目(独立类库) | 本组件(uniapp) |
+|------|-------------------|-----------------|
+| UI 库 | Element Plus | wot-design-uni |
+| 图片选择 | `<input type="file">` + FileReader | `uni.chooseImage` + `pathToBase64` |
+| 图片预览 | ElImageViewer | `uni.previewImage` |
+| 图片编辑 | @synboy/image-editor (fabric.js) | 简化为选择 + 预览 |
+| 图标 | SVG 渲染函数 (`h()`) | `wd-icon` 组件 |
+| 退出编辑 | `document.addEventListener('mousedown')` | `@click.self` 在容器上处理 |
+| 自动聚焦 | `nextTick` + `document.querySelector` | 移除(移动端不适用) |
+| 按压反馈 | `@mousedown/@mouseup/@mouseleave` | CSS `:active` 伪类 |
+| 提取器 | `OptimizedTemplateExtractor` 类(需 Workbook 实例) | `extractConfig()` 函数(直接处理 JSON) |
+| 打包方式 | Vite 库模式(`app.use()` 全局注册) | uniapp 组件(easycom 或直接 import) |

+ 2 - 0
src/components/SpreadCustomRender/index.ts

@@ -49,6 +49,8 @@ export { useComputed } from './composables/useComputed'
 // 工具
 export { spreadjsFormatToDayjs, resolveThemeColor } from './utils/formatUtils'
 export { chooseImageAsBase64, pathToBase64, previewImage } from './utils/imageUtils'
+export { extractConfig } from './utils/extractor'
+export type { ExtractorOptions } from './utils/extractor'
 
 // 类型
 export type * from './types'

+ 332 - 0
src/components/SpreadCustomRender/utils/extractor.ts

@@ -0,0 +1,332 @@
+/**
+ * SpreadJS 模板配置提取器
+ *
+ * 从 spreadjs-custom-render/src/converter/extractor.ts 移植。
+ * 原 OptimizedTemplateExtractor 是类形式(需要 Workbook 实例),
+ * 这里改为函数形式,直接处理 toJSON() 输出的 JSON,更灵活。
+ *
+ * 主要功能:
+ * 1. 清理不需要的顶层字段(dataManager, users, backgroundImage 等)
+ * 2. 标准化单元格样式(pt->px、默认边框颜色、fontWeight 归一化)
+ * 3. 处理富文本单元格(提取纯文本,合并样式)
+ * 4. 压缩样式(移除默认值,可选)
+ */
+
+import { resolveThemeColor } from './formatUtils'
+import type {
+  SpreadWorkbookConfig,
+  SpreadSheetConfig,
+  SpreadTable,
+  SpreadCellStyle,
+} from '../types'
+
+/** 需要删除的顶层配置字段(减少数据量) */
+const FIELDS_TO_REMOVE = [
+  'dataManager',
+  'users',
+  'pivotCaches',
+  'backgroundImage',
+  'backgroundImageLayout',
+  'builtInFileIcons',
+  'theme',
+]
+
+/** 需要删除的样式字段 */
+const STYLE_FIELDS_TO_REMOVE = [
+  'backgroundImage',
+]
+
+export interface ExtractorOptions {
+  includeEmptyCells?: boolean
+  compressStyles?: boolean
+  includeFormulas?: boolean
+}
+
+/**
+ * 提取并处理 Workbook 配置
+ *
+ * @param config workbook.toJSON() 的原始输出
+ * @param options 提取选项
+ * @returns 处理后的配置
+ */
+export function extractConfig(
+  config: SpreadWorkbookConfig,
+  options: ExtractorOptions = {},
+): SpreadWorkbookConfig {
+  const opts = {
+    includeEmptyCells: options.includeEmptyCells ?? true,
+    compressStyles: options.compressStyles ?? true,
+    includeFormulas: options.includeFormulas ?? true,
+  }
+
+  // 删除不需要的字段
+  cleanupFields(config)
+
+  // 后处理样式
+  postProcessStyles(config)
+
+  // 清理空单元格
+  if (!opts.includeEmptyCells) {
+    cleanEmptyCells(config)
+  }
+
+  // 压缩样式
+  if (opts.compressStyles) {
+    compressStyles(config)
+  }
+
+  // 移除公式
+  if (!opts.includeFormulas) {
+    removeFormulas(config)
+  }
+
+  return config
+}
+
+/**
+ * 清理不需要的顶层字段
+ */
+function cleanupFields(config: SpreadWorkbookConfig): void {
+  FIELDS_TO_REMOVE.forEach(field => {
+    if ((config as any)[field]) {
+      delete (config as any)[field]
+    }
+  })
+}
+
+/**
+ * 后处理样式:修复 SpreadJS toJSON() 的输出问题
+ */
+function postProcessStyles(config: SpreadWorkbookConfig): void {
+  Object.values(config.sheets).forEach((sheet: SpreadSheetConfig) => {
+    const dataTable = sheet.data?.dataTable
+    if (!dataTable) return
+
+    // 后处理单元格样式
+    Object.values(dataTable).forEach(rowData => {
+      Object.values(rowData).forEach(cell => {
+        // 处理富文本
+        if (cell.value && typeof cell.value === 'object' && cell.value.richText) {
+          processRichText(cell)
+        }
+        // 标准化样式
+        if (cell.style) {
+          normalizeCellStyle(cell.style)
+        }
+      })
+    })
+
+    // 后处理表格样式
+    sheet.tables?.forEach((table: SpreadTable) => {
+      if (table.headerStyle) normalizeCellStyle(table.headerStyle)
+      if (table.dataStyle) normalizeCellStyle(table.dataStyle)
+      if (table.alternateRowStyle) normalizeCellStyle(table.alternateRowStyle)
+      table.columns?.forEach(col => {
+        if (col.headerStyle) normalizeCellStyle(col.headerStyle)
+        if (col.dataStyle) normalizeCellStyle(col.dataStyle)
+      })
+    })
+  })
+}
+
+/**
+ * 标准化单元格样式
+ */
+function normalizeCellStyle(style: SpreadCellStyle): void {
+  // 1. 删除不需要的样式字段
+  STYLE_FIELDS_TO_REMOVE.forEach(field => {
+    if ((style as any)[field]) {
+      delete (style as any)[field]
+    }
+  })
+
+  // 2. 将 pt 单位的 fontSize 转换为 px
+  if (style.fontSize && typeof style.fontSize === 'string') {
+    if (style.fontSize.endsWith('pt')) {
+      const size = parseFloat(style.fontSize)
+      if (!isNaN(size)) {
+        style.fontSize = Math.round(size * 4 / 3) + 'px'
+      }
+    }
+  }
+
+  // 3. 为缺少颜色的边框添加默认颜色
+  const defaultBorderColor = '#000000'
+  const borderSides = ['borderLeft', 'borderRight', 'borderTop', 'borderBottom'] as const
+
+  borderSides.forEach(side => {
+    const border = style[side] as any
+    if (border && typeof border === 'object' && !border.color) {
+      border.color = defaultBorderColor
+    }
+  })
+
+  // 4. 标准化 fontWeight(数字转字符串)
+  if (style.fontWeight && typeof style.fontWeight === 'number') {
+    style.fontWeight = style.fontWeight >= 700 ? 'bold' : 'normal'
+  }
+
+  // 5. 添加默认字体颜色(如果没有设置)
+  if (!style.foreColor) {
+    style.foreColor = '#000000'
+  }
+}
+
+/**
+ * 处理富文本单元格
+ * 提取纯文本并合并样式到 cell.style
+ */
+function processRichText(cell: any): void {
+  if (!cell.value || typeof cell.value !== 'object' || !cell.value.richText) {
+    return
+  }
+
+  const richTextValue = cell.value
+  const richTextArray = richTextValue.richText
+
+  // 提取纯文本
+  if (richTextValue.text !== undefined) {
+    cell.value = richTextValue.text
+  } else {
+    cell.value = richTextArray.map((segment: any) => segment.text || '').join('')
+  }
+
+  if (!richTextArray || richTextArray.length === 0) return
+
+  // 初始化单元格样式
+  if (!cell.style) {
+    cell.style = {}
+  }
+
+  // 从第一个有样式的文本段提取基础样式
+  const firstStyledSegment = richTextArray.find((seg: any) => seg.style)
+  if (firstStyledSegment && firstStyledSegment.style) {
+    mergeRichTextStyle(cell.style, firstStyledSegment.style)
+  }
+}
+
+/**
+ * 合并富文本样式到单元格样式
+ */
+function mergeRichTextStyle(cellStyle: SpreadCellStyle, richTextStyle: any): void {
+  // 解析字体(格式如 "14px 宋体")
+  if (richTextStyle.font) {
+    const fontMatch = richTextStyle.font.match(/(\d+(?:\.\d+)?)\s*(px|pt)?\s*(.+)/)
+    if (fontMatch) {
+      const size = parseFloat(fontMatch[1])
+      const unit = fontMatch[2] || 'px'
+      const family = fontMatch[3].trim()
+
+      if (!cellStyle.fontSize) {
+        cellStyle.fontSize = unit === 'pt' ? Math.round(size * 4 / 3) + 'px' : size + 'px'
+      }
+      if (!cellStyle.fontFamily) {
+        cellStyle.fontFamily = family
+      }
+    }
+  }
+
+  if (richTextStyle.foreColor && !cellStyle.foreColor) {
+    cellStyle.foreColor = resolveThemeColor(richTextStyle.foreColor)
+  }
+  if (richTextStyle.backColor && !cellStyle.backColor) {
+    cellStyle.backColor = resolveThemeColor(richTextStyle.backColor)
+  }
+  if (richTextStyle.fontWeight && !cellStyle.fontWeight) {
+    cellStyle.fontWeight = richTextStyle.fontWeight
+  }
+  if (richTextStyle.fontStyle && !cellStyle.fontStyle) {
+    cellStyle.fontStyle = richTextStyle.fontStyle
+  }
+  if (richTextStyle.textDecoration && !cellStyle.textDecoration) {
+    cellStyle.textDecoration = richTextStyle.textDecoration
+  }
+}
+
+/**
+ * 清理空单元格
+ */
+function cleanEmptyCells(config: SpreadWorkbookConfig): void {
+  Object.values(config.sheets).forEach((sheet: SpreadSheetConfig) => {
+    const dataTable = sheet.data?.dataTable
+    if (!dataTable) return
+
+    Object.keys(dataTable).forEach(rowStr => {
+      const row = parseInt(rowStr)
+      const rowData = dataTable[row]
+
+      Object.keys(rowData).forEach(colStr => {
+        const col = parseInt(colStr)
+        const cell = rowData[col]
+
+        if (!cell.value && !cell.formula && !cell.style && !cell.bindingPath && !cell.comment) {
+          delete rowData[col]
+        }
+      })
+
+      if (Object.keys(rowData).length === 0) {
+        delete dataTable[row]
+      }
+    })
+  })
+}
+
+/**
+ * 压缩样式(移除默认值)
+ */
+function compressStyles(config: SpreadWorkbookConfig): void {
+  Object.values(config.sheets).forEach((sheet: SpreadSheetConfig) => {
+    const dataTable = sheet.data?.dataTable
+    if (!dataTable) return
+
+    Object.values(dataTable).forEach(rowData => {
+      Object.values(rowData).forEach(cell => {
+        if (cell.style) compressCellStyle(cell.style)
+      })
+    })
+
+    sheet.tables?.forEach((table: SpreadTable) => {
+      if (table.headerStyle) compressCellStyle(table.headerStyle)
+      if (table.dataStyle) compressCellStyle(table.dataStyle)
+      if (table.alternateRowStyle) compressCellStyle(table.alternateRowStyle)
+      table.columns?.forEach(col => {
+        if (col.headerStyle) compressCellStyle(col.headerStyle)
+        if (col.dataStyle) compressCellStyle(col.dataStyle)
+      })
+    })
+  })
+}
+
+/**
+ * 压缩单个单元格样式(移除默认值)
+ */
+function compressCellStyle(style: SpreadCellStyle): void {
+  const defaults: Record<string, any> = {
+    wordWrap: false,
+    locked: false,
+    allowEditInCell: true,
+    linkStyle: false,
+  }
+
+  Object.keys(defaults).forEach(key => {
+    if (style[key as keyof SpreadCellStyle] === defaults[key]) {
+      delete style[key as keyof SpreadCellStyle]
+    }
+  })
+}
+
+/**
+ * 移除公式
+ */
+function removeFormulas(config: SpreadWorkbookConfig): void {
+  Object.values(config.sheets).forEach((sheet: SpreadSheetConfig) => {
+    const dataTable = sheet.data?.dataTable
+    if (!dataTable) return
+
+    Object.values(dataTable).forEach(rowData => {
+      Object.values(rowData).forEach(cell => {
+        delete cell.formula
+      })
+    })
+  })
+}

+ 327 - 0
src/components/SpreadDesigner/spreadDesignerCustomGeneric.vue

@@ -0,0 +1,327 @@
+<template>
+  <view class="spread-designer-custom-generic">
+    <slot name="navBar">
+      <NavBar>
+        <template #title>
+          <text class="nav-title">{{ navTitle }}</text>
+        </template>
+        <template #right>
+          <button
+            v-for="(button, index) in navButtons"
+            :key="index"
+            :class="['nav-btn', button.className]"
+            @click="handleNavButtonClick(button)"
+          >
+            {{ button.text }}
+          </button>
+        </template>
+      </NavBar>
+    </slot>
+
+    <view class="main-container">
+      <SpreadCustomRender
+        ref="renderRef"
+        @ready="onRenderReady"
+        @save="onRenderSave"
+      />
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, onMounted } from 'vue'
+import GC from './tools/gc'
+import '@grapecity-software/spread-sheets/styles/gc.spread.sheets.excel2013white.css'
+import '@grapecity-software/spread-sheets-print'
+import '@grapecity-software/spread-sheets-shapes'
+import '@grapecity-software/spread-sheets-charts'
+import '@grapecity-software/spread-sheets-slicers'
+import '@grapecity-software/spread-sheets-pivot-addon'
+import '@grapecity-software/spread-sheets-tablesheet'
+import '@grapecity-software/spread-sheets-ganttsheet'
+import '@grapecity-software/spread-sheets-reportsheet-addon'
+import '@grapecity-software/spread-sheets-formula-panel'
+import '@grapecity-software/spread-sheets-io'
+import NavBar from '@/components/NavBar/NavBar.vue'
+import { SpreadCustomRender, extractConfig } from '@/components/SpreadCustomRender'
+import { base64ToBlob } from './tools/spreadUtils'
+import type { SpreadWorkbookConfig } from '@/components/SpreadCustomRender'
+
+// ==================== Props ====================
+
+const props = defineProps({
+  businessConfig: {
+    type: Object as () => Record<string, any> | null,
+    default: null,
+  },
+  templateData: {
+    type: Object as () => Record<string, any> | null,
+    default: null,
+  },
+  templateBlob: {
+    type: String,
+    default: '',
+  },
+})
+
+const emit = defineEmits(['save', 'cancel', 'customAction', 'close'])
+
+// ==================== SpreadJS License ====================
+
+GC.Spread.Sheets.LicenseKey = import.meta.env.VITE_SPREADJS_LICENSE_KEY
+
+// ==================== 状态 ====================
+
+const renderRef = ref<InstanceType<typeof SpreadCustomRender>>()
+const navTitle = ref('通用渲染器')
+const navButtons = ref<Array<{ text: string; className: string; action: string }>>([])
+const workbookConfig = ref<SpreadWorkbookConfig | null>(null)
+const formData = ref<Record<string, any>>({})
+const renderReady = ref(false)
+
+// 隐藏 Workbook(仅 fallback 时才创建)
+let workbook: any = null
+
+// ==================== 初始化 ====================
+
+onMounted(() => {
+  if (props.businessConfig?.ui) {
+    initUI(props.businessConfig.ui)
+  }
+  if (props.templateBlob) {
+    loadTemplate(props.templateBlob)
+  }
+})
+
+// ==================== UI 初始化 ====================
+
+function initUI(uiConfig: Record<string, any>) {
+  navTitle.value = uiConfig.title || '通用渲染器'
+  navButtons.value = []
+
+  navButtons.value.push({
+    text: uiConfig.cancelButtonText || '取消',
+    className: '',
+    action: 'cancel',
+  })
+
+  if (!uiConfig.hideSaveButton) {
+    navButtons.value.push({
+      text: uiConfig.saveButtonText || '保存',
+      className: 'primary',
+      action: 'save',
+    })
+  }
+
+  if (uiConfig.customButtons && Array.isArray(uiConfig.customButtons)) {
+    uiConfig.customButtons.forEach((button: any) => {
+      navButtons.value.push({
+        text: button.text,
+        className: button.className || '',
+        action: button.action,
+      })
+    })
+  }
+}
+
+// ==================== 模板加载 ====================
+
+/**
+ * 加载模板:
+ * 主方案 — GC.Spread.Sheets.IO.open() 直接返回 JSON,传给 extractConfig 处理
+ * 回退   — workbook.open() 加载后 toJSON() 再 extractConfig
+ */
+function loadTemplate(blobBase64: string) {
+  const blob = base64ToBlob(blobBase64, '')
+  console.log('[spreadDesignerCustomGeneric] 开始加载模板, blob size:', blob.size)
+
+  if (GC.Spread.Sheets.IO && typeof GC.Spread.Sheets.IO.open === 'function') {
+    // 主方案:IO.open 返回 JSON,无需 Workbook 实例
+    GC.Spread.Sheets.IO.open(
+      blob,
+      (json: any) => {
+        console.log('[spreadDesignerCustomGeneric] IO.open 成功')
+        onConfigReady(json as SpreadWorkbookConfig)
+      },
+      (error: any) => {
+        console.error('[spreadDesignerCustomGeneric] IO.open 失败, 回退到 workbook.open:', error)
+        loadViaWorkbook(blobBase64)
+      },
+    )
+  } else {
+    loadViaWorkbook(blobBase64)
+  }
+}
+
+/**
+ * 回退方案:创建 Workbook 实例,open 后 toJSON
+ */
+function loadViaWorkbook(blobBase64: string) {
+  if (!workbook) {
+    const host = document.createElement('div')
+    host.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;'
+    document.body.appendChild(host)
+    try {
+      workbook = new GC.Spread.Sheets.Workbook(host)
+    } catch (e) {
+      console.error('[spreadDesignerCustomGeneric] Workbook 创建失败:', e)
+      return
+    }
+  }
+
+  const blob = base64ToBlob(blobBase64, '')
+  workbook.open(
+    blob,
+    () => {
+      console.log('[spreadDesignerCustomGeneric] workbook.open 成功')
+      onConfigReady(workbook.toJSON() as SpreadWorkbookConfig)
+    },
+    (error: any) => {
+      console.error('[spreadDesignerCustomGeneric] workbook.open 也失败:', error)
+    },
+  )
+}
+
+/**
+ * 配置就绪:extractConfig 后处理 -> 设置 formData -> 注册渲染器
+ */
+function onConfigReady(rawConfig: SpreadWorkbookConfig) {
+  // extractConfig: 清理字段、标准化样式、处理富文本、压缩默认值
+  const config = extractConfig(rawConfig)
+  workbookConfig.value = config
+
+  if (props.templateData?.data) {
+    formData.value = { ...props.templateData.data }
+  }
+
+  console.log('[spreadDesignerCustomGeneric] 配置处理完成, sheets:', Object.keys(config.sheets || {}))
+  registerRender()
+}
+
+function registerRender() {
+  if (!workbookConfig.value || !renderReady.value || !renderRef.value) return
+  renderRef.value.register({
+    config: workbookConfig.value!,
+    formData: formData.value,
+    showToolbar: false,
+    readonly: false,
+  })
+  console.log('[spreadDesignerCustomGeneric] 已注册到 SpreadCustomRender')
+}
+
+// ==================== 事件处理 ====================
+
+function onRenderReady() {
+  renderReady.value = true
+  registerRender()
+}
+
+function onRenderSave(data: Record<string, any>) {
+  saveRecord(data)
+}
+
+function handleNavButtonClick(button: { action: string }) {
+  if (button.action === 'cancel') {
+    emit('cancel')
+  } else if (button.action === 'save') {
+    saveRecord()
+  } else {
+    handleCustomAction(button.action)
+  }
+}
+
+function saveRecord(renderData?: Record<string, any>) {
+  if (!renderRef.value) return
+  const data = renderData || renderRef.value.getBoundFormData()
+  emit('save', {
+    dataJSON: data,
+    businessType: props.businessConfig?.businessType,
+    templateId: props.templateData?.data?.templateId || props.templateData?.template,
+    reportUrl: props.templateData?.data?.templateUrl || props.templateData?.templateUrl || '',
+  })
+}
+
+function handleCustomAction(action: string) {
+  if (!renderRef.value) return
+  emit('customAction', { action, data: renderRef.value.getFormData() })
+}
+
+// ==================== Watch ====================
+
+watch(
+  () => props.templateBlob,
+  (newBlob) => {
+    if (newBlob) loadTemplate(newBlob)
+  },
+)
+
+watch(
+  () => props.businessConfig,
+  (config) => {
+    if (config?.ui) initUI(config.ui)
+  },
+  { immediate: true },
+)
+
+// ==================== 暴露方法 ====================
+
+defineExpose({
+  getRenderData: () => renderRef.value?.getBoundFormData() || {},
+  getFormData: () => renderRef.value?.getFormData() || {},
+  setReadonly: (readonly: boolean) => renderRef.value?.setReadonly(readonly),
+})
+</script>
+
+<style scoped>
+* {
+  box-sizing: border-box;
+  padding: 0;
+  margin: 0;
+}
+
+.spread-designer-custom-generic {
+  position: relative;
+  height: 100vh;
+  overflow: hidden;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+}
+
+.main-container {
+  width: 100%;
+  height: calc(100vh - clamp(44px, 8vw, 56px) - env(safe-area-inset-top, 0px));
+  overflow: auto;
+}
+
+.nav-title {
+  flex: 1;
+  font-size: 17px;
+  font-weight: 600;
+  color: #333;
+  text-align: center;
+}
+
+.nav-btn {
+  margin: 0 4px;
+  padding: 0 12px;
+  font-size: 12px;
+  color: #495057;
+  white-space: nowrap;
+  background-color: #fff;
+  border: 1px solid #dee2e6;
+  border-radius: 4px;
+}
+
+.nav-btn:active {
+  background-color: #f8f9fa;
+}
+
+.nav-btn.primary {
+  color: white;
+  background-color: #007bff;
+  border-color: #007bff;
+}
+
+.nav-btn.primary:active {
+  background-color: #0056b3;
+}
+</style>

+ 10 - 9
src/pages.json

@@ -121,6 +121,16 @@
         "disableScroll": true
       }
     },
+    {
+      "path": "pages/editor/securityCheckEditor_customVer",
+      "type": "page",
+      "layout": "default",
+      "style": {
+        "navigationBarTitleText": "",
+        "navigationStyle": "custom",
+        "disableScroll": true
+      }
+    },
     {
       "path": "pages/editor/serviceOrderEditor",
       "type": "page",
@@ -485,15 +495,6 @@
         "disableScroll": true
       }
     },
-    {
-      "path": "pages/securityCheck/detail/index copy",
-      "type": "page",
-      "layout": "default",
-      "style": {
-        "navigationBarTitleText": "安全检查记录详情",
-        "navigationStyle": "custom"
-      }
-    },
     {
       "path": "pages/securityCheck/detail/index",
       "type": "page",

+ 200 - 0
src/pages/editor/securityCheckEditor_customVer.vue

@@ -0,0 +1,200 @@
+<route lang="json5" type="page">
+{
+  layout: 'default',
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+    disableScroll: true,
+  },
+}
+</route>
+
+<!-- 安全检查记录编辑 -->
+<template>
+  <div>
+    <SpreadDesignerCustomGeneric
+      :businessConfig="businessConfig"
+      :templateData="templateData"
+      :templateBlob="templateBlob"
+      @save="handleSave"
+      @cancel="handleCancel"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import SpreadDesignerCustomGeneric from '@/components/SpreadDesigner/spreadDesignerCustomGeneric.vue'
+import { onLoad } from '@dcloudio/uni-app'
+import { getEnvBaseUrl } from '@/utils/index'
+import { useConfigStore } from '@/store/config'
+import {
+  SecurityCheckFuncName,
+  requestFunc as securityCheckRequestFunc,
+} from '@/api/ApiRouter/taskOrderSecurityCheck'
+import { getStandardTemplate } from '@/api/index'
+import { getDynamicTbVal, saveDynamicTbVal } from '@/api/task'
+
+const configStore = useConfigStore()
+const equipType = configStore.getEquipType()
+
+const businessConfig = ref({
+  businessType: 'AQJC',
+  title: '安全检查记录编辑',
+  disableNavigate: true, // 是否禁用跳转,默认不禁用
+
+  ui: {
+    title: '安全检查记录',
+    saveButtonText: '保存',
+    cancelButtonText: '取消',
+    showAdditionalToolbar: true,
+    customButtons: [],
+  },
+})
+const templateBlob = ref<string>('')
+const templateData = ref<any>({})
+const skipEntryKeys = ref<string[]>([])
+
+let templateId: string = ''
+let orderId: string = ''
+let mode: 'edit' | 'create' = 'edit'
+let securityCheckId: string = ''
+
+onLoad((options: any) => {
+  templateId = options.templateId
+  orderId = options.orderId
+  mode = options.mode || 'edit'
+  securityCheckId = options.securityCheckId || ''
+  init()
+})
+
+const instId = ref<string>('')
+const refId = ref<string>('')
+const init = async () => {
+  skipEntryKeys.value = []
+  if (!templateId) {
+    return
+  }
+  refId.value = null
+  if (mode === 'create') {
+    const createResp = await securityCheckRequestFunc(
+      SecurityCheckFuncName.createSecurityCheckItem,
+      equipType,
+      {
+        businessType: 300,
+        conclusion: '',
+        date: null,
+        name: '进入现场(设备)前安全检查记录',
+        orderId,
+        templateId,
+      },
+    )
+    refId.value = createResp.data
+  } else {
+    refId.value = securityCheckId
+  }
+
+  const res = await getStandardTemplate({ id: templateId })
+  const resData = (res as any).data
+  const dataMap: any = {}
+
+  const dynamicTbValResp = await getDynamicTbVal({ refId: refId.value, templateId })
+  const dynamicTb: any = dynamicTbValResp.data
+  instId.value = dynamicTb?.dynamicTbInsRespVO?.id || ''
+  for (let i = 0; i < dynamicTb.dynamicTbValRespVOList.length; i++) {
+    const item = dynamicTb.dynamicTbValRespVOList[i]
+    const val = item.valValue
+    if (typeof val === 'string' && /\.(jpg|png)$/i.test(val)) {
+      skipEntryKeys.value.push(item.colCode)
+      continue
+    }
+    dataMap[item.colCode] = val
+  }
+
+  templateData.value = {
+    schema: resData.bindingPathSchema ? JSON.parse(resData.bindingPathSchema) : {},
+    data: {
+      ...dataMap,
+      templateId,
+      templateUrl: resData.fileUrl,
+    },
+    pathNameMapping: JSON.parse(resData.bindingPathNameJson),
+    template: templateId,
+    templateUrl: resData.fileUrl,
+  }
+
+  const excelBlob = await fetchExcel(refId.value, templateId)
+  const fileBase64 = await blobToBase64(excelBlob)
+  templateBlob.value = fileBase64
+}
+
+const fetchExcel = async (refId: string | number, templateId: string | number): Promise<Blob> => {
+  const apiPath = '/pressure2/excel/excel'
+  let requestUrl = apiPath
+  if (JSON.parse(import.meta.env.VITE_APP_PROXY) && import.meta.env.MODE === 'development') {
+    requestUrl = import.meta.env.VITE_APP_PROXY_PREFIX + apiPath
+  } else {
+    requestUrl = getEnvBaseUrl() + apiPath
+  }
+
+  const token = uni.getStorageSync('APP_ACCESS_TOKEN')
+  const headers: Record<string, string> = { 'Content-Type': 'application/json' }
+  if (token) {
+    headers.Authorization = `Bearer ${token}`
+  }
+
+  const response = await fetch(requestUrl, {
+    method: 'POST',
+    headers,
+    body: JSON.stringify({ refId, templateId }),
+  })
+
+  if (!response.ok) {
+    throw new Error(`获取Excel失败, status ${response.status}`)
+  }
+
+  return await response.blob()
+}
+
+const blobToBase64 = (blob: Blob): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onload = () => {
+      const base64 = (reader.result as string).split(',')[1]
+      resolve(base64)
+    }
+    reader.onerror = reject
+    reader.readAsDataURL(blob)
+  })
+}
+
+const handleSave = async (data: any) => {
+  const reqData = {}
+  for (const key in data.dataJSON) {
+    if (skipEntryKeys.value.includes(key)) {
+      continue
+    }
+    const element = data.dataJSON[key];
+    reqData[key] = element
+  }
+  const result = await saveDynamicTbVal({ params: reqData, instId: instId.value })
+  if (result?.code === 0 && result?.data) {
+    uni.showToast({ title: '保存成功', icon: 'success' })
+  } else {
+    const msg = result?.msg || '保存失败'
+    uni.showToast({ title: msg, icon: 'error' })
+  }
+  const updateResp = await securityCheckRequestFunc(
+    SecurityCheckFuncName.updateSecurityCheckItem,
+    equipType,
+    {
+      id: refId.value,
+      jsonData: JSON.stringify(reqData),
+    },
+  )
+}
+
+const handleCancel = () => {
+  uni.navigateBack()
+}
+</script>

+ 0 - 247
src/pages/securityCheck/detail/index copy.vue

@@ -1,247 +0,0 @@
-<route lang="json5" type="page">
-{
-  layout: 'default',
-  style: {
-    navigationBarTitleText: '安全检查记录详情',
-    navigationStyle: 'custom',
-  },
-}
-</route>
-
-<template>
-  <view class="detail-container">
-    <!-- 导航栏 -->
-    <NavBar>
-      <template #title>
-        <HeadView v-if="renderCenter" title="安全检查记录详情" :btn-array="btnArray" />
-        <text v-else class="nav-title">安全检查记录详情</text>
-      </template>
-    </NavBar>
-
-    <!-- PDF 预览区域 -->
-    <view class="pdf-container">
-      <view v-if="loading" class="loading-container">
-        <text class="loading-text">加载中...</text>
-      </view>
-      <SpreadPDFViewer
-        ref="spreadPdfViewerRef"
-        :businessConfig="businessConfig"
-        :templateData="templateData"
-        :templateBlob="templateBlob"
-      />
-    </view>
-  </view>
-</template>
-
-<script lang="ts" setup>
-import { ref, computed, onMounted } from 'vue'
-import { onLoad } from '@dcloudio/uni-app'
-import HeadView from '../components/HeadView.vue'
-import NavBar from '@/components/NavBar/NavBar.vue'
-import SpreadPDFViewer from '@/components/SpreadDesigner/SpreadPDFViewer.vue'
-
-import { useConfigStore } from '@/store/config'
-
-import { buildFileUrl } from '@/utils/index'
-import { requestFunc, SecurityCheckFuncName } from '@/api/ApiRouter/taskOrderSecurityCheck'
-import { getDynamicTbVal } from '@/api/task'
-import { getStandardTemplate } from '@/api'
-
-
-const configStore = useConfigStore()
-const equipType = configStore.getEquipType()
-
-// 路由参数
-const detailItem = ref<any>({})
-const orderId = ref('')
-const unitContact = ref('')
-const unitPhone = ref('')
-const receiverEmail = ref('')
-const templateId = ref('')
-const spreadPdfViewerRef = ref<any>(null)
-
-const templateBlob = ref<any>(null)
-const businessConfig = ref<any>({
-  businessType: 'AQJC',
-  title: '安全检查记录编辑',
-  disableNavigate: true, // 是否禁用跳转,默认不禁用
-
-  ui: {
-    title: '安全检查记录',
-    saveButtonText: '保存',
-    cancelButtonText: '取消',
-    showAdditionalToolbar: true,
-    customButtons: [],
-  },
-})
-const templateData = ref<any>({})
-
-onLoad((options) => {
-  orderId.value = options.orderId || ''
-  unitContact.value = options.unitContact || ''
-  unitPhone.value = options.unitPhone || ''
-  receiverEmail.value = options.receiverEmail || ''
-  templateId.value = options.templateId || ''
-
-  if (options.detailItem) {
-    try {
-      detailItem.value = JSON.parse(decodeURIComponent(options.detailItem))
-    } catch (e) {
-      console.error('解析 detailItem 失败:', e)
-    }
-  }
-})
-
-// 状态
-const loading = ref(false)
-
-// 是否显示操作按钮 (对应 PJ 中的 useMemo 计算)
-const renderCenter = computed(() => {
-  const { id } = detailItem.value || {}
-  if (!id) return false
-  return true
-})
-
-// 操作按钮数组 (与 PJ 版本一致,空数组)
-const btnArray = computed(() => {
-  const { signFilePdf, id } = detailItem.value || {}
-  if (!id) return []
-
-  return [
-    // { value: signFilePdf ? '重新签名' : '签名', color: 'rgb(47,142,255)', click: handleToSign },
-  ]
-})
-
-// 初始化
-const init = async () => {
-  loading.value = true
-  try {
-    // const tplParams = await handleGetTemplate()
-    // await getAppServiceOrderPDF(tplParams)
-    const id = detailItem.value?.id || ''
-    if (!id) return
-    const defaultTemplateResp = await requestFunc(SecurityCheckFuncName.getTemplate, equipType, { orderId: orderId.value })
-    const res = await getStandardTemplate({ id: defaultTemplateResp.data?.templateId })
-    const resData = (res as any).data
-    const dataMap: any = {}
-    const dynamicTbValResp = await getDynamicTbVal({ refId: id })
-    const dynamicTb: any = dynamicTbValResp.data
-    for (let i = 0; i < dynamicTb.dynamicTbValRespVOList.length; i++) {
-      const item = dynamicTb.dynamicTbValRespVOList[i]
-      dataMap[item.colCode] = item.valValue
-    }
-    // 组装templateData
-    templateData.value = {
-      schema: resData.bindingPathSchema ? JSON.parse(resData.bindingPathSchema) : {},
-      data: {
-        ...dataMap,
-        templateId,
-        templateUrl: resData.fileUrl,
-      },
-      pathNameMapping: JSON.parse(resData.bindingPathNameJson),
-      template: templateId,
-      templateUrl: resData.fileUrl,
-    }
-
-    console.log(templateData.value)
-    // 获取 template 文件
-    const fileUri = resData.fileUrl
-    const fileUrl = buildFileUrl(fileUri)
-    const fileBase64 = await spreadPdfViewerRef.value.downloadFileAsBase64(fileUrl)
-    templateBlob.value = fileBase64
-  } catch (error) {
-    console.error('[Page] 初始化失败:', error)
-    uni.showToast({ title: 'PDF 加载失败', icon: 'none' })
-  } finally {
-    loading.value = false
-  }
-}
-
-onMounted(async () => {
-  await init()
-})
-</script>
-
-<style lang="scss" scoped>
-.detail-container {
-  display: flex;
-  flex-direction: column;
-  height: 100vh;
-  background-color: #f5f5f5;
-}
-
-.navigate-view {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  height: 44px;
-  padding: 0 15px;
-  background-color: #fff;
-  border-bottom: 1px solid #eee;
-}
-
-.navigate-left {
-  display: flex;
-  align-items: center;
-  width: 44px;
-}
-
-.back-icon {
-  width: 20px;
-  height: 20px;
-}
-
-.navigate-center {
-  display: flex;
-  flex: 1;
-  justify-content: center;
-}
-
-.navigate-title {
-  font-size: 17px;
-  font-weight: 500;
-  color: #333;
-}
-
-.navigate-right {
-  display: flex;
-  align-items: center;
-  width: 44px;
-}
-
-.nav-title {
-  font-size: 17px;
-  font-weight: 500;
-  color: #333;
-}
-
-.pdf-container {
-  flex: 1;
-  background-color: #f5f5f5;
-}
-/* #ifdef H5 */
-.pdf-viewer-container {
-  box-sizing: border-box;
-  width: 100%;
-  height: 100%;
-  padding: 10px;
-  text-align: center;
-  background-color: #fff;
-}
-/* #endif */
-
-.loading-container,
-.empty-container {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  height: 100%;
-}
-
-.loading-text,
-.empty-text {
-  font-size: 14px;
-  color: #999;
-}
-</style>

+ 1 - 1
src/types/uni-pages.d.ts

@@ -12,6 +12,7 @@ interface NavigateToOptions {
        "/pages/editor/inspectionPlanEditor" |
        "/pages/editor/mainQuestionEditor" |
        "/pages/editor/securityCheckEditor" |
+       "/pages/editor/securityCheckEditor_customVer" |
        "/pages/editor/serviceOrderEditor" |
        "/pages/editor/workInstructionEditor" |
        "/pages/loading/index" |
@@ -48,7 +49,6 @@ interface NavigateToOptions {
        "/pages/pendingVerification/list/PendingVerificationList" |
        "/pages/pendingVerification/list/PipeItem" |
        "/pages/pendingVerification/preViewReport/index" |
-       "/pages/securityCheck/detail/index copy" |
        "/pages/securityCheck/detail/index" |
        "/pages/workInstructionAudit/list/WorkInstructionAuditList" |
        "/pages/workInstructionAudit/preViewReport/index";