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

重构SpreadDesigner和SpreadDesignerGeneric

yangguanjin пре 1 дан
родитељ
комит
84958362aa

+ 337 - 0
src/components/SpreadDesigner/components/FloatingInputToolbar.vue

@@ -0,0 +1,337 @@
+<template>
+  <view
+    v-if="visible"
+    class="floating-input-container"
+    :class="{ 'keyboard-visible': keyboardHeight > 0 }"
+    :style="{ '--keyboard-height': `${keyboardHeight - 32}px` }"
+  >
+    <view class="floating-toolbar">
+      <view class="floating-field-name-display">{{ fieldName }}</view>
+      <view class="button-group">
+        <button
+          v-if="showImageButton"
+          class="upload-image-btn"
+          :disabled="!currentCell"
+          @click="$emit('insertImage')"
+        >
+          插入图片
+        </button>
+        <button
+          class="special-symbol-btn"
+          :disabled="!currentCell"
+          @click="$emit('insertSymbol')"
+        >
+          特殊符号
+        </button>
+        <button
+          id="prevCellBtn"
+          class="prev-cell-btn"
+          :disabled="!currentCell"
+          @click="$emit('prev')"
+        >
+          <text class="btn-text">上一项</text>
+        </button>
+        <button
+          id="nextCellBtn"
+          class="next-cell-btn"
+          :disabled="!currentCell"
+          @click="$emit('next')"
+        >
+          <text class="btn-text">下一项</text>
+        </button>
+      </view>
+    </view>
+    <view class="floating-input-wrapper">
+      <textarea
+        id="floatingInput"
+        :value="modelValue"
+        placeholder="请选择单元格"
+        @blur="$emit('blur', $event)"
+        @click="$emit('click', $event)"
+        @focus="$emit('focus', $event)"
+        @input="onInput"
+        @keyup="$emit('keyup', $event)"
+      />
+      <button id="floatingClearBtn" class="clear-btn" @click="$emit('clear')">×</button>
+    </view>
+  </view>
+</template>
+
+<script setup>
+const props = defineProps({
+  visible: { type: Boolean, default: false },
+  modelValue: { type: String, default: '' },
+  fieldName: { type: String, default: '-' },
+  currentCell: { type: Object, default: null },
+  keyboardHeight: { type: Number, default: 0 },
+  showImageButton: { type: Boolean, default: true },
+})
+
+const emit = defineEmits([
+  'update:modelValue',
+  'insertImage',
+  'insertSymbol',
+  'prev',
+  'next',
+  'clear',
+  'blur',
+  'click',
+  'focus',
+  'input',
+  'keyup',
+])
+
+function onInput(event) {
+  // uni-app 事件: 值在 event.detail.value,不是 event.target.value
+  const val = event?.detail?.value ?? event?.target?.value ?? ''
+  emit('update:modelValue', val)
+  emit('input', event)
+}
+</script>
+
+<style scoped>
+* {
+  box-sizing: border-box;
+}
+
+.floating-input-container {
+  position: fixed;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 9999;
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  min-height: 85px;
+  padding: 8px 12px 12px 12px;
+  background: white;
+  border-top: 1px solid #e0e0e0;
+  transition: all 0.3s ease;
+}
+
+.floating-input-container.keyboard-visible {
+  position: fixed !important;
+  bottom: var(--keyboard-height, 0);
+  z-index: 10000;
+  min-height: 105px;
+  max-height: 345px;
+}
+
+.floating-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  height: 35px;
+  padding: 0 2px;
+  margin-top: 4px;
+  margin-bottom: 10px;
+}
+
+.button-group {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.floating-field-name-display {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  height: 35px;
+  padding: 6px 12px;
+  margin-right: 8px;
+  overflow: hidden;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  font-size: 12px;
+  line-height: 1.2;
+  color: #666;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  background: #f5f5f5;
+  border-radius: 4px;
+}
+
+.floating-input-wrapper {
+  position: relative;
+  width: 100%;
+}
+
+.floating-input-wrapper textarea {
+  width: 100%;
+  height: 40px;
+  padding: 8px 32px 8px 12px;
+  font-size: 14px;
+  resize: none;
+  border: 1px solid #d9d9d9;
+  border-radius: 4px;
+  outline: none;
+}
+
+.floating-input-wrapper textarea:focus {
+  border-color: #007aff;
+}
+
+.clear-btn {
+  position: absolute;
+  top: 50%;
+  right: 8px;
+  z-index: 2;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  font-size: 16px;
+  font-weight: bold;
+  color: #666;
+  cursor: pointer;
+  background: #f0f0f0;
+  border: none;
+  border-radius: 12px;
+  transform: translateY(-50%);
+}
+
+.clear-btn:hover {
+  color: #333;
+  background: #e0e0e0;
+}
+
+.upload-image-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 60px;
+  height: 35px;
+  padding: 6px 12px;
+  font-size: 12px;
+  font-weight: 500;
+  color: white;
+  cursor: pointer;
+  background: #28a745;
+  border: none;
+  border-radius: 4px;
+  transition: all 0.2s ease;
+}
+
+.upload-image-btn:hover:not(:disabled) {
+  background: #1e7e34;
+  transform: translateY(-1px);
+}
+
+.upload-image-btn:disabled {
+  color: #999999;
+  cursor: not-allowed;
+  background: #cccccc;
+  opacity: 0.7;
+}
+
+.special-symbol-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 60px;
+  height: 35px;
+  padding: 6px 12px;
+  font-size: 12px;
+  font-weight: 500;
+  color: white;
+  cursor: pointer;
+  background: #e6a23c;
+  border: none;
+  border-radius: 4px;
+  transition: all 0.2s ease;
+}
+
+.special-symbol-btn:hover:not(:disabled) {
+  background: #c87f0a;
+  transform: translateY(-1px);
+}
+
+.special-symbol-btn:disabled {
+  color: #999999;
+  cursor: not-allowed;
+  background: #cccccc;
+  opacity: 0.7;
+}
+
+.prev-cell-btn {
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 60px;
+  height: 35px;
+  padding: 6px 12px;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 1;
+  color: white;
+  cursor: pointer;
+  background: #007aff;
+  border: none;
+  border-radius: 4px;
+  transition: all 0.2s ease;
+}
+
+.prev-cell-btn:hover:not(:disabled) {
+  background: #0056b3;
+  transform: translateY(-1px);
+}
+
+.prev-cell-btn:active:not(:disabled) {
+  transform: translateY(0);
+}
+
+.prev-cell-btn:focus {
+  outline: none;
+  box-shadow: 0 0 0 1px rgba(0, 122, 255, 0.3);
+}
+
+.prev-cell-btn:disabled {
+  color: #999999;
+  cursor: not-allowed;
+  background: #cccccc;
+  opacity: 0.7;
+}
+
+.next-cell-btn {
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 60px;
+  height: 35px;
+  padding: 6px 12px;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 1;
+  color: white;
+  cursor: pointer;
+  background: #007aff;
+  border: none;
+  border-radius: 4px;
+  transition: all 0.2s ease;
+}
+
+.next-cell-btn:hover:not(:disabled) {
+  background: #0056b3;
+  transform: translateY(-1px);
+}
+
+.next-cell-btn:active:not(:disabled) {
+  transform: translateY(0);
+}
+
+.next-cell-btn:focus {
+  outline: none;
+  box-shadow: 0 0 0 1px rgba(0, 122, 255, 0.3);
+}
+
+.next-cell-btn:disabled {
+  color: #999999;
+  cursor: not-allowed;
+  background: #cccccc;
+  opacity: 0.7;
+}
+</style>

+ 111 - 0
src/components/SpreadDesigner/components/ImageSourceDialog.vue

@@ -0,0 +1,111 @@
+<template>
+  <view v-if="visible" class="dialog-overlay" @click="$emit('close')">
+    <view class="image-source-dialog-content" @click.stop>
+      <view class="image-source-dialog-header">
+        <text class="image-source-dialog-title">插入图片</text>
+        <button class="image-source-dialog-close" @click="$emit('close')">×</button>
+      </view>
+      <view class="image-source-options">
+        <button class="image-source-option-btn" @click="$emit('takePhoto')">
+          <text class="option-label">拍摄图片</text>
+        </button>
+        <button class="image-source-option-btn" @click="$emit('pickFile')">
+          <text class="option-label">选择文件</text>
+        </button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+defineProps({
+  visible: { type: Boolean, default: false },
+})
+
+defineEmits(['close', 'takePhoto', 'pickFile'])
+</script>
+
+<style scoped>
+.dialog-overlay {
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 10001;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: rgba(0, 0, 0, 0.5);
+}
+
+.image-source-dialog-content {
+  width: min(80vw, 320px);
+  padding: 20px;
+  background-color: white;
+  border-radius: 8px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.image-source-dialog-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16px;
+}
+
+.image-source-dialog-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #333;
+}
+
+.image-source-dialog-close {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  font-size: 18px;
+  color: #666;
+  cursor: pointer;
+  background: #f5f5f5;
+  border: none;
+  border-radius: 14px;
+}
+
+.image-source-options {
+  display: flex;
+  gap: 12px;
+  justify-content: center;
+}
+
+.image-source-option-btn {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 120px;
+  height: 80px;
+  font-size: 14px;
+  font-weight: 500;
+  color: #333;
+  cursor: pointer;
+  background: #f8f9fa;
+  border: 1px solid #dee2e6;
+  border-radius: 8px;
+  transition: all 0.2s ease;
+}
+
+.image-source-option-btn:hover {
+  color: #007aff;
+  background: #eef6ff;
+  border-color: #007aff;
+}
+
+.option-label {
+  font-size: 14px;
+  font-weight: 500;
+  color: inherit;
+}
+</style>

+ 46 - 0
src/components/SpreadDesigner/components/MessageDialog.vue

@@ -0,0 +1,46 @@
+<template>
+  <view v-if="visible" class="dialog-overlay" @click="$emit('close')">
+    <view class="dialog-content" @click.stop>
+      <view class="modal-message">{{ message }}</view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+defineProps({
+  visible: { type: Boolean, default: false },
+  message: { type: String, default: '' },
+})
+
+defineEmits(['close'])
+</script>
+
+<style scoped>
+.dialog-overlay {
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 10001;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: rgba(0, 0, 0, 0.5);
+}
+
+.dialog-content {
+  max-width: 80%;
+  padding: 20px;
+  background-color: white;
+  border-radius: 8px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.modal-message {
+  font-size: 16px;
+  line-height: 1.5;
+  color: #333;
+  text-align: center;
+}
+</style>

+ 119 - 0
src/components/SpreadDesigner/components/SpecialSymbolDialog.vue

@@ -0,0 +1,119 @@
+<template>
+  <view v-if="visible" class="dialog-overlay" @click="$emit('close')">
+    <view class="symbol-dialog-content" @click.stop>
+      <view class="symbol-dialog-header">
+        <text class="symbol-dialog-title">特殊符号</text>
+        <button class="symbol-dialog-close" @click="$emit('close')">×</button>
+      </view>
+      <view class="symbol-groups">
+        <view v-for="group in groups" :key="group.title" class="symbol-group">
+          <view class="symbol-group-title">{{ group.title }}</view>
+          <view class="symbol-grid">
+            <button
+              v-for="symbol in group.symbols"
+              :key="symbol"
+              class="symbol-item"
+              @click="$emit('select', symbol)"
+            >
+              {{ symbol }}
+            </button>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+defineProps({
+  visible: { type: Boolean, default: false },
+  groups: { type: Array, default: () => [] },
+})
+
+defineEmits(['close', 'select'])
+</script>
+
+<style scoped>
+.dialog-overlay {
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 10001;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: rgba(0, 0, 0, 0.5);
+}
+
+.symbol-dialog-content {
+  width: min(92vw, 560px);
+  max-height: 70vh;
+  padding: 16px;
+  overflow-y: auto;
+  background-color: white;
+  border-radius: 8px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.symbol-dialog-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.symbol-dialog-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #333;
+}
+
+.symbol-dialog-close {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  font-size: 18px;
+  color: #666;
+  cursor: pointer;
+  background: #f5f5f5;
+  border: none;
+  border-radius: 14px;
+}
+
+.symbol-group + .symbol-group {
+  margin-top: 14px;
+}
+
+.symbol-group-title {
+  margin-bottom: 8px;
+  font-size: 13px;
+  font-weight: 600;
+  color: #666;
+}
+
+.symbol-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
+  gap: 8px;
+}
+
+.symbol-item {
+  height: 36px;
+  font-size: 15px;
+  color: #333;
+  cursor: pointer;
+  background: #f8f9fa;
+  border: 1px solid #dee2e6;
+  border-radius: 4px;
+}
+
+.symbol-item:hover {
+  color: #007aff;
+  background: #eef6ff;
+  border-color: #007aff;
+}
+</style>

+ 194 - 0
src/components/SpreadDesigner/composables/useCellNavigation.ts

@@ -0,0 +1,194 @@
+/**
+ * 空单元格导航 —— 上/下一项查找
+ * 依赖 useFloatingInput 的 currentCell / currentCellInfo / showFloatingInput / updateFieldNameDisplay
+ */
+import GC from '../tools/gc'
+
+export function useCellNavigation(floatingInput) {
+  const { currentCell, currentCellInfo, showFloatingInput, updateFieldNameDisplay } = floatingInput
+
+  function findNextEmptyBoundCell(direction = 'next') {
+    if (!currentCell.value || !currentCell.value.sheet) {
+      console.warn('没有当前活动单元格')
+      return
+    }
+
+    try {
+      const sheet = currentCell.value.sheet
+      const usedRange = sheet.getUsedRange(GC.Spread.Sheets.UsedRangeType.data)
+
+      if (!usedRange) {
+        console.warn('没有找到使用范围')
+        return
+      }
+
+      const { rowCount, colCount } = usedRange
+      const startRow = currentCell.value.row
+      const startCol = currentCell.value.col
+
+      const targetCell = findNextEmptyCellFromPosition(
+        sheet,
+        startRow,
+        startCol,
+        rowCount,
+        colCount,
+        direction,
+      )
+
+      if (targetCell) {
+        currentCell.value = { sheet, row: targetCell.row, col: targetCell.col }
+        const cell = sheet.getCell(targetCell.row, targetCell.col)
+        const bindingPath = sheet.getBindingPath(targetCell.row, targetCell.col) || '-'
+
+        currentCellInfo.value = {
+          value: cell.value() || '',
+          isLocked: false,
+          formatter: cell.formatter(),
+          fieldName: bindingPath,
+        }
+
+        updateFieldNameDisplay(bindingPath)
+        showFloatingInput()
+        sheet.setSelection(targetCell.row, targetCell.col, 1, 1)
+
+        const directionText = direction === 'next' ? '下一个' : '上一个'
+        console.log(
+          `跳转到${directionText}未输入单元格: 行${targetCell.row + 1}, 列${targetCell.col + 1}`,
+        )
+      } else {
+        console.log(`没有找到更多${direction === 'next' ? '下一个' : '上一个'}未输入的绑定字段单元格`)
+      }
+    } catch (error) {
+      console.error('查找过程中出错:', error)
+    }
+  }
+
+  function findNextEmptyCellFromPosition(
+    sheet,
+    startRow,
+    startCol,
+    maxRow,
+    maxCol,
+    direction = 'next',
+  ) {
+    const currentCellRange = getMergedCellRange(sheet, startRow, startCol)
+    const targetCell = searchInRange(
+      sheet,
+      currentCellRange,
+      startRow,
+      startCol,
+      maxRow,
+      maxCol,
+      direction,
+      true,
+    )
+    if (targetCell) return targetCell
+    return searchInRange(
+      sheet,
+      currentCellRange,
+      startRow,
+      startCol,
+      maxRow,
+      maxCol,
+      direction,
+      false,
+    )
+  }
+
+  function searchInRange(
+    sheet,
+    currentCellRange,
+    startRow,
+    startCol,
+    maxRow,
+    maxCol,
+    direction,
+    fromCurrentPosition,
+  ) {
+    const rowStart = fromCurrentPosition ? startRow : direction === 'next' ? 0 : maxRow - 1
+    const rowEnd = fromCurrentPosition
+      ? direction === 'next'
+        ? maxRow
+        : -1
+      : direction === 'next'
+        ? startRow
+        : startRow
+    const rowStep = direction === 'next' ? 1 : -1
+
+    for (let row = rowStart; direction === 'next' ? row < rowEnd : row >= rowEnd; row += rowStep) {
+      let colStart, colEnd, colStep
+
+      if (fromCurrentPosition) {
+        colStart =
+          row === startRow
+            ? direction === 'next'
+              ? startCol + 1
+              : startCol - 1
+            : direction === 'next'
+              ? 0
+              : maxCol - 1
+        colEnd = direction === 'next' ? maxCol : -1
+        colStep = direction === 'next' ? 1 : -1
+      } else {
+        colStart = direction === 'next' ? 0 : maxCol - 1
+        colEnd = row === startRow ? startCol : direction === 'next' ? maxCol : -1
+        colStep = direction === 'next' ? 1 : -1
+      }
+
+      for (let col = colStart; direction === 'next' ? col < colEnd : col >= colEnd; col += colStep) {
+        if (!fromCurrentPosition && row === startRow && col === startCol) {
+          continue
+        }
+
+        if (isInMergedRange(currentCellRange, row, col)) {
+          continue
+        }
+
+        const bindingPath = sheet.getBindingPath(row, col)
+        const cellValue = sheet.getValue(row, col)
+
+        if (bindingPath && (cellValue === null || cellValue === undefined || cellValue === '')) {
+          return { row, col }
+        }
+      }
+    }
+
+    return null
+  }
+
+  function getMergedCellRange(sheet, row, col) {
+    try {
+      const span = sheet.getSpan(row, col)
+      if (span) {
+        return {
+          row: span.row,
+          col: span.col,
+          rowCount: span.rowCount,
+          colCount: span.colCount,
+        }
+      }
+    } catch (error) {
+      console.log('获取合并范围失败:', error)
+    }
+    return null
+  }
+
+  function isInMergedRange(mergedRange, row, col) {
+    if (!mergedRange) {
+      return false
+    }
+
+    const { row: rangeRow, col: rangeCol, rowCount, colCount } = mergedRange
+    return (
+      row >= rangeRow && row < rangeRow + rowCount && col >= rangeCol && col < rangeCol + colCount
+    )
+  }
+
+  return {
+    findNextEmptyBoundCell,
+    findNextEmptyCellFromPosition,
+    searchInRange,
+    getMergedCellRange,
+    isInMergedRange,
+  }
+}

+ 249 - 0
src/components/SpreadDesigner/composables/useContinuePage.ts

@@ -0,0 +1,249 @@
+/**
+ * 续页处理 —— 数据扁平化、续页生成、绑定路径修改、数据还原
+ * spreadDesigner 专用 composable
+ */
+
+/**
+ * @param copyConfigGetter 返回当前 copyConfig prop 的函数
+ */
+export function useContinuePage(copyConfigGetter) {
+  let prefixFields = new Set()
+  let dataCount = 0
+
+  function processContinueDataSource(spreadInstance, dataSource) {
+    const copyConfig = copyConfigGetter()
+    if (!copyConfig?.isContinuePage) {
+      return { dataSource }
+    }
+    console.log('@@@@@datasource', dataSource)
+    dataSource = { ...dataSource }
+    const continuePageSheetName = copyConfig.sheetName
+    console.log('@@@@copyconfig....', copyConfig)
+
+    // 0. 收集所有 sheet 页中的 bindingPath 字段集合,根据 sheet 页分组
+    const sheetToFieldListMap = {}
+    const fieldListToSheetNameMap = new Map()
+    for (const sheet of spreadInstance.sheets) {
+      const boundFields = new Set()
+      for (let row = 0; row < sheet.getRowCount(); row++) {
+        for (let col = 0; col < sheet.getColumnCount(); col++) {
+          const bindingPath = sheet.getBindingPath(row, col)
+          if (bindingPath) {
+            boundFields.add(bindingPath)
+          }
+        }
+      }
+      const currentSheetName = sheet.name()
+      sheetToFieldListMap[currentSheetName] = boundFields
+      fieldListToSheetNameMap.set(boundFields, currentSheetName)
+    }
+    console.log('@@@@sheetToFieldListMap....', sheetToFieldListMap)
+
+    // 每个 sheet 的数据行数
+    const sheetToRecordRowCount = {}
+    for (let sheetName in sheetToFieldListMap) {
+      const fieldList = [...sheetToFieldListMap[sheetName]]
+      let maxSuffixNum = 0
+      let minSuffixNum = -1
+      for (let i = 0; i < fieldList.length; i++) {
+        const field = fieldList[i]
+        const suffixNumStr = field.split('_')[1]
+        if (suffixNumStr == null || suffixNumStr == '' || isNaN(Number(suffixNumStr))) {
+          continue
+        }
+        const suffixNum = Number(suffixNumStr)
+        maxSuffixNum = suffixNum > maxSuffixNum ? suffixNum : maxSuffixNum
+        if (minSuffixNum === -1) {
+          minSuffixNum = suffixNum
+        } else {
+          minSuffixNum = suffixNum < minSuffixNum ? suffixNum : minSuffixNum
+        }
+      }
+
+      sheetToRecordRowCount[sheetName] = maxSuffixNum - minSuffixNum + 1
+    }
+    console.log('@@@@sheetToRecordRowCount.....', sheetToRecordRowCount)
+
+    // 1. 识别 {filed}_{++num} 模式的字段
+    const continuePageFieldSet = sheetToFieldListMap[continuePageSheetName]
+    console.log('@@@copyConfigContinuePageFieldSet...', continuePageFieldSet)
+
+    const prefixFieldSet = new Set()
+    const pattern = /^(.+)_(\d+)$/
+    for (const continuePageField of continuePageFieldSet) {
+      if (!continuePageField.match(pattern)) {
+        continue
+      }
+      prefixFieldSet.add(continuePageField.split('_')[0])
+    }
+    console.log('@@@prefixFieldSet.....', prefixFieldSet)
+
+    // 2. 遍历 {field}数组,实现数据扁平处理
+    let totalDataCount = 0
+    let firstFlag = true
+    for (const prefixField of prefixFieldSet) {
+      const columnDataSource = dataSource[prefixField + '_1']
+      if (columnDataSource == null) {
+        break
+      }
+      if (firstFlag) {
+        totalDataCount = columnDataSource.length
+        firstFlag = false
+      } else {
+        if (totalDataCount !== columnDataSource.length) {
+          console.error('续页处理的数据,格式错误,字段为: ', prefixField)
+          continue
+        }
+      }
+      for (let i = 0; i < columnDataSource.length; i++) {
+        const cellData = columnDataSource[i]
+        const dataSourceKey = `${prefixField}_${i + 1}`
+        dataSource[dataSourceKey] = cellData
+      }
+    }
+    console.log('@@@flatDataSource.....', dataSource)
+
+    return { dataSource, prefixFieldSet, sheetToRecordRowCount, totalDataCount }
+  }
+
+  function processContinuePage(spreadInstance, dataSource, sheetToRecordRowCount, totalDataCount) {
+    const copyConfig = copyConfigGetter()
+    if (!copyConfig?.isContinuePage) {
+      return dataSource
+    }
+    console.log('@@@@@datasource', dataSource, '@@@totalDataCount', totalDataCount)
+    dataSource = { ...dataSource }
+    const copyRange = copyConfig.copyRange
+    const col = parseInt(copyRange.topLeft.split(',')[0])
+    const row = parseInt(copyRange.topLeft.split(',')[1])
+    const colCount = parseInt(copyRange.topRight.split(',')[0]) - col + 1
+    let rowCount = parseInt(copyRange.bottomLeft.split(',')[1]) - row + 1
+    const isHiddenContinuePage = copyConfig.hidden
+    const continuePageSheetName = copyConfig.sheetName
+
+    // 转换 sheetName 为对应数组下标
+    const sheetIdxToRecordCountArray = new Array(spreadInstance.sheets.length).fill(0)
+    for (const sheet of spreadInstance.sheets) {
+      const sheetName = sheet.name()
+      const sheetIdx = spreadInstance.getSheetIndex(sheetName)
+      sheetIdxToRecordCountArray[sheetIdx] = sheetToRecordRowCount[sheetName]
+    }
+    console.log('@@@sheetIdxToRecordCountArray', sheetIdxToRecordCountArray)
+
+    // 一览表
+    let actualCursor = sheetIdxToRecordCountArray[0]
+    if (totalDataCount <= actualCursor) {
+      if (isHiddenContinuePage) {
+        spreadInstance.getSheet(1).visible(false)
+      }
+      return
+    }
+
+    // 一览表+续页表
+    actualCursor = actualCursor + rowCount
+    if (totalDataCount > sheetIdxToRecordCountArray[0] && totalDataCount <= actualCursor) {
+      spreadInstance.getSheet(1).visible(true)
+      return
+    }
+    spreadInstance.getSheet(1).visible(true)
+
+    let continuePageSuffixNum = 2
+    let copyPageIdx = 2
+    while (totalDataCount > actualCursor) {
+      const newSheetName = `${continuePageSheetName}_${continuePageSuffixNum}`
+      spreadInstance.commandManager().execute({
+        cmd: 'copySheet',
+        sheetName: continuePageSheetName,
+        targetIndex: copyPageIdx,
+        newName: newSheetName,
+        includeBindingSource: true,
+      })
+
+      const currentCopiedSheet = spreadInstance.getSheet(copyPageIdx)
+      alterContinuePageBindingPath(currentCopiedSheet, actualCursor + 1, col, row, rowCount, colCount)
+
+      actualCursor += rowCount
+      continuePageSuffixNum += 1
+      copyPageIdx += 1
+    }
+
+    const sheetToFieldListMap = {}
+    for (const sheet of spreadInstance.sheets) {
+      const boundFields = new Set()
+      for (let row = 0; row < sheet.getRowCount(); row++) {
+        for (let col = 0; col < sheet.getColumnCount(); col++) {
+          const bindingPath = sheet.getBindingPath(row, col)
+          if (bindingPath) {
+            boundFields.add(bindingPath)
+          }
+        }
+      }
+      const currentSheetName = sheet.name()
+      sheetToFieldListMap[currentSheetName] = boundFields
+    }
+
+    console.log('@@@validateFieldMap......', sheetToFieldListMap)
+  }
+
+  function alterContinuePageBindingPath(sheet, startNum, startCol, startRow, rowCount, columnCount) {
+    for (let r = startRow; r < startRow + rowCount; r++) {
+      for (let c = startCol; c < startCol + columnCount; c++) {
+        const cellBindingPath = sheet.getBindingPath(r, c)
+        if (cellBindingPath == null) {
+          continue
+        }
+        const bindingPrefix = cellBindingPath.split('_')[0]
+        const newBindingPath = bindingPrefix + '_' + startNum
+        sheet.setBindingPath(r, c, newBindingPath)
+      }
+      startNum += 1
+    }
+  }
+
+  function handleDataJson(dataJson) {
+    const copyConfig = copyConfigGetter()
+    if (!copyConfig?.isContinuePage) {
+      return dataJson
+    }
+
+    const prefixFieldData = {}
+    for (const prefixField of prefixFields) {
+      const cellDataList = []
+      for (let i = 0; i < dataCount; i++) {
+        const cellData = dataJson[`${prefixField}_${i + 1}`]
+        cellDataList.push(cellData)
+      }
+      prefixFieldData[`${prefixField}_1`] = JSON.stringify(cellDataList)
+
+      for (const key in dataJson) {
+        if (key.startsWith(prefixField)) {
+          delete dataJson[key]
+        }
+      }
+    }
+
+    return Object.assign(dataJson, prefixFieldData)
+  }
+
+  function beforeSetDataSource(spreadInstance, resultDataSource) {
+    const copyConfig = copyConfigGetter()
+    if (!copyConfig?.isContinuePage) {
+      return resultDataSource
+    }
+
+    const { dataSource, sheetToRecordRowCount, prefixFieldSet, totalDataCount } =
+      processContinueDataSource(spreadInstance, resultDataSource)
+
+    prefixFields = prefixFieldSet
+    dataCount = totalDataCount
+
+    processContinuePage(spreadInstance, dataSource, sheetToRecordRowCount, totalDataCount)
+
+    return dataSource
+  }
+
+  return {
+    beforeSetDataSource,
+    handleDataJson,
+  }
+}

+ 215 - 0
src/components/SpreadDesigner/composables/useFloatingInput.ts

@@ -0,0 +1,215 @@
+/**
+ * 悬浮输入框状态与事件管理
+ * 包含:输入值、光标、字段名映射、特殊符号弹窗、消息弹窗、键盘高度
+ */
+import { ref, nextTick } from 'vue'
+import GC from '../tools/gc'
+
+export function useFloatingInput() {
+  const floatingInputVisible = ref(false)
+  const floatingInputValue = ref('')
+  const currentCell = ref(null)
+  const currentCellInfo = ref(null)
+  const keyboardHeight = ref(0)
+  const selectedPicture = ref(null)
+  const currentFieldName = ref('-')
+  const fieldNameMapping = ref([])
+  const dialogVisible = ref(false)
+  const dialogMessage = ref('')
+  const floatingInputRef = ref(null)
+  const floatingInputCursor = ref(0)
+  const specialSymbolVisible = ref(false)
+
+  function updateFieldNameDisplay(fieldName) {
+    let displayName = '-'
+    if (fieldNameMapping.value && Array.isArray(fieldNameMapping.value)) {
+      const fieldMapping = fieldNameMapping.value.find((item) => item.field === fieldName)
+      if (fieldMapping && fieldMapping.displayName) {
+        displayName = fieldMapping.displayName
+      }
+    }
+    currentFieldName.value = displayName
+  }
+
+  function showFloatingInput() {
+    floatingInputValue.value = currentCellInfo.value.value
+    floatingInputCursor.value = floatingInputValue.value.length
+    floatingInputVisible.value = true
+  }
+
+  function getFloatingInputElement(event) {
+    return (
+      event?.target ||
+      floatingInputRef.value?.$el ||
+      floatingInputRef.value ||
+      document.getElementById('floatingInput')
+    )
+  }
+
+  function updateFloatingInputCursor(event) {
+    const inputElement = getFloatingInputElement(event)
+    const cursor = event?.detail?.cursor ?? inputElement?.selectionStart
+    if (typeof cursor === 'number') {
+      floatingInputCursor.value = cursor
+    }
+  }
+
+  function handleFloatingInput(event) {
+    updateFloatingInputCursor(event)
+    backfillValueToCell()
+  }
+
+  function openSpecialSymbolDialog() {
+    const inputElement = getFloatingInputElement()
+    const cursor = inputElement?.selectionStart
+    if (typeof cursor === 'number') {
+      floatingInputCursor.value = cursor
+    }
+    specialSymbolVisible.value = true
+  }
+
+  function insertSpecialSymbol(symbol) {
+    const cursor = Math.min(floatingInputCursor.value, floatingInputValue.value.length)
+    floatingInputValue.value = `${floatingInputValue.value.slice(0, cursor)}${symbol}${floatingInputValue.value.slice(cursor)}`
+    floatingInputCursor.value = cursor + symbol.length
+    specialSymbolVisible.value = false
+    backfillValueToCell()
+
+    nextTick(() => {
+      const inputElement = getFloatingInputElement()
+      inputElement?.focus?.()
+      inputElement?.setSelectionRange?.(floatingInputCursor.value, floatingInputCursor.value)
+    })
+  }
+
+  function backfillValueToCell() {
+    if (!currentCell.value || !currentCellInfo.value) {
+      return
+    }
+    const { sheet, row, col } = currentCell.value
+    sheet.setValue(row, col, floatingInputValue.value)
+  }
+
+  function clearInput() {
+    floatingInputValue.value = ''
+    if (!currentCell.value || !currentCellInfo.value) return
+    const { sheet, row, col } = currentCell.value
+    sheet.setValue(row, col, '')
+  }
+
+  function showDialog(message, timer = 3000) {
+    dialogMessage.value = message
+    dialogVisible.value = true
+    setTimeout(() => {
+      dialogVisible.value = false
+    }, timer)
+  }
+
+  function setKeyboardHeight(height) {
+    keyboardHeight.value = height
+  }
+
+  function handleCameraResult(result) {
+    if (result.success && result.imageData) {
+      // 处理相机拍摄结果
+    } else {
+      // 相机拍摄失败或用户取消
+    }
+  }
+
+  /**
+   * 绑定 CellClick + EditChange 事件
+   * CellClick: 点击单元格时更新悬浮输入框状态
+   * EditChange: 用户在单元格内实时编辑时同步文本到悬浮输入框
+   */
+  function initSpreadInputEvents(spreadInstance) {
+    spreadInstance.bind(GC.Spread.Sheets.Events.CellClick, function (sender, args) {
+      const sheet = args.sheet
+      const row = args.row
+      const col = args.col
+
+      selectedPicture.value = null
+      currentCell.value = { sheet, row, col }
+      const cell = sheet.getCell(row, col)
+      const bindingPath = sheet.getBindingPath(row, col) || '-'
+
+      currentCellInfo.value = {
+        value: cell.value() || '',
+        isLocked: false,
+        formatter: cell.formatter(),
+        fieldName: bindingPath,
+      }
+
+      updateFieldNameDisplay(bindingPath)
+      showFloatingInput()
+    })
+
+    // 实时同步:用户在单元格编辑框中输入时,同步到悬浮输入框
+    spreadInstance.bind(GC.Spread.Sheets.Events.EditChange, function (sender, args) {
+      // undefined 时不是真正的编辑输入,跳过;null 表示编辑框已清空,视为 ""
+      if (args.editingText === undefined) return
+      const text = args.editingText ?? ''
+
+      const sheet = args.sheet
+      const row = args.row
+      const col = args.col
+
+      // 如果编辑的单元格与当前选中的一致(按 row/col 匹配),直接同步文本
+      if (
+        currentCell.value &&
+        currentCell.value.row === row &&
+        currentCell.value.col === col
+      ) {
+        floatingInputValue.value = text
+        return
+      }
+
+      // 不一致时(如键盘导航后进入编辑),先更新当前单元格信息再同步
+      selectedPicture.value = null
+      currentCell.value = { sheet, row, col }
+      const cell = sheet.getCell(row, col)
+      const bindingPath = sheet.getBindingPath(row, col) || '-'
+      currentCellInfo.value = {
+        value: cell.value() || '',
+        isLocked: false,
+        formatter: cell.formatter(),
+        fieldName: bindingPath,
+      }
+      updateFieldNameDisplay(bindingPath)
+      floatingInputValue.value = text
+      floatingInputVisible.value = true
+    })
+
+  }
+
+  return {
+    // state
+    floatingInputVisible,
+    floatingInputValue,
+    currentCell,
+    currentCellInfo,
+    keyboardHeight,
+    selectedPicture,
+    currentFieldName,
+    fieldNameMapping,
+    dialogVisible,
+    dialogMessage,
+    floatingInputRef,
+    floatingInputCursor,
+    specialSymbolVisible,
+    // methods
+    updateFieldNameDisplay,
+    showFloatingInput,
+    getFloatingInputElement,
+    updateFloatingInputCursor,
+    handleFloatingInput,
+    openSpecialSymbolDialog,
+    insertSpecialSymbol,
+    backfillValueToCell,
+    clearInput,
+    showDialog,
+    setKeyboardHeight,
+    handleCameraResult,
+    initSpreadInputEvents,
+  }
+}

+ 169 - 0
src/components/SpreadDesigner/composables/usePictureInsert.ts

@@ -0,0 +1,169 @@
+/**
+ * 图片插入与收集
+ * spreadDesigner 专用 composable
+ */
+import GC from '../tools/gc'
+
+export function usePictureInsert({ designer, currentCell, showDialog, getMergedCellRange }) {
+  function getPictureSource(photoData) {
+    if (!photoData?.base64) return ''
+    if (photoData.base64.startsWith('data:')) return photoData.base64
+    return `data:${photoData.type || 'image/jpeg'};base64,${photoData.base64}`
+  }
+
+  function getCellRangeOffset(sheet, row, col) {
+    let x = 0
+    let y = 0
+    for (let c = 0; c < col; c++) {
+      x += sheet.getColumnWidth(c)
+    }
+    for (let r = 0; r < row; r++) {
+      y += sheet.getRowHeight(r)
+    }
+    return { x, y }
+  }
+
+  function getCellRangeSize(sheet, row, col, rowCount, colCount) {
+    let width = 0
+    let height = 0
+    for (let c = 0; c < colCount; c++) {
+      width += sheet.getColumnWidth(col + c)
+    }
+    for (let r = 0; r < rowCount; r++) {
+      height += sheet.getRowHeight(row + r)
+    }
+    return { width, height }
+  }
+
+  function getTargetPictureCell() {
+    if (!designer.value) return null
+    const spreadInstance = designer.value.getWorkbook()
+    const sheet = currentCell.value?.sheet || spreadInstance.getActiveSheet()
+    let row = currentCell.value?.row
+    let col = currentCell.value?.col
+
+    if ((row === null || row === undefined || col === null || col === undefined) && sheet) {
+      row = sheet.getActiveRowIndex?.()
+      col = sheet.getActiveColumnIndex?.()
+    }
+
+    if ((row === null || row === undefined || col === null || col === undefined) && sheet) {
+      const selection = sheet.getSelections?.()?.[0]
+      row = selection?.row
+      col = selection?.col
+    }
+
+    if (
+      row === null ||
+      row === undefined ||
+      col === null ||
+      col === undefined ||
+      row < 0 ||
+      col < 0
+    ) {
+      return null
+    }
+
+    return { sheet, row, col }
+  }
+
+  function handleTakePhotoData(photoData) {
+    console.log('处理图片。。。。。')
+    const targetCell = getTargetPictureCell()
+    if (!targetCell) {
+      showDialog('请先选择需要插入图片的单元格')
+      return false
+    }
+
+    const pictureSource = getPictureSource(photoData)
+    if (!pictureSource) {
+      showDialog('图片数据为空')
+      return false
+    }
+
+    try {
+      const { sheet, row, col } = targetCell
+      const targetRange = getMergedCellRange(sheet, row, col) || {
+        row,
+        col,
+        rowCount: 1,
+        colCount: 1,
+      }
+      const { x, y } = getCellRangeOffset(sheet, targetRange.row, targetRange.col)
+      const { width, height } = getCellRangeSize(
+        sheet,
+        targetRange.row,
+        targetRange.col,
+        targetRange.rowCount,
+        targetRange.colCount,
+      )
+      const pictureName = `addPicture_${targetRange.row}_${targetRange.col}_${Date.now()}`
+      const maxPictureWidth = Math.max(width * 0.72, 1)
+      const maxPictureHeight = Math.max(height * 0.72, 1)
+      const sourceWidth = Number(photoData.width) || maxPictureWidth
+      const sourceHeight = Number(photoData.height) || maxPictureHeight
+      const scale = Math.min(maxPictureWidth / sourceWidth, maxPictureHeight / sourceHeight)
+      const pictureWidth = Math.max(sourceWidth * scale, 1)
+      const pictureHeight = Math.max(sourceHeight * scale, 1)
+      const pictureX = x + (width - pictureWidth) / 2
+      const pictureY = y + (height - pictureHeight) / 2
+      const picture = sheet.shapes.addPictureShape(
+        pictureName,
+        pictureSource,
+        pictureX,
+        pictureY,
+        pictureWidth,
+        pictureHeight,
+      )
+
+      picture.startRow?.(targetRange.row)
+      picture.startColumn?.(targetRange.col)
+      picture.endRow?.(targetRange.row + targetRange.rowCount - 1)
+      picture.endColumn?.(targetRange.col + targetRange.colCount - 1)
+      picture.allowMove?.(true)
+      picture.allowResize?.(true)
+
+      sheet.repaint?.()
+      showDialog('图片已插入')
+      return true
+    } catch (error) {
+      console.error('插入图片失败:', error)
+      showDialog('插入图片失败')
+      return false
+    }
+  }
+
+  function getImages(designerInstance) {
+    const spreadInstance = designerInstance.getWorkbook()
+    const images = []
+    for (const sheet of spreadInstance.sheets) {
+      const shapes = sheet.shapes.all()
+      for (const shape of shapes) {
+        if (shape instanceof GC.Spread.Sheets.Shapes.PictureShape) {
+          if (shape.name().startsWith('签名_')) {
+            continue
+          }
+          images.push({
+            shapeName: shape.name(),
+            sheetName: sheet.name(),
+            imgX: shape.x(),
+            imgY: shape.y(),
+            imgHeight: shape.height(),
+            imgWidth: shape.width(),
+            imgUrl: shape.src(),
+          })
+        }
+      }
+    }
+    return images
+  }
+
+  return {
+    getPictureSource,
+    getCellRangeOffset,
+    getCellRangeSize,
+    getTargetPictureCell,
+    handleTakePhotoData,
+    getImages,
+  }
+}

+ 295 - 0
src/components/SpreadDesigner/composables/useSpreadDesigner.ts

@@ -0,0 +1,295 @@
+/**
+ * Designer 实例管理 + Sheet 操作 + 数据源读写
+ * 管理 designer ref,暴露所有公共 sheet/designer 操作函数
+ */
+import { ref } from 'vue'
+import GC from '../tools/gc'
+import { is, changeStringToObject } from '../tools/spreadUtils'
+import {
+  getSheetBindingPathData,
+  generateDefaultData,
+  deepMergeSchemaValue,
+  generateAndReturnDataSourceOADate,
+} from '../tools/dataSourceProcessor'
+
+export function useSpreadDesigner() {
+  const designer = ref(null)
+
+  /**
+   * 监听单元格值变化,同步其他 sheet 的 dataSource
+   */
+  function registerCellValuesChangeEventHandlerForEverySheet(sheets) {
+    sheets.forEach((activedSheet) => {
+      activedSheet.bind(GC.Spread.Sheets.Events.ValueChanged, function (sender, args) {
+        const bindingPathName = activedSheet.getBindingPath(args.row, args.col)
+        if (bindingPathName) {
+          try {
+            for (const sheet of sheets) {
+              const dataSource = sheet.getDataSource()?.getSource() || {}
+              if (
+                !dataSource.hasOwnProperty(bindingPathName) ||
+                (activedSheet.name() === sheet.name() && activedSheet.__ID__ === sheet.__ID__)
+              )
+                continue
+
+              const tables = sheet.tables?.all() || []
+              for (let i = 0; i < tables.length; i++) {
+                const table = sheet.tables.all()[i]
+                table.expandBoundRows(true)
+              }
+              const newDataSource = new GC.Spread.Sheets.Bindings.CellBindingSource({
+                ...dataSource,
+                [bindingPathName]: args.newValue,
+              })
+              sheet.setDataSource(newDataSource)
+            }
+          } catch (err) {
+            console.error('监听单元格出错啦', err)
+          }
+        }
+      })
+    })
+  }
+
+  function registerZoomEventHandler(designerInstance) {
+    if (!designerInstance) return
+    const spreadInstance = designerInstance?.getWorkbook()
+    if (Object.prototype.toString.call(spreadInstance) !== '[object Object]') return
+    spreadInstance.bind(GC.Spread.Sheets.Events.ViewZooming, function (sender, args) {
+      const activedSheet = spreadInstance.getActiveSheet()
+      const minZoom = calcSheetZoom(activedSheet)
+      if (args.newZoomFactor < minZoom) args.cancel = true
+    })
+  }
+
+  function calcSheetZoom(sheet) {
+    const screenWidth = window.screen.width
+    const usedRange = sheet.getUsedRange(GC.Spread.Sheets.UsedRangeType.style)
+    sheet.showCell(usedRange.row, usedRange.col)
+
+    const scrollbarWidth = 40
+    let totalWidth = 0
+    for (let col = usedRange.col; col <= usedRange.colCount - 1; col++) {
+      totalWidth += sheet.getColumnWidth(col)
+    }
+    return 1 + (screenWidth - scrollbarWidth - totalWidth) / totalWidth
+  }
+
+  function initDesignerSheetConfig(sheet) {
+    sheet?.zoom(1)
+    sheet?.zoom(calcSheetZoom(sheet))
+    sheet.options.rowHeaderVisible = false
+    sheet.options.colHeaderVisible = false
+    sheet.setActiveCell(null)
+  }
+
+  async function setDefaultSchema(designerInstance, bindingPathSchema) {
+    const initBindingPathSchema = !bindingPathSchema ? {} : changeStringToObject(bindingPathSchema)
+    await designerInstance.setData('treeNodeFromJson', JSON.stringify(initBindingPathSchema))
+  }
+
+  function getDefaultSchema(designerInstance) {
+    return (
+      designerInstance.getData('updatedTreeNode') ||
+      designerInstance.getData('treeNodeFromJson') ||
+      designerInstance.getData('oldTreeNodeFromJson')
+    )
+  }
+
+  /**
+   * 设置数据到模板文件,渲染对应数据
+   * @param beforeSetDataSource 可选钩子,在数据合并完成后、写入 sheet 前调用。
+   *        接收 (spreadInstance, resultDataSource),返回处理后的 dataSource。
+   *        用于续页等特殊处理(spreadDesigner 传入,generic 不传)。
+   */
+  function setDataSource(designerInstance, schemaData, dataSourceValues, beforeSetDataSource) {
+    dataSourceValues =
+      !is(dataSourceValues, 'Object') && is(dataSourceValues, 'String')
+        ? JSON.parse(dataSourceValues)
+        : dataSourceValues || {}
+    schemaData =
+      !is(schemaData, 'Object') && is(schemaData, 'String')
+        ? JSON.parse(schemaData)
+        : schemaData || {}
+
+    const formatterSource = generateDefaultData(schemaData)
+    const resultDataSource = deepMergeSchemaValue(formatterSource, dataSourceValues)
+
+    const spreadInstance = designerInstance.getWorkbook()
+
+    // 续页等特殊处理钩子
+    let finalDataSource = resultDataSource
+    if (beforeSetDataSource) {
+      finalDataSource = beforeSetDataSource(spreadInstance, resultDataSource) || resultDataSource
+    }
+
+    registerCellValuesChangeEventHandlerForEverySheet(spreadInstance.sheets)
+    for (const sheet of spreadInstance.sheets) {
+      const tables = sheet.tables?.all() || []
+      for (let i = 0; i < tables.length; i++) {
+        const table = tables[i]
+        table.expandBoundRows(true)
+      }
+      const filterResultDataSource = getSheetBindingPathData(sheet, finalDataSource)
+      const dataSource = new GC.Spread.Sheets.Bindings.CellBindingSource(filterResultDataSource)
+      sheet.setDataSource(dataSource)
+    }
+  }
+
+  function getDataSource(designerInstance) {
+    const spreadInstance = designerInstance.getWorkbook()
+
+    let dataSource = {}
+    for (const sheet of spreadInstance.sheets) {
+      const source = sheet.getDataSource()?.getSource()
+      if (source) {
+        for (const key in source) {
+          if (is(source[key], 'Object') && is(dataSource[key], 'Object')) {
+            source[key] = Object.assign(dataSource[key], source[key])
+          }
+        }
+        dataSource = {
+          ...dataSource,
+          ...source,
+        }
+      }
+    }
+    dataSource = generateAndReturnDataSourceOADate(dataSource)
+    return dataSource
+  }
+
+  function handleSheetTableCopyTo(designerInstance, isRowMerage) {
+    if (!designerInstance) return
+    const spreadInstance = designerInstance.getWorkbook()
+    for (const sheet of spreadInstance.sheets) {
+      const tables = sheet.tables?.all() || []
+
+      for (let tableIndex = 0; tableIndex < tables.length; tableIndex++) {
+        const range = tables[tableIndex].range()
+        const { row, rowCount, col, colCount } = range
+
+        sheet.getRange(row, col, rowCount, colCount).wordWrap(true)
+
+        if (isRowMerage) {
+          const firstRowSpans = []
+          for (let c = col; c < col + colCount; c++) {
+            const span = sheet.getSpan(row, c)
+            if (span && span.row === row) {
+              firstRowSpans.push({
+                startCol: span.col,
+                colCount: span.colCount,
+              })
+              c += span.colCount - 1
+            }
+          }
+
+          if (firstRowSpans.length === 0) {
+            const firstRowValues = []
+            for (let c = col; c < col + colCount; c++) {
+              firstRowValues.push(sheet.getValue(row, c))
+            }
+
+            let startMergeCol = 0
+            for (let c = 1; c <= colCount; c++) {
+              if (c === colCount || firstRowValues[c] !== firstRowValues[c - 1]) {
+                if (c - startMergeCol > 1) {
+                  firstRowSpans.push({
+                    startCol: col + startMergeCol,
+                    colCount: c - startMergeCol,
+                  })
+                  sheet.addSpan(row, col + startMergeCol, 1, c - startMergeCol)
+                }
+                startMergeCol = c
+              }
+            }
+          }
+
+          for (let r = 1; r < rowCount; r++) {
+            const currentRow = row + r
+            firstRowSpans.forEach((mergeInfo) => {
+              try {
+                const firstCellValue = sheet.getValue(currentRow, mergeInfo.startCol)
+                let shouldMerge = true
+
+                for (let c = 1; c < mergeInfo.colCount; c++) {
+                  const currentCellValue = sheet.getValue(currentRow, mergeInfo.startCol + c)
+                  if (currentCellValue !== firstCellValue) {
+                    shouldMerge = false
+                    break
+                  }
+                }
+
+                if (shouldMerge) {
+                  sheet.addSpan(currentRow, mergeInfo.startCol, 1, mergeInfo.colCount)
+                }
+              } catch (error) {
+                console.warn(
+                  `合并单元格失败 at (${currentRow}, ${mergeInfo.startCol}):`,
+                  error.message,
+                )
+              }
+            })
+          }
+        }
+
+        for (let rowIndex = 1; rowIndex < rowCount; rowIndex++) {
+          const currentRow = row + rowIndex
+          sheet?.copyTo(
+            row,
+            col,
+            currentRow,
+            col,
+            1,
+            colCount,
+            GC.Spread.Sheets.CopyToOptions.style | GC.Spread.Sheets.CopyToOptions.formula,
+          )
+        }
+
+        setTimeout(() => {
+          for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
+            const currentRow = row + rowIndex
+            sheet?.autoFitRow(currentRow)
+            const currentHeight = sheet.getRowHeight(currentRow)
+            const newHeight = Math.max(currentHeight * 1.2, 25)
+            sheet.setRowHeight(currentRow, newHeight)
+          }
+        }, 100)
+      }
+
+      const currentTheme = sheet.currentTheme()
+      currentTheme.bodyFont('仿宋_GB2312')
+    }
+  }
+
+  function removeSheetCellFocus(designerInstance) {
+    const spreadInstance = designerInstance.getWorkbook()
+    if (spreadInstance) {
+      spreadInstance.focus(false)
+      for (const sheet of spreadInstance?.sheets) {
+        sheet.clearSelection()
+      }
+    }
+  }
+
+  function listenActiveSheetChange(spreadInstance) {
+    spreadInstance.bind(GC.Spread.Sheets.Events.ActiveSheetChanged, function (sender, args) {
+      const newSheet = args.newSheet
+      initDesignerSheetConfig(newSheet)
+    })
+  }
+
+  return {
+    designer,
+    registerZoomEventHandler,
+    calcSheetZoom,
+    initDesignerSheetConfig,
+    setDefaultSchema,
+    getDefaultSchema,
+    setDataSource,
+    getDataSource,
+    handleSheetTableCopyTo,
+    removeSheetCellFocus,
+    registerCellValuesChangeEventHandlerForEverySheet,
+    listenActiveSheetChange,
+  }
+}

Разлика између датотеке није приказан због своје велике величине
+ 109 - 1778
src/components/SpreadDesigner/spreadDesigner.vue


Разлика између датотеке није приказан због своје велике величине
+ 133 - 1217
src/components/SpreadDesigner/spreadDesignerGeneric.vue


+ 196 - 0
src/components/SpreadDesigner/tools/dataSourceProcessor.ts

@@ -0,0 +1,196 @@
+/**
+ * 数据源管线 —— schema 默认值生成、深度合并、绑定路径过滤、OADate 格式化
+ */
+import dayjs from 'dayjs'
+import { is, formatterOADateNumber } from './spreadUtils'
+
+/**
+ * 只过滤出当前 sheet 页需要的字段
+ */
+export function getSheetBindingPathData(sheet, dataSource) {
+  const boundFields = new Set()
+  const tableMap = new Map()
+
+  for (let row = 0; row < sheet.getRowCount(); row++) {
+    for (let col = 0; col < sheet.getColumnCount(); col++) {
+      const bindingPath = sheet.getBindingPath(row, col)
+      if (bindingPath) {
+        boundFields.add(bindingPath)
+      }
+    }
+  }
+
+  const tables = sheet.tables.all() || []
+  if (tables.length > 0) {
+    tables.forEach(function (table) {
+      const tableName = table.bindingPath()
+      boundFields.add(tableName)
+      const tableBoundFields = new Set()
+      const columnCount = table.range().colCount
+      for (let i = 0; i < columnCount; i++) {
+        const field = table.getColumnDataField(i)
+        if (field) {
+          tableBoundFields.add(field)
+        }
+      }
+      tableMap.set(tableName, [...tableBoundFields])
+    })
+  }
+
+  const boundFieldsArray = [...boundFields].filter(Boolean)
+  if (boundFieldsArray.length === 0) return {}
+
+  function recombineDataSource(dataSourceObj, prekey, boundFieldsArray) {
+    const filterResult = {}
+    for (const key in dataSourceObj) {
+      if (is(dataSourceObj[key], 'Object')) {
+        const nextKey = !prekey ? key : `${prekey}.${key}`
+        if (boundFieldsArray.findIndex((field) => field.indexOf(nextKey) >= 0) >= 0) {
+          filterResult[key] = recombineDataSource(dataSourceObj[key], nextKey, boundFieldsArray)
+        }
+        continue
+      }
+
+      if (is(dataSourceObj[key], 'Array') && dataSourceObj[key].every((x) => is(x, 'Object'))) {
+        if (boundFieldsArray.includes(key)) {
+          filterResult[key] = Object.entries(dataSourceObj[key]).map(([i, item]) => {
+            if (is(item, 'String')) return item
+            const tableBoundFieldArrays = tableMap.get(key)
+            return recombineDataSource(item, '', tableBoundFieldArrays)
+          })
+        }
+        continue
+      }
+
+      if (boundFieldsArray.includes(!prekey ? key : `${prekey}.${key}`)) {
+        filterResult[key] = dataSourceObj[key]
+      }
+    }
+    return filterResult
+  }
+
+  const newDataSource = recombineDataSource(dataSource, '', boundFieldsArray)
+  boundFields.clear()
+  tableMap.clear()
+  return newDataSource
+}
+
+/**
+ * 生成属性键,并填充对应默认值
+ */
+export function generateDefaultData(schema) {
+  const result = {}
+  for (const key in schema.properties) {
+    result[key] = generatePropertyDefaultValue(schema.properties[key])
+  }
+  return result
+}
+
+/**
+ * 为属性填充默认值
+ */
+export function generatePropertyDefaultValue(property) {
+  if (property.type === 'array' && property.items) {
+    if (property.items.type !== 'object' || !property.items.properties) {
+      return ['']
+    }
+    const itemValue = {}
+    for (const itemKey in property.items.properties) {
+      itemValue[itemKey] = generatePropertyDefaultValue(property.items.properties[itemKey])
+    }
+    return [itemValue]
+  }
+
+  if (property.properties) {
+    const objValue = {}
+    for (const objKey in property.properties) {
+      objValue[objKey] = generatePropertyDefaultValue(property.properties[objKey])
+    }
+    return objValue
+  }
+
+  return ''
+}
+
+/**
+ * 为属性填充非默认数据值
+ * @param target 属性值为默认值的键值数据容器
+ * @param source 填充数据到键值容器的数据源头
+ */
+export function deepMergeSchemaValue(target, source) {
+  const result = { ...target }
+
+  for (const key in source) {
+    if (source.hasOwnProperty(key)) {
+      const targetValue = target[key]
+      const sourceValue = source[key]
+
+      if (targetValue === undefined) {
+        continue
+      }
+
+      if (Array.isArray(targetValue) && Array.isArray(sourceValue)) {
+        result[key] = sourceValue.length > 0 ? sourceValue : targetValue
+      } else if (
+        typeof targetValue === 'object' &&
+        targetValue !== null &&
+        typeof sourceValue === 'object' &&
+        sourceValue !== null
+      ) {
+        result[key] =
+          Object.keys(sourceValue).length === 0
+            ? targetValue
+            : deepMergeSchemaValue(targetValue, sourceValue)
+      } else if (sourceValue !== undefined && sourceValue !== '') {
+        const trimmedSourceValue = sourceValue.trim()
+        // JSON字符串需要解析为对象或数组
+        if (
+          typeof trimmedSourceValue === 'string' &&
+          (trimmedSourceValue.startsWith('{') || trimmedSourceValue.startsWith('['))
+        ) {
+          result[key] = JSON.parse(trimmedSourceValue)
+        } else {
+          result[key] = trimmedSourceValue
+        }
+      }
+    }
+  }
+
+  return result
+}
+
+/**
+ * 遍历数据源,将 OADate 格式或 Date 对象统一格式化为 "YYYY年MM月DD日"
+ */
+export function generateAndReturnDataSourceOADate(dataSource) {
+  if (!is(dataSource, 'Object') && !is(dataSource, 'Array')) return {}
+
+  if (is(dataSource, 'Object')) {
+    for (const key in dataSource) {
+      if (is(dataSource[key], 'String') && dataSource[key].indexOf('OADate') >= 0) {
+        dataSource[key] = dayjs(formatterOADateNumber(dataSource[key])).format('YYYY年MM月DD日')
+        continue
+      } else if (is(dataSource[key], 'Date')) {
+        dataSource[key] = dayjs(dataSource[key]).format('YYYY年MM月DD日')
+        continue
+      }
+      if (is(dataSource[key], 'Object') || is(dataSource[key], 'Array')) {
+        dataSource[key] = generateAndReturnDataSourceOADate(dataSource[key])
+        continue
+      }
+    }
+  }
+
+  if (is(dataSource, 'Array')) {
+    return dataSource.map((keyItem) => {
+      if (is(keyItem, 'String') && keyItem.indexOf('OADate') >= 0) {
+        return dayjs(formatterOADateNumber(keyItem)).format('YYYY年MM月DD日')
+      }
+      if (is(keyItem, 'Object') || is(keyItem, 'Array')) {
+        return generateAndReturnDataSourceOADate(keyItem)
+      }
+      return keyItem
+    })
+  }
+  return dataSource
+}

+ 52 - 0
src/components/SpreadDesigner/tools/spreadUtils.ts

@@ -0,0 +1,52 @@
+/**
+ * 纯工具函数 —— 类型判断、数据转换、日期解析
+ * 无组件状态依赖、无副作用(除类型转换的 console.error)
+ */
+
+export function is(val, type) {
+  return Object.prototype.toString.call(val) === `[object ${type}]`
+}
+
+export function changeStringToObject(json) {
+  if (is(json, 'String') && json?.length) {
+    return changeStringToObject(JSON.parse(json))
+  } else if (is(json, 'Object')) {
+    return json
+  } else {
+    console.error('数据类型不正确!')
+    return null
+  }
+}
+
+export function base64ToBlob(base64Data, contentType = '') {
+  const byteCharacters = atob(base64Data)
+  const byteNumbers = new Array(byteCharacters.length)
+  for (let i = 0; i < byteCharacters.length; i++) {
+    byteNumbers[i] = byteCharacters.charCodeAt(i)
+  }
+  const byteArray = new Uint8Array(byteNumbers)
+  return new Blob([byteArray], { type: contentType })
+}
+
+export function blobToBase64(blob) {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onload = () => {
+      const base64 = reader.result.split(',')[1]
+      resolve(base64)
+    }
+    reader.onerror = reject
+    reader.readAsDataURL(blob)
+  })
+}
+
+export function formatterOADateNumber(input) {
+  const regex = /OADate\(([\d.]+)\)/
+  const match = input.match(regex)
+  let OADate = null
+  if (match && match[1]) {
+    OADate = Number(match[1])
+    OADate = isNaN(OADate) ? null : parseInt(OADate)
+  }
+  return OADate !== null ? new Date(Math.round(OADate - 25569) * 86400000) : null
+}

+ 1 - 0
src/components/components.d.ts

@@ -4,6 +4,7 @@ AreaCascader: typeof import('./AreaCascader/AreaCascader.vue')['default']
 Cascader: typeof import('./Cascader/Cascader.vue')['default']
 DeptSelect: typeof import('./DeptSelect/DeptSelect.vue')['default']
 DictSelect: typeof import('./DictSelect/DictSelect.vue')['default']
+EndDatePrompt: typeof import('./EndDatePrompt/EndDatePrompt.vue')['default']
 NavBar: typeof import('./NavBar/NavBar.vue')['default']
 PageLayout: typeof import('./PageLayout/PageLayout.vue')['default']
 RadioFilterBar: typeof import('./RadioFilterBar/RadioFilterBar.vue')['default']