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,1133 @@
1
+ /**
2
+ * build-report 輔助模塊(業務域 + 路由/滾動基礎設施)
3
+ *
4
+ * 1. 章節標題(接口原文)
5
+ * 2. 報告連貫性檢查 / 定稿記錄(session;無本地假定稿)
6
+ * 3. 報告歷史預覽與章節佈局
7
+ * 4. 報告歷史時間展示
8
+ * 5. 申請創建模板詳情
9
+ * 6. 報告編輯路由同步
10
+ * 7. 膠囊滾動條與視口高度
11
+ */
12
+
13
+ import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
14
+ import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
15
+ import { resolveReportTemplateById, type ReportTemplateCard } from './report-template-data'
16
+ import type {
17
+ PromptChapterForm,
18
+ PromptRightPanel,
19
+ ReportModelStep,
20
+ } from './useReportWorkflow'
21
+
22
+ // =============================================================================
23
+ // 1. 章節標題(以接口原文為準,不做本地 i18n 詞典映射)
24
+ // =============================================================================
25
+
26
+ export type ChapterContentLanguage = 'zh-Hant' | 'zh-Hans'
27
+
28
+ export const CHAPTER_CONTENT_LANGUAGE_OPTIONS = [
29
+ { value: 'zh-Hans', label: '簡體中文' },
30
+ { value: 'zh-Hant', label: '繁體中文' },
31
+ ] as const
32
+
33
+ export function stripChapterNumberPrefix(title: string) {
34
+ return title.trim().replace(/^\d+\.\s*/, '')
35
+ }
36
+
37
+ export function shouldShowChapterLanguageSelect(reportLanguage: string) {
38
+ return reportLanguage === 'zh-Hant' || reportLanguage === 'zh-Hans'
39
+ }
40
+
41
+ export function resolveEffectiveChapterLocale(
42
+ _reportLanguage: string,
43
+ contentLanguage: ChapterContentLanguage
44
+ ): 'zh-Hant' | 'zh-Hans' {
45
+ return contentLanguage
46
+ }
47
+
48
+ /** 直接返回接口/來源標題(去序號前綴),不再查本地 mock 詞典 */
49
+ export function translateChapterLabel(
50
+ label: string,
51
+ _reportLanguage?: string,
52
+ _contentLanguage?: ChapterContentLanguage
53
+ ) {
54
+ return stripChapterNumberPrefix(label)
55
+ }
56
+
57
+ export function translateChapterTitle(
58
+ title: string,
59
+ _reportLanguage?: string,
60
+ _contentLanguage?: ChapterContentLanguage
61
+ ) {
62
+ return title.trim()
63
+ }
64
+
65
+ export function translateChapterTitles(
66
+ chapters: string[],
67
+ _reportLanguage?: string,
68
+ _contentLanguage?: ChapterContentLanguage
69
+ ) {
70
+ return chapters.map((title, index) => {
71
+ const trimmed = title.trim()
72
+ if (/^\d+\./.test(trimmed)) return trimmed
73
+ return `${index + 1}. ${stripChapterNumberPrefix(trimmed)}`
74
+ })
75
+ }
76
+
77
+ export function translateUnavailableVersionLabel(
78
+ _reportLanguage?: string,
79
+ _contentLanguage?: ChapterContentLanguage
80
+ ) {
81
+ return '無可用版本'
82
+ }
83
+
84
+ // =============================================================================
85
+ // 2. 報告連貫性檢查 / 定稿記錄(report-coherence-check)
86
+ // =============================================================================
87
+
88
+ export interface ReportChapterSelectionItem {
89
+ label: string
90
+ version: string
91
+ generator?: string
92
+ }
93
+
94
+ export interface ReportGenerationSignature {
95
+ templateId: string
96
+ chapters: ReportChapterSelectionItem[]
97
+ reportLanguage: string
98
+ chapterContentLanguage: ChapterContentLanguage
99
+ }
100
+
101
+ export interface FinalizedReportRecord {
102
+ id: string
103
+ displayTitle: string
104
+ templateId: string
105
+ chapters: ReportChapterSelectionItem[]
106
+ reportLanguage: string
107
+ chapterContentLanguage: ChapterContentLanguage
108
+ sourceFileIds: string[]
109
+ finalizedAt: string
110
+ }
111
+
112
+ export interface CoherenceCheckSessionPayload {
113
+ templateId: string
114
+ reportName: string
115
+ year: string
116
+ chapters: ReportChapterSelectionItem[]
117
+ chapterContentLanguage: ChapterContentLanguage
118
+ reportLanguage: string
119
+ /** 接口:實例 / 版本(可選,深鏈時可能沒有) */
120
+ instanceId?: string
121
+ versionIdList?: string[]
122
+ /** 接口返回的連貫性檢查正文 */
123
+ coherenceCheckContentText?: string
124
+ beenFinalizedFlag?: string
125
+ }
126
+
127
+ export const COHERENCE_CHECK_SESSION_KEY = 'gen-ai-report-coherence-check-payload'
128
+ const COHERENCE_CHECKED_SIGNATURES_KEY = 'gen-ai-coherence-checked-signatures'
129
+ const GENERATED_REPORTS_SESSION_KEY = 'gen-ai-generated-report-records'
130
+
131
+ const REPORT_LANGUAGE_LABELS: Record<string, string> = {
132
+ 'zh-Hant': '繁體中文',
133
+ 'zh-Hans': '簡體中文',
134
+ en: 'English',
135
+ }
136
+
137
+ function loadGeneratedReportsFromSession(): FinalizedReportRecord[] {
138
+ const raw = sessionStorage.getItem(GENERATED_REPORTS_SESSION_KEY)
139
+ if (!raw) return []
140
+ try {
141
+ return JSON.parse(raw) as FinalizedReportRecord[]
142
+ } catch {
143
+ return []
144
+ }
145
+ }
146
+
147
+ function saveGeneratedReportsToSession(records: FinalizedReportRecord[]) {
148
+ sessionStorage.setItem(GENERATED_REPORTS_SESSION_KEY, JSON.stringify(records))
149
+ }
150
+
151
+ function loadAllFinalizedReports(): FinalizedReportRecord[] {
152
+ // 僅 session 中用戶生成的記錄;不再混入本地假定稿
153
+ return loadGeneratedReportsFromSession()
154
+ }
155
+
156
+ export function serializeGenerationSignature(signature: ReportGenerationSignature) {
157
+ return JSON.stringify({
158
+ templateId: signature.templateId,
159
+ chapters: signature.chapters,
160
+ reportLanguage: signature.reportLanguage,
161
+ chapterContentLanguage: signature.chapterContentLanguage,
162
+ })
163
+ }
164
+
165
+ export function buildGenerationSignature(
166
+ templateId: string,
167
+ chapters: ReportChapterSelectionItem[],
168
+ reportLanguage: string,
169
+ chapterContentLanguage: ChapterContentLanguage
170
+ ): ReportGenerationSignature {
171
+ return {
172
+ templateId,
173
+ chapters,
174
+ reportLanguage,
175
+ chapterContentLanguage,
176
+ }
177
+ }
178
+
179
+ export function findFinalizedReportById(recordId: string): FinalizedReportRecord | null {
180
+ return loadAllFinalizedReports().find((record) => record.id === recordId) ?? null
181
+ }
182
+
183
+ export function findFinalizedReportBySignature(
184
+ signature: ReportGenerationSignature
185
+ ): FinalizedReportRecord | null {
186
+ const target = serializeGenerationSignature(signature)
187
+ return (
188
+ loadAllFinalizedReports().find((record) => serializeGenerationSignature(record) === target) ??
189
+ null
190
+ )
191
+ }
192
+
193
+ /** @deprecated use findFinalizedReportBySignature */
194
+ export function findFinalizedReportBySelection(
195
+ templateId: string,
196
+ selection: ReportChapterSelectionItem[]
197
+ ) {
198
+ return findFinalizedReportBySignature(
199
+ buildGenerationSignature(templateId, selection, 'zh-Hant', 'zh-Hant')
200
+ )
201
+ }
202
+
203
+ function loadCoherenceCheckedSignatures(): string[] {
204
+ const raw = sessionStorage.getItem(COHERENCE_CHECKED_SIGNATURES_KEY)
205
+ if (!raw) return []
206
+ try {
207
+ return JSON.parse(raw) as string[]
208
+ } catch {
209
+ return []
210
+ }
211
+ }
212
+
213
+ function saveCoherenceCheckedSignatures(signatures: string[]) {
214
+ sessionStorage.setItem(COHERENCE_CHECKED_SIGNATURES_KEY, JSON.stringify(signatures))
215
+ }
216
+
217
+ export function isCoherenceCheckCompleted(signature: ReportGenerationSignature) {
218
+ const key = serializeGenerationSignature(signature)
219
+ return loadCoherenceCheckedSignatures().includes(key)
220
+ }
221
+
222
+ export function markCoherenceCheckCompleted(signature: ReportGenerationSignature) {
223
+ const key = serializeGenerationSignature(signature)
224
+ const stored = loadCoherenceCheckedSignatures()
225
+ if (stored.includes(key)) return
226
+ saveCoherenceCheckedSignatures([...stored, key])
227
+ }
228
+
229
+ export function getReportLanguageLabel(reportLanguage: string) {
230
+ return REPORT_LANGUAGE_LABELS[reportLanguage] ?? '繁體中文'
231
+ }
232
+
233
+ function getNextReportVersionLabel(templateId: string) {
234
+ const count = loadAllFinalizedReports().filter(
235
+ (record) => record.templateId === templateId
236
+ ).length
237
+ return `v${count + 1}`
238
+ }
239
+
240
+ export function buildGeneratedReportDisplayTitle(
241
+ reportName: string,
242
+ year: string,
243
+ reportLanguage: string,
244
+ templateId: string
245
+ ) {
246
+ const name = reportName.trim() || '未命名報告'
247
+ const versionLabel = getNextReportVersionLabel(templateId)
248
+ const languageLabel = getReportLanguageLabel(reportLanguage)
249
+ return `${name} ${year} ${versionLabel} ${languageLabel}`
250
+ }
251
+
252
+ export function registerGeneratedReport(
253
+ signature: ReportGenerationSignature,
254
+ reportName: string,
255
+ year: string,
256
+ sourceFileIds: string[]
257
+ ): FinalizedReportRecord {
258
+ const displayTitle = buildGeneratedReportDisplayTitle(
259
+ reportName,
260
+ year,
261
+ signature.reportLanguage,
262
+ signature.templateId
263
+ )
264
+ const record: FinalizedReportRecord = {
265
+ id: `generated-${Date.now()}`,
266
+ displayTitle,
267
+ sourceFileIds,
268
+ finalizedAt: new Date().toISOString(),
269
+ ...signature,
270
+ }
271
+ const nextRecords = [record, ...loadGeneratedReportsFromSession()]
272
+ saveGeneratedReportsToSession(nextRecords)
273
+ return record
274
+ }
275
+
276
+ export function saveCoherenceCheckSession(payload: CoherenceCheckSessionPayload) {
277
+ sessionStorage.setItem(COHERENCE_CHECK_SESSION_KEY, JSON.stringify(payload))
278
+ }
279
+
280
+ export function loadCoherenceCheckSession(): CoherenceCheckSessionPayload | null {
281
+ const raw = sessionStorage.getItem(COHERENCE_CHECK_SESSION_KEY)
282
+ if (!raw) return null
283
+ try {
284
+ const parsed = JSON.parse(raw) as CoherenceCheckSessionPayload & { reportLanguage?: string }
285
+ return { ...parsed, reportLanguage: parsed.reportLanguage ?? 'zh-Hant' }
286
+ } catch {
287
+ return null
288
+ }
289
+ }
290
+
291
+ /** 深鏈 / 無 session 時:僅按 templateId 從已解析模板組裝章節列表(無本地假定稿正文) */
292
+ export function buildCoherenceCheckPayloadFromTemplateId(
293
+ templateId: string
294
+ ): CoherenceCheckSessionPayload | null {
295
+ const id = templateId.trim()
296
+ if (!id) return null
297
+
298
+ const template = resolveReportTemplateById(id)
299
+ const chapters: ReportChapterSelectionItem[] = (template?.chapters ?? []).map((label) => ({
300
+ label: stripChapterNumberPrefix(label),
301
+ version: '',
302
+ }))
303
+
304
+ if (!chapters.length) return null
305
+
306
+ return {
307
+ templateId: id,
308
+ reportName: template?.name ?? '未命名報告',
309
+ year: String(new Date().getFullYear()),
310
+ chapters,
311
+ chapterContentLanguage: 'zh-Hant',
312
+ reportLanguage: 'zh-Hant',
313
+ }
314
+ }
315
+
316
+ export function openReportCoherenceCheck(router: Router, payload: CoherenceCheckSessionPayload) {
317
+ saveCoherenceCheckSession(payload)
318
+ markCoherenceCheckCompleted(
319
+ buildGenerationSignature(
320
+ payload.templateId,
321
+ payload.chapters,
322
+ payload.reportLanguage,
323
+ payload.chapterContentLanguage
324
+ )
325
+ )
326
+ openCoherenceCheckResultPage(router, payload.templateId)
327
+ }
328
+
329
+ export function openReportCoherenceCheckResult(
330
+ router: Router,
331
+ payload: CoherenceCheckSessionPayload
332
+ ) {
333
+ saveCoherenceCheckSession(payload)
334
+ openCoherenceCheckResultPage(router, payload.templateId)
335
+ }
336
+
337
+ function openCoherenceCheckResultPage(router: Router, templateId: string) {
338
+ void router.push({
339
+ name: 'report-coherence-check' as const,
340
+ query: { templateId },
341
+ })
342
+ }
343
+
344
+ // =============================================================================
345
+ // 3. 報告歷史預覽與章節佈局(report-history-preview)
346
+ // =============================================================================
347
+
348
+ export function buildReportHistoryChapterBodies(record: FinalizedReportRecord): string[] {
349
+ // 歷史正文以接口 / AdjustInstance 為準;本地定稿記錄無正文時返回空串
350
+ return record.chapters.map(() => '')
351
+ }
352
+
353
+ export function serializeSelectedChapterLabels(chapters: ReportChapterSelectionItem[]) {
354
+ return JSON.stringify(chapters.map((chapter) => chapter.label))
355
+ }
356
+
357
+ export function buildSelectedChapterLabels(
358
+ order: string[],
359
+ selected: Record<string, boolean>,
360
+ presets: Array<{ key: string; label: string }>,
361
+ isUnavailable: (label: string) => boolean
362
+ ) {
363
+ return order
364
+ .filter((key) => {
365
+ const preset = presets.find((item) => item.key === key)
366
+ return preset && selected[key] && !isUnavailable(preset.label)
367
+ })
368
+ .map((key) => presets.find((item) => item.key === key)!.label)
369
+ }
370
+
371
+ export function serializeChapterLayoutFromState(
372
+ order: string[],
373
+ selected: Record<string, boolean>,
374
+ presets: Array<{ key: string; label: string }>,
375
+ isUnavailable: (label: string) => boolean
376
+ ) {
377
+ return JSON.stringify(buildSelectedChapterLabels(order, selected, presets, isUnavailable))
378
+ }
379
+
380
+ export function buildChapterOrderForHistoryRecord(
381
+ templateChapterLabels: string[],
382
+ recordChapters: ReportChapterSelectionItem[],
383
+ isUnavailable: (label: string) => boolean
384
+ ) {
385
+ const presets = templateChapterLabels.map((title, index) => ({
386
+ key: `chapter-${index}`,
387
+ label: stripChapterNumberPrefix(title),
388
+ }))
389
+
390
+ const findPresetKey = (chapterLabel: string) =>
391
+ presets.find((preset) => preset.label === stripChapterNumberPrefix(chapterLabel))?.key
392
+ const findChapter = (presetLabel: string) =>
393
+ recordChapters.find(
394
+ (chapter) => stripChapterNumberPrefix(chapter.label) === stripChapterNumberPrefix(presetLabel)
395
+ )
396
+
397
+ const selectedKeys = recordChapters
398
+ .map((chapter) => findPresetKey(chapter.label))
399
+ .filter((key): key is string => !!key)
400
+
401
+ const remainingKeys = presets
402
+ .filter((preset) => !selectedKeys.includes(preset.key))
403
+ .map((preset) => preset.key)
404
+
405
+ return {
406
+ presets,
407
+ chapterOrder: [...selectedKeys, ...remainingKeys],
408
+ selectedChapters: Object.fromEntries(
409
+ presets.map((preset) => [
410
+ preset.key,
411
+ !!findChapter(preset.label) && !isUnavailable(preset.label),
412
+ ])
413
+ ),
414
+ chapterVersions: Object.fromEntries(
415
+ presets.map((preset) => {
416
+ const matched = findChapter(preset.label)
417
+ return [preset.key, matched?.version ?? 'V3']
418
+ })
419
+ ),
420
+ }
421
+ }
422
+
423
+ // =============================================================================
424
+ // 4. Prompt 表單工具
425
+ // =============================================================================
426
+
427
+ export function clonePromptChapterForm(form: PromptChapterForm): PromptChapterForm {
428
+ return { ...form }
429
+ }
430
+
431
+ // =============================================================================
432
+ // 5. 報告歷史時間展示(report-history-time)
433
+ // =============================================================================
434
+
435
+ const MINUTE_MS = 60 * 1000
436
+ const HOUR_MS = 60 * MINUTE_MS
437
+ const DAY_MS = 24 * HOUR_MS
438
+
439
+ function pad2(value: number) {
440
+ return String(value).padStart(2, '0')
441
+ }
442
+
443
+ export function formatAbsoluteDateTime(date: Date) {
444
+ const year = date.getFullYear()
445
+ const month = pad2(date.getMonth() + 1)
446
+ const day = pad2(date.getDate())
447
+ const hours = pad2(date.getHours())
448
+ const minutes = pad2(date.getMinutes())
449
+ const seconds = pad2(date.getSeconds())
450
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
451
+ }
452
+
453
+ /**
454
+ * 報告定稿時間展示:
455
+ * - 距當前 < 60 分鐘:X min 前
456
+ * - 距當前 < 24 小時:X h 前
457
+ * - 距當前 >= 24 小時:yyyy-mm-dd HH:MM:SS
458
+ */
459
+ export function formatReportHistoryTime(
460
+ finalizedAt: string | number | Date,
461
+ now: Date | number = Date.now()
462
+ ) {
463
+ const finalized = finalizedAt instanceof Date ? finalizedAt : new Date(finalizedAt)
464
+ const nowMs = typeof now === 'number' ? now : now.getTime()
465
+ const finalizedMs = finalized.getTime()
466
+
467
+ if (Number.isNaN(finalizedMs)) return ''
468
+
469
+ const diffMs = Math.max(0, nowMs - finalizedMs)
470
+
471
+ if (diffMs < MINUTE_MS) {
472
+ return '1 min 前'
473
+ }
474
+
475
+ if (diffMs < HOUR_MS) {
476
+ const minutes = Math.floor(diffMs / MINUTE_MS)
477
+ return `${minutes} min 前`
478
+ }
479
+
480
+ if (diffMs < DAY_MS) {
481
+ const hours = Math.floor(diffMs / HOUR_MS)
482
+ return `${hours} h 前`
483
+ }
484
+
485
+ return formatAbsoluteDateTime(finalized)
486
+ }
487
+
488
+ export interface CoherenceCheckResultSection {
489
+ title: string
490
+ paragraphs: string[]
491
+ }
492
+
493
+ /** 將接口連貫性檢查正文拆成可展示段落(無 mock 文案) */
494
+ export function parseCoherenceCheckContentText(contentText: string): CoherenceCheckResultSection[] {
495
+ const text = String(contentText ?? '').trim()
496
+ if (!text) return []
497
+
498
+ // 若接口已按段落分隔,保留為單節多段;否則整段展示
499
+ const paragraphs = text
500
+ .split(/\n{2,}/)
501
+ .map((p) => p.trim())
502
+ .filter(Boolean)
503
+ if (!paragraphs.length) return []
504
+
505
+ return [
506
+ {
507
+ title: '連貫性檢查結果',
508
+ paragraphs,
509
+ },
510
+ ]
511
+ }
512
+
513
+ // =============================================================================
514
+ // 6. 申請創建模板詳情(apply-template-detail)
515
+ // =============================================================================
516
+
517
+ export interface ApplyTemplateApprovalRecord {
518
+ id: string
519
+ operator: string
520
+ status: string
521
+ completedAt: string
522
+ detail: string
523
+ }
524
+
525
+ export interface ApplyTemplateChapter {
526
+ id: string
527
+ title: string
528
+ description: string
529
+ prompt: string
530
+ }
531
+
532
+ export interface ApplyTemplateDetail {
533
+ sourceReport: string
534
+ language: string
535
+ title: string
536
+ description: string
537
+ chapters: ApplyTemplateChapter[]
538
+ approvalRecords: ApplyTemplateApprovalRecord[]
539
+ }
540
+
541
+ export type ApplyTemplateProcessAction = 'reject' | 'approve'
542
+
543
+ export interface ApplyTemplateProcessPayload {
544
+ action: ApplyTemplateProcessAction
545
+ title: string
546
+ description: string
547
+ opinion: string
548
+ }
549
+
550
+ export function getApplyTemplateLanguageDisplay(language: string) {
551
+ return language === 'en' ? 'English[原模板所選語言]' : '中文[原模板所選語言]'
552
+ }
553
+
554
+ export function getApplyTemplateDisplayChapters(detail: ApplyTemplateDetail | null) {
555
+ if (!detail) return []
556
+ return detail.chapters.filter(
557
+ (chapter, index) => index < 2 || Boolean(chapter.prompt.trim() || chapter.description.trim())
558
+ )
559
+ }
560
+
561
+ export function buildApplyTemplateDetailFromReport(params: {
562
+ sourceReport: string
563
+ language: string
564
+ title: string
565
+ description: string
566
+ chapterTitles: string[]
567
+ chapterPrompt?: string
568
+ approvalRecords?: ApplyTemplateApprovalRecord[]
569
+ }): ApplyTemplateDetail {
570
+ const chapters: ApplyTemplateChapter[] = params.chapterTitles.map((rawTitle, index) => {
571
+ const title = stripChapterNumberPrefix(rawTitle)
572
+ return {
573
+ id: `report-ch-${index}`,
574
+ title,
575
+ // 僅帶入當前報告已有數據;無則空(不填本地假描述 / Prompt)
576
+ description: '',
577
+ prompt:
578
+ index === 0 && params.chapterPrompt?.trim() ? params.chapterPrompt.trim() : '',
579
+ }
580
+ })
581
+
582
+ return {
583
+ sourceReport: params.sourceReport,
584
+ language: params.language,
585
+ title: params.title,
586
+ description: params.description,
587
+ chapters,
588
+ approvalRecords: params.approvalRecords ?? [],
589
+ }
590
+ }
591
+
592
+ // =============================================================================
593
+ // 8. 報告編輯路由同步(report-edit-route)
594
+ // =============================================================================
595
+
596
+ export const BUILD_REPORT_PATH = '/gen-ai/build-report'
597
+ export const REPORT_EDIT_PAGE_NAME = 'reportEdit'
598
+
599
+ const VALID_STEPS = new Set<ReportModelStep>(['select', 'detail', 'generating', 'prompt'])
600
+
601
+ export function isReportEditRoute(route: RouteLocationNormalizedLoaded) {
602
+ const pageName = route.query.pageName
603
+ if (pageName === REPORT_EDIT_PAGE_NAME) return true
604
+ if (Array.isArray(pageName) && pageName.includes(REPORT_EDIT_PAGE_NAME)) return true
605
+ // 遷移後可能拼接參數:路徑裡只要包含 reportEdit 即視為編輯流
606
+ return decodeURIComponent(route.fullPath).includes(REPORT_EDIT_PAGE_NAME)
607
+ }
608
+
609
+ /** 從路由字串解析步驟(支援 step 被拼接其他參數的情況) */
610
+ export function matchReportModelStep(haystack: string): ReportModelStep {
611
+ const text = decodeURIComponent(String(haystack || '')).toLowerCase()
612
+ // 優先級:generating > prompt > detail > select(避免短詞誤傷;禁止 includes('select') 誤匹配)
613
+ if (text.includes('generating')) return 'generating'
614
+ if (text.includes('generate-settings')) return 'prompt'
615
+ if (/(?:^|[/?&#_=.-])prompt(?:$|[/?&#_=.-])/.test(text) || text.includes('step=prompt')) {
616
+ return 'prompt'
617
+ }
618
+ if (text.includes('prompt')) return 'prompt'
619
+ if (/(?:^|[/?&#_=.-])detail(?:$|[/?&#_=.-])/.test(text) || text.includes('step=detail')) {
620
+ return 'detail'
621
+ }
622
+ if (text.includes('detail')) return 'detail'
623
+ if (/(?:^|[/?&#_=.-])select(?:$|[/?&#_=.-])/.test(text) || text.includes('step=select')) {
624
+ return 'select'
625
+ }
626
+ return 'select'
627
+ }
628
+
629
+ /** 清洗遷移後可能污染的 step 值(如 `generating&foo=1` / 雙編碼) */
630
+ export function sanitizeReportEditStepValue(raw: unknown): ReportModelStep | null {
631
+ if (raw == null) return null
632
+ const text = decodeURIComponent(String(Array.isArray(raw) ? raw[0] : raw)).trim()
633
+ if (!text) return null
634
+ if (text === 'generate-settings') return 'prompt'
635
+ if (VALID_STEPS.has(text as ReportModelStep)) return text as ReportModelStep
636
+ // 拼接污染:取首個可識別步驟關鍵字
637
+ return matchReportModelStep(text)
638
+ }
639
+
640
+ export function parseReportEditStep(route: RouteLocationNormalizedLoaded): ReportModelStep | null {
641
+ if (!isReportEditRoute(route)) return null
642
+
643
+ const fromQuery = sanitizeReportEditStepValue(route.query.step)
644
+ if (fromQuery) return fromQuery
645
+
646
+ // 遷移兼容:path 段 /generating|/prompt|/detail
647
+ const path = decodeURIComponent(route.path).toLowerCase()
648
+ if (path.includes('/generating')) return 'generating'
649
+ if (path.includes('/prompt')) return 'prompt'
650
+ if (path.includes('/detail')) return 'detail'
651
+
652
+ return matchReportModelStep(route.fullPath)
653
+ }
654
+
655
+ export function parsePromptRightPanel(route: RouteLocationNormalizedLoaded): PromptRightPanel {
656
+ const stepRaw = String(route.query.step ?? '')
657
+ if (
658
+ stepRaw === 'generate-settings' ||
659
+ route.query.rightPanel === 'settings' ||
660
+ String(route.query.rightPanel ?? '').includes('settings')
661
+ ) {
662
+ return 'settings'
663
+ }
664
+ return 'prompt'
665
+ }
666
+
667
+ export function buildReportEditQuery(
668
+ step: ReportModelStep,
669
+ templateId?: string,
670
+ rightPanel?: PromptRightPanel,
671
+ instanceId?: string,
672
+ preserveQuery?: Record<string, unknown>
673
+ ) {
674
+ const query: Record<string, string> = {}
675
+
676
+ // 遷移兼容:保留其它業務 query,但強制覆蓋 pageName / step
677
+ if (preserveQuery) {
678
+ for (const [key, value] of Object.entries(preserveQuery)) {
679
+ if (key === 'pageName' || key === 'step' || key === 'rightPanel') continue
680
+ if (value == null) continue
681
+ const text = Array.isArray(value) ? value.find((v) => v != null) : value
682
+ if (text == null || text === '') continue
683
+ query[key] = String(text)
684
+ }
685
+ }
686
+
687
+ query.pageName = REPORT_EDIT_PAGE_NAME
688
+ query.step = step
689
+ if (templateId) query.templateId = templateId
690
+ // settings:寫入 rightPanel;prompt:故意不帶該參數,用於清掉遷移/設置頁殘留
691
+ if (step === 'prompt' && rightPanel === 'settings') {
692
+ query.rightPanel = 'settings'
693
+ }
694
+ if (step === 'generating' && instanceId) query.instanceId = instanceId
695
+ return query
696
+ }
697
+
698
+ export function navigateToReportEdit(
699
+ router: Router,
700
+ step: ReportModelStep = 'select',
701
+ templateId?: string,
702
+ replace = false,
703
+ rightPanel?: PromptRightPanel,
704
+ instanceId?: string
705
+ ) {
706
+ const currentQuery = router.currentRoute.value.query as Record<string, unknown>
707
+ const location = {
708
+ path: BUILD_REPORT_PATH,
709
+ query: buildReportEditQuery(step, templateId, rightPanel, instanceId, currentQuery),
710
+ }
711
+ return replace ? router.replace(location) : router.push(location)
712
+ }
713
+
714
+ export function exitReportEdit(router: Router) {
715
+ return router.replace({ path: BUILD_REPORT_PATH })
716
+ }
717
+
718
+ interface ReportEditRouteSyncActions {
719
+ restoreReportEditSession: () => void
720
+ openReportEdit: (step: ReportModelStep) => void
721
+ closeReportEdit: () => void
722
+ setSelectedTemplate: (template: ReportTemplateCard | null) => void
723
+ loadTemplateFromSession: () => void
724
+ showPromptSettingsPanel: () => void
725
+ showPromptEditorPanel: () => void
726
+ /** 導航鎖定期間跳過用路由覆寫 modelStep */
727
+ isReportEditNavLocked?: () => boolean
728
+ }
729
+
730
+ export function createReportEditRouteSyncActions(
731
+ workflow: ReturnType<typeof import('./useReportWorkflow').useReportWorkflow>
732
+ ): ReportEditRouteSyncActions {
733
+ return {
734
+ restoreReportEditSession: workflow.restoreReportEditSession,
735
+ openReportEdit: workflow.openReportEdit,
736
+ closeReportEdit: workflow.closeReportEdit,
737
+ setSelectedTemplate: workflow.setSelectedTemplate,
738
+ loadTemplateFromSession: workflow.loadTemplateFromSession,
739
+ showPromptSettingsPanel: workflow.showPromptSettingsPanel,
740
+ showPromptEditorPanel: workflow.showPromptEditorPanel,
741
+ isReportEditNavLocked: () => workflow.isReportEditNavLocked(),
742
+ }
743
+ }
744
+
745
+ export function syncWorkflowFromReportEditRoute(
746
+ route: RouteLocationNormalizedLoaded,
747
+ actions: ReportEditRouteSyncActions
748
+ ) {
749
+ // 刷新後先從 session 恢復 draft / 模板 / 正文 / 資訊源等
750
+ actions.restoreReportEditSession()
751
+
752
+ // 主動跳轉鎖定期:只恢復 session,不讓舊/中間態路由覆寫 modelStep
753
+ if (actions.isReportEditNavLocked?.()) return
754
+
755
+ const step = parseReportEditStep(route)
756
+ if (!step) {
757
+ actions.closeReportEdit()
758
+ return
759
+ }
760
+
761
+ actions.openReportEdit(step)
762
+
763
+ const rightPanel = parsePromptRightPanel(route)
764
+ if (step === 'prompt' && rightPanel === 'settings') {
765
+ actions.showPromptSettingsPanel()
766
+ } else if (step === 'prompt') {
767
+ actions.showPromptEditorPanel()
768
+ }
769
+
770
+ const templateId = route.query.templateId
771
+ if (typeof templateId === 'string' && templateId) {
772
+ const found = resolveReportTemplateById(templateId)
773
+ if (found) {
774
+ actions.setSelectedTemplate(found)
775
+ return
776
+ }
777
+ }
778
+
779
+ if (step === 'detail' || step === 'generating' || step === 'prompt') {
780
+ actions.loadTemplateFromSession()
781
+ }
782
+ }
783
+
784
+ // =============================================================================
785
+ // 9. 膠囊滾動條與視口高度(scroll)
786
+ // =============================================================================
787
+
788
+ export const CAPSULE_SCROLL_CONTAINER_CLASS = 'capsule-scroll-container'
789
+
790
+ export interface CapsuleScrollbarTheme {
791
+ thumbColor?: string
792
+ thumbHoverColor?: string
793
+ thumbActiveColor?: string
794
+ trackColor?: string
795
+ thumbWidthPx?: number
796
+ classPrefix?: string
797
+ }
798
+
799
+ export interface CapsuleScrollbarOptions extends CapsuleScrollbarTheme {
800
+ scrollEl: { value: HTMLElement | null }
801
+ anchorEl?: { value: HTMLElement | null }
802
+ active: { value: boolean }
803
+ gutterPx?: number
804
+ thumbWidthPx?: number
805
+ minThumbPx?: number
806
+ visibleRatio?: number
807
+ alwaysVisible?: boolean
808
+ autoHideAfterMs?: number
809
+ rightOffsetPx?: number
810
+ }
811
+
812
+ export interface CapsuleScrollbarBindings {
813
+ canScroll: { value: boolean }
814
+ shown: { value: boolean }
815
+ visible: { value: boolean }
816
+ rootStyle: { value: { [key: string]: string } }
817
+ thumbStyle: { value: { [key: string]: string } }
818
+ rootClass: string
819
+ thumbClass: string
820
+ onThumbMouseDown: (event: MouseEvent) => void
821
+ refresh: () => void
822
+ bind: () => void
823
+ unbind: () => void
824
+ }
825
+
826
+ const DEFAULT_CLASS_PREFIX = 'capsule-scrollbar'
827
+
828
+ interface NativeHideSnapshot {
829
+ paddingRight: string
830
+ marginRight: string
831
+ width: string
832
+ boxSizing: string
833
+ scrollbarWidth: string
834
+ msOverflowStyle: string
835
+ }
836
+
837
+ export function markCapsuleScrollContainer(el: HTMLElement): () => void {
838
+ const computed = getComputedStyle(el)
839
+ const snapshot: NativeHideSnapshot = {
840
+ paddingRight: el.style.paddingRight,
841
+ marginRight: el.style.marginRight,
842
+ width: el.style.width,
843
+ boxSizing: el.style.boxSizing,
844
+ scrollbarWidth: el.style.scrollbarWidth,
845
+ msOverflowStyle:
846
+ (el.style as CSSStyleDeclaration & { msOverflowStyle?: string }).msOverflowStyle ?? '',
847
+ }
848
+
849
+ el.style.setProperty('--capsule-scroll-padding-right', computed.paddingRight)
850
+ el.classList.add(CAPSULE_SCROLL_CONTAINER_CLASS)
851
+ el.style.scrollbarWidth = 'none'
852
+ ;(el.style as CSSStyleDeclaration & { msOverflowStyle?: string }).msOverflowStyle = 'none'
853
+
854
+ return () => {
855
+ el.classList.remove(CAPSULE_SCROLL_CONTAINER_CLASS)
856
+ el.style.removeProperty('--capsule-scroll-padding-right')
857
+ el.style.paddingRight = snapshot.paddingRight
858
+ el.style.marginRight = snapshot.marginRight
859
+ el.style.width = snapshot.width
860
+ el.style.boxSizing = snapshot.boxSizing
861
+ el.style.scrollbarWidth = snapshot.scrollbarWidth
862
+ ;(el.style as CSSStyleDeclaration & { msOverflowStyle?: string }).msOverflowStyle =
863
+ snapshot.msOverflowStyle
864
+ }
865
+ }
866
+
867
+ export function useCapsuleScrollbar(options: CapsuleScrollbarOptions): CapsuleScrollbarBindings {
868
+ const thumbWidthPx = options.thumbWidthPx ?? 10
869
+ const gutterPx = options.gutterPx ?? thumbWidthPx
870
+ const minThumbPx = options.minThumbPx ?? 48
871
+ const visibleRatio = options.visibleRatio ?? 1
872
+ const rightOffsetPx = options.rightOffsetPx ?? 1
873
+ const autoHideAfterMs = options.autoHideAfterMs ?? 3000
874
+ const alwaysVisible = options.alwaysVisible ?? autoHideAfterMs <= 0
875
+ const classPrefix = options.classPrefix ?? DEFAULT_CLASS_PREFIX
876
+
877
+ const canScroll = ref(false)
878
+ const shown = ref(false)
879
+ const zoneTop = ref(0)
880
+ const zoneRight = ref(rightOffsetPx)
881
+ const zoneHeight = ref(0)
882
+ const thumbHeight = ref(minThumbPx)
883
+ const thumbOffset = ref(0)
884
+
885
+ const visible = computed(() => canScroll.value && (alwaysVisible || shown.value))
886
+
887
+ let resizeObserver: ResizeObserver | undefined
888
+ let thumbDragCleanup: (() => void) | undefined
889
+ let removeContainerMark: (() => void) | undefined
890
+ let hideTimer: number | undefined
891
+ let rafId = 0
892
+
893
+ const rootStyle = computed(() => ({
894
+ top: `${zoneTop.value + thumbOffset.value}px`,
895
+ right: `${zoneRight.value}px`,
896
+ width: `${gutterPx}px`,
897
+ height: `${Math.round(thumbHeight.value)}px`,
898
+ }))
899
+
900
+ const thumbStyle = computed(() => ({
901
+ width: `${thumbWidthPx}px`,
902
+ height: '100%',
903
+ }))
904
+
905
+ function clearHideTimer() {
906
+ if (hideTimer !== undefined) {
907
+ clearTimeout(hideTimer)
908
+ hideTimer = undefined
909
+ }
910
+ }
911
+
912
+ function scheduleHide() {
913
+ clearHideTimer()
914
+ if (alwaysVisible || autoHideAfterMs <= 0) return
915
+ hideTimer = window.setTimeout(() => {
916
+ shown.value = false
917
+ hideTimer = undefined
918
+ }, autoHideAfterMs)
919
+ }
920
+
921
+ function revealBar() {
922
+ if (!canScroll.value) return
923
+ shown.value = true
924
+ scheduleHide()
925
+ }
926
+
927
+ function refreshLayout() {
928
+ const scrollEl = options.scrollEl.value
929
+ const anchorEl = options.anchorEl?.value ?? scrollEl
930
+ if (!options.active.value || !scrollEl || !anchorEl) {
931
+ canScroll.value = false
932
+ shown.value = false
933
+ return
934
+ }
935
+
936
+ const { scrollTop, scrollHeight, clientHeight } = scrollEl
937
+ const scrollable = scrollHeight > clientHeight + 1
938
+ canScroll.value = scrollable
939
+ if (!scrollable) {
940
+ shown.value = false
941
+ return
942
+ }
943
+
944
+ const anchorRect = anchorEl.getBoundingClientRect()
945
+ const shrinkInset = (anchorRect.height * (1 - visibleRatio)) / 2
946
+
947
+ zoneTop.value = Math.round(anchorRect.top + shrinkInset)
948
+ zoneRight.value = rightOffsetPx
949
+ zoneHeight.value = Math.round(anchorRect.height * visibleRatio)
950
+
951
+ const trackH = zoneHeight.value
952
+ thumbHeight.value = Math.max(minThumbPx, (clientHeight / scrollHeight) * trackH)
953
+ const maxThumbTravel = Math.max(0, trackH - thumbHeight.value)
954
+ const scrollMax = scrollHeight - clientHeight
955
+ thumbOffset.value = scrollMax > 0 ? Math.round((scrollTop / scrollMax) * maxThumbTravel) : 0
956
+ }
957
+
958
+ function scheduleRefresh() {
959
+ cancelAnimationFrame(rafId)
960
+ rafId = requestAnimationFrame(refreshLayout)
961
+ }
962
+
963
+ function onScroll() {
964
+ revealBar()
965
+ scheduleRefresh()
966
+ }
967
+
968
+ function bind() {
969
+ unbind()
970
+ const scrollEl = options.scrollEl.value
971
+ if (!scrollEl) return
972
+
973
+ removeContainerMark = markCapsuleScrollContainer(scrollEl)
974
+ refreshLayout()
975
+ scrollEl.addEventListener('scroll', onScroll, { passive: true })
976
+ resizeObserver = new ResizeObserver(() => scheduleRefresh())
977
+ resizeObserver.observe(scrollEl)
978
+ if (options.anchorEl?.value) resizeObserver.observe(options.anchorEl.value)
979
+ window.addEventListener('resize', scheduleRefresh, { passive: true })
980
+ }
981
+
982
+ function unbind() {
983
+ cancelAnimationFrame(rafId)
984
+ clearHideTimer()
985
+ window.removeEventListener('resize', scheduleRefresh)
986
+ resizeObserver?.disconnect()
987
+ resizeObserver = undefined
988
+ thumbDragCleanup?.()
989
+ thumbDragCleanup = undefined
990
+ options.scrollEl.value?.removeEventListener('scroll', onScroll)
991
+ removeContainerMark?.()
992
+ removeContainerMark = undefined
993
+ canScroll.value = false
994
+ shown.value = false
995
+ }
996
+
997
+ function onThumbMouseDown(event: MouseEvent) {
998
+ const scrollEl = options.scrollEl.value
999
+ if (!scrollEl) return
1000
+
1001
+ event.preventDefault()
1002
+ revealBar()
1003
+ clearHideTimer()
1004
+
1005
+ const startY = event.clientY
1006
+ const startScrollTop = scrollEl.scrollTop
1007
+ const trackH = zoneHeight.value
1008
+ const scrollMax = scrollEl.scrollHeight - scrollEl.clientHeight
1009
+ const maxThumbTravel = Math.max(0, trackH - thumbHeight.value)
1010
+
1011
+ const onMove = (ev: MouseEvent) => {
1012
+ if (maxThumbTravel <= 0 || scrollMax <= 0) return
1013
+ scrollEl.scrollTop = startScrollTop + ((ev.clientY - startY) / maxThumbTravel) * scrollMax
1014
+ }
1015
+
1016
+ const onUp = () => {
1017
+ document.removeEventListener('mousemove', onMove)
1018
+ document.removeEventListener('mouseup', onUp)
1019
+ thumbDragCleanup = undefined
1020
+ scheduleHide()
1021
+ }
1022
+
1023
+ document.addEventListener('mousemove', onMove)
1024
+ document.addEventListener('mouseup', onUp)
1025
+ thumbDragCleanup = onUp
1026
+ }
1027
+
1028
+ watch(
1029
+ () => options.active.value,
1030
+ (active) => {
1031
+ if (active) {
1032
+ void nextTick(() => {
1033
+ bind()
1034
+ void nextTick(refreshLayout)
1035
+ })
1036
+ return
1037
+ }
1038
+ unbind()
1039
+ },
1040
+ { immediate: true }
1041
+ )
1042
+
1043
+ onUnmounted(unbind)
1044
+
1045
+ return {
1046
+ canScroll,
1047
+ shown,
1048
+ visible,
1049
+ rootStyle,
1050
+ thumbStyle,
1051
+ rootClass: classPrefix,
1052
+ thumbClass: `${classPrefix}__thumb`,
1053
+ onThumbMouseDown,
1054
+ refresh: refreshLayout,
1055
+ bind,
1056
+ unbind,
1057
+ }
1058
+ }
1059
+
1060
+ export function lockDocumentScroll(): () => void {
1061
+ const html = document.documentElement
1062
+ const body = document.body
1063
+ const prevHtml = html.style.overflow
1064
+ const prevBody = body.style.overflow
1065
+
1066
+ html.style.overflow = 'hidden'
1067
+ body.style.overflow = 'hidden'
1068
+
1069
+ return () => {
1070
+ html.style.overflow = prevHtml
1071
+ body.style.overflow = prevBody
1072
+ }
1073
+ }
1074
+
1075
+ export interface ViewportBodyHeightOptions {
1076
+ subtract?: number
1077
+ subtractSelector?: string
1078
+ subtractRoot?: HTMLElement
1079
+ listenResize?: boolean
1080
+ }
1081
+
1082
+ export function getViewportHeight(): number {
1083
+ return window.innerHeight
1084
+ }
1085
+
1086
+ function resolveSubtract(el: HTMLElement, options?: ViewportBodyHeightOptions): number {
1087
+ if (options?.subtract !== undefined) return options.subtract
1088
+
1089
+ if (options?.subtractSelector) {
1090
+ const root = options.subtractRoot ?? el.parentElement ?? document.body
1091
+ const node = root.querySelector(options.subtractSelector) as HTMLElement | null
1092
+ if (node) return node.getBoundingClientRect().height
1093
+ }
1094
+
1095
+ return 0
1096
+ }
1097
+
1098
+ export function getViewportBodyHeight(
1099
+ el: HTMLElement,
1100
+ options?: ViewportBodyHeightOptions
1101
+ ): number {
1102
+ const subtract = resolveSubtract(el, options)
1103
+ return Math.max(0, getViewportHeight() - subtract)
1104
+ }
1105
+
1106
+ export function bindViewportBodyHeight(
1107
+ el: HTMLElement,
1108
+ options?: ViewportBodyHeightOptions
1109
+ ): () => void {
1110
+ const listenResize = options?.listenResize ?? true
1111
+
1112
+ const update = () => {
1113
+ const height = getViewportBodyHeight(el, options)
1114
+ el.style.height = `${height}px`
1115
+ el.style.maxHeight = `${height}px`
1116
+ }
1117
+
1118
+ update()
1119
+
1120
+ if (!listenResize) {
1121
+ return () => {
1122
+ el.style.height = ''
1123
+ el.style.maxHeight = ''
1124
+ }
1125
+ }
1126
+
1127
+ window.addEventListener('resize', update)
1128
+ return () => {
1129
+ window.removeEventListener('resize', update)
1130
+ el.style.height = ''
1131
+ el.style.maxHeight = ''
1132
+ }
1133
+ }