adtec-core-package 3.1.5 → 3.1.7

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.
@@ -0,0 +1,479 @@
1
+ <template>
2
+ <div class="office-preview-root" :class="{ 'is-rich-preview': isRichPreview }">
3
+ <template v-if="isRichPreview">
4
+ <office-preview-header-bar
5
+ v-if="showHeader"
6
+ :title="headerTitle"
7
+ :zoom="scale"
8
+ :current-page="previewCurrentPage"
9
+ :total-pages="previewTotalPages"
10
+ :search-query="searchQuery"
11
+ :search-index="searchMatchIndex"
12
+ :search-total="searchMatchTotal"
13
+ :show-search="showSearch"
14
+ :show-page-nav="showPageNav"
15
+ :show-zoom="showZoom"
16
+ :show-download="showDownload"
17
+ :show-refresh="showRefresh"
18
+ :show-fullscreen="showFullscreen"
19
+ :show-close="showClose"
20
+ :fullscreen="fullscreen"
21
+ :download-loading="downloadLoading"
22
+ :min-zoom="MIN_OFFICE_ZOOM"
23
+ :max-zoom="MAX_RICH_PREVIEW_ZOOM"
24
+ @zoom-in="zoomIn"
25
+ @zoom-out="zoomOut"
26
+ @zoom-reset="resetZoom"
27
+ @download="emit('download')"
28
+ @refresh="emit('refresh')"
29
+ @toggle-fullscreen="emit('toggle-fullscreen')"
30
+ @close="emit('close')"
31
+ @page-prev="goPrevPage"
32
+ @page-next="goNextPage"
33
+ @search="handleSearch"
34
+ @search-clear="handleSearchClear"
35
+ @search-prev="goPrevSearchMatch"
36
+ @search-next="goNextSearchMatch"
37
+ />
38
+ <div class="office-rich-wrap">
39
+ <pdf-js-viewer
40
+ v-if="officeType === 'pdf' || previewAsPdf"
41
+ ref="pdfViewerRef"
42
+ :src="fileSrc"
43
+ :scale="scale"
44
+ class="office-rich-viewer"
45
+ @loaded="handleRendered"
46
+ @error="handleError"
47
+ @page-change="previewCurrentPage = $event"
48
+ />
49
+ <excel-js-viewer
50
+ v-else-if="officeType === 'excel'"
51
+ ref="excelViewerRef"
52
+ :src="fileSrc"
53
+ :scale="scale"
54
+ class="office-rich-viewer"
55
+ @loaded="handleRendered"
56
+ @error="handleError"
57
+ @page-change="previewCurrentPage = $event"
58
+ />
59
+ <docx-js-viewer
60
+ v-else-if="officeType === 'docx' && !previewAsPdf"
61
+ ref="docxViewerRef"
62
+ :src="fileSrc"
63
+ :scale="scale"
64
+ :file-name="fileName"
65
+ :file-type="fileType"
66
+ :file-url="url"
67
+ class="office-rich-viewer"
68
+ @loaded="handleRendered"
69
+ @error="handleError"
70
+ @page-change="previewCurrentPage = $event"
71
+ />
72
+ <div v-if="isPreviewLoading" class="office-rich-loading">
73
+ <div class="loading-card">
74
+ <span class="loading-spinner" />
75
+ <p class="loading-title">{{ loadingMessage }}</p>
76
+ <p v-if="headerTitle" class="loading-sub">{{ headerTitle }}</p>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </template>
81
+ <div v-else-if="isUnsupportedType" class="unsupported-preview">
82
+ <el-empty :description="unsupportedMessage">
83
+ <el-button v-if="showDownload" type="primary" @click="emit('download')">
84
+ 下载文件
85
+ </el-button>
86
+ </el-empty>
87
+ </div>
88
+ <pre v-else-if="officeType === 'txt'" class="txt-preview">{{ txtContent }}</pre>
89
+ </div>
90
+ </template>
91
+ <script setup lang="ts">
92
+ import { computed, ref, watch, nextTick } from 'vue'
93
+ import { ElMessage } from 'element-plus'
94
+ import request from '../../utils/AxiosConfig'
95
+ import documentApi from '../../api/DocumentApi'
96
+ import frameworkUtils from '../../utils/FrameworkUtils'
97
+ import { toSameOriginFileUrl } from '../../utils/toSameOriginFileUrl'
98
+ import OfficePreviewHeaderBar from './OfficePreviewHeaderBar.vue'
99
+ import PdfJsViewer from './PdfJsViewer.vue'
100
+ import ExcelJsViewer from './ExcelJsViewer.vue'
101
+ import DocxJsViewer from './DocxJsViewer.vue'
102
+ import {
103
+ type OfficePreviewType,
104
+ clampRichPreviewZoom,
105
+ isSupportedPreviewType,
106
+ isLegacyDocFile,
107
+ MAX_RICH_PREVIEW_ZOOM,
108
+ MIN_OFFICE_ZOOM,
109
+ toOfficePreviewType,
110
+ OFFICE_ZOOM_STEP,
111
+ } from '../../utils/officePreviewUtil.ts'
112
+
113
+ const props = withDefaults(
114
+ defineProps<{
115
+ url: string
116
+ fileId?: string
117
+ fileName?: string
118
+ fileType?: string
119
+ showHeader?: boolean
120
+ showSearch?: boolean
121
+ showPageNav?: boolean
122
+ showZoom?: boolean
123
+ showDownload?: boolean
124
+ showRefresh?: boolean
125
+ showFullscreen?: boolean
126
+ showClose?: boolean
127
+ fullscreen?: boolean
128
+ downloadLoading?: boolean
129
+ }>(),
130
+ {
131
+ showHeader: true,
132
+ showSearch: true,
133
+ showPageNav: true,
134
+ showZoom: true,
135
+ showDownload: false,
136
+ showRefresh: false,
137
+ showFullscreen: false,
138
+ showClose: false,
139
+ fullscreen: false,
140
+ downloadLoading: false,
141
+ },
142
+ )
143
+
144
+ const scale = defineModel<number>('scale', { default: 1 })
145
+
146
+ const emit = defineEmits<{
147
+ loaded: []
148
+ error: [err?: unknown]
149
+ download: []
150
+ refresh: []
151
+ 'toggle-fullscreen': []
152
+ close: []
153
+ }>()
154
+
155
+ const fileSrc = ref<Blob | ArrayBuffer>()
156
+ const txtContent = ref('')
157
+ const previewAsPdf = ref(false)
158
+ const pdfViewerRef = ref<InstanceType<typeof PdfJsViewer> | null>(null)
159
+ const excelViewerRef = ref<InstanceType<typeof ExcelJsViewer> | null>(null)
160
+ const docxViewerRef = ref<InstanceType<typeof DocxJsViewer> | null>(null)
161
+ const previewCurrentPage = ref(1)
162
+ const previewTotalPages = ref(0)
163
+ const searchQuery = ref('')
164
+ const searchMatchIndex = ref(-1)
165
+ const searchMatchTotal = ref(0)
166
+ const isPreviewLoading = ref(false)
167
+ const loadingPhase = ref<'fetch' | 'open'>('fetch')
168
+
169
+ type RichViewer = {
170
+ totalPages?: number
171
+ scrollToPage?: (page: number) => Promise<void>
172
+ runSearch?: (query: string) => Promise<{ total: number; index: number } | undefined>
173
+ clearSearch?: () => void
174
+ gotoSearchMatch?: (direction: 1 | -1) => Promise<{ total: number; index: number } | undefined>
175
+ refitLayout?: () => void
176
+ }
177
+
178
+ const loadingMessage = computed(() =>
179
+ loadingPhase.value === 'fetch' ? '正在获取文档,请稍候…' : '正在打开文档,请稍候…',
180
+ )
181
+
182
+ const officeType = computed<OfficePreviewType>(() =>
183
+ toOfficePreviewType(props.fileType, props.fileName, props.url),
184
+ )
185
+
186
+ const isLegacyDoc = computed(() =>
187
+ isLegacyDocFile(props.fileType, props.fileName, props.url),
188
+ )
189
+
190
+ const isRichPreview = computed(
191
+ () =>
192
+ officeType.value === 'pdf' ||
193
+ officeType.value === 'excel' ||
194
+ officeType.value === 'docx',
195
+ )
196
+
197
+ const isUnsupportedType = computed(
198
+ () => !!officeType.value && !isSupportedPreviewType(officeType.value),
199
+ )
200
+
201
+ const unsupportedMessage = computed(() => {
202
+ const ext = officeType.value || '未知'
203
+ return `暂不支持在线预览 ${ext} 格式,请下载后查看`
204
+ })
205
+
206
+ const headerTitle = computed(() => props.fileName || '')
207
+
208
+ const activeViewer = computed<RichViewer | null>(() => {
209
+ if (officeType.value === 'pdf' || previewAsPdf.value) return pdfViewerRef.value
210
+ if (officeType.value === 'excel') return excelViewerRef.value
211
+ if (officeType.value === 'docx') return docxViewerRef.value
212
+ return null
213
+ })
214
+
215
+ const resetPreview = () => {
216
+ fileSrc.value = undefined
217
+ txtContent.value = ''
218
+ previewAsPdf.value = false
219
+ scale.value = 1
220
+ previewCurrentPage.value = 1
221
+ previewTotalPages.value = 0
222
+ searchQuery.value = ''
223
+ searchMatchIndex.value = -1
224
+ searchMatchTotal.value = 0
225
+ isPreviewLoading.value = false
226
+ loadingPhase.value = 'fetch'
227
+ pdfViewerRef.value?.clearSearch()
228
+ excelViewerRef.value?.clearSearch()
229
+ docxViewerRef.value?.clearSearch()
230
+ }
231
+
232
+ const loadPreview = async () => {
233
+ if (!props.url || !officeType.value) {
234
+ return
235
+ }
236
+ if (isUnsupportedType.value) {
237
+ isPreviewLoading.value = false
238
+ emit('loaded')
239
+ return
240
+ }
241
+ isPreviewLoading.value = true
242
+ loadingPhase.value = 'fetch'
243
+ try {
244
+ if (isLegacyDoc.value) {
245
+ if (!props.fileId) {
246
+ isPreviewLoading.value = false
247
+ ElMessage.warning('无法预览该 .doc 文件,请下载后查看')
248
+ emit('loaded')
249
+ return
250
+ }
251
+ loadingPhase.value = 'open'
252
+ const pdfBlob = await documentApi.previewWordAsPdf(props.fileId)
253
+ previewAsPdf.value = true
254
+ fileSrc.value = pdfBlob
255
+ return
256
+ }
257
+ const res = await request<Blob>({
258
+ url: toSameOriginFileUrl(props.url),
259
+ method: 'get',
260
+ responseType: 'blob',
261
+ })
262
+ if (officeType.value === 'txt') {
263
+ txtContent.value = await res.data.text()
264
+ isPreviewLoading.value = false
265
+ emit('loaded')
266
+ } else {
267
+ loadingPhase.value = 'open'
268
+ fileSrc.value = res.data
269
+ }
270
+ } catch (err) {
271
+ isPreviewLoading.value = false
272
+ frameworkUtils.messageError(err)
273
+ emit('error', err)
274
+ }
275
+ }
276
+
277
+ const handleRendered = async () => {
278
+ await nextTick()
279
+ previewTotalPages.value = Number(activeViewer.value?.totalPages ?? 0)
280
+ isPreviewLoading.value = false
281
+ emit('loaded')
282
+ }
283
+
284
+ const handleError = (err?: unknown) => {
285
+ isPreviewLoading.value = false
286
+ emit('error', err)
287
+ }
288
+
289
+ const zoomIn = () => {
290
+ scale.value = clampRichPreviewZoom(scale.value + OFFICE_ZOOM_STEP)
291
+ }
292
+
293
+ const zoomOut = () => {
294
+ scale.value = clampRichPreviewZoom(scale.value - OFFICE_ZOOM_STEP)
295
+ }
296
+
297
+ const resetZoom = () => {
298
+ scale.value = 1
299
+ }
300
+
301
+ const goPrevPage = () => {
302
+ void activeViewer.value?.scrollToPage?.(previewCurrentPage.value - 1)
303
+ }
304
+
305
+ const goNextPage = () => {
306
+ void activeViewer.value?.scrollToPage?.(previewCurrentPage.value + 1)
307
+ }
308
+
309
+ const handleSearch = async (query: string) => {
310
+ searchQuery.value = query
311
+ if (!query) {
312
+ handleSearchClear()
313
+ return
314
+ }
315
+ const result = await activeViewer.value?.runSearch?.(query)
316
+ if (!result || result.total === 0) {
317
+ searchMatchIndex.value = -1
318
+ searchMatchTotal.value = 0
319
+ ElMessage.warning('未找到匹配内容')
320
+ return
321
+ }
322
+ searchMatchIndex.value = result.index
323
+ searchMatchTotal.value = result.total
324
+ }
325
+
326
+ const handleSearchClear = () => {
327
+ searchQuery.value = ''
328
+ searchMatchIndex.value = -1
329
+ searchMatchTotal.value = 0
330
+ activeViewer.value?.clearSearch?.()
331
+ }
332
+
333
+ const goPrevSearchMatch = async () => {
334
+ const result = await activeViewer.value?.gotoSearchMatch?.(-1)
335
+ if (!result) return
336
+ searchMatchIndex.value = result.index
337
+ searchMatchTotal.value = result.total
338
+ }
339
+
340
+ const goNextSearchMatch = async () => {
341
+ const result = await activeViewer.value?.gotoSearchMatch?.(1)
342
+ if (!result) return
343
+ searchMatchIndex.value = result.index
344
+ searchMatchTotal.value = result.total
345
+ }
346
+
347
+ watch(
348
+ () => [props.url, props.fileName, props.fileType] as const,
349
+ ([fileUrl]) => {
350
+ resetPreview()
351
+ if (fileUrl) {
352
+ loadPreview()
353
+ }
354
+ },
355
+ { immediate: true },
356
+ )
357
+
358
+ watch(
359
+ () => props.fullscreen,
360
+ () => {
361
+ void nextTick(() => {
362
+ activeViewer.value?.refitLayout?.()
363
+ })
364
+ },
365
+ )
366
+
367
+ defineExpose({
368
+ zoomIn,
369
+ zoomOut,
370
+ resetZoom,
371
+ officeType,
372
+ })
373
+ </script>
374
+ <style scoped lang="scss">
375
+ .office-preview-root {
376
+ width: 100%;
377
+ height: 100%;
378
+ min-height: 0;
379
+ overflow: auto;
380
+ background: #f5f5f5;
381
+
382
+ &.is-rich-preview {
383
+ display: flex;
384
+ flex-direction: column;
385
+ overflow: hidden;
386
+ }
387
+ }
388
+
389
+ .office-rich-viewer {
390
+ flex: 1;
391
+ width: 100%;
392
+ min-height: 0;
393
+ }
394
+
395
+ .office-rich-wrap {
396
+ position: relative;
397
+ flex: 1;
398
+ width: 100%;
399
+ min-height: 0;
400
+ display: flex;
401
+ flex-direction: column;
402
+ }
403
+
404
+ .office-rich-loading {
405
+ position: absolute;
406
+ inset: 0;
407
+ z-index: 3;
408
+ display: flex;
409
+ align-items: center;
410
+ justify-content: center;
411
+ background: #f5f5f5;
412
+ }
413
+
414
+ .loading-card {
415
+ display: flex;
416
+ flex-direction: column;
417
+ align-items: center;
418
+ gap: 12px;
419
+ padding: 28px 36px;
420
+ border-radius: 12px;
421
+ background: #fff;
422
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
423
+ }
424
+
425
+ .loading-spinner {
426
+ width: 36px;
427
+ height: 36px;
428
+ border: 3px solid #e5e7eb;
429
+ border-top-color: #409eff;
430
+ border-radius: 50%;
431
+ animation: office-preview-spin 0.9s linear infinite;
432
+ }
433
+
434
+ .loading-title {
435
+ margin: 0;
436
+ font-size: 14px;
437
+ font-weight: 600;
438
+ color: #374151;
439
+ }
440
+
441
+ .loading-sub {
442
+ margin: 0;
443
+ max-width: 280px;
444
+ font-size: 12px;
445
+ color: #9ca3af;
446
+ text-align: center;
447
+ white-space: nowrap;
448
+ overflow: hidden;
449
+ text-overflow: ellipsis;
450
+ }
451
+
452
+ @keyframes office-preview-spin {
453
+ to {
454
+ transform: rotate(360deg);
455
+ }
456
+ }
457
+
458
+ .txt-preview {
459
+ height: 100%;
460
+ margin: 0;
461
+ padding: 16px;
462
+ overflow: auto;
463
+ text-align: left;
464
+ white-space: pre-wrap;
465
+ word-break: break-word;
466
+ background: #fff;
467
+ font-size: 14px;
468
+ line-height: 1.6;
469
+ }
470
+
471
+ .unsupported-preview {
472
+ display: flex;
473
+ align-items: center;
474
+ justify-content: center;
475
+ height: 100%;
476
+ min-height: 240px;
477
+ background: #fff;
478
+ }
479
+ </style>