aegon-lv97 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gen-ai/build-report/chartStreamRefs.ts +227 -0
- package/gen-ai/build-report/coherence-check/index.vue +559 -0
- package/gen-ai/build-report/components/ReportChapterWorkspace.vue +4571 -0
- package/gen-ai/build-report/components/ReportConfigPanel.vue +727 -0
- package/gen-ai/build-report/components/ReportGenerateSettingsPanel.vue +2361 -0
- package/gen-ai/build-report/components/ReportGeneratingPanel.vue +1765 -0
- package/gen-ai/build-report/components/ReportPromptPanel.vue +625 -0
- package/gen-ai/build-report/components/ReportSourceFileSummary.vue +665 -0
- package/gen-ai/build-report/components/ReportSourcesPanel.vue +1327 -0
- package/gen-ai/build-report/components/ReportTemplateDetail.vue +191 -0
- package/gen-ai/build-report/components/ReportTemplateEditorFormPanel.vue +667 -0
- package/gen-ai/build-report/components/ReportTemplatePicker.vue +302 -0
- package/gen-ai/build-report/components/ReportWorkflowSidebar.vue +148 -0
- package/gen-ai/build-report/components/paramsSetting.vue +886 -0
- package/gen-ai/build-report/components/reportEdit.vue +2283 -0
- package/gen-ai/build-report/index.vue +856 -0
- package/gen-ai/build-report/report-domain.ts +1133 -0
- package/gen-ai/build-report/report-template-data.ts +295 -0
- package/gen-ai/build-report/useReportWorkflow.ts +1386 -0
- package/gen-ai/common/assets/NotoSans-SC.woff2 +0 -0
- package/gen-ai/common/assets/NotoSans-TC.woff2 +0 -0
- package/gen-ai/common/assets/NotoSans.woff2 +0 -0
- package/gen-ai/common/assets/fonts.css +278 -0
- package/gen-ai/common/assets/pagination.css +163 -0
- package/gen-ai/common/directives/btnLoading.ts +455 -0
- package/gen-ai/common/directives/debounce.ts +98 -0
- package/gen-ai/common/directives/index.ts +30 -0
- package/gen-ai/common/en.md +417 -0
- package/gen-ai/common/index.ts +36 -0
- package/gen-ai/common/types/component-expose.ts +9 -0
- package/gen-ai/common/utils/api-res-status.ts +7 -0
- package/gen-ai/common/utils/navigation.ts +59 -0
- package/gen-ai/common/utils/remote-request.ts +45 -0
- package/gen-ai/common/utils/scroll.ts +577 -0
- package/gen-ai/components/AIExport/docx-export.ts +950 -0
- package/gen-ai/components/AIExport/docxExport.ts +2566 -0
- package/gen-ai/components/AIExport/index.ts +52 -0
- package/gen-ai/components/AIExport/parseMarkdownHtml.ts +653 -0
- package/gen-ai/components/CapsuleScrollbar.vue +149 -0
- package/gen-ai/components/ChapterTitleScroll.vue +468 -0
- package/gen-ai/components/EntryHeroNavCards.vue +379 -0
- package/gen-ai/components/GenAiPagination.vue +52 -0
- package/gen-ai/components/ModelCard.vue +1114 -0
- package/gen-ai/components/Preview/FilePreviewPage.vue +949 -0
- package/gen-ai/components/Preview/PreviewCapsuleScroll.vue +205 -0
- package/gen-ai/components/Preview/PreviewOutlineTree.vue +148 -0
- package/gen-ai/components/Preview/index.ts +11 -0
- package/gen-ai/components/Preview/preview.ts +724 -0
- package/gen-ai/components/PromptField.vue +505 -0
- package/gen-ai/components/RecentReportsTable.vue +974 -0
- package/gen-ai/components/ReportCollapsibleSection.vue +172 -0
- package/gen-ai/components/ReportHistoryList.vue +637 -0
- package/gen-ai/components/ReportSectionConfirmPopover.vue +251 -0
- package/gen-ai/components/editor/ChapterTiptapEditor.vue +3718 -0
- package/gen-ai/components/editor/StructureBlockView.vue +441 -0
- package/gen-ai/components/editor/TiptapToolbar.vue +1036 -0
- package/gen-ai/components/editor/TypewriterDocEditor.vue +422 -0
- package/gen-ai/components/editor/_StructureOrgNode.vue +326 -0
- package/gen-ai/components/editor/index.ts +28 -0
- package/gen-ai/components/editor/parseMarkdownHtml.ts +754 -0
- package/gen-ai/components/editor/reviewCitationTip.ts +163 -0
- package/gen-ai/gen-entry/api/index.ts +32 -0
- package/gen-ai/gen-entry/index.vue +287 -0
- package/gen-ai/gen-entry/types/index.ts +4 -0
- package/gen-ai/i.mark +2 -0
- package/gen-ai/management-center/index.vue +1815 -0
- package/gen-ai/management-center/mockData.ts +248 -0
- package/gen-ai/management-center/types/index.ts +37 -0
- package/gen-ai/model-manager/TemplateEditorFormPanel.vue +661 -0
- package/gen-ai/model-manager/api/index.ts +32 -0
- package/gen-ai/model-manager/api/mockApi.ts +33 -0
- package/gen-ai/model-manager/index.vue +1583 -0
- package/gen-ai/model-manager/mockData.ts +177 -0
- package/gen-ai/model-manager/template-detail-shared.ts +154 -0
- package/gen-ai/model-manager/template-editor.vue +345 -0
- package/gen-ai/model-manager/types/index.ts +40 -0
- package/gen-ai/model-manager/utils.ts +25 -0
- package/package.json +12 -0
|
@@ -0,0 +1,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
|
+
}
|