Przeglądaj źródła

初步迁移SpreadDesigner

yangguanjin 2 miesięcy temu
rodzic
commit
ed6b60067f
28 zmienionych plików z 11642 dodań i 15 usunięć
  1. 186 0
      src/api/laboratory/standard/template.ts
  2. 150 0
      src/components/DynamicReport/BatchUploadFile.vue
  3. 495 0
      src/components/DynamicReport/CreateBatchUploadImages.ts
  4. 531 0
      src/components/DynamicReport/CustomUploadFile.vue
  5. 505 0
      src/components/DynamicReport/ImageSelectorDialog.vue
  6. 464 0
      src/components/DynamicReport/SpreadEditor.vue
  7. 12 0
      src/components/DynamicReport/SpreadInterface.ts
  8. 505 0
      src/components/DynamicReport/SpreadViewer.vue
  9. 4 0
      src/components/DynamicReport/index.ts
  10. 45 0
      src/components/DynamicReport/tempIF.ts
  11. 441 0
      src/components/SpreadDesigner/BatchUploadFile.vue
  12. 139 0
      src/components/SpreadDesigner/BindingPathKeyList.tsx
  13. 631 0
      src/components/SpreadDesigner/compoments/ImageSelectorDialog.vue
  14. 332 0
      src/components/SpreadDesigner/formatTime.ts
  15. 14 0
      src/components/SpreadDesigner/index.api.ts
  16. 3902 0
      src/components/SpreadDesigner/index.vue
  17. 126 0
      src/components/SpreadDesigner/is.ts
  18. 152 0
      src/components/SpreadDesigner/spreadStyles.module.scss
  19. 495 0
      src/components/SpreadDesigner/tools/CreateBatchUploadImages.ts
  20. 639 0
      src/components/SpreadDesigner/tools/DynamicFormManager.ts
  21. 34 0
      src/components/SpreadDesigner/tools/gc.ts
  22. 39 0
      src/components/SpreadDesigner/type.d.ts
  23. 1764 0
      src/components/SpreadDesigner/utils.ts
  24. 4 0
      src/pages.json
  25. 17 0
      src/pages/spread/index.vue
  26. 14 14
      src/pages/webview/generic-webview.vue
  27. 1 0
      src/types/uni-pages.d.ts
  28. 1 1
      tsconfig.json

+ 186 - 0
src/api/laboratory/standard/template.ts

@@ -0,0 +1,186 @@
+import { httpGet, httpPost, httpPut } from '@/utils/http'
+
+interface TemplateType {
+  name: string
+  type: string
+  classId: string
+  signType: string
+  sort: number
+  status: number
+  versionNumber: string
+  bindingPathSchema: string
+  file: File
+}
+
+interface UpdateTemplateType extends TemplateType {
+  id: string
+}
+
+interface TemplateQueryParams {
+  pageNo: number;
+  pageSize: number;
+  name?: string;
+  type?: string;
+  classId?: string;
+  signType?: string;
+  sort?: number;
+  status?: number;
+  versionNumber?: string;
+  bindingPathSchema?: string;
+  file?: File;
+}
+
+/* export const createStandardTemplate = (data: TemplateType) => {
+  return request.post({
+    url: '/system/standard-template/create',
+    // headers: {
+    //   'Content-Type': 'application/x-www-form-urlencoded'
+    // },
+    data
+  })
+}
+
+export const updateStandardTemplate = (data: UpdateTemplateType) => {
+  return request.put({
+    url: '/system/standard-template/update',
+    // headers: {
+    //   'Content-Type': 'application/x-www-form-urlencoded'
+    // },
+    data
+  })
+}
+
+export const updateStandardTemplateStatus = (data: {id: string, status: number}) => {
+  return request.put({
+    url: '/system/standard-template/updateStatus',
+    data
+  })
+}
+
+export const deleteStandardTemplate = (params: {id: string}) => {
+  return request.delete({
+    url: '/system/standard-template/delete',
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded'
+    },
+    params
+  })
+}
+
+export const getStandardTemplateList = (params: TemplateQueryParams) => {
+  return request.get({
+    url: '/system/standard-template/page',
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded'
+    },
+    params
+  })
+}
+
+export const exportStandardTemplateList = (params: TemplateQueryParams) => {
+  return request.get({
+    url: '/system/standard-template/export-excel',
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded'
+    },
+    params
+  })
+}
+
+export const getStandardTemplateInfo = (params: {id: string}) => {
+  return request.get({
+    url: '/system/standard-template/get',
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded',
+      'Response-Type': "blob"
+    },
+    params
+  })
+}
+
+// 获得标准模版初始化数据源绑定分页
+export const getTmplateInitData = (params: {pageNo: number, pageSize: number, type: string, classId: string}) => {
+  return request.get({url: '/system/standard-template-init-data/page', params })
+}
+
+// excel转pdf
+export const getPDF = (data) => {
+  return request.download2({
+    url: '/system/standard-template/getPdf',
+    data,
+    headers: {
+      'Content-Type': 'multipart/form-data',
+      'Accept': 'application/pdf'
+    },
+    responseType: 'blob'
+  })
+}
+// 获取文件流
+export const getFile = ( url) => {
+  return request.download({
+    url: url,
+    responseType: 'blob'
+  })
+}
+// 用于测试的接口
+export const getTestPdf = (data) => {
+  return request.download2({
+    url: '/system/standard-template/getTestPdf',
+    data,
+    headers: {
+      'Content-Type': 'multipart/form-data',
+      'Accept': 'application/pdf'
+    },
+    responseType: 'blob'
+  })
+} */
+
+/**
+ * 保存字段释义列表
+ * @param id 模板ID
+ * @param bindingPathNameJson 字段释义列表
+ */
+export const updateNameSchemaJson = (data:any)=> {
+  return httpPut('/pressure/report-template/updateBindingPathNameJson', data)
+}
+
+// 保存drawio画图数据
+/* export const saveDrawioData = (data:any) => {
+  return request.post({
+    url: '/system/template-business-parameter/create',
+    data
+  })
+}
+// 删除drawio画图数据
+export const deleteDrawioData = (params: {id: string}) => {
+  return request.delete({
+    url: '/system/template-business-parameter/delete',
+    params
+  })
+}
+// 更新drawio画图数据
+export const updateDrawioData = (data:any) => {
+  return request.put({
+    url: '/system/template-business-parameter/update',
+    data
+  })
+}
+// 查询drawio画图数据
+export const getDrawioData = (params:any) => {
+  return request.get({
+    url: '/system/template-business-parameter/page',
+    params
+  })
+}
+// 更新模板标识
+export const updateTemplateCode = (data: any) => {
+  return request.put({url: '/system/standard-template/updateCode', data})
+}
+// 下载记录报告模板导入
+export const downloadTemplateApi = (params) => {
+  return request.download({url: '/system/standard-template/download-import-template', params})
+}
+// 导出记录报告模板导入
+export const exportExportExcelApi = (params) => {
+  return request.download({url: '/system/standard-template/export-excel', params})
+} */

+ 150 - 0
src/components/DynamicReport/BatchUploadFile.vue

@@ -0,0 +1,150 @@
+<template>
+  <wd-button type="primary" @click="handleShowUploadDialog">批量上传文件</wd-button>
+  <wd-popup
+    position="right"
+    v-model="showUploadDialog"
+    :custom-style="{ width: '800px' }"
+    class="upload-modal"
+  >
+    <view class="upload-container">
+      <CustomUploadFile
+        v-model:fileList="formData.filePaths"
+        apiUrl="infra/file/upload"
+        accept=".jpg,.png"
+        list-type="text"
+        :max-size="10 * 1024 * 1024"
+        @handleApply="handleApply"
+      />
+    </view>
+    <view class="dialog-footer">
+      <wd-button type="primary" @click="handleTemplateImage">一键应用</wd-button>
+      <wd-button class="ml16" @click="handleCloseShowUploadDialog">取消</wd-button>
+    </view>
+  </wd-popup>
+</template>
+<script lang="ts" setup>
+import CreateBatchUploadImages from './CreateBatchUploadImages'
+import {buildFileUrl} from '@/utils'
+import {urlToBase64} from "@/utils/filt";
+import CustomUploadFile from './CustomUploadFile.vue'
+
+const props = defineProps({colList: {
+    type: Array,
+    default: () => []
+  }
+})
+const showUploadDialog = ref(false)
+// 批量上传文件
+const handleShowUploadDialog = async () => {
+  showUploadDialog.value = true;
+}
+// 关闭批量上传文件弹窗
+const handleCloseShowUploadDialog = () => {
+  showUploadDialog.value = false
+}
+
+const handleTemplateImage = async () => {
+  for (const file of formData.value.filePaths) {
+    await handleApply(file)
+  }
+}
+const handleApply = async (file) => {
+  const base = await urlToBase64(buildFileUrl(file.url))
+  let path = props.colList.find(item => item.colCode === "Illustration").colCode
+  if (!path) {
+    path = props.colList[0].colCode
+  }
+  //url是不是否是路径
+  let b = file.url.indexOf('/') > -1;
+  let split = b ? file.url.split('/') : file.url;
+  const name = {
+    url: b ? split[split.length - 1] : file.url,
+    path: path,
+    x: 100,
+    y: 50,
+    width: 180,
+    height: 150
+  }
+  emit("uploadImg", {base, name});
+  // 删掉列表上的
+  const newList = [...formData.value.filePaths]
+  const index = newList.findIndex(f => f.uid === file.uid || f.url === file.url || f.name === file.name)
+  if (index > -1) {
+    newList.splice(index, 1)
+  }
+  formData.value.filePaths = newList
+}
+const fileList = ref([])
+const batchFormRef = ref()
+const formData = ref({
+  description: '',
+  height: 250,
+  width: 350,
+  applyToReport: false,
+  path: '',
+  filePaths: []
+})
+
+// 删除图片
+const handleDelete = (index, rowInfo) => {
+  fileList.value.splice(index, 1)
+}
+
+// 清空应用表单
+const handleReset = () => {
+  batchFormRef.value?.resetFields()
+  formData.value.filePaths = []
+}
+
+const emit = defineEmits(['uploadImg'])
+
+</script>
+<style lang="scss" scoped>
+::v-deep .wd-input-number {
+  width: 100%;
+
+  .wd-input__inner {
+    text-align: left;
+  }
+}
+
+::v-deep .wd-form {
+  margin-top: 20px;
+}
+
+::v-deep .wd-drawer__body,
+::v-deep .wd-popup__body {
+  padding: 0 20px 100px;
+}
+
+.dialog-footer {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  padding: 20px 20px 20px 0;
+  z-index: 1000;
+  display: flex;
+  justify-content: center;
+  background-color: #fff;
+  width: 100%;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.ml16 {
+  margin-left: 16px !important;
+}
+
+.upload-container {
+  display: flex;
+  justify-content: flex-start;
+  // align-items: center;
+  gap: 12px;
+  margin-bottom: 15px;
+}
+
+button.el-button--default {
+  margin-right: 12px;
+}
+</style>

+ 495 - 0
src/components/DynamicReport/CreateBatchUploadImages.ts

@@ -0,0 +1,495 @@
+import * as GC from '@grapecity-software/spread-sheets'
+import '@grapecity-software/spread-sheets-designer'
+
+// 定义所需类型接口
+interface IDesigner {
+  getWorkbook(): ISpread
+}
+
+interface ISpread {
+  sheets: ISheet[]
+  getSheet(sheetIndex: number): ISheet | null
+  getActiveSheet(): ISheet
+  commandManager(): any
+  addSheet(name: string, sheet: any): any
+  removeSheet(index: number): void
+}
+
+interface ISheet {
+  name: string
+  rowCount: number
+  columnCount: number
+  floatingObjects: FloatingObjects
+  shapes: Shapes
+  setSelection: any
+  getBindingPath(row: number, col: number): string | null
+  getCellRect(row: number, col: number): ICellRect
+  getDataSource(): any
+  getSpans(): { row: number; col: number }[]
+  printInfo(): any
+  toJSON?(): any // 可选方法
+}
+
+interface ICellRect {
+  x: number
+  y: number
+  width: number
+  height: number
+}
+
+interface IBindingImageInfo {
+  row: number
+  col: number
+  bindingPath: string
+  width: number
+  height: number
+  x: number
+  y: number
+}
+
+interface IImageProperty {
+  url: string
+  width: number
+  height: number
+  groupHeight: number
+  description?: string
+}
+
+interface FloatingObjects {
+  all(): any[]
+  clear(): void
+}
+
+interface Shapes {
+  all(): any[]
+  clear(): void
+  add(text: string, shapeType: any, x: number, y: number, width: number, height: number): any
+  addPictureShape(
+    name: string,
+    src: string,
+    x: number,
+    y: number,
+    width: number,
+    height: number
+  ): any
+  group(items: any[]): any
+}
+
+const DEFAULT_BINDING_PATH = 'images'
+const GAP_WIDTH = 20
+const TEXT_SHAPE_HEIGHT = 32
+
+export default class CreateBatchUploadImages {
+  private designer: IDesigner | null = null
+  private spread: ISpread | null = null
+  private bindingPathName: string
+  private templateSheet: ISheet | null = null
+  private gapWidth: number
+  private textShapeHeight: number
+  private zoomFactor: number
+
+  constructor(bindingPathName?: string) {
+    this.bindingPathName = bindingPathName || DEFAULT_BINDING_PATH
+    this.gapWidth = GAP_WIDTH
+    this.textShapeHeight = TEXT_SHAPE_HEIGHT
+    try {
+      this.designer = this.getDesigner()
+      this.spread = this.getSpread()
+      this.templateSheet = this.getActiveSheet()
+      this.zoomFactor = this.templateSheet?.zoom() || 1
+    } catch (error) {
+      console.error('初始化失败:', error)
+    }
+  }
+
+  /**
+   * 获取Spread工作簿实例
+   * @returns {ISpread} 工作簿对象
+   */
+  public getSpread(): ISpread {
+    if (!this.spread) {
+      if (!this.designer) {
+        throw new Error('Designer 未初始化')
+      }
+      this.spread = this.designer.getWorkbook()
+    }
+    return this.spread
+  }
+
+  /**
+   * 获取设计器Dom
+   */
+  public getDesigner(): IDesigner {
+    if (this.designer) {
+      return this.designer
+    }
+
+    const designerElement =
+      document.getElementById('gc-designer-container') || 'gc-designer-container'
+    const gcDesigner = GC.Spread.Sheets.Designer.findControl(designerElement)
+
+    if (!gcDesigner) {
+      throw new Error('Designer control not found')
+    }
+
+    this.designer = gcDesigner
+
+    this.setSheetsZoom()
+    return gcDesigner
+  }
+
+  /**
+   * 获取当前使用的工作表
+   */
+  public getActiveSheet(): ISheet {
+    const spread = this.getSpread()
+    return spread.getActiveSheet()
+  }
+
+  /**
+   * 设置sheet的缩放比例
+   * **/
+  public setSheetsZoom() {
+    const spread = this.getSpread()
+    for (const sheet of spread.sheets) {
+      sheet?.zoom(1)
+      sheet?.showCell(0, 0);
+    }
+  }
+  /**
+   * 获取绑定图片的单元格信息
+   * @returns {IBindingImageInfo | null} 图片绑定信息
+   */
+  getBindingPathByImages(): IBindingImageInfo | null {
+    this.getSpread()
+    const sheet = this.getActiveSheet()
+    const spans = sheet.getSpans()
+
+    for (const item of spans) {
+      const bindingPath = sheet.getBindingPath(item.row, item.col)
+      if (bindingPath === this.bindingPathName) {
+        const cellRect = sheet.getCellRect(item.row, item.col)
+        return {
+          row: item.row,
+          col: item.col,
+          bindingPath: this.bindingPathName,
+          width: cellRect.width,
+          height: cellRect.height,
+          x: cellRect.x,
+          y: cellRect.y
+        }
+      }
+    }
+
+    console.warn(`未找到绑定路径为 ${this.bindingPathName} 的单元格`)
+    return null
+  }
+
+  /**
+   * 将图片地址转换为 base64
+   */
+  getImageBase64AndProperty(src: string, targetWidth: number): Promise<IImageProperty> {
+    return new Promise((resolve, reject) => {
+      const image = new Image()
+      image.crossOrigin = 'Anonymous'
+      image.src = src
+
+      image.onload = () => {
+        try {
+          const canvas = document.createElement('canvas')
+          const ctx = canvas.getContext('2d')
+          if (!ctx) {
+            throw new Error('无法获取2D上下文')
+          }
+
+          canvas.width = image.width
+          canvas.height = image.height
+          ctx.drawImage(image, 0, 0, canvas.width, canvas.height)
+
+          const scaleFactor = targetWidth / image.width
+          const flowWidth = targetWidth
+          const flowHeight = scaleFactor * image.height
+
+          resolve({
+            url: canvas.toDataURL('image/png'),
+            height: flowHeight,
+            width: flowWidth,
+            groupHeight: flowHeight + this.gapWidth + this.textShapeHeight
+          })
+        } catch (error) {
+          reject(`图片处理失败: ${error}`)
+        }
+      }
+
+      image.onerror = () => {
+        reject('图像加载失败')
+      }
+    })
+  }
+
+  /**
+   * 创建文本浮层对象
+   */
+  createTextFloatingObject(
+    text: string,
+    name: string,
+    x: number,
+    y: number,
+    width: number,
+    height: number,
+    sheet: ISheet
+  ) {
+    const textShape = sheet.shapes.add(
+      text,
+      GC.Spread.Sheets.Shapes.AutoShapeType.rectangle,
+      x,
+      y,
+      width,
+      height
+    )
+
+    textShape.isTextBox(true)
+    textShape.text(text)
+
+    const shapeStyle = textShape.style()
+    shapeStyle.fill.color = 'white'
+    shapeStyle.fill.transparency = 1
+    shapeStyle.textEffect.color = 'black'
+    shapeStyle.line.color = '#ffffff'
+    shapeStyle.line.transparency = 1
+    shapeStyle.textFrame.hAlign = GC.Spread.Sheets.HorizontalAlign.center
+    shapeStyle.textFrame.vAlign = GC.Spread.Sheets.VerticalAlign.center
+
+    textShape.style(shapeStyle)
+    return textShape
+  }
+
+  /**
+   * 创建图片浮层对象
+   */
+  createImageFloatingObject(
+    src: string,
+    name: string,
+    x: number,
+    y: number,
+    width: number,
+    height: number,
+    sheet: ISheet
+  ) {
+    return sheet.shapes.addPictureShape(name, src, x, y, width, height)
+  }
+
+  /**
+   * 合并浮层对象
+   */
+  groupByTextAndImageFloatingObject(
+    imageFloatingObject: any,
+    textFloatingObject: any,
+    sheet: ISheet
+  ) {
+    return sheet.shapes.group([textFloatingObject, imageFloatingObject])
+  }
+
+  /**
+   * 获取所有浮动对象
+   */
+  getAllFloatingObjects(sheet: ISheet) {
+    return [...(sheet.floatingObjects?.all() || []), ...(sheet.shapes?.all() || [])]
+  }
+
+  /**
+   * 删除所有浮动对象
+   */
+  deleteAllFloatingObjects(sheet: ISheet) {
+    if (sheet.floatingObjects?.all().length > 0) {
+      sheet.floatingObjects.clear()
+    }
+    if (sheet.shapes?.all().length > 0) {
+      sheet.shapes.clear()
+    }
+  }
+
+  /**
+   * 删除指定工作表
+   */
+  deleteSheet(sheetIndex: number) {
+    this.spread?.removeSheet(sheetIndex)
+  }
+
+  /**
+   * 复制工作表到新工作表
+   */
+  copySheetToNewSheet(sheetIndex: number, dataSource: any) {
+    const spread = this.getSpread()
+    const sourceSheet = this.getActiveSheet()
+
+    if (!sourceSheet.toJSON) {
+      console.error('当前工作表不支持序列化')
+      return
+    }
+
+    const sourceSheetJson = JSON.parse(JSON.stringify(sourceSheet.toJSON()))
+    const newSheetName = `CopiedSheet${sheetIndex}`
+    const newSheet = new GC.Spread.Sheets.Worksheet(newSheetName)
+    spread.addSheet(spread.sheets.length, newSheet)
+    sourceSheetJson.name = newSheetName
+    newSheet.fromJSON(sourceSheetJson)
+    // 复制数据源的值
+    newSheet?.setDataSource(dataSource)
+  }
+
+  /**
+   * 根据列数获取图片宽度
+   */
+  getImageWidth(columnCount: number): number {
+    const containerInfo = this.getBindingPathByImages()
+    if (!containerInfo) return 100 // 默认宽度
+
+    return (
+      (containerInfo.width - (columnCount - 1) * this.gapWidth - this.gapWidth * 2) / columnCount
+    )
+  }
+
+  /**
+   * 计算图片位置
+   */
+  cacheLastPosition(w: number, h: number, col: number, rowIndex: number): { x: number; y: number } {
+    const containerInfo = this.getBindingPathByImages()
+    if (!containerInfo) return { x: 0, y: 0 }
+
+    const startX = this.gapWidth
+    const startY = containerInfo.y
+
+    const x = startX + col * (w + this.gapWidth)
+    const y = startY + rowIndex * (h + this.gapWidth)
+
+    return { x, y }
+  }
+
+  /**
+   * 渲染图片到工作表
+   */
+  async renderImage(fileList: IImageProperty[][], sheet: ISheet) {
+    for (let colIndex = 0; colIndex < fileList.length; colIndex++) {
+      const columnFiles = fileList[colIndex]
+
+      for (let rowIndex = 0; rowIndex < columnFiles.length; rowIndex++) {
+        const file = columnFiles[rowIndex]
+        const lastFile = !rowIndex ? {} : columnFiles[rowIndex - 1]
+
+        // 创建图片和文本浮层
+        const imageObj = this.createImageFloatingObject(
+          file.url,
+          `image-col${colIndex}-row${rowIndex}`,
+          0,
+          0,
+          file.width,
+          file.height,
+          sheet
+        )
+
+        const textObj = this.createTextFloatingObject(
+          file.description || `Image ${colIndex}-${rowIndex}`,
+          `text-col${colIndex}-row${rowIndex}`,
+          0,
+          file.height + this.gapWidth,
+          file.width,
+          this.textShapeHeight,
+          sheet
+        )
+
+        // 组合并定位
+        const group = this.groupByTextAndImageFloatingObject(imageObj, textObj, sheet)
+        const position = this.cacheLastPosition(
+          file.width,
+          lastFile?.groupHeight || 0,
+          colIndex,
+          rowIndex
+        )
+        group.x(position.x)
+        group.y(position.y)
+      }
+    }
+  }
+
+  /**
+   * 主渲染方法
+   */
+  async render(fileList: IImageProperty[]) {
+    const containerInfo = this.getBindingPathByImages()
+    if (!containerInfo) {
+      console.error('无法渲染图片:未找到容器信息')
+      return
+    }
+
+    const groupedFiles = this.groupImages(
+      fileList,
+      containerInfo.width,
+      containerInfo.height,
+      this.gapWidth,
+      2
+    )
+    const activeDataSource = this.templateSheet?.getDataSource()
+    for (let i = 0; i < groupedFiles.length; i++) {
+      if (i > 0) {
+        this.copySheetToNewSheet(i, activeDataSource)
+      }
+
+      const sheet = this.spread?.sheets[i]
+      if (!sheet) continue
+
+      this.deleteAllFloatingObjects(sheet)
+      await this.renderImage(groupedFiles[i], sheet)
+    }
+  }
+
+  /**
+   * 图片分组算法
+   */
+  private groupImages(
+    files: IImageProperty[],
+    containerWidth: number,
+    containerHeight: number,
+    margin: number,
+    columns: number
+  ): IImageProperty[][][] {
+    const maxHeight = containerHeight - margin * 2
+    const result: IImageProperty[][][] = []
+    let currentPage: IImageProperty[][] = Array.from({ length: columns }, () => [])
+    let columnHeights = new Array(columns).fill(0)
+
+    for (const file of files) {
+      // 调整过大的图片
+      const adjustedHeight = Math.min(file.groupHeight, maxHeight)
+      const adjustedFile = { ...file, groupHeight: adjustedHeight }
+
+      let placed = false
+
+      // 尝试放入当前页
+      for (let col = 0; col < columns; col++) {
+        if (columnHeights[col] + adjustedFile.groupHeight <= maxHeight) {
+          currentPage[col].push(adjustedFile)
+          columnHeights[col] += adjustedFile.groupHeight
+          placed = true
+          break
+        }
+      }
+
+      // 创建新页
+      if (!placed) {
+        result.push(currentPage)
+        currentPage = Array.from({ length: columns }, () => [])
+        columnHeights = new Array(columns).fill(0)
+        currentPage[0].push(adjustedFile)
+        columnHeights[0] = adjustedFile.groupHeight
+      }
+    }
+
+    // 添加最后一页
+    if (currentPage.some((column) => column.length > 0)) {
+      result.push(currentPage)
+    }
+
+    return result
+  }
+}

+ 531 - 0
src/components/DynamicReport/CustomUploadFile.vue

@@ -0,0 +1,531 @@
+<template>
+<div class="upload-container">
+  <wd-upload
+    action="#"
+    multiple
+    :headers="headers"
+    :file-list="[]"
+    :accept="realAccept"
+    :before-upload="beforeUpload"
+    :on-success="handleSuccess"
+    :on-error="handleError"
+    :on-remove="handleRemove"
+    :http-request="handlePostFile"
+    :list-type="listType"
+    :auto-upload="autoUpload"
+    :limit="limit"
+    :disabled="disabled"
+    :show-file-list="false"
+  >
+    <slot name="trigger">
+      <wd-button :type="buttonType" :bg="false" :disabled="disabled">
+        <wd-icon v-if="showButtonIcon" name="upload" />
+        {{buttonText}}
+      </wd-button>
+    </slot>
+    <template #tip v-if="showTips">
+      <div class="upload-tip">
+        支持扩展名:{{ fileExtensions }}<br/>
+        <span v-if="maxSize > 0">单个文件(图片)大小不超过 {{ formatFileSize(maxSize) }}</span>
+      </div>
+    </template>
+  </wd-upload>
+
+  <!-- 已上传文件列表 - 显示在上传按钮下方 -->
+  <div v-if="processedFileList.length > 0" class="uploaded-files-section">
+    <div class="files-header">
+      <span class="files-title">已上传文件 ({{ processedFileList.length }})</span>
+    </div>
+    <div class="uploaded-files">
+      <div v-for="file in processedFileList" :key="file.uid || file.url" class="custom-file-item">
+        <div class="file-info">
+          <!-- 文件图标或预览 -->
+          <div class="file-preview" @click="handlePreview(file)">
+            <wd-img-preview
+              v-if="isImage(file)"
+              :src="file.url"
+              mode="cover"
+              style="width: 40px; height: 40px; border-radius: 4px; cursor: pointer;"
+              :preview-src-list="[]"
+              :hide-on-click-modal="true"
+            />
+            <wd-icon v-else-if="isPdf(file)" size="40" class="pdf-icon" name="file-pdf" />
+            <wd-icon v-else size="40" class="file-icon" name="file" />
+          </div>
+
+          <!-- 文件名称和操作 -->
+          <div class="file-content">
+            <div class="file-name" :title="file.name">
+              {{ file.name }}
+            </div>
+            <div class="file-actions">
+              <wd-button 
+                v-if="isImage(file)" 
+                type="primary" 
+                size="small" 
+                text 
+                @click="handleApply(file)"
+              >
+                应用
+              </wd-button>
+              <wd-button
+                v-if="isImage(file)"
+                type="primary"
+                size="small"
+                text
+                @click="openImagePreview(file)"
+              >
+                预览
+              </wd-button>
+              <wd-button 
+                v-if="isPdf(file)" 
+                type="primary" 
+                size="small" 
+                text 
+                @click="openPdfPreview(file)"
+              >
+                查看
+              </wd-button>
+              <wd-button 
+                type="primary" 
+                size="small" 
+                text 
+                @click="downloadFile(file)"
+              >
+                下载
+              </wd-button>
+              <wd-button 
+                type="error" 
+                size="small" 
+                text 
+                @click="handleRemove(file)"
+              >
+                删除
+              </wd-button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- 图片预览对话框 -->
+  <wd-popup
+    v-model="imagePreviewVisible"
+    title="图片预览"
+    width="80%"
+    @close="closeImagePreview"
+    position="center"
+  >
+    <div class="image-preview-container">
+      <wd-img-preview
+        :src="currentPreviewImage"
+        mode="contain"
+        style="width: 100%; max-height: 70vh;"
+        :preview-src-list="[]"
+        :hide-on-click-modal="true"
+      />
+    </div>
+  </wd-popup>
+
+  <!-- PDF预览对话框 -->
+  <wd-popup
+    v-model="pdfPreviewVisible"
+    title="PDF预览"
+    width="90%"
+    @close="closePdfPreview"
+    position="center"
+  >
+    <div class="pdf-preview-container">
+      <iframe
+        :src="currentPreviewPdf"
+        width="100%"
+        height="600px"
+        frameborder="0"
+      ></iframe>
+    </div>
+    <template #footer>
+      <span class="dialog-footer">
+        <wd-button @click="closePdfPreview">关闭</wd-button>
+        <wd-button type="primary" @click="openPdfInNewWindow">在新窗口打开</wd-button>
+      </span>
+    </template>
+  </wd-popup>
+</div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
+import { useToast } from 'wot-design-uni'
+import request from '@/config/axios'
+import SparkMD5 from 'spark-md5'
+import { getAccessToken } from '@/utils/auth'
+import { buildFileUrl } from './utils'
+
+const toast = useToast()
+
+const VITE_BASE_URL = ref(import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/')
+
+const props = defineProps({
+  apiUrl: { type: String, required: true },
+  chunkSize: { type: Number, default: 5 },
+  accept: { type: String, default: '' },
+  headers: { type: Object, default: () => ({}) },
+  fileList: { type: Array, default: () => [] },
+  autoUpload: { type: Boolean, default: true },
+  listType: { type: String, default: 'text' },
+  showTips: { type: Boolean, default: true },
+  buttonText: { type: String, default: '上传文件' },
+  buttonType: { type: String, default: 'default' },
+  showButtonIcon: { type: Boolean, default: true },
+  limit: { type: Number, default: 0 },
+  disabled: { type: Boolean, default: false },
+  // 新增:文件大小限制,单位为字节,默认0表示无限制
+  maxSize: { type: Number, default: 0 }
+})
+
+const emit = defineEmits(['update:fileList', 'success', 'error','handleApply'])
+
+// 预览相关状态
+const imagePreviewVisible = ref(false)
+const pdfPreviewVisible = ref(false)
+const currentPreviewImage = ref('')
+const currentPreviewPdf = ref('')
+
+// 处理后的文件列表,用于回显
+const processedFileList = computed(() => {
+  return (props.fileList || [])?.map(file => ({
+    ...file,
+    url: file.url ? buildFileUrl(file.url) : buildFileUrl(file.name || `${file.path || ''}${file.name || ''}`)
+  }))
+})
+
+// 实际接受的类型
+const realAccept = computed(() => {
+  return props.accept || '*'
+})
+
+// 支持的文件扩展名提示
+const fileExtensions = computed(() => {
+  return props.accept || '任意类型'
+})
+
+// 格式化文件大小显示
+const formatFileSize = (bytes) => {
+  if (bytes === 0) return '0 B'
+  const k = 1024
+  const sizes = ['B', 'KB', 'MB', 'GB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+}
+
+// 应用文件
+const handleApply = (file) => {
+  emit('handleApply', file)
+}
+
+// 判断是否为图片文件
+const isImage = (file) => {
+  const imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']
+  const extension = (file.name || '').split('.').pop()?.toLowerCase()
+  return imageTypes.includes(extension)
+}
+
+// 判断是否为PDF文件
+const isPdf = (file) => {
+  const extension = (file.name || '').split('.').pop()?.toLowerCase()
+  return extension === 'pdf'
+}
+
+// 打开图片预览
+const openImagePreview = (file) => {
+  currentPreviewImage.value = file.url
+  imagePreviewVisible.value = true
+}
+
+// 关闭图片预览
+const closeImagePreview = () => {
+  imagePreviewVisible.value = false
+  currentPreviewImage.value = ''
+}
+
+// 打开PDF预览
+const openPdfPreview = (file) => {
+  currentPreviewPdf.value = file.url
+  pdfPreviewVisible.value = true
+}
+
+// 关闭PDF预览
+const closePdfPreview = () => {
+  pdfPreviewVisible.value = false
+  currentPreviewPdf.value = ''
+}
+
+// 在新窗口打开PDF
+const openPdfInNewWindow = () => {
+  if (currentPreviewPdf.value) {
+    window.open(currentPreviewPdf.value, '_blank')
+  }
+}
+
+// 下载文件
+const downloadFile = (file) => {
+  if (isImage(file)) {
+    window.open(file.url, '_blank')
+  } else {
+    const link = document.createElement('a')
+    link.href = file.url
+    link.download = file.name
+    document.body.appendChild(link)
+    link.click()
+    document.body.removeChild(link)
+  }
+}
+
+// 处理文件预览
+const handlePreview = (file) => {
+  if (isImage(file)) {
+    openImagePreview(file)
+  } else if (isPdf(file)) {
+    openPdfPreview(file)
+  }
+}
+
+// 文件上传前校验
+const beforeUpload = (file) => {
+  // 校验文件类型
+  const lastName = (file.name || '').match(/\.([^.]+)$/)?.[0] || null;
+  if (realAccept.value !== '*' && !realAccept.value.includes(lastName)) {
+    toast.error('只能上传' + realAccept.value)
+    return false
+  }
+  
+  // 校验文件大小
+  if (props.maxSize > 0 && file.size > props.maxSize) {
+    toast.error(`文件大小不能超过 ${formatFileSize(props.maxSize)}`)
+    return false
+  }
+  
+  return true
+}
+
+const activeUploads = ref(false)
+
+// 文件上传方法
+const handlePostFile = async (options) => {
+  const { file, onProgress, onSuccess, onError } = options
+  try {
+    activeUploads.value = true
+    const formData = new FormData()
+    formData.append('file', file)
+
+    const result = await request.post({
+      url: VITE_BASE_URL.value + props.apiUrl,
+      data: formData,
+      headers: {
+        authorization: 'Bearer ' + getAccessToken(),
+        'Content-Type': 'multipart/form-data',
+        accept: '*/*',
+        ...props.headers
+      },
+      onUploadProgress: (progressEvent) => {
+        const percent = Math.round((progressEvent.loaded / file.size) * 100)
+        onProgress({ percent })
+      }
+    })
+    if(result) {
+      const fileObj = {
+        ...file,
+        name: file.name,
+        status: 'success',
+        url: result,
+        uid: file.uid || Date.now() + Math.random()
+      }
+      updateFileList(fileObj)
+      onSuccess(result)
+      emit('success', result, file)
+    }
+  } catch (err) {
+    onError(err)
+    emit('error', err, file)
+  } finally {
+    activeUploads.value = false
+  }
+}
+
+// 文件上传成功时的回调
+const handleSuccess = (response, file) => {
+  activeUploads.value = false
+}
+
+// 文件上传失败时的回调
+const handleError = (err) => {
+  activeUploads.value = false
+  toast.error('上传失败')
+}
+
+// 处理删除
+const handleRemove = async (file) => {
+  if (file.id) {
+    try {
+      await request.delete(`${props.apiUrl}/${file.id}`)
+      updateFileList(file, true)
+    } catch (err) {
+      toast.error('删除失败')
+    }
+  } else {
+    updateFileList(file, true)
+  }
+}
+
+// 更新文件列表
+const updateFileList = (file, isRemove = false) => {
+  console.log(file, 'updateFileList file')
+  const newList = [...props.fileList]
+  const index = newList.findIndex(f => f.uid === file.uid || f.url === file.url || f.name === file.name)
+
+  if (isRemove && index > -1) {
+    newList.splice(index, 1)
+  } else if (!isRemove && index === -1) {
+    newList.push(file)
+  } else if (!isRemove && index > -1) {
+    newList[index] = { ...newList[index], ...file }
+  }
+
+  console.log(newList, 'updateFileList newList')
+  emit('update:fileList', newList)
+}
+
+// 监听页面关闭事件
+const handleBeforeUnload = (e) => {
+  if (activeUploads.value) {
+    e.preventDefault()
+    e.returnValue = '文件正在上传中,确定要离开吗?'
+    return '文件正在上传中,确定要离开吗?'
+  }
+}
+
+onMounted(() => {
+  window.addEventListener('beforeunload', handleBeforeUnload)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('beforeunload', handleBeforeUnload)
+})
+</script>
+
+<style scoped>
+.upload-container {
+  width: 100%;
+}
+
+.uploaded-files-section {
+  //margin-top: 16px;
+}
+
+.files-header {
+  margin-bottom: 12px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid #e4e7ed;
+}
+
+.files-title {
+  font-size: 14px;
+  font-weight: 500;
+  color: #303133;
+}
+
+.uploaded-files {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.custom-file-item {
+  display: block;
+  width: 100%;
+  padding: 12px;
+  border: 1px solid #e4e7ed;
+  border-radius: 6px;
+  background-color: #fafafa;
+  transition: all 0.3s ease;
+}
+
+.custom-file-item:hover {
+  background-color: #f0f9ff;
+  border-color: #409eff;
+}
+
+.file-info {
+  display: flex;
+  align-items: flex-start;
+  width: 100%;
+}
+
+.file-preview {
+  margin-right: 12px;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.pdf-icon {
+  color: #f56565;
+  cursor: pointer;
+}
+
+.file-icon {
+  color: #718096;
+}
+
+.file-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.file-name {
+  font-size: 14px;
+  color: #303133;
+  margin-bottom: 8px;
+  word-break: break-all;
+  line-height: 1.4;
+}
+
+.file-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.image-preview-container {
+  text-align: center;
+  padding: 20px;
+}
+
+.pdf-preview-container {
+  width: 100%;
+  height: 600px;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+/* 隐藏默认的文件列表 */
+:deep(.wd-upload__list) {
+  display: none;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .file-actions {
+    margin-top: 4px;
+  }
+  
+  .file-actions .wd-button {
+    padding: 4px 8px;
+    font-size: 12px;
+  }
+}
+</style>

+ 505 - 0
src/components/DynamicReport/ImageSelectorDialog.vue

@@ -0,0 +1,505 @@
+<template>
+  <div>
+    <!-- 触发按钮 -->
+    <!-- <el-button type="primary" @click="openDialog">
+      选择图片 ({{ selectedImages.length }})
+    </el-button> -->
+
+    <!-- 主弹窗 -->
+    <wd-popup
+      v-model="dialogVisible"
+      title="选择图片"
+      width="80%"
+      :close-on-click-modal="false"
+    >
+      <!-- 工具栏 -->
+      <div class="toolbar">
+        <div class="toolbar-left">
+          <!-- <el-input
+            v-model="searchKeyword"
+            placeholder="搜索图片名称"
+            style="width: 200px"
+            clearable
+            @input="handleSearch"
+          >
+            <template #prefix>
+              <el-icon><Search /></el-icon>
+            </template>
+          </el-input> -->
+        </div>
+        <div class="toolbar-right">
+          <wd-button @click="selectAll" :disabled="!imageList.length">
+            全选 ({{ selectedImages.length }}/{{ imageList.length }})
+          </wd-button>
+          <wd-button @click="clearSelection" :disabled="!selectedImages.length">
+            清空选择
+          </wd-button>
+          <wd-button @click="refreshList" :loading="loading">
+            刷新
+          </wd-button>
+        </div>
+      </div>
+
+      <!-- 图片网格 -->
+      <div v-loading="loading" class="image-grid">
+        <div
+          v-for="image in filteredImages"
+          :key="image.id"
+          class="image-item"
+          :class="{ selected: isSelected(image.id) }"
+          @click="toggleSelection(image)"
+        >
+          <!-- 选择状态 -->
+          <div class="selection-overlay">
+            <wd-checkbox
+              :model-value="isSelected(image.id)"
+              @click.stop
+              @change="(checked) => handleCheckboxChange(checked, image)"
+            />
+          </div>
+
+          <!-- 图片 -->
+          <div class="image-wrapper" @click.stop="previewImage(image)">
+            <img
+              :src="getImageUrl(image.fileUrl)"
+              :alt="image.name"
+              @load="onImageLoad"
+              @error="onImageError"
+            />
+            <div class="image-overlay">
+              <wd-icon class="preview-icon" name="picture" />
+            </div>
+          </div>
+
+          <!-- 图片信息 -->
+          <div class="image-info">
+            <div class="image-name" :title="image.name">{{ image.name }}</div>
+            <div class="image-meta">
+              <span>{{ formatDate(image.createTime) }}</span>
+            </div>
+          </div>
+        </div>
+
+        <!-- 空状态 -->
+        <div v-if="!loading && !filteredImages.length" class="empty-state">
+          <wd-status-tip status="empty" text="暂无图片数据" />
+        </div>
+      </div>
+
+      <!-- 分页 -->
+      <!-- <div class="pagination-wrapper" v-if="total > pageSize">
+        <el-pagination
+          v-model:current-page="currentPage"
+          v-model:page-size="pageSize"
+          :page-sizes="[20, 40, 60, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handlePageChange"
+        />
+      </div> -->
+
+      <!-- 底部操作栏 -->
+      <template #footer>
+        <div class="dialog-footer">
+          <div class="selected-count">
+            已选择 {{ selectedImages.length }} 张图片
+          </div>
+          <div>
+            <wd-button @click="dialogVisible = false">取消</wd-button>
+            <wd-button
+              type="primary"
+              @click="confirmSelection"
+              :disabled="!selectedImages.length"
+              style="margin-left: 20px"
+            >
+              确定选择
+            </wd-button>
+          </div>
+        </div>
+      </template>
+    </wd-popup>
+
+    <!-- 图片预览弹窗 -->
+    <wd-popup
+      v-model="previewVisible"
+      :show-close="false"
+      :close-on-click-modal="true"
+      width="auto"
+      position="center"
+    >
+      <div class="preview-container">
+        <img
+          v-if="currentPreviewImage"
+          :src="getImageUrl(currentPreviewImage.fileUrl)"
+          :alt="currentPreviewImage.name"
+          class="preview-image"
+          @error="onPreviewImageError"
+        />
+        <div class="preview-info">
+          <h3>{{ currentPreviewImage?.name }}</h3>
+          <p>创建时间: {{ formatDate(currentPreviewImage?.createTime) }}</p>
+        </div>
+        <wd-button
+          class="close-btn"
+          circle
+          @click="closePreview"
+        >
+          <wd-icon name="close" />
+        </wd-button>
+      </div>
+    </wd-popup>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { listByTaskIdApi } from '@/api/laboratory/functional'
+import { useRoute } from "vue-router";
+import { buildFileUrl } from './utils'
+import _ from 'lodash'
+
+const toast = useToast()
+// Props
+const props = defineProps({
+  // 最大选择数量
+  maxSelection: {
+    type: Number,
+    default: 0 // 0表示无限制
+  },
+  // 默认选中的图片ID数组
+  defaultSelected: {
+    type: Array,
+    default: () => []
+  },
+  // API接口地址
+  apiUrl: {
+    type: String,
+    default: '/api/images'
+  },
+  // 图片base URL
+  baseUrl: {
+    type: String,
+    default: ''
+  }
+})
+
+// Emits
+const emit = defineEmits(['confirm', 'cancel'])
+const route = useRoute()
+// 响应式数据
+const dialogVisible = ref(false)
+const previewVisible = ref(false)
+const loading = ref(false)
+const imageList = ref([])
+const selectedImages = ref([...props.defaultSelected])
+const searchKeyword = ref('')
+const currentPreviewImage = ref(null)
+const isPreviewClosing = ref(false)
+
+// 分页数据
+const currentPage = ref(1)
+const pageSize = ref(20)
+const total = ref(0)
+
+// 计算属性
+const filteredImages = computed(() => {
+  return (imageList.value || [])?.map(file => ({
+    ...file,
+    fileUrl: file.fileUrl ? buildFileUrl(file.fileUrl) : buildFileUrl(file.name || `${file.path || ''}${file.name || ''}`)
+  }))
+})
+
+// 方法
+const openDialog = () => {
+  dialogVisible.value = true
+  selectedImages.value = [...props.defaultSelected]
+  loadImageList()
+}
+
+const closeDialog = () => {
+  dialogVisible.value = false
+}
+
+const loadImageList = async () => {
+  try {
+    loading.value = true
+    const taskId = route
+    const response = await listByTaskIdApi(route.query.id)
+    const data = _.cloneDeep(response)
+    // 根据实际API响应结构调整
+    imageList.value = data || []
+    total.value = imageList.value.length
+  } catch (error) {
+    console.error('加载图片列表失败:', error)
+  } finally {
+    loading.value = false
+  }
+}
+const isSelected = (imageId) => {
+  return selectedImages.value.includes(imageId)
+}
+
+const toggleSelection = (image) => {
+  const index = selectedImages.value.indexOf(image.id)
+  if (index > -1) {
+    selectedImages.value.splice(index, 1)
+  } else {
+    if (props.maxSelection && selectedImages.value.length >= props.maxSelection) {
+      toast.warning(`最多只能选择${props.maxSelection}张图片`)
+      return
+    }
+    selectedImages.value.push(image.id)
+  }
+}
+
+const handleCheckboxChange = (checked, image) => {
+  if (checked) {
+    if (props.maxSelection && selectedImages.value.length >= props.maxSelection) {
+      toast.warning(`最多只能选择${props.maxSelection}张图片`)
+      return
+    }
+    if (!selectedImages.value.includes(image.id)) {
+      selectedImages.value.push(image.id)
+    }
+  } else {
+    const index = selectedImages.value.indexOf(image.id)
+    if (index > -1) {
+      selectedImages.value.splice(index, 1)
+    }
+  }
+}
+
+const selectAll = () => {
+  const allIds = filteredImages.value.map(img => img.id)
+  if (props.maxSelection && allIds.length > props.maxSelection) {
+    toast.warning(`最多只能选择${props.maxSelection}张图片`)
+    selectedImages.value = allIds.slice(0, props.maxSelection)
+  } else {
+    selectedImages.value = [...allIds]
+  }
+}
+
+const clearSelection = () => {
+  selectedImages.value = []
+}
+
+const refreshList = () => {
+  currentPage.value = 1
+  loadImageList()
+}
+
+const handleSearch = () => {
+  // 搜索逻辑已在计算属性中处理
+}
+
+const handlePageChange = (page) => {
+  currentPage.value = page
+  loadImageList()
+}
+
+const handleSizeChange = (size) => {
+  pageSize.value = size
+  currentPage.value = 1
+  loadImageList()
+}
+
+const previewImage = (image) => {
+  currentPreviewImage.value = image
+  previewVisible.value = true
+  isPreviewClosing.value = false
+}
+
+const closePreview = () => {
+  isPreviewClosing.value = true
+  previewVisible.value = false
+  
+  // 延迟清空图片源,避免触发错误事件
+  setTimeout(() => {
+    currentPreviewImage.value = null
+    isPreviewClosing.value = false
+  }, 100)
+}
+
+const confirmSelection = () => {
+  const selectedImageData = imageList.value.filter(img => 
+    selectedImages.value.includes(img.id)
+  )
+  emit('confirm', selectedImageData)
+  dialogVisible.value = false
+  toast.success(`已选择${selectedImages.value.length}张图片`)
+}
+
+const getImageUrl = (fileUrl) => {
+  if (!fileUrl) return ''
+  if (fileUrl.startsWith('http')) return fileUrl
+  return `${props.baseUrl}${fileUrl}`
+}
+
+const formatDate = (timestamp) => {
+  if (!timestamp) return ''
+  const date = new Date(timestamp)
+  return date.toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+const onImageLoad = (event) => {
+  // 图片加载成功
+}
+
+const onImageError = (event) => {
+  if (previewVisible.value && !isPreviewClosing.value) {
+    console.error('图片加载失败')
+    // 可以设置默认图片
+    event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiBmaWxsPSIjRjVGNUY1Ii8+CjxwYXRoIGQ9Ik0zNSA2NUw1MCA0NUw2NSA2NUgzNVoiIGZpbGw9IiNEOUQ5RDkiLz4KPGNpcmNsZSBjeD0iNDAiIGN5PSIzNSIgcj0iNSIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K'
+  }
+}
+
+const onPreviewImageError = () => {
+  if (previewVisible.value && currentPreviewImage.value && !isPreviewClosing.value) {
+    toast.error('图片加载失败')
+  }
+}
+defineExpose({
+  openDialog
+})
+// 生命周期
+onMounted(() => {
+  // 组件挂载时可以预加载数据
+})
+</script>
+
+<style scoped>
+.preview-icon {
+  color: white;
+  font-size: 24px;
+}
+
+.image-info {
+  padding: 12px;
+}
+
+.image-name {
+  font-weight: 500;
+  color: #303133;
+  margin-bottom: 4px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.image-meta {
+  font-size: 12px;
+  color: #909399;
+}
+
+.empty-state {
+  grid-column: 1 / -1;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 200px;
+}
+
+.pagination-wrapper {
+  margin-top: 20px;
+  display: flex;
+  justify-content: center;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.selected-count {
+  color: #409eff;
+  font-weight: 500;
+}
+
+/* 预览弹窗样式 */
+.preview-container {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  max-width: 90vw;
+  max-height: 90vh;
+}
+
+.preview-image {
+  max-width: 100%;
+  max-height: 80vh;
+  object-fit: contain;
+  border-radius: 8px;
+}
+
+.preview-info {
+  background: rgba(0, 0, 0, 0.7);
+  color: white;
+  padding: 15px;
+  border-radius: 8px;
+  margin-top: 15px;
+  text-align: center;
+}
+
+.preview-info h3 {
+  margin: 0 0 8px 0;
+  font-size: 16px;
+}
+
+.preview-info p {
+  margin: 0;
+  font-size: 14px;
+  opacity: 0.8;
+}
+
+.close-btn {
+  position: absolute;
+  top: -10px;
+  right: -10px;
+  background: rgba(0, 0, 0, 0.6);
+  border: none;
+  color: white;
+}
+
+.close-btn:hover {
+  background: rgba(0, 0, 0, 0.8);
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .image-grid {
+    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+    gap: 12px;
+  }
+  
+  .toolbar {
+    flex-direction: column;
+    gap: 15px;
+  }
+  
+  .toolbar-right {
+    flex-wrap: wrap;
+    justify-content: center;
+  }
+  
+    .image-selector-dialog :deep(.wd-popup) {
+    width: 95% !important;
+    margin: 5vh auto;
+  }
+  
+  .dialog-footer {
+    flex-direction: column;
+    gap: 15px;
+    text-align: center;
+  }
+}
+</style>

+ 464 - 0
src/components/DynamicReport/SpreadEditor.vue

@@ -0,0 +1,464 @@
+<template>
+  <div class="lab-designer-container">
+    <SpreadDesigner class="spread-designer-container" v-loading="loading" businessType="2" :businessId="props.formData.id" ref="spreadDesignerRef"
+                    @init="handleDesignerInit" style="width: 100%;padding: 10px 20px 10px 10px;height: 650px">
+<!--                    @init="handleDesignerInit" style="width: 100%;padding: 10px 20px 10px 10px;height: 750px">-->
+      <template #toolbar>
+        <wd-button type="warning" @click="handleConfig">模版配置</wd-button>
+        <wd-button type="primary" @click="handlePreview">模版预览</wd-button>
+        <wd-button type="primary" @click="handleSave">保存</wd-button>
+        <wd-button type="default" @click="handleCancel">取消</wd-button>
+      </template>
+    </SpreadDesigner>
+    <wd-popup v-model="isConfig" position="right" width="380" height="80%" title="模版配置">
+      <wd-form label-position="left" label-width="120">
+        <wd-form-item label="是否存在续页" required>
+          <wd-switch v-model="isContinuePage" />
+        </wd-form-item>
+        <wd-form-item label="续页工作表名称" required>
+          <wd-input v-model="continuePageSheetName" :disabled="!isContinuePage"
+                    placeholder="请输入续页工作表"/>
+        </wd-form-item>
+        <wd-form-item label="隐藏空白续页">
+          <wd-switch v-model="isHiddenContinuePage" :disabled="!isContinuePage" />
+        </wd-form-item>
+      </wd-form>
+      <wd-card title="续页复制范围" style="margin: 20px;" v-if="isContinuePage">
+        <view style="margin-bottom: 10px;font-size: 14px;color: #909399;">选择需要复制表格的4个角,点击复制坐标</view>
+        <wd-form label-position="left" :show-line="false">
+          <view style="display: flex; flex-wrap: wrap; gap: 10px;">
+            <view style="width: calc(50% - 5px);">
+              <wd-input v-model="copyRangeTopLeft" placeholder="左上角">
+                <template #suffix>
+                  <wd-button type="primary" size="small" @click="handleCopy('lt')">复制</wd-button>
+                </template>
+              </wd-input>
+            </view>
+            <view style="width: calc(50% - 5px);">
+              <wd-input v-model="copyRangeTopRight" placeholder="右上角">
+                <template #suffix>
+                  <wd-button type="primary" size="small" @click="handleCopy('rt')">复制</wd-button>
+                </template>
+              </wd-input>
+            </view>
+          </view>
+          <view style="height: 10px;"></view>
+          <view style="display: flex; flex-wrap: wrap; gap: 10px;">
+            <view style="width: calc(50% - 5px);">
+              <wd-input v-model="copyRangeBottomLeft" placeholder="左下角">
+                <template #suffix>
+                  <wd-button type="primary" size="small" @click="handleCopy('lb')">复制</wd-button>
+                </template>
+              </wd-input>
+            </view>
+            <view style="width: calc(50% - 5px);">
+              <wd-input v-model="copyRangeBottomRight" placeholder="右下角">
+                <template #suffix>
+                  <wd-button type="primary" size="small" @click="handleCopy('rb')">复制</wd-button>
+                </template>
+              </wd-input>
+            </view>
+          </view>
+        </wd-form>
+      </wd-card>
+      <view style="padding: 10px; text-align: right;">
+        <wd-button type="primary" @click="saveConfig">保存</wd-button>
+      </view>
+    </wd-popup>
+  </div>
+</template>
+
+<script setup lang="ts">
+import '@grapecity-software/spread-sheets-designer-resources-cn'
+import * as GC from '@grapecity-software/spread-sheets'
+import SpreadDesigner from '@/components/SpreadDesigner/index.vue'
+import {useTagsViewStore} from '@/store/modules/tagsView'
+import {
+  createStandardTemplateV2,
+  getStandardTemplateInfoV2,
+  updateStandardTemplate
+} from '@/api/laboratory/standard/template'
+import {useRoute, useRouter} from 'vue-router'
+import {buildFileUrl} from '@/utils'
+import {DynamicTbApi} from '@/api/pressure2/dynamictb'
+import {DynamicTbInsApi} from '@/api/pressure2/dynamictbins'
+import axios from 'axios'
+import { useToast } from 'wot-design-uni'
+
+const toast = useToast()
+
+const route = useRoute()
+
+const router = useRouter();
+
+const tagsViewStore = useTagsViewStore()
+
+defineOptions({ name: 'SpreadEditor' });
+
+let designer = null
+const spreadDesignerRef = ref(null)
+
+const loading = ref(false)
+const isConfig = ref(false)
+
+// 续页配置相关变量
+const isContinuePage = ref(false)
+const continuePageSheetName = ref('续页')
+const isAutoCopy = ref(false)
+const isHiddenContinuePage = ref(false)
+
+// 复制范围相关变量
+const copyRangeTopLeft = ref('')
+const copyRangeTopRight = ref('')
+const copyRangeBottomLeft = ref('')
+const copyRangeBottomRight = ref('')
+
+const props = defineProps({
+  dtParams: {
+    type: Object,
+    default: () => ({}),
+    required: false
+  },
+  formData: {
+    type: Object,
+    default: () => ({}),
+    required: false
+  }
+});
+
+const getTbCols = async () => {
+  return await DynamicTbApi.getDtCols(props.dtParams);
+}
+
+const handleDesignerInit = (instance) => {
+  designer = instance
+  if (props.formData.id) {
+    fetchTemplateData()
+  }
+}
+// 取消编辑
+const handleCancel = () => {
+  tagsViewStore.closeSelectedTag(route)
+}
+// 保存编辑
+const handleSave = () => {
+
+  loading.value = true
+  const formData = new FormData()
+  const spread = designer.getWorkbook()
+  // 工作表数据
+  let bindingPathSchema = spreadDesignerRef.value?.getDefaultSchema()
+  // 字段列表释义
+  let bindingPathNameJson = spreadDesignerRef.value?.handleUpdateDesignerState.get('bindingPathDataJSON')
+  bindingPathNameJson = !bindingPathNameJson ? '[]' : JSON.stringify(bindingPathNameJson)
+
+  spread.save(async function (blob) {
+    loading.value = false
+    let result = null
+    formData.append('file', blob)
+    formData.append('id', props.formData.id)
+    formData.append('name', props.formData.tbName)
+    formData.append('signType', '')
+    formData.append('classId', props.formData.tbType)
+    formData.append('type', '2')
+    // formData.append('code', props.formData.tbCode)
+    formData.append('versionNumber', props.formData.tbCode)
+    formData.append('status', 0)
+    formData.append('bindingPathSchema', !bindingPathSchema ? '' : bindingPathSchema)
+    formData.append('bindingPathNameJson', bindingPathNameJson)
+    if (isNewTemplate) {
+      // let params = {
+      //   file: blob,
+      //   id: props.formData.id,
+      //   name: props.formData.tbName,
+      //   signType: '',
+      //   classId: props.formData.tbType,
+      //   type: '2',
+      //   // code: props.formData.tbCode,
+      //   versionNumber: props.formData.tbCode,
+      //   status: 0,
+      //   bindingPathSchema: !bindingPathSchema ? '' : bindingPathSchema,
+      //   bindingPathNameJson: bindingPathNameJson,
+      // }
+      result = await createStandardTemplateV2(formData)
+      isNewTemplate = false;
+    } else {
+      result = await updateStandardTemplate(formData);
+    }
+    if (result) {
+      loading.value = false
+      toast.success('保存成功')
+      // tagsViewStore.closeSelectedTag(route)
+    }
+  })
+}
+// 预览
+const handlePreview = () => {
+
+  DynamicTbInsApi.getOrCreatePreviewData(props.formData.id).then(res => {
+    router.push({
+      path: `/cybggl/preview/${props.formData.id}/${res.refId}`
+    });
+  })
+
+}
+
+let isNewTemplate = false;
+
+// 获取模版详情
+const fetchTemplateData = async () => {
+  loading.value = true
+  const templateRes = await getStandardTemplateInfoV2({ id: props.formData.id })
+  const spread = designer.getWorkbook()
+
+  if (templateRes && templateRes.id) {
+    // 已有模板的情况
+    let bindingPathSchema = JSON.parse(templateRes.bindingPathSchema);
+    let properties = {}; //bindingPathSchema.properties;
+    let bindingPathName = [];
+    let schemaSources = await getTbCols();
+
+    schemaSources?.forEach(element => {
+      /*if (element.col_val_type == 5){
+        let items = {
+          type: 'object',
+          properties: {
+          },
+        }
+        element.note.split(',').forEach(element2 => {
+          items.properties[element2] = {
+            type: 'string',
+          }
+        });
+        properties[element.col_code] = {
+          dataFieldType: 'table',
+          type: 'array',
+          items: items
+        }
+      }else {*/
+      properties[element.colCode] = {
+        dataFieldType: 'text',
+        type: 'string'
+      }
+      // }
+      bindingPathName.push(
+        {
+          "dataFieldType": "text",
+          "field": element.colCode,
+          "displayName": element.colName,
+          "type": "string",
+          "isVerify": "0"
+        }
+      )
+    });
+    bindingPathSchema.properties = properties;
+    spreadDesignerRef.value.setDefaultSchema(JSON.stringify(bindingPathSchema), JSON.stringify(bindingPathName))
+
+    if (!templateRes.fileUrl) return loading.value = false
+
+    // 获取模板文件流
+    const fileUrl = !templateRes.fileUrl ? '' : buildFileUrl(templateRes.fileUrl)
+    const response = await axios.get(fileUrl, { responseType: 'blob' });
+    const blob = new Blob([response.data], { type: response.headers['content-type'] });
+
+    // 渲染葡萄城文件
+    spread.open(blob, () => {
+      spread.getActiveSheet().zoom(1)
+      // 设置数据源的值
+      spreadDesignerRef.value.newSetDataSource(JSON.stringify(bindingPathSchema), {})
+      loading.value = false
+      console.log('加载成功')
+      //helpCopy(spread)
+      handleBing()
+    }, (err) => {
+      loading.value = false
+      console.log('加载失败', err)
+    })
+  } else {
+    // 第一次创建模板的情况
+    await initNewTemplate()
+  }
+  loading.value = false;
+}
+let temp = ref<string | null>(null)
+let index = ref<number>()
+const helpCopy = (spread) => {
+  spread.bind(GC.Spread.Sheets.Events.ClipboardPasting, function (_e, info) {
+    const cellRange = info.fromRange;
+    if (info.sheet.getBindingPath(cellRange.row, cellRange.col)) {
+      if (temp.value && temp.value.split('_')[0] !== info.sheet.getBindingPath(cellRange.row, cellRange.col).split('_')[0]) {
+        index.value = undefined
+      }
+      temp.value = info.sheet.getBindingPath(cellRange.row, cellRange.col)
+      if (!index.value){
+        index.value = parseInt(info.sheet.getBindingPath(cellRange.row, cellRange.col).split('_')[1]|| 1 )+1
+      }
+    }
+  })
+  spread.bind(GC.Spread.Sheets.Events.ClipboardPasted, function (_e, info) {
+    if (temp.value) {
+      const arr = temp.value.split('_')
+      const cellRange = info.cellRange;
+      temp.value = arr[0] + '_' + index.value
+      index.value = index.value + 1
+      info.sheet.setBindingPath(cellRange.row, cellRange.col, temp.value)
+    }
+  })
+}
+const autoCopy = ref(false)
+watch(autoCopy,()=>{
+  if (autoCopy.value){
+    helpCopy(designer.getWorkbook())
+  }else {
+    handleUnbind()
+  }
+})
+const handleUnbind = () => {
+  const spread = designer.getWorkbook()
+  spread.unbindAll()
+}
+// 初始化新模板
+const initNewTemplate = async () => {
+  try {
+    // 获取动态表格列数据
+    const schemaSources = await getTbCols();
+
+    // 构建初始的 bindingPathSchema
+    const initialSchema = {
+      type: "object",
+      $schema: 'http://json-schema.org/draft-04/schema#',
+      properties: {}
+    };
+
+    // 将动态表格列添加到 schema
+    schemaSources?.forEach(element => {
+      initialSchema.properties[element.col_code] = {
+        dataFieldType: 'text',
+        type: 'string'
+      };
+    });
+
+    // 设置默认 schema 和空的绑定路径名称
+    spreadDesignerRef.value.setDefaultSchema(JSON.stringify(initialSchema), '[]');
+
+    // 设置空的数据源
+    spreadDesignerRef.value.newSetDataSource(JSON.stringify(initialSchema), {});
+
+    console.log('新模板初始化成功,动态列已添加到数据区');
+    isNewTemplate = true;
+  } catch (error) {
+    console.error('初始化新模板失败:', error);
+    toast.error('初始化模板失败');
+  }
+}
+let sheetTemp,colTemp,rowTemp
+
+const handleBing = () => {
+  const spread = designer.getWorkbook()
+  spread.sheets.forEach(sheet => {
+    sheet.bind(GC.Spread.Sheets.Events.CellClick, (s, args) => {
+      sheetTemp = args.sheetName
+      colTemp = args.col
+      rowTemp = args.row
+    })
+  });
+}
+const handleCopy = (value) => {
+  if (sheetTemp != continuePageSheetName.value) {
+    toast.error('当前工作表不是续页');
+    return
+  }
+  switch (value) {
+    case 'lt':
+      copyRangeTopLeft.value = colTemp + ',' + rowTemp
+      break;
+    case 'rt':
+      copyRangeTopRight.value = colTemp + ',' + rowTemp
+      break;
+    case 'lb':
+      copyRangeBottomLeft.value = colTemp + ',' + rowTemp
+      break;
+    case 'rb':
+      copyRangeBottomRight.value = colTemp + ',' + rowTemp
+      break;
+  }
+}
+const handleConfig = async () => {
+  isConfig.value = true
+  // 查询配置
+ let data = await DynamicTbApi.getDynamicTb(props.formData.id);
+  console.log(data.copyConfig)
+  if (data.copyConfig){
+    const config = JSON.parse(data.copyConfig)
+    continuePageSheetName.value = config.sheetName
+    copyRangeTopLeft.value = config.copyRange.topLeft
+    copyRangeTopRight.value = config.copyRange.topRight
+    copyRangeBottomLeft.value = config.copyRange.bottomLeft
+    copyRangeBottomRight.value = config.copyRange.bottomRight
+    isHiddenContinuePage.value = config.hidden
+  }
+}
+
+const saveConfig = () => {
+  if (!isContinuePage.value){
+    DynamicTbApi.updateDynamicTb({
+      id: props.formData.id,
+      copyConfig: ""
+    }).then(() => {
+      toast.success('保存成功');
+      isConfig.value = false
+    }).catch(() => {
+      toast.error('保存失败');
+    })
+    return;
+  }
+  // 检查判空
+  if (!continuePageSheetName.value) {
+    toast.error('请选择续页工作表');
+    return
+  }
+  if (!copyRangeTopLeft.value || !copyRangeTopRight.value || !copyRangeBottomLeft.value || !copyRangeBottomRight.value) {
+    toast.error('请选择复制范围');
+    return;
+  }
+  DynamicTbApi.updateDynamicTb({
+    id: props.formData.id,
+    copyConfig: JSON.stringify({
+      copyRange: {
+        topLeft: copyRangeTopLeft.value,
+        topRight: copyRangeTopRight.value,
+        bottomLeft: copyRangeBottomLeft.value,
+        bottomRight: copyRangeBottomRight.value
+      },
+      sheetName: continuePageSheetName.value,
+      hidden: isHiddenContinuePage.value
+    })
+  }).then(() => {
+    toast.success('保存成功');
+    isConfig.value = false
+  }).catch(() => {
+    toast.error('保存失败');
+  })
+}
+</script>
+
+
+<style lang="scss" scoped>
+:deep(.default-toolbar) {
+  padding-top: 0;
+}
+
+.lab-designer-container {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  align-items: stretch;
+  height: calc(100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height));
+
+  .spread-designer-container {
+    width: calc(100% - 440px);
+    height: 100%;
+    padding: 0;
+  }
+}
+</style>

+ 12 - 0
src/components/DynamicReport/SpreadInterface.ts

@@ -0,0 +1,12 @@
+export interface InitParams{
+  templateId:string;
+  refId:string;
+  insId?:string;
+  refName?:string;
+  serviceName?:string;
+  params?:Object;
+  isReport?:boolean;
+  reportType?:number; //1:记录, 2:报告, 3:结论报告
+  opType:number; // 0:excel输入,1:pdf查看
+  dataSource?:string; // 默认数据源
+}

+ 505 - 0
src/components/DynamicReport/SpreadViewer.vue

@@ -0,0 +1,505 @@
+<template>
+  <div v-show="showSpread" class="lab-designer-container" v-loading="loading" >
+    <div class="header-row">
+      <div class="title"></div>
+      <div class="unit-footer-btns">
+        <wd-button type="primary" @click="handleGenerate(true)">生成续页</wd-button>
+        <BatchUploadFile @uploadImg="addPic" :colList="colListData" />
+        <wd-button type="primary" @click="handleSave">保存</wd-button>
+        <wd-button type="primary" @click="openPdf">预览PDF</wd-button>
+      </div>
+    </div>
+    <div v-loading="loading" ref="previewContainer" class="spread-container"></div>
+  </div>
+  <div v-show="showPdf" class="pdf-viewer-container" v-loading="pdfLoading" ref="pdfViewer">
+    <div class="pdf-viewer-content">
+      <VuePdfEmbed
+        :key="pdfTimestamp"
+        :height="pdfViewerHeight"
+        :width="pdfContentWidth"
+        :source="recordSource"
+        :text-layer="false"
+        :annotation-layer="false"
+        @rendered="handlePdfRendered"
+      />
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+import '@grapecity-software/spread-sheets-designer-resources-cn'
+import * as GC from '@grapecity-software/spread-sheets'
+// 导入PDF导出模块
+import '@grapecity-software/spread-sheets-pdf'
+import VuePdfEmbed from 'vue-pdf-embed'
+import 'vue-pdf-embed/dist/styles/annotationLayer.css'
+import 'vue-pdf-embed/dist/styles/textLayer.css'
+import * as ExcelIO from "@grapecity-software/spread-excelio"
+import SpreadDesigner from '@/components/SpreadDesigner/index.vue'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import {
+  getTmplateInitData,
+  updateStandardTemplate,
+  getStandardTemplateInfo,
+  getPDF,
+  getTestPdf
+} from '@/api/laboratory/standard/template';
+import {ref, onMounted,onUnmounted} from "vue";
+import {InitParams} from './SpreadInterface';
+import { buildFileUrl } from '@/utils'
+import axios from 'axios'
+import { DynamicTbApi } from '@/api/pressure2/dynamictb'
+import {DynamicTbColApi} from "@/api/pressure2/dynamictbcol";
+import { DynamicTbValApi } from '@/api/pressure2/dynamictbval'
+import BatchUploadFile from "./BatchUploadFile.vue";
+import {editReport, handleCopy} from "@/utils/reportUtil";
+import Designer from '@grapecity-software/spread-sheets-designer-vue'
+import { useToast } from 'wot-design-uni'
+
+defineOptions({ name: 'SpreadViewer' });
+
+const toast = useToast()
+
+const props = defineProps({
+  initData: {
+    type: Object as PropType<InitParams>,
+    default: ()=>({}),
+    required: false
+  }
+});
+
+const insId=ref<string>(null);
+
+const colListData=ref([]);
+const addPic=(picData)=>{
+   if(previewSpread){
+     picData.name.sheet = previewSpread.getActiveSheet().name()
+     previewSpread.getActiveSheet().shapes.addPictureShape(JSON.stringify(picData.name), picData.base, picData.name.x, picData.name.y, picData.name.width, picData.name.height);
+   }
+}
+/*
+watch(()=>[props.initData.templateId,props.initData.refId],([newTide,newRid],[oldTid,oldRid])=>{
+  if(newTide && newRid){
+    initPreview();
+  }
+
+}); */
+
+//const route = useRoute()
+// spread相关
+const tagsViewStore = useTagsViewStore()
+const loading = ref(true)
+const showSpread=ref(true)
+const previewContainer = ref(null)
+let sheetData = {};
+const designerKey = import.meta.env.VITE_SPREADJS_DESIGNER_KEY
+const licenseKey = import.meta.env.VITE_SPREADJS_LICENSE_KEY
+GC.Spread.Sheets.Designer.LicenseKey = designerKey
+//同时对SpreadJS与ExcelIO进行授权
+GC.Spread.Sheets.LicenseKey = licenseKey
+// pdf相关
+let previewSpread = null
+const pdfLoading = ref(false);
+const showPdf=ref(false);
+const recordSource = ref('')
+const pdfViewer = ref()
+const pdfContentWidth = ref(800)
+const pdfViewerHeight = ref(600)
+const pdfTimestamp = ref('')
+
+const handlePdfRendered = () => {
+  pdfLoading.value = false
+}
+
+// 获取模版详情
+const fetchTemplateData = async () => {
+  console.log(this)
+  if(!props.initData.templateId){
+    return;
+  }
+  const templateRes = await getStandardTemplateInfo({ id: props.initData.templateId })
+  if (templateRes && templateRes.fileUrl) {
+    const fileUrl = buildFileUrl(templateRes.fileUrl)
+    const response = await axios.get(fileUrl, { responseType: 'blob' });
+    return new Blob([response.data], { type: response.headers['content-type'] });
+  }
+  return;
+}
+let dynamicTbColRespVOList
+const initPreview = async () => {
+  if(!props.initData.refId){
+    return false;
+  }
+  loading.value = true;
+  // showSpread.value=true;
+  // showPdf.value=false;
+
+  previewSpread?.destroy();
+
+  previewSpread = new GC.Spread.Sheets.Workbook(previewContainer.value)
+  try {
+    let blob = await fetchTemplateData();
+    if (!blob) return;
+
+    previewSpread.open(blob, () => {
+      console.log('预览加载完成');
+      let sheets = previewSpread.sheets;
+      //DynamicTbValApi.getDynamicTbValByIns(props.instanceId)
+      DynamicTbValApi.getDynamicTbInsAndValByRefId(props.initData)
+      .then(async res => {
+
+        let dataSource1 = new GC.Spread.Sheets.Bindings.CellBindingSource({});
+        insId.value = res.dynamicTbInsRespVO.id;
+
+        if (res.dynamicTbValRespVOList && res.dynamicTbValRespVOList.length > 0) {
+          // 设置数据
+          sheetData = {};
+          res.dynamicTbValRespVOList.forEach(i => sheetData[i.colCode] = i.valValue);
+          dataSource1 = new GC.Spread.Sheets.Bindings.CellBindingSource(sheetData);
+        }
+        if (props.initData.dataSource){
+          dataSource1 = new GC.Spread.Sheets.Bindings.CellBindingSource(props.initData.dataSource);
+        }
+        sheets.forEach((sheet) => {
+          sheet.setDataSource(dataSource1);
+        });
+        dynamicTbColRespVOList = res.dynamicTbColRespVOList
+        // 可编辑字段
+        const editCols = res.dynamicTbColRespVOList.filter(i => i.isEdit).map(i => i.colCode);
+        // 图片字段
+        const imgCols = res.dynamicTbColRespVOList.filter(i => i.colValType === 4).map(i => i.colCode);
+        colListData.value = res.dynamicTbColRespVOList.filter(i => i.colValType === 4)
+        for (const sheet of sheets) {
+          await editReport(sheet, editCols, imgCols,props.initData.opType )
+        }
+
+        // 是否隐藏续页
+        await hiddenPage()
+
+        if (props.initData.opType == 1) {
+          showSpread.value = false;
+          pdfLoading.value = true;
+          openPdf();
+        } else {
+          loading.value = false;
+        }
+      })
+
+    }, (error) => {
+      console.error('文件打开失败:', error);
+    });
+    // 处理复制
+    if (props.initData.opType == 0) {
+      handleCopy(previewSpread)
+    }
+  } catch (error) {
+    console.error('预览加载失败:', error);
+  } finally {
+
+  }
+}
+
+const openPdf = async () => {
+  const formData = new FormData()
+  const rules = []
+  previewSpread.sheets.forEach((sheet) => {
+    sheet.conditionalFormats.getRules().forEach(rule => {
+      if (rule.ruleType() === 2) {
+        console.log(rule)
+        rules.push({
+          sheet,
+          rule
+        })
+      }
+    })
+    rules.forEach(rule => {
+      sheet.conditionalFormats.removeRule(rule.rule)
+    })
+  });
+ await previewSpread.save(async function (blob) {
+    formData.append('file', blob)
+    const response = await getPDF(formData)
+    if (response) {
+      const flow = new Blob([response], { type: 'application/pdf' })
+      recordSource.value = window.URL.createObjectURL(flow);
+      if(props.initData.opType==0){
+        loading.value = false;
+        window.open(recordSource.value, '_blank');
+      }
+      if(props.initData.opType==1){
+        //showSpread.value=false;
+        pdfTimestamp.value = Date.now();
+        showPdf.value=true;
+        pdfLoading.value=true;
+        setTimeout(() => {
+          calculatePdfSize()
+        },100)
+      }
+    }
+
+  }, (e) => {
+
+  }, {
+    includeBindingSource: true
+  })
+  previewSpread.sheets.forEach((sheet) => {
+    rules.forEach(rule => {
+      if (rule.sheet === sheet){
+      sheet.conditionalFormats.addRule(rule.rule)
+      }
+    })
+  });
+}
+
+const handleSpreadPrint = () => {
+  if (!previewSpread) return
+  previewSpread.print()
+}
+
+const handleSave = () => {
+  loading.value = true;
+
+  ///sheetData
+  let dataSource = {};
+  previewSpread.sheets.forEach((sheet) => {
+
+    const rT = sheet.getDataSource().rT;
+    for (const key in rT) {
+      if (dataSource[key] && sheetData[key] == rT[key]) {
+      } else {
+        dataSource[key] = rT[key]
+      }
+    }
+
+
+    sheet.shapes.all().forEach(shape => {
+      if (shape.name() && shape.name().startsWith('{') && shape.name().endsWith('}')) {
+        // 解析json
+        let data = JSON.parse(shape.name())
+        if (data) {
+          data.x = shape.x()
+          data.y = shape.y()
+          data.width = shape.width()
+          data.height = shape.height()
+          if (dataSource[data.path] && dataSource[data.path].startsWith('[') && dataSource[data.path].endsWith(']')) {
+            // 说明已经有数组了
+            let arr = JSON.parse(dataSource[data.path])
+            arr.push(data)
+            dataSource[data.path] = JSON.stringify(arr)
+          } else {
+            dataSource[data.path] = JSON.stringify([data])
+          }
+        }
+      }
+    })
+  });
+  //let dataSource = previewSpread.getActiveSheet().getDataSource().rT;
+
+  if (dataSource) {
+    DynamicTbValApi.saveAllColValue(insId.value, dataSource).then(res => {
+      if (res) {
+        toast.success('保存成功')
+        emit('saveSuccess', {insId:insId.value,dataSource,refId:props.initData.refId});
+      } else{
+        toast.error('保存失败')
+        emit('saveFail');
+      }
+
+    }).catch(e => {
+      toast.error('保存失败')
+      emit('saveFail');
+    }).finally(() => {
+      loading.value = false;
+    });
+  }
+}
+let generateData
+let sheetNum = ref(2)
+/**
+ * 生成续页
+ * @param {boolean} isSetTimeout 是否设置定时器
+ */
+const handleGenerate = (isSetTimeout) => {
+  const generate = async () => {
+
+    const sheet = previewSpread.getSheetFromName(generateData.sheetName)
+    if (sheet.visible()) {
+      previewSpread.commandManager().execute({
+        cmd: "copySheet",
+        sheetName: generateData.sheetName,
+        targetIndex: 999,
+        newName: generateData.sheetName + " (" + sheetNum.value + ")",
+        includeBindingSource: true
+      });
+    } else {
+      sheet.visible(true)
+      return
+    }
+    // 范围内的字段叠加
+    let col = parseInt(generateData.copyRange.topLeft.split(',')[0])
+    let row = parseInt(generateData.copyRange.topLeft.split(',')[1])
+    let colCount = parseInt(generateData.copyRange.topRight.split(',')[0]) - col
+    let rowCount = parseInt(generateData.copyRange.bottomLeft.split(',')[1]) - row
+    const sheet2 = previewSpread.getSheetFromName(generateData.sheetName + " (" + sheetNum.value + ")")
+    let colPathList = []
+    let dynamicTbColRespVOListCode = dynamicTbColRespVOList.map(i => i.colCode)
+    for (let i = 0; i <= colCount; i++) {
+      for (let j = 0; j <= rowCount; j++) {
+        const bindingPath = sheet.getBindingPath(row + j, col + i)
+        if (bindingPath) {
+          let newPath = bindingPath.split('_')[0] + "_" + (parseInt(bindingPath.split('_')[1]) + rowCount + 1) * (sheetNum.value - 1)
+          sheet2.setBindingPath(row + j, col + i, newPath)
+          if (!dynamicTbColRespVOListCode.includes(newPath)){
+            colPathList.push(newPath)
+          }
+        }
+      }
+    }
+    sheetNum.value = sheetNum.value + 1
+    await DynamicTbColApi.createBatchByCodes(colPathList,props.initData.templateId)
+  }
+
+  if (isSetTimeout) {
+    const loading = ElLoading.service({text: '正在生成中'})
+    setTimeout(() => {
+      try {
+        generate()
+      } finally {
+        loading.close()
+      }
+    }, 20)
+  } else {
+    generate()
+  }
+}
+/**
+ * 隐藏续页
+ */
+const hiddenPage = async () => {
+  sheetNum.value = 2
+  const data = await DynamicTbApi.getDynamicTb(props.initData.templateId)
+
+  if (data && data.copyConfig) {
+    generateData = JSON.parse(data.copyConfig)
+  } else {
+    return
+  }
+  // 计算坐标
+  let col = parseInt(generateData.copyRange.topLeft.split(',')[0])
+  let row = parseInt(generateData.copyRange.topLeft.split(',')[1])
+  let colCount = parseInt(generateData.copyRange.topRight.split(',')[0]) - col
+  let rowCount = parseInt(generateData.copyRange.bottomLeft.split(',')[1]) - row
+  const sheet = previewSpread.getSheetFromName(generateData.sheetName)
+  if (!sheet) {
+    return;
+  }
+  if (generateData.hidden) {
+    // 判断第一行,如果全部为空,则隐藏
+    let isEmpty = true;
+    for (let i = 0; i < colCount; i++) {
+      const range = sheet.getCell(row, col + i)
+      if (range.value()) {
+        isEmpty = false;
+        break;
+      }
+    }
+    if (isEmpty) {
+      sheet.visible(false)
+    }
+  }
+  // 判断最后一行,如果不为空,自动续页
+  let isNotEmpty = false;
+  for (let i = 0; i < colCount; i++) {
+    const range = sheet.getCell(row + rowCount, col + i)
+    if (range.value()) {
+      isNotEmpty = true;
+      break;
+    }
+  }
+  if (isNotEmpty) {
+    handleGenerate(false)
+  }
+}
+
+defineExpose({reloadView:initPreview});
+
+const emit = defineEmits(['saveSuccess','docReady','docCreate','saveFail'])
+
+// 动态计算PDF查看器尺寸
+const calculatePdfSize = () => {
+  pdfContentWidth.value = pdfViewer.value.clientWidth - 45
+  pdfViewerHeight.value = pdfViewer.value.clientHeight
+  console.log('props.pdfUrl', pdfContentWidth.value, pdfViewerHeight.value)
+}
+
+onMounted(() => {
+
+  window.addEventListener('resize', calculatePdfSize)
+
+  console.log("SpreadViewer,onMounted:"+JSON.stringify(props.initData));
+  //initPreview();
+
+})
+
+onUnmounted(()=>{
+  window.removeEventListener('resize', calculatePdfSize)
+  previewSpread?.destroy();
+})
+
+</script>
+<style lang="scss" scoped>
+:deep(.default-toolbar) {
+  padding-top: 0;
+}
+
+.lab-designer-container {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+
+  align-items: stretch;
+  height: calc(100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height));
+
+  .spread-designer-container {
+    width: calc(100% - 440px);
+    height: 100%;
+    padding: 0;
+  }
+}
+
+.header-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0px 20px 16px 20px;
+  background: #fff;
+  border-bottom: 1px solid #e8e8e8;
+}
+
+.header-row .title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.spread-container {
+  flex: 1;
+  background: #f5f7fa;
+  overflow: auto;
+  padding: 0;
+}
+.pdf-viewer-container {
+  width: 100%;
+  height: calc(100% - 40px);
+  //background-color: #8E8E9D;
+  padding: 5px;
+  box-sizing: border-box;
+  overflow-y: auto;
+}
+.pdf-viewer-content {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+
+</style>

+ 4 - 0
src/components/DynamicReport/index.ts

@@ -0,0 +1,4 @@
+import SpreadEditor from './SpreadEditor.vue';
+import SpreadViewer from './SpreadViewer.vue';
+
+export {SpreadEditor,SpreadViewer}

+ 45 - 0
src/components/DynamicReport/tempIF.ts

@@ -0,0 +1,45 @@
+/**
+* 报表工具接口
+ */
+//模板编辑参数
+export interface TempParams {
+  tbCode: string; //
+  tbVersion: number|null; // null表示最高版本
+}
+
+
+// 模板数据参数
+export interface DataParams {
+  daRefid: string; // 表报实例Id
+  daRefname: string; // 表报实例Name
+}
+
+// 初始化
+export interface InitFunc {
+  (initData:any): any ;
+}
+
+// 设置模板字段
+export interface SetTempColsFunc {
+  (rows:any[]): any ;
+}
+// 保存模板 : 存放后的模板地址
+export interface SaveTemp {
+  ():string;
+}
+// 设置模板上的数据
+/*
+export interface SetDataSourceFunc{
+  (rows:any[]):any;
+}*/
+// 设置实例上的数据
+export interface SetDataSourceFunc{
+  (rows:any[]):any;
+}
+
+// 获取实例上的数据
+export interface GetDataSourceFunc{
+  (data:any):any[];
+}
+
+

+ 441 - 0
src/components/SpreadDesigner/BatchUploadFile.vue

@@ -0,0 +1,441 @@
+<template>
+  <wd-button type="primary" @click="handleShowUploadDialog" v-if="isShowFileRespFlag">批量上传文件</wd-button>
+  <wd-popup
+    v-model="showUploadDialog"
+    position="right"
+    custom-style="width: 800px"
+    class="upload-modal"
+  >
+    <div class="drawer-header">
+      <h3>上传文件</h3>
+      <wd-icon name="close" @click="handleCloseShowUploadDialog" />
+    </div>
+    
+    <div class="upload-container">
+      <wd-button size="small" type="success" style="margin-left: 10px;" @click="handleSelectFromImageLibrary" v-if="isShowFileRespFlag">从图片资料库选择</wd-button>
+      <wd-upload
+        :file-list="fileList"
+        accept="image"
+        :multiple="true"
+        :show-upload-list="false"
+        :before-remove="beforeRemove"
+        @change="handleUploadFileChange"
+      >
+        <wd-button size="small" type="primary">选择文件</wd-button>
+        <template #tip>
+          <div class="upload-tip">只能上传jpg/png文件,且不超过500kb</div>
+        </template>
+      </wd-upload>
+    </div>
+    
+    <!-- 图片描述、图片宽高、是否应用到报告 均可以一键编辑 -->
+    <wd-form ref="batchFormRef" :model="formData" class="control-form">
+      <view class="form-row">
+        <view class="form-item">
+          <text class="form-label">描述</text>
+          <wd-input v-model="formData.description" placeholder="输入统一描述"/>
+        </view>
+      </view>
+
+      <view class="form-row">
+        <view class="form-item">
+          <wd-checkbox v-model="formData.applyToReport">是否应用到报告</wd-checkbox>
+        </view>
+      </view>
+      <view class="form-row">
+        <view class="form-item">
+          <wd-button type="primary" size="small" @click="handleBatchChangeListValue">一键应用</wd-button>
+          <wd-button size="small" @click="handleReset('batchFormRef')" style="margin-left: 10px;">清空表单</wd-button>
+        </view>
+      </view>
+    </wd-form>
+
+    <wd-table 
+      ref="tableRef"
+      :data="fileList" 
+      border 
+      style="width: 100%"
+      row-key="uid"
+    >
+      <!-- 拖拽图标 -->
+      <wd-table-col label="拖拽" width="60" align="center">
+        <template #value>
+          <wd-icon name="more" class="drag-handle" style="cursor: move; font-size: 18px; color: #909399;" />
+        </template>
+      </wd-table-col>
+
+      <!-- 图片预览 -->
+      <wd-table-col label="图片预览" width="120">
+        <template #value="{ row }">
+          <img 
+            :src="row.url" 
+            style="max-height: 100px; max-width: 150px; object-fit: contain;"
+          />
+        </template>
+      </wd-table-col>
+
+      <!-- 描述 -->
+      <wd-table-col label="描述">
+        <template #value="{ row }">
+          <wd-input 
+            v-model="row.description"
+            placeholder="请输入图片描述"
+          />
+        </template>
+      </wd-table-col>
+
+      <!-- 应用到报告 -->
+      <wd-table-col label="应用到报告" width="100" align="center">
+        <template #value="{ row }">
+          <wd-checkbox v-model="row.applyToReport"/>
+        </template>
+      </wd-table-col>
+
+      <!-- 排序 -->
+      <wd-table-col label="排序" width="100">
+        <template #value="{ row, index }">
+          <wd-input-number 
+            v-model="row.order"
+            :min="1"
+            :max="fileList.length"
+            :step="1"
+            @change="handleOrderChange(index, row)"
+          />
+        </template>
+      </wd-table-col>
+
+      <wd-table-col label="操作" width="100">
+        <template #value="{ row, index }">
+          <wd-button type="error" size="small" @click="handleDelete(index, row)">删除</wd-button>
+        </template>
+      </wd-table-col>
+    </wd-table>
+
+    <div class="dialog-footer">
+      <wd-button type="primary" @click="handleTemplateImage">应用到Excel中</wd-button>
+      <wd-button class="ml16" @click="handleCloseShowUploadDialog">取消</wd-button>
+    </div>
+
+    <ImageSelectorDialog
+      ref="imageSelectorDialogRef"
+      @confirm="handleConfirm"
+    />
+  </wd-popup>
+</template>
+
+<script setup>
+import { ref, nextTick, watch, onBeforeUnmount } from 'vue'
+import Sortable from 'sortablejs'
+import CreateBatchUploadImages from './tools/CreateBatchUploadImages'
+const ImageSelectorDialog = defineAsyncComponent(() => import('./compoments/ImageSelectorDialog.vue'))
+import { buildFileUrl } from './utils'
+import { uploadFile } from '@/api/common'
+import _ from 'lodash'
+
+const props = defineProps({
+  uploadBtnObj: {
+    type: Object,
+    default: () => {
+      return {}
+    }
+  },
+  isShowFileRespFlag: {
+    type: Boolean,
+    default: false
+  },
+})
+
+const imageSelectorDialogRef = ref()
+const showUploadDialog = ref(false)
+const tableRef = ref()
+let sortableInstance = null
+
+// 初始化拖拽功能
+const initSortable = () => {
+  nextTick(() => {
+    const el = tableRef.value?.$el.querySelector('.wd-table__body tbody')
+    if (!el) return
+
+    // 如果已存在实例,先销毁
+    if (sortableInstance) {
+      sortableInstance.destroy()
+    }
+
+    sortableInstance = Sortable.create(el, {
+      handle: '.drag-handle', // 指定拖拽手柄
+      animation: 150,
+      onEnd: ({ newIndex, oldIndex }) => {
+        if (newIndex === oldIndex) return
+        // 更新数组顺序
+        const movedItem = fileList.value.splice(oldIndex, 1)[0]
+        fileList.value.splice(newIndex, 0, movedItem)
+        // 使用 nextTick 确保更新
+        nextTick(() => {
+          fileList.value.forEach((item, index) => {
+            item.order = index + 1
+          })
+        })
+      }
+    })
+  })
+}
+
+// 处理序号变化,自动重新排序
+const handleOrderChange = (currentIndex, currentRow) => {
+  const newOrder = currentRow.order
+  const oldOrder = currentIndex + 1
+  
+  // 如果序号没有变化,直接返回
+  if (newOrder === oldOrder) return
+  
+  // 确保新序号在有效范围内
+  if (newOrder < 1 || newOrder > fileList.value.length) {
+    currentRow.order = oldOrder
+    return
+  }
+  
+  // 移除当前项
+  const [movedItem] = fileList.value.splice(currentIndex, 1)
+  
+  // 插入到新位置(序号从1开始,索引从0开始)
+  fileList.value.splice(newOrder - 1, 0, movedItem)
+  
+  // 重新分配所有项的序号
+  nextTick(() => {
+    fileList.value.forEach((item, index) => {
+      item.order = index + 1
+    })
+  })
+}
+
+// 监听弹窗打开,初始化拖拽
+watch(showUploadDialog, (newVal) => {
+  if (newVal) {
+    initSortable()
+  }
+})
+
+// 批量上传文件
+const handleShowUploadDialog = () => {
+  showUploadDialog.value = true
+}
+
+const handleSelectFromImageLibrary = () => {
+  imageSelectorDialogRef.value.openDialog()
+}
+
+// 关闭批量上传文件弹窗
+const handleCloseShowUploadDialog = () => {
+  showUploadDialog.value = false
+}
+
+let batchUploadImages = null
+const handleTemplateImage = async () => {
+  if(!batchUploadImages) {
+    batchUploadImages = new CreateBatchUploadImages()
+  } else {
+    batchUploadImages.setSheetsZoom()
+  }
+  const waitUploadList = fileList.value.filter(item => item.isUploaded === false)
+  const uploadedFileList = fileList.value.filter(item => item.isUploaded ?? item.isUploaded !== false)
+  for(let file of waitUploadList) {
+    const uploadedFile = await uploadFile({file: file.raw})
+    uploadedFileList.push({
+      url: !uploadedFile ? buildFileUrl(uploadedFile) : file.url,
+      ...file
+    })
+  }
+
+  const fileListMap = []
+  for(const file of uploadedFileList) {
+    const targetWidth = batchUploadImages.getImageWidth(2)
+    const targetInfo = await batchUploadImages.getImageBase64AndProperty(file.url, targetWidth)
+    fileListMap.push({
+      ...file,
+      ...targetInfo
+    })
+  }
+  batchUploadImages.render(fileListMap)
+  handleCloseShowUploadDialog()
+}
+
+const handleConfirm = async (list) => {
+  const newList = list.map((item)=> {
+    return {
+      ...item,
+      url: buildFileUrl(item.fileUrl)
+    }
+  })
+  if(!batchUploadImages) {
+    batchUploadImages = new CreateBatchUploadImages()
+  } else {
+    batchUploadImages.setSheetsZoom()
+  }
+  const fileListMap = []
+  for(const file of newList) {
+    const targetWidth = batchUploadImages.getImageWidth(2)
+    const targetInfo = await batchUploadImages.getImageBase64AndProperty(file.url, targetWidth)
+    fileListMap.push({
+      ...file,
+      ...targetInfo
+    })
+  }
+  batchUploadImages.render(fileListMap)
+  handleCloseShowUploadDialog()
+}
+
+const fileList = ref([])
+
+const batchFormRef = ref()
+const formData = ref({
+  description: '',
+  height: 250,
+  width: 350,
+  applyToReport: false
+})
+
+const fileToDataUrl = (file) => {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.onload = (e) => {
+      resolve(e.target.result);
+    };
+    reader.onerror = (e) => {
+      reject(e);
+    };
+    reader.readAsDataURL(file);
+  });
+}
+
+const handleUploadFileChange = (file, changeFileList) => {
+  fileToDataUrl(file.raw).then(url => {
+    fileList.value.push({
+      ...file,
+      url,
+      description: '示例图' + (fileList.value.length + 1),
+      applyToReport: false,
+      height: 250,
+      width: 350,
+      order: fileList.value.length + 1,
+      isUploaded: false
+    })
+    // 文件列表更新后重新初始化拖拽
+    nextTick(() => {
+      initSortable()
+    })
+  })
+}
+
+// 删除图片
+const handleDelete = (index, rowInfo) => {
+  fileList.value.splice(index, 1)
+  // 更新剩余项的 order
+  fileList.value.forEach((item, idx) => {
+    item.order = idx + 1
+  })
+}
+
+// 清空应用表单
+const handleReset = () => {
+  batchFormRef.value?.resetFields()
+}
+
+// 一键应用表单
+const handleBatchChangeListValue = async () => {
+  fileList.value.forEach(item => {
+    item.description = formData.value.description
+    item.height = formData.value.height
+    item.width = formData.value.width
+    item.applyToReport = formData.value.applyToReport
+  })
+}
+
+// 组件卸载时销毁 sortable 实例
+onBeforeUnmount(() => {
+  if (sortableInstance) {
+    sortableInstance.destroy()
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.drawer-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 15px 20px;
+  border-bottom: 1px solid #ebeef5;
+  h3 {
+    margin: 0;
+    font-size: 18px;
+    font-weight: 500;
+  }
+}
+
+.upload-tip {
+  color: #909399;
+  font-size: 12px;
+  margin-top: 8px;
+}
+
+.control-form {
+  margin-top: 20px;
+  padding: 0 20px;
+  
+  .form-row {
+    display: flex;
+    align-items: center;
+    margin-bottom: 15px;
+    
+    .form-item {
+      display: flex;
+      align-items: center;
+      
+      .form-label {
+        width: 80px;
+        font-size: 14px;
+        color: #606266;
+      }
+    }
+  }
+}
+
+.dialog-footer {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  padding: 20px;
+  z-index: 1000;
+  display: flex;
+  justify-content: flex-end;
+  background-color: #fff;
+  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.ml16 {
+  margin-left: 16px !important;
+}
+
+.upload-container {
+  display: flex;
+  justify-content: flex-start;
+  gap: 12px;
+  margin-bottom: 15px;
+  padding: 0 20px;
+}
+
+.drag-handle {
+  font-size: 18px;
+  color: #909399;
+  &:hover {
+    color: #409eff;
+  }
+}
+
+// 拖拽时的样式
+::v-deep .sortable-ghost {
+  opacity: 0.4;
+  background: #f0f9ff;
+}
+</style>

+ 139 - 0
src/components/SpreadDesigner/BindingPathKeyList.tsx

@@ -0,0 +1,139 @@
+import { defineComponent, ref, PropType } from 'vue'
+import { BindingPathItemType, SpreadTemplateApiDataType } from './type'
+import styles from './spreadStyles.module.scss'
+import { updateNameSchemaJson } from '@/api/laboratory/standard/template'
+import { compareBindingPathDisplayName, generateSchemaDataToJSON } from './utils'
+import { has } from 'lodash'
+import { useToast } from 'wot-design-uni'
+
+export default defineComponent({
+  name: 'BindingPathKeyList',
+  props: {
+    templateApiData: {
+      required: true,
+      type: Object,
+      default: () => ({} as SpreadTemplateApiDataType)
+    },
+    updateApiParams: {
+      type: Object,
+      default: () => ({})
+    },
+    bindingPathData: {
+      // 字段数据列表
+      required: true,
+      type: Array as PropType<BindingPathItemType[]>,
+      default: () => ([])
+    },
+    callbackFn: {
+      required: true,
+      type: Object,
+      default: () => ({})
+    }
+  },
+  emits: [
+    'update:bindingPathData',
+    'success' // 保存/同步更新成功
+  ],
+  setup(props, {slots, emit, expose}) { 
+    // const instance = getCurrentInstance()
+    // 控制刷新图标的状态
+    const isRotating = ref(false)
+    const toast = useToast()
+    // 刷新数据源
+    const handleReloadJSON = () => {
+      isRotating.value = true
+      try {
+        // 获取工作表数据
+        const currentBindingPathSchema = props?.callbackFn?.getDefaultSchema("treeNodeFromJson") || props?.callbackFn?.getDefaultSchema("oldTreeNodeFromJson") || props?.callbackFn?.getDefaultSchema("updatedTreeNode");
+        // 对比两份数据
+        const newBindingPathData = compareBindingPathDisplayName(generateSchemaDataToJSON(currentBindingPathSchema), props.bindingPathData)
+        emit('update:bindingPathData', newBindingPathData)
+        emit('success', newBindingPathData)
+      } catch (err) {
+        
+        console.error('刷新数据源报错:', err)
+        
+      } finally {
+        const timer = setTimeout(() => {
+          isRotating.value = false
+          toast.success('同步成功!')
+          clearTimeout(timer)
+        }, 1000)
+      }
+    }
+    
+    // 保存字段数据
+    const handleSaveBindingPathJSON = async () => {
+      try {
+        await updateNameSchemaJson({
+          ...props.templateApiData,
+          bindingPathNameJson: JSON.stringify(props.bindingPathData),
+          // TODO: 接口需要传递这几个参数
+          name: '1',
+          classId: '1',
+          type: '1',
+          isAutoAmount: '1'
+        });
+        toast.success('保存成功');
+        emit('success')
+      } catch (error) {
+        toast.error('保存失败');
+      }
+    }
+
+
+    expose({
+      SaveBindingPathJSON: handleSaveBindingPathJSON
+    })
+
+    // 树节点元素
+    const treeNodeElement = (dataItem: BindingPathItemType) => {
+      return <div className={styles.treeNode}>
+          <div className={`${styles.iconPre} ${styles.icon_text}`}></div>
+          <div className={styles.tree_node_title}>{dataItem.field}</div>
+          {
+            dataItem && <div className={styles.tree_node_title_inner}>
+              <wd-input style="width: 110px" v-model={dataItem.displayName} placeholder="中文释义"/>
+              <wd-checkbox v-model={dataItem.isVerify} true-value="1" false-value="0">不校验空值</wd-checkbox>
+            </div>
+          }
+        </div>
+    }
+
+    // 递归处理bindingPathData数据
+    const handleRecursionBindingPathData = (data: BindingPathItemType[]) => {
+      return data.map((dataItem: BindingPathItemType) => {
+        return <div className={styles.treeNodeWrapper}>
+          {treeNodeElement(dataItem)}
+
+          {
+            has(dataItem, 'child') && <div className={styles.treeChildren}>
+              {handleRecursionBindingPathData(dataItem.child)}
+            </div>
+          }
+        </div>
+        // }
+      })
+    }
+      
+    return () => (
+      <div className={`fieldContent ${styles.field_data_list}`}> 
+        <div className={styles.field_data_list_header}>
+          字段列表释义
+          {!!props.templateApiData.id && <wd-button type="primary" onClick={handleSaveBindingPathJSON}>保存</wd-button>}
+        </div>
+        <div className={styles.field_data_list_container}>
+          <div className={styles.field_data_list_container_title}>
+            <wd-icon name="refresh" onClick={handleReloadJSON} className={`wd-icon ${isRotating.value ? styles.rotate_icon : ''}`} />
+            同步数据源
+          </div>
+          <div className={styles.treeList}>
+            {
+              handleRecursionBindingPathData(props.bindingPathData)
+            }
+          </div>
+        </div>
+      </div>
+    )
+  }
+})

+ 631 - 0
src/components/SpreadDesigner/compoments/ImageSelectorDialog.vue

@@ -0,0 +1,631 @@
+<template>
+  <div>
+    <!-- 触发按钮 -->
+    <!-- <el-button type="primary" @click="openDialog">
+      选择图片 ({{ selectedImages.length }})
+    </el-button> -->
+
+    <!-- 主弹窗 -->
+    <wd-popup
+      v-model="dialogVisible"
+      position="center"
+      custom-style="width: 80%"
+      :close-on-click-modal="false"
+      class="image-selector-dialog"
+    >
+      <div class="dialog-header">
+        <h3>选择图片</h3>
+        <wd-icon name="close" @click="dialogVisible = false" />
+      </div>
+      
+      <!-- 工具栏 -->
+      <div class="toolbar">
+        <div class="toolbar-left">
+          <!-- <wd-input
+            v-model="searchKeyword"
+            placeholder="搜索图片名称"
+            style="width: 200px"
+            @input="handleSearch"
+          /> -->
+        </div>
+        <div class="toolbar-right">
+          <wd-button @click="selectAll" :disabled="!imageList.length" size="small">
+            全选 ({{ selectedImages.length }}/{{ imageList.length }})
+          </wd-button>
+          <wd-button @click="clearSelection" :disabled="!selectedImages.length" size="small">
+            清空选择
+          </wd-button>
+          <wd-button @click="refreshList" :loading="loading" size="small">
+            刷新
+          </wd-button>
+        </div>
+      </div>
+
+      <!-- 图片网格 -->
+      <div class="image-grid" :class="{ 'is-loading': loading }">
+        <div v-if="loading" class="loading-overlay">
+          <wd-loading type="ring" />
+        </div>
+        <template v-else>
+          <div
+            v-for="image in filteredImages"
+            :key="image.id"
+            class="image-item"
+            :class="{ selected: isSelected(image.id) }"
+            @click="toggleSelection(image)"
+          >
+            <!-- 选择状态 -->
+            <div class="selection-overlay">
+              <wd-checkbox
+                :model-value="isSelected(image.id)"
+                @click.stop
+                @change="(checked) => handleCheckboxChange(checked, image)"
+              />
+            </div>
+
+            <!-- 图片 -->
+            <div class="image-wrapper" @click.stop="previewImage(image)">
+              <img
+                :src="getImageUrl(image.fileUrl)"
+                :alt="image.name"
+                @load="onImageLoad"
+                @error="onImageError"
+              />
+              <div class="image-overlay">
+                <wd-icon name="picture" size="24px" color="white" />
+              </div>
+            </div>
+
+            <!-- 图片信息 -->
+            <div class="image-info">
+              <div class="image-name" :title="image.name">{{ image.name }}</div>
+              <div class="image-meta">
+                <span>{{ formatDate(image.createTime) }}</span>
+              </div>
+            </div>
+          </div>
+        </template>
+
+        <!-- 空状态 -->
+        <div v-if="!loading && !filteredImages.length" class="empty-state">
+          <wd-status-tip image="content" tip="暂无图片数据" />
+        </div>
+      </div>
+
+      <!-- 分页 -->
+      <!-- <div class="pagination-wrapper" v-if="total > pageSize">
+        <wd-pagination
+          v-model:current-page="currentPage"
+          v-model:page-size="pageSize"
+          :page-sizes="[20, 40, 60, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handlePageChange"
+        />
+      </div> -->
+
+      <!-- 底部操作栏 -->
+      <div class="dialog-footer">
+        <div class="selected-count">
+          已选择 {{ selectedImages.length }} 张图片
+        </div>
+        <div>
+          <wd-button @click="dialogVisible = false" size="small">取消</wd-button>
+          <wd-button
+            type="primary"
+            @click="confirmSelection"
+            :disabled="!selectedImages.length"
+            size="small"
+            style="margin-left: 20px"
+          >
+            确定选择
+          </wd-button>
+        </div>
+      </div>
+    </wd-popup>
+
+    <!-- 图片预览弹窗 -->
+    <wd-popup
+      v-model="previewVisible"
+      position="center"
+      :close-on-click-modal="true"
+      custom-style="background: transparent; box-shadow: none;"
+      class="image-preview-dialog"
+    >
+      <div class="preview-container">
+        <img
+          v-if="currentPreviewImage"
+          :src="getImageUrl(currentPreviewImage.fileUrl)"
+          :alt="currentPreviewImage.name"
+          class="preview-image"
+          @error="onPreviewImageError"
+        />
+        <div class="preview-info">
+          <h3>{{ currentPreviewImage?.name }}</h3>
+          <p>创建时间: {{ formatDate(currentPreviewImage?.createTime) }}</p>
+        </div>
+        <wd-button
+          class="close-btn"
+          type="error"
+          round
+          @click="closePreview"
+          custom-style="background: rgba(0, 0, 0, 0.6); border: none;"
+        >
+          <wd-icon name="close" color="white" />
+        </wd-button>
+      </div>
+    </wd-popup>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { listByTaskIdApi } from '@/api/laboratory/functional'
+import { useRoute } from "vue-router";
+import { buildFileUrl } from './utils'
+import _ from 'lodash'
+const toast = useToast()
+// Props
+const props = defineProps({
+  // 最大选择数量
+  maxSelection: {
+    type: Number,
+    default: 0 // 0表示无限制
+  },
+  // 默认选中的图片ID数组
+  defaultSelected: {
+    type: Array,
+    default: () => []
+  },
+  // API接口地址
+  apiUrl: {
+    type: String,
+    default: '/api/images'
+  },
+  // 图片base URL
+  baseUrl: {
+    type: String,
+    default: ''
+  }
+})
+
+// Emits
+const emit = defineEmits(['confirm', 'cancel'])
+const route = useRoute()
+// 响应式数据
+const dialogVisible = ref(false)
+const previewVisible = ref(false)
+const loading = ref(false)
+const imageList = ref([])
+const selectedImages = ref([...props.defaultSelected])
+const searchKeyword = ref('')
+const currentPreviewImage = ref(null)
+const isPreviewClosing = ref(false)
+
+// 分页数据
+const currentPage = ref(1)
+const pageSize = ref(20)
+const total = ref(0)
+
+// 计算属性
+const filteredImages = computed(() => {
+  return (imageList.value || [])?.map(file => ({
+    ...file,
+    fileUrl: file.fileUrl ? buildFileUrl(file.fileUrl) : buildFileUrl(file.name || `${file.path || ''}${file.name || ''}`)
+  }))
+})
+
+// 方法
+const openDialog = () => {
+  dialogVisible.value = true
+  selectedImages.value = [...props.defaultSelected]
+  loadImageList()
+}
+
+const closeDialog = () => {
+  dialogVisible.value = false
+}
+
+const loadImageList = async () => {
+  try {
+    loading.value = true
+    const taskId = route
+    const response = await listByTaskIdApi(route.query.id)
+    const data = _.cloneDeep(response)
+    // 根据实际API响应结构调整
+    imageList.value = data || []
+    total.value = imageList.value.length
+  } catch (error) {
+    console.error('加载图片列表失败:', error)
+  } finally {
+    loading.value = false
+  }
+}
+const isSelected = (imageId) => {
+  return selectedImages.value.includes(imageId)
+}
+
+const toggleSelection = (image) => {
+  const index = selectedImages.value.indexOf(image.id)
+  if (index > -1) {
+    selectedImages.value.splice(index, 1)
+  } else {
+    if (props.maxSelection && selectedImages.value.length >= props.maxSelection) {
+      toast.warning(`最多只能选择${props.maxSelection}张图片`)
+      return
+    }
+    selectedImages.value.push(image.id)
+  }
+}
+
+const handleCheckboxChange = (checked, image) => {
+  if (checked) {
+    if (props.maxSelection && selectedImages.value.length >= props.maxSelection) {
+      toast.warning(`最多只能选择${props.maxSelection}张图片`)
+      return
+    }
+    if (!selectedImages.value.includes(image.id)) {
+      selectedImages.value.push(image.id)
+    }
+  } else {
+    const index = selectedImages.value.indexOf(image.id)
+    if (index > -1) {
+      selectedImages.value.splice(index, 1)
+    }
+  }
+}
+
+const selectAll = () => {
+  const allIds = filteredImages.value.map(img => img.id)
+  if (props.maxSelection && allIds.length > props.maxSelection) {
+    toast.warning(`最多只能选择${props.maxSelection}张图片`)
+    selectedImages.value = allIds.slice(0, props.maxSelection)
+  } else {
+    selectedImages.value = [...allIds]
+  }
+}
+
+const clearSelection = () => {
+  selectedImages.value = []
+}
+
+const refreshList = () => {
+  currentPage.value = 1
+  loadImageList()
+}
+
+const handleSearch = () => {
+  // 搜索逻辑已在计算属性中处理
+}
+
+const handlePageChange = (page) => {
+  currentPage.value = page
+  loadImageList()
+}
+
+const handleSizeChange = (size) => {
+  pageSize.value = size
+  currentPage.value = 1
+  loadImageList()
+}
+
+const previewImage = (image) => {
+  currentPreviewImage.value = image
+  previewVisible.value = true
+  isPreviewClosing.value = false
+}
+
+const closePreview = () => {
+  isPreviewClosing.value = true
+  previewVisible.value = false
+  
+  // 延迟清空图片源,避免触发错误事件
+  setTimeout(() => {
+    currentPreviewImage.value = null
+    isPreviewClosing.value = false
+  }, 100)
+}
+
+const confirmSelection = () => {
+  const selectedImageData = imageList.value.filter(img => 
+    selectedImages.value.includes(img.id)
+  )
+  emit('confirm', selectedImageData)
+  dialogVisible.value = false
+  toast.success(`已选择${selectedImages.value.length}张图片`)
+}
+
+const getImageUrl = (fileUrl) => {
+  if (!fileUrl) return ''
+  if (fileUrl.startsWith('http')) return fileUrl
+  return `${props.baseUrl}${fileUrl}`
+}
+
+const formatDate = (timestamp) => {
+  if (!timestamp) return ''
+  const date = new Date(timestamp)
+  return date.toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+const onImageLoad = (event) => {
+  // 图片加载成功
+}
+
+const onImageError = (event) => {
+  if (previewVisible.value && !isPreviewClosing.value) {
+    console.error('图片加载失败')
+    // 可以设置默认图片
+    event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiBmaWxsPSIjRjVGNUY1Ii8+CjxwYXRoIGQ9Ik0zNSA2NUw1MCA0NUw2NSA2NUgzNVoiIGZpbGw9IiNEOUQ5RDkiLz4KPGNpcmNsZSBjeD0iNDAiIGN5PSIzNSIgcj0iNSIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K'
+  }
+}
+
+const onPreviewImageError = () => {
+  if (previewVisible.value && currentPreviewImage.value && !isPreviewClosing.value) {
+    toast.error('图片加载失败')
+  }
+}
+defineExpose({
+  openDialog
+})
+// 生命周期
+onMounted(() => {
+  // 组件挂载时可以预加载数据
+})
+</script>
+
+<style scoped>
+.dialog-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 15px 20px;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.dialog-header h3 {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 500;
+}
+
+.toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+  padding-bottom: 15px;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.toolbar-right {
+  display: flex;
+  gap: 10px;
+}
+
+.image-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 16px;
+  min-height: 300px;
+  max-height: 500px;
+  overflow-y: auto;
+  padding: 10px 0;
+}
+
+.image-item {
+  position: relative;
+  border: 2px solid transparent;
+  border-radius: 8px;
+  overflow: hidden;
+  transition: all 0.3s ease;
+  cursor: pointer;
+  background: #fff;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.image-item:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+  transform: translateY(-2px);
+}
+
+.image-item.selected {
+  border-color: #409eff;
+  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
+}
+
+.selection-overlay {
+  position: absolute;
+  top: 8px;
+  left: 8px;
+  z-index: 10;
+  background: rgba(255, 255, 255, 0.9);
+  border-radius: 4px;
+  padding: 2px;
+}
+
+.image-wrapper {
+  position: relative;
+  height: 150px;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f5f5f5;
+}
+
+.image-wrapper img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  transition: transform 0.3s ease;
+}
+
+.image-wrapper:hover img {
+  transform: scale(1.05);
+}
+
+.image-overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.4);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  opacity: 0;
+  transition: opacity 0.3s ease;
+}
+
+.image-wrapper:hover .image-overlay {
+  opacity: 1;
+}
+
+.preview-icon {
+  color: white;
+  font-size: 24px;
+}
+
+.image-info {
+  padding: 12px;
+}
+
+.image-name {
+  font-weight: 500;
+  color: #303133;
+  margin-bottom: 4px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.image-meta {
+  font-size: 12px;
+  color: #909399;
+}
+
+.empty-state {
+  grid-column: 1 / -1;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 200px;
+}
+
+.pagination-wrapper {
+  margin-top: 20px;
+  display: flex;
+  justify-content: center;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.selected-count {
+  color: #409eff;
+  font-weight: 500;
+}
+
+/* 预览弹窗样式 */
+.image-preview-dialog {
+  background: transparent;
+}
+
+.preview-container {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  max-width: 90vw;
+  max-height: 90vh;
+}
+
+.preview-image {
+  max-width: 100%;
+  max-height: 80vh;
+  object-fit: contain;
+  border-radius: 8px;
+}
+
+.preview-info {
+  background: rgba(0, 0, 0, 0.7);
+  color: white;
+  padding: 15px;
+  border-radius: 8px;
+  margin-top: 15px;
+  text-align: center;
+}
+
+.preview-info h3 {
+  margin: 0 0 8px 0;
+  font-size: 16px;
+}
+
+.preview-info p {
+  margin: 0;
+  font-size: 14px;
+  opacity: 0.8;
+}
+
+.close-btn {
+  position: absolute;
+  top: -10px;
+  right: -10px;
+  background: rgba(0, 0, 0, 0.6);
+  border: none;
+  color: white;
+}
+
+.close-btn:hover {
+  background: rgba(0, 0, 0, 0.8);
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .image-grid {
+    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+    gap: 12px;
+  }
+  
+  .toolbar {
+    flex-direction: column;
+    gap: 15px;
+  }
+  
+  .toolbar-right {
+    flex-wrap: wrap;
+    justify-content: center;
+  }
+  
+  .dialog-footer {
+    flex-direction: column;
+    gap: 15px;
+    text-align: center;
+  }
+}
+
+/* 加载状态样式 */
+.loading-overlay {
+  grid-column: 1 / -1;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 300px;
+}
+
+.image-grid.is-loading {
+  position: relative;
+}
+</style>

+ 332 - 0
src/components/SpreadDesigner/formatTime.ts

@@ -0,0 +1,332 @@
+import dayjs from 'dayjs'
+
+/**
+ * 日期快捷选项适用于 el-date-picker
+ */
+export const defaultShortcuts = [
+  {
+    text: '今天',
+    value: () => {
+      return new Date()
+    }
+  },
+  {
+    text: '昨天',
+    value: () => {
+      const date = new Date()
+      date.setTime(date.getTime() - 3600 * 1000 * 24)
+      return [date, date]
+    }
+  },
+  {
+    text: '最近七天',
+    value: () => {
+      const date = new Date()
+      date.setTime(date.getTime() - 3600 * 1000 * 24 * 7)
+      return [date, new Date()]
+    }
+  },
+  {
+    text: '最近 30 天',
+    value: () => {
+      const date = new Date()
+      date.setTime(date.getTime() - 3600 * 1000 * 24 * 30)
+      return [date, new Date()]
+    }
+  },
+  {
+    text: '本月',
+    value: () => {
+      const date = new Date()
+      date.setDate(1) // 设置为当前月的第一天
+      return [date, new Date()]
+    }
+  },
+  {
+    text: '今年',
+    value: () => {
+      const date = new Date()
+      return [new Date(`${date.getFullYear()}-01-01`), date]
+    }
+  }
+]
+
+/**
+ * 时间日期转换
+ * @param date 当前时间,new Date() 格式
+ * @param format 需要转换的时间格式字符串
+ * @description format 字符串随意,如 `YYYY-MM、YYYY-MM-DD`
+ * @description format 季度:"YYYY-MM-DD HH:mm:ss QQQQ"
+ * @description format 星期:"YYYY-MM-DD HH:mm:ss WWW"
+ * @description format 几周:"YYYY-MM-DD HH:mm:ss ZZZ"
+ * @description format 季度 + 星期 + 几周:"YYYY-MM-DD HH:mm:ss WWW QQQQ ZZZ"
+ * @returns 返回拼接后的时间字符串
+ */
+export function formatDate(date: Date, format?: string): string {
+  // 日期不存在,则返回空
+  if (!date) {
+    return ''
+  }
+  // 日期存在,则进行格式化
+  return date ? dayjs(date).format(format ?? 'YYYY-MM-DD HH:mm:ss') : ''
+}
+
+/**
+ * 获取当前的日期+时间
+ */
+export function getNowDateTime() {
+  return dayjs()
+}
+
+/**
+ * 获取当前日期是第几周
+ * @param dateTime 当前传入的日期值
+ * @returns 返回第几周数字值
+ */
+export function getWeek(dateTime: Date): number {
+  const temptTime = new Date(dateTime.getTime())
+  // 周几
+  const weekday = temptTime.getDay() || 7
+  // 周1+5天=周六
+  temptTime.setDate(temptTime.getDate() - weekday + 1 + 5)
+  let firstDay = new Date(temptTime.getFullYear(), 0, 1)
+  const dayOfWeek = firstDay.getDay()
+  let spendDay = 1
+  if (dayOfWeek != 0) spendDay = 7 - dayOfWeek + 1
+  firstDay = new Date(temptTime.getFullYear(), 0, 1 + spendDay)
+  const d = Math.ceil((temptTime.valueOf() - firstDay.valueOf()) / 86400000)
+  return Math.ceil(d / 7)
+}
+
+/**
+ * 将时间转换为 `几秒前`、`几分钟前`、`几小时前`、`几天前`
+ * @param param 当前时间,new Date() 格式或者字符串时间格式
+ * @param format 需要转换的时间格式字符串
+ * @description param 10秒:  10 * 1000
+ * @description param 1分:   60 * 1000
+ * @description param 1小时: 60 * 60 * 1000
+ * @description param 24小时:60 * 60 * 24 * 1000
+ * @description param 3天:   60 * 60* 24 * 1000 * 3
+ * @returns 返回拼接后的时间字符串
+ */
+export function formatPast(param: string | Date, format = 'YYYY-MM-DD HH:mm:ss'): string {
+  // 传入格式处理、存储转换值
+  let t: any, s: number
+  // 获取js 时间戳
+  let time: number = new Date().getTime()
+  // 是否是对象
+  typeof param === 'string' || 'object' ? (t = new Date(param).getTime()) : (t = param)
+  // 当前时间戳 - 传入时间戳
+  time = Number.parseInt(`${time - t}`)
+  if (time < 10000) {
+    // 10秒内
+    return '刚刚'
+  } else if (time < 60000 && time >= 10000) {
+    // 超过10秒少于1分钟内
+    s = Math.floor(time / 1000)
+    return `${s}秒前`
+  } else if (time < 3600000 && time >= 60000) {
+    // 超过1分钟少于1小时
+    s = Math.floor(time / 60000)
+    return `${s}分钟前`
+  } else if (time < 86400000 && time >= 3600000) {
+    // 超过1小时少于24小时
+    s = Math.floor(time / 3600000)
+    return `${s}小时前`
+  } else if (time < 259200000 && time >= 86400000) {
+    // 超过1天少于3天内
+    s = Math.floor(time / 86400000)
+    return `${s}天前`
+  } else {
+    // 超过3天
+    const date = typeof param === 'string' || 'object' ? new Date(param) : param
+    return formatDate(date, format)
+  }
+}
+
+/**
+ * 时间问候语
+ * @param param 当前时间,new Date() 格式
+ * @description param 调用 `formatAxis(new Date())` 输出 `上午好`
+ * @returns 返回拼接后的时间字符串
+ */
+export function formatAxis(param: Date): string {
+  const hour: number = new Date(param).getHours()
+  if (hour < 6) return '凌晨好'
+  else if (hour < 9) return '早上好'
+  else if (hour < 12) return '上午好'
+  else if (hour < 14) return '中午好'
+  else if (hour < 17) return '下午好'
+  else if (hour < 19) return '傍晚好'
+  else if (hour < 22) return '晚上好'
+  else return '夜里好'
+}
+
+/**
+ * 将毫秒,转换成时间字符串。例如说,xx 分钟
+ *
+ * @param ms 毫秒
+ * @returns {string} 字符串
+ */
+export function formatPast2(ms: number): string {
+  const day = Math.floor(ms / (24 * 60 * 60 * 1000))
+  const hour = Math.floor(ms / (60 * 60 * 1000) - day * 24)
+  const minute = Math.floor(ms / (60 * 1000) - day * 24 * 60 - hour * 60)
+  const second = Math.floor(ms / 1000 - day * 24 * 60 * 60 - hour * 60 * 60 - minute * 60)
+  if (day > 0) {
+    return day + ' 天' + hour + ' 小时 ' + minute + ' 分钟'
+  }
+  if (hour > 0) {
+    return hour + ' 小时 ' + minute + ' 分钟'
+  }
+  if (minute > 0) {
+    return minute + ' 分钟'
+  }
+  if (second > 0) {
+    return second + ' 秒'
+  } else {
+    return 0 + ' 秒'
+  }
+}
+
+/**
+ * 设置起始日期,时间为00:00:00
+ * @param param 传入日期
+ * @returns 带时间00:00:00的日期
+ */
+export function beginOfDay(param: Date): Date {
+  return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 0, 0, 0)
+}
+
+/**
+ * 设置结束日期,时间为23:59:59
+ * @param param 传入日期
+ * @returns 带时间23:59:59的日期
+ */
+export function endOfDay(param: Date): Date {
+  return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 23, 59, 59)
+}
+
+/**
+ * 计算两个日期间隔天数
+ * @param param1 日期1
+ * @param param2 日期2
+ */
+export function betweenDay(param1: Date, param2: Date): number {
+  param1 = convertDate(param1)
+  param2 = convertDate(param2)
+  // 计算差值
+  return Math.floor((param2.getTime() - param1.getTime()) / (24 * 3600 * 1000))
+}
+
+/**
+ * 日期计算
+ * @param param1 日期
+ * @param param2 添加的时间
+ */
+export function addTime(param1: Date, param2: number): Date {
+  param1 = convertDate(param1)
+  return new Date(param1.getTime() + param2)
+}
+
+/**
+ * 日期转换
+ * @param param 日期
+ */
+export function convertDate(param: Date | string): Date {
+  if (typeof param === 'string') {
+    return new Date(param)
+  }
+  return param
+}
+
+/**
+ * 指定的两个日期, 是否为同一天
+ * @param a 日期 A
+ * @param b 日期 B
+ */
+export function isSameDay(a: dayjs.ConfigType, b: dayjs.ConfigType): boolean {
+  if (!a || !b) return false
+
+  const aa = dayjs(a)
+  const bb = dayjs(b)
+  return aa.year() == bb.year() && aa.month() == bb.month() && aa.day() == bb.day()
+}
+
+/**
+ * 获取一天的开始时间、截止时间
+ * @param date 日期
+ * @param days 天数
+ */
+export function getDayRange(
+  date: dayjs.ConfigType,
+  days: number
+): [dayjs.ConfigType, dayjs.ConfigType] {
+  const day = dayjs(date).add(days, 'd')
+  return getDateRange(day, day)
+}
+
+/**
+ * 获取最近7天的开始时间、截止时间
+ */
+export function getLast7Days(): [dayjs.ConfigType, dayjs.ConfigType] {
+  const lastWeekDay = dayjs().subtract(7, 'd')
+  const yesterday = dayjs().subtract(1, 'd')
+  return getDateRange(lastWeekDay, yesterday)
+}
+
+/**
+ * 获取最近30天的开始时间、截止时间
+ */
+export function getLast30Days(): [dayjs.ConfigType, dayjs.ConfigType] {
+  const lastMonthDay = dayjs().subtract(30, 'd')
+  const yesterday = dayjs().subtract(1, 'd')
+  return getDateRange(lastMonthDay, yesterday)
+}
+
+/**
+ * 获取最近1年的开始时间、截止时间
+ */
+export function getLast1Year(): [dayjs.ConfigType, dayjs.ConfigType] {
+  const lastYearDay = dayjs().subtract(1, 'y')
+  const yesterday = dayjs().subtract(1, 'd')
+  return getDateRange(lastYearDay, yesterday)
+}
+
+/**
+ * 获取指定日期的开始时间、截止时间
+ * @param beginDate 开始日期
+ * @param endDate 截止日期
+ */
+export function getDateRange(
+  beginDate: dayjs.ConfigType,
+  endDate: dayjs.ConfigType
+): [string, string] {
+  return [
+    dayjs(beginDate).startOf('d').format('YYYY-MM-DD HH:mm:ss'),
+    dayjs(endDate).endOf('d').format('YYYY-MM-DD HH:mm:ss')
+  ]
+}
+
+/**
+ * 格式化数组格式的日期
+ * @param dateArr 日期数组 [年, 月, 日]
+ * @returns 格式化后的日期字符串,格式:YYYY-MM-DD
+ */
+export function formatArrayDate(dateArr: number[] | null): string {
+  if (!dateArr || dateArr.length !== 3) {
+    return ''
+  }
+  const [year, month, day] = dateArr
+  return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
+}
+
+
+export function formatDate1(date: Date, format?: string): string {
+  // 日期不存在,则返回空
+  if (!date) {
+    return ''
+  }
+  // 日期存在,则进行格式化
+  return date ? dayjs(date).format(format ?? 'YYYY-MM-DD') : ''
+}

+ 14 - 0
src/components/SpreadDesigner/index.api.ts

@@ -0,0 +1,14 @@
+// 葡萄城相关接口
+
+import { httpGet, httpPost } from '@/utils/http'
+
+// 获得个人自定义快捷键列表
+export const getGrapeCityUserShortcutList = (params) => {
+  return httpGet('/pressure/grape-city-user-shortcut/list', params)
+}
+
+// 批量更新个人自定义快捷键
+export const grapeCityUserShortcutBatchUpdate = (data) => {
+  return httpPost('/pressure/grape-city-user-shortcut/batch-update', data)
+}
+

Plik diff jest za duży
+ 3902 - 0
src/components/SpreadDesigner/index.vue


+ 126 - 0
src/components/SpreadDesigner/is.ts

@@ -0,0 +1,126 @@
+// copy to vben-admin
+
+const toString = Object.prototype.toString
+
+export const is = (val: unknown, type: string) => {
+  return toString.call(val) === `[object ${type}]`
+}
+
+export const isDef = <T = unknown>(val?: T): val is T => {
+  return typeof val !== 'undefined'
+}
+
+export const isUnDef = <T = unknown>(val?: T): val is T => {
+  return !isDef(val)
+}
+
+export const isObject = (val: any): val is Record<any, any> => {
+  return val !== null && is(val, 'Object')
+}
+
+export const isEmpty = (val: any): boolean => {
+  if (val === null || val === undefined || typeof val === 'undefined') {
+    return true
+  }
+  if (isArray(val) || isString(val)) {
+    return val.length === 0
+  }
+
+  if (val instanceof Map || val instanceof Set) {
+    return val.size === 0
+  }
+
+  if (isObject(val)) {
+    return Object.keys(val).length === 0
+  }
+
+  return false
+}
+
+export const isDate = (val: unknown): val is Date => {
+  return is(val, 'Date')
+}
+
+export const isNull = (val: unknown): val is null => {
+  return val === null
+}
+
+export const isNullAndUnDef = (val: unknown): val is null | undefined => {
+  return isUnDef(val) && isNull(val)
+}
+
+export const isNullOrUnDef = (val: unknown): val is null | undefined => {
+  return isUnDef(val) || isNull(val)
+}
+
+export const isNumber = (val: unknown): val is number => {
+  return is(val, 'Number')
+}
+
+export const isPromise = <T = any>(val: unknown): val is Promise<T> => {
+  return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch)
+}
+
+export const isString = (val: unknown): val is string => {
+  return is(val, 'String')
+}
+
+export const isFunction = (val: unknown): val is Function => {
+  return typeof val === 'function'
+}
+
+export const isBoolean = (val: unknown): val is boolean => {
+  return is(val, 'Boolean')
+}
+
+export const isRegExp = (val: unknown): val is RegExp => {
+  return is(val, 'RegExp')
+}
+
+export const isArray = (val: any): val is Array<any> => {
+  return val && Array.isArray(val)
+}
+
+export const isWindow = (val: any): val is Window => {
+  return typeof window !== 'undefined' && is(val, 'Window')
+}
+
+export const isElement = (val: unknown): val is Element => {
+  return isObject(val) && !!val.tagName
+}
+
+export const isMap = (val: unknown): val is Map<any, any> => {
+  return is(val, 'Map')
+}
+
+export const isServer = typeof window === 'undefined'
+
+export const isClient = !isServer
+
+export const isUrl = (path: string): boolean => {
+  // fix:修复hash路由无法跳转的问题
+  const reg =
+  /(((^https?:(?:\/\/)?)(?:[-:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%#\/.\w-_]*)?\??(?:[-\+=&%@.\w_]*)#?(?:[\w]*))?)$/
+  return reg.test(path)
+}
+
+export const isDark = (): boolean => {
+  return window.matchMedia('(prefers-color-scheme: dark)').matches
+}
+
+// 是否是图片链接
+export const isImgPath = (path: string): boolean => {
+  return /(https?:\/\/|data:image\/).*?\.(png|jpg|jpeg|gif|svg|webp|ico)/gi.test(path)
+}
+
+export const isEmptyVal = (val: any): boolean => {
+  return val === '' || val === null || val === undefined
+}
+
+
+// 校验是否是有效的 MD5 哈希值
+export function isMD5(str) {
+  // 判断长度是否为32位,且只包含小写字母a-f和数字0-9
+  const md5Regex = /^[a-f0-9]{32}$/
+  return md5Regex.test(str)
+}

+ 152 - 0
src/components/SpreadDesigner/spreadStyles.module.scss

@@ -0,0 +1,152 @@
+.field_data_list {
+  flex-basis: 25%;
+}
+.field_data_list_header {
+  position: sticky;
+  left: 0;
+  top: 0;
+  z-index: 3;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  height: 60px;
+  padding: 10px;
+  color: rgba(0, 0, 0, 0.6);
+  font: bold 12pt Calibri, sans-serif;
+  background: linear-gradient(to bottom, rgb(228, 229, 232) 15%, rgb(211, 211, 211) 85%);
+  box-sizing: border-box;
+}
+.field_data_list_container {
+  padding: 10px;
+}
+.field_data_list_container_title {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.rotate_icon {
+  font-size: 16px;
+  cursor: pointer;
+  animation: rotateAndChangeColor 1.2s cubic-bezier(0.4, 0.0, 0.2, 1) infinite;
+  color: #007aff; /* 点击时的颜色 */
+  transition: color 0.6s cubic-bezier(0.4, 0.0, 0.2, 1);
+}
+@keyframes rotateAndChangeColor {
+  0% {
+    transform: rotate(0deg);
+    color: #007aff;
+  }
+  100% {
+    transform: rotate(360deg);
+    color: #007aff;
+  }
+}
+.treeList {
+
+}
+.treeNodeWrapper {
+  position: relative;
+  &::before {
+    content: "";
+    position: absolute;
+    top: 0;
+    left: 6px;
+    width: 1px;
+    height: 100%;
+    border-left: 1px dashed #999;
+  }
+  &::after {
+    content: "";
+    position: absolute;
+    top: 20px;
+    left: 6px;
+    width: 12px;
+    border-bottom: 1px dashed #999;
+  }
+  &:last-child::before {
+    height: 20px;
+  }
+}
+.treeNode {
+  display: flex;
+  align-items: flex-start;
+  padding: 5px 0;
+  padding-left: 20px;
+}
+.tree_node_title {
+  min-width: 100px;
+  padding-left: 4px;
+  font-size: 12px;
+  line-height: 30px;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
+.tree_node_title_inner {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  padding-left: 8px;
+  gap: 2px 10px;
+}
+
+.icon_add {
+  background-image: url("@/assets/imgs/addIcon.png");
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: 16px;
+}
+
+.icon_setting {
+  background-image: url("@/assets/imgs/setIcon.png");
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: 16px;
+}
+
+.icon_del {
+  background-image: url("@/assets/imgs/delIcon.png");
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: 16px;
+}
+
+.icon_text {
+  background-image: url("@/assets/imgs/textIcon.png");
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: 16px;
+}
+
+.icon_table {
+  background-image: url("@/assets/imgs/tableIcon.png");
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: 16px;
+}
+
+.iconPre {
+  min-width: 20px;
+  height: 30px;
+}
+
+.treeChildren {
+  // position: relative;
+  margin-left: 20px;
+  // &::before {
+  //   content: "";
+  //   position: absolute;
+  //   top: 0;
+  //   left: 6px;
+  //   width: 1px;
+  //   height: 100%;
+  //   border-left: 1px dashed #999;
+  // }
+  // &::after {
+  //   content: "";
+  //   position: absolute;
+  //   top: 20px;
+  //   left: 6px;
+  //   width: 12px;
+  //   border-bottom: 1px dashed #999;
+  // }
+}

+ 495 - 0
src/components/SpreadDesigner/tools/CreateBatchUploadImages.ts

@@ -0,0 +1,495 @@
+import * as GC from '@grapecity-software/spread-sheets'
+import '@grapecity-software/spread-sheets-designer'
+
+// 定义所需类型接口
+interface IDesigner {
+  getWorkbook(): ISpread
+}
+
+interface ISpread {
+  sheets: ISheet[]
+  getSheet(sheetIndex: number): ISheet | null
+  getActiveSheet(): ISheet
+  commandManager(): any
+  addSheet(name: string, sheet: any): any
+  removeSheet(index: number): void
+}
+
+interface ISheet {
+  name: string
+  rowCount: number
+  columnCount: number
+  floatingObjects: FloatingObjects
+  shapes: Shapes
+  setSelection: any
+  getBindingPath(row: number, col: number): string | null
+  getCellRect(row: number, col: number): ICellRect
+  getDataSource(): any
+  getSpans(): { row: number; col: number }[]
+  printInfo(): any
+  toJSON?(): any // 可选方法
+}
+
+interface ICellRect {
+  x: number
+  y: number
+  width: number
+  height: number
+}
+
+interface IBindingImageInfo {
+  row: number
+  col: number
+  bindingPath: string
+  width: number
+  height: number
+  x: number
+  y: number
+}
+
+interface IImageProperty {
+  url: string
+  width: number
+  height: number
+  groupHeight: number
+  description?: string
+}
+
+interface FloatingObjects {
+  all(): any[]
+  clear(): void
+}
+
+interface Shapes {
+  all(): any[]
+  clear(): void
+  add(text: string, shapeType: any, x: number, y: number, width: number, height: number): any
+  addPictureShape(
+    name: string,
+    src: string,
+    x: number,
+    y: number,
+    width: number,
+    height: number
+  ): any
+  group(items: any[]): any
+}
+
+const DEFAULT_BINDING_PATH = 'images'
+const GAP_WIDTH = 20
+const TEXT_SHAPE_HEIGHT = 32
+
+export default class CreateBatchUploadImages {
+  private designer: IDesigner | null = null
+  private spread: ISpread | null = null
+  private bindingPathName: string
+  private templateSheet: ISheet | null = null
+  private gapWidth: number
+  private textShapeHeight: number
+  private zoomFactor: number
+
+  constructor(bindingPathName?: string) {
+    this.bindingPathName = bindingPathName || DEFAULT_BINDING_PATH
+    this.gapWidth = GAP_WIDTH
+    this.textShapeHeight = TEXT_SHAPE_HEIGHT
+    try {
+      this.designer = this.getDesigner()
+      this.spread = this.getSpread()
+      this.templateSheet = this.getActiveSheet()
+      this.zoomFactor = this.templateSheet?.zoom() || 1
+    } catch (error) {
+      console.error('初始化失败:', error)
+    }
+  }
+
+  /**
+   * 获取Spread工作簿实例
+   * @returns {ISpread} 工作簿对象
+   */
+  public getSpread(): ISpread {
+    if (!this.spread) {
+      if (!this.designer) {
+        throw new Error('Designer 未初始化')
+      }
+      this.spread = this.designer.getWorkbook()
+    }
+    return this.spread
+  }
+
+  /**
+   * 获取设计器Dom
+   */
+  public getDesigner(): IDesigner {
+    if (this.designer) {
+      return this.designer
+    }
+
+    const designerElement =
+      document.getElementById('gc-designer-container') || 'gc-designer-container'
+    const gcDesigner = GC.Spread.Sheets.Designer.findControl(designerElement)
+
+    if (!gcDesigner) {
+      throw new Error('Designer control not found')
+    }
+
+    this.designer = gcDesigner
+
+    this.setSheetsZoom()
+    return gcDesigner
+  }
+
+  /**
+   * 获取当前使用的工作表
+   */
+  public getActiveSheet(): ISheet {
+    const spread = this.getSpread()
+    return spread.getActiveSheet()
+  }
+
+  /**
+   * 设置sheet的缩放比例
+   * **/
+  public setSheetsZoom() {
+    const spread = this.getSpread()
+    for (const sheet of spread.sheets) {
+      sheet?.zoom(1)
+      sheet?.showCell(0, 0)
+    }
+  }
+  /**
+   * 获取绑定图片的单元格信息
+   * @returns {IBindingImageInfo | null} 图片绑定信息
+   */
+  getBindingPathByImages(): IBindingImageInfo | null {
+    this.getSpread()
+    const sheet = this.getActiveSheet()
+    const spans = sheet.getSpans()
+
+    for (const item of spans) {
+      const bindingPath = sheet.getBindingPath(item.row, item.col)
+      if (bindingPath === this.bindingPathName) {
+        const cellRect = sheet.getCellRect(item.row, item.col)
+        return {
+          row: item.row,
+          col: item.col,
+          bindingPath: this.bindingPathName,
+          width: cellRect.width,
+          height: cellRect.height,
+          x: cellRect.x,
+          y: cellRect.y
+        }
+      }
+    }
+
+    console.warn(`未找到绑定路径为 ${this.bindingPathName} 的单元格`)
+    return null
+  }
+
+  /**
+   * 将图片地址转换为 base64
+   */
+  getImageBase64AndProperty(src: string, targetWidth: number): Promise<IImageProperty> {
+    return new Promise((resolve, reject) => {
+      const image = new Image()
+      image.crossOrigin = 'Anonymous'
+      image.src = src
+
+      image.onload = () => {
+        try {
+          const canvas = document.createElement('canvas')
+          const ctx = canvas.getContext('2d')
+          if (!ctx) {
+            throw new Error('无法获取2D上下文')
+          }
+
+          canvas.width = image.width
+          canvas.height = image.height
+          ctx.drawImage(image, 0, 0, canvas.width, canvas.height)
+
+          const scaleFactor = targetWidth / image.width
+          const flowWidth = targetWidth
+          const flowHeight = scaleFactor * image.height
+
+          resolve({
+            url: canvas.toDataURL('image/png'),
+            height: flowHeight,
+            width: flowWidth,
+            groupHeight: flowHeight + this.gapWidth + this.textShapeHeight
+          })
+        } catch (error) {
+          reject(`图片处理失败: ${error}`)
+        }
+      }
+
+      image.onerror = () => {
+        reject('图像加载失败')
+      }
+    })
+  }
+
+  /**
+   * 创建文本浮层对象
+   */
+  createTextFloatingObject(
+    text: string,
+    name: string,
+    x: number,
+    y: number,
+    width: number,
+    height: number,
+    sheet: ISheet
+  ) {
+    const textShape = sheet.shapes.add(
+      text,
+      GC.Spread.Sheets.Shapes.AutoShapeType.rectangle,
+      x,
+      y,
+      width,
+      height
+    )
+
+    textShape.isTextBox(true)
+    textShape.text(text)
+
+    const shapeStyle = textShape.style()
+    shapeStyle.fill.color = 'white'
+    shapeStyle.fill.transparency = 1
+    shapeStyle.textEffect.color = 'black'
+    shapeStyle.line.color = '#ffffff'
+    shapeStyle.line.transparency = 1
+    shapeStyle.textFrame.hAlign = GC.Spread.Sheets.HorizontalAlign.center
+    shapeStyle.textFrame.vAlign = GC.Spread.Sheets.VerticalAlign.center
+
+    textShape.style(shapeStyle)
+    return textShape
+  }
+
+  /**
+   * 创建图片浮层对象
+   */
+  createImageFloatingObject(
+    src: string,
+    name: string,
+    x: number,
+    y: number,
+    width: number,
+    height: number,
+    sheet: ISheet
+  ) {
+    return sheet.shapes.addPictureShape(name, src, x, y, width, height)
+  }
+
+  /**
+   * 合并浮层对象
+   */
+  groupByTextAndImageFloatingObject(
+    imageFloatingObject: any,
+    textFloatingObject: any,
+    sheet: ISheet
+  ) {
+    return sheet.shapes.group([textFloatingObject, imageFloatingObject])
+  }
+
+  /**
+   * 获取所有浮动对象
+   */
+  getAllFloatingObjects(sheet: ISheet) {
+    return [...(sheet.floatingObjects?.all() || []), ...(sheet.shapes?.all() || [])]
+  }
+
+  /**
+   * 删除所有浮动对象
+   */
+  deleteAllFloatingObjects(sheet: ISheet) {
+    if (sheet.floatingObjects?.all().length > 0) {
+      sheet.floatingObjects.clear()
+    }
+    if (sheet.shapes?.all().length > 0) {
+      sheet.shapes.clear()
+    }
+  }
+
+  /**
+   * 删除指定工作表
+   */
+  deleteSheet(sheetIndex: number) {
+    this.spread?.removeSheet(sheetIndex)
+  }
+
+  /**
+   * 复制工作表到新工作表
+   */
+  copySheetToNewSheet(sheetIndex: number, dataSource: any) {
+    const spread = this.getSpread()
+    const sourceSheet = this.getActiveSheet()
+
+    if (!sourceSheet.toJSON) {
+      console.error('当前工作表不支持序列化')
+      return
+    }
+
+    const sourceSheetJson = JSON.parse(JSON.stringify(sourceSheet.toJSON()))
+    const newSheetName = `CopiedSheet${sheetIndex}`
+    const newSheet = new GC.Spread.Sheets.Worksheet(newSheetName)
+    spread.addSheet(spread.sheets.length, newSheet)
+    sourceSheetJson.name = newSheetName
+    newSheet.fromJSON(sourceSheetJson)
+    // 复制数据源的值
+    newSheet?.setDataSource(dataSource)
+  }
+
+  /**
+   * 根据列数获取图片宽度
+   */
+  getImageWidth(columnCount: number): number {
+    const containerInfo = this.getBindingPathByImages()
+    if (!containerInfo) return 100 // 默认宽度
+
+    return (
+      (containerInfo.width - (columnCount - 1) * this.gapWidth - this.gapWidth * 2) / columnCount
+    )
+  }
+
+  /**
+   * 计算图片位置
+   */
+  cacheLastPosition(w: number, h: number, col: number, rowIndex: number): { x: number; y: number } {
+    const containerInfo = this.getBindingPathByImages()
+    if (!containerInfo) return { x: 0, y: 0 }
+
+    const startX = this.gapWidth
+    const startY = containerInfo.y
+
+    const x = startX + col * (w + this.gapWidth)
+    const y = startY + rowIndex * (h + this.gapWidth)
+
+    return { x, y }
+  }
+
+  /**
+   * 渲染图片到工作表
+   */
+  async renderImage(fileList: IImageProperty[][], sheet: ISheet) {
+    for (let colIndex = 0; colIndex < fileList.length; colIndex++) {
+      const columnFiles = fileList[colIndex]
+
+      for (let rowIndex = 0; rowIndex < columnFiles.length; rowIndex++) {
+        const file = columnFiles[rowIndex]
+        const lastFile = !rowIndex ? {} : columnFiles[rowIndex - 1]
+
+        // 创建图片和文本浮层
+        const imageObj = this.createImageFloatingObject(
+          file.url,
+          `image-col${colIndex}-row${rowIndex}`,
+          0,
+          0,
+          file.width,
+          file.height,
+          sheet
+        )
+
+        const textObj = this.createTextFloatingObject(
+          file.description || `Image ${colIndex}-${rowIndex}`,
+          `text-col${colIndex}-row${rowIndex}`,
+          0,
+          file.height + this.gapWidth,
+          file.width,
+          this.textShapeHeight,
+          sheet
+        )
+
+        // 组合并定位
+        const group = this.groupByTextAndImageFloatingObject(imageObj, textObj, sheet)
+        const position = this.cacheLastPosition(
+          file.width,
+          lastFile?.groupHeight || 0,
+          colIndex,
+          rowIndex
+        )
+        group.x(position.x)
+        group.y(position.y)
+      }
+    }
+  }
+
+  /**
+   * 主渲染方法
+   */
+  async render(fileList: IImageProperty[]) {
+    const containerInfo = this.getBindingPathByImages()
+    if (!containerInfo) {
+      console.error('无法渲染图片:未找到容器信息')
+      return
+    }
+
+    const groupedFiles = this.groupImages(
+      fileList,
+      containerInfo.width,
+      containerInfo.height,
+      this.gapWidth,
+      2
+    )
+    const activeDataSource = this.templateSheet?.getDataSource()
+    for (let i = 0; i < groupedFiles.length; i++) {
+      if (i > 0) {
+        this.copySheetToNewSheet(i, activeDataSource)
+      }
+
+      const sheet = this.spread?.sheets[i]
+      if (!sheet) continue
+
+      this.deleteAllFloatingObjects(sheet)
+      await this.renderImage(groupedFiles[i], sheet)
+    }
+  }
+
+  /**
+   * 图片分组算法
+   */
+  private groupImages(
+    files: IImageProperty[],
+    containerWidth: number,
+    containerHeight: number,
+    margin: number,
+    columns: number
+  ): IImageProperty[][][] {
+    const maxHeight = containerHeight - margin * 2
+    const result: IImageProperty[][][] = []
+    let currentPage: IImageProperty[][] = Array.from({ length: columns }, () => [])
+    let columnHeights = new Array(columns).fill(0)
+
+    for (const file of files) {
+      // 调整过大的图片
+      const adjustedHeight = Math.min(file.groupHeight, maxHeight)
+      const adjustedFile = { ...file, groupHeight: adjustedHeight }
+
+      let placed = false
+
+      // 尝试放入当前页
+      for (let col = 0; col < columns; col++) {
+        if (columnHeights[col] + adjustedFile.groupHeight <= maxHeight) {
+          currentPage[col].push(adjustedFile)
+          columnHeights[col] += adjustedFile.groupHeight
+          placed = true
+          break
+        }
+      }
+
+      // 创建新页
+      if (!placed) {
+        result.push(currentPage)
+        currentPage = Array.from({ length: columns }, () => [])
+        columnHeights = new Array(columns).fill(0)
+        currentPage[0].push(adjustedFile)
+        columnHeights[0] = adjustedFile.groupHeight
+      }
+    }
+
+    // 添加最后一页
+    if (currentPage.some((column) => column.length > 0)) {
+      result.push(currentPage)
+    }
+
+    return result
+  }
+}

+ 639 - 0
src/components/SpreadDesigner/tools/DynamicFormManager.ts

@@ -0,0 +1,639 @@
+import GC from '@/components/SpreadDesigner/tools/gc'
+
+export interface FormConfig {
+  startRow: number;
+  templateRow: number;
+  dataArray: any[];
+  rowCount: number;
+  endRow?: number;
+  tableName?: string;
+  tableInstance?: any;
+  bindingPath?: string;
+  sheet?: any; // Sheet 引用
+}
+
+export interface FormInfo {
+  formName: string;
+  formConfig: FormConfig;
+  index: number;
+  currentRow: number;
+}
+
+export default class DynamicFormManager {
+  private spread: any;
+  private sheet: any;
+  private formConfigs: Map<string, FormConfig>;
+  private autoScanEnabled: boolean = true;
+
+  constructor(spread: any) {
+    this.spread = spread;
+    this.sheet = spread.getActiveSheet();
+    this.formConfigs = new Map();
+  }
+
+  /**
+   * 刷新 Table 的数据源
+   */
+  private refreshTableDataSource(formConfig: FormConfig): void {
+    if (!formConfig.tableInstance || !formConfig.bindingPath || !formConfig.sheet) return;
+    
+    try {
+      const sheet = formConfig.sheet;
+      const table = formConfig.tableInstance;
+      const currentDataSource = sheet.getDataSource();
+      
+      if (currentDataSource) {
+        const sourceData = currentDataSource.getSource();
+        
+        // 更新绑定路径对应的数据数组
+        sourceData[formConfig.bindingPath] = [...formConfig.dataArray];
+        
+        // 强制刷新步骤
+        this.spread.suspendPaint();
+        
+        // 清空并重新设置数据源
+        sheet.setDataSource(null);
+        sheet.setDataSource(currentDataSource);
+        
+        // 更新 Table 的 range 以匹配新的行数
+        const range = table.range();
+        const newRange = new GC.Spread.Sheets.Range(
+          range.row, 
+          range.col, 
+          formConfig.dataArray.length + 1, // +1 包含表头
+          range.colCount
+        );
+        table.range(newRange);
+        
+        this.spread.resumePaint();
+        
+        // console.log(`✅ 已刷新 Table ${formConfig.tableName}, 新行数: ${formConfig.dataArray.length}`);
+      }
+    } catch (error) {
+      console.error('刷新 Table 数据源失败:', error);
+      this.spread.resumePaint();
+    }
+  }
+
+  /**
+   * 创建新数据对象(空数据)
+   */
+  private createNewDataObject(referenceData: any, defaultData: any): any {
+    const newData: any = {};
+    
+    if (referenceData && Object.keys(referenceData).length > 0) {
+      for (const key in referenceData) {
+        if (defaultData.hasOwnProperty(key)) {
+          newData[key] = defaultData[key];
+        } else {
+          const value = referenceData[key];
+          if (typeof value === 'number') {
+            newData[key] = 0;
+          } else if (typeof value === 'string') {
+            newData[key] = '';
+          } else if (Array.isArray(value)) {
+            newData[key] = [];
+          } else if (value === null) {
+            newData[key] = null;
+          } else if (typeof value === 'object') {
+            newData[key] = {};
+          } else {
+            newData[key] = '';
+          }
+        }
+      }
+    } else {
+      return { ...defaultData };
+    }
+    
+    return newData;
+  }
+
+  /**
+   * 复制行格式和公式
+   */
+  private copyRowFormat(sourceRow: number, targetRow: number): void {
+    const colCount = this.sheet.getColumnCount();
+    
+    for (let col = 0; col < colCount; col++) {
+      const style = this.sheet.getStyle(sourceRow, col);
+      if (style) {
+        this.sheet.setStyle(targetRow, col, style);
+      }
+      
+      const formula = this.sheet.getFormula(sourceRow, col);
+      if (formula) {
+        this.sheet.setFormula(targetRow, col, formula);
+      }
+      
+      const validation = this.sheet.getDataValidator(sourceRow, col);
+      if (validation) {
+        this.sheet.setDataValidator(targetRow, col, validation);
+      }
+      
+      const cellType = this.sheet.getCellType(sourceRow, col);
+      if (cellType) {
+        this.sheet.setCellType(targetRow, col, cellType);
+      }
+    }
+  }
+
+  /**
+   * 更新后续表单的起始行
+   */
+  private updateSubsequentForms(currentFormName: string, offset: number): void {
+    let foundCurrent = false;
+    
+    for (const [formName, config] of this.formConfigs) {
+      if (foundCurrent && !config.tableInstance) {
+        config.startRow += offset;
+        config.endRow = config.startRow + config.rowCount - 1;
+        // console.log(`  更新表单 ${formName}: 新起始行=${config.startRow}`);
+      }
+      if (formName === currentFormName) {
+        foundCurrent = true;
+      }
+    }
+  }
+
+  /**
+   * 刷新表单数据到 SpreadJS
+   */
+  refreshFormData(formName: string, columnMapping?: Map<number, string>): void {
+    const config = this.formConfigs.get(formName);
+    if (!config) {
+      console.warn(`表单 ${formName} 未注册`);
+      return;
+    }
+
+    config.dataArray.forEach((data, index) => {
+      const row = config.startRow + index;
+      
+      if (columnMapping) {
+        columnMapping.forEach((fieldName, col) => {
+          if (data.hasOwnProperty(fieldName)) {
+            this.sheet.setValue(row, col, data[fieldName]);
+          }
+        });
+      }
+    });
+
+    this.spread.refresh();
+  }
+
+  /**
+   * 获取所有已注册的表单信息
+   */
+  getRegisteredForms(): string[] {
+    return Array.from(this.formConfigs.keys());
+  }
+
+  /**
+   * 启用/禁用自动扫描
+   */
+  setAutoScanEnabled(enabled: boolean): void {
+    this.autoScanEnabled = enabled;
+  }
+
+  /**
+   * 清除所有表单注册
+   */
+  clearAllForms(): void {
+    this.formConfigs.clear();
+    // console.log('✅ 已清除所有表单注册');
+  }
+
+  /**
+   * 自动扫描所有 Sheet 中的 Table 并注册为动态表单
+   */
+  autoRegisterTablesFromAllSheets(): void {
+    const sheets = this.spread.sheets;
+    for (const sheet of sheets) {
+      this.autoRegisterTablesFromSheet(sheet);
+    }
+    // console.log(`✅ 自动扫描完成,共注册 ${this.formConfigs.size} 个动态表单`);
+  }
+
+/**
+ * 从指定 Sheet 自动注册所有 Table
+ * 修复:正确处理 Table 的行索引映射
+ */
+autoRegisterTablesFromSheet(sheet: any): void {
+  const tables = sheet.tables?.all() || [];
+  // console.log(`🔍 开始扫描 Sheet: ${sheet.name()},发现 ${tables.length} 个表格`);
+
+  for (let i = 0; i < tables.length; i++) {
+    const table = tables[i];
+    const tableName = table.name();
+    const range = table.range();
+    const bindingPath = table.bindingPath();
+    
+    // console.log(`📊 处理表格 ${i}: ${tableName}, bindingPath: ${bindingPath}`);
+    // console.log(`   range.row: ${range.row}, range.rowCount: ${range.rowCount}`);
+    
+    if (!bindingPath) {
+      console.warn(`⚠️ Table ${tableName} 没有绑定路径,跳过注册`);
+      continue;
+    }
+    
+    const dataSource = sheet.getDataSource();
+    if (!dataSource) {
+      console.warn(`⚠️ Table ${tableName} 的 Sheet 没有数据源,跳过注册`);
+      continue;
+    }
+    
+    const dataArray = this.extractTableDataArray(bindingPath, dataSource);
+    // console.log(`📦 Table ${tableName} 数据:`, dataArray);
+    
+    // 🔧 修复:range.row 就是表格的起始行(包含表头)
+    // 如果 range.row = 6,表头在第7行(6+1),数据从索引6开始就是第一行数据
+    // SpreadJS 的 Table range 通常包含表头,所以:
+    // - 表头行 = range.row(如果Table有表头的话)
+    // - 数据起始行 = range.row(实际数据行的索引)
+    
+    const startRow = range.row;  // 数据起始行就是 range.row
+    const templateRow = startRow; // 模板行就是第一行数据
+    const rowCount = dataArray && dataArray.length > 0 ? dataArray.length : 0;
+    
+    // 使用 Sheet 名称 + Table 名称作为唯一标识
+    const uniqueFormName = `${sheet.name()}_${tableName}`;
+    
+    this.formConfigs.set(uniqueFormName, {
+      startRow,           // 数据起始行
+      templateRow,        // 模板行(第一行数据)
+      dataArray: dataArray || [],
+      rowCount: rowCount,
+      endRow: startRow + rowCount - 1,  // 数据结束行
+      tableName,
+      tableInstance: table,
+      bindingPath,
+      sheet: sheet
+    });
+    
+    // console.log(`✅ 自动注册 Table: ${uniqueFormName}`);
+    // console.log(`   绑定路径: ${bindingPath}`);
+    // console.log(`   数据起始行: ${startRow} (显示为第 ${startRow + 1} 行)`);
+    // console.log(`   数据结束行: ${startRow + rowCount - 1} (显示为第 ${startRow + rowCount} 行)`);
+    // console.log(`   数据行数: ${rowCount}`);
+  }
+}
+
+  /**
+   * 提取 Table 绑定的数据数组
+   */
+  private extractTableDataArray(bindingPath: string, dataSource: any): any[] {
+    try {
+      if (!bindingPath || !dataSource) {
+        return [];
+      }
+
+      const sourceData = dataSource.getSource();
+
+      if (!sourceData) {
+        console.error('数据源的 getSource() 返回空');
+        return [];
+      }
+
+      const dataArray = sourceData[bindingPath];
+
+      // console.log(`�� 从绑定路径 "${bindingPath}" 获取数据:`, dataArray);
+
+      if (Array.isArray(dataArray)) {
+        return dataArray;
+      }
+
+      console.warn(`⚠️ 绑定路径 "${bindingPath}" 的数据不是数组类型:`, typeof dataArray);
+      return [];
+    } catch (error) {
+      console.error('提取 Table 数据数组失败:', error);
+      return [];
+    }
+  }
+
+  /**
+   * 监听数据源变化,自动更新表单注册
+   */
+  setupAutoUpdateOnDataSourceChange(sheet: any): void {
+    const originalSetDataSource = sheet.setDataSource.bind(sheet);
+    sheet.setDataSource = (dataSource: any) => {
+      originalSetDataSource(dataSource);
+      
+      if (this.autoScanEnabled) {
+        this.clearFormsInSheet(sheet);
+        this.autoRegisterTablesFromSheet(sheet);
+      }
+    };
+  }
+
+  /**
+   * 清除指定 Sheet 中的表单注册
+   */
+  private clearFormsInSheet(sheet: any): void {
+    const tables = sheet.tables?.all() || [];
+    const sheetName = sheet.name();
+    for (const table of tables) {
+      const uniqueFormName = `${sheetName}_${table.name()}`;
+      this.formConfigs.delete(uniqueFormName);
+    }
+  }
+
+  /**
+   * 手动注册动态表单(保留原有方法以兼容手动注册场景)
+   */
+  registerForm(formName: string, startRow: number, templateRow: number, dataArray: any[]): void {
+    if (dataArray.length === 0) {
+      console.warn(`表单 ${formName} 的数据数组为空,至少需要一条数据`);
+      return;
+    }
+
+    this.formConfigs.set(formName, {
+      startRow,
+      templateRow,
+      dataArray,
+      rowCount: dataArray.length,
+      endRow: startRow + dataArray.length - 1
+    });
+
+    // console.log(`✅ 已注册表单: ${formName}, 起始行索引: ${startRow} (显示为第 ${startRow + 1} 行), 行数: ${dataArray.length}`);
+  }
+
+  /**
+   * 获取当前激活单元格所属的动态表单
+   * 修复:增加调试信息,显示人类可读的行号
+   */
+  getActiveFormInfo(): FormInfo | null {
+    const activeSheet = this.spread.getActiveSheet();
+    const activeRow = activeSheet.getActiveRowIndex();  // 这是编程索引(从0开始)
+    const activeSheetName = activeSheet.name();
+    
+    // console.log(`�� 当前激活 Sheet: ${activeSheetName}, 行索引: ${activeRow} (显示为第 ${activeRow + 1} 行)`);
+    // console.log('已注册表单:', this.formConfigs);
+    
+    for (const [formName, config] of this.formConfigs) {
+      // 检查 Sheet 是否匹配
+      const configSheetName = config.sheet ? config.sheet.name() : null;
+      
+      if (configSheetName !== activeSheetName) {
+        // console.log(`  跳过表单 ${formName}: Sheet 不匹配 (${configSheetName} !== ${activeSheetName})`);
+        continue;
+      }
+      
+      const endRow = config.startRow + config.rowCount - 1;
+      
+      // console.log(`  检查表单 ${formName}: startRow=${config.startRow} (显示第 ${config.startRow + 1} 行), endRow=${endRow} (显示第 ${endRow + 1} 行)`);
+      
+      if (activeRow >= config.startRow && activeRow <= endRow) {
+        const index = activeRow - config.startRow;
+        // console.log(`✅ 找到表单: ${formName}, 数据索引: ${index}`);
+        
+        return {
+          formName,
+          formConfig: config,
+          index,
+          currentRow: activeRow
+        };
+      }
+    }
+
+    console.warn(`⚠️ 当前单元格(Sheet: ${activeSheetName}, 行索引: ${activeRow}, 显示第 ${activeRow + 1} 行)不在任何动态表单中`);
+    // console.log('已注册的表单:', Array.from(this.formConfigs.keys()));
+
+    return null;
+  }
+
+  /**
+   * 在当前激活行下方添加新行
+   */
+  addRowBelowActive(defaultData: any = {}): boolean {
+    const formInfo = this.getActiveFormInfo();
+    if (!formInfo) {
+      return false;
+    }
+
+    const { formName, formConfig, index, currentRow } = formInfo;
+
+    try {
+      const newData = this.createNewDataObject(
+        formConfig.dataArray.length > 0 ? formConfig.dataArray[index] : {},
+        defaultData
+      );
+      
+      formConfig.dataArray.splice(index + 1, 0, newData);
+      
+      formConfig.rowCount++;
+      formConfig.endRow = formConfig.startRow + formConfig.rowCount - 1;
+      
+      if (formConfig.tableInstance && formConfig.bindingPath) {
+        this.refreshTableDataSource(formConfig);
+      } else {
+        const insertRow = currentRow + 1;
+        this.sheet.addRows(insertRow, 1);
+        this.copyRowFormat(formConfig.templateRow, insertRow);
+      }
+      
+      this.updateSubsequentForms(formName, 1);
+      
+      // console.log(`✅ 已在 ${formName} 的第 ${index + 1} 个位置添加新行`);
+      
+      this.spread.refresh();
+      
+      return true;
+    } catch (error) {
+      console.error('添加行失败:', error);
+      return false;
+    }
+  }
+
+  /**
+   * 复制当前行到表单末尾
+   */
+/**
+ * 复制当前行到表单末尾
+ * @param defaultData 默认数据
+ * @param count 要生成的行数,默认为 1
+ */
+duplicateRowToEnd(defaultData: any = {}, count: number = 1): boolean {
+  const formInfo = this.getActiveFormInfo();
+  if (!formInfo) {
+    return false;
+  }
+
+  const { formName, formConfig, index } = formInfo;
+
+  // 验证 count 参数
+  if (count < 1) {
+    console.warn('⚠️ 行数必须大于 0');
+    return false;
+  }
+
+  try {
+    // 批量创建新行数据
+    const newRows = [];
+    for (let i = 0; i < count; i++) {
+      const copiedData = this.createNewDataObject(
+        formConfig.dataArray.length > 0 ? formConfig.dataArray[index] : {},
+        defaultData
+      );
+      newRows.push(copiedData);
+    }
+    
+    // 添加到数据数组
+    formConfig.dataArray.push(...newRows);
+    
+    formConfig.rowCount += count;
+    formConfig.endRow = formConfig.startRow + formConfig.rowCount - 1;
+    
+    if (formConfig.tableInstance && formConfig.bindingPath) {
+      this.refreshTableDataSource(formConfig);
+    } else {
+      const insertRow = formConfig.startRow + formConfig.rowCount - count;
+      this.sheet.addRows(insertRow, count);
+      
+      // 批量复制格式
+      for (let i = 0; i < count; i++) {
+        this.copyRowFormat(formConfig.templateRow, insertRow + i);
+      }
+    }
+    
+    this.updateSubsequentForms(formName, count);
+    
+    // console.log(`✅ 已将 ${formName} 的第 ${index} 行复制 ${count} 次到末尾`);
+    
+    this.spread.refresh();
+    
+    return true;
+  } catch (error) {
+    console.error('复制行失败:', error);
+    return false;
+  }
+}
+
+/**
+ * 批量删除选中的行
+ * @returns 成功删除的行数
+ */
+deleteSelectedRows(): number {
+  const activeSheet = this.spread.getActiveSheet();
+  const selections = activeSheet.getSelections();
+  
+  if (!selections || selections.length === 0) {
+    console.warn('⚠️ 没有选中任何单元格');
+    return 0;
+  }
+
+  // 收集所有选中的行索引(去重并排序)
+  const selectedRows = new Set<number>();
+  selections.forEach(selection => {
+    const startRow = selection.row;
+    const rowCount = selection.rowCount;
+    for (let i = 0; i < rowCount; i++) {
+      selectedRows.add(startRow + i);
+    }
+  });
+
+  // 按行索引从大到小排序(从后往前删除,避免索引变化)
+  const sortedRows = Array.from(selectedRows).sort((a, b) => b - a);
+  
+  console.log(`�� 选中了 ${sortedRows.length} 行,准备删除:`, sortedRows.map(r => r + 1));
+
+  // 按表单分组
+  const rowsByForm = new Map<string, { config: FormConfig; rows: number[] }>();
+  
+  sortedRows.forEach(rowIndex => {
+    const formInfo = this.getFormInfoByRow(rowIndex);
+    if (formInfo) {
+      if (!rowsByForm.has(formInfo.formName)) {
+        rowsByForm.set(formInfo.formName, {
+          config: formInfo.formConfig,
+          rows: []
+        });
+      }
+      rowsByForm.get(formInfo.formName)!.rows.push(rowIndex);
+    }
+  });
+
+  let deletedCount = 0;
+
+  // 遍历每个表单进行批量删除
+  rowsByForm.forEach((formData, formName) => {
+    const { config, rows } = formData;
+    
+    // 检查是否会删除所有行
+    if (rows.length >= config.rowCount) {
+      console.warn(`⚠️ 表单 ${formName} 至少需要保留一行,跳过删除`);
+      return;
+    }
+
+    try {
+      // 转换为数据索引(从后往前)
+      const dataIndices = rows
+        .map(rowIndex => rowIndex - config.startRow)
+        .sort((a, b) => b - a);
+
+      // 从数据数组中删除
+      dataIndices.forEach(index => {
+        config.dataArray.splice(index, 1);
+      });
+
+      // 更新配置
+      const deletedInThisForm = dataIndices.length;
+      config.rowCount -= deletedInThisForm;
+      config.endRow = config.startRow + config.rowCount - 1;
+
+      // 刷新表格
+      if (config.tableInstance && config.bindingPath) {
+        this.refreshTableDataSource(config);
+      } else {
+        // 非 Table 模式:从后往前逐行删除
+        rows.forEach(rowIndex => {
+          this.sheet.deleteRows(rowIndex, 1);
+        });
+      }
+
+      this.updateSubsequentForms(formName, -deletedInThisForm);
+      
+      deletedCount += deletedInThisForm;
+      console.log(`✅ 表单 ${formName} 删除了 ${deletedInThisForm} 行`);
+    } catch (error) {
+      console.error(`删除表单 ${formName} 的行时出错:`, error);
+    }
+  });
+
+  if (deletedCount > 0) {
+    this.spread.refresh();
+    console.log(`✅ 总共删除了 ${deletedCount} 行`);
+  }
+
+  return deletedCount;
+}
+
+/**
+ * 根据行索引获取表单信息(辅助方法)
+ */
+private getFormInfoByRow(rowIndex: number): FormInfo | null {
+  const activeSheet = this.spread.getActiveSheet();
+  const activeSheetName = activeSheet.name();
+  
+  for (const [formName, config] of this.formConfigs) {
+    const configSheetName = config.sheet ? config.sheet.name() : null;
+    
+    if (configSheetName !== activeSheetName) {
+      continue;
+    }
+    
+    const endRow = config.startRow + config.rowCount - 1;
+    
+    if (rowIndex >= config.startRow && rowIndex <= endRow) {
+      const index = rowIndex - config.startRow;
+      return {
+        formName,
+        formConfig: config,
+        index,
+        currentRow: rowIndex
+      };
+    }
+  }
+  
+  return null;
+}
+}

+ 34 - 0
src/components/SpreadDesigner/tools/gc.ts

@@ -0,0 +1,34 @@
+import * as GC from '@grapecity-software/spread-sheets'
+import _ from 'lodash'
+import '@grapecity-software/spread-sheets-resources-zh'
+import '@grapecity-software/spread-sheets-designer-resources-cn'
+GC.Spread.Common.CultureManager.culture('zh-cn');
+// 1. 保存原始 bindingPath 方法
+const originalBindingPath = GC.Spread.Sheets.CellRange.prototype.bindingPath
+
+// 2. 重写 bindingPath
+GC.Spread.Sheets.CellRange.prototype.bindingPath = function(fieldName) {
+  // 调用原始绑定方法
+  originalBindingPath.apply(this)
+  
+  // 自定义逻辑(例如根据字段名设置单元格类型)
+  if (fieldName) {
+    const sheet = this.sheet;
+    const row = this.row;
+    const col = this.col;
+
+    const currentDataSource = sheet.getDataSource()?.getSource() || {}
+    
+    const locationPath = sheet.getBindingPath(row, col)
+    if(locationPath !== fieldName) {
+      sheet.setBindingPath(row, col, fieldName)
+    }
+
+    if(!_.has(currentDataSource, fieldName)) {
+      const newDataSource = new GC.Spread.Sheets.Bindings.CellBindingSource({...currentDataSource, [fieldName]: ''})
+      sheet.setDataSource(newDataSource)
+    }
+  }
+}
+
+export default GC

+ 39 - 0
src/components/SpreadDesigner/type.d.ts

@@ -0,0 +1,39 @@
+// 导出用户接口
+export interface BindingPathItemType {
+  field: string,
+  type: string,
+  dataFieldType: string,
+  displayName: string,
+  isVerify?: string,
+  child?: BindingPathItemType[]
+}
+
+
+// 模板中进行数据保存,需要用到的参数
+export interface SpreadTemplateApiDataType {
+  id: string
+}
+
+
+export interface SpreadDesignerInstance {
+  handleSheetTableCopyTo: ()=> any
+  handleSheetTableCopyTo2: () => any
+  batchCalibrationDateFn: (dateStr) => any
+  checkAllBindingPath: ()=> any
+  handleSheetTableautoFitRow: ()=> any
+  fetchTemplateData: ()=> any
+  setDefaultSchema: ()=> any
+  getDefaultSchema: ()=> any
+  handleMergeCol: ()=> any
+  newSetDataSource: ()=> any
+  setDataSource: ()=> any
+  getDataSource: ()=> any
+  removeSheetCellFocus: ()=> any
+  initInquiryLoop: (useCache?: boolean)=> any
+  restartInquiryLoop: ()=> any
+  designer: any
+  isFullScreen: boolean
+  handleUpdateDesignerState: any
+  // 更新查看字段释义接口方法
+  SaveBindingPathJSON: () => any
+}

Plik diff jest za duży
+ 1764 - 0
src/components/SpreadDesigner/utils.ts


+ 4 - 0
src/pages.json

@@ -144,6 +144,10 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/spread/index",
+      "type": "page"
+    },
     {
       "path": "pages/systemFile/systemFile",
       "type": "page",

+ 17 - 0
src/pages/spread/index.vue

@@ -0,0 +1,17 @@
+<template>
+    <SpreadViewer />
+</template>
+
+
+<script setup>
+import SpreadViewer from '@/components/DynamicReport/SpreadViewer.vue'
+
+
+let param = useRoute().query
+
+
+
+</script>
+
+<style scoped>
+</style>

+ 14 - 14
src/pages/webview/generic-webview.vue

@@ -1,56 +1,56 @@
 <template>
   <view class="webview-container">
     <!-- WebView 组件 -->
-    <!-- <web-view
+    <web-view
       v-if="!loadingError && config"
       ref="webViewRef"
       :src="webviewUrl"
       @message="handleMessage"
       @load="handleLoad"
       @error="handleError"
-    /> -->
+    />
 
     <!-- 加载状态 -->
-    <!-- <view v-if="isLoading && !loadingError" class="loading-overlay">
+    <view v-if="isLoading && !loadingError" class="loading-overlay">
       <view class="loading-content">
         <text class="loading-text">加载中...</text>
         <text v-if="loadingProgress > 0" class="loading-progress">{{ loadingProgress }}%</text>
       </view>
-    </view> -->
+    </view>
 
     <!-- 错误状态 -->
-    <!-- <view v-if="loadingError" class="error-overlay">
+    <view v-if="loadingError" class="error-overlay">
       <view class="error-content">
         <text class="error-text">{{ loadingError }}</text>
         <view class="retry-btn" @click="handleRetry">
           <text>重试</text>
         </view>
       </view>
-    </view> -->
+    </view>
 
     <!-- 模板库弹窗 -->
-    <!-- <TemplateLibraryPopup
+    <TemplateLibraryPopup
       ref="templateLibraryRef"
       @select="handleTemplateSelect"
       @delete="handleTemplateDelete"
-    /> -->
+    />
 
     <!-- 审核人选择弹窗 -->
-    <!-- <SelectUserPopup
+    <SelectUserPopup
       ref="selectUserRef"
       @confirm="handleSelectUserConfirm"
-    /> -->
+    />
 
     <!-- 仪器库选择弹窗 -->
-    <!-- <InstrumentLibraryPopup
+    <InstrumentLibraryPopup
       ref="instrumentLibraryRef"
       @detail="handleInstrumentDetail"
       @apply="handleInstrumentApply"
-    /> -->
+    />
 
     <!-- 仪器详情弹窗 -->
-    <!-- <InstrumentDetailPopup ref="instrumentDetailRef" /> -->
-    <spreadGeneric />
+    <InstrumentDetailPopup ref="instrumentDetailRef" />
+    <!-- <spreadGeneric /> -->
   </view>
 </template>
 

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

@@ -14,6 +14,7 @@ interface NavigateToOptions {
        "/pages/serviceOrderDetail/index" |
        "/pages/sign/index" |
        "/pages/sign-detail/index" |
+       "/pages/spread/index" |
        "/pages/systemFile/systemFile" |
        "/pages/taskOnlinePage/taskOnline" |
        "/pages/unClaim/unClaimList" |

+ 1 - 1
tsconfig.json

@@ -31,7 +31,7 @@
   "exclude": ["node_modules"],
   "include": [
     "src/**/*.ts",
-	"src/**/*.uts",
+	  "src/**/*.uts",
     "src/**/*.js",
     "src/**/*.d.ts",
     "src/**/*.min.js",