aegon-lv82 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 (148) hide show
  1. package/package.json +12 -0
  2. package/src/App.vue +44 -0
  3. package/src/api/index.ts +19 -0
  4. package/src/api/modules/build-report/reportEditor.ts +526 -0
  5. package/src/api/modules/build-report/reportPermission.ts +64 -0
  6. package/src/api/modules/index.ts +69 -0
  7. package/src/api/modules/industry/green_mortgage.ts +42 -0
  8. package/src/api/modules/user/index.ts +4 -0
  9. package/src/api/request.ts +124 -0
  10. package/src/assets/sample-access-icon.png +0 -0
  11. package/src/assets/sample-pie-chart.png +0 -0
  12. package/src/assets/vue.svg +1 -0
  13. package/src/components/CapsuleScrollbar.vue +7 -0
  14. package/src/components/Export/ExcelExport.vue +592 -0
  15. package/src/components/Export/ExcelExport2.vue +494 -0
  16. package/src/components/Export/ExcelExport3.vue +342 -0
  17. package/src/components/Export/ExcelExport4.vue +665 -0
  18. package/src/components/Export/excelExport.js +547 -0
  19. package/src/components/Export/excelExport.ts +551 -0
  20. package/src/components/GEN-AI/index.vue +140 -0
  21. package/src/components/GEN-AI/index1.vue +422 -0
  22. package/src/components/GEN-AI/index10.vue +5 -0
  23. package/src/components/GEN-AI/index2.vue +548 -0
  24. package/src/components/GEN-AI/index3.vue +605 -0
  25. package/src/components/GEN-AI/index4.vue +609 -0
  26. package/src/components/GEN-AI/index5.vue +558 -0
  27. package/src/components/GEN-AI/index6.vue +634 -0
  28. package/src/components/GEN-AI/index7.vue +688 -0
  29. package/src/components/GEN-AI/index8.vue +458 -0
  30. package/src/components/GEN-AI/index9.vue +1217 -0
  31. package/src/components/GEN-AI/types.ts +12 -0
  32. package/src/components/GEN-AI/utils.ts +42 -0
  33. package/src/components/HelloWorld.vue +41 -0
  34. package/src/components/PageCard.vue +124 -0
  35. package/src/components/PageHeader.vue +32 -0
  36. package/src/components/backup/index5 copy.vue +556 -0
  37. package/src/components/backup/index5.vue +620 -0
  38. package/src/components/backup/index9 copy.vue +1029 -0
  39. package/src/components/backup/index9-pro.vue +1065 -0
  40. package/src/components/backup/index9.vue +1057 -0
  41. package/src/directives/btnLoading.ts +455 -0
  42. package/src/directives/debounce copy.ts +670 -0
  43. package/src/directives/debounce.ts +98 -0
  44. package/src/directives/index.ts +25 -0
  45. package/src/layouts/MainLayout.vue +129 -0
  46. package/src/main.ts +76 -0
  47. package/src/router/index.ts +142 -0
  48. package/src/router/menus.ts +28 -0
  49. package/src/store/modules/gen-ai/index.ts +1 -0
  50. package/src/store/modules/gen-ai/reportBuild.ts +37 -0
  51. package/src/style.css +79 -0
  52. package/src/styles/report-config-popover.css +39 -0
  53. package/src/styles/report-icon-btn.css +19 -0
  54. package/src/test/mock.ts +101 -0
  55. package/src/test/test1.vue +402 -0
  56. package/src/test/test2.vue +1689 -0
  57. package/src/types/gen-ai/build-report/reportEditor.ts +684 -0
  58. package/src/utils/function.ts +46 -0
  59. package/src/utils/gen-ai-navigation.ts +1 -0
  60. package/src/utils/gen-ai-scroll.ts +1 -0
  61. package/src/utils/openDataLoaderWordExport.ts +33 -0
  62. package/src/utils/pageScrollbar.ts +115 -0
  63. package/src/utils/randomTranscode.ts +87 -0
  64. package/src/utils/reportPdfExport.ts +44 -0
  65. package/src/views/Blank.vue +68 -0
  66. package/src/views/Home.vue +29 -0
  67. package/src/views/TemplateCenter/Knowledge.ts +83 -0
  68. package/src/views/TemplateCenter/data.d.ts +10 -0
  69. package/src/views/TemplateCenter/index.vue +1128 -0
  70. package/src/views/TemplateCenter/service.ts +69 -0
  71. package/src/views/gen-ai/build-report/chartStreamRefs.ts +174 -0
  72. package/src/views/gen-ai/build-report/coherence-check/index.vue +559 -0
  73. package/src/views/gen-ai/build-report/components/ReportChapterWorkspace.vue +4258 -0
  74. package/src/views/gen-ai/build-report/components/ReportConfigPanel.vue +727 -0
  75. package/src/views/gen-ai/build-report/components/ReportGenerateSettingsPanel.vue +2361 -0
  76. package/src/views/gen-ai/build-report/components/ReportGeneratingPanel.vue +1765 -0
  77. package/src/views/gen-ai/build-report/components/ReportPromptPanel.vue +614 -0
  78. package/src/views/gen-ai/build-report/components/ReportSourceFileSummary.vue +656 -0
  79. package/src/views/gen-ai/build-report/components/ReportSourcesPanel.vue +1327 -0
  80. package/src/views/gen-ai/build-report/components/ReportTemplateDetail.vue +191 -0
  81. package/src/views/gen-ai/build-report/components/ReportTemplateEditorFormPanel.vue +667 -0
  82. package/src/views/gen-ai/build-report/components/ReportTemplatePicker.vue +302 -0
  83. package/src/views/gen-ai/build-report/components/ReportWorkflowSidebar.vue +148 -0
  84. package/src/views/gen-ai/build-report/components/paramsSetting.vue +886 -0
  85. package/src/views/gen-ai/build-report/components/reportEdit.vue +2283 -0
  86. package/src/views/gen-ai/build-report/index.vue +856 -0
  87. package/src/views/gen-ai/build-report/report-domain.ts +1133 -0
  88. package/src/views/gen-ai/build-report/report-template-data.ts +295 -0
  89. package/src/views/gen-ai/build-report/useReportWorkflow.ts +1408 -0
  90. package/src/views/gen-ai/common/assets/NotoSans-SC.woff2 +0 -0
  91. package/src/views/gen-ai/common/assets/NotoSans-TC.woff2 +0 -0
  92. package/src/views/gen-ai/common/assets/NotoSans.woff2 +0 -0
  93. package/src/views/gen-ai/common/assets/fonts.css +278 -0
  94. package/src/views/gen-ai/common/assets/pagination.css +163 -0
  95. package/src/views/gen-ai/common/directives/btnLoading.ts +455 -0
  96. package/src/views/gen-ai/common/directives/debounce.ts +98 -0
  97. package/src/views/gen-ai/common/directives/index.ts +30 -0
  98. package/src/views/gen-ai/common/en.md +417 -0
  99. package/src/views/gen-ai/common/index.ts +36 -0
  100. package/src/views/gen-ai/common/types/component-expose.ts +9 -0
  101. package/src/views/gen-ai/common/utils/api-res-status.ts +7 -0
  102. package/src/views/gen-ai/common/utils/navigation.ts +59 -0
  103. package/src/views/gen-ai/common/utils/remote-request.ts +45 -0
  104. package/src/views/gen-ai/common/utils/scroll.ts +577 -0
  105. package/src/views/gen-ai/components/AIExport/docx-export.ts +950 -0
  106. package/src/views/gen-ai/components/AIExport/docxExport.ts +2566 -0
  107. package/src/views/gen-ai/components/AIExport/index.ts +52 -0
  108. package/src/views/gen-ai/components/AIExport/parseMarkdownHtml.ts +649 -0
  109. package/src/views/gen-ai/components/CapsuleScrollbar.vue +149 -0
  110. package/src/views/gen-ai/components/ChapterTitleScroll.vue +468 -0
  111. package/src/views/gen-ai/components/EntryHeroNavCards.vue +379 -0
  112. package/src/views/gen-ai/components/GenAiPagination.vue +52 -0
  113. package/src/views/gen-ai/components/ModelCard.vue +1114 -0
  114. package/src/views/gen-ai/components/Preview/FilePreviewPage.vue +947 -0
  115. package/src/views/gen-ai/components/Preview/PreviewCapsuleScroll.vue +205 -0
  116. package/src/views/gen-ai/components/Preview/PreviewOutlineTree.vue +148 -0
  117. package/src/views/gen-ai/components/Preview/index.ts +11 -0
  118. package/src/views/gen-ai/components/Preview/preview.ts +722 -0
  119. package/src/views/gen-ai/components/PromptField.vue +505 -0
  120. package/src/views/gen-ai/components/RecentReportsTable.vue +974 -0
  121. package/src/views/gen-ai/components/ReportCollapsibleSection.vue +172 -0
  122. package/src/views/gen-ai/components/ReportHistoryList.vue +637 -0
  123. package/src/views/gen-ai/components/ReportSectionConfirmPopover.vue +251 -0
  124. package/src/views/gen-ai/components/editor/ChapterTiptapEditor.vue +3139 -0
  125. package/src/views/gen-ai/components/editor/StructureBlockView.vue +441 -0
  126. package/src/views/gen-ai/components/editor/TiptapToolbar.vue +1036 -0
  127. package/src/views/gen-ai/components/editor/TypewriterDocEditor.vue +398 -0
  128. package/src/views/gen-ai/components/editor/_StructureOrgNode.vue +326 -0
  129. package/src/views/gen-ai/components/editor/index.ts +12 -0
  130. package/src/views/gen-ai/components/editor/parseMarkdownHtml.ts +667 -0
  131. package/src/views/gen-ai/gen-entry/api/index.ts +32 -0
  132. package/src/views/gen-ai/gen-entry/index.vue +287 -0
  133. package/src/views/gen-ai/gen-entry/types/index.ts +4 -0
  134. package/src/views/gen-ai/i.mark +2 -0
  135. package/src/views/gen-ai/management-center/index.vue +1815 -0
  136. package/src/views/gen-ai/management-center/mockData.ts +248 -0
  137. package/src/views/gen-ai/management-center/types/index.ts +37 -0
  138. package/src/views/gen-ai/model-manager/TemplateEditorFormPanel.vue +661 -0
  139. package/src/views/gen-ai/model-manager/api/index.ts +32 -0
  140. package/src/views/gen-ai/model-manager/api/mockApi.ts +33 -0
  141. package/src/views/gen-ai/model-manager/index.vue +1583 -0
  142. package/src/views/gen-ai/model-manager/mockData.ts +177 -0
  143. package/src/views/gen-ai/model-manager/template-detail-shared.ts +154 -0
  144. package/src/views/gen-ai/model-manager/template-editor.vue +345 -0
  145. package/src/views/gen-ai/model-manager/types/index.ts +40 -0
  146. package/src/views/gen-ai/model-manager/utils.ts +25 -0
  147. package/src/views/greenMortgage.vue +1834 -0
  148. package/src/vite-env.d.ts +46 -0
@@ -0,0 +1,722 @@
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
+ /** 檢測是否為開發用假樣例(曾誤寫入緩存會導致「空白/假內容」) */
241
+ export async function isDemoSampleBlob(blob: Blob): Promise<boolean> {
242
+ if (!blob || blob.size <= 0) return false
243
+ // 本地樣例 docx/pdf 體積很小;真實圖文報告通常遠大於此
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
+
254
+ export function getPreviewFileKind(fileName: string): PreviewFileKind {
255
+ const ext = String(fileName || '')
256
+ .trim()
257
+ .split('.')
258
+ .pop()
259
+ ?.toLowerCase()
260
+ if (ext === 'pdf') return 'pdf'
261
+ if (ext === 'docx') return 'docx'
262
+ if (ext === 'doc') return 'doc'
263
+ if (ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'gif' || ext === 'webp') {
264
+ return 'image'
265
+ }
266
+ return 'unknown'
267
+ }
268
+
269
+ export function isPreviewableFileName(fileName: string) {
270
+ const kind = getPreviewFileKind(fileName)
271
+ return kind === 'pdf' || kind === 'docx' || kind === 'doc' || kind === 'image'
272
+ }
273
+
274
+ function previewStorageKey(token: string) {
275
+ return `${PREVIEW_STORAGE_PREFIX}${token}`
276
+ }
277
+
278
+ /** 寫入 localStorage 並新開預覽頁;可選先寫入 blob 緩存 */
279
+ export async function openFilePreviewInNewTab(payload: FilePreviewPayload & { blob?: Blob }) {
280
+ const name = String(payload.name || '').trim() || '未命名檔案'
281
+ if (!isPreviewableFileName(name)) {
282
+ throw new Error('暫僅支持預覽 PDF / DOCX / DOC / 圖片')
283
+ }
284
+ const id = String(payload.id || '').trim()
285
+ if (payload.blob) {
286
+ if (await isDemoSampleBlob(payload.blob)) {
287
+ throw new Error('無法預覽:檔案內容無效,請重新上傳真實檔案')
288
+ }
289
+ if (id) await putSourceFileBlob(id, payload.blob, name)
290
+ }
291
+
292
+ const token =
293
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
294
+ ? crypto.randomUUID()
295
+ : `fp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
296
+
297
+ // 同源新 tab 可讀取 opener 創建的 blob: URL,作為 IDB 之外的可靠傳遞
298
+ let objectUrl = payload.objectUrl ? String(payload.objectUrl) : undefined
299
+ if (!objectUrl && payload.blob) {
300
+ objectUrl = URL.createObjectURL(payload.blob)
301
+ window.setTimeout(
302
+ () => {
303
+ if (objectUrl) URL.revokeObjectURL(objectUrl)
304
+ },
305
+ 5 * 60 * 1000
306
+ )
307
+ }
308
+
309
+ const data: FilePreviewPayload = {
310
+ id,
311
+ name,
312
+ url: payload.url ? String(payload.url) : undefined,
313
+ objectUrl,
314
+ }
315
+ localStorage.setItem(previewStorageKey(token), JSON.stringify(data))
316
+
317
+ const base = String(import.meta.env.BASE_URL || '/').replace(/\/?$/, '/')
318
+ const href = `${window.location.origin}${base}gen-ai/file-preview?token=${encodeURIComponent(token)}`
319
+ //0917 tip 文件名跳轉:用 <a target=_blank rel=noopener> 打開,與 opener 斷開,絕不改寫原頁路由/內容
320
+ const anchor = document.createElement('a')
321
+ anchor.href = href
322
+ anchor.target = '_blank'
323
+ anchor.rel = 'noopener noreferrer'
324
+ anchor.style.display = 'none'
325
+ document.body.appendChild(anchor)
326
+ anchor.click()
327
+ anchor.remove()
328
+ return token
329
+ }
330
+
331
+ export function readFilePreviewPayload(token: string): FilePreviewPayload | null {
332
+ const raw = localStorage.getItem(previewStorageKey(token))
333
+ if (!raw) return null
334
+ try {
335
+ const parsed = JSON.parse(raw) as FilePreviewPayload
336
+ if (!parsed?.name) return null
337
+ return parsed
338
+ } catch {
339
+ return null
340
+ }
341
+ }
342
+
343
+ export function clearFilePreviewPayload(token: string) {
344
+ localStorage.removeItem(previewStorageKey(token))
345
+ }
346
+
347
+ async function blobFromDownloadResponse(res: Response, signal?: AbortSignal): Promise<Blob | null> {
348
+ if (!res.ok) return null
349
+ const contentType = res.headers.get('content-type') || ''
350
+ if (contentType.includes('application/json')) {
351
+ const json = (await res.json()) as {
352
+ success?: boolean
353
+ message?: string
354
+ data?:
355
+ | string
356
+ | {
357
+ url?: string
358
+ fileUrl?: string
359
+ fileBase64?: string
360
+ base64?: string
361
+ content?: string
362
+ fileName?: string
363
+ }
364
+ }
365
+ const data = json?.data
366
+ if (typeof data === 'string' && data.trim()) {
367
+ if (/^https?:\/\//i.test(data) || data.startsWith('/')) {
368
+ const fileRes = await fetch(data, { signal, credentials: 'include' })
369
+ if (!fileRes.ok) return null
370
+ return fileRes.blob()
371
+ }
372
+ const rawB64 = data.includes(',') ? data.split(',').pop()! : data
373
+ const bin = atob(rawB64)
374
+ const bytes = new Uint8Array(bin.length)
375
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
376
+ return new Blob([bytes])
377
+ }
378
+ if (data && typeof data === 'object') {
379
+ const link = data.url || data.fileUrl
380
+ if (link) {
381
+ const fileRes = await fetch(link, { signal, credentials: 'include' })
382
+ if (!fileRes.ok) return null
383
+ return fileRes.blob()
384
+ }
385
+ const b64 = data.fileBase64 || data.base64 || data.content
386
+ if (b64) {
387
+ const rawB64 = b64.includes(',') ? b64.split(',').pop()! : b64
388
+ const bin = atob(rawB64)
389
+ const bytes = new Uint8Array(bin.length)
390
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
391
+ return new Blob([bytes])
392
+ }
393
+ }
394
+ return null
395
+ }
396
+ return res.blob()
397
+ }
398
+
399
+ async function downloadFromApi(fileId: string, fileName: string, signal?: AbortSignal) {
400
+ let lastStatus = 0
401
+ for (const candidate of FILE_DOWNLOAD_CANDIDATES) {
402
+ try {
403
+ const res = await fetch(candidate.url, {
404
+ method: 'POST',
405
+ headers: {
406
+ ...buildAuthHeaders(),
407
+ Accept: '*/*',
408
+ },
409
+ body: JSON.stringify({
410
+ [candidate.bodyKey]: fileId,
411
+ fileIndexId: fileId,
412
+ fileId,
413
+ fileName,
414
+ }),
415
+ signal,
416
+ })
417
+ lastStatus = res.status
418
+ const blob = await blobFromDownloadResponse(res, signal)
419
+ if (blob && blob.size > 0) {
420
+ await putSourceFileBlob(fileId, blob, fileName)
421
+ return blob
422
+ }
423
+ } catch {
424
+ // try next
425
+ }
426
+ }
427
+ if (lastStatus) {
428
+ throw new Error(`下載檔案失敗 ${lastStatus}`)
429
+ }
430
+ throw new Error('下載檔案失敗')
431
+ }
432
+
433
+ /** 按 url / objectUrl / IndexedDB / 下載接口取得 Blob(絕不回退樣例檔) */
434
+ export async function fetchPreviewFileBlob(
435
+ payload: FilePreviewPayload,
436
+ signal?: AbortSignal
437
+ ): Promise<Blob> {
438
+ if (payload.objectUrl) {
439
+ const res = await fetch(payload.objectUrl, { signal })
440
+ if (!res.ok) throw new Error(`讀取本地預覽失敗 ${res.status}`)
441
+ return res.blob()
442
+ }
443
+ // 已有明確檔案地址:只拉該地址,禁止再走 GenaiReportFileController
444
+ if (payload.url) {
445
+ const res = await fetch(payload.url, { signal, credentials: 'include' })
446
+ if (!res.ok) throw new Error(`下載檔案失敗 ${res.status}`)
447
+ const blob = await res.blob()
448
+ if (await isDemoSampleBlob(blob)) {
449
+ throw new Error('取得的檔案無效,請重新上傳後再預覽')
450
+ }
451
+ if (payload.id) await putSourceFileBlob(payload.id, blob, payload.name)
452
+ return blob
453
+ }
454
+
455
+ const fileId = String(payload.id || '').trim()
456
+ if (fileId) {
457
+ const cached = await getSourceFileBlob(fileId)
458
+ if (cached && cached.size > 0) {
459
+ if (await isDemoSampleBlob(cached)) {
460
+ await deleteSourceFileBlob(fileId)
461
+ } else {
462
+ return cached
463
+ }
464
+ }
465
+ }
466
+
467
+ if (!fileId) {
468
+ throw new Error('缺少檔案標識,無法預覽')
469
+ }
470
+
471
+ return downloadFromApi(fileId, payload.name, signal)
472
+ }
473
+
474
+ /** 與資訊源面板同一預覽頁:緩存 blob → FilePreviewPage */
475
+ export async function openSourceFilePreview(file: FilePreviewPayload) {
476
+ const name = String(file.name || '').trim() || '未命名檔案'
477
+ if (!isPreviewableFileName(name)) {
478
+ throw new Error('暫僅支持預覽 PDF / DOCX / DOC / 圖片')
479
+ }
480
+ const id = String(file.id || '').trim()
481
+ let blob = id ? await getSourceFileBlob(id) : null
482
+ if (blob && (await isDemoSampleBlob(blob))) {
483
+ if (id) await deleteSourceFileBlob(id)
484
+ blob = null
485
+ }
486
+ if (!blob || blob.size <= 0) {
487
+ blob = await fetchPreviewFileBlob({
488
+ id,
489
+ name,
490
+ url: file.url,
491
+ })
492
+ }
493
+ if (!blob || blob.size <= 0 || (await isDemoSampleBlob(blob))) {
494
+ throw new Error('無法取得真實檔案內容')
495
+ }
496
+ await openFilePreviewInNewTab({
497
+ id,
498
+ name,
499
+ url: file.url,
500
+ blob,
501
+ })
502
+ }
503
+
504
+ export async function blobToArrayBuffer(blob: Blob) {
505
+ return blob.arrayBuffer()
506
+ }
507
+
508
+ /** DOC / DOCX → HTML(mammoth 盡力;舊 .doc 可能失敗) */
509
+ export async function convertOfficeBlobToHtml(blob: Blob): Promise<{
510
+ html: string
511
+ messages: string[]
512
+ }> {
513
+ const arrayBuffer = await blob.arrayBuffer()
514
+ const result = await mammoth.convertToHtml({ arrayBuffer })
515
+ return {
516
+ html: String(result.value || ''),
517
+ messages: (result.messages || []).map((m) => String(m.message || '')),
518
+ }
519
+ }
520
+
521
+ export function countPlainTextChars(text: string) {
522
+ return Array.from(String(text || '').replace(/\s+/g, '')).length
523
+ }
524
+
525
+ export function stripHtmlToText(html: string) {
526
+ const doc = new DOMParser().parseFromString(html || '', 'text/html')
527
+ return doc.body?.textContent || ''
528
+ }
529
+
530
+ function pushOutlineItem(
531
+ roots: PreviewOutlineItem[],
532
+ stack: PreviewOutlineItem[],
533
+ item: PreviewOutlineItem
534
+ ) {
535
+ while (stack.length) {
536
+ const top = stack[stack.length - 1]
537
+ if (!top || top.level < item.level) break
538
+ stack.pop()
539
+ }
540
+ const parent = stack[stack.length - 1]
541
+ if (!parent) roots.push(item)
542
+ else {
543
+ if (!parent.children) parent.children = []
544
+ parent.children.push(item)
545
+ }
546
+ stack.push(item)
547
+ }
548
+
549
+ function normalizeOutlineTitle(text: string) {
550
+ return String(text || '')
551
+ .replace(/\s+/g, ' ')
552
+ .trim()
553
+ }
554
+
555
+ function liOwnTitle(li: HTMLElement) {
556
+ const clone = li.cloneNode(true) as HTMLElement
557
+ clone.querySelectorAll('ol, ul').forEach((node) => node.remove())
558
+ return normalizeOutlineTitle(clone.textContent || '')
559
+ }
560
+
561
+ function getPreviewBodyRoot(root: ParentNode): HTMLElement {
562
+ if (!(root instanceof HTMLElement)) return root as HTMLElement
563
+ const scoped =
564
+ root.querySelector('.file-preview-page__paper--html') ??
565
+ root.querySelector('.file-preview-page__paper--office') ??
566
+ root.querySelector('.docx-wrapper') ??
567
+ root.querySelector('article')
568
+ return scoped instanceof HTMLElement ? scoped : root
569
+ }
570
+
571
+ function resolveHeadingLevel(el: HTMLElement): number | null {
572
+ const tag = el.tagName.toLowerCase()
573
+ if (/^h[1-6]$/.test(tag)) return Number(tag.slice(1))
574
+ const cls = el.className?.toString?.() || ''
575
+ const m = cls.match(/(?:Heading|heading)[-_]?(\d)/i) || cls.match(/Heading(\d)/)
576
+ if (m?.[1]) return Math.min(6, Math.max(1, Number(m[1])))
577
+ return null
578
+ }
579
+
580
+ function isOutlineHeading(el: HTMLElement): boolean {
581
+ return resolveHeadingLevel(el) != null
582
+ }
583
+
584
+ function countOlAncestors(el: HTMLElement, boundary: HTMLElement): number {
585
+ let depth = 0
586
+ let node: HTMLElement | null = el.parentElement
587
+ while (node && boundary.contains(node)) {
588
+ if (node.tagName.toLowerCase() === 'ol') depth++
589
+ node = node.parentElement
590
+ }
591
+ return Math.max(1, depth)
592
+ }
593
+
594
+ type BodyOutlineCandidate = {
595
+ el: HTMLElement
596
+ level: number
597
+ title: string
598
+ }
599
+
600
+ /** 按正文 DOM 顺序采集:仅 h1–h6 / docx 标题 + ol>li */
601
+ function collectBodyOutlineCandidates(bodyRoot: HTMLElement): BodyOutlineCandidate[] {
602
+ const items: BodyOutlineCandidate[] = []
603
+ let lastHeadingLevel = 0
604
+
605
+ const walker = document.createTreeWalker(bodyRoot, NodeFilter.SHOW_ELEMENT, {
606
+ acceptNode(node) {
607
+ const el = node as HTMLElement
608
+ if (isOutlineHeading(el)) return NodeFilter.FILTER_ACCEPT
609
+ if (el.tagName.toLowerCase() === 'li' && el.parentElement?.tagName.toLowerCase() === 'ol') {
610
+ return NodeFilter.FILTER_ACCEPT
611
+ }
612
+ return NodeFilter.FILTER_SKIP
613
+ },
614
+ })
615
+
616
+ let node = walker.nextNode() as HTMLElement | null
617
+ while (node) {
618
+ if (isOutlineHeading(node)) {
619
+ const level = resolveHeadingLevel(node)!
620
+ const title = normalizeOutlineTitle(node.textContent || '')
621
+ if (title) {
622
+ items.push({ el: node, level, title })
623
+ lastHeadingLevel = level
624
+ }
625
+ } else if (node.tagName.toLowerCase() === 'li') {
626
+ const olDepth = countOlAncestors(node, bodyRoot)
627
+ const level = lastHeadingLevel > 0 ? Math.min(6, lastHeadingLevel + olDepth) : olDepth
628
+ const title = liOwnTitle(node)
629
+ if (title) items.push({ el: node, level, title })
630
+ }
631
+ node = walker.nextNode() as HTMLElement | null
632
+ }
633
+
634
+ return items
635
+ }
636
+
637
+ function stampOutlineAnchor(el: HTMLElement, id: string, stamp: boolean) {
638
+ if (stamp) {
639
+ el.id = id
640
+ el.setAttribute('data-outline-id', id)
641
+ el.style.scrollMarginTop = '12px'
642
+ } else {
643
+ el.setAttribute('id', id)
644
+ el.setAttribute('data-outline-id', id)
645
+ }
646
+ }
647
+
648
+ /**
649
+ * 左側大綱:仅以正文为准
650
+ * - 标题:h1–h6 / docx Heading
651
+ * - 有序列表:ol > li(按嵌套深度与前置标题排层级)
652
+ * 正文无二者则返回空列表
653
+ */
654
+ export function buildOutlineFromOrderedLists(
655
+ root: ParentNode,
656
+ options?: { stamp?: boolean }
657
+ ): PreviewOutlineItem[] {
658
+ const stamp = options?.stamp !== false
659
+ const bodyRoot = getPreviewBodyRoot(root)
660
+ const candidates = collectBodyOutlineCandidates(bodyRoot)
661
+ if (!candidates.length) return []
662
+
663
+ const roots: PreviewOutlineItem[] = []
664
+ const stack: PreviewOutlineItem[] = []
665
+ let index = 0
666
+
667
+ for (const { el, level, title } of candidates) {
668
+ const id = `outline-${index++}`
669
+ stampOutlineAnchor(el, id, stamp)
670
+ pushOutlineItem(roots, stack, { id, level, title, children: [] })
671
+ }
672
+
673
+ return roots
674
+ }
675
+
676
+ /** mammoth HTML:给正文标题 / 有序列表打锚点并返回大纲 */
677
+ export function decorateHtmlWithOutline(html: string): {
678
+ html: string
679
+ outline: PreviewOutlineItem[]
680
+ } {
681
+ const doc = new DOMParser().parseFromString(html || '', 'text/html')
682
+ const outline = buildOutlineFromOrderedLists(doc.body, { stamp: true })
683
+ return { html: doc.body.innerHTML, outline }
684
+ }
685
+
686
+ export function downloadBlobAsFile(blob: Blob, fileName: string) {
687
+ const url = URL.createObjectURL(blob)
688
+ const a = document.createElement('a')
689
+ a.href = url
690
+ a.download = fileName || 'download'
691
+ a.rel = 'noopener'
692
+ document.body.appendChild(a)
693
+ a.click()
694
+ a.remove()
695
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000)
696
+ }
697
+
698
+ export function printPreviewElement(el: HTMLElement | null) {
699
+ if (!el) {
700
+ window.print()
701
+ return
702
+ }
703
+ const win = window.open('', '_blank')
704
+ if (!win) {
705
+ window.print()
706
+ return
707
+ }
708
+ win.document.write(`<!doctype html><html><head><title>列印</title>
709
+ <style>
710
+ body{margin:0;font-family:Noto Sans,Noto Sans TC,sans-serif;}
711
+ img{max-width:100%;}
712
+ @page{margin:12mm;}
713
+ </style>
714
+ </head><body>${el.innerHTML}</body></html>`)
715
+ win.document.close()
716
+ win.focus()
717
+ win.print()
718
+ }
719
+
720
+ export function clampZoomPercent(value: number) {
721
+ return Math.min(200, Math.max(50, Math.round(value)))
722
+ }