@veltra/desktop 1.7.13 → 1.7.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/components/batch-edit/batch-edit-form.js +9 -3
  2. package/dist/components/batch-edit/batch-edit-form.js.map +1 -1
  3. package/dist/components/batch-edit/batch-edit-list.js +2 -1
  4. package/dist/components/batch-edit/batch-edit-list.js.map +1 -1
  5. package/dist/components/batch-edit/batch-edit.js +57 -30
  6. package/dist/components/batch-edit/batch-edit.js.map +1 -1
  7. package/dist/components/batch-edit/style.d.ts +1 -0
  8. package/dist/components/batch-edit/style.js +1 -0
  9. package/dist/components/batch-edit/style2.css +14 -0
  10. package/dist/components/batch-edit/use-dialog-effect.js +20 -0
  11. package/dist/components/batch-edit/use-dialog-effect.js.map +1 -0
  12. package/dist/components/batch-edit/use-edit-state.js +22 -3
  13. package/dist/components/batch-edit/use-edit-state.js.map +1 -1
  14. package/dist/components/batch-edit/use-handlers.js +5 -0
  15. package/dist/components/batch-edit/use-handlers.js.map +1 -1
  16. package/dist/components/batch-edit/use-shortcut-key.js +5 -2
  17. package/dist/components/batch-edit/use-shortcut-key.js.map +1 -1
  18. package/dist/components/button/button.js +2 -2
  19. package/dist/components/button/button.js.map +1 -1
  20. package/dist/components/file-viewer/file-viewer.js +21 -14
  21. package/dist/components/file-viewer/file-viewer.js.map +1 -1
  22. package/dist/components/file-viewer/helper.js +2 -0
  23. package/dist/components/file-viewer/helper.js.map +1 -1
  24. package/dist/components/file-viewer/previewers/ofd-previewer.js +196 -0
  25. package/dist/components/file-viewer/previewers/ofd-previewer.js.map +1 -0
  26. package/dist/components/file-viewer/style2.css +27 -0
  27. package/dist/components/form-item/style2.css +17 -2
  28. package/dist/components/table-editor/style2.css +48 -5
  29. package/dist/components/table-editor/table-editor.js +228 -63
  30. package/dist/components/table-editor/table-editor.js.map +1 -1
  31. package/dist/components/tip/style2.css +63 -0
  32. package/dist/components/tip/tip.js +6 -1
  33. package/dist/components/tip/tip.js.map +1 -1
  34. package/dist/index.d.ts +3 -3
  35. package/dist/packages/ofd-core/dist/color.js +69 -0
  36. package/dist/packages/ofd-core/dist/color.js.map +1 -0
  37. package/dist/packages/ofd-core/dist/ctm.js +55 -0
  38. package/dist/packages/ofd-core/dist/ctm.js.map +1 -0
  39. package/dist/packages/ofd-core/dist/error.js +14 -0
  40. package/dist/packages/ofd-core/dist/error.js.map +1 -0
  41. package/dist/packages/ofd-core/dist/font.js +384 -0
  42. package/dist/packages/ofd-core/dist/font.js.map +1 -0
  43. package/dist/packages/ofd-core/dist/index.js +6 -0
  44. package/dist/packages/ofd-core/dist/number.js +13 -0
  45. package/dist/packages/ofd-core/dist/number.js.map +1 -0
  46. package/dist/packages/ofd-core/dist/page-objects.js +181 -0
  47. package/dist/packages/ofd-core/dist/page-objects.js.map +1 -0
  48. package/dist/packages/ofd-core/dist/parse.js +65 -0
  49. package/dist/packages/ofd-core/dist/parse.js.map +1 -0
  50. package/dist/packages/ofd-core/dist/render.js +361 -0
  51. package/dist/packages/ofd-core/dist/render.js.map +1 -0
  52. package/dist/packages/ofd-core/dist/xml.js +199 -0
  53. package/dist/packages/ofd-core/dist/xml.js.map +1 -0
  54. package/dist/packages/ofd-core/dist/zip.js +93 -0
  55. package/dist/packages/ofd-core/dist/zip.js.map +1 -0
  56. package/dist/style.d.ts +1 -1
  57. package/dist/style.js +1 -1
  58. package/dist/types/batch-edit.d.ts +6 -0
  59. package/dist/types/file-viewer.d.ts +1 -1
  60. package/dist/types/index.d.ts +3 -3
  61. package/dist/types/table-editor.d.ts +28 -7
  62. package/dist/types/tip.d.ts +5 -1
  63. package/package.json +8 -7
  64. package/src/components/batch-edit/__test__/batch-edit.test.ts +191 -4
  65. package/src/components/batch-edit/batch-edit-form.vue +17 -3
  66. package/src/components/batch-edit/batch-edit-list.vue +2 -1
  67. package/src/components/batch-edit/batch-edit.vue +22 -4
  68. package/src/components/batch-edit/style.scss +20 -0
  69. package/src/components/batch-edit/style.ts +1 -0
  70. package/src/components/batch-edit/use-dialog-effect.ts +9 -3
  71. package/src/components/batch-edit/use-edit-state.ts +32 -4
  72. package/src/components/batch-edit/use-handlers.ts +10 -0
  73. package/src/components/batch-edit/use-shortcut-key.ts +6 -2
  74. package/src/components/button/button.vue +3 -2
  75. package/src/components/file-viewer/__test__/helper.test.ts +13 -0
  76. package/src/components/file-viewer/file-viewer.vue +23 -16
  77. package/src/components/file-viewer/helper.ts +2 -0
  78. package/src/components/file-viewer/previewers/ofd-previewer.vue +216 -0
  79. package/src/components/file-viewer/style.scss +33 -0
  80. package/src/components/form-item/style.scss +11 -1
  81. package/src/components/table-editor/__test__/table-editor.test.ts +479 -0
  82. package/src/components/table-editor/style.scss +57 -5
  83. package/src/components/table-editor/table-editor.vue +286 -38
  84. package/src/components/tip/style.scss +15 -0
  85. package/src/components/tip/tip.vue +1 -1
  86. package/src/types/batch-edit.ts +6 -0
  87. package/src/types/file-viewer.ts +1 -1
  88. package/src/types/table-editor.ts +29 -7
  89. package/src/types/tip.ts +6 -1
@@ -9,15 +9,29 @@
9
9
  @focusin="handleFocusIn"
10
10
  @focusout="handleFocusOut"
11
11
  @keydown.capture="handleKeydown"
12
+ v-bind="$attrs"
12
13
  >
13
14
  <!-- 编辑列表 -->
14
15
  <BatchEditList :slots="slots" />
15
16
 
16
17
  <!-- 编辑表单 -->
17
- <BatchEditForm ref="form" v-slot="scoped">
18
+ <BatchEditForm v-if="props.formMode === 'panel'" ref="form" v-slot="scoped">
18
19
  <slot name="form" v-bind="scoped" />
19
20
  </BatchEditForm>
20
21
  </u-layout>
22
+
23
+ <!-- 弹框模式:表单经 UDialog 呈现(teleport 到 body),快捷键监听落在弹框上,Esc 与弹框自带的 keyup.esc 去重 -->
24
+ <u-dialog
25
+ v-if="props.formMode === 'dialog'"
26
+ v-model="state.formVisible"
27
+ :class="cls.e('form-dialog')"
28
+ @keydown.capture="handleKeydown"
29
+ @keyup.esc.stop
30
+ >
31
+ <BatchEditForm ref="form" v-slot="scoped">
32
+ <slot name="form" v-bind="scoped" />
33
+ </BatchEditForm>
34
+ </u-dialog>
21
35
  </template>
22
36
 
23
37
  <script lang="ts" setup>
@@ -25,6 +39,7 @@ import { bem } from '@veltra/utils'
25
39
  import { computed, provide, useTemplateRef } from 'vue'
26
40
 
27
41
  import type { BatchEditEmits, BatchEditProps, BatchEditSlots, FormExposed } from '../../types'
42
+ import { UDialog } from '../dialog'
28
43
  import { ULayout } from '../layout'
29
44
  import BatchEditForm from './batch-edit-form.vue'
30
45
  import BatchEditList from './batch-edit-list.vue'
@@ -34,9 +49,12 @@ import { useFeatures } from './use-features'
34
49
  import { useHandlers } from './use-handlers'
35
50
  import { useShortcutKey } from './use-shortcut-key'
36
51
 
37
- defineOptions({ name: 'UBatchEdit' })
52
+ defineOptions({ name: 'UBatchEdit', inheritAttrs: false })
38
53
 
39
- const props = withDefaults(defineProps<BatchEditProps>(), { cols: () => ['1fr', '420px'] })
54
+ const props = withDefaults(defineProps<BatchEditProps>(), {
55
+ cols: () => ['1fr', '420px'],
56
+ formMode: 'panel'
57
+ })
40
58
 
41
59
  const emit = defineEmits<BatchEditEmits>()
42
60
 
@@ -61,7 +79,7 @@ const { handleFocusIn, handleFocusOut, handleKeydown, focused } = useShortcutKey
61
79
  })
62
80
 
63
81
  const cols = computed(() => {
64
- return state.formVisible ? props.cols : undefined
82
+ return props.formMode === 'panel' && state.formVisible ? props.cols : undefined
65
83
  })
66
84
 
67
85
  provide(BatchEditDIKey, {
@@ -52,6 +52,14 @@ $root-name: batch-edit;
52
52
  overflow: hidden;
53
53
  isolation: isolate;
54
54
  min-height: 0;
55
+
56
+ // 弹框模式:背景 / 圆角 / 阴影由弹框外壳提供,表单只保留内部布局并限高滚动
57
+ @include m.is(dialog) {
58
+ max-height: min(600px, 80vh);
59
+ background-color: transparent;
60
+ border-radius: 0;
61
+ box-shadow: none;
62
+ }
55
63
  }
56
64
 
57
65
  @include m.e(form-header) {
@@ -171,3 +179,15 @@ $root-name: batch-edit;
171
179
  flex-shrink: 0;
172
180
  }
173
181
  }
182
+
183
+ // ---------- 弹框模式 ----------
184
+ // 弹框 teleport 到 body,不在 .u-batch-edit 内,样式置于顶层
185
+ #{fn.bem(batch-edit, form-dialog)} {
186
+ width: 560px;
187
+ max-width: calc(100vw - 32px);
188
+
189
+ // 表单自带头部(图标 / 标题 / 关闭按钮),弹框头部不再展示
190
+ #{fn.bem(dialog, header)} {
191
+ display: none;
192
+ }
193
+ }
@@ -1,5 +1,6 @@
1
1
  import '../action/style'
2
2
  import '../button/style'
3
+ import '../dialog/style'
3
4
  import '../form/style'
4
5
  import '../icon/style'
5
6
  import '../layout/style'
@@ -10,12 +10,18 @@ interface Options {
10
10
  /**
11
11
  * 在表单上的副作用。
12
12
  * 包括:关闭表单
13
+ *
14
+ * sync 触发:弹框关闭会随即卸载表单组件,默认 pre 时序下 watcher 可能随组件卸载被丢弃
13
15
  */
14
16
  export function useDialogEffect(options: Options) {
15
17
  const dialogCtx = inject(DialogDIKey, undefined)
16
18
  if (!dialogCtx) return
17
19
 
18
- watch(dialogCtx.visible, (visible) => {
19
- !visible && options.closeForm?.()
20
- })
20
+ watch(
21
+ dialogCtx.visible,
22
+ (visible) => {
23
+ !visible && options.closeForm?.()
24
+ },
25
+ { flush: 'sync' }
26
+ )
21
27
  }
@@ -1,4 +1,4 @@
1
- import { o } from '@cat-kit/core'
1
+ import { copy, o } from '@cat-kit/core'
2
2
  import { nextTick, shallowReactive, shallowRef, watch, type ShallowRef } from 'vue'
3
3
 
4
4
  import type { BatchEditProps, BatchEditStates, FormExposed } from '../../types'
@@ -24,9 +24,37 @@ export function useEditState(options: Options) {
24
24
  /** 是否正在以编程方式重置/回显表单,此期间禁止 quick-edit 回写行数据 */
25
25
  const syncing = shallowRef(false)
26
26
 
27
+ /**
28
+ * model 初始快照。
29
+ * 面板模式由常驻挂载的 UForm 自行维护快照;dialog 模式表单随弹框卸载,
30
+ * UForm 快照会在每次打开时按已回显的 model 重拍,故这里单独保留一份供恢复
31
+ */
32
+ const initialModel = shallowRef<Record<string, any>>()
33
+
34
+ watch(
35
+ () => props.model,
36
+ (model) => {
37
+ initialModel.value = props.formMode === 'dialog' && model ? copy(model) : undefined
38
+ },
39
+ { immediate: true }
40
+ )
41
+
42
+ /** 重置表单数据到 model 初始快照 */
43
+ function resetModel() {
44
+ if (props.formMode !== 'dialog') {
45
+ formRef.value?.reset()
46
+ return
47
+ }
48
+
49
+ formRef.value?.clearValidate()
50
+ if (!props.model || !initialModel.value) return
51
+ for (const key of Object.keys(initialModel.value)) {
52
+ o(props.model).set(key, copy(initialModel.value[key]))
53
+ }
54
+ }
55
+
27
56
  function resetState() {
28
- // 表单常驻挂载,重置即恢复到 model 初始快照
29
- formRef.value?.reset()
57
+ resetModel()
30
58
 
31
59
  if (state.row) {
32
60
  state.row.isCurrent = false
@@ -47,7 +75,7 @@ export function useEditState(options: Options) {
47
75
  // 先重置回 model 初始快照,再同步回显行数据。
48
76
  // 同一 tick 内的连续写入会被表单字段 watcher 合并,
49
77
  // 重置产生的默认值不会触发 field:update 回写行数据
50
- formRef.value?.reset()
78
+ resetModel()
51
79
 
52
80
  if (row) {
53
81
  if (props.model) {
@@ -170,6 +170,12 @@ export function useHandlers(options: Options): EditReturned {
170
170
  }
171
171
  insert(item)
172
172
 
173
+ // 弹框模式保存成功即关闭,不走面板的连续新增
174
+ if (props.formMode === 'dialog') {
175
+ resetState()
176
+ return
177
+ }
178
+
173
179
  // 推进插入点,便于连续新增;并重置表单到初始默认值
174
180
  const next = last(state.indexPath) + 1
175
181
  state.indexPath = [...state.indexPath.slice(0, -1), next]
@@ -190,6 +196,10 @@ export function useHandlers(options: Options): EditReturned {
190
196
  Object.keys(item).forEach((key) => {
191
197
  o(row.data).set(key, o(item).get(key))
192
198
  })
199
+
200
+ if (props.formMode === 'dialog') {
201
+ resetState()
202
+ }
193
203
  })
194
204
 
195
205
  const handleDelete = runWithLoading(async (row: TableRow) => {
@@ -38,12 +38,16 @@ export function useShortcutKey(options: Options) {
38
38
  }
39
39
 
40
40
  /**
41
- * 键盘快捷键(仅组件获焦时生效):
41
+ * 键盘快捷键:
42
42
  * - Esc 关闭表单
43
43
  * - ⌘/Ctrl + S 保存(快速编辑模式下编辑行时除外)
44
+ *
45
+ * 面板模式仅组件获焦时生效;弹框模式表单被 teleport 到 body,
46
+ * 组件聚焦状态覆盖不到,改为弹框打开期间生效
44
47
  */
45
48
  function handleKeydown(e: KeyboardEvent) {
46
- if (!focused.value) return
49
+ const active = props.formMode === 'dialog' ? state.formVisible : focused.value
50
+ if (!active) return
47
51
  if (props.readonly && e.key !== 'Escape') return
48
52
 
49
53
  if (e.key === 'Escape') return runEscape(e)
@@ -58,8 +58,9 @@ const classList = computed(() => {
58
58
  })
59
59
 
60
60
  const ripple = computed(() => {
61
- if (props.disabled || props.loading || props.text) return false
62
- if (props.plain && props.type) {
61
+ if (props.disabled || props.loading) return false
62
+ // plain / text 变体无底色,波纹用类型色才能看清
63
+ if ((props.plain || props.text) && props.type) {
63
64
  return bem.is(`ripple-${props.type}`)
64
65
  }
65
66
  return true
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { FILE_VIEWER_KIND_LABEL, inferKind } from '../helper'
4
+
5
+ describe('file-viewer helper', () => {
6
+ it('.ofd 按 OFD 类别识别,不落入 text 兜底', () => {
7
+ expect(inferKind('invoice.ofd')).toBe('ofd')
8
+ })
9
+
10
+ it('OFD 类别的展示标签为 OFD', () => {
11
+ expect(FILE_VIEWER_KIND_LABEL.ofd).toBe('OFD')
12
+ })
13
+ })
@@ -169,7 +169,7 @@
169
169
  :class="isTransformable ? cls.e('previewer') : undefined"
170
170
  :style="previewerStyle"
171
171
  @error="handleChildError"
172
- @zoom-change="handlePdfZoomChange"
172
+ @zoom-change="handlePreviewerZoomChange"
173
173
  />
174
174
  </div>
175
175
  <div v-else :class="cls.e('empty')">
@@ -238,10 +238,12 @@ const MIN_SCALE = 0.5
238
238
  const MAX_SCALE = 3
239
239
  /** 每次缩放固定增减 10% */
240
240
  const SCALE_STEP = 0.1
241
- /** 使用 CSS transform 缩放的类型(PDF 由 EmbedPDF zoom 插件处理) */
241
+ /** 使用 CSS transform 缩放的类型(PDF / OFD 由各自 previewer 内部缩放) */
242
242
  const TRANSFORMABLE_KINDS = new Set<FileViewerKind>(['image'])
243
243
  /** 工具栏显示缩放控件的类型 */
244
- const ZOOMABLE_KINDS = new Set<FileViewerKind>(['image', 'pdf'])
244
+ const ZOOMABLE_KINDS = new Set<FileViewerKind>(['image', 'pdf', 'ofd'])
245
+ /** 缩放由 previewer 内部实现、组件只透传命令并回显级别的类型 */
246
+ const DELEGATED_ZOOM_KINDS = new Set<FileViewerKind>(['pdf', 'ofd'])
245
247
 
246
248
  const activeId = defineModel<string | undefined>('modelValue', { default: undefined })
247
249
  const openModel = defineModel<boolean | undefined>('open', { default: undefined })
@@ -253,7 +255,7 @@ const previewerRef = shallowRef<{
253
255
  resetZoom?: () => void
254
256
  }>()
255
257
  const scale = ref(1)
256
- const pdfZoomLevel = ref(1)
258
+ const previewerZoomLevel = ref(1)
257
259
  const offsetX = ref(0)
258
260
  const offsetY = ref(0)
259
261
  const isDragging = ref(false)
@@ -268,6 +270,7 @@ const PreviewerMap: Record<FileViewerKind, ReturnType<typeof defineAsyncComponen
268
270
  pdf: defineAsyncComponent(() => import('./previewers/pdf-previewer.vue')),
269
271
  sheet: defineAsyncComponent(() => import('./previewers/sheet-previewer.vue')),
270
272
  docx: defineAsyncComponent(() => import('./previewers/docx-previewer.vue')),
273
+ ofd: defineAsyncComponent(() => import('./previewers/ofd-previewer.vue')),
271
274
  text: defineAsyncComponent(() => import('./previewers/text-previewer.vue'))
272
275
  }
273
276
 
@@ -312,11 +315,15 @@ const isTransformable = computed(
312
315
 
313
316
  const isZoomable = computed(() => !!activeFile.value && ZOOMABLE_KINDS.has(activeFile.value.kind))
314
317
 
315
- const isPdfActive = computed(() => activeFile.value?.kind === 'pdf')
318
+ const isDelegatedZoom = computed(
319
+ () => !!activeFile.value && DELEGATED_ZOOM_KINDS.has(activeFile.value.kind)
320
+ )
316
321
 
317
322
  const canPan = computed(() => isTransformable.value && scale.value > 1)
318
323
 
319
- const displayZoomLevel = computed(() => (isPdfActive.value ? pdfZoomLevel.value : scale.value))
324
+ const displayZoomLevel = computed(() =>
325
+ isDelegatedZoom.value ? previewerZoomLevel.value : scale.value
326
+ )
320
327
 
321
328
  const zoomPercent = computed(() => `${Math.round(displayZoomLevel.value * 100)}%`)
322
329
 
@@ -325,8 +332,8 @@ const zoomInDisabled = computed(() => !isZoomable.value || displayZoomLevel.valu
325
332
  const zoomOutDisabled = computed(() => !isZoomable.value || displayZoomLevel.value <= MIN_SCALE)
326
333
 
327
334
  const isTransformReset = computed(() => {
328
- if (isPdfActive.value) {
329
- return Math.abs(pdfZoomLevel.value - 1) < 0.02
335
+ if (isDelegatedZoom.value) {
336
+ return Math.abs(previewerZoomLevel.value - 1) < 0.02
330
337
  }
331
338
  return scale.value === 1 && offsetX.value === 0 && offsetY.value === 0
332
339
  })
@@ -378,7 +385,7 @@ function setScale(value: number) {
378
385
 
379
386
  function zoomIn() {
380
387
  if (!isZoomable.value) return
381
- if (isPdfActive.value) {
388
+ if (isDelegatedZoom.value) {
382
389
  previewerRef.value?.zoomIn?.()
383
390
  return
384
391
  }
@@ -387,7 +394,7 @@ function zoomIn() {
387
394
 
388
395
  function zoomOut() {
389
396
  if (!isZoomable.value) return
390
- if (isPdfActive.value) {
397
+ if (isDelegatedZoom.value) {
391
398
  previewerRef.value?.zoomOut?.()
392
399
  return
393
400
  }
@@ -395,9 +402,9 @@ function zoomOut() {
395
402
  }
396
403
 
397
404
  function resetTransform() {
398
- if (isPdfActive.value) {
405
+ if (isDelegatedZoom.value) {
399
406
  previewerRef.value?.resetZoom?.()
400
- pdfZoomLevel.value = 1
407
+ previewerZoomLevel.value = 1
401
408
  return
402
409
  }
403
410
  scale.value = 1
@@ -407,8 +414,8 @@ function resetTransform() {
407
414
  isDragging.value = false
408
415
  }
409
416
 
410
- function handlePdfZoomChange(level: number) {
411
- pdfZoomLevel.value = level
417
+ function handlePreviewerZoomChange(level: number) {
418
+ previewerZoomLevel.value = level
412
419
  }
413
420
 
414
421
  function isZoomWheel(e: WheelEvent): boolean {
@@ -462,7 +469,7 @@ function handleViewportPointerEnd(e: PointerEvent) {
462
469
  }
463
470
 
464
471
  function handleViewportDblclick() {
465
- if (isPdfActive.value) return
472
+ if (isDelegatedZoom.value) return
466
473
  if (!isTransformable.value) return
467
474
  if (scale.value === 1) {
468
475
  setScale(2)
@@ -521,7 +528,7 @@ watch(
521
528
  watch(
522
529
  () => activeFile.value?.id,
523
530
  async () => {
524
- pdfZoomLevel.value = 1
531
+ previewerZoomLevel.value = 1
525
532
  await nextTick()
526
533
  resetTransform()
527
534
  }
@@ -47,6 +47,7 @@ export function inferKind(name: string, explicit?: FileViewerKind): FileViewerKi
47
47
  if (ext === 'pdf') return 'pdf'
48
48
  if (SHEET_EXTS.has(ext)) return 'sheet'
49
49
  if (ext === 'docx') return 'docx'
50
+ if (ext === 'ofd') return 'ofd'
50
51
  if (TEXT_EXTS.has(ext)) return 'text'
51
52
  return 'text'
52
53
  }
@@ -57,6 +58,7 @@ export const FILE_VIEWER_KIND_LABEL: Record<FileViewerKind, string> = {
57
58
  pdf: 'PDF',
58
59
  sheet: 'XLS',
59
60
  docx: 'DOC',
61
+ ofd: 'OFD',
60
62
  text: 'TXT'
61
63
  }
62
64
 
@@ -0,0 +1,216 @@
1
+ <template>
2
+ <div :class="cls.e('ofd')">
3
+ <div ref="scroll" :class="cls.e('ofd-scroll')">
4
+ <div
5
+ v-for="(page, i) in pages"
6
+ :key="i"
7
+ :data-index="i"
8
+ :class="cls.e('ofd-page')"
9
+ :style="{
10
+ width: page.widthPx * zoomLevel + 'px',
11
+ height: page.heightPx * zoomLevel + 'px'
12
+ }"
13
+ >
14
+ <div v-if="page.svg" :class="cls.e('ofd-page-body')" v-html="page.svg" />
15
+ </div>
16
+ <div v-if="!pages.length && !loading" :class="cls.e('empty')">
17
+ <u-empty text="该文件没有可预览的页面" :size="32" />
18
+ </div>
19
+ </div>
20
+ <div v-if="loading" :class="cls.e('loading')">正在解析 OFD…</div>
21
+ </div>
22
+ </template>
23
+
24
+ <script lang="ts" setup>
25
+ import { bem } from '@veltra/utils'
26
+ import { nextTick, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
27
+
28
+ import type { FileViewerItem } from '../../../types/file-viewer'
29
+ import { UEmpty } from '../../empty'
30
+ import { toArrayBuffer } from '../helper'
31
+
32
+ defineOptions({ name: 'UFileViewerOfdPreviewer' })
33
+
34
+ /** 仅类型查询;运行时动态 import 按需加载,构建时随 alwaysBundle 内联进 dist */
35
+ type OfdCoreModule = typeof import('@veltra/ofd-core')
36
+ type OfdContainer = import('@veltra/ofd-core').OfdContainer
37
+ type OfdZip = import('@veltra/ofd-core').OfdZip
38
+
39
+ /** OFD 毫米 → CSS 像素(96dpi),与内核 SVG 输出的换算一致 */
40
+ const PX_PER_MM = 96 / 25.4
41
+
42
+ const MIN_ZOOM = 0.5
43
+ const MAX_ZOOM = 3
44
+ /** 每次缩放固定增减 10%,对齐 pdf 缩放配置 */
45
+ const ZOOM_STEP = 0.1
46
+ /** 可视区上下各预渲染一个视口高度的页 */
47
+ const RENDER_MARGIN = '100% 0px'
48
+
49
+ interface OfdPageView {
50
+ docIndex: number
51
+ pageIndex: number
52
+ /** 页面基准尺寸(缩放 1 时),毫米按 96dpi 换算为像素 */
53
+ widthPx: number
54
+ heightPx: number
55
+ /** null = 未渲染;'' = 已尝试但渲染失败,不再重试 */
56
+ svg: string | null
57
+ }
58
+
59
+ const props = defineProps<{ file: FileViewerItem }>()
60
+
61
+ const emit = defineEmits<{
62
+ (e: 'error', err: unknown): void
63
+ (e: 'zoom-change', level: number): void
64
+ }>()
65
+
66
+ const cls = bem('file-viewer')
67
+ const scrollEl = useTemplateRef<HTMLDivElement>('scroll')
68
+ const loading = ref(true)
69
+ const pages = ref<OfdPageView[]>([])
70
+ const zoomLevel = ref(1)
71
+
72
+ let controller: AbortController | undefined
73
+ /** 递增 token:防止切文件后旧解析/渲染结果落地 */
74
+ let loadToken = 0
75
+ let ofdCore: OfdCoreModule | undefined
76
+ let observer: IntersectionObserver | undefined
77
+ let activeZip: OfdZip | undefined
78
+ let activeContainer: OfdContainer | undefined
79
+ /** 已进入渲染流程(进行中或已完成)的页,防止滚动重复触发 */
80
+ const scheduledPages = new Set<number>()
81
+
82
+ // ---- 缩放:作用于页面容器尺寸,滚动高度随之自适应 ----
83
+
84
+ function clampZoom(value: number): number {
85
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.round(value * 100) / 100))
86
+ }
87
+
88
+ function setZoom(value: number) {
89
+ const next = clampZoom(value)
90
+ if (zoomLevel.value === next) return
91
+ zoomLevel.value = next
92
+ emit('zoom-change', next)
93
+ }
94
+
95
+ function zoomIn() {
96
+ setZoom(zoomLevel.value + ZOOM_STEP)
97
+ }
98
+
99
+ function zoomOut() {
100
+ setZoom(zoomLevel.value - ZOOM_STEP)
101
+ }
102
+
103
+ function resetZoom() {
104
+ setZoom(1)
105
+ }
106
+
107
+ defineExpose({ zoomIn, zoomOut, resetZoom })
108
+
109
+ // ---- 解析与懒渲染 ----
110
+
111
+ /** 动态加载 ofd-core chunk;首次加载后缓存模块复用 */
112
+ async function resolveOfdCore(): Promise<OfdCoreModule> {
113
+ const mod = ofdCore ?? (await import('@veltra/ofd-core'))
114
+ ofdCore = mod
115
+ return mod
116
+ }
117
+
118
+ function teardownRender() {
119
+ observer?.disconnect()
120
+ observer = undefined
121
+ activeZip = undefined
122
+ activeContainer = undefined
123
+ scheduledPages.clear()
124
+ }
125
+
126
+ function observePages() {
127
+ const root = scrollEl.value
128
+ if (!root || typeof IntersectionObserver === 'undefined') return
129
+ observer = new IntersectionObserver(
130
+ (entries) => {
131
+ const token = loadToken
132
+ for (const entry of entries) {
133
+ if (!entry.isIntersecting) continue
134
+ observer?.unobserve(entry.target)
135
+ const index = Number((entry.target as HTMLElement).dataset.index)
136
+ if (Number.isNaN(index) || scheduledPages.has(index)) continue
137
+ scheduledPages.add(index)
138
+ void renderPage(index, token)
139
+ }
140
+ },
141
+ { root, rootMargin: RENDER_MARGIN }
142
+ )
143
+ for (const el of root.querySelectorAll<HTMLElement>('[data-index]')) {
144
+ observer.observe(el)
145
+ }
146
+ }
147
+
148
+ /** 渲染单页 SVG;失败置空串避免滚动时反复重试同一页 */
149
+ async function renderPage(index: number, token: number) {
150
+ const page = pages.value[index]
151
+ const core = ofdCore
152
+ if (!page || page.svg !== null || !core || !activeZip || !activeContainer) return
153
+ try {
154
+ const svg = await core.pageToSvg(activeZip, activeContainer, page.docIndex, page.pageIndex)
155
+ if (token !== loadToken) return
156
+ pages.value[index] = { ...page, svg }
157
+ } catch (err) {
158
+ if (token !== loadToken) return
159
+ pages.value[index] = { ...page, svg: '' }
160
+ emit('error', err)
161
+ }
162
+ }
163
+
164
+ async function load() {
165
+ const token = ++loadToken
166
+ controller?.abort()
167
+ controller = new AbortController()
168
+ teardownRender()
169
+ loading.value = true
170
+ pages.value = []
171
+
172
+ try {
173
+ const core = await resolveOfdCore()
174
+ if (token !== loadToken) return
175
+
176
+ const buf = await toArrayBuffer(props.file.src, controller.signal)
177
+ if (token !== loadToken) return
178
+ const zip = core.openOfdZip(new Uint8Array(buf))
179
+ const container = await core.parseOfdContainer(zip)
180
+ if (token !== loadToken) return
181
+
182
+ activeZip = zip
183
+ activeContainer = container
184
+ // 未声明尺寸时按 A4 兜底,与内核 pageToSvg 的兜底一致
185
+ pages.value = container.docs.flatMap((doc, docIndex) =>
186
+ doc.pages.map((page) => {
187
+ const size = page.size ?? doc.pageSize ?? { width: 210, height: 297 }
188
+ return {
189
+ docIndex,
190
+ pageIndex: page.index,
191
+ widthPx: Math.round(size.width * PX_PER_MM),
192
+ heightPx: Math.round(size.height * PX_PER_MM),
193
+ svg: null
194
+ }
195
+ })
196
+ )
197
+ await nextTick()
198
+ if (token !== loadToken) return
199
+ observePages()
200
+ } catch (err) {
201
+ if (token !== loadToken) return
202
+ if ((err as { name?: string })?.name === 'AbortError') return
203
+ emit('error', err)
204
+ } finally {
205
+ if (token === loadToken) loading.value = false
206
+ }
207
+ }
208
+
209
+ watch(() => props.file, load, { immediate: true })
210
+
211
+ onBeforeUnmount(() => {
212
+ controller?.abort()
213
+ teardownRender()
214
+ pages.value = []
215
+ })
216
+ </script>
@@ -302,6 +302,12 @@
302
302
  --u-file-viewer-kind-bg: rgba(100, 116, 139, 0.1);
303
303
  }
304
304
 
305
+ @include m.em(badge, ofd) {
306
+ --u-file-viewer-kind: #7c3aed;
307
+ --u-file-viewer-kind-border: rgba(124, 58, 237, 0.28);
308
+ --u-file-viewer-kind-bg: rgba(124, 58, 237, 0.1);
309
+ }
310
+
305
311
  @include m.e(stage) {
306
312
  display: flex;
307
313
  flex: 1;
@@ -686,6 +692,33 @@
686
692
  0 0 0 1px rgba(255, 255, 255, 0.04);
687
693
  }
688
694
 
695
+ @include m.e(ofd) {
696
+ position: absolute;
697
+ inset: 0;
698
+ }
699
+
700
+ @include m.e(ofd-scroll) {
701
+ width: 100%;
702
+ height: 100%;
703
+ overflow: auto;
704
+ }
705
+
706
+ @include m.e(ofd-page) {
707
+ margin: 24px auto;
708
+ background: #fff;
709
+ box-shadow: var(--u-file-viewer-page-shadow);
710
+
711
+ svg {
712
+ display: block;
713
+ width: 100%;
714
+ height: 100%;
715
+ }
716
+ }
717
+
718
+ &.is-modal &__ofd-page {
719
+ margin: clamp(24px, 3vw, 44px) auto;
720
+ }
721
+
689
722
  @include m.e(docx) {
690
723
  position: absolute;
691
724
  inset: 0;
@@ -52,7 +52,17 @@ $root-name: form-item;
52
52
  #{fn.bem(multi-tree-select)},
53
53
  #{fn.bem(auto-complete)},
54
54
  #{fn.bem(textarea--more)} {
55
- border-color: fn.use-var(color, danger);
55
+ // hover 的 primary 描边与错误态同权重,按源序分胜负:错误态显式压住,避免悬浮时描边回蓝
56
+ &,
57
+ &:hover,
58
+ &.is-focus {
59
+ border-color: fn.use-var(color, danger);
60
+ }
61
+
62
+ // 聚焦光晕与描边同色,错误态转红
63
+ &.is-focus {
64
+ box-shadow: fn.use-var(focus-ring, danger);
65
+ }
56
66
  }
57
67
  }
58
68