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,724 @@
1
+ /**
2
+ * 檔案預覽工具:打開預覽、Blob 緩存、大綱、mammoth、docx-preview
3
+ * - 路由:/gen-ai/file-preview
4
+ * - DOCX:docx-preview → mammoth;DOC:mammoth;PDF:@vue-office/pdf
5
+ */
6
+ import { buildAuthHeaders } from '@/api/request'
7
+ import { renderAsync, type Options } from 'docx-preview'
8
+ import mammoth from 'mammoth'
9
+
10
+ /* ── IndexedDB Blob 緩存 ── */
11
+
12
+ const DB_NAME = 'gen-ai-source-file-blobs'
13
+ const DB_VERSION = 2
14
+ const STORE = 'files'
15
+
16
+ type StoredFile = {
17
+ id: string
18
+ name: string
19
+ mime: string
20
+ blob: Blob
21
+ updatedAt: number
22
+ }
23
+
24
+ function openDb(): Promise<IDBDatabase> {
25
+ return new Promise((resolve, reject) => {
26
+ if (typeof indexedDB === 'undefined') {
27
+ reject(new Error('IndexedDB 不可用'))
28
+ return
29
+ }
30
+ const req = indexedDB.open(DB_NAME, DB_VERSION)
31
+ req.onerror = () => reject(req.error || new Error('打開 IndexedDB 失敗'))
32
+ req.onupgradeneeded = () => {
33
+ const db = req.result
34
+ if (db.objectStoreNames.contains(STORE)) db.deleteObjectStore(STORE)
35
+ db.createObjectStore(STORE, { keyPath: 'id' })
36
+ }
37
+ req.onsuccess = () => resolve(req.result)
38
+ })
39
+ }
40
+
41
+ export async function putSourceFileBlob(id: string, blob: Blob, name = '') {
42
+ const key = String(id || '').trim()
43
+ if (!key || !blob) return
44
+ const db = await openDb()
45
+ await new Promise<void>((resolve, reject) => {
46
+ const tx = db.transaction(STORE, 'readwrite')
47
+ tx.oncomplete = () => resolve()
48
+ tx.onerror = () => reject(tx.error || new Error('寫入檔案緩存失敗'))
49
+ const row: StoredFile = {
50
+ id: key,
51
+ name: name || 'file',
52
+ mime: blob.type || 'application/octet-stream',
53
+ blob,
54
+ updatedAt: Date.now(),
55
+ }
56
+ tx.objectStore(STORE).put(row)
57
+ })
58
+ db.close()
59
+ }
60
+
61
+ export async function getSourceFileBlob(id: string): Promise<Blob | null> {
62
+ const key = String(id || '').trim()
63
+ if (!key) return null
64
+ try {
65
+ const db = await openDb()
66
+ const row = await new Promise<StoredFile | undefined>((resolve, reject) => {
67
+ const tx = db.transaction(STORE, 'readonly')
68
+ const req = tx.objectStore(STORE).get(key)
69
+ req.onsuccess = () => resolve(req.result as StoredFile | undefined)
70
+ req.onerror = () => reject(req.error || new Error('讀取檔案緩存失敗'))
71
+ })
72
+ db.close()
73
+ return row?.blob ?? null
74
+ } catch {
75
+ return null
76
+ }
77
+ }
78
+
79
+ export async function deleteSourceFileBlob(id: string) {
80
+ const key = String(id || '').trim()
81
+ if (!key) return
82
+ try {
83
+ const db = await openDb()
84
+ await new Promise<void>((resolve, reject) => {
85
+ const tx = db.transaction(STORE, 'readwrite')
86
+ tx.oncomplete = () => resolve()
87
+ tx.onerror = () => reject(tx.error || new Error('刪除檔案緩存失敗'))
88
+ tx.objectStore(STORE).delete(key)
89
+ })
90
+ db.close()
91
+ } catch {
92
+ // ignore
93
+ }
94
+ }
95
+
96
+ export async function clearAllSourceFileBlobs() {
97
+ try {
98
+ const db = await openDb()
99
+ await new Promise<void>((resolve, reject) => {
100
+ const tx = db.transaction(STORE, 'readwrite')
101
+ tx.oncomplete = () => resolve()
102
+ tx.onerror = () => reject(tx.error || new Error('清空檔案緩存失敗'))
103
+ tx.objectStore(STORE).clear()
104
+ })
105
+ db.close()
106
+ } catch {
107
+ // ignore
108
+ }
109
+ }
110
+
111
+ /* ── docx-preview ── */
112
+
113
+ const DOCX_PREVIEW_DEFAULT_OPTIONS: Partial<Options> = {
114
+ className: 'docx',
115
+ inWrapper: true,
116
+ ignoreWidth: false,
117
+ ignoreHeight: true,
118
+ ignoreFonts: false,
119
+ breakPages: true,
120
+ ignoreLastRenderedPageBreak: false,
121
+ useBase64URL: true,
122
+ renderHeaders: true,
123
+ renderFooters: true,
124
+ renderFootnotes: true,
125
+ renderEndnotes: true,
126
+ renderAltChunks: true,
127
+ trimXmlDeclaration: true,
128
+ experimental: true,
129
+ debug: false,
130
+ }
131
+
132
+ type DocxPreviewSource = Blob | ArrayBuffer | Uint8Array
133
+
134
+ let renderGeneration = 0
135
+
136
+ function normalizeDocxPreviewSource(source: DocxPreviewSource): Blob | ArrayBuffer {
137
+ if (source instanceof Blob) return source
138
+ if (source instanceof ArrayBuffer) return source
139
+ if (source instanceof Uint8Array) {
140
+ const copy = source.slice()
141
+ return copy.buffer.slice(copy.byteOffset, copy.byteOffset + copy.byteLength)
142
+ }
143
+ throw new TypeError('unsupported docx preview source')
144
+ }
145
+
146
+ export function clearDocxPreviewContainer(container: HTMLElement | null | undefined) {
147
+ if (!container) return
148
+ container.querySelectorAll('img[src^="blob:"]').forEach((node) => {
149
+ const src = (node as HTMLImageElement).src
150
+ if (src.startsWith('blob:')) URL.revokeObjectURL(src)
151
+ })
152
+ container.innerHTML = ''
153
+ }
154
+
155
+ function patchDocxPreviewImages(root: ParentNode) {
156
+ root.querySelectorAll('img').forEach((img) => {
157
+ if (!(img instanceof HTMLImageElement)) return
158
+ if (img.dataset.docxPatched === '1') return
159
+ img.dataset.docxPatched = '1'
160
+ img.addEventListener(
161
+ 'error',
162
+ () => {
163
+ if (img.dataset.docxBroken === '1') return
164
+ img.dataset.docxBroken = '1'
165
+ img.alt = img.alt || '图片无法解析'
166
+ img.style.objectFit = 'contain'
167
+ img.style.background = '#f5f5f5'
168
+ img.style.minHeight = '48px'
169
+ img.style.minWidth = '48px'
170
+ },
171
+ { once: true }
172
+ )
173
+ })
174
+ }
175
+
176
+ function patchDocxPreviewPageOverflow(root: ParentNode) {
177
+ root.querySelectorAll('section.docx, .docx-wrapper > section').forEach((node) => {
178
+ if (!(node instanceof HTMLElement)) return
179
+ node.style.setProperty('overflow', 'visible', 'important')
180
+ node.style.setProperty('height', 'auto', 'important')
181
+ if (node.style.minHeight && /mm|px|in|pt/.test(node.style.minHeight)) {
182
+ node.style.minHeight = ''
183
+ }
184
+ })
185
+ }
186
+
187
+ export async function renderDocxWithDocxPreview(
188
+ bodyContainer: HTMLElement,
189
+ source: DocxPreviewSource,
190
+ options: Partial<Options> = {},
191
+ styleContainer?: HTMLElement | null
192
+ ) {
193
+ const generation = ++renderGeneration
194
+ clearDocxPreviewContainer(bodyContainer)
195
+ const data = normalizeDocxPreviewSource(source)
196
+ const mergedOptions = { ...DOCX_PREVIEW_DEFAULT_OPTIONS, ...options }
197
+ await renderAsync(data, bodyContainer, styleContainer ?? undefined, mergedOptions)
198
+ if (generation !== renderGeneration) throw new Error('docx-preview render stale')
199
+ patchDocxPreviewImages(bodyContainer)
200
+ patchDocxPreviewPageOverflow(bodyContainer)
201
+ return { generation }
202
+ }
203
+
204
+ export function invalidateDocxPreviewRender() {
205
+ renderGeneration += 1
206
+ }
207
+
208
+ /* ── 打開預覽 / 取檔 / 大綱 / mammoth ── */
209
+
210
+ export type PreviewFileKind = 'pdf' | 'docx' | 'doc' | 'image' | 'unknown'
211
+
212
+ export type PreviewOutlineItem = {
213
+ id: string
214
+ level: number
215
+ title: string
216
+ children?: PreviewOutlineItem[]
217
+ }
218
+
219
+ export type FilePreviewPayload = {
220
+ id: string
221
+ name: string
222
+ /** 可選直鏈;無則按 id 走下載接口 / 本地緩存 */
223
+ url?: string
224
+ /** 可選:打開前已取到的 blob(同源 tab 傳遞用 object URL) */
225
+ objectUrl?: string
226
+ }
227
+
228
+ /** 新開 tab 不共享 sessionStorage,故用 localStorage + 讀後清理 */
229
+ const PREVIEW_STORAGE_PREFIX = 'gen-ai-file-preview:'
230
+ /**
231
+ * 與 GenaiReportSectionController 命名一致;參數用 fileIndexId(對齊 fileIndexIdList)
232
+ * 本地上傳檔會先走 IndexedDB,不依賴此接口。
233
+ */
234
+ const FILE_DOWNLOAD_CANDIDATES = [
235
+ { url: '/api/v1/GenaiReportFileController/download', bodyKey: 'fileIndexId' as const },
236
+ { url: '/api/v1/GenaiReportFileController/downloadFile', bodyKey: 'fileIndexId' as const },
237
+ { url: '/api/v1/GenaiReportFileController/download', bodyKey: 'fileId' as const },
238
+ ]
239
+
240
+ /** //0918 檢測是否為歷史緩存中的無效樣例 blob(誤寫入會導致空白預覽) */
241
+ export async function isDemoSampleBlob(blob: Blob): Promise<boolean> {
242
+ if (!blob || blob.size <= 0) return false
243
+ // 無效樣例體積很小;真實圖文報告通常遠大於此
244
+ if (blob.size > 12_000) return false
245
+ try {
246
+ const buf = await blob.arrayBuffer()
247
+ const text = new TextDecoder('utf-8', { fatal: false }).decode(buf)
248
+ return text.includes('GenAI File Preview Sample') || text.includes('Heading One Demo')
249
+ } catch {
250
+ return false
251
+ }
252
+ }
253
+ //0918
254
+
255
+ export function getPreviewFileKind(fileName: string): PreviewFileKind {
256
+ const ext = String(fileName || '')
257
+ .trim()
258
+ .split('.')
259
+ .pop()
260
+ ?.toLowerCase()
261
+ if (ext === 'pdf') return 'pdf'
262
+ if (ext === 'docx') return 'docx'
263
+ if (ext === 'doc') return 'doc'
264
+ if (ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'gif' || ext === 'webp') {
265
+ return 'image'
266
+ }
267
+ return 'unknown'
268
+ }
269
+
270
+ export function isPreviewableFileName(fileName: string) {
271
+ const kind = getPreviewFileKind(fileName)
272
+ return kind === 'pdf' || kind === 'docx' || kind === 'doc' || kind === 'image'
273
+ }
274
+
275
+ function previewStorageKey(token: string) {
276
+ return `${PREVIEW_STORAGE_PREFIX}${token}`
277
+ }
278
+
279
+ /** 寫入 localStorage 並新開預覽頁;可選先寫入 blob 緩存 */
280
+ export async function openFilePreviewInNewTab(payload: FilePreviewPayload & { blob?: Blob }) {
281
+ const name = String(payload.name || '').trim() || '未命名檔案'
282
+ if (!isPreviewableFileName(name)) {
283
+ throw new Error('暫僅支持預覽 PDF / DOCX / DOC / 圖片')
284
+ }
285
+ const id = String(payload.id || '').trim()
286
+ if (payload.blob) {
287
+ if (await isDemoSampleBlob(payload.blob)) {
288
+ throw new Error('無法預覽:檔案內容無效,請重新上傳真實檔案')
289
+ }
290
+ if (id) await putSourceFileBlob(id, payload.blob, name)
291
+ }
292
+
293
+ const token =
294
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
295
+ ? crypto.randomUUID()
296
+ : `fp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
297
+
298
+ // 同源新 tab 可讀取 opener 創建的 blob: URL,作為 IDB 之外的可靠傳遞
299
+ let objectUrl = payload.objectUrl ? String(payload.objectUrl) : undefined
300
+ if (!objectUrl && payload.blob) {
301
+ objectUrl = URL.createObjectURL(payload.blob)
302
+ window.setTimeout(
303
+ () => {
304
+ if (objectUrl) URL.revokeObjectURL(objectUrl)
305
+ },
306
+ 5 * 60 * 1000
307
+ )
308
+ }
309
+
310
+ const data: FilePreviewPayload = {
311
+ id,
312
+ name,
313
+ url: payload.url ? String(payload.url) : undefined,
314
+ objectUrl,
315
+ }
316
+ localStorage.setItem(previewStorageKey(token), JSON.stringify(data))
317
+
318
+ const base = String(import.meta.env.BASE_URL || '/').replace(/\/?$/, '/')
319
+ const href = `${window.location.origin}${base}gen-ai/file-preview?token=${encodeURIComponent(token)}`
320
+ //0918 tip 文件名跳轉:用 <a target=_blank rel=noopener> 打開,與 opener 斷開,絕不改寫原頁路由/內容
321
+ const anchor = document.createElement('a')
322
+ anchor.href = href
323
+ anchor.target = '_blank'
324
+ anchor.rel = 'noopener noreferrer'
325
+ anchor.style.display = 'none'
326
+ document.body.appendChild(anchor)
327
+ anchor.click()
328
+ anchor.remove()
329
+ //0918
330
+ return token
331
+ }
332
+
333
+ export function readFilePreviewPayload(token: string): FilePreviewPayload | null {
334
+ const raw = localStorage.getItem(previewStorageKey(token))
335
+ if (!raw) return null
336
+ try {
337
+ const parsed = JSON.parse(raw) as FilePreviewPayload
338
+ if (!parsed?.name) return null
339
+ return parsed
340
+ } catch {
341
+ return null
342
+ }
343
+ }
344
+
345
+ export function clearFilePreviewPayload(token: string) {
346
+ localStorage.removeItem(previewStorageKey(token))
347
+ }
348
+
349
+ async function blobFromDownloadResponse(res: Response, signal?: AbortSignal): Promise<Blob | null> {
350
+ if (!res.ok) return null
351
+ const contentType = res.headers.get('content-type') || ''
352
+ if (contentType.includes('application/json')) {
353
+ const json = (await res.json()) as {
354
+ success?: boolean
355
+ message?: string
356
+ data?:
357
+ | string
358
+ | {
359
+ url?: string
360
+ fileUrl?: string
361
+ fileBase64?: string
362
+ base64?: string
363
+ content?: string
364
+ fileName?: string
365
+ }
366
+ }
367
+ const data = json?.data
368
+ if (typeof data === 'string' && data.trim()) {
369
+ if (/^https?:\/\//i.test(data) || data.startsWith('/')) {
370
+ const fileRes = await fetch(data, { signal, credentials: 'include' })
371
+ if (!fileRes.ok) return null
372
+ return fileRes.blob()
373
+ }
374
+ const rawB64 = data.includes(',') ? data.split(',').pop()! : data
375
+ const bin = atob(rawB64)
376
+ const bytes = new Uint8Array(bin.length)
377
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
378
+ return new Blob([bytes])
379
+ }
380
+ if (data && typeof data === 'object') {
381
+ const link = data.url || data.fileUrl
382
+ if (link) {
383
+ const fileRes = await fetch(link, { signal, credentials: 'include' })
384
+ if (!fileRes.ok) return null
385
+ return fileRes.blob()
386
+ }
387
+ const b64 = data.fileBase64 || data.base64 || data.content
388
+ if (b64) {
389
+ const rawB64 = b64.includes(',') ? b64.split(',').pop()! : b64
390
+ const bin = atob(rawB64)
391
+ const bytes = new Uint8Array(bin.length)
392
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
393
+ return new Blob([bytes])
394
+ }
395
+ }
396
+ return null
397
+ }
398
+ return res.blob()
399
+ }
400
+
401
+ async function downloadFromApi(fileId: string, fileName: string, signal?: AbortSignal) {
402
+ let lastStatus = 0
403
+ for (const candidate of FILE_DOWNLOAD_CANDIDATES) {
404
+ try {
405
+ const res = await fetch(candidate.url, {
406
+ method: 'POST',
407
+ headers: {
408
+ ...buildAuthHeaders(),
409
+ Accept: '*/*',
410
+ },
411
+ body: JSON.stringify({
412
+ [candidate.bodyKey]: fileId,
413
+ fileIndexId: fileId,
414
+ fileId,
415
+ fileName,
416
+ }),
417
+ signal,
418
+ })
419
+ lastStatus = res.status
420
+ const blob = await blobFromDownloadResponse(res, signal)
421
+ if (blob && blob.size > 0) {
422
+ await putSourceFileBlob(fileId, blob, fileName)
423
+ return blob
424
+ }
425
+ } catch {
426
+ // try next
427
+ }
428
+ }
429
+ if (lastStatus) {
430
+ throw new Error(`下載檔案失敗 ${lastStatus}`)
431
+ }
432
+ throw new Error('下載檔案失敗')
433
+ }
434
+
435
+ /** 按 url / objectUrl / IndexedDB / 下載接口取得 Blob(絕不回退樣例檔) */
436
+ export async function fetchPreviewFileBlob(
437
+ payload: FilePreviewPayload,
438
+ signal?: AbortSignal
439
+ ): Promise<Blob> {
440
+ if (payload.objectUrl) {
441
+ const res = await fetch(payload.objectUrl, { signal })
442
+ if (!res.ok) throw new Error(`讀取本地預覽失敗 ${res.status}`)
443
+ return res.blob()
444
+ }
445
+ // 已有明確檔案地址:只拉該地址,禁止再走 GenaiReportFileController
446
+ if (payload.url) {
447
+ const res = await fetch(payload.url, { signal, credentials: 'include' })
448
+ if (!res.ok) throw new Error(`下載檔案失敗 ${res.status}`)
449
+ const blob = await res.blob()
450
+ if (await isDemoSampleBlob(blob)) {
451
+ throw new Error('取得的檔案無效,請重新上傳後再預覽')
452
+ }
453
+ if (payload.id) await putSourceFileBlob(payload.id, blob, payload.name)
454
+ return blob
455
+ }
456
+
457
+ const fileId = String(payload.id || '').trim()
458
+ if (fileId) {
459
+ const cached = await getSourceFileBlob(fileId)
460
+ if (cached && cached.size > 0) {
461
+ if (await isDemoSampleBlob(cached)) {
462
+ await deleteSourceFileBlob(fileId)
463
+ } else {
464
+ return cached
465
+ }
466
+ }
467
+ }
468
+
469
+ if (!fileId) {
470
+ throw new Error('缺少檔案標識,無法預覽')
471
+ }
472
+
473
+ return downloadFromApi(fileId, payload.name, signal)
474
+ }
475
+
476
+ /** 與資訊源面板同一預覽頁:緩存 blob → FilePreviewPage */
477
+ export async function openSourceFilePreview(file: FilePreviewPayload) {
478
+ const name = String(file.name || '').trim() || '未命名檔案'
479
+ if (!isPreviewableFileName(name)) {
480
+ throw new Error('暫僅支持預覽 PDF / DOCX / DOC / 圖片')
481
+ }
482
+ const id = String(file.id || '').trim()
483
+ let blob = id ? await getSourceFileBlob(id) : null
484
+ if (blob && (await isDemoSampleBlob(blob))) {
485
+ if (id) await deleteSourceFileBlob(id)
486
+ blob = null
487
+ }
488
+ if (!blob || blob.size <= 0) {
489
+ blob = await fetchPreviewFileBlob({
490
+ id,
491
+ name,
492
+ url: file.url,
493
+ })
494
+ }
495
+ if (!blob || blob.size <= 0 || (await isDemoSampleBlob(blob))) {
496
+ throw new Error('無法取得真實檔案內容')
497
+ }
498
+ await openFilePreviewInNewTab({
499
+ id,
500
+ name,
501
+ url: file.url,
502
+ blob,
503
+ })
504
+ }
505
+
506
+ export async function blobToArrayBuffer(blob: Blob) {
507
+ return blob.arrayBuffer()
508
+ }
509
+
510
+ /** DOC / DOCX → HTML(mammoth 盡力;舊 .doc 可能失敗) */
511
+ export async function convertOfficeBlobToHtml(blob: Blob): Promise<{
512
+ html: string
513
+ messages: string[]
514
+ }> {
515
+ const arrayBuffer = await blob.arrayBuffer()
516
+ const result = await mammoth.convertToHtml({ arrayBuffer })
517
+ return {
518
+ html: String(result.value || ''),
519
+ messages: (result.messages || []).map((m) => String(m.message || '')),
520
+ }
521
+ }
522
+
523
+ export function countPlainTextChars(text: string) {
524
+ return Array.from(String(text || '').replace(/\s+/g, '')).length
525
+ }
526
+
527
+ export function stripHtmlToText(html: string) {
528
+ const doc = new DOMParser().parseFromString(html || '', 'text/html')
529
+ return doc.body?.textContent || ''
530
+ }
531
+
532
+ function pushOutlineItem(
533
+ roots: PreviewOutlineItem[],
534
+ stack: PreviewOutlineItem[],
535
+ item: PreviewOutlineItem
536
+ ) {
537
+ while (stack.length) {
538
+ const top = stack[stack.length - 1]
539
+ if (!top || top.level < item.level) break
540
+ stack.pop()
541
+ }
542
+ const parent = stack[stack.length - 1]
543
+ if (!parent) roots.push(item)
544
+ else {
545
+ if (!parent.children) parent.children = []
546
+ parent.children.push(item)
547
+ }
548
+ stack.push(item)
549
+ }
550
+
551
+ function normalizeOutlineTitle(text: string) {
552
+ return String(text || '')
553
+ .replace(/\s+/g, ' ')
554
+ .trim()
555
+ }
556
+
557
+ function liOwnTitle(li: HTMLElement) {
558
+ const clone = li.cloneNode(true) as HTMLElement
559
+ clone.querySelectorAll('ol, ul').forEach((node) => node.remove())
560
+ return normalizeOutlineTitle(clone.textContent || '')
561
+ }
562
+
563
+ function getPreviewBodyRoot(root: ParentNode): HTMLElement {
564
+ if (!(root instanceof HTMLElement)) return root as HTMLElement
565
+ const scoped =
566
+ root.querySelector('.file-preview-page__paper--html') ??
567
+ root.querySelector('.file-preview-page__paper--office') ??
568
+ root.querySelector('.docx-wrapper') ??
569
+ root.querySelector('article')
570
+ return scoped instanceof HTMLElement ? scoped : root
571
+ }
572
+
573
+ function resolveHeadingLevel(el: HTMLElement): number | null {
574
+ const tag = el.tagName.toLowerCase()
575
+ if (/^h[1-6]$/.test(tag)) return Number(tag.slice(1))
576
+ const cls = el.className?.toString?.() || ''
577
+ const m = cls.match(/(?:Heading|heading)[-_]?(\d)/i) || cls.match(/Heading(\d)/)
578
+ if (m?.[1]) return Math.min(6, Math.max(1, Number(m[1])))
579
+ return null
580
+ }
581
+
582
+ function isOutlineHeading(el: HTMLElement): boolean {
583
+ return resolveHeadingLevel(el) != null
584
+ }
585
+
586
+ function countOlAncestors(el: HTMLElement, boundary: HTMLElement): number {
587
+ let depth = 0
588
+ let node: HTMLElement | null = el.parentElement
589
+ while (node && boundary.contains(node)) {
590
+ if (node.tagName.toLowerCase() === 'ol') depth++
591
+ node = node.parentElement
592
+ }
593
+ return Math.max(1, depth)
594
+ }
595
+
596
+ type BodyOutlineCandidate = {
597
+ el: HTMLElement
598
+ level: number
599
+ title: string
600
+ }
601
+
602
+ /** 按正文 DOM 顺序采集:仅 h1–h6 / docx 标题 + ol>li */
603
+ function collectBodyOutlineCandidates(bodyRoot: HTMLElement): BodyOutlineCandidate[] {
604
+ const items: BodyOutlineCandidate[] = []
605
+ let lastHeadingLevel = 0
606
+
607
+ const walker = document.createTreeWalker(bodyRoot, NodeFilter.SHOW_ELEMENT, {
608
+ acceptNode(node) {
609
+ const el = node as HTMLElement
610
+ if (isOutlineHeading(el)) return NodeFilter.FILTER_ACCEPT
611
+ if (el.tagName.toLowerCase() === 'li' && el.parentElement?.tagName.toLowerCase() === 'ol') {
612
+ return NodeFilter.FILTER_ACCEPT
613
+ }
614
+ return NodeFilter.FILTER_SKIP
615
+ },
616
+ })
617
+
618
+ let node = walker.nextNode() as HTMLElement | null
619
+ while (node) {
620
+ if (isOutlineHeading(node)) {
621
+ const level = resolveHeadingLevel(node)!
622
+ const title = normalizeOutlineTitle(node.textContent || '')
623
+ if (title) {
624
+ items.push({ el: node, level, title })
625
+ lastHeadingLevel = level
626
+ }
627
+ } else if (node.tagName.toLowerCase() === 'li') {
628
+ const olDepth = countOlAncestors(node, bodyRoot)
629
+ const level = lastHeadingLevel > 0 ? Math.min(6, lastHeadingLevel + olDepth) : olDepth
630
+ const title = liOwnTitle(node)
631
+ if (title) items.push({ el: node, level, title })
632
+ }
633
+ node = walker.nextNode() as HTMLElement | null
634
+ }
635
+
636
+ return items
637
+ }
638
+
639
+ function stampOutlineAnchor(el: HTMLElement, id: string, stamp: boolean) {
640
+ if (stamp) {
641
+ el.id = id
642
+ el.setAttribute('data-outline-id', id)
643
+ el.style.scrollMarginTop = '12px'
644
+ } else {
645
+ el.setAttribute('id', id)
646
+ el.setAttribute('data-outline-id', id)
647
+ }
648
+ }
649
+
650
+ /**
651
+ * 左側大綱:仅以正文为准
652
+ * - 标题:h1–h6 / docx Heading
653
+ * - 有序列表:ol > li(按嵌套深度与前置标题排层级)
654
+ * 正文无二者则返回空列表
655
+ */
656
+ export function buildOutlineFromOrderedLists(
657
+ root: ParentNode,
658
+ options?: { stamp?: boolean }
659
+ ): PreviewOutlineItem[] {
660
+ const stamp = options?.stamp !== false
661
+ const bodyRoot = getPreviewBodyRoot(root)
662
+ const candidates = collectBodyOutlineCandidates(bodyRoot)
663
+ if (!candidates.length) return []
664
+
665
+ const roots: PreviewOutlineItem[] = []
666
+ const stack: PreviewOutlineItem[] = []
667
+ let index = 0
668
+
669
+ for (const { el, level, title } of candidates) {
670
+ const id = `outline-${index++}`
671
+ stampOutlineAnchor(el, id, stamp)
672
+ pushOutlineItem(roots, stack, { id, level, title, children: [] })
673
+ }
674
+
675
+ return roots
676
+ }
677
+
678
+ /** mammoth HTML:给正文标题 / 有序列表打锚点并返回大纲 */
679
+ export function decorateHtmlWithOutline(html: string): {
680
+ html: string
681
+ outline: PreviewOutlineItem[]
682
+ } {
683
+ const doc = new DOMParser().parseFromString(html || '', 'text/html')
684
+ const outline = buildOutlineFromOrderedLists(doc.body, { stamp: true })
685
+ return { html: doc.body.innerHTML, outline }
686
+ }
687
+
688
+ export function downloadBlobAsFile(blob: Blob, fileName: string) {
689
+ const url = URL.createObjectURL(blob)
690
+ const a = document.createElement('a')
691
+ a.href = url
692
+ a.download = fileName || 'download'
693
+ a.rel = 'noopener'
694
+ document.body.appendChild(a)
695
+ a.click()
696
+ a.remove()
697
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000)
698
+ }
699
+
700
+ export function printPreviewElement(el: HTMLElement | null) {
701
+ if (!el) {
702
+ window.print()
703
+ return
704
+ }
705
+ const win = window.open('', '_blank')
706
+ if (!win) {
707
+ window.print()
708
+ return
709
+ }
710
+ win.document.write(`<!doctype html><html><head><title>列印</title>
711
+ <style>
712
+ body{margin:0;font-family:Noto Sans,Noto Sans TC,sans-serif;}
713
+ img{max-width:100%;}
714
+ @page{margin:12mm;}
715
+ </style>
716
+ </head><body>${el.innerHTML}</body></html>`)
717
+ win.document.close()
718
+ win.focus()
719
+ win.print()
720
+ }
721
+
722
+ export function clampZoomPercent(value: number) {
723
+ return Math.min(200, Math.max(50, Math.round(value)))
724
+ }