aegon-lv97 1.0.0

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 (78) hide show
  1. package/gen-ai/build-report/chartStreamRefs.ts +227 -0
  2. package/gen-ai/build-report/coherence-check/index.vue +559 -0
  3. package/gen-ai/build-report/components/ReportChapterWorkspace.vue +4571 -0
  4. package/gen-ai/build-report/components/ReportConfigPanel.vue +727 -0
  5. package/gen-ai/build-report/components/ReportGenerateSettingsPanel.vue +2361 -0
  6. package/gen-ai/build-report/components/ReportGeneratingPanel.vue +1765 -0
  7. package/gen-ai/build-report/components/ReportPromptPanel.vue +625 -0
  8. package/gen-ai/build-report/components/ReportSourceFileSummary.vue +665 -0
  9. package/gen-ai/build-report/components/ReportSourcesPanel.vue +1327 -0
  10. package/gen-ai/build-report/components/ReportTemplateDetail.vue +191 -0
  11. package/gen-ai/build-report/components/ReportTemplateEditorFormPanel.vue +667 -0
  12. package/gen-ai/build-report/components/ReportTemplatePicker.vue +302 -0
  13. package/gen-ai/build-report/components/ReportWorkflowSidebar.vue +148 -0
  14. package/gen-ai/build-report/components/paramsSetting.vue +886 -0
  15. package/gen-ai/build-report/components/reportEdit.vue +2283 -0
  16. package/gen-ai/build-report/index.vue +856 -0
  17. package/gen-ai/build-report/report-domain.ts +1133 -0
  18. package/gen-ai/build-report/report-template-data.ts +295 -0
  19. package/gen-ai/build-report/useReportWorkflow.ts +1386 -0
  20. package/gen-ai/common/assets/NotoSans-SC.woff2 +0 -0
  21. package/gen-ai/common/assets/NotoSans-TC.woff2 +0 -0
  22. package/gen-ai/common/assets/NotoSans.woff2 +0 -0
  23. package/gen-ai/common/assets/fonts.css +278 -0
  24. package/gen-ai/common/assets/pagination.css +163 -0
  25. package/gen-ai/common/directives/btnLoading.ts +455 -0
  26. package/gen-ai/common/directives/debounce.ts +98 -0
  27. package/gen-ai/common/directives/index.ts +30 -0
  28. package/gen-ai/common/en.md +417 -0
  29. package/gen-ai/common/index.ts +36 -0
  30. package/gen-ai/common/types/component-expose.ts +9 -0
  31. package/gen-ai/common/utils/api-res-status.ts +7 -0
  32. package/gen-ai/common/utils/navigation.ts +59 -0
  33. package/gen-ai/common/utils/remote-request.ts +45 -0
  34. package/gen-ai/common/utils/scroll.ts +577 -0
  35. package/gen-ai/components/AIExport/docx-export.ts +950 -0
  36. package/gen-ai/components/AIExport/docxExport.ts +2566 -0
  37. package/gen-ai/components/AIExport/index.ts +52 -0
  38. package/gen-ai/components/AIExport/parseMarkdownHtml.ts +653 -0
  39. package/gen-ai/components/CapsuleScrollbar.vue +149 -0
  40. package/gen-ai/components/ChapterTitleScroll.vue +468 -0
  41. package/gen-ai/components/EntryHeroNavCards.vue +379 -0
  42. package/gen-ai/components/GenAiPagination.vue +52 -0
  43. package/gen-ai/components/ModelCard.vue +1114 -0
  44. package/gen-ai/components/Preview/FilePreviewPage.vue +949 -0
  45. package/gen-ai/components/Preview/PreviewCapsuleScroll.vue +205 -0
  46. package/gen-ai/components/Preview/PreviewOutlineTree.vue +148 -0
  47. package/gen-ai/components/Preview/index.ts +11 -0
  48. package/gen-ai/components/Preview/preview.ts +724 -0
  49. package/gen-ai/components/PromptField.vue +505 -0
  50. package/gen-ai/components/RecentReportsTable.vue +974 -0
  51. package/gen-ai/components/ReportCollapsibleSection.vue +172 -0
  52. package/gen-ai/components/ReportHistoryList.vue +637 -0
  53. package/gen-ai/components/ReportSectionConfirmPopover.vue +251 -0
  54. package/gen-ai/components/editor/ChapterTiptapEditor.vue +3718 -0
  55. package/gen-ai/components/editor/StructureBlockView.vue +441 -0
  56. package/gen-ai/components/editor/TiptapToolbar.vue +1036 -0
  57. package/gen-ai/components/editor/TypewriterDocEditor.vue +422 -0
  58. package/gen-ai/components/editor/_StructureOrgNode.vue +326 -0
  59. package/gen-ai/components/editor/index.ts +28 -0
  60. package/gen-ai/components/editor/parseMarkdownHtml.ts +754 -0
  61. package/gen-ai/components/editor/reviewCitationTip.ts +163 -0
  62. package/gen-ai/gen-entry/api/index.ts +32 -0
  63. package/gen-ai/gen-entry/index.vue +287 -0
  64. package/gen-ai/gen-entry/types/index.ts +4 -0
  65. package/gen-ai/i.mark +2 -0
  66. package/gen-ai/management-center/index.vue +1815 -0
  67. package/gen-ai/management-center/mockData.ts +248 -0
  68. package/gen-ai/management-center/types/index.ts +37 -0
  69. package/gen-ai/model-manager/TemplateEditorFormPanel.vue +661 -0
  70. package/gen-ai/model-manager/api/index.ts +32 -0
  71. package/gen-ai/model-manager/api/mockApi.ts +33 -0
  72. package/gen-ai/model-manager/index.vue +1583 -0
  73. package/gen-ai/model-manager/mockData.ts +177 -0
  74. package/gen-ai/model-manager/template-detail-shared.ts +154 -0
  75. package/gen-ai/model-manager/template-editor.vue +345 -0
  76. package/gen-ai/model-manager/types/index.ts +40 -0
  77. package/gen-ai/model-manager/utils.ts +25 -0
  78. package/package.json +12 -0
@@ -0,0 +1,949 @@
1
+ <template>
2
+ <div class="file-preview-page" :class="{ 'is-sidebar-collapsed': !sidebarOpen }">
3
+ <header class="file-preview-page__header">
4
+ <div class="file-preview-page__header-left">
5
+ <button
6
+ type="button"
7
+ class="file-preview-page__icon-btn"
8
+ aria-label="切換大綱"
9
+ @click="toggleSidebar"
10
+ >
11
+ <el-icon :size="20"><Operation /></el-icon>
12
+ </button>
13
+ <h1 class="file-preview-page__title" :title="fileName">{{ fileName }}</h1>
14
+ </div>
15
+
16
+ <div class="file-preview-page__header-center">
17
+ <button
18
+ type="button"
19
+ class="file-preview-page__icon-btn"
20
+ aria-label="縮小"
21
+ :disabled="zoomPercent <= 50"
22
+ @click="zoomBy(-10)"
23
+ >
24
+ <el-icon :size="18"><Minus /></el-icon>
25
+ </button>
26
+ <span class="file-preview-page__zoom">{{ zoomPercent }}%</span>
27
+ <button
28
+ type="button"
29
+ class="file-preview-page__icon-btn"
30
+ aria-label="放大"
31
+ :disabled="zoomPercent >= 200"
32
+ @click="zoomBy(10)"
33
+ >
34
+ <el-icon :size="18"><Plus /></el-icon>
35
+ </button>
36
+ <span class="file-preview-page__chars">{{ charCount }} 字元</span>
37
+ </div>
38
+
39
+ <div class="file-preview-page__header-right">
40
+ <button type="button" class="file-preview-page__text-btn" @click="handlePrint">
41
+ <el-icon :size="16"><Printer /></el-icon>
42
+ 列印
43
+ </button>
44
+ <button type="button" class="file-preview-page__text-btn" @click="handleDownload">
45
+ <el-icon :size="16"><Download /></el-icon>
46
+ 下載
47
+ </button>
48
+ <button
49
+ type="button"
50
+ class="file-preview-page__icon-btn"
51
+ aria-label="關閉"
52
+ @click="handleClose"
53
+ >
54
+ <el-icon :size="18"><Close /></el-icon>
55
+ </button>
56
+ </div>
57
+ </header>
58
+
59
+ <div class="file-preview-page__body">
60
+ <!-- //0918 目錄:與正文同一 PreviewCapsuleScroll,高度=視口-頂欄 -->
61
+ <aside v-show="sidebarOpen" class="file-preview-page__sidebar">
62
+ <PreviewCapsuleScroll ref="sidebarScrollRef" class="file-preview-page__panel-scroll">
63
+ <div v-if="!outline.length" class="file-preview-page__outline-empty">暫無大綱</div>
64
+ <div v-else class="file-preview-page__outline-pad">
65
+ <PreviewOutlineTree
66
+ :items="outline"
67
+ :expanded-ids="expandedIds"
68
+ :active-id="activeOutlineId"
69
+ @toggle="toggleOutline"
70
+ @select="scrollToOutline"
71
+ />
72
+ </div>
73
+ </PreviewCapsuleScroll>
74
+ </aside>
75
+
76
+ <!-- //0918 正文 -->
77
+ <main class="file-preview-page__stage" v-loading="loading">
78
+ <PreviewCapsuleScroll ref="stageScrollRef" class="file-preview-page__panel-scroll">
79
+ <div ref="stageRef" class="file-preview-page__stage-inner">
80
+ <div class="file-preview-page__scale" :style="{ zoom: zoomPercent / 100 }">
81
+ <div v-if="errorMessage" class="file-preview-page__error">{{ errorMessage }}</div>
82
+
83
+ <template v-else>
84
+ <!-- PDF → @vue-office/pdf -->
85
+ <div
86
+ v-if="kind === 'pdf' && officeSrc"
87
+ class="file-preview-page__paper file-preview-page__paper--office file-preview-page__paper--pdf"
88
+ >
89
+ <VueOfficePdf
90
+ :src="officeSrc"
91
+ class="file-preview-page__office-viewer"
92
+ @rendered="onOfficeRendered"
93
+ @error="onOfficeError"
94
+ />
95
+ </div>
96
+
97
+ <!-- DOCX → docx-preview;失敗再 mammoth -->
98
+ <div
99
+ v-else-if="kind === 'docx' && !mammothHtml"
100
+ class="file-preview-page__paper file-preview-page__paper--office"
101
+ >
102
+ <div
103
+ ref="docxPreviewHostRef"
104
+ class="file-preview-page__office-viewer file-preview-page__docx-native"
105
+ />
106
+ </div>
107
+
108
+ <!-- DOC / DOCX fallback → mammoth -->
109
+ <article
110
+ v-else-if="mammothHtml"
111
+ ref="printRef"
112
+ class="file-preview-page__paper file-preview-page__paper--html"
113
+ v-html="mammothHtml"
114
+ />
115
+
116
+ <!-- 圖片 -->
117
+ <div
118
+ v-else-if="kind === 'image' && imageUrl"
119
+ class="file-preview-page__paper file-preview-page__paper--image"
120
+ >
121
+ <img :src="imageUrl" :alt="fileName" class="file-preview-page__image" />
122
+ </div>
123
+
124
+ <div v-else-if="!loading" class="file-preview-page__error">暫無可預覽內容</div>
125
+ </template>
126
+ </div>
127
+ </div>
128
+ </PreviewCapsuleScroll>
129
+ </main>
130
+ <!-- //0918 -->
131
+ </div>
132
+ </div>
133
+ </template>
134
+
135
+ <script setup lang="ts">
136
+ import { Close, Download, Minus, Operation, Plus, Printer } from '@element-plus/icons-vue'
137
+ import { ElIcon, ElMessage } from 'element-plus'
138
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
139
+ import { useRoute } from 'vue-router'
140
+ /** postinstall 後可用包入口;類型見 shims / 包內 d.ts */
141
+ import VueOfficePdf from '@vue-office/pdf'
142
+ import '../../common/assets/fonts.css'
143
+ import {
144
+ blobToArrayBuffer,
145
+ buildOutlineFromOrderedLists,
146
+ clampZoomPercent,
147
+ clearDocxPreviewContainer,
148
+ clearFilePreviewPayload,
149
+ convertOfficeBlobToHtml,
150
+ countPlainTextChars,
151
+ decorateHtmlWithOutline,
152
+ downloadBlobAsFile,
153
+ fetchPreviewFileBlob,
154
+ getPreviewFileKind,
155
+ invalidateDocxPreviewRender,
156
+ printPreviewElement,
157
+ readFilePreviewPayload,
158
+ renderDocxWithDocxPreview,
159
+ stripHtmlToText,
160
+ type PreviewFileKind,
161
+ type PreviewOutlineItem,
162
+ } from './preview'
163
+ import PreviewCapsuleScroll from './PreviewCapsuleScroll.vue'
164
+ import PreviewOutlineTree from './PreviewOutlineTree.vue'
165
+
166
+ const route = useRoute()
167
+ const token = computed(() => String(route.query.token || '').trim())
168
+
169
+ const loading = ref(true)
170
+ const errorMessage = ref('')
171
+ const fileName = ref('檔案預覽')
172
+ const kind = ref<PreviewFileKind>('unknown')
173
+ const zoomPercent = ref(100)
174
+ const charCount = ref(0)
175
+ /** 無大綱時默認收起;有大綱時自動展開(用戶手動收起後不再強行打開) */
176
+ const sidebarOpen = ref(false)
177
+ const sidebarManualCollapsed = ref(false)
178
+ const outline = ref<PreviewOutlineItem[]>([])
179
+ const expandedIds = ref<Set<string>>(new Set())
180
+ const activeOutlineId = ref('')
181
+ const officeSrc = ref<string | ArrayBuffer | Blob | undefined>()
182
+ const mammothHtml = ref('')
183
+ const imageUrl = ref('')
184
+ const fileBlob = ref<Blob | null>(null)
185
+ const printRef = ref<HTMLElement | null>(null)
186
+ const docxPreviewHostRef = ref<HTMLElement | null>(null)
187
+ const stageRef = ref<HTMLElement | null>(null)
188
+ const sidebarScrollRef = ref<InstanceType<typeof PreviewCapsuleScroll> | null>(null)
189
+ const stageScrollRef = ref<InstanceType<typeof PreviewCapsuleScroll> | null>(null)
190
+
191
+ let abort: AbortController | null = null
192
+ let imageObjectUrl = ''
193
+
194
+ function refreshSidebarScroll() {
195
+ sidebarScrollRef.value?.refresh()
196
+ }
197
+
198
+ function refreshStageScroll() {
199
+ stageScrollRef.value?.refresh()
200
+ }
201
+
202
+ function getStageScrollWrap() {
203
+ return stageScrollRef.value?.getWrap() ?? null
204
+ }
205
+
206
+ const hasOutline = computed(() => outline.value.length > 0)
207
+
208
+ function toggleSidebar() {
209
+ const next = !sidebarOpen.value
210
+ sidebarOpen.value = next
211
+ // 有大綱時手動收起:記住,避免 outline 重建後又被自動打開
212
+ sidebarManualCollapsed.value = hasOutline.value ? !next : false
213
+ }
214
+
215
+ watch(hasOutline, (has) => {
216
+ if (!has) {
217
+ sidebarOpen.value = false
218
+ sidebarManualCollapsed.value = false
219
+ return
220
+ }
221
+ if (!sidebarManualCollapsed.value) sidebarOpen.value = true
222
+ void nextTick(() => refreshSidebarScroll())
223
+ })
224
+
225
+ watch(sidebarOpen, (open) => {
226
+ if (!open) return
227
+ void nextTick(() => {
228
+ refreshSidebarScroll()
229
+ window.requestAnimationFrame(() => refreshSidebarScroll())
230
+ })
231
+ })
232
+
233
+ watch(
234
+ [outline, expandedIds, sidebarOpen],
235
+ () => {
236
+ if (!sidebarOpen.value) return
237
+ void nextTick(() => refreshSidebarScroll())
238
+ },
239
+ { deep: true }
240
+ )
241
+
242
+ watch([zoomPercent, loading, mammothHtml, officeSrc], () => {
243
+ void nextTick(() => refreshStageScroll())
244
+ })
245
+
246
+ function revokeImageUrl() {
247
+ if (imageObjectUrl) {
248
+ URL.revokeObjectURL(imageObjectUrl)
249
+ imageObjectUrl = ''
250
+ }
251
+ imageUrl.value = ''
252
+ }
253
+
254
+ async function applyMammothHtml(blob: Blob) {
255
+ const converted = await convertOfficeBlobToHtml(blob)
256
+ const decorated = decorateHtmlWithOutline(converted.html)
257
+ if (!decorated.html.trim()) throw new Error('empty')
258
+ invalidateDocxPreviewRender()
259
+ clearDocxPreviewContainer(docxPreviewHostRef.value)
260
+ mammothHtml.value = decorated.html
261
+ outline.value = decorated.outline
262
+ const ids = new Set<string>()
263
+ collectOutlineIds(decorated.outline, ids)
264
+ expandedIds.value = ids
265
+ charCount.value = countPlainTextChars(stripHtmlToText(decorated.html))
266
+ loading.value = false
267
+ officeSrc.value = undefined
268
+ await nextTick()
269
+ if (stageRef.value) buildOutlineFromDom(stageRef.value)
270
+ }
271
+
272
+ function zoomBy(delta: number) {
273
+ zoomPercent.value = clampZoomPercent(zoomPercent.value + delta)
274
+ }
275
+
276
+ function collectOutlineIds(items: PreviewOutlineItem[], into: Set<string>) {
277
+ for (const item of items) {
278
+ into.add(item.id)
279
+ if (item.children?.length) collectOutlineIds(item.children, into)
280
+ }
281
+ }
282
+
283
+ function toggleOutline(id: string) {
284
+ const next = new Set(expandedIds.value)
285
+ if (next.has(id)) next.delete(id)
286
+ else next.add(id)
287
+ expandedIds.value = next
288
+ }
289
+
290
+ function findOutlineTitle(items: PreviewOutlineItem[], id: string): string {
291
+ for (const item of items) {
292
+ if (item.id === id) return item.title
293
+ if (item.children?.length) {
294
+ const found = findOutlineTitle(item.children, id)
295
+ if (found) return found
296
+ }
297
+ }
298
+ return ''
299
+ }
300
+
301
+ function normalizeOutlineText(text: string) {
302
+ return (text || '').replace(/\s+/g, ' ').trim()
303
+ }
304
+
305
+ function scrollElementIntoStage(el: HTMLElement) {
306
+ const stage = getStageScrollWrap()
307
+ if (!stage) {
308
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' })
309
+ return
310
+ }
311
+ // 在可滾動 stage 內定位(兼容 zoom)
312
+ const stageRect = stage.getBoundingClientRect()
313
+ const elRect = el.getBoundingClientRect()
314
+ const nextTop = stage.scrollTop + (elRect.top - stageRect.top) - 12
315
+ stage.scrollTo({ top: Math.max(0, nextTop), behavior: 'smooth' })
316
+ }
317
+
318
+ function scrollToOutline(id: string) {
319
+ activeOutlineId.value = id
320
+
321
+ const byId =
322
+ (document.getElementById(id) as HTMLElement | null) ||
323
+ (stageRef.value?.querySelector(`[data-outline-id="${CSS.escape(id)}"]`) as HTMLElement | null)
324
+ if (byId) {
325
+ scrollElementIntoStage(byId)
326
+ return
327
+ }
328
+
329
+ const title = normalizeOutlineText(findOutlineTitle(outline.value, id))
330
+ if (!title || !stageRef.value) return
331
+
332
+ // 僅在正文標題 / 有序列表錨點中匹配
333
+ const candidates = Array.from(
334
+ stageRef.value.querySelectorAll(
335
+ 'h1, h2, h3, h4, h5, h6, ol > li, [data-outline-id], .docx-wrapper [class*="Heading"], .docx-wrapper [class*="heading"]'
336
+ )
337
+ ) as HTMLElement[]
338
+ const matched =
339
+ candidates.find((node) => normalizeOutlineText(node.textContent || '') === title) ||
340
+ candidates.find((node) => normalizeOutlineText(node.textContent || '').includes(title))
341
+
342
+ if (matched) {
343
+ const target = matched as HTMLElement
344
+ if (!target.id) target.id = id
345
+ target.setAttribute('data-outline-id', id)
346
+ scrollElementIntoStage(target)
347
+ }
348
+ }
349
+
350
+ /** 左側大綱:僅以正文標題 / 有序列表為準 */
351
+ function buildOutlineFromDom(root: HTMLElement) {
352
+ // 清掉舊錨點,避免切檔 / 重渲後殘留非有序項
353
+ root.querySelectorAll('[data-outline-id]').forEach((el) => {
354
+ if (el instanceof HTMLElement) {
355
+ el.removeAttribute('data-outline-id')
356
+ if (el.id?.startsWith('outline-')) el.removeAttribute('id')
357
+ }
358
+ })
359
+
360
+ const roots = buildOutlineFromOrderedLists(root, { stamp: true })
361
+ outline.value = roots
362
+ const ids = new Set<string>()
363
+ collectOutlineIds(roots, ids)
364
+ expandedIds.value = ids
365
+ void nextTick(() => {
366
+ refreshSidebarScroll()
367
+ refreshStageScroll()
368
+ })
369
+ }
370
+
371
+ async function nextTickCountFromDom() {
372
+ await Promise.resolve()
373
+ // 等 vue-office 佈局穩定後再抽大綱並打錨點
374
+ await new Promise<void>((resolve) => {
375
+ window.requestAnimationFrame(() => resolve())
376
+ })
377
+ const root = stageRef.value
378
+ if (!root) return
379
+ charCount.value = countPlainTextChars(root.innerText || '')
380
+ buildOutlineFromDom(root)
381
+ void nextTick(() => refreshStageScroll())
382
+ }
383
+
384
+ function onOfficeRendered() {
385
+ loading.value = false
386
+ void nextTickCountFromDom()
387
+ }
388
+
389
+ function onOfficeError() {
390
+ loading.value = false
391
+ errorMessage.value = '檔案預覽失敗'
392
+ }
393
+
394
+ function handlePrint() {
395
+ if (mammothHtml.value && printRef.value) {
396
+ printPreviewElement(printRef.value)
397
+ return
398
+ }
399
+ window.print()
400
+ }
401
+
402
+ function handleDownload() {
403
+ if (!fileBlob.value) {
404
+ ElMessage.warning('檔案尚未載入完成')
405
+ return
406
+ }
407
+ downloadBlobAsFile(fileBlob.value, fileName.value)
408
+ }
409
+
410
+ function handleClose() {
411
+ window.close()
412
+ window.setTimeout(() => {
413
+ if (!window.closed) ElMessage.info('請手動關閉此分頁')
414
+ }, 200)
415
+ }
416
+
417
+ async function tryRenderDocxNative(blob: Blob) {
418
+ invalidateDocxPreviewRender()
419
+ clearDocxPreviewContainer(docxPreviewHostRef.value)
420
+
421
+ await nextTick()
422
+ const host = docxPreviewHostRef.value
423
+ if (!host) return false
424
+
425
+ try {
426
+ const buffer = await blobToArrayBuffer(blob)
427
+ await renderDocxWithDocxPreview(host, buffer)
428
+ onOfficeRendered()
429
+ return true
430
+ } catch (err) {
431
+ console.warn('[file-preview] docx-preview failed, fallback mammoth', err)
432
+ clearDocxPreviewContainer(host)
433
+ return false
434
+ }
435
+ }
436
+
437
+ async function loadPreview() {
438
+ loading.value = true
439
+ errorMessage.value = ''
440
+ mammothHtml.value = ''
441
+ officeSrc.value = undefined
442
+ invalidateDocxPreviewRender()
443
+ clearDocxPreviewContainer(docxPreviewHostRef.value)
444
+ outline.value = []
445
+ fileBlob.value = null
446
+ revokeImageUrl()
447
+
448
+ const t = token.value
449
+ if (!t) {
450
+ loading.value = false
451
+ errorMessage.value = '缺少預覽參數'
452
+ return
453
+ }
454
+ const payload = readFilePreviewPayload(t)
455
+ if (!payload) {
456
+ loading.value = false
457
+ errorMessage.value = '預覽會話已失效,請返回重新點擊預覽'
458
+ return
459
+ }
460
+
461
+ fileName.value = payload.name
462
+ document.title = payload.name
463
+ kind.value = getPreviewFileKind(payload.name)
464
+
465
+ abort?.abort()
466
+ abort = new AbortController()
467
+
468
+ try {
469
+ const remoteUrl = String(payload.url || '').trim()
470
+ const useRemoteUrl = /^https?:\/\//i.test(remoteUrl)
471
+
472
+ //0918 tip / REF:token 裡已有下載地址,PDF/圖片直接用該 URL 渲染,不走本系統 download 接口
473
+ if (useRemoteUrl && kind.value === 'pdf') {
474
+ officeSrc.value = remoteUrl
475
+ return
476
+ }
477
+ if (useRemoteUrl && kind.value === 'image') {
478
+ imageUrl.value = remoteUrl
479
+ loading.value = false
480
+ return
481
+ }
482
+ //0918
483
+
484
+ const blob = await fetchPreviewFileBlob(payload, abort.signal)
485
+ fileBlob.value = blob
486
+
487
+ if (kind.value === 'pdf') {
488
+ officeSrc.value = await blobToArrayBuffer(blob)
489
+ return
490
+ }
491
+
492
+ if (kind.value === 'docx') {
493
+ const nativeOk = await tryRenderDocxNative(blob)
494
+ if (nativeOk) {
495
+ // 並行 mammoth 僅預填大綱 / 字數(不替換正文)
496
+ void convertOfficeBlobToHtml(blob)
497
+ .then((converted) => {
498
+ if (!outline.value.length) {
499
+ const decorated = decorateHtmlWithOutline(converted.html)
500
+ outline.value = decorated.outline
501
+ const ids = new Set<string>()
502
+ collectOutlineIds(decorated.outline, ids)
503
+ expandedIds.value = ids
504
+ }
505
+ if (!charCount.value) {
506
+ charCount.value = countPlainTextChars(stripHtmlToText(converted.html))
507
+ }
508
+ })
509
+ .catch(() => undefined)
510
+ return
511
+ }
512
+ try {
513
+ await applyMammothHtml(blob)
514
+ } catch {
515
+ errorMessage.value = 'DOCX 預覽失敗'
516
+ loading.value = false
517
+ }
518
+ return
519
+ }
520
+
521
+ if (kind.value === 'doc') {
522
+ try {
523
+ await applyMammothHtml(blob)
524
+ } catch (err) {
525
+ console.error(err)
526
+ errorMessage.value = '舊版 DOC 預覽失敗,請轉為 DOCX 後再試'
527
+ loading.value = false
528
+ }
529
+ return
530
+ }
531
+
532
+ if (kind.value === 'image') {
533
+ revokeImageUrl()
534
+ imageObjectUrl = URL.createObjectURL(blob)
535
+ imageUrl.value = imageObjectUrl
536
+ charCount.value = 0
537
+ loading.value = false
538
+ return
539
+ }
540
+
541
+ errorMessage.value = '暫僅支持預覽 PDF / DOCX / DOC / 圖片'
542
+ loading.value = false
543
+ } catch (err) {
544
+ console.error(err)
545
+ errorMessage.value = err instanceof Error && err.message.trim() ? err.message : '檔案載入失敗'
546
+ loading.value = false
547
+ }
548
+ }
549
+
550
+ function onWindowResize() {
551
+ refreshSidebarScroll()
552
+ refreshStageScroll()
553
+ }
554
+
555
+ onMounted(() => {
556
+ void loadPreview()
557
+ window.addEventListener('resize', onWindowResize, { passive: true })
558
+ void nextTick(() => {
559
+ refreshSidebarScroll()
560
+ refreshStageScroll()
561
+ })
562
+ })
563
+
564
+ onBeforeUnmount(() => {
565
+ window.removeEventListener('resize', onWindowResize)
566
+ abort?.abort()
567
+ invalidateDocxPreviewRender()
568
+ clearDocxPreviewContainer(docxPreviewHostRef.value)
569
+ revokeImageUrl()
570
+ if (token.value) clearFilePreviewPayload(token.value)
571
+ })
572
+ </script>
573
+
574
+ <style scoped>
575
+ .file-preview-page {
576
+ --fp-header-h: 72px;
577
+ --fp-font: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
578
+ display: flex;
579
+ flex-direction: column;
580
+ height: 100vh;
581
+ max-height: 100vh;
582
+ min-height: 100vh;
583
+ overflow: hidden;
584
+ background: #e8e8e8;
585
+ color: #303133;
586
+ font-family: var(--fp-font);
587
+ font-size: 15px;
588
+ font-weight: 500;
589
+ }
590
+
591
+ .file-preview-page__header {
592
+ flex-shrink: 0;
593
+ display: grid;
594
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
595
+ align-items: center;
596
+ gap: 12px;
597
+ height: var(--fp-header-h);
598
+ padding: 0 12px;
599
+ background: #fff;
600
+ border-bottom: 1px solid #e5e7eb;
601
+ z-index: 10;
602
+ font-family: var(--fp-font);
603
+ font-size: 15px;
604
+ font-weight: 500;
605
+ color: #303133;
606
+ }
607
+
608
+ .file-preview-page__header-left,
609
+ .file-preview-page__header-right {
610
+ display: flex;
611
+ align-items: center;
612
+ gap: 8px;
613
+ min-width: 0;
614
+ }
615
+
616
+ .file-preview-page__header-right {
617
+ justify-content: flex-end;
618
+ }
619
+
620
+ .file-preview-page__header-center {
621
+ display: inline-flex;
622
+ align-items: center;
623
+ gap: 8px;
624
+ }
625
+
626
+ .file-preview-page__title {
627
+ margin: 0;
628
+ font-family: var(--fp-font);
629
+ font-size: 18px;
630
+ font-weight: 500;
631
+ color: #303133;
632
+ overflow: hidden;
633
+ text-overflow: ellipsis;
634
+ white-space: nowrap;
635
+ }
636
+
637
+ .file-preview-page__zoom {
638
+ box-sizing: border-box;
639
+ display: inline-flex;
640
+ align-items: center;
641
+ justify-content: center;
642
+ min-width: 64px;
643
+ height: 28px;
644
+ padding: 0 10px;
645
+ border: 1px solid #dcdfe6;
646
+ border-radius: 4px;
647
+ background: #fff;
648
+ font-family: var(--fp-font);
649
+ font-size: 15px;
650
+ font-weight: 500;
651
+ color: #303133;
652
+ line-height: 1;
653
+ text-align: center;
654
+ }
655
+
656
+ .file-preview-page__chars {
657
+ margin-left: 8px;
658
+ font-family: var(--fp-font);
659
+ font-size: 15px;
660
+ font-weight: 500;
661
+ color: #303133;
662
+ text-align: center;
663
+ }
664
+
665
+ .file-preview-page__icon-btn,
666
+ .file-preview-page__text-btn {
667
+ display: inline-flex;
668
+ align-items: center;
669
+ justify-content: center;
670
+ gap: 4px;
671
+ height: 32px;
672
+ padding: 0 8px;
673
+ border: none;
674
+ border-radius: 4px;
675
+ background: transparent;
676
+ color: #303133;
677
+ cursor: pointer;
678
+ font-family: var(--fp-font);
679
+ font-size: 15px;
680
+ font-weight: 500;
681
+ }
682
+
683
+ .file-preview-page__icon-btn {
684
+ width: 32px;
685
+ padding: 0;
686
+ }
687
+
688
+ .file-preview-page__icon-btn:hover,
689
+ .file-preview-page__text-btn:hover {
690
+ background: #f5f5f5;
691
+ }
692
+
693
+ .file-preview-page__icon-btn:disabled {
694
+ opacity: 0.4;
695
+ cursor: not-allowed;
696
+ }
697
+
698
+ .file-preview-page__body {
699
+ flex: 1;
700
+ display: flex;
701
+ min-height: 0;
702
+ /* 正文 / 目錄共用視口高度 = 整頁 - 頂欄 */
703
+ height: calc(100vh - var(--fp-header-h));
704
+ max-height: calc(100vh - var(--fp-header-h));
705
+ overflow: hidden;
706
+ }
707
+
708
+ .file-preview-page__sidebar {
709
+ flex-shrink: 0;
710
+ width: 385px;
711
+ height: 100%;
712
+ max-height: 100%;
713
+ min-height: 0;
714
+ display: flex;
715
+ flex-direction: column;
716
+ overflow: hidden;
717
+ background: #fff;
718
+ border-right: 1px solid #e5e7eb;
719
+ box-sizing: border-box;
720
+ font-family: var(--fp-font);
721
+ font-size: 16px;
722
+ font-weight: 500;
723
+ color: #303133;
724
+ }
725
+
726
+ /* 目錄 / 正文滾動宿主:同一 class,同一高度鏈 */
727
+ .file-preview-page__panel-scroll {
728
+ flex: 1 1 auto;
729
+ min-height: 0 !important;
730
+ height: 100% !important;
731
+ max-height: 100% !important;
732
+ width: 100%;
733
+ }
734
+
735
+ .file-preview-page__outline-pad {
736
+ box-sizing: border-box;
737
+ padding: 12px 16px 12px 12px;
738
+ font-family: var(--fp-font);
739
+ font-size: 16px;
740
+ font-weight: 500;
741
+ color: #303133;
742
+ }
743
+
744
+ .file-preview-page__outline-empty {
745
+ box-sizing: border-box;
746
+ display: flex;
747
+ align-items: center;
748
+ justify-content: center;
749
+ min-height: 240px;
750
+ padding: 16px 12px;
751
+ font-family: var(--fp-font);
752
+ font-size: 16px;
753
+ font-weight: 500;
754
+ color: #606266;
755
+ text-align: center;
756
+ }
757
+
758
+ .file-preview-page__stage {
759
+ flex: 1;
760
+ min-width: 0;
761
+ height: 100%;
762
+ max-height: 100%;
763
+ min-height: 0;
764
+ display: flex;
765
+ flex-direction: column;
766
+ overflow: hidden;
767
+ }
768
+
769
+ .file-preview-page__stage-inner {
770
+ box-sizing: border-box;
771
+ padding: 24px 16px 48px;
772
+ }
773
+
774
+ .file-preview-page__scale {
775
+ width: 210mm;
776
+ max-width: 100%;
777
+ margin: 0 auto;
778
+ }
779
+
780
+ .file-preview-page__paper {
781
+ box-sizing: border-box;
782
+ width: 100%;
783
+ min-height: 297mm;
784
+ background: #fff;
785
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
786
+ }
787
+
788
+ .file-preview-page__paper--html {
789
+ padding: 48px 56px;
790
+ font-family: var(--fp-font);
791
+ font-size: 15px;
792
+ font-weight: 400;
793
+ line-height: 1.7;
794
+ color: #303133;
795
+ }
796
+
797
+ .file-preview-page__paper--html :deep(h1),
798
+ .file-preview-page__paper--html :deep(h2),
799
+ .file-preview-page__paper--html :deep(h3),
800
+ .file-preview-page__paper--html :deep(h4) {
801
+ margin: 1.2em 0 0.6em;
802
+ font-weight: 700;
803
+ color: #303133;
804
+ scroll-margin-top: 12px;
805
+ }
806
+
807
+ .file-preview-page__paper--html :deep(h1) {
808
+ font-size: 22px;
809
+ }
810
+
811
+ .file-preview-page__paper--html :deep(h2) {
812
+ font-size: 18px;
813
+ }
814
+
815
+ .file-preview-page__paper--html :deep(p) {
816
+ margin: 0.6em 0;
817
+ }
818
+
819
+ .file-preview-page__paper--office {
820
+ overflow: visible;
821
+ padding: 0;
822
+ }
823
+
824
+ /* PDF:去掉 A4 紙內 vue-office 默認灰底 / 內邊距造成的左右暗色帶 */
825
+ .file-preview-page__paper--pdf {
826
+ box-shadow: none;
827
+ }
828
+
829
+ .file-preview-page__paper--pdf :deep(.vue-office-pdf) {
830
+ overflow: visible !important;
831
+ text-align: left !important;
832
+ }
833
+
834
+ .file-preview-page__paper--pdf :deep(.vue-office-pdf-wrapper) {
835
+ background: #fff !important;
836
+ padding: 0 !important;
837
+ }
838
+
839
+ .file-preview-page__paper--pdf :deep(canvas) {
840
+ display: block;
841
+ width: 100% !important;
842
+ box-shadow: none !important;
843
+ }
844
+
845
+ /* vue-office 默認 height:100% 在僅有 min-height 的父級會塌成頂部窄條 */
846
+ .file-preview-page__office-viewer {
847
+ display: block;
848
+ width: 100%;
849
+ min-height: 297mm;
850
+ height: auto !important;
851
+ }
852
+
853
+ .file-preview-page__paper--office :deep(.vue-office-pdf) {
854
+ background: #fff !important;
855
+ width: 100% !important;
856
+ height: auto !important;
857
+ min-height: 297mm !important;
858
+ overflow: visible !important;
859
+ }
860
+
861
+ .file-preview-page__paper--office :deep(.docx-wrapper) {
862
+ background: #fff !important;
863
+ padding: 24px 32px !important;
864
+ width: 100% !important;
865
+ box-sizing: border-box;
866
+ }
867
+
868
+ .file-preview-page__paper--office :deep(.docx-wrapper > section.docx) {
869
+ box-shadow: none !important;
870
+ margin: 0 auto 16px !important;
871
+ /* 保留 docx-preview 计算的页宽,避免 width:100% 导致分页失效 */
872
+ width: auto !important;
873
+ max-width: 100%;
874
+ box-sizing: border-box;
875
+ /* 禁止页盒裁切正文:滚到底仍缺内容多半是 overflow:hidden + 固定页高 */
876
+ overflow: visible !important;
877
+ height: auto !important;
878
+ max-height: none !important;
879
+ }
880
+
881
+ .file-preview-page__docx-native :deep(.docx-wrapper) {
882
+ background: #fff !important;
883
+ padding: 24px 32px !important;
884
+ width: 100% !important;
885
+ box-sizing: border-box;
886
+ overflow: visible !important;
887
+ }
888
+
889
+ .file-preview-page__docx-native :deep(section.docx) {
890
+ overflow: visible !important;
891
+ height: auto !important;
892
+ max-height: none !important;
893
+ }
894
+
895
+ .file-preview-page__paper--image {
896
+ display: flex;
897
+ align-items: center;
898
+ justify-content: center;
899
+ padding: 24px;
900
+ }
901
+
902
+ .file-preview-page__image {
903
+ max-width: 100%;
904
+ height: auto;
905
+ object-fit: contain;
906
+ }
907
+
908
+ .file-preview-page__error {
909
+ box-sizing: border-box;
910
+ width: 210mm;
911
+ max-width: 100%;
912
+ min-height: 297mm;
913
+ margin: 0 auto;
914
+ padding: 48px 56px;
915
+ display: flex;
916
+ align-items: center;
917
+ justify-content: center;
918
+ background: #fff;
919
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
920
+ text-align: center;
921
+ color: #c53355;
922
+ font-family: var(--fp-font);
923
+ font-size: 15px;
924
+ font-weight: 500;
925
+ line-height: 1.6;
926
+ }
927
+
928
+ @media print {
929
+ .file-preview-page__header,
930
+ .file-preview-page__sidebar {
931
+ display: none !important;
932
+ }
933
+
934
+ .file-preview-page__stage {
935
+ padding: 0;
936
+ background: #fff;
937
+ overflow: visible;
938
+ }
939
+
940
+ .file-preview-page__scale {
941
+ zoom: 1 !important;
942
+ width: 100%;
943
+ }
944
+
945
+ .file-preview-page__paper {
946
+ box-shadow: none;
947
+ }
948
+ }
949
+ </style>