@h-ai/ui 0.1.0-alpha.60 → 0.1.0-alpha.62
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/README.en.md +62 -1
- package/README.ja.md +62 -1
- package/README.md +63 -1
- package/auto-import.d.ts +2 -0
- package/auto-import.js +2 -0
- package/dist/components/scenes/guide/GuideHost.svelte +111 -0
- package/dist/components/scenes/guide/GuideHost.svelte.d.ts +16 -0
- package/dist/components/scenes/guide/HelpCenter.svelte +94 -0
- package/dist/components/scenes/guide/HelpCenter.svelte.d.ts +13 -0
- package/dist/components/scenes/guide/index.d.ts +2 -0
- package/dist/components/scenes/guide/index.js +2 -0
- package/dist/components/scenes/iam/AuthPanel.svelte +45 -38
- package/dist/components/scenes/iam/auth-error-message.d.ts +2 -4
- package/dist/components/scenes/index.d.ts +1 -0
- package/dist/components/scenes/index.js +1 -0
- package/dist/guide-storage.d.ts +6 -0
- package/dist/guide-storage.js +67 -0
- package/dist/guide-types.d.ts +78 -0
- package/dist/guide-types.js +1 -0
- package/dist/guide.d.ts +3 -0
- package/dist/guide.js +3 -0
- package/dist/guide.svelte.d.ts +8 -0
- package/dist/guide.svelte.js +450 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/messages/en-US.json +18 -1
- package/dist/messages/zh-CN.json +18 -1
- package/dist/messages.d.ts +2 -1
- package/dist/messages.js +3 -2
- package/dist/styles/guide.css +88 -0
- package/package.json +8 -2
- package/vite.js +1 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { GuideOptions, GuideProgress } from './guide-types.js';
|
|
2
|
+
/** 存储受限时使用会话内存;只保存非敏感的引导偏好。 */
|
|
3
|
+
export declare function createGuideStorage(options: GuideOptions): {
|
|
4
|
+
get(key: string): GuideProgress | null;
|
|
5
|
+
set(key: string, progress: GuideProgress | null): void;
|
|
6
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readStoredValue, writeStoredValue } from './internal/browser-safety.js';
|
|
2
|
+
/** 存储受限时使用会话内存;只保存非敏感的引导偏好。 */
|
|
3
|
+
export function createGuideStorage(options) {
|
|
4
|
+
const memory = new Map();
|
|
5
|
+
const persistence = options.persistence ?? 'cookie';
|
|
6
|
+
function read(key) {
|
|
7
|
+
if (memory.has(key))
|
|
8
|
+
return memory.get(key) ?? null;
|
|
9
|
+
try {
|
|
10
|
+
if (persistence === 'localStorage')
|
|
11
|
+
return readStoredValue(key) ?? memory.get(key) ?? null;
|
|
12
|
+
if (persistence === 'cookie' && typeof document !== 'undefined') {
|
|
13
|
+
const prefix = `${encodeURIComponent(key)}=`;
|
|
14
|
+
const value = document.cookie.split('; ').find(item => item.startsWith(prefix));
|
|
15
|
+
return value ? decodeURIComponent(value.slice(prefix.length)) : memory.get(key) ?? null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// Cookie 禁用或内容损坏时不影响正常使用。
|
|
20
|
+
}
|
|
21
|
+
return memory.get(key) ?? null;
|
|
22
|
+
}
|
|
23
|
+
function write(key, value) {
|
|
24
|
+
memory.set(key, value);
|
|
25
|
+
try {
|
|
26
|
+
if (persistence === 'localStorage') {
|
|
27
|
+
if (value === null) {
|
|
28
|
+
if (typeof localStorage !== 'undefined')
|
|
29
|
+
localStorage.removeItem(key);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
writeStoredValue(key, value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else if (persistence === 'cookie' && typeof document !== 'undefined') {
|
|
36
|
+
const secure = globalThis.location?.protocol === 'https:' ? '; Secure' : '';
|
|
37
|
+
document.cookie = `${encodeURIComponent(key)}=${encodeURIComponent(value ?? '')}; Path=/; SameSite=Lax; Max-Age=${value === null ? '0' : '31536000'}${secure}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// WebView 或隐私模式下保留内存态。
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
get(key) {
|
|
46
|
+
try {
|
|
47
|
+
const raw = read(key);
|
|
48
|
+
if (!raw)
|
|
49
|
+
return null;
|
|
50
|
+
const value = JSON.parse(raw);
|
|
51
|
+
if (value && typeof value === 'object'
|
|
52
|
+
&& 'version' in value && typeof value.version === 'number' && Number.isFinite(value.version)
|
|
53
|
+
&& 'updatedAt' in value && typeof value.updatedAt === 'number' && Number.isFinite(value.updatedAt)
|
|
54
|
+
&& 'status' in value && (value.status === 'completed' || value.status === 'dismissed' || value.status === 'deferred')) {
|
|
55
|
+
return { version: value.version, updatedAt: value.updatedAt, status: value.status };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// 损坏的偏好作为未看过处理。
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
},
|
|
63
|
+
set(key, progress) {
|
|
64
|
+
write(key, progress ? JSON.stringify(progress) : null);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { NavAdapter } from './components/scenes/crud/nav-adapter.js';
|
|
2
|
+
/** 业务文案使用函数延迟求值,切换语言时重新读取应用翻译。 */
|
|
3
|
+
export type GuideText = string | (() => string);
|
|
4
|
+
export interface GuideStep {
|
|
5
|
+
/** data-guide 的值;复杂定位可传入返回元素的函数。 */
|
|
6
|
+
target: string | (() => Element | null);
|
|
7
|
+
title: GuideText;
|
|
8
|
+
description: GuideText;
|
|
9
|
+
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
10
|
+
/** 只准备页面交互状态,例如展开页签;可通过 signal 取消异步等待。 */
|
|
11
|
+
before?: (signal: AbortSignal) => void | Promise<void>;
|
|
12
|
+
/** 可选元素缺失时跳过,必要元素缺失时停止并显示提示。 */
|
|
13
|
+
optional?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface GuideTour {
|
|
16
|
+
id: string;
|
|
17
|
+
title: GuideText;
|
|
18
|
+
description?: GuideText;
|
|
19
|
+
version?: number;
|
|
20
|
+
/** 目标页面路径;导航由宿主的 NavAdapter 提供。 */
|
|
21
|
+
route?: string;
|
|
22
|
+
steps: readonly GuideStep[];
|
|
23
|
+
/** 按当前权限或功能开关决定是否提供此导览。 */
|
|
24
|
+
enabled?: () => boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface GuideArticle {
|
|
27
|
+
id: string;
|
|
28
|
+
title: GuideText;
|
|
29
|
+
description?: GuideText;
|
|
30
|
+
category?: GuideText;
|
|
31
|
+
/** Markdown 正文;原始 HTML 默认关闭。 */
|
|
32
|
+
content: GuideText;
|
|
33
|
+
tourId?: string;
|
|
34
|
+
enabled?: () => boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface GuideProgress {
|
|
37
|
+
version: number;
|
|
38
|
+
status: 'completed' | 'dismissed' | 'deferred';
|
|
39
|
+
updatedAt: number;
|
|
40
|
+
}
|
|
41
|
+
export interface GuideOptions {
|
|
42
|
+
tours: readonly GuideTour[];
|
|
43
|
+
articles?: readonly GuideArticle[];
|
|
44
|
+
persistence?: 'cookie' | 'localStorage' | 'memory';
|
|
45
|
+
/** 同源多个应用应使用不同前缀。 */
|
|
46
|
+
persistKey?: string;
|
|
47
|
+
nav?: NavAdapter;
|
|
48
|
+
/** 每个目标元素的等待上限,默认 3000 毫秒。 */
|
|
49
|
+
waitTimeout?: number;
|
|
50
|
+
}
|
|
51
|
+
/** 浏览器 UI 控制器,操作失败返回 false 并设置 error,不抛业务异常。 */
|
|
52
|
+
export interface GuideController {
|
|
53
|
+
readonly tours: readonly GuideTour[];
|
|
54
|
+
readonly articles: readonly GuideArticle[];
|
|
55
|
+
readonly offeredTour: GuideTour | undefined;
|
|
56
|
+
readonly activeTourId: string | null;
|
|
57
|
+
readonly busy: boolean;
|
|
58
|
+
readonly error: string;
|
|
59
|
+
readonly locale: string;
|
|
60
|
+
readonly pathname: string;
|
|
61
|
+
readonly helpOpen: boolean;
|
|
62
|
+
readonly articleId: string | null;
|
|
63
|
+
start: (id: string) => Promise<boolean>;
|
|
64
|
+
startIfNeeded: (id: string) => boolean;
|
|
65
|
+
defer: () => void;
|
|
66
|
+
dismiss: () => void;
|
|
67
|
+
stop: () => void;
|
|
68
|
+
reset: (id: string) => void;
|
|
69
|
+
getProgress: (id: string) => GuideProgress | null;
|
|
70
|
+
openHelp: (articleId?: string) => void;
|
|
71
|
+
closeHelp: () => void;
|
|
72
|
+
clearError: () => void;
|
|
73
|
+
/** GuideHost 负责同步以下宿主状态,应用通常不需要手动调用。 */
|
|
74
|
+
setLocale: (locale: string) => void;
|
|
75
|
+
setScope: (scope: string) => void;
|
|
76
|
+
handleNavigation: (pathname: string) => void;
|
|
77
|
+
resume: () => Promise<boolean>;
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/guide.d.ts
ADDED
package/dist/guide.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GuideController, GuideOptions, GuideText } from './guide-types.js';
|
|
2
|
+
/** 解析业务文案;既支持普通字符串,也支持应用 i18n 函数。 */
|
|
3
|
+
export declare function resolveGuideText(text?: GuideText): string;
|
|
4
|
+
/**
|
|
5
|
+
* 创建应用独享的新手引导控制器。SSR 可创建,DOM 操作只在客户端启动时发生。
|
|
6
|
+
* 配置和控制器应在布局组件内创建,避免在 SSR 模块级共享用户状态。
|
|
7
|
+
*/
|
|
8
|
+
export declare function createGuide(options: GuideOptions): GuideController;
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { tick } from 'svelte';
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
3
|
+
import { createBrowserNavAdapter } from './components/scenes/crud/nav-adapter.js';
|
|
4
|
+
import { createGuideStorage } from './guide-storage.js';
|
|
5
|
+
import { getUILocale, uiM } from './messages.js';
|
|
6
|
+
/** 同一浏览器页面只允许一个导览拥有遮罩和键盘监听。 */
|
|
7
|
+
let stopActiveGuide;
|
|
8
|
+
/** 解析业务文案;既支持普通字符串,也支持应用 i18n 函数。 */
|
|
9
|
+
export function resolveGuideText(text) {
|
|
10
|
+
return typeof text === 'function' ? text() : text ?? '';
|
|
11
|
+
}
|
|
12
|
+
function escapeGuideText(text) {
|
|
13
|
+
return resolveGuideText(text).replace(/[&<>"']/g, character => ({
|
|
14
|
+
'&': '&',
|
|
15
|
+
'<': '<',
|
|
16
|
+
'>': '>',
|
|
17
|
+
'"': '"',
|
|
18
|
+
'\'': ''',
|
|
19
|
+
})[character]);
|
|
20
|
+
}
|
|
21
|
+
function findTarget(step) {
|
|
22
|
+
const element = typeof step.target === 'function'
|
|
23
|
+
? step.target()
|
|
24
|
+
: [...document.querySelectorAll('[data-guide]')].find(item => item.getAttribute('data-guide') === step.target);
|
|
25
|
+
if (!element?.isConnected || !element.getClientRects().length || element.closest('[hidden], [aria-hidden="true"]'))
|
|
26
|
+
return null;
|
|
27
|
+
const style = getComputedStyle(element);
|
|
28
|
+
return style.visibility === 'hidden' || style.display === 'none' ? null : element;
|
|
29
|
+
}
|
|
30
|
+
/** 等待异步渲染、页签展开或布局变化,所有退出路径均清理监听和计时器。 */
|
|
31
|
+
function waitForTarget(step, signal, timeout, ready) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
let observer;
|
|
34
|
+
let timer;
|
|
35
|
+
let poll;
|
|
36
|
+
const finish = (element) => {
|
|
37
|
+
observer?.disconnect();
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
clearInterval(poll);
|
|
40
|
+
signal.removeEventListener('abort', aborted);
|
|
41
|
+
resolve(element);
|
|
42
|
+
};
|
|
43
|
+
function aborted() {
|
|
44
|
+
finish(null);
|
|
45
|
+
}
|
|
46
|
+
const check = () => {
|
|
47
|
+
if (signal.aborted) {
|
|
48
|
+
finish(null);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const element = ready() ? findTarget(step) : null;
|
|
52
|
+
if (element)
|
|
53
|
+
finish(element);
|
|
54
|
+
};
|
|
55
|
+
if (signal.aborted) {
|
|
56
|
+
finish(null);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const initial = ready() ? findTarget(step) : null;
|
|
60
|
+
if (initial) {
|
|
61
|
+
finish(initial);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
observer = new MutationObserver(check);
|
|
65
|
+
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
|
|
66
|
+
signal.addEventListener('abort', aborted, { once: true });
|
|
67
|
+
timer = setTimeout(finish, timeout, null);
|
|
68
|
+
poll = setInterval(check, 50);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 创建应用独享的新手引导控制器。SSR 可创建,DOM 操作只在客户端启动时发生。
|
|
73
|
+
* 配置和控制器应在布局组件内创建,避免在 SSR 模块级共享用户状态。
|
|
74
|
+
*/
|
|
75
|
+
export function createGuide(options) {
|
|
76
|
+
const storage = createGuideStorage(options);
|
|
77
|
+
const nav = options.nav ?? createBrowserNavAdapter();
|
|
78
|
+
const persistKey = options.persistKey ?? 'hai-guide';
|
|
79
|
+
const pendingKey = `${persistKey}-pending`;
|
|
80
|
+
let scope = 'anonymous';
|
|
81
|
+
let locale = $state(getUILocale());
|
|
82
|
+
let offeredId = $state(null);
|
|
83
|
+
let activeTourId = $state(null);
|
|
84
|
+
let busy = $state(false);
|
|
85
|
+
let error = $state('');
|
|
86
|
+
let helpOpen = $state(false);
|
|
87
|
+
let articleId = $state(null);
|
|
88
|
+
let driver;
|
|
89
|
+
let abort;
|
|
90
|
+
let generation = 0;
|
|
91
|
+
let expectedPath;
|
|
92
|
+
let navigating = false;
|
|
93
|
+
let focusBefore = null;
|
|
94
|
+
let activeElement = null;
|
|
95
|
+
let targetObserver;
|
|
96
|
+
const msg = (key) => uiM(key, undefined, locale);
|
|
97
|
+
const progressKey = (id) => `${persistKey}:${scope}:${id}`;
|
|
98
|
+
const tourFor = (id) => options.tours.find(tour => tour.id === id && (!tour.enabled || tour.enabled()));
|
|
99
|
+
function clearPending() {
|
|
100
|
+
try {
|
|
101
|
+
sessionStorage.removeItem(pendingKey);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// 存储受限时仅支持宿主客户端导航。
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function stop() {
|
|
108
|
+
generation++;
|
|
109
|
+
abort?.abort();
|
|
110
|
+
abort = undefined;
|
|
111
|
+
targetObserver?.disconnect();
|
|
112
|
+
targetObserver = undefined;
|
|
113
|
+
const previousDriver = driver;
|
|
114
|
+
driver = undefined;
|
|
115
|
+
previousDriver?.destroy();
|
|
116
|
+
activeTourId = null;
|
|
117
|
+
activeElement = null;
|
|
118
|
+
busy = false;
|
|
119
|
+
expectedPath = undefined;
|
|
120
|
+
if (!navigating)
|
|
121
|
+
clearPending();
|
|
122
|
+
if (stopActiveGuide === stop)
|
|
123
|
+
stopActiveGuide = undefined;
|
|
124
|
+
if (focusBefore?.isConnected)
|
|
125
|
+
focusBefore.focus();
|
|
126
|
+
focusBefore = null;
|
|
127
|
+
}
|
|
128
|
+
function mark(tour, status) {
|
|
129
|
+
storage.set(progressKey(tour.id), { version: tour.version ?? 1, status, updatedAt: Date.now() });
|
|
130
|
+
}
|
|
131
|
+
function getProgress(id) {
|
|
132
|
+
return storage.get(progressKey(id));
|
|
133
|
+
}
|
|
134
|
+
function driverSteps(tour) {
|
|
135
|
+
return tour.steps.map(step => ({
|
|
136
|
+
element: () => findTarget(step) ?? document.body,
|
|
137
|
+
popover: {
|
|
138
|
+
title: escapeGuideText(step.title),
|
|
139
|
+
description: escapeGuideText(step.description),
|
|
140
|
+
side: step.side ?? 'bottom',
|
|
141
|
+
},
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
function translatedConfig(tour) {
|
|
145
|
+
return {
|
|
146
|
+
steps: driverSteps(tour),
|
|
147
|
+
nextBtnText: msg('guide_next'),
|
|
148
|
+
prevBtnText: msg('guide_previous'),
|
|
149
|
+
doneBtnText: msg('guide_done'),
|
|
150
|
+
closeBtnLabel: msg('guide_close'),
|
|
151
|
+
progressText: uiM('guide_progress', { current: '{{current}}', total: '{{total}}' }, locale),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async function start(id) {
|
|
155
|
+
if (typeof document === 'undefined')
|
|
156
|
+
return false;
|
|
157
|
+
stopActiveGuide?.();
|
|
158
|
+
stop();
|
|
159
|
+
navigating = false;
|
|
160
|
+
error = '';
|
|
161
|
+
offeredId = null;
|
|
162
|
+
helpOpen = false;
|
|
163
|
+
const tour = tourFor(id);
|
|
164
|
+
if (!tour?.steps.length) {
|
|
165
|
+
error = 'guide_unavailable';
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
const run = generation;
|
|
169
|
+
const signal = new AbortController();
|
|
170
|
+
abort = signal;
|
|
171
|
+
activeTourId = id;
|
|
172
|
+
busy = true;
|
|
173
|
+
stopActiveGuide = stop;
|
|
174
|
+
focusBefore = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
175
|
+
const requiredSeen = new SvelteSet();
|
|
176
|
+
let finishHighlight;
|
|
177
|
+
function hasPreviousStep(index = driver?.getActiveIndex() ?? 0) {
|
|
178
|
+
return [...requiredSeen].some(stepIndex => stepIndex < index);
|
|
179
|
+
}
|
|
180
|
+
function disableNavigation(disabled) {
|
|
181
|
+
const popover = driver?.getState().popover;
|
|
182
|
+
if (popover) {
|
|
183
|
+
popover.nextButton.disabled = disabled;
|
|
184
|
+
popover.previousButton.disabled = disabled || !hasPreviousStep();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function showStep(index, direction = 1) {
|
|
188
|
+
if (signal.signal.aborted || run !== generation)
|
|
189
|
+
return false;
|
|
190
|
+
busy = true;
|
|
191
|
+
disableNavigation(true);
|
|
192
|
+
try {
|
|
193
|
+
while (index >= 0 && index < tour.steps.length) {
|
|
194
|
+
const step = tour.steps[index];
|
|
195
|
+
await step.before?.(signal.signal);
|
|
196
|
+
await tick();
|
|
197
|
+
if (signal.signal.aborted || run !== generation)
|
|
198
|
+
return false;
|
|
199
|
+
const element = await waitForTarget(step, signal.signal, options.waitTimeout ?? 3000, () => !expectedPath || nav.pathname === expectedPath);
|
|
200
|
+
if (signal.signal.aborted || run !== generation)
|
|
201
|
+
return false;
|
|
202
|
+
if (!element) {
|
|
203
|
+
if (step.optional) {
|
|
204
|
+
index += direction;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
error = 'guide_targetMissing';
|
|
208
|
+
stop();
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
requiredSeen.add(index);
|
|
212
|
+
activeElement = element;
|
|
213
|
+
driver?.setConfig({ ...driver.getConfig(), ...translatedConfig(tour) });
|
|
214
|
+
// 等到引擎完成首帧定位后再报告启动成功,键盘此时已可操作。
|
|
215
|
+
await new Promise((resolve) => {
|
|
216
|
+
const finished = () => {
|
|
217
|
+
signal.signal.removeEventListener('abort', finished);
|
|
218
|
+
if (finishHighlight === finished)
|
|
219
|
+
finishHighlight = undefined;
|
|
220
|
+
resolve();
|
|
221
|
+
};
|
|
222
|
+
finishHighlight = finished;
|
|
223
|
+
signal.signal.addEventListener('abort', finished, { once: true });
|
|
224
|
+
driver?.drive(index);
|
|
225
|
+
});
|
|
226
|
+
if (!signal.signal.aborted && run === generation && !findTarget(step)) {
|
|
227
|
+
error = 'guide_targetMissing';
|
|
228
|
+
stop();
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
return !signal.signal.aborted && run === generation;
|
|
232
|
+
}
|
|
233
|
+
if (direction > 0 && tour.steps.every((step, stepIndex) => step.optional || requiredSeen.has(stepIndex)) && requiredSeen.size) {
|
|
234
|
+
// 完成点击与 DOM 卸载可能发生在同一帧,不能把已消失的目标误记为完成。
|
|
235
|
+
const currentStep = tour.steps[driver?.getActiveIndex() ?? 0];
|
|
236
|
+
if (!currentStep || !findTarget(currentStep)) {
|
|
237
|
+
error = 'guide_targetMissing';
|
|
238
|
+
stop();
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
mark(tour, 'completed');
|
|
242
|
+
stop();
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
error = 'guide_targetMissing';
|
|
246
|
+
stop();
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
if (!signal.signal.aborted && run === generation) {
|
|
251
|
+
error = 'guide_startFailed';
|
|
252
|
+
stop();
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
if (run === generation) {
|
|
258
|
+
busy = false;
|
|
259
|
+
disableNavigation(false);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
// 动态导入引擎,只有开始导览时才加载定位和遮罩实现。
|
|
265
|
+
const { driver: createDriver } = await import('driver.js');
|
|
266
|
+
if (signal.signal.aborted || run !== generation)
|
|
267
|
+
return false;
|
|
268
|
+
if (tour.route && nav.pathname !== tour.route.split('?')[0]) {
|
|
269
|
+
expectedPath = tour.route.split('?')[0];
|
|
270
|
+
navigating = true;
|
|
271
|
+
try {
|
|
272
|
+
sessionStorage.setItem(pendingKey, JSON.stringify({ id, scope, route: expectedPath, createdAt: Date.now() }));
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Cookie 或 WebView 存储限制不阻止客户端导航。
|
|
276
|
+
}
|
|
277
|
+
await nav.navigate(tour.route);
|
|
278
|
+
if (signal.signal.aborted || run !== generation)
|
|
279
|
+
return false;
|
|
280
|
+
if (!options.nav) {
|
|
281
|
+
// 默认导航会完整加载新页面;保留待启动记录,由目标页宿主恢复。
|
|
282
|
+
stop();
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
navigating = false;
|
|
286
|
+
}
|
|
287
|
+
expectedPath ??= nav.pathname;
|
|
288
|
+
driver = createDriver({
|
|
289
|
+
...translatedConfig(tour),
|
|
290
|
+
popoverClass: 'hai-guide-popover',
|
|
291
|
+
showProgress: true,
|
|
292
|
+
animate: !globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
|
|
293
|
+
duration: 200,
|
|
294
|
+
stageRadius: 10,
|
|
295
|
+
stagePadding: 8,
|
|
296
|
+
overlayOpacity: 0.45,
|
|
297
|
+
allowKeyboardControl: true,
|
|
298
|
+
overlayClickBehavior: 'close',
|
|
299
|
+
onNextClick: () => {
|
|
300
|
+
if (!busy)
|
|
301
|
+
void showStep((driver?.getActiveIndex() ?? 0) + 1);
|
|
302
|
+
},
|
|
303
|
+
onPrevClick: () => {
|
|
304
|
+
if (!busy && hasPreviousStep())
|
|
305
|
+
void showStep((driver?.getActiveIndex() ?? 0) - 1, -1);
|
|
306
|
+
},
|
|
307
|
+
onDoneClick: () => {
|
|
308
|
+
if (!busy)
|
|
309
|
+
void showStep(tour.steps.length);
|
|
310
|
+
},
|
|
311
|
+
onHighlighted: () => finishHighlight?.(),
|
|
312
|
+
// 引擎首帧未完成时不会触发 onDestroyed;从关闭开始时统一取消等待。
|
|
313
|
+
onDestroyStarted: stop,
|
|
314
|
+
onDestroyed: () => {
|
|
315
|
+
if (driver) {
|
|
316
|
+
driver = undefined;
|
|
317
|
+
stop();
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
onPopoverRender: (popover) => {
|
|
321
|
+
// 第三方默认支持 HTML;业务翻译和插值统一按纯文本写入。
|
|
322
|
+
const index = driver?.getActiveIndex() ?? 0;
|
|
323
|
+
const step = tour.steps[index];
|
|
324
|
+
popover.title.textContent = resolveGuideText(step?.title);
|
|
325
|
+
popover.description.textContent = resolveGuideText(step?.description);
|
|
326
|
+
popover.nextButton.textContent = msg(index === tour.steps.length - 1 ? 'guide_done' : 'guide_next');
|
|
327
|
+
popover.previousButton.textContent = msg('guide_previous');
|
|
328
|
+
popover.progress.textContent = uiM('guide_progress', { current: index + 1, total: tour.steps.length }, locale);
|
|
329
|
+
popover.nextButton.disabled = busy;
|
|
330
|
+
popover.previousButton.disabled = busy || !hasPreviousStep(index);
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
const started = await showStep(0);
|
|
334
|
+
if (started && !signal.signal.aborted) {
|
|
335
|
+
clearPending();
|
|
336
|
+
targetObserver = new MutationObserver(() => {
|
|
337
|
+
if (!busy && activeElement && !findTarget(tour.steps[driver?.getActiveIndex() ?? 0])) {
|
|
338
|
+
error = 'guide_targetMissing';
|
|
339
|
+
stop();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
targetObserver.observe(document.documentElement, { subtree: true, childList: true, attributes: true });
|
|
343
|
+
}
|
|
344
|
+
return started;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
if (!signal.signal.aborted && run === generation) {
|
|
348
|
+
navigating = false;
|
|
349
|
+
error = 'guide_startFailed';
|
|
350
|
+
stop();
|
|
351
|
+
}
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
get tours() { return options.tours.filter(tour => !tour.enabled || tour.enabled()); },
|
|
357
|
+
get articles() { return (options.articles ?? []).filter(article => !article.enabled || article.enabled()); },
|
|
358
|
+
get offeredTour() { return offeredId ? tourFor(offeredId) : undefined; },
|
|
359
|
+
get activeTourId() { return activeTourId; },
|
|
360
|
+
get busy() { return busy; },
|
|
361
|
+
get error() { return error ? msg(error) : ''; },
|
|
362
|
+
get locale() { return locale; },
|
|
363
|
+
get pathname() { return nav.pathname; },
|
|
364
|
+
get helpOpen() { return helpOpen; },
|
|
365
|
+
get articleId() { return articleId; },
|
|
366
|
+
start,
|
|
367
|
+
startIfNeeded(id) {
|
|
368
|
+
const tour = tourFor(id);
|
|
369
|
+
if (!tour || activeTourId || offeredId || typeof document === 'undefined')
|
|
370
|
+
return false;
|
|
371
|
+
const progress = getProgress(id);
|
|
372
|
+
if (progress?.version === (tour.version ?? 1)
|
|
373
|
+
&& (progress.status !== 'deferred' || Date.now() - progress.updatedAt < 86400000)) {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
offeredId = id;
|
|
377
|
+
return true;
|
|
378
|
+
},
|
|
379
|
+
defer() {
|
|
380
|
+
const tour = offeredId ? tourFor(offeredId) : undefined;
|
|
381
|
+
if (tour)
|
|
382
|
+
mark(tour, 'deferred');
|
|
383
|
+
offeredId = null;
|
|
384
|
+
},
|
|
385
|
+
dismiss() {
|
|
386
|
+
const tour = offeredId ? tourFor(offeredId) : undefined;
|
|
387
|
+
if (tour)
|
|
388
|
+
mark(tour, 'dismissed');
|
|
389
|
+
offeredId = null;
|
|
390
|
+
},
|
|
391
|
+
stop,
|
|
392
|
+
reset(id) { storage.set(progressKey(id), null); },
|
|
393
|
+
getProgress,
|
|
394
|
+
openHelp(id) {
|
|
395
|
+
stop();
|
|
396
|
+
offeredId = null;
|
|
397
|
+
error = '';
|
|
398
|
+
articleId = id ?? null;
|
|
399
|
+
helpOpen = true;
|
|
400
|
+
},
|
|
401
|
+
closeHelp() { helpOpen = false; },
|
|
402
|
+
clearError() { error = ''; },
|
|
403
|
+
setLocale(value) {
|
|
404
|
+
locale = value;
|
|
405
|
+
const tour = activeTourId ? tourFor(activeTourId) : undefined;
|
|
406
|
+
if (driver && tour) {
|
|
407
|
+
driver.setConfig({ ...driver.getConfig(), ...translatedConfig(tour) });
|
|
408
|
+
driver.drive(driver.getActiveIndex() ?? 0);
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
setScope(value) {
|
|
412
|
+
if (scope !== value) {
|
|
413
|
+
// 首次装配 scope 时保留上一页面的待启动记录,账号切换才取消当前交互。
|
|
414
|
+
if (activeTourId || offeredId || helpOpen) {
|
|
415
|
+
navigating = false;
|
|
416
|
+
stop();
|
|
417
|
+
}
|
|
418
|
+
scope = value;
|
|
419
|
+
offeredId = null;
|
|
420
|
+
helpOpen = false;
|
|
421
|
+
error = '';
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
handleNavigation(pathname) {
|
|
425
|
+
if (!navigating && activeTourId && expectedPath && pathname !== expectedPath)
|
|
426
|
+
stop();
|
|
427
|
+
},
|
|
428
|
+
async resume() {
|
|
429
|
+
try {
|
|
430
|
+
const raw = sessionStorage.getItem(pendingKey);
|
|
431
|
+
if (!raw)
|
|
432
|
+
return false;
|
|
433
|
+
clearPending();
|
|
434
|
+
const pending = JSON.parse(raw);
|
|
435
|
+
if (pending && typeof pending === 'object'
|
|
436
|
+
&& 'id' in pending && typeof pending.id === 'string'
|
|
437
|
+
&& 'scope' in pending && pending.scope === scope
|
|
438
|
+
&& 'route' in pending && pending.route === nav.pathname
|
|
439
|
+
&& 'createdAt' in pending && typeof pending.createdAt === 'number'
|
|
440
|
+
&& Date.now() - pending.createdAt < 300000) {
|
|
441
|
+
return await start(pending.id);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
// 过期或损坏的导航记录不阻止页面使用。
|
|
446
|
+
}
|
|
447
|
+
return false;
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
// ─── 组件导出(从 components/ 统一导出所有三层组件及其类型) ───
|
|
8
8
|
export * from './components/index.js';
|
|
9
|
+
export * from './guide-types.js';
|
|
10
|
+
export * from './guide.svelte.js';
|
|
9
11
|
// ─── i18n 国际化(Paraglide 辅助工具) ───
|
|
10
12
|
export * from './i18n.svelte.js';
|
|
11
13
|
// ─── 浏览器能力安全辅助(剪贴板、localStorage 等降级方案) ───
|
package/dist/messages/en-US.json
CHANGED
|
@@ -351,5 +351,22 @@
|
|
|
351
351
|
"password_policy_uppercase": "Include uppercase letters",
|
|
352
352
|
"password_policy_lowercase": "Include lowercase letters",
|
|
353
353
|
"password_policy_number": "Include numbers",
|
|
354
|
-
"password_policy_special": "Include special characters"
|
|
354
|
+
"password_policy_special": "Include special characters",
|
|
355
|
+
"guide_welcome": "Welcome",
|
|
356
|
+
"guide_start": "Start tour",
|
|
357
|
+
"guide_later": "Remind me later",
|
|
358
|
+
"guide_dismiss": "Do not remind me",
|
|
359
|
+
"guide_help": "Help center",
|
|
360
|
+
"guide_close": "Close",
|
|
361
|
+
"guide_loading": "Preparing your tour…",
|
|
362
|
+
"guide_next": "Next",
|
|
363
|
+
"guide_previous": "Previous",
|
|
364
|
+
"guide_done": "Done",
|
|
365
|
+
"guide_progress": "Step {current} of {total}",
|
|
366
|
+
"guide_search": "Search help",
|
|
367
|
+
"guide_empty": "No matching help content",
|
|
368
|
+
"guide_tours": "Product tours",
|
|
369
|
+
"guide_unavailable": "This tour is unavailable. Choose another topic in the help center.",
|
|
370
|
+
"guide_targetMissing": "This action is not available on the current page. Try again later or read the help content.",
|
|
371
|
+
"guide_startFailed": "Unable to start the tour. Please try again later."
|
|
355
372
|
}
|