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.
- package/gen-ai/build-report/chartStreamRefs.ts +227 -0
- package/gen-ai/build-report/coherence-check/index.vue +559 -0
- package/gen-ai/build-report/components/ReportChapterWorkspace.vue +4571 -0
- package/gen-ai/build-report/components/ReportConfigPanel.vue +727 -0
- package/gen-ai/build-report/components/ReportGenerateSettingsPanel.vue +2361 -0
- package/gen-ai/build-report/components/ReportGeneratingPanel.vue +1765 -0
- package/gen-ai/build-report/components/ReportPromptPanel.vue +625 -0
- package/gen-ai/build-report/components/ReportSourceFileSummary.vue +665 -0
- package/gen-ai/build-report/components/ReportSourcesPanel.vue +1327 -0
- package/gen-ai/build-report/components/ReportTemplateDetail.vue +191 -0
- package/gen-ai/build-report/components/ReportTemplateEditorFormPanel.vue +667 -0
- package/gen-ai/build-report/components/ReportTemplatePicker.vue +302 -0
- package/gen-ai/build-report/components/ReportWorkflowSidebar.vue +148 -0
- package/gen-ai/build-report/components/paramsSetting.vue +886 -0
- package/gen-ai/build-report/components/reportEdit.vue +2283 -0
- package/gen-ai/build-report/index.vue +856 -0
- package/gen-ai/build-report/report-domain.ts +1133 -0
- package/gen-ai/build-report/report-template-data.ts +295 -0
- package/gen-ai/build-report/useReportWorkflow.ts +1386 -0
- package/gen-ai/common/assets/NotoSans-SC.woff2 +0 -0
- package/gen-ai/common/assets/NotoSans-TC.woff2 +0 -0
- package/gen-ai/common/assets/NotoSans.woff2 +0 -0
- package/gen-ai/common/assets/fonts.css +278 -0
- package/gen-ai/common/assets/pagination.css +163 -0
- package/gen-ai/common/directives/btnLoading.ts +455 -0
- package/gen-ai/common/directives/debounce.ts +98 -0
- package/gen-ai/common/directives/index.ts +30 -0
- package/gen-ai/common/en.md +417 -0
- package/gen-ai/common/index.ts +36 -0
- package/gen-ai/common/types/component-expose.ts +9 -0
- package/gen-ai/common/utils/api-res-status.ts +7 -0
- package/gen-ai/common/utils/navigation.ts +59 -0
- package/gen-ai/common/utils/remote-request.ts +45 -0
- package/gen-ai/common/utils/scroll.ts +577 -0
- package/gen-ai/components/AIExport/docx-export.ts +950 -0
- package/gen-ai/components/AIExport/docxExport.ts +2566 -0
- package/gen-ai/components/AIExport/index.ts +52 -0
- package/gen-ai/components/AIExport/parseMarkdownHtml.ts +653 -0
- package/gen-ai/components/CapsuleScrollbar.vue +149 -0
- package/gen-ai/components/ChapterTitleScroll.vue +468 -0
- package/gen-ai/components/EntryHeroNavCards.vue +379 -0
- package/gen-ai/components/GenAiPagination.vue +52 -0
- package/gen-ai/components/ModelCard.vue +1114 -0
- package/gen-ai/components/Preview/FilePreviewPage.vue +949 -0
- package/gen-ai/components/Preview/PreviewCapsuleScroll.vue +205 -0
- package/gen-ai/components/Preview/PreviewOutlineTree.vue +148 -0
- package/gen-ai/components/Preview/index.ts +11 -0
- package/gen-ai/components/Preview/preview.ts +724 -0
- package/gen-ai/components/PromptField.vue +505 -0
- package/gen-ai/components/RecentReportsTable.vue +974 -0
- package/gen-ai/components/ReportCollapsibleSection.vue +172 -0
- package/gen-ai/components/ReportHistoryList.vue +637 -0
- package/gen-ai/components/ReportSectionConfirmPopover.vue +251 -0
- package/gen-ai/components/editor/ChapterTiptapEditor.vue +3718 -0
- package/gen-ai/components/editor/StructureBlockView.vue +441 -0
- package/gen-ai/components/editor/TiptapToolbar.vue +1036 -0
- package/gen-ai/components/editor/TypewriterDocEditor.vue +422 -0
- package/gen-ai/components/editor/_StructureOrgNode.vue +326 -0
- package/gen-ai/components/editor/index.ts +28 -0
- package/gen-ai/components/editor/parseMarkdownHtml.ts +754 -0
- package/gen-ai/components/editor/reviewCitationTip.ts +163 -0
- package/gen-ai/gen-entry/api/index.ts +32 -0
- package/gen-ai/gen-entry/index.vue +287 -0
- package/gen-ai/gen-entry/types/index.ts +4 -0
- package/gen-ai/i.mark +2 -0
- package/gen-ai/management-center/index.vue +1815 -0
- package/gen-ai/management-center/mockData.ts +248 -0
- package/gen-ai/management-center/types/index.ts +37 -0
- package/gen-ai/model-manager/TemplateEditorFormPanel.vue +661 -0
- package/gen-ai/model-manager/api/index.ts +32 -0
- package/gen-ai/model-manager/api/mockApi.ts +33 -0
- package/gen-ai/model-manager/index.vue +1583 -0
- package/gen-ai/model-manager/mockData.ts +177 -0
- package/gen-ai/model-manager/template-detail-shared.ts +154 -0
- package/gen-ai/model-manager/template-editor.vue +345 -0
- package/gen-ai/model-manager/types/index.ts +40 -0
- package/gen-ai/model-manager/utils.ts +25 -0
- 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
|
+
}
|