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,1765 @@
1
+ <template>
2
+ <div ref="pageRef" class="report-generating-page">
3
+ <div class="report-generating-page__layout">
4
+ <div class="report-generating-page__main">
5
+ <header class="report-generating-page__hero">
6
+ <h1 class="report-generating-page__hero-title">{{ heroTitle }}</h1>
7
+ </header>
8
+
9
+ <div class="report-generating-page__body">
10
+ <el-scrollbar class="report-generating-page__scrollbar">
11
+ <div class="report-generating-page__content">
12
+ <ul v-if="visibleChapters.length" class="report-generating-chapters">
13
+ <li
14
+ v-for="chapter in visibleChapters"
15
+ :key="chapter.id"
16
+ :ref="(el) => bindChapterRowEl(chapter.id, el)"
17
+ class="report-generating-chapters__row"
18
+ :class="{ 'is-creating-active': chapter.status === 'creating' }"
19
+ :data-chapter-id="chapter.id"
20
+ >
21
+ <span class="report-generating-chapters__state" :class="`is-${chapter.status}`">
22
+ <template v-if="chapter.status === 'success'">
23
+ <el-icon
24
+ class="report-generating-chapters__icon is-success"
25
+ aria-hidden="true"
26
+ >
27
+ <Check />
28
+ </el-icon>
29
+ </template>
30
+ <template v-else-if="chapter.status === 'failure'">
31
+ <el-icon
32
+ class="report-generating-chapters__icon is-failed"
33
+ aria-hidden="true"
34
+ >
35
+ <Close />
36
+ </el-icon>
37
+ </template>
38
+ <template v-else-if="chapter.status === 'creating'">
39
+ <el-icon
40
+ class="report-generating-chapters__icon is-loading"
41
+ aria-hidden="true"
42
+ >
43
+ <RefreshRight />
44
+ </el-icon>
45
+ </template>
46
+ <template v-else>
47
+ <span
48
+ class="report-generating-chapters__icon is-pending report-generating-hourglass"
49
+ aria-hidden="true"
50
+ >
51
+ <svg
52
+ class="report-generating-hourglass__svg"
53
+ viewBox="0 0 24 28"
54
+ xmlns="http://www.w3.org/2000/svg"
55
+ >
56
+ <path
57
+ class="report-generating-hourglass__frame"
58
+ d="M6 2h12l-5.2 9.2a1.2 1.2 0 0 0 0 1.2L18 22H6l5.2-9.6a1.2 1.2 0 0 0 0-1.2L6 2z"
59
+ fill="none"
60
+ stroke="currentColor"
61
+ stroke-width="1.4"
62
+ stroke-linejoin="round"
63
+ />
64
+ <path
65
+ class="report-generating-hourglass__sand-top"
66
+ d="M8.2 4.2h7.6L12 10.8 8.2 4.2z"
67
+ fill="currentColor"
68
+ />
69
+ <path
70
+ class="report-generating-hourglass__sand-bottom"
71
+ d="M12 17.2 15.8 23.8H8.2L12 17.2z"
72
+ fill="currentColor"
73
+ />
74
+ <circle
75
+ class="report-generating-hourglass__drip report-generating-hourglass__drip--1"
76
+ cx="12"
77
+ cy="12.8"
78
+ r="0.75"
79
+ fill="currentColor"
80
+ />
81
+ <circle
82
+ class="report-generating-hourglass__drip report-generating-hourglass__drip--2"
83
+ cx="12"
84
+ cy="12.8"
85
+ r="0.6"
86
+ fill="currentColor"
87
+ />
88
+ <circle
89
+ class="report-generating-hourglass__drip report-generating-hourglass__drip--3"
90
+ cx="12"
91
+ cy="12.8"
92
+ r="0.55"
93
+ fill="currentColor"
94
+ />
95
+ </svg>
96
+ </span>
97
+ </template>
98
+ </span>
99
+ <div class="report-generating-chapters__name">
100
+ <span class="report-generating-chapters__index">{{ chapter.orderSeq }}.</span>
101
+ <ChapterTitleScroll
102
+ root-tag="span"
103
+ :title="chapter.sectionName"
104
+ :max-chars="19"
105
+ />
106
+ </div>
107
+ </li>
108
+ </ul>
109
+ </div>
110
+ </el-scrollbar>
111
+ </div>
112
+ </div>
113
+
114
+ <aside class="report-generating-page__video" aria-label="視頻播放區">
115
+ <div class="report-generating-page__video-slot">
116
+ <!-- 視頻已刪:guide.mp4 相關暫時注釋
117
+ <video
118
+ ref="videoEl"
119
+ class="report-generating-page__player"
120
+ :src="GENERATING_VIDEO_SRC"
121
+ muted
122
+ autoplay
123
+ loop
124
+ playsinline
125
+ webkit-playsinline
126
+ preload="auto"
127
+ />
128
+ -->
129
+ </div>
130
+ </aside>
131
+ </div>
132
+
133
+ <footer v-if="showActionFooter" class="report-generating-page__footer">
134
+ <p class="report-generating-page__summary">{{ completionSummary }}</p>
135
+ <div class="report-generating-page__footer-actions">
136
+ <button
137
+ type="button"
138
+ class="app-btn app-btn--wide app-btn--outline"
139
+ :disabled="isGenerating || retrying || isSectionRequesting || !failedCount"
140
+ @click="retryFailedChapters"
141
+ >
142
+ 重試失敗的章節
143
+ </button>
144
+ <!-- <button
145
+ type="button"
146
+ class="app-btn app-btn--wide"
147
+ :disabled="isGenerating || retrying || isSectionRequesting || !canEnterEditing"
148
+ @click="enterEditing"
149
+ >
150
+ 進入編輯
151
+ </button> -->
152
+ <button type="button" class="app-btn app-btn--wide" @click="enterEditing">進入編輯</button>
153
+ </div>
154
+ </footer>
155
+ </div>
156
+ </template>
157
+
158
+ <script setup lang="ts">
159
+ import { Check, Close, RefreshRight } from '@element-plus/icons-vue'
160
+ import { ElIcon, ElMessage, ElScrollbar } from 'element-plus'
161
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
162
+ import { useRoute, useRouter } from 'vue-router'
163
+ import { ChapterTitleScroll } from '../../common'
164
+ // 視頻已刪:guide.mp4 相關暫時注釋
165
+ // import generatingVideoSrc from '../../common/assets/guide.mp4?url'
166
+ import { postGenAiRequest } from '../../common/utils/remote-request'
167
+ import { getViewportHeight, lockDocumentScroll, navigateToReportEdit } from '../report-domain'
168
+ import {
169
+ clearGeneratingSession,
170
+ loadGeneratingSession,
171
+ saveGeneratingSession,
172
+ useReportWorkflow,
173
+ type GeneratingSessionChapter,
174
+ } from '../useReportWorkflow'
175
+ const emit = defineEmits<{
176
+ 'enter-editing': []
177
+ }>()
178
+
179
+ /** 生成中頁視頻源(視頻已刪,暫時注釋) */
180
+ // const GENERATING_VIDEO_SRC = generatingVideoSrc
181
+
182
+ /** 輪詢間隔 2s;未收到 completed:true 則持續輪詢(無次數上限) */
183
+ const SECTION_POLL_INTERVAL_MS = 2000 // 轮询间隔2s
184
+ /** CREATING 計時閾值(僅追蹤開始時間;failure 叉號只認接口 createdFlag=FAILURE,本地超時不寫成 failure) */
185
+ const SECTION_CREATING_TIMEOUT_MS = 1_800_000 // 30分钟超时
186
+
187
+ type GenAiSectionCreatedFlag = 'PENDING' | 'CREATING' | 'SUCCESS' | 'FAILURE'
188
+
189
+ interface genAiSearchReportParams {
190
+ categoryId: number
191
+ instanceId: string
192
+ }
193
+
194
+ interface GenAiGeneratingSectionItem {
195
+ sectionId: string
196
+ sectionName: string
197
+ orderSeq: number
198
+ createdFlag: GenAiSectionCreatedFlag
199
+ }
200
+
201
+ type ChapterGenerationStatus = 'pending' | 'creating' | 'success' | 'failure'
202
+
203
+ interface ChapterGenerationItem {
204
+ id: string
205
+ sectionId: string
206
+ sectionName: string
207
+ orderSeq: number
208
+ status: ChapterGenerationStatus
209
+ body?: string
210
+ }
211
+
212
+ const CREATED_FLAG_STATUS_MAP: Record<GenAiSectionCreatedFlag, ChapterGenerationStatus> = {
213
+ PENDING: 'pending',
214
+ CREATING: 'creating',
215
+ SUCCESS: 'success',
216
+ FAILURE: 'failure',
217
+ }
218
+
219
+ const route = useRoute()
220
+ const router = useRouter()
221
+ const instanceId = ref('')
222
+
223
+ const {
224
+ markGenerationSuccess,
225
+ selectedTemplate,
226
+ setGeneratedChaptersFromApi,
227
+ loadTemplateFromSession,
228
+ } = useReportWorkflow()
229
+
230
+ const templateId = computed(() => {
231
+ const fromRoute = route.query.templateId
232
+ if (typeof fromRoute === 'string' && fromRoute) return fromRoute
233
+ return selectedTemplate.value?.id ?? '1'
234
+ })
235
+
236
+ const pageRef = ref<HTMLElement | null>(null)
237
+ // 視頻已刪:guide.mp4 相關暫時注釋
238
+ // const videoEl = ref<HTMLVideoElement | null>(null)
239
+ let measureRafId = 0
240
+ let cleanupLock: (() => void) | null = null
241
+ let cleanupBodyHeight: (() => void) | null = null
242
+ let generationAbortController: AbortController | null = null
243
+ /** 章節進入 CREATING 的時間戳(僅觀測;不因超時改 status) */
244
+ const creatingStartedAt = new Map<string, number>()
245
+ let creatingTimeoutTimer = 0
246
+
247
+ // 視頻已刪:guide.mp4 相關暫時注釋
248
+ // function ensureVideoPlaying() {
249
+ // const el = videoEl.value
250
+ // if (!el) return
251
+ // el.muted = true
252
+ // el.loop = true
253
+ // const playPromise = el.play()
254
+ // if (playPromise && typeof playPromise.then === 'function') {
255
+ // void playPromise.catch(() => {
256
+ // window.setTimeout(() => {
257
+ // void videoEl.value?.play()?.catch(() => undefined)
258
+ // }, 120)
259
+ // })
260
+ // }
261
+ // }
262
+
263
+ // function initGeneratingVideo() {
264
+ // const el = videoEl.value
265
+ // if (!el) return
266
+ //
267
+ // el.muted = true
268
+ // el.defaultMuted = true
269
+ // el.autoplay = true
270
+ // el.loop = true
271
+ // el.playsInline = true
272
+ // el.setAttribute('muted', '')
273
+ // el.setAttribute('playsinline', '')
274
+ // el.setAttribute('webkit-playsinline', '')
275
+ //
276
+ // el.addEventListener('loadeddata', ensureVideoPlaying)
277
+ // el.addEventListener('canplay', ensureVideoPlaying)
278
+ // el.addEventListener('ended', onVideoEnded)
279
+ // ensureVideoPlaying()
280
+ // }
281
+
282
+ // function onVideoEnded() {
283
+ // const el = videoEl.value
284
+ // if (!el) return
285
+ // el.currentTime = 0
286
+ // ensureVideoPlaying()
287
+ // }
288
+
289
+ // function disposeGeneratingVideo() {
290
+ // const el = videoEl.value
291
+ // if (!el) return
292
+ // el.removeEventListener('loadeddata', ensureVideoPlaying)
293
+ // el.removeEventListener('canplay', ensureVideoPlaying)
294
+ // el.removeEventListener('ended', onVideoEnded)
295
+ // el.pause()
296
+ // }
297
+
298
+ function delay(ms: number) {
299
+ return new Promise<void>((resolve) => {
300
+ window.setTimeout(resolve, ms)
301
+ })
302
+ }
303
+
304
+ function throwIfAborted(signal: AbortSignal) {
305
+ if (signal.aborted) {
306
+ throw new DOMException('章節生成已取消', 'AbortError')
307
+ }
308
+ }
309
+
310
+ function mapCreatedFlag(flag: GenAiSectionCreatedFlag | string): ChapterGenerationStatus {
311
+ // 僅認接口明確返回值;缺省 / 未知一律 pending(叉號必須 createdFlag=FAILURE)
312
+ const key = String(flag ?? '')
313
+ .trim()
314
+ .toUpperCase() as GenAiSectionCreatedFlag
315
+ return CREATED_FLAG_STATUS_MAP[key] ?? 'pending'
316
+ }
317
+
318
+ function resolveOrderSeq(raw: unknown, fallback: number): number {
319
+ const n = typeof raw === 'number' ? raw : Number.parseInt(String(raw ?? ''), 10)
320
+ return Number.isFinite(n) && n > 0 ? n : fallback
321
+ }
322
+
323
+ /**
324
+ * 按 orderSeq 排序;同 sectionId 去重;保證 orderSeq 唯一不亂序
325
+ */
326
+ function normalizeSectionList(list: GenAiGeneratingSectionItem[]): GenAiGeneratingSectionItem[] {
327
+ const byId = new Map<string, GenAiGeneratingSectionItem>()
328
+
329
+ list.forEach((raw, index) => {
330
+ const sectionId = String(raw?.sectionId ?? '').trim()
331
+ if (!sectionId || byId.has(sectionId)) return
332
+ byId.set(sectionId, {
333
+ sectionId,
334
+ sectionName: String(raw?.sectionName ?? ''),
335
+ orderSeq: resolveOrderSeq(raw?.orderSeq, index + 1),
336
+ createdFlag: raw?.createdFlag,
337
+ })
338
+ })
339
+
340
+ const sorted = [...byId.values()].sort((a, b) => {
341
+ if (a.orderSeq !== b.orderSeq) return a.orderSeq - b.orderSeq
342
+ return a.sectionId.localeCompare(b.sectionId, 'en', { numeric: true })
343
+ })
344
+
345
+ const used = new Set<number>()
346
+ return sorted.map((item, index) => {
347
+ let orderSeq = item.orderSeq
348
+ if (used.has(orderSeq)) {
349
+ orderSeq = index + 1
350
+ while (used.has(orderSeq)) orderSeq += 1
351
+ }
352
+ used.add(orderSeq)
353
+ return { ...item, orderSeq }
354
+ })
355
+ }
356
+
357
+ function sortSectionList(list: GenAiGeneratingSectionItem[]) {
358
+ return normalizeSectionList(list)
359
+ }
360
+
361
+ function toChapterItems(list: GenAiGeneratingSectionItem[]): ChapterGenerationItem[] {
362
+ return normalizeSectionList(list).map((item) => ({
363
+ id: item.sectionId,
364
+ sectionId: item.sectionId,
365
+ sectionName: item.sectionName,
366
+ orderSeq: item.orderSeq,
367
+ status: mapCreatedFlag(item.createdFlag),
368
+ }))
369
+ }
370
+
371
+ function resolveGeneratingParams(): genAiSearchReportParams {
372
+ const categoryRaw = route.query.categoryId
373
+ const instanceRaw = route.query.instanceId
374
+ const categoryId =
375
+ typeof categoryRaw === 'string' && categoryRaw
376
+ ? Number.parseInt(categoryRaw, 10)
377
+ : Number.parseInt(templateId.value, 10)
378
+
379
+ if (typeof instanceRaw === 'string' && instanceRaw) {
380
+ instanceId.value = instanceRaw
381
+ }
382
+
383
+ return {
384
+ categoryId: Number.isFinite(categoryId) && categoryId > 0 ? categoryId : 1,
385
+ instanceId: instanceId.value,
386
+ }
387
+ }
388
+
389
+ /**
390
+ * 發送請求封裝:僅 path + 業務參數(超時由網關處理,不傳 timeoutMs)
391
+ */
392
+ const genAiCreateReportApi = {
393
+ getGeneratingSection(params: { categoryId: number; instanceId: string }) {
394
+ return postGenAiRequest('/generatingSection', params)
395
+ },
396
+ getGeneratingSectionStatus(params: {
397
+ categoryId: number
398
+ instanceId: string
399
+ sectionIdList: string[]
400
+ }) {
401
+ return postGenAiRequest('/generatingSectionStatus', params)
402
+ },
403
+ }
404
+
405
+ /** success === false 或非 200 視為失敗,呼叫端應停止後續連續請求 */
406
+ function isGenAiResponseOk(res: any): boolean {
407
+ if (res == null) return false
408
+ if (res.success === false) return false
409
+ if (res.success === true) return true
410
+ return String(res.code) === '200'
411
+ }
412
+
413
+ /**
414
+ * —— 發送方式 / 參數 / res 結構不可改 ——
415
+ * success === false 或非 200 時返回 ok:false,呼叫端停止後續連續請求
416
+ */
417
+ const generatingSection = async (params: genAiSearchReportParams) => {
418
+ const res: any = await genAiCreateReportApi
419
+ .getGeneratingSection({
420
+ categoryId: params.categoryId,
421
+ instanceId: params.instanceId,
422
+ })
423
+ .catch((err) => {
424
+ console.error('接口異常', err)
425
+ return null
426
+ })
427
+
428
+ try {
429
+ if (!isGenAiResponseOk(res)) {
430
+ ElMessage.warning(res?.message || '接口異常')
431
+ return {
432
+ ok: false as const,
433
+ sectionList: [] as GenAiGeneratingSectionItem[],
434
+ completed: false,
435
+ }
436
+ }
437
+
438
+ const sectionList = normalizeSectionList(
439
+ (res.data?.sectionList ?? []) as GenAiGeneratingSectionItem[]
440
+ )
441
+ const allFinish = !!res.data?.completed
442
+ if (res.data?.instanceId) {
443
+ instanceId.value = String(res.data.instanceId)
444
+ void syncInstanceIdToRoute(instanceId.value)
445
+ }
446
+ console.log('分段列表', sectionList, '是否全部完成', allFinish)
447
+ return { ok: true as const, sectionList, completed: allFinish }
448
+ } catch (err) {
449
+ console.error('接口異常', err)
450
+ ElMessage.warning('接口異常')
451
+ return { ok: false as const, sectionList: [] as GenAiGeneratingSectionItem[], completed: false }
452
+ }
453
+ }
454
+
455
+ const generatingSectionStatus = async (params: {
456
+ categoryId: number
457
+ instanceId: string
458
+ sectionIdList: string[]
459
+ }) => {
460
+ // 傳入 id 順序保證按當前章節 orderSeq 由小到大
461
+ const orderedSectionIdList = (() => {
462
+ const idSet = new Set(params.sectionIdList.map(String))
463
+ const fromChapters = [...chapters.value]
464
+ .filter((item) => idSet.has(item.id))
465
+ .sort((a, b) => {
466
+ if (a.orderSeq !== b.orderSeq) return a.orderSeq - b.orderSeq
467
+ return a.sectionId.localeCompare(b.sectionId, 'en', { numeric: true })
468
+ })
469
+ .map((item) => item.id)
470
+ if (fromChapters.length === idSet.size) return fromChapters
471
+ // 章節尚未寫入時,保持傳入順序(呼叫方 buildSectionIdList 已按 orderSeq 排好)
472
+ return params.sectionIdList.map(String)
473
+ })()
474
+
475
+ const res: any = await genAiCreateReportApi
476
+ .getGeneratingSectionStatus({
477
+ categoryId: params.categoryId,
478
+ instanceId: params.instanceId,
479
+ sectionIdList: orderedSectionIdList,
480
+ })
481
+ .catch((err) => {
482
+ console.error('接口異常', err)
483
+ return null
484
+ })
485
+
486
+ try {
487
+ if (!isGenAiResponseOk(res)) {
488
+ ElMessage.warning(res?.message || '接口異常')
489
+ return { ok: false as const, list: [] as GenAiGeneratingSectionItem[] }
490
+ }
491
+
492
+ return {
493
+ ok: true as const,
494
+ list: normalizeSectionList(
495
+ (Array.isArray(res.data) ? res.data : []) as GenAiGeneratingSectionItem[]
496
+ ),
497
+ }
498
+ } catch (err) {
499
+ console.error('接口異常', err)
500
+ ElMessage.warning('接口異常')
501
+ return { ok: false as const, list: [] as GenAiGeneratingSectionItem[] }
502
+ }
503
+ }
504
+
505
+ const chapters = ref<ChapterGenerationItem[]>([])
506
+ const isGenerating = ref(false)
507
+ const isComplete = ref(false)
508
+ const retrying = ref(false)
509
+ /** 正在請求 / 輪詢 getGeneratingSection 時為 true,期間不展示 footer */
510
+ const isSectionRequesting = ref(false)
511
+
512
+ const chapterRowEls = new Map<string, HTMLElement>()
513
+
514
+ function bindChapterRowEl(id: string, el: unknown) {
515
+ if (el instanceof HTMLElement) chapterRowEls.set(id, el)
516
+ else chapterRowEls.delete(id)
517
+ }
518
+
519
+ const visibleChapters = computed(() =>
520
+ [...chapters.value].sort((a, b) => {
521
+ if (a.orderSeq !== b.orderSeq) return a.orderSeq - b.orderSeq
522
+ return a.sectionId.localeCompare(b.sectionId, 'en', { numeric: true })
523
+ })
524
+ )
525
+
526
+ /** 當前正在生成的章節(creating) */
527
+ const activeCreatingChapterId = computed(
528
+ () => visibleChapters.value.find((item) => item.status === 'creating')?.id ?? null
529
+ )
530
+
531
+ /** 滾動條滾到當前生成中的章節行 */
532
+ function scrollToActiveCreatingChapter() {
533
+ const id = activeCreatingChapterId.value
534
+ if (!id) return
535
+
536
+ void nextTick(() => {
537
+ const row =
538
+ chapterRowEls.get(id) ??
539
+ (document.querySelector(
540
+ `.report-generating-chapters__row[data-chapter-id="${CSS.escape(id)}"]`
541
+ ) as HTMLElement | null)
542
+ if (!row) return
543
+
544
+ row.scrollIntoView({ block: 'center', behavior: 'smooth', inline: 'nearest' })
545
+ })
546
+ }
547
+
548
+ watch(activeCreatingChapterId, (id) => {
549
+ if (id) scrollToActiveCreatingChapter()
550
+ })
551
+
552
+ /** 生成頁 height / min-height 相對視口固定扣減;章節溢出時由 __body 內部滾動 */
553
+ const GENERATING_VIEWPORT_OFFSET_PX = 110
554
+
555
+ const doneCount = computed(() => chapters.value.filter((item) => item.status === 'success').length)
556
+ const failedCount = computed(
557
+ () => chapters.value.filter((item) => item.status === 'failure').length
558
+ )
559
+
560
+ /** 至少有一章成功才可進入編輯;全部失敗時置灰不可點 */
561
+ const canEnterEditing = computed(() => doneCount.value > 0)
562
+
563
+ const completionSummary = computed(
564
+ () => `報告生成完畢,${doneCount.value}個章節已完成,${failedCount.value}個章節失敗`
565
+ )
566
+
567
+ const heroTitle = computed(() =>
568
+ isComplete.value && !isGenerating.value && !isSectionRequesting.value
569
+ ? '報告生成完畢'
570
+ : '報告生成中...'
571
+ )
572
+
573
+ /** 僅在 generatingSection 請求全部結束後顯示底部操作區 */
574
+ const showActionFooter = computed(
575
+ () => isComplete.value && !isGenerating.value && !isSectionRequesting.value && !retrying.value
576
+ )
577
+
578
+ function getGeneratingHostEl(page: HTMLElement) {
579
+ return page.closest('.report-edit-page__generating') as HTMLElement | null
580
+ }
581
+
582
+ function clearGeneratingHeightStyles(page: HTMLElement) {
583
+ page.style.height = ''
584
+ page.style.maxHeight = ''
585
+ page.style.minHeight = ''
586
+ const host = getGeneratingHostEl(page)
587
+ if (host) {
588
+ host.style.height = ''
589
+ host.style.maxHeight = ''
590
+ host.style.minHeight = ''
591
+ }
592
+ }
593
+
594
+ /** 視口高度減去固定 110px 後寫入 page + report-edit-page__generating(始終貼合視口) */
595
+ function applyGeneratingViewportHeights(page: HTMLElement) {
596
+ const heightPx = Math.max(0, getViewportHeight() - GENERATING_VIEWPORT_OFFSET_PX)
597
+ const height = `${heightPx}px`
598
+ const host = getGeneratingHostEl(page)
599
+
600
+ page.style.height = height
601
+ page.style.maxHeight = height
602
+ page.style.minHeight = height
603
+ if (host) {
604
+ host.style.height = height
605
+ host.style.maxHeight = height
606
+ host.style.minHeight = height
607
+ }
608
+ }
609
+
610
+ async function syncViewportHeightMode() {
611
+ await nextTick()
612
+ await new Promise<void>((resolve) => {
613
+ cancelAnimationFrame(measureRafId)
614
+ measureRafId = requestAnimationFrame(() => {
615
+ measureRafId = requestAnimationFrame(() => resolve())
616
+ })
617
+ })
618
+
619
+ const page = pageRef.value
620
+ if (!page) return
621
+
622
+ cleanupBodyHeight?.()
623
+ cleanupBodyHeight = null
624
+
625
+ applyGeneratingViewportHeights(page)
626
+ const update = () => applyGeneratingViewportHeights(page)
627
+ window.addEventListener('resize', update)
628
+ cleanupBodyHeight = () => {
629
+ window.removeEventListener('resize', update)
630
+ clearGeneratingHeightStyles(page)
631
+ }
632
+ }
633
+
634
+ function scheduleSyncViewportHeightMode() {
635
+ cancelAnimationFrame(measureRafId)
636
+ measureRafId = requestAnimationFrame(() => {
637
+ void syncViewportHeightMode()
638
+ })
639
+ }
640
+
641
+ function clearCreatingTimeoutState(ids?: string[]) {
642
+ if (!ids?.length) {
643
+ creatingStartedAt.clear()
644
+ return
645
+ }
646
+ for (const id of ids) {
647
+ creatingStartedAt.delete(id)
648
+ }
649
+ }
650
+
651
+ function stopCreatingTimeoutWatcher() {
652
+ if (creatingTimeoutTimer) {
653
+ clearInterval(creatingTimeoutTimer)
654
+ creatingTimeoutTimer = 0
655
+ }
656
+ }
657
+
658
+ function startCreatingTimeoutWatcher() {
659
+ stopCreatingTimeoutWatcher()
660
+ creatingTimeoutTimer = window.setInterval(() => {
661
+ applyCreatingTimeouts()
662
+ }, 1000)
663
+ }
664
+
665
+ /** 同步 CREATING 計時觀測;不把本地超時寫成 failure(叉號只認接口 createdFlag=FAILURE) */
666
+ function applyCreatingTimeouts() {
667
+ const now = Date.now()
668
+ void SECTION_CREATING_TIMEOUT_MS
669
+
670
+ for (const chapter of chapters.value) {
671
+ if (chapter.status === 'creating') {
672
+ if (!creatingStartedAt.has(chapter.id)) {
673
+ creatingStartedAt.set(chapter.id, now)
674
+ }
675
+ continue
676
+ }
677
+ creatingStartedAt.delete(chapter.id)
678
+ }
679
+ }
680
+
681
+ function applySectionList(sectionList: GenAiGeneratingSectionItem[]) {
682
+ const prevCount = chapters.value.length
683
+ const nextChapters = toChapterItems(sectionList)
684
+ const prevMap = new Map(chapters.value.map((row) => [row.id, row] as const))
685
+
686
+ chapters.value = nextChapters.map((item) => {
687
+ const prev = prevMap.get(item.id)
688
+ // 前一次已是 success:後續輪詢不再改動該章節(含 orderSeq)
689
+ if (prev?.status === 'success') {
690
+ return prev
691
+ }
692
+ return {
693
+ ...item,
694
+ // 已有 orderSeq 則保持,避免輪詢改序錯亂
695
+ orderSeq: prev?.orderSeq && prev.orderSeq > 0 ? prev.orderSeq : item.orderSeq,
696
+ body: item.status === 'success' ? prev?.body : undefined,
697
+ }
698
+ })
699
+ // 最終再按 orderSeq 排序,防止合併後錯亂
700
+ chapters.value = [...chapters.value].sort((a, b) => {
701
+ if (a.orderSeq !== b.orderSeq) return a.orderSeq - b.orderSeq
702
+ return a.sectionId.localeCompare(b.sectionId, 'en', { numeric: true })
703
+ })
704
+ applyCreatingTimeouts()
705
+ // 章節數量變化才重測;純狀態輪詢不觸發,避免視頻跳動
706
+ if (chapters.value.length !== prevCount) {
707
+ scheduleSyncViewportHeightMode()
708
+ }
709
+ }
710
+
711
+ function applySectionStatus(statusList: GenAiGeneratingSectionItem[]) {
712
+ if (!statusList.length) return
713
+
714
+ const statusMap = new Map(
715
+ sortSectionList(statusList).map((item) => [String(item.sectionId), item] as const)
716
+ )
717
+
718
+ chapters.value = chapters.value.map((chapter) => {
719
+ // 前一次已是 success:後續輪詢不再改動該章節
720
+ if (chapter.status === 'success') return chapter
721
+
722
+ const next = statusMap.get(chapter.id)
723
+ if (!next) return chapter
724
+ const status = mapCreatedFlag(next.createdFlag)
725
+
726
+ return {
727
+ ...chapter,
728
+ sectionName: next.sectionName || chapter.sectionName,
729
+ // status 輪詢不改 orderSeq,避免序號重複/錯亂
730
+ status,
731
+ body: status === 'success' ? chapter.body : undefined,
732
+ }
733
+ })
734
+
735
+ applyCreatingTimeouts()
736
+ }
737
+
738
+ /** 接口 sectionList 是否全部為 FAILURE(此情況不再繼續請求 generatingSection) */
739
+ function isSectionListAllFailure(sectionList: GenAiGeneratingSectionItem[]) {
740
+ const list = normalizeSectionList(sectionList)
741
+ if (!list.length) return false
742
+ return list.every((item) => mapCreatedFlag(item.createdFlag) === 'failure')
743
+ }
744
+
745
+ function buildSectionIdList(sectionList: GenAiGeneratingSectionItem[]) {
746
+ // generatingSectionStatus 的 sectionIdList 必須按 orderSeq 由小到大
747
+ return normalizeSectionList(sectionList).map((item) => String(item.sectionId))
748
+ }
749
+
750
+ function stopGeneratingFlow() {
751
+ isSectionRequesting.value = false
752
+ isComplete.value = true
753
+ isGenerating.value = false
754
+ }
755
+
756
+ /**
757
+ * 流程:
758
+ * 1. 第一次請求 generatingSection,拿到 sectionList
759
+ * 2. 用 sectionList 拼裝 sectionIdList,generatingSectionStatus 只請求一次
760
+ * 3. 之後每 2s 只輪詢 generatingSection;僅 completed / 全部 FAILURE / 報錯 / 取消時停止
761
+ */
762
+ async function runGeneratingFlow(signal: AbortSignal) {
763
+ isSectionRequesting.value = true
764
+ // —— 第一次:generatingSection → 拼裝 sectionIdList → status 僅一次 ——
765
+ throwIfAborted(signal)
766
+ let params = resolveGeneratingParams()
767
+ const firstSection = await generatingSection(params)
768
+ throwIfAborted(signal)
769
+
770
+ if (!firstSection.ok) {
771
+ stopGeneratingFlow()
772
+ return
773
+ }
774
+
775
+ applySectionList(firstSection.sectionList)
776
+
777
+ // 全部 FAILURE:不再打 status,也不再輪詢 generatingSection
778
+ if (isSectionListAllFailure(firstSection.sectionList)) {
779
+ stopGeneratingFlow()
780
+ return
781
+ }
782
+
783
+ const sectionIdList = buildSectionIdList(firstSection.sectionList)
784
+
785
+ if (sectionIdList.length) {
786
+ const firstStatus = await generatingSectionStatus({
787
+ categoryId: params.categoryId,
788
+ instanceId: instanceId.value,
789
+ sectionIdList,
790
+ })
791
+ throwIfAborted(signal)
792
+ if (!firstStatus.ok) {
793
+ stopGeneratingFlow()
794
+ return
795
+ }
796
+ applySectionStatus(firstStatus.list)
797
+ }
798
+
799
+ // 僅認接口 completed;未完成則進入持續輪詢
800
+ if (firstSection.completed) {
801
+ stopGeneratingFlow()
802
+ return
803
+ }
804
+
805
+ // —— 後續只輪詢 generatingSection(不再打 status);無次數上限 ——
806
+ while (true) {
807
+ throwIfAborted(signal)
808
+ await delay(SECTION_POLL_INTERVAL_MS)
809
+ throwIfAborted(signal)
810
+
811
+ params = resolveGeneratingParams()
812
+ const sectionRes = await generatingSection(params)
813
+ throwIfAborted(signal)
814
+ if (!sectionRes.ok) {
815
+ stopGeneratingFlow()
816
+ return
817
+ }
818
+
819
+ applySectionList(sectionRes.sectionList)
820
+
821
+ if (sectionRes.completed || isSectionListAllFailure(sectionRes.sectionList)) {
822
+ stopGeneratingFlow()
823
+ return
824
+ }
825
+ }
826
+ }
827
+
828
+ /**
829
+ * 重試失敗章節(每次點擊完整走一遍):
830
+ * 1. 先調用 generatingSection
831
+ * 2. 無論 completed true / false(含首包仍全 FAILURE)→ 都先調用一次 generatingSectionStatus
832
+ * 3. 再每 2s 循環輪詢 generatingSection
833
+ * 進頁 runGeneratingFlow 不變;已成功章節不改。
834
+ */
835
+ async function pollFailedChaptersBySection(signal: AbortSignal, failedIds: string[]) {
836
+ if (!failedIds.length) return
837
+
838
+ isSectionRequesting.value = true
839
+
840
+ // —— 第一次:generatingSection ——
841
+ throwIfAborted(signal)
842
+ let params = resolveGeneratingParams()
843
+ const firstSection = await generatingSection(params)
844
+ throwIfAborted(signal)
845
+
846
+ if (!firstSection.ok) {
847
+ stopGeneratingFlow()
848
+ return
849
+ }
850
+
851
+ applySectionList(firstSection.sectionList)
852
+
853
+ // 無論 completed / 是否全 FAILURE:都先打一次 status(修復重試首包仍 FAILURE 時被提前 return 跳過 status)
854
+ const sectionIdList = buildSectionIdList(firstSection.sectionList)
855
+ const idsForStatus = sectionIdList.length ? sectionIdList : failedIds.map(String)
856
+ if (idsForStatus.length) {
857
+ const firstStatus = await generatingSectionStatus({
858
+ categoryId: params.categoryId,
859
+ instanceId: instanceId.value,
860
+ sectionIdList: idsForStatus,
861
+ })
862
+ throwIfAborted(signal)
863
+ if (!firstStatus.ok) {
864
+ stopGeneratingFlow()
865
+ return
866
+ }
867
+ applySectionStatus(firstStatus.list)
868
+ }
869
+
870
+ // —— 後續只輪詢 generatingSection(不再打 status);無次數上限 ——
871
+ while (true) {
872
+ throwIfAborted(signal)
873
+ await delay(SECTION_POLL_INTERVAL_MS)
874
+ throwIfAborted(signal)
875
+
876
+ params = resolveGeneratingParams()
877
+ const sectionRes = await generatingSection(params)
878
+ throwIfAborted(signal)
879
+ if (!sectionRes.ok) {
880
+ stopGeneratingFlow()
881
+ return
882
+ }
883
+
884
+ applySectionList(sectionRes.sectionList)
885
+
886
+ if (sectionRes.completed || isSectionListAllFailure(sectionRes.sectionList)) {
887
+ stopGeneratingFlow()
888
+ return
889
+ }
890
+ }
891
+ }
892
+
893
+ async function startGeneration() {
894
+ generationAbortController?.abort()
895
+ const controller = new AbortController()
896
+ generationAbortController = controller
897
+
898
+ clearCreatingTimeoutState()
899
+ chapters.value = []
900
+ isComplete.value = false
901
+ isGenerating.value = true
902
+ isSectionRequesting.value = true
903
+ startCreatingTimeoutWatcher()
904
+
905
+ try {
906
+ await runGeneratingFlow(controller.signal)
907
+ } catch (error) {
908
+ if (error instanceof DOMException && error.name === 'AbortError') {
909
+ isSectionRequesting.value = false
910
+ return
911
+ }
912
+ isSectionRequesting.value = false
913
+ throw error
914
+ } finally {
915
+ stopCreatingTimeoutWatcher()
916
+ applyCreatingTimeouts()
917
+ if (!controller.signal.aborted) {
918
+ isGenerating.value = false
919
+ isSectionRequesting.value = false
920
+ // 流程正常結束時 stopGeneratingFlow 已設 isComplete;異常中斷也需收斂請求態
921
+ if (!isComplete.value && chapters.value.length) {
922
+ isComplete.value = true
923
+ }
924
+ }
925
+ scheduleSyncViewportHeightMode()
926
+ }
927
+ }
928
+
929
+ function buildChapterBody(chapter: ChapterGenerationItem) {
930
+ // 僅用接口/會話寫入的 body;無正文則空(不回落本地假文案)
931
+ return chapter.body || ''
932
+ }
933
+
934
+ function persistGeneratingSession() {
935
+ const tid = String(templateId.value || selectedTemplate.value?.id || '')
936
+ if (!tid || !chapters.value.length) return
937
+ saveGeneratingSession({
938
+ templateId: tid,
939
+ instanceId: instanceId.value,
940
+ chapters: chapters.value.map(
941
+ (chapter): GeneratingSessionChapter => ({
942
+ id: chapter.id,
943
+ sectionId: chapter.sectionId,
944
+ sectionName: chapter.sectionName,
945
+ orderSeq: chapter.orderSeq,
946
+ status: chapter.status,
947
+ body: chapter.body,
948
+ })
949
+ ),
950
+ isComplete: isComplete.value,
951
+ })
952
+ }
953
+
954
+ async function syncInstanceIdToRoute(id: string) {
955
+ if (!id || route.query.instanceId === id) return
956
+ // 強制保持 generating,避免遷移後 query 拼接把 step 沖掉
957
+ await navigateToReportEdit(
958
+ router,
959
+ 'generating',
960
+ typeof route.query.templateId === 'string' ? route.query.templateId : undefined,
961
+ true,
962
+ undefined,
963
+ id
964
+ )
965
+ }
966
+
967
+ function enterEditing() {
968
+ // 僅在仍在生成 / 輪詢時攔截;按鈕已常駐可點,遷移/接口失敗時也要能進編輯頁
969
+ if (isGenerating.value || retrying.value || isSectionRequesting.value) {
970
+ ElMessage.warning('報告仍在生成中,請稍候再進入編輯')
971
+ return
972
+ }
973
+
974
+ if (!selectedTemplate.value) {
975
+ ElMessage.warning('缺少報告模板,無法進入編輯')
976
+ return
977
+ }
978
+
979
+ const orderedChapters = [...chapters.value].sort((a, b) => {
980
+ if (a.orderSeq !== b.orderSeq) return a.orderSeq - b.orderSeq
981
+ return a.sectionId.localeCompare(b.sectionId, 'en', { numeric: true })
982
+ })
983
+
984
+ if (orderedChapters.length) {
985
+ setGeneratedChaptersFromApi(
986
+ orderedChapters.map((chapter) => {
987
+ const label =
988
+ String(chapter.sectionName || '')
989
+ .replace(/^\d+\.\s*/, '')
990
+ .trim() || chapter.sectionName
991
+ return {
992
+ title: `${chapter.orderSeq}. ${label}`,
993
+ body: buildChapterBody(chapter),
994
+ }
995
+ })
996
+ )
997
+ } else {
998
+ // 接口無章節:空列表進入編輯(不回落本地假標題)
999
+ setGeneratedChaptersFromApi([])
1000
+ }
1001
+
1002
+ markGenerationSuccess()
1003
+ clearGeneratingSession()
1004
+ emit('enter-editing')
1005
+ }
1006
+
1007
+ async function retryFailedChapters() {
1008
+ if (isGenerating.value || retrying.value || !failedCount.value) return
1009
+
1010
+ const failedIds = [...chapters.value]
1011
+ .filter((item) => item.status === 'failure')
1012
+ .sort((a, b) => {
1013
+ if (a.orderSeq !== b.orderSeq) return a.orderSeq - b.orderSeq
1014
+ return a.sectionId.localeCompare(b.sectionId, 'en', { numeric: true })
1015
+ })
1016
+ .map((item) => item.id)
1017
+
1018
+ if (!failedIds.length) return
1019
+
1020
+ generationAbortController?.abort()
1021
+ const controller = new AbortController()
1022
+ generationAbortController = controller
1023
+
1024
+ retrying.value = true
1025
+ isGenerating.value = true
1026
+ isComplete.value = false
1027
+ isSectionRequesting.value = true
1028
+ clearCreatingTimeoutState(failedIds)
1029
+
1030
+ chapters.value = chapters.value.map((item) =>
1031
+ item.status === 'failure' ? { ...item, status: 'pending', body: undefined } : item
1032
+ )
1033
+ startCreatingTimeoutWatcher()
1034
+ scheduleSyncViewportHeightMode()
1035
+
1036
+ try {
1037
+ await pollFailedChaptersBySection(controller.signal, failedIds)
1038
+ } catch (error) {
1039
+ if (error instanceof DOMException && error.name === 'AbortError') {
1040
+ isSectionRequesting.value = false
1041
+ return
1042
+ }
1043
+ isSectionRequesting.value = false
1044
+ throw error
1045
+ } finally {
1046
+ stopCreatingTimeoutWatcher()
1047
+ applyCreatingTimeouts()
1048
+ retrying.value = false
1049
+ if (!controller.signal.aborted) {
1050
+ isGenerating.value = false
1051
+ isSectionRequesting.value = false
1052
+ if (!isComplete.value) {
1053
+ isComplete.value = true
1054
+ }
1055
+ }
1056
+ scheduleSyncViewportHeightMode()
1057
+ }
1058
+ }
1059
+
1060
+ onMounted(() => {
1061
+ loadTemplateFromSession()
1062
+ cleanupLock = lockDocumentScroll()
1063
+ window.addEventListener('resize', scheduleSyncViewportHeightMode)
1064
+
1065
+ const snapshot = loadGeneratingSession()
1066
+ const tid = String(templateId.value || '')
1067
+ const routeInstanceId = typeof route.query.instanceId === 'string' ? route.query.instanceId : ''
1068
+
1069
+ if (snapshot && snapshot.templateId === tid && snapshot.chapters?.length) {
1070
+ chapters.value = snapshot.chapters.map((chapter) => ({ ...chapter }))
1071
+ instanceId.value = snapshot.instanceId || routeInstanceId
1072
+ isComplete.value = snapshot.isComplete
1073
+ isGenerating.value = false
1074
+ isSectionRequesting.value = false
1075
+ if (instanceId.value) void syncInstanceIdToRoute(instanceId.value)
1076
+ // 已完成:直接恢復列表;未完成:重新跑生成以繼續進度
1077
+ if (!snapshot.isComplete) {
1078
+ void startGeneration()
1079
+ }
1080
+ } else {
1081
+ void startGeneration()
1082
+ }
1083
+
1084
+ // 視頻已刪:guide.mp4 相關暫時注釋
1085
+ // void nextTick(() => initGeneratingVideo())
1086
+ scheduleSyncViewportHeightMode()
1087
+ })
1088
+
1089
+ onUnmounted(() => {
1090
+ persistGeneratingSession()
1091
+ generationAbortController?.abort()
1092
+ stopCreatingTimeoutWatcher()
1093
+ clearCreatingTimeoutState()
1094
+ cancelAnimationFrame(measureRafId)
1095
+ window.removeEventListener('resize', scheduleSyncViewportHeightMode)
1096
+ cleanupBodyHeight?.()
1097
+ cleanupBodyHeight = null
1098
+ cleanupLock?.()
1099
+ // 視頻已刪:guide.mp4 相關暫時注釋
1100
+ // disposeGeneratingVideo()
1101
+ })
1102
+
1103
+ watch(
1104
+ [chapters, isComplete, instanceId],
1105
+ () => {
1106
+ persistGeneratingSession()
1107
+ },
1108
+ { deep: true }
1109
+ )
1110
+
1111
+ /** footer 顯隱時同步測高,避免按鈕區撐破視口 */
1112
+ watch(showActionFooter, () => {
1113
+ scheduleSyncViewportHeightMode()
1114
+ })
1115
+ </script>
1116
+
1117
+ <style scoped>
1118
+ .report-generating-page {
1119
+ --gen-ai-fs-hero: clamp(26px, calc(32 * 100vw / 1920), 32px);
1120
+ --gen-ai-fw-bold: 700;
1121
+ --report-generating-video-slot-width: 646px;
1122
+ --report-generating-video-slot-height: 363px;
1123
+ --report-generating-video-aspect: 646 / 363;
1124
+ --report-generating-video-sticky-top: 24px;
1125
+ --report-generating-gap: 50px;
1126
+ /* 左右等寬(以 video 為準)+ 間距 */
1127
+ --report-generating-layout-max-width: calc(
1128
+ 2 * var(--report-generating-video-slot-width) + var(--report-generating-gap)
1129
+ );
1130
+ display: flex;
1131
+ flex-direction: column;
1132
+ box-sizing: border-box;
1133
+ min-height: 0;
1134
+ height: 100%;
1135
+ margin-bottom: 0;
1136
+ padding: 0;
1137
+ background: #fff;
1138
+ overflow: hidden;
1139
+ }
1140
+
1141
+ .report-generating-page__layout {
1142
+ display: grid;
1143
+ grid-template-columns: minmax(0, var(--report-generating-video-slot-width)) minmax(
1144
+ 0,
1145
+ var(--report-generating-video-slot-width)
1146
+ );
1147
+ grid-template-rows: minmax(0, 1fr);
1148
+ column-gap: var(--report-generating-gap);
1149
+ align-items: stretch;
1150
+ justify-content: center;
1151
+ flex: 1 1 auto;
1152
+ width: min(100%, var(--report-generating-layout-max-width));
1153
+ min-height: 0;
1154
+ margin: 0 auto;
1155
+ padding: clamp(32px, 12vh, 96px) 24px 0;
1156
+ box-sizing: border-box;
1157
+ overflow: hidden;
1158
+ }
1159
+
1160
+ .report-generating-page__main {
1161
+ display: flex;
1162
+ flex-direction: column;
1163
+ align-items: stretch;
1164
+ grid-row: 1;
1165
+ width: 100%;
1166
+ max-width: var(--report-generating-video-slot-width);
1167
+ min-width: 0;
1168
+ min-height: 0;
1169
+ height: 100%;
1170
+ overflow: hidden;
1171
+ justify-self: stretch;
1172
+ }
1173
+
1174
+ .report-generating-page__video {
1175
+ position: relative;
1176
+ grid-row: 1;
1177
+ align-self: start;
1178
+ justify-self: stretch;
1179
+ margin-top: 0;
1180
+ width: 100%;
1181
+ max-width: var(--report-generating-video-slot-width);
1182
+ height: fit-content;
1183
+ max-height: none;
1184
+ display: block;
1185
+ z-index: 2;
1186
+ }
1187
+
1188
+ .report-generating-page__video-slot {
1189
+ position: relative;
1190
+ display: block;
1191
+ width: 100%;
1192
+ max-width: var(--report-generating-video-slot-width);
1193
+ height: auto;
1194
+ aspect-ratio: var(--report-generating-video-aspect);
1195
+ margin-left: auto;
1196
+ margin-right: auto;
1197
+ border-radius: 12px;
1198
+ overflow: hidden;
1199
+ line-height: 0;
1200
+ font-size: 0;
1201
+ flex-shrink: 0;
1202
+ /* 實心底,避免滾動條出現後亞像素縫露出 video 默認黑底 */
1203
+ background: #f3f0ff;
1204
+ isolation: isolate;
1205
+ }
1206
+
1207
+ /* 遮住底邊 1px 黑縫(滾動條導致寬高重算時常見) */
1208
+ .report-generating-page__video-slot::after {
1209
+ content: '';
1210
+ position: absolute;
1211
+ left: 0;
1212
+ right: 0;
1213
+ bottom: -1px;
1214
+ height: 3px;
1215
+ background: #f3f0ff;
1216
+ pointer-events: none;
1217
+ z-index: 2;
1218
+ border-radius: 0 0 12px 12px;
1219
+ }
1220
+
1221
+ .report-generating-page__video-slot .report-generating-page__player {
1222
+ position: absolute;
1223
+ /* 略放大並多蓋住底邊,消除 sticky + 滾動條後的黑線 */
1224
+ inset: -3px -2px -5px -2px;
1225
+ width: calc(100% + 4px);
1226
+ height: calc(100% + 8px);
1227
+ max-width: none;
1228
+ object-fit: cover;
1229
+ object-position: center bottom;
1230
+ background: #f3f0ff;
1231
+ border: 0;
1232
+ outline: none;
1233
+ display: block;
1234
+ vertical-align: top;
1235
+ }
1236
+
1237
+ .report-generating-page__hero {
1238
+ flex-shrink: 0;
1239
+ text-align: left;
1240
+ padding: 0 0 20px;
1241
+ box-sizing: border-box;
1242
+ background: transparent;
1243
+ }
1244
+
1245
+ .report-generating-page__hero-title {
1246
+ margin: 0;
1247
+ font-size: var(--gen-ai-fs-hero);
1248
+ font-weight: var(--gen-ai-fw-bold);
1249
+ color: #1a1a1a;
1250
+ }
1251
+
1252
+ .report-generating-page__body {
1253
+ position: relative;
1254
+ flex: 1 1 0;
1255
+ min-height: 0;
1256
+ overflow: hidden;
1257
+ }
1258
+
1259
+ /* 與章節 el-select 下拉滾動條同款(el-scrollbar) */
1260
+ .report-generating-page__scrollbar {
1261
+ position: absolute;
1262
+ inset: 0;
1263
+ height: 100%;
1264
+ }
1265
+
1266
+ .report-generating-page__scrollbar :deep(.el-scrollbar) {
1267
+ height: 100%;
1268
+ }
1269
+
1270
+ .report-generating-page__scrollbar :deep(.el-scrollbar__wrap) {
1271
+ overflow-x: hidden !important;
1272
+ overflow-y: auto !important;
1273
+ max-height: 100% !important;
1274
+ scrollbar-width: none !important;
1275
+ -ms-overflow-style: none !important;
1276
+ }
1277
+
1278
+ .report-generating-page__scrollbar :deep(.el-scrollbar__wrap::-webkit-scrollbar) {
1279
+ width: 0 !important;
1280
+ height: 0 !important;
1281
+ display: none !important;
1282
+ }
1283
+
1284
+ .report-generating-page__scrollbar :deep(.el-scrollbar__bar.is-horizontal) {
1285
+ display: none !important;
1286
+ }
1287
+
1288
+ .report-generating-page__scrollbar :deep(.el-scrollbar__bar.is-vertical) {
1289
+ width: 8px !important;
1290
+ right: 4px !important;
1291
+ top: 2px !important;
1292
+ bottom: 2px !important;
1293
+ opacity: 1 !important;
1294
+ z-index: 10;
1295
+ }
1296
+
1297
+ .report-generating-page__scrollbar :deep(.el-scrollbar__bar.is-vertical .el-scrollbar__thumb) {
1298
+ display: block !important;
1299
+ width: 8px !important;
1300
+ min-height: 50px !important;
1301
+ border-radius: 4px !important;
1302
+ background-color: #999 !important;
1303
+ opacity: 1 !important;
1304
+ }
1305
+
1306
+ .report-generating-page__content {
1307
+ width: 100%;
1308
+ max-width: 100%;
1309
+ margin: 0;
1310
+ padding: 0 12px 12px 0;
1311
+ box-sizing: border-box;
1312
+ }
1313
+
1314
+ .report-generating-chapters {
1315
+ margin: 0;
1316
+ padding: 0;
1317
+ list-style: none;
1318
+ width: 100%;
1319
+ max-width: 100%;
1320
+ }
1321
+
1322
+ .report-generating-chapters__row {
1323
+ display: flex;
1324
+ align-items: center;
1325
+ justify-content: flex-start;
1326
+ gap: 10px;
1327
+ min-height: 40px;
1328
+ width: 100%;
1329
+ max-width: 100%;
1330
+ font-size: 15px;
1331
+ font-weight: 500;
1332
+ color: #303133;
1333
+ overflow: hidden;
1334
+ }
1335
+
1336
+ .report-generating-chapters__state {
1337
+ display: inline-flex;
1338
+ align-items: center;
1339
+ justify-content: center;
1340
+ flex-shrink: 0;
1341
+ width: 15px;
1342
+ min-height: 40px;
1343
+ }
1344
+
1345
+ .report-generating-chapters__state .report-generating-chapters__icon {
1346
+ font-weight: 700;
1347
+ }
1348
+
1349
+ .report-generating-chapters__state .report-generating-chapters__icon :deep(svg) {
1350
+ stroke: currentColor;
1351
+ stroke-width: 48;
1352
+ paint-order: stroke fill;
1353
+ overflow: visible;
1354
+ }
1355
+
1356
+ .report-generating-chapters__state .report-generating-hourglass__svg {
1357
+ stroke-width: 1.8;
1358
+ }
1359
+
1360
+ .report-generating-chapters__state .report-generating-hourglass__frame {
1361
+ stroke-width: 1.8;
1362
+ }
1363
+
1364
+ .report-generating-chapters__state .report-generating-hourglass__sand-top,
1365
+ .report-generating-chapters__state .report-generating-hourglass__sand-bottom,
1366
+ .report-generating-chapters__state .report-generating-hourglass__drip {
1367
+ stroke: currentColor;
1368
+ stroke-width: 0.6;
1369
+ paint-order: stroke fill;
1370
+ }
1371
+
1372
+ .report-generating-chapters__name {
1373
+ flex: 1;
1374
+ min-width: 0;
1375
+ overflow: hidden;
1376
+ display: flex;
1377
+ align-items: center;
1378
+ }
1379
+
1380
+ .report-generating-chapters__index {
1381
+ flex-shrink: 0;
1382
+ height: 40px;
1383
+ line-height: 40px;
1384
+ margin-right: 6px;
1385
+ font-family: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
1386
+ font-size: 15px;
1387
+ font-weight: 500;
1388
+ color: #303133;
1389
+ white-space: nowrap;
1390
+ }
1391
+
1392
+ .report-generating-chapters__name :deep(.chapter-title-scroll) {
1393
+ display: flex;
1394
+ align-items: center;
1395
+ flex: 1;
1396
+ min-width: 0;
1397
+ width: auto;
1398
+ max-width: 100%;
1399
+ height: 40px;
1400
+ line-height: 40px;
1401
+ font-family: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
1402
+ font-size: 15px;
1403
+ font-weight: 500;
1404
+ color: #303133;
1405
+ }
1406
+
1407
+ .report-generating-chapters__name :deep(.chapter-title-scroll.is-char-truncated) {
1408
+ flex: 0 1 auto;
1409
+ width: max-content;
1410
+ max-width: 100%;
1411
+ }
1412
+
1413
+ .report-generating-chapters__name :deep(.chapter-title-scroll__viewport) {
1414
+ display: block;
1415
+ flex: 1 1 auto;
1416
+ min-width: 0;
1417
+ height: 40px;
1418
+ line-height: 40px;
1419
+ overflow: hidden;
1420
+ font-family: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
1421
+ font-size: 15px;
1422
+ font-weight: 500;
1423
+ color: #303133;
1424
+ }
1425
+
1426
+ /* 19 字前綴完整可見,不被 overflow / ... 裁切遮擋 */
1427
+ .report-generating-chapters__name
1428
+ :deep(.chapter-title-scroll.is-char-truncated .chapter-title-scroll__viewport) {
1429
+ flex: 0 0 auto;
1430
+ min-width: auto;
1431
+ width: max-content;
1432
+ max-width: none;
1433
+ overflow: visible;
1434
+ }
1435
+
1436
+ .report-generating-chapters__name :deep(.chapter-title-scroll__text) {
1437
+ font-family: inherit;
1438
+ font-size: inherit;
1439
+ font-weight: 500;
1440
+ line-height: inherit;
1441
+ color: #303133;
1442
+ }
1443
+
1444
+ .report-generating-chapters__name :deep(.chapter-title-scroll.is-truncatable) {
1445
+ cursor: pointer;
1446
+ }
1447
+
1448
+ .report-generating-chapters__name :deep(.chapter-title-scroll.is-truncatable.is-dragging) {
1449
+ cursor: grabbing;
1450
+ }
1451
+
1452
+ .report-generating-chapters__name :deep(.chapter-title-scroll__ellipsis) {
1453
+ position: static;
1454
+ flex: 0 0 auto;
1455
+ flex-shrink: 0;
1456
+ display: inline-flex;
1457
+ align-items: center;
1458
+ height: 1em;
1459
+ margin-left: 2px;
1460
+ padding: 0;
1461
+ background: none;
1462
+ color: #c53355;
1463
+ font-size: 15px;
1464
+ font-weight: 700;
1465
+ line-height: 1;
1466
+ white-space: nowrap;
1467
+ }
1468
+
1469
+ .report-generating-chapters__icon {
1470
+ --report-generating-icon-color: rgb(31, 199, 255);
1471
+ font-size: 15px;
1472
+ width: 15px;
1473
+ height: 15px;
1474
+ color: var(--report-generating-icon-color);
1475
+ }
1476
+
1477
+ .report-generating-chapters__icon.is-success {
1478
+ color: var(--report-generating-icon-color);
1479
+ }
1480
+
1481
+ .report-generating-chapters__icon.is-failed {
1482
+ color: #c53355;
1483
+ }
1484
+
1485
+ .report-generating-chapters__icon.is-loading {
1486
+ color: var(--report-generating-icon-color);
1487
+ animation: report-generating-spin 1s linear infinite;
1488
+ -webkit-mask-image: conic-gradient(from 0deg, transparent 8%, rgba(0, 0, 0, 0.25) 35%, #000 78%);
1489
+ mask-image: conic-gradient(from 0deg, transparent 8%, rgba(0, 0, 0, 0.25) 35%, #000 78%);
1490
+ }
1491
+
1492
+ .report-generating-chapters__icon.is-pending,
1493
+ .report-generating-hourglass {
1494
+ display: inline-flex;
1495
+ align-items: center;
1496
+ justify-content: center;
1497
+ width: 15px;
1498
+ height: 15px;
1499
+ color: var(--report-generating-icon-color, rgb(31, 199, 255));
1500
+ }
1501
+
1502
+ .report-generating-hourglass__svg {
1503
+ width: 100%;
1504
+ height: 100%;
1505
+ overflow: visible;
1506
+ animation: report-generating-hourglass-flip 2.8s ease-in-out infinite;
1507
+ }
1508
+
1509
+ .report-generating-hourglass__sand-top {
1510
+ transform-box: fill-box;
1511
+ transform-origin: center top;
1512
+ animation: report-generating-sand-top 2.8s ease-in-out infinite;
1513
+ }
1514
+
1515
+ .report-generating-hourglass__sand-bottom {
1516
+ transform-box: fill-box;
1517
+ transform-origin: center bottom;
1518
+ animation: report-generating-sand-bottom 2.8s ease-in-out infinite;
1519
+ }
1520
+
1521
+ .report-generating-hourglass__drip {
1522
+ opacity: 0;
1523
+ animation: report-generating-sand-drip 1.1s linear infinite;
1524
+ }
1525
+
1526
+ .report-generating-hourglass__drip--2 {
1527
+ animation-delay: 0.35s;
1528
+ }
1529
+
1530
+ .report-generating-hourglass__drip--3 {
1531
+ animation-delay: 0.7s;
1532
+ }
1533
+
1534
+ @keyframes report-generating-hourglass-flip {
1535
+ 0%,
1536
+ 42% {
1537
+ transform: rotate(0deg);
1538
+ }
1539
+
1540
+ 50%,
1541
+ 92% {
1542
+ transform: rotate(180deg);
1543
+ }
1544
+
1545
+ 100% {
1546
+ transform: rotate(360deg);
1547
+ }
1548
+ }
1549
+
1550
+ @keyframes report-generating-sand-top {
1551
+ 0%,
1552
+ 8% {
1553
+ transform: scaleY(1);
1554
+ opacity: 0.9;
1555
+ }
1556
+
1557
+ 40%,
1558
+ 50% {
1559
+ transform: scaleY(0.12);
1560
+ opacity: 0.35;
1561
+ }
1562
+
1563
+ 58%,
1564
+ 100% {
1565
+ transform: scaleY(1);
1566
+ opacity: 0.9;
1567
+ }
1568
+ }
1569
+
1570
+ @keyframes report-generating-sand-bottom {
1571
+ 0%,
1572
+ 8% {
1573
+ transform: scaleY(0.12);
1574
+ opacity: 0.35;
1575
+ }
1576
+
1577
+ 40%,
1578
+ 50% {
1579
+ transform: scaleY(1);
1580
+ opacity: 0.9;
1581
+ }
1582
+
1583
+ 58%,
1584
+ 100% {
1585
+ transform: scaleY(0.12);
1586
+ opacity: 0.35;
1587
+ }
1588
+ }
1589
+
1590
+ @keyframes report-generating-sand-drip {
1591
+ 0% {
1592
+ transform: translateY(0);
1593
+ opacity: 0;
1594
+ }
1595
+
1596
+ 12% {
1597
+ opacity: 0.95;
1598
+ }
1599
+
1600
+ 88% {
1601
+ opacity: 0.85;
1602
+ }
1603
+
1604
+ 100% {
1605
+ transform: translateY(6px);
1606
+ opacity: 0;
1607
+ }
1608
+ }
1609
+
1610
+ .report-generating-chapters__state.is-creating .report-generating-chapters__icon.is-loading {
1611
+ color: var(--report-generating-icon-color, rgb(31, 199, 255));
1612
+ }
1613
+
1614
+ .report-generating-page__footer {
1615
+ flex-shrink: 0;
1616
+ display: flex;
1617
+ flex-direction: column;
1618
+ align-items: center;
1619
+ justify-content: center;
1620
+ gap: 20px;
1621
+ width: 100%;
1622
+ margin-top: auto;
1623
+ padding: 32px 0 40px;
1624
+ background: transparent;
1625
+ }
1626
+
1627
+ .report-generating-page__footer-actions {
1628
+ display: flex;
1629
+ align-items: center;
1630
+ justify-content: center;
1631
+ gap: 16px;
1632
+ flex-wrap: wrap;
1633
+ }
1634
+
1635
+ .report-generating-page__summary {
1636
+ flex-shrink: 0;
1637
+ margin: 0;
1638
+ font-size: 16px;
1639
+ font-weight: 600;
1640
+ color: #303133;
1641
+ text-align: center;
1642
+ }
1643
+
1644
+ @keyframes report-generating-spin {
1645
+ from {
1646
+ transform: rotate(0deg);
1647
+ }
1648
+
1649
+ to {
1650
+ transform: rotate(360deg);
1651
+ }
1652
+ }
1653
+
1654
+ .app-btn {
1655
+ display: inline-flex;
1656
+ align-items: center;
1657
+ justify-content: center;
1658
+ box-sizing: border-box;
1659
+ width: auto;
1660
+ min-width: 160px;
1661
+ height: 39px;
1662
+ padding: 0 20px;
1663
+ border: 1px solid #c53355;
1664
+ border-radius: 4px;
1665
+ background: #c53355;
1666
+ color: #fff;
1667
+ font-size: 16px;
1668
+ font-weight: 600;
1669
+ line-height: 1;
1670
+ white-space: nowrap;
1671
+ cursor: pointer;
1672
+ transition: all 0.2s ease;
1673
+ }
1674
+
1675
+ .app-btn:hover:not(:disabled) {
1676
+ background: #fff;
1677
+ color: #c53355;
1678
+ }
1679
+
1680
+ .app-btn:disabled {
1681
+ opacity: 0.45;
1682
+ cursor: not-allowed;
1683
+ }
1684
+
1685
+ .app-btn--wide {
1686
+ width: auto;
1687
+ min-width: 160px;
1688
+ }
1689
+
1690
+ .app-btn--outline {
1691
+ background: #fff;
1692
+ color: #c53355;
1693
+ }
1694
+
1695
+ .app-btn--outline:hover:not(:disabled) {
1696
+ background: #c53355;
1697
+ color: #fff;
1698
+ }
1699
+
1700
+ @media (max-width: 1023px) {
1701
+ .report-generating-page__layout {
1702
+ display: flex;
1703
+ flex-direction: column;
1704
+ align-items: center;
1705
+ justify-content: flex-start;
1706
+ gap: clamp(16px, 3vw, 24px);
1707
+ width: 100%;
1708
+ max-width: min(100%, var(--report-generating-video-slot-width));
1709
+ margin-left: auto;
1710
+ margin-right: auto;
1711
+ padding: 0 clamp(16px, 4vw, 24px);
1712
+ overflow: hidden;
1713
+ }
1714
+
1715
+ /* 視頻在上、章節在下 */
1716
+ .report-generating-page__video {
1717
+ position: relative;
1718
+ top: auto;
1719
+ order: -1;
1720
+ width: 100%;
1721
+ max-width: min(100%, var(--report-generating-video-slot-width));
1722
+ margin-top: 0;
1723
+ align-self: center;
1724
+ justify-self: center;
1725
+ z-index: 1;
1726
+ }
1727
+
1728
+ .report-generating-page__video-slot {
1729
+ width: 100%;
1730
+ max-width: 100%;
1731
+ height: auto;
1732
+ aspect-ratio: var(--report-generating-video-aspect);
1733
+ margin-left: auto;
1734
+ margin-right: auto;
1735
+ }
1736
+
1737
+ .report-generating-page__main {
1738
+ order: 1;
1739
+ width: 100%;
1740
+ max-width: min(100%, var(--report-generating-video-slot-width));
1741
+ margin-top: 0;
1742
+ align-self: center;
1743
+ flex: 1 1 auto;
1744
+ min-height: 0;
1745
+ }
1746
+
1747
+ .report-generating-page__hero-title {
1748
+ font-size: clamp(22px, 4.2vw, var(--gen-ai-fs-hero));
1749
+ }
1750
+
1751
+ .report-generating-chapters__row {
1752
+ font-size: clamp(13px, 2.2vw, 15px);
1753
+ }
1754
+
1755
+ .report-generating-page__footer {
1756
+ justify-content: center;
1757
+ align-items: center;
1758
+ padding-bottom: 24px;
1759
+ }
1760
+
1761
+ .report-generating-page__footer-actions {
1762
+ justify-content: center;
1763
+ }
1764
+ }
1765
+ </style>