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,577 @@
1
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
2
+
3
+ export const CAPSULE_SCROLL_CONTAINER_CLASS = 'capsule-scroll-container'
4
+
5
+ export interface CapsuleScrollbarTheme {
6
+ thumbColor?: string
7
+ thumbHoverColor?: string
8
+ thumbActiveColor?: string
9
+ trackColor?: string
10
+ thumbWidthPx?: number
11
+ classPrefix?: string
12
+ }
13
+
14
+ export interface CapsuleScrollbarOptions extends CapsuleScrollbarTheme {
15
+ scrollEl: { value: HTMLElement | null }
16
+ anchorEl?: { value: HTMLElement | null }
17
+ active: { value: boolean }
18
+ gutterPx?: number
19
+ thumbWidthPx?: number
20
+ minThumbPx?: number
21
+ visibleRatio?: number
22
+ alwaysVisible?: boolean
23
+ autoHideAfterMs?: number
24
+ rightOffsetPx?: number
25
+ }
26
+
27
+ export interface CapsuleScrollbarBindings {
28
+ canScroll: { value: boolean }
29
+ shown: { value: boolean }
30
+ visible: { value: boolean }
31
+ rootStyle: { value: { [key: string]: string } }
32
+ thumbStyle: { value: { [key: string]: string } }
33
+ rootClass: string
34
+ thumbClass: string
35
+ onThumbMouseDown: (event: MouseEvent) => void
36
+ refresh: () => void
37
+ bind: () => void
38
+ unbind: () => void
39
+ }
40
+
41
+ const DEFAULT_CLASS_PREFIX = 'capsule-scrollbar'
42
+
43
+ interface NativeHideSnapshot {
44
+ paddingRight: string
45
+ marginRight: string
46
+ width: string
47
+ boxSizing: string
48
+ scrollbarWidth: string
49
+ msOverflowStyle: string
50
+ }
51
+
52
+ export function markCapsuleScrollContainer(el: HTMLElement): () => void {
53
+ const computed = getComputedStyle(el)
54
+ const snapshot: NativeHideSnapshot = {
55
+ paddingRight: el.style.paddingRight,
56
+ marginRight: el.style.marginRight,
57
+ width: el.style.width,
58
+ boxSizing: el.style.boxSizing,
59
+ scrollbarWidth: el.style.scrollbarWidth,
60
+ msOverflowStyle:
61
+ (el.style as CSSStyleDeclaration & { msOverflowStyle?: string }).msOverflowStyle ?? '',
62
+ }
63
+
64
+ el.style.setProperty('--capsule-scroll-padding-right', computed.paddingRight)
65
+ el.classList.add(CAPSULE_SCROLL_CONTAINER_CLASS)
66
+ el.style.scrollbarWidth = 'none'
67
+ ;(el.style as CSSStyleDeclaration & { msOverflowStyle?: string }).msOverflowStyle = 'none'
68
+
69
+ return () => {
70
+ el.classList.remove(CAPSULE_SCROLL_CONTAINER_CLASS)
71
+ el.style.removeProperty('--capsule-scroll-padding-right')
72
+ el.style.paddingRight = snapshot.paddingRight
73
+ el.style.marginRight = snapshot.marginRight
74
+ el.style.width = snapshot.width
75
+ el.style.boxSizing = snapshot.boxSizing
76
+ el.style.scrollbarWidth = snapshot.scrollbarWidth
77
+ ;(el.style as CSSStyleDeclaration & { msOverflowStyle?: string }).msOverflowStyle =
78
+ snapshot.msOverflowStyle
79
+ }
80
+ }
81
+
82
+ export function useCapsuleScrollbar(options: CapsuleScrollbarOptions): CapsuleScrollbarBindings {
83
+ const thumbWidthPx = options.thumbWidthPx ?? 10
84
+ const gutterPx = options.gutterPx ?? thumbWidthPx
85
+ const minThumbPx = options.minThumbPx ?? 48
86
+ const visibleRatio = options.visibleRatio ?? 1
87
+ const rightOffsetPx = options.rightOffsetPx ?? 1
88
+ const autoHideAfterMs = options.autoHideAfterMs ?? 3000
89
+ const alwaysVisible = options.alwaysVisible ?? autoHideAfterMs <= 0
90
+ const classPrefix = options.classPrefix ?? DEFAULT_CLASS_PREFIX
91
+
92
+ const canScroll = ref(false)
93
+ const shown = ref(false)
94
+ const zoneTop = ref(0)
95
+ const zoneRight = ref(rightOffsetPx)
96
+ const zoneHeight = ref(0)
97
+ const thumbHeight = ref(minThumbPx)
98
+ const thumbOffset = ref(0)
99
+
100
+ const visible = computed(() => canScroll.value && (alwaysVisible || shown.value))
101
+
102
+ let resizeObserver: ResizeObserver | undefined
103
+ let thumbDragCleanup: (() => void) | undefined
104
+ let removeContainerMark: (() => void) | undefined
105
+ let hideTimer: number | undefined
106
+ let rafId = 0
107
+
108
+ const rootStyle = computed(() => ({
109
+ top: `${zoneTop.value + thumbOffset.value}px`,
110
+ right: `${zoneRight.value}px`,
111
+ width: `${gutterPx}px`,
112
+ height: `${Math.round(thumbHeight.value)}px`,
113
+ }))
114
+
115
+ const thumbStyle = computed(() => ({
116
+ width: `${thumbWidthPx}px`,
117
+ height: '100%',
118
+ }))
119
+
120
+ function clearHideTimer() {
121
+ if (hideTimer !== undefined) {
122
+ clearTimeout(hideTimer)
123
+ hideTimer = undefined
124
+ }
125
+ }
126
+
127
+ function scheduleHide() {
128
+ clearHideTimer()
129
+ if (alwaysVisible || autoHideAfterMs <= 0) return
130
+ hideTimer = window.setTimeout(() => {
131
+ shown.value = false
132
+ hideTimer = undefined
133
+ }, autoHideAfterMs)
134
+ }
135
+
136
+ function revealBar() {
137
+ if (!canScroll.value) return
138
+ shown.value = true
139
+ scheduleHide()
140
+ }
141
+
142
+ function refreshLayout() {
143
+ const scrollEl = options.scrollEl.value
144
+ const anchorEl = options.anchorEl?.value ?? scrollEl
145
+ if (!options.active.value || !scrollEl || !anchorEl) {
146
+ canScroll.value = false
147
+ shown.value = false
148
+ return
149
+ }
150
+
151
+ const { scrollTop, scrollHeight, clientHeight } = scrollEl
152
+ const scrollable = scrollHeight > clientHeight + 1
153
+ canScroll.value = scrollable
154
+ if (!scrollable) {
155
+ shown.value = false
156
+ return
157
+ }
158
+
159
+ const anchorRect = anchorEl.getBoundingClientRect()
160
+ const shrinkInset = (anchorRect.height * (1 - visibleRatio)) / 2
161
+
162
+ zoneTop.value = Math.round(anchorRect.top + shrinkInset)
163
+ zoneRight.value = rightOffsetPx
164
+ zoneHeight.value = Math.round(anchorRect.height * visibleRatio)
165
+
166
+ const trackH = zoneHeight.value
167
+ thumbHeight.value = Math.max(minThumbPx, (clientHeight / scrollHeight) * trackH)
168
+ const maxThumbTravel = Math.max(0, trackH - thumbHeight.value)
169
+ const scrollMax = scrollHeight - clientHeight
170
+ thumbOffset.value = scrollMax > 0 ? Math.round((scrollTop / scrollMax) * maxThumbTravel) : 0
171
+ }
172
+
173
+ function scheduleRefresh() {
174
+ cancelAnimationFrame(rafId)
175
+ rafId = requestAnimationFrame(refreshLayout)
176
+ }
177
+
178
+ function onScroll() {
179
+ revealBar()
180
+ scheduleRefresh()
181
+ }
182
+
183
+ function bind() {
184
+ unbind()
185
+ const scrollEl = options.scrollEl.value
186
+ if (!scrollEl) return
187
+
188
+ removeContainerMark = markCapsuleScrollContainer(scrollEl)
189
+ refreshLayout()
190
+ scrollEl.addEventListener('scroll', onScroll, { passive: true })
191
+ resizeObserver = new ResizeObserver(() => scheduleRefresh())
192
+ resizeObserver.observe(scrollEl)
193
+ if (options.anchorEl?.value) resizeObserver.observe(options.anchorEl.value)
194
+ window.addEventListener('resize', scheduleRefresh, { passive: true })
195
+ }
196
+
197
+ function unbind() {
198
+ cancelAnimationFrame(rafId)
199
+ clearHideTimer()
200
+ window.removeEventListener('resize', scheduleRefresh)
201
+ resizeObserver?.disconnect()
202
+ resizeObserver = undefined
203
+ thumbDragCleanup?.()
204
+ thumbDragCleanup = undefined
205
+ options.scrollEl.value?.removeEventListener('scroll', onScroll)
206
+ removeContainerMark?.()
207
+ removeContainerMark = undefined
208
+ canScroll.value = false
209
+ shown.value = false
210
+ }
211
+
212
+ function onThumbMouseDown(event: MouseEvent) {
213
+ const scrollEl = options.scrollEl.value
214
+ if (!scrollEl) return
215
+
216
+ event.preventDefault()
217
+ revealBar()
218
+ clearHideTimer()
219
+
220
+ const startY = event.clientY
221
+ const startScrollTop = scrollEl.scrollTop
222
+ const trackH = zoneHeight.value
223
+ const scrollMax = scrollEl.scrollHeight - scrollEl.clientHeight
224
+ const maxThumbTravel = Math.max(0, trackH - thumbHeight.value)
225
+
226
+ const onMove = (ev: MouseEvent) => {
227
+ if (maxThumbTravel <= 0 || scrollMax <= 0) return
228
+ scrollEl.scrollTop = startScrollTop + ((ev.clientY - startY) / maxThumbTravel) * scrollMax
229
+ }
230
+
231
+ const onUp = () => {
232
+ document.removeEventListener('mousemove', onMove)
233
+ document.removeEventListener('mouseup', onUp)
234
+ thumbDragCleanup = undefined
235
+ scheduleHide()
236
+ }
237
+
238
+ document.addEventListener('mousemove', onMove)
239
+ document.addEventListener('mouseup', onUp)
240
+ thumbDragCleanup = onUp
241
+ }
242
+
243
+ watch(
244
+ () => options.active.value,
245
+ (active) => {
246
+ if (active) {
247
+ void nextTick(() => {
248
+ bind()
249
+ void nextTick(refreshLayout)
250
+ })
251
+ return
252
+ }
253
+ unbind()
254
+ },
255
+ { immediate: true }
256
+ )
257
+
258
+ onUnmounted(unbind)
259
+
260
+ return {
261
+ canScroll,
262
+ shown,
263
+ visible,
264
+ rootStyle,
265
+ thumbStyle,
266
+ rootClass: classPrefix,
267
+ thumbClass: `${classPrefix}__thumb`,
268
+ onThumbMouseDown,
269
+ refresh: refreshLayout,
270
+ bind,
271
+ unbind,
272
+ }
273
+ }
274
+
275
+ // --- 頁面滿屏滾動鎖定 ---
276
+
277
+ const LAYOUT_SCROLL_ROOT_SELECTOR = '.main-layout__main'
278
+
279
+ interface OverflowSnapshot {
280
+ el: HTMLElement
281
+ overflow: string
282
+ overflowX: string
283
+ overflowY: string
284
+ }
285
+
286
+ function snapshotOverflow(el: HTMLElement): OverflowSnapshot {
287
+ return {
288
+ el,
289
+ overflow: el.style.overflow,
290
+ overflowX: el.style.overflowX,
291
+ overflowY: el.style.overflowY,
292
+ }
293
+ }
294
+
295
+ function lockElementOverflow(el: HTMLElement) {
296
+ el.style.overflow = 'hidden'
297
+ el.style.overflowX = 'hidden'
298
+ el.style.overflowY = 'hidden'
299
+ }
300
+
301
+ function restoreElementOverflow(snapshot: OverflowSnapshot) {
302
+ const { el, overflow, overflowX, overflowY } = snapshot
303
+ el.style.overflow = overflow
304
+ el.style.overflowX = overflowX
305
+ el.style.overflowY = overflowY
306
+ }
307
+
308
+ /** 鎖定 document / 佈局主區滾動,返回 cleanup 恢復原 overflow(保留,新頁面請用 hiddenLayoutScroll) */
309
+ export function lockDocumentScroll(): () => void {
310
+ const roots = [
311
+ document.documentElement,
312
+ document.body,
313
+ ...Array.from(document.querySelectorAll<HTMLElement>(LAYOUT_SCROLL_ROOT_SELECTOR)),
314
+ ]
315
+
316
+ const snapshots = roots.map(snapshotOverflow)
317
+ snapshots.forEach(({ el }) => lockElementOverflow(el))
318
+
319
+ return () => {
320
+ snapshots.forEach(restoreElementOverflow)
321
+ }
322
+ }
323
+
324
+ // --- 隱藏 .layout 佈局滾動(進頁調用,離頁 cleanup)---
325
+ // useHiddenLayoutScroll / hiddenLayoutScroll 及配套
326
+
327
+ /** 默認佈局滾動容器:#app 下的 .layout */
328
+ export const LAYOUT_CONTAINER_SELECTOR = '.layout'
329
+
330
+ /** 鎖定時掛上的 class,resize 後仍靠樣式表固定 */
331
+ export const LAYOUT_SCROLL_HIDDEN_CLASS = 'layout--scroll-hidden'
332
+
333
+ const LAYOUT_SCROLL_HIDDEN_STYLE_ID = 'gen-ai-layout-scroll-hidden-style'
334
+
335
+ interface LayoutScrollSnapshot {
336
+ el: HTMLElement
337
+ height: string
338
+ maxHeight: string
339
+ overflow: string
340
+ overflowX: string
341
+ overflowY: string
342
+ }
343
+
344
+ let layoutScrollLockCount = 0
345
+ let layoutScrollSnapshots: LayoutScrollSnapshot[] | null = null
346
+ let layoutScrollActiveSelector = LAYOUT_CONTAINER_SELECTOR
347
+ let layoutScrollReapplyBound = false
348
+
349
+ function ensureLayoutScrollHiddenStyles() {
350
+ if (typeof document === 'undefined') return
351
+ if (document.getElementById(LAYOUT_SCROLL_HIDDEN_STYLE_ID)) return
352
+
353
+ const style = document.createElement('style')
354
+ style.id = LAYOUT_SCROLL_HIDDEN_STYLE_ID
355
+ style.textContent = `
356
+ .${LAYOUT_SCROLL_HIDDEN_CLASS},
357
+ .layout.${LAYOUT_SCROLL_HIDDEN_CLASS},
358
+ .main-layout__main.${LAYOUT_SCROLL_HIDDEN_CLASS},
359
+ .main-layout__main.layout.${LAYOUT_SCROLL_HIDDEN_CLASS} {
360
+ height: 100% !important;
361
+ max-height: 100% !important;
362
+ overflow: hidden !important;
363
+ overflow-x: hidden !important;
364
+ overflow-y: hidden !important;
365
+ }
366
+ `
367
+ document.head.appendChild(style)
368
+ }
369
+
370
+ function snapshotLayoutScroll(el: HTMLElement): LayoutScrollSnapshot {
371
+ return {
372
+ el,
373
+ height: el.style.height,
374
+ maxHeight: el.style.maxHeight,
375
+ overflow: el.style.overflow,
376
+ overflowX: el.style.overflowX,
377
+ overflowY: el.style.overflowY,
378
+ }
379
+ }
380
+
381
+ function applyHiddenLayoutScroll(el: HTMLElement) {
382
+ ensureLayoutScrollHiddenStyles()
383
+ el.classList.add(LAYOUT_SCROLL_HIDDEN_CLASS)
384
+ // inline + important:防止業務樣式 / resize 覆寫
385
+ el.style.setProperty('height', '100%', 'important')
386
+ el.style.setProperty('max-height', '100%', 'important')
387
+ el.style.setProperty('overflow', 'hidden', 'important')
388
+ el.style.setProperty('overflow-x', 'hidden', 'important')
389
+ el.style.setProperty('overflow-y', 'hidden', 'important')
390
+ }
391
+
392
+ function restoreLayoutScroll(snapshot: LayoutScrollSnapshot) {
393
+ const { el, height, maxHeight, overflow, overflowX, overflowY } = snapshot
394
+ if (!el.isConnected) return
395
+ el.classList.remove(LAYOUT_SCROLL_HIDDEN_CLASS)
396
+ el.style.removeProperty('height')
397
+ el.style.removeProperty('max-height')
398
+ el.style.removeProperty('overflow')
399
+ el.style.removeProperty('overflow-x')
400
+ el.style.removeProperty('overflow-y')
401
+ // 還原進頁前的 inline(若原本有)
402
+ if (height) el.style.height = height
403
+ if (maxHeight) el.style.maxHeight = maxHeight
404
+ if (overflow) el.style.overflow = overflow
405
+ if (overflowX) el.style.overflowX = overflowX
406
+ if (overflowY) el.style.overflowY = overflowY
407
+ }
408
+
409
+ function resolveLayoutScrollElements(selector: string): HTMLElement[] {
410
+ const fromSelector = Array.from(document.querySelectorAll<HTMLElement>(selector))
411
+ if (fromSelector.length) return fromSelector
412
+
413
+ // 兼容尚未掛 layout class 的主內容區
414
+ return Array.from(document.querySelectorAll<HTMLElement>(LAYOUT_SCROLL_ROOT_SELECTOR))
415
+ }
416
+
417
+ function reapplyHiddenLayoutScroll() {
418
+ if (layoutScrollLockCount <= 0) return
419
+ const elements = resolveLayoutScrollElements(layoutScrollActiveSelector)
420
+
421
+ // DOM 若被重建:先快照原樣再套鎖定,離頁才能正確恢復
422
+ if (layoutScrollSnapshots) {
423
+ const known = new Set(layoutScrollSnapshots.map((s) => s.el))
424
+ for (const el of elements) {
425
+ if (!known.has(el)) {
426
+ layoutScrollSnapshots.push(snapshotLayoutScroll(el))
427
+ }
428
+ }
429
+ }
430
+
431
+ elements.forEach(applyHiddenLayoutScroll)
432
+ }
433
+
434
+ function bindLayoutScrollReapply() {
435
+ if (layoutScrollReapplyBound) return
436
+ layoutScrollReapplyBound = true
437
+ window.addEventListener('resize', reapplyHiddenLayoutScroll, { passive: true })
438
+ window.visualViewport?.addEventListener('resize', reapplyHiddenLayoutScroll)
439
+ window.visualViewport?.addEventListener('scroll', reapplyHiddenLayoutScroll)
440
+ }
441
+
442
+ function unbindLayoutScrollReapply() {
443
+ if (!layoutScrollReapplyBound) return
444
+ layoutScrollReapplyBound = false
445
+ window.removeEventListener('resize', reapplyHiddenLayoutScroll)
446
+ window.visualViewport?.removeEventListener('resize', reapplyHiddenLayoutScroll)
447
+ window.visualViewport?.removeEventListener('scroll', reapplyHiddenLayoutScroll)
448
+ }
449
+
450
+ export interface HiddenLayoutScrollOptions {
451
+ /** 佈局容器選擇器,默認 `.layout` */
452
+ selector?: string
453
+ }
454
+
455
+ /**
456
+ * 進頁隱藏佈局滾動:`.layout { height: 100%; overflow: hidden }`
457
+ * 離頁調用返回的 cleanup 恢復原樣。支持多次嵌套調用(引用計數)。
458
+ * resize / 視口變化時會自動重套,保證鎖定一直有效。
459
+ */
460
+ export function hiddenLayoutScroll(options?: HiddenLayoutScrollOptions): () => void {
461
+ const selector = options?.selector ?? LAYOUT_CONTAINER_SELECTOR
462
+ layoutScrollActiveSelector = selector
463
+ const elements = resolveLayoutScrollElements(selector)
464
+
465
+ if (layoutScrollLockCount === 0) {
466
+ layoutScrollSnapshots = elements.map(snapshotLayoutScroll)
467
+ elements.forEach(applyHiddenLayoutScroll)
468
+ bindLayoutScrollReapply()
469
+ } else {
470
+ // 已鎖定時再調用:立即重套,避免漏網
471
+ elements.forEach(applyHiddenLayoutScroll)
472
+ }
473
+ layoutScrollLockCount += 1
474
+
475
+ // 下一幀再套一次,擋住 mount 後樣式回寫
476
+ requestAnimationFrame(() => reapplyHiddenLayoutScroll())
477
+
478
+ let released = false
479
+ return () => {
480
+ if (released) return
481
+ released = true
482
+ layoutScrollLockCount = Math.max(0, layoutScrollLockCount - 1)
483
+ if (layoutScrollLockCount === 0) {
484
+ unbindLayoutScrollReapply()
485
+ layoutScrollSnapshots?.forEach(restoreLayoutScroll)
486
+ layoutScrollSnapshots = null
487
+ }
488
+ }
489
+ }
490
+
491
+ /**
492
+ * 頁面 setup 內調用即可:自動 onMounted 鎖定 .layout、onUnmounted 恢復。
493
+ * 業務頁無需再手寫 cleanup;resize 期間鎖定保持有效。
494
+ */
495
+ export function useHiddenLayoutScroll(options?: HiddenLayoutScrollOptions): void {
496
+ let cleanup: (() => void) | undefined
497
+
498
+ onMounted(() => {
499
+ cleanup = hiddenLayoutScroll(options)
500
+ })
501
+
502
+ onUnmounted(() => {
503
+ cleanup?.()
504
+ cleanup = undefined
505
+ })
506
+ }
507
+
508
+ // --- 視口主內容區高度 ---
509
+
510
+ export interface ViewportBodyHeightOptions {
511
+ /** 從視口高度中扣除的固定像素 */
512
+ subtract?: number
513
+ /** 按選擇器測量需扣除的高度(如 PageHeader) */
514
+ subtractSelector?: string
515
+ /** 測量 subtractSelector 時的父級容器,默認取 element 的 offsetParent 或 document */
516
+ subtractRoot?: HTMLElement
517
+ /** 是否監聽 resize,默認 true */
518
+ listenResize?: boolean
519
+ }
520
+
521
+ /** 當前視口高度(px) */
522
+ export function getViewportHeight(): number {
523
+ return window.innerHeight
524
+ }
525
+
526
+ function resolveSubtract(el: HTMLElement, options?: ViewportBodyHeightOptions): number {
527
+ if (options?.subtract !== undefined) return options.subtract
528
+
529
+ if (options?.subtractSelector) {
530
+ const root = options.subtractRoot ?? el.parentElement ?? document.body
531
+ const node = root.querySelector(options.subtractSelector) as HTMLElement | null
532
+ if (node) return node.getBoundingClientRect().height
533
+ }
534
+
535
+ return 0
536
+ }
537
+
538
+ /** 計算主內容區可用高度(視口高度 − 扣除項) */
539
+ export function getViewportBodyHeight(
540
+ el: HTMLElement,
541
+ options?: ViewportBodyHeightOptions
542
+ ): number {
543
+ const subtract = resolveSubtract(el, options)
544
+ return Math.max(0, getViewportHeight() - subtract)
545
+ }
546
+
547
+ /**
548
+ * 將元素高度綁定為當前視口可用高度;返回 cleanup(移除監聽並清空 inline height)
549
+ */
550
+ export function bindViewportBodyHeight(
551
+ el: HTMLElement,
552
+ options?: ViewportBodyHeightOptions
553
+ ): () => void {
554
+ const listenResize = options?.listenResize ?? true
555
+
556
+ const update = () => {
557
+ const height = getViewportBodyHeight(el, options)
558
+ el.style.height = `${height}px`
559
+ el.style.maxHeight = `${height}px`
560
+ }
561
+
562
+ update()
563
+
564
+ if (!listenResize) {
565
+ return () => {
566
+ el.style.height = ''
567
+ el.style.maxHeight = ''
568
+ }
569
+ }
570
+
571
+ window.addEventListener('resize', update)
572
+ return () => {
573
+ window.removeEventListener('resize', update)
574
+ el.style.height = ''
575
+ el.style.maxHeight = ''
576
+ }
577
+ }