tour-guide-coach 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.
@@ -0,0 +1,239 @@
1
+ <!--
2
+ 漫游式引导 · 全局蒙版组件(零 UI 库依赖)
3
+ -----------------------------------------------------------------
4
+ 样式沿用 Element Plus 设计语言(色彩、圆角、字号),但不依赖任何组件库。
5
+ 原生 <button> + 内联 SVG + CSS 变量(--tg-* 前缀,自动 fallback --el-*)。
6
+ -->
7
+ <template>
8
+ <Teleport to="body">
9
+ <div v-if="store.active" class="tour-root">
10
+ <!-- 4 块遮罩矩形 -->
11
+ <div class="tour-mask" :style="maskTop" @click="onBlockedClick" />
12
+ <div class="tour-mask" :style="maskBottom" @click="onBlockedClick" />
13
+ <div class="tour-mask" :style="maskLeft" @click="onBlockedClick" />
14
+ <div class="tour-mask" :style="maskRight" @click="onBlockedClick" />
15
+ <!-- 高亮边框 -->
16
+ <div v-if="rect" class="tour-highlight" :style="highlightStyle" />
17
+ <!-- 提示气泡 -->
18
+ <div v-if="step && !store.waitingTarget" class="tour-popover" :style="popoverStyle">
19
+ <div class="tp-head">
20
+ <span class="tp-step-no" :style="{ background: accentColor }">{{ store.stepIndex + 1 }}</span>
21
+ <span v-if="step.gate" class="tp-required">*</span>
22
+ <span class="tp-title">{{ step.title }}</span>
23
+ <span class="tp-of">第 {{ store.stepIndex + 1 }} / {{ store.totalSteps }} 步</span>
24
+ </div>
25
+ <p class="tp-content">{{ step.content }}</p>
26
+ <div v-if="step.tip" class="tp-tip">💡 {{ step.tip }}</div>
27
+ <div v-if="step.warn" class="tp-warn">⚠️ {{ step.warn }}</div>
28
+ <div class="tp-foot">
29
+ <button class="tg-btn tg-btn--link tg-btn--sm" @click="abort()">退出引导</button>
30
+ <!-- trigger=click:只显示提示文字 -->
31
+ <span v-if="step.trigger === 'click'" class="tp-click-hint">
32
+ 👆 请点击高亮区域
33
+ </span>
34
+ <!-- 其他步骤:跳过/下一步 -->
35
+ <span v-else-if="store.gatePass" class="tp-btns">
36
+ <button v-if="canSkip" class="tg-btn tg-btn--sm" @click="skip()">跳过此步</button>
37
+ <button class="tg-btn tg-btn--primary tg-btn--sm" :style="{ background: accentColor, borderColor: accentColor }" @click="next()">
38
+ {{ isLast ? '完成' : '下一步' }}
39
+ </button>
40
+ </span>
41
+ <span v-else class="tp-gate">
42
+ <svg class="tp-gate-icon" viewBox="0 0 1024 1024" width="14" height="14" fill="currentColor"><path d="M512 160a96 96 0 0 1 96 96v64H416v-64a96 96 0 0 1 96-96zm0-64a160 160 0 0 0-160 160v64h-32a64 64 0 0 0-64 64v384a64 64 0 0 0 64 64h384a64 64 0 0 0 64-64V384a64 64 0 0 0-64-64h-32v-64A160 160 0 0 0 512 96zM320 448h384v320H320V448z"/></svg>
43
+ {{ step.gateHint || '请先完成本步操作' }}
44
+ </span>
45
+ </div>
46
+ </div>
47
+ <!-- 等待目标 -->
48
+ <div v-if="store.waitingTarget" class="tour-waiting">
49
+ <svg v-if="!store.waitTimedOut" class="tg-spin" viewBox="0 0 1024 1024" width="28" height="28" fill="currentColor"><path d="M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896zm0 64a384 384 0 1 0 0 768 384 384 0 0 0 0-768z" opacity=".25"/><path d="M512 64a448 448 0 0 1 448 448h-64A384 384 0 0 0 512 128V64z"/></svg>
50
+ <svg v-else viewBox="0 0 1024 1024" width="28" height="28" fill="#e6a23c"><path d="M512 64L64 896h896L512 64zm0 192l288 576H224l288-576zm-32 224v128h64V480h-64zm0 160v64h64v-64h-64z"/></svg>
51
+ <p>{{ store.waitTimedOut ? '未找到目标元素,可能是页面状态不符' : (step?.content ?? '等待页面加载...') }}</p>
52
+ <button v-if="store.waitTimedOut" class="tg-btn tg-btn--warning tg-btn--sm" @click="next()">跳过此步</button>
53
+ <button class="tg-btn tg-btn--sm" @click="abort()">退出引导</button>
54
+ </div>
55
+ </div>
56
+ </Teleport>
57
+ </template>
58
+
59
+ <script setup lang="ts">
60
+ import { computed, watch, onBeforeUnmount } from 'vue'
61
+ import { tourState, abort, next, skip, currentStep } from './store'
62
+ import type { CSSProperties } from 'vue'
63
+
64
+ const store = tourState
65
+
66
+ /** ESC 退出引导 */
67
+ function onKeydown(e: KeyboardEvent): void {
68
+ if (e.key === 'Escape' && store.active) abort()
69
+ }
70
+ watch(() => store.active, (isActive) => {
71
+ if (isActive) window.addEventListener('keydown', onKeydown)
72
+ else window.removeEventListener('keydown', onKeydown)
73
+ })
74
+ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
75
+
76
+ const step = computed(() => currentStep())
77
+ const rect = computed(() => store.highlightRect)
78
+ const accentColor = computed(() => step.value?.accent ?? store.config?.accent ?? '#409eff')
79
+ const isLast = computed(() => store.stepIndex >= store.totalSteps - 1)
80
+ const canSkip = computed(() => {
81
+ const s = step.value
82
+ if (!s) return false
83
+ return !s.gate && s.trigger === 'manual'
84
+ })
85
+
86
+ // ---------- 蒙版 4 块定位 ----------
87
+ function calcMasks() {
88
+ const r = rect.value
89
+ if (!r) return null
90
+ return {
91
+ top: { left: 0, top: 0, width: '100vw', height: `${Math.max(r.top, 0)}px` },
92
+ bottom: { left: 0, top: `${r.top + r.height}px`, width: '100vw', height: `calc(100vh - ${r.top + r.height}px)` },
93
+ left: { left: 0, top: `${r.top}px`, width: `${Math.max(r.left, 0)}px`, height: `${r.height}px` },
94
+ right: { left: `${r.left + r.width}px`, top: `${r.top}px`, width: `calc(100vw - ${r.left + r.width}px)`, height: `${r.height}px` }
95
+ }
96
+ }
97
+ const masks = computed(calcMasks)
98
+ const maskTop = computed<CSSProperties>(() => masks.value?.top ?? { display: 'none' })
99
+ const maskBottom = computed<CSSProperties>(() => masks.value?.bottom ?? { display: 'none' })
100
+ const maskLeft = computed<CSSProperties>(() => masks.value?.left ?? { display: 'none' })
101
+ const maskRight = computed<CSSProperties>(() => masks.value?.right ?? { display: 'none' })
102
+
103
+ // ---------- 高亮框 ----------
104
+ const highlightStyle = computed<CSSProperties>(() => {
105
+ const r = rect.value
106
+ if (!r) return { display: 'none' }
107
+ return {
108
+ position: 'fixed', top: `${r.top}px`, left: `${r.left}px`,
109
+ width: `${r.width}px`, height: `${r.height}px`,
110
+ borderRadius: '8px', border: `2px solid ${accentColor.value}`,
111
+ boxShadow: `0 0 0 3px ${accentColor.value}33, 0 0 20px ${accentColor.value}44`,
112
+ pointerEvents: 'none', transition: 'all 0.35s cubic-bezier(0.4,0,0.2,1)'
113
+ }
114
+ })
115
+
116
+ // ---------- Popover 定位 ----------
117
+ const popoverStyle = computed<CSSProperties>(() => {
118
+ const r = rect.value
119
+ if (!r) return { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%,-50%)' }
120
+ const placement = step.value?.placement ?? 'bottom'
121
+ const gap = 14
122
+ const base: CSSProperties = { position: 'fixed', zIndex: 100002 }
123
+ switch (placement) {
124
+ case 'top': return { ...base, left: `${r.left}px`, bottom: `calc(100vh - ${r.top - gap}px)`, maxWidth: '420px' }
125
+ case 'bottom': return { ...base, left: `${r.left}px`, top: `${r.top + r.height + gap}px`, maxWidth: '420px' }
126
+ case 'left': return { ...base, right: `calc(100vw - ${r.left - gap}px)`, top: `${r.top}px`, maxWidth: '360px' }
127
+ case 'right': return { ...base, left: `${r.left + r.width + gap}px`, top: `${r.top}px`, maxWidth: '360px' }
128
+ default: return { ...base, top: '50%', left: '50%', transform: 'translate(-50%,-50%)', maxWidth: '480px' }
129
+ }
130
+ })
131
+
132
+ function onBlockedClick(): void { /* 静默拦截 */ }
133
+ </script>
134
+
135
+ <style scoped>
136
+ /* ---------- CSS 变量:自动 fallback 到 --el-*(兼容 EP 项目) ---------- */
137
+ .tour-root {
138
+ --tg-primary: var(--el-color-primary, #409eff);
139
+ --tg-success: var(--el-color-success, #67c23a);
140
+ --tg-warning: var(--el-color-warning, #e6a23c);
141
+ --tg-danger: var(--el-color-danger, #f56c6c);
142
+ --tg-text-1: var(--el-text-color-primary, #303133);
143
+ --tg-text-2: var(--el-text-color-regular, #606266);
144
+ --tg-text-3: var(--el-text-color-secondary, #909399);
145
+ --tg-border: var(--el-border-color-lighter, #ebeef5);
146
+ --tg-fill-light: var(--el-fill-color-light, #f5f7fa);
147
+ --tg-radius: var(--el-border-radius-base, 4px);
148
+
149
+ position: fixed; inset: 0; z-index: 100000;
150
+ pointer-events: none;
151
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
152
+ }
153
+
154
+ /* ---------- 遮罩 ---------- */
155
+ .tour-mask {
156
+ position: fixed; background: rgba(0,0,0,0.55);
157
+ transition: all 0.35s cubic-bezier(0.4,0,0.2,1);
158
+ cursor: not-allowed; pointer-events: auto;
159
+ }
160
+
161
+ /* ---------- 高亮框 ---------- */
162
+ .tour-highlight { z-index: 100001; }
163
+
164
+ /* ---------- 按钮(EP 风格) ---------- */
165
+ .tg-btn {
166
+ display: inline-flex; align-items: center; justify-content: center;
167
+ border: 1px solid var(--tg-border); border-radius: var(--tg-radius);
168
+ background: #fff; color: var(--tg-text-2);
169
+ font-size: 14px; font-weight: 500; line-height: 1;
170
+ padding: 8px 15px; cursor: pointer;
171
+ transition: color .2s, border-color .2s, background .2s;
172
+ outline: none; white-space: nowrap; user-select: none;
173
+ }
174
+ .tg-btn:hover { color: var(--tg-primary); border-color: var(--tg-primary); background: #ecf5ff; }
175
+ .tg-btn--sm { padding: 5px 11px; font-size: 12px; }
176
+ .tg-btn--primary {
177
+ background: var(--tg-primary); border-color: var(--tg-primary); color: #fff;
178
+ }
179
+ .tg-btn--primary:hover { opacity: .85; background: var(--tg-primary); color: #fff; }
180
+ .tg-btn--warning {
181
+ background: var(--tg-warning); border-color: var(--tg-warning); color: #fff;
182
+ }
183
+ .tg-btn--warning:hover { opacity: .85; background: var(--tg-warning); color: #fff; }
184
+ .tg-btn--link {
185
+ border: none; background: none; padding: 4px 6px;
186
+ color: var(--tg-text-3);
187
+ }
188
+ .tg-btn--link:hover { color: var(--tg-primary); background: none; }
189
+
190
+ /* ---------- 气泡 ---------- */
191
+ .tour-popover {
192
+ z-index: 100002; background: #fff; border-radius: 12px;
193
+ padding: 20px 24px; box-shadow: 0 8px 40px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
194
+ animation: tp-in 0.3s ease; pointer-events: auto;
195
+ min-width: 280px; max-width: 420px;
196
+ }
197
+ @keyframes tp-in {
198
+ from { opacity: 0; transform: translateY(6px) scale(0.97); }
199
+ to { opacity: 1; transform: translateY(0) scale(1); }
200
+ }
201
+ .tp-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
202
+ .tp-step-no {
203
+ display: inline-flex; align-items: center; justify-content: center;
204
+ width: 28px; height: 28px; border-radius: 50%;
205
+ color: #fff; font-size: 14px; font-weight: 700; flex-shrink: 0;
206
+ }
207
+ .tp-required { color: var(--tg-danger); font-size: 20px; font-weight: 700; line-height: 1; }
208
+ .tp-title { font-size: 16px; font-weight: 700; color: var(--tg-text-1); }
209
+ .tp-of { font-size: 13px; color: var(--tg-text-3); margin-left: auto; font-variant-numeric: tabular-nums; }
210
+ .tp-content { margin: 0 0 14px; font-size: 14px; line-height: 1.8; color: var(--tg-text-2); white-space: pre-line; }
211
+ .tp-tip {
212
+ font-size: 13px; color: #529b2e; background: #f0f9eb;
213
+ padding: 8px 12px; border-radius: 6px; margin-bottom: 10px;
214
+ border-left: 3px solid #67c23a;
215
+ }
216
+ .tp-warn {
217
+ font-size: 13px; color: #b88230; background: #fdf6ec;
218
+ padding: 8px 12px; border-radius: 6px; margin-bottom: 10px;
219
+ border-left: 3px solid #e6a23c;
220
+ }
221
+ .tp-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 14px; border-top: 1px solid var(--tg-border); }
222
+ .tp-btns { display: flex; gap: 10px; }
223
+ .tp-gate { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; color: var(--tg-warning); font-weight: 500; }
224
+ .tp-gate-icon { flex-shrink: 0; }
225
+ .tp-click-hint { font-size: 14px; color: var(--tg-primary); font-weight: 500; animation: pulse-hint 1.5s ease-in-out infinite; }
226
+ @keyframes pulse-hint { 0%,100%{opacity:1} 50%{opacity:.5} }
227
+
228
+ /* ---------- 等待态 ---------- */
229
+ .tour-waiting {
230
+ position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
231
+ z-index: 100002; background: #fff; padding: 32px 40px;
232
+ border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.15);
233
+ text-align: center; pointer-events: auto;
234
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
235
+ }
236
+ .tour-waiting p { margin: 4px 0 8px; font-size: 14px; color: var(--tg-text-2); }
237
+ .tg-spin { animation: tg-rotate 1s linear infinite; }
238
+ @keyframes tg-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
239
+ </style>
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 示例引导配置 · 表单创建流程
3
+ * -----------------------------------------------------------------
4
+ * 演示 trigger 类型、gate 门控、expandSelector 动态高亮的完整用法。
5
+ * 对应 demo 页面的表单弹窗。
6
+ */
7
+ import type { TourConfig } from '../types'
8
+
9
+ export const demoFormTour: TourConfig = {
10
+ key: 'demo-form',
11
+ name: '新建表单引导',
12
+ accent: '#409eff',
13
+ steps: [
14
+ {
15
+ id: 'open-btn',
16
+ target: '#demo-create-btn',
17
+ placement: 'bottom',
18
+ title: '点击「新建」',
19
+ content: '点击这个按钮打开表单弹窗。',
20
+ trigger: 'click',
21
+ afterClickWait: '.el-dialog__body .el-form-item:nth-child(1)',
22
+ clickDelay: 500
23
+ },
24
+ {
25
+ id: 'name-field',
26
+ target: '.el-dialog__body .el-form-item:nth-child(1) .el-input',
27
+ placement: 'right',
28
+ title: '填写名称',
29
+ content: '输入名称(必填)。',
30
+ trigger: 'input',
31
+ gate: (resolve) => {
32
+ const el = resolve('.el-dialog__body .el-form-item:nth-child(1) input')
33
+ return !!(el as HTMLInputElement | null)?.value?.trim()
34
+ },
35
+ gateHint: '请先输入名称',
36
+ tip: '名称是唯一必填字段。'
37
+ },
38
+ {
39
+ id: 'category-select',
40
+ target: '.el-dialog__body .el-form-item:nth-child(2) .el-select',
41
+ expandSelector: '.el-select-dropdown',
42
+ placement: 'right',
43
+ title: '选择分类',
44
+ content: '从下拉中选择分类(必填)。\n注意下拉面板也被高亮区域包含。',
45
+ trigger: 'change',
46
+ gate: (resolve) => {
47
+ const el = resolve('.el-dialog__body .el-form-item:nth-child(2) .el-select')
48
+ if (!el) return false
49
+ const ph = el.querySelector('.el-select__placeholder')
50
+ if (!ph) return true
51
+ return !ph.classList.contains('is-transparent')
52
+ },
53
+ gateHint: '请选择一个分类'
54
+ },
55
+ {
56
+ id: 'date-field',
57
+ target: '.el-dialog__body .el-form-item:nth-child(3) .el-input',
58
+ expandSelector: '.el-picker-panel',
59
+ placement: 'right',
60
+ title: '选择日期',
61
+ content: '选择日期(选填)。\n日历面板同样使用动态高亮扩展。',
62
+ trigger: 'manual'
63
+ },
64
+ {
65
+ id: 'remark-field',
66
+ target: '.el-dialog__body .el-form-item:nth-child(4) .el-textarea',
67
+ placement: 'right',
68
+ title: '备注',
69
+ content: '填写备注(选填),可以直接点「下一步」跳过。',
70
+ trigger: 'manual'
71
+ },
72
+ {
73
+ id: 'submit-btn',
74
+ target: '.el-dialog__footer .el-button--primary',
75
+ placement: 'top',
76
+ title: '提交表单',
77
+ content: '点击「确定」提交,引导结束。',
78
+ trigger: 'click',
79
+ clickDelay: 500
80
+ }
81
+ ]
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * vue-tour-guide · 公共入口
3
+ * -----------------------------------------------------------------
4
+ * 零外部依赖(仅需 Vue 3),全局单例模式。
5
+ *
6
+ * 使用方式:
7
+ * import { tourState, startTour, abort, TourOverlay } from 'vue-tour-guide'
8
+ * // 在 layout 中挂 <TourOverlay />
9
+ * // 业务页调用 startTour(config)
10
+ */
11
+ export { tourState, startTour, abort, next, skip, complete } from './store'
12
+ export type { TargetResolver } from './store'
13
+ export { default as TourOverlay } from './TourOverlay.vue'
14
+ export type { TourConfig, TourStep, StepTrigger, PopPlacement, HighlightRect } from './types'
package/src/store.ts ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * 漫游式引导引擎 · 状态机(模块级单例,零外部依赖)
3
+ * -----------------------------------------------------------------
4
+ * 用 Vue 3 的 reactive + ref 替代 Pinia。
5
+ * 全局单例——同一时间只允许一个 tour 播放。
6
+ *
7
+ * 生命周期:
8
+ * startTour(config, resolver) → 逐步 enterStep → resolveTarget
9
+ * → 绑定 trigger 监听 → 用户操作满足条件 → next() → complete/abort
10
+ */
11
+ import { ref, shallowReactive } from 'vue'
12
+ import type { TourConfig, TourStep, HighlightRect } from './types'
13
+
14
+ /** 元素解析器:宿主页面提供,把 target 名解析为 DOM 元素 */
15
+ export type TargetResolver = (target: string) => HTMLElement | null
16
+
17
+ const GATE_POLL_INTERVAL = 150
18
+ const ELEMENT_WAIT_TIMEOUT = 12000
19
+
20
+ // ---------- 响应式状态(模块级单例) ----------
21
+ export const tourState = shallowReactive({
22
+ active: false,
23
+ config: null as TourConfig | null,
24
+ stepIndex: 0,
25
+ totalSteps: 0,
26
+ highlightRect: null as HighlightRect | null,
27
+ targetEl: null as HTMLElement | null,
28
+ gatePass: true,
29
+ waitingTarget: false,
30
+ waitTimedOut: false
31
+ })
32
+
33
+ // ---------- 内部状态(非响应式) ----------
34
+ let onComplete: (() => void) | null = null
35
+ let _resolver: TargetResolver | null = null
36
+ let _gateTimer: ReturnType<typeof setInterval> | null = null
37
+ let _waitObserver: MutationObserver | null = null
38
+ let _waitTimer: ReturnType<typeof setTimeout> | null = null
39
+ let _clickDelayTimer: ReturnType<typeof setTimeout> | null = null
40
+ let _resizeObs: ResizeObserver | null = null
41
+ let _scrollHandler: (() => void) | null = null
42
+ let _expandObs: MutationObserver | null = null
43
+ let _currentStep: TourStep | null = null
44
+
45
+ function _currentStepFn(): TourStep | null {
46
+ return tourState.config?.steps[tourState.stepIndex] ?? null
47
+ }
48
+
49
+ /** 获取当前步骤(供组件使用) */
50
+ export function currentStep(): TourStep | null {
51
+ return _currentStepFn()
52
+ }
53
+
54
+ // ---------- 启动引导 ----------
55
+ export function startTour(tour: TourConfig, resolver?: TargetResolver, done?: () => void): void {
56
+ if (tourState.active) abort()
57
+ tourState.config = tour
58
+ tourState.stepIndex = 0
59
+ tourState.totalSteps = tour.steps.length
60
+ tourState.gatePass = true
61
+ onComplete = done ?? null
62
+ _resolver = resolver ?? null
63
+ tourState.active = true
64
+ _enterStep()
65
+ }
66
+
67
+ export function abort(): void {
68
+ _cleanup()
69
+ tourState.active = false
70
+ tourState.config = null
71
+ tourState.highlightRect = null
72
+ tourState.targetEl = null
73
+ _resolver = null
74
+ onComplete = null
75
+ }
76
+
77
+ export function complete(): void {
78
+ _cleanup()
79
+ tourState.active = false
80
+ tourState.highlightRect = null
81
+ tourState.targetEl = null
82
+ const cb = onComplete
83
+ _resolver = null
84
+ onComplete = null
85
+ cb?.()
86
+ }
87
+
88
+ // ---------- 步骤推进 ----------
89
+ export function next(): void {
90
+ _cleanup()
91
+ const cfg = tourState.config
92
+ if (!cfg) return
93
+ if (tourState.stepIndex >= cfg.steps.length - 1) { complete(); return }
94
+ tourState.stepIndex += 1
95
+ _enterStep()
96
+ }
97
+
98
+ export function skip(): void { next() }
99
+
100
+ // ---------- 进入当前步 ----------
101
+ function _enterStep(): void {
102
+ const step = _currentStepFn()
103
+ if (!step) { complete(); return }
104
+ _currentStep = step
105
+ tourState.gatePass = step.gate ? false : true
106
+ tourState.waitingTarget = true
107
+ tourState.waitTimedOut = false
108
+ tourState.highlightRect = null
109
+ tourState.targetEl = null
110
+
111
+ const enterResult = step.beforeEnter?.()
112
+ if (enterResult instanceof Promise) {
113
+ enterResult.then(() => _resolveTarget(step))
114
+ } else {
115
+ requestAnimationFrame(() => _resolveTarget(step))
116
+ }
117
+ }
118
+
119
+ // ---------- 解析目标元素 ----------
120
+ function _resolveTarget(step: TourStep): void {
121
+ if (step.trigger === 'auto') {
122
+ tourState.waitingTarget = false
123
+ _autoAdvance(step)
124
+ return
125
+ }
126
+ if (step.trigger === 'wait-dom') {
127
+ _waitForDom(step)
128
+ return
129
+ }
130
+ const el = _findTarget(step.target)
131
+ if (el) { _onTargetReady(el, step); return }
132
+ _startWaitForElement(step.target, () => {
133
+ const el2 = _findTarget(step.target)
134
+ _onTargetReady(el2, step)
135
+ })
136
+ }
137
+
138
+ function _findTarget(target: string): HTMLElement | null {
139
+ if (_resolver) {
140
+ const el = _resolver(target)
141
+ if (el) return el
142
+ }
143
+ try { return document.querySelector(target) } catch { return null }
144
+ }
145
+
146
+ function _onTargetReady(el: HTMLElement | null, step: TourStep): void {
147
+ if (!el) { tourState.waitTimedOut = true; return }
148
+ tourState.waitingTarget = false
149
+ tourState.waitTimedOut = false
150
+ tourState.targetEl = el
151
+ _updateRect(el)
152
+ _bindTrigger(el, step)
153
+ _startGate(step)
154
+ _startTrackingRect(el)
155
+ }
156
+
157
+ // ---------- 高亮矩形跟踪 ----------
158
+ function _updateRect(el: HTMLElement): void {
159
+ const r = el.getBoundingClientRect()
160
+ const pad = 6
161
+ let rect: HighlightRect = {
162
+ top: r.top - pad, left: r.left - pad,
163
+ width: r.width + pad * 2, height: r.height + pad * 2
164
+ }
165
+ const expandSel = _currentStep?.expandSelector
166
+ if (expandSel) {
167
+ const candidates = document.querySelectorAll(expandSel)
168
+ let expandEl: HTMLElement | null = null
169
+ for (const c of candidates) {
170
+ if ((c as HTMLElement).offsetHeight > 0) { expandEl = c as HTMLElement; break }
171
+ }
172
+ if (expandEl) {
173
+ const er = expandEl.getBoundingClientRect()
174
+ const top = Math.min(rect.top, er.top - pad)
175
+ const left = Math.min(rect.left, er.left - pad)
176
+ const right = Math.max(rect.left + rect.width, er.right + pad)
177
+ const bottom = Math.max(rect.top + rect.height, er.bottom + pad)
178
+ rect = { top, left, width: right - left, height: bottom - top }
179
+ }
180
+ }
181
+ tourState.highlightRect = rect
182
+ }
183
+
184
+ function _startTrackingRect(el: HTMLElement): void {
185
+ _stopTrackingRect()
186
+ _scrollHandler = () => _updateRect(el)
187
+ window.addEventListener('scroll', _scrollHandler, true)
188
+ window.addEventListener('resize', _scrollHandler)
189
+ _resizeObs = new ResizeObserver(() => _updateRect(el))
190
+ _resizeObs.observe(el)
191
+ const expandSel = _currentStep?.expandSelector
192
+ if (expandSel) {
193
+ _expandObs = new MutationObserver(() => _updateRect(el))
194
+ _expandObs.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] })
195
+ }
196
+ }
197
+
198
+ function _stopTrackingRect(): void {
199
+ if (_scrollHandler) {
200
+ window.removeEventListener('scroll', _scrollHandler, true)
201
+ window.removeEventListener('resize', _scrollHandler)
202
+ }
203
+ _scrollHandler = null
204
+ _resizeObs?.disconnect(); _resizeObs = null
205
+ _expandObs?.disconnect(); _expandObs = null
206
+ }
207
+
208
+ // ---------- 事件绑定 ----------
209
+ function _bindTrigger(el: HTMLElement, step: TourStep): void {
210
+ if (step.trigger === 'click') {
211
+ const handler = () => {
212
+ el.removeEventListener('click', handler)
213
+ const delay = step.clickDelay ?? 350
214
+ if (step.afterClickWait) {
215
+ _waitForDom({ waitForElement: step.afterClickWait, clickDelay: delay } as TourStep)
216
+ } else {
217
+ _clickDelayTimer = setTimeout(() => next(), delay)
218
+ }
219
+ }
220
+ el.addEventListener('click', handler)
221
+ }
222
+ }
223
+
224
+ // ---------- Gate 轮询 ----------
225
+ function _startGate(step: TourStep): void {
226
+ if (!step.gate) { tourState.gatePass = true; return }
227
+ const resolve = (t: string) => _findTarget(t)
228
+ tourState.gatePass = step.gate(resolve)
229
+ if (tourState.gatePass) return
230
+ _gateTimer = setInterval(() => {
231
+ if (step.gate!(resolve)) {
232
+ tourState.gatePass = true
233
+ if (_gateTimer) { clearInterval(_gateTimer); _gateTimer = null }
234
+ }
235
+ }, GATE_POLL_INTERVAL)
236
+ }
237
+
238
+ // ---------- 等待 DOM ----------
239
+ function _waitForDom(step: TourStep): void {
240
+ const sel = step.waitForElement
241
+ if (!sel) { next(); return }
242
+ if (_findTarget(sel)) {
243
+ _clickDelayTimer = setTimeout(() => next(), step.clickDelay ?? 300)
244
+ return
245
+ }
246
+ _startWaitForElement(sel, () => {
247
+ _clickDelayTimer = setTimeout(() => next(), step.clickDelay ?? 300)
248
+ })
249
+ }
250
+
251
+ function _startWaitForElement(target: string, cb: () => void): void {
252
+ let resolved = false
253
+ const finish = () => { if (!resolved) { resolved = true; _waitObserver?.disconnect(); _waitObserver = null; cb() } }
254
+ _waitObserver = new MutationObserver(() => { if (_findTarget(target)) finish() })
255
+ _waitObserver.observe(document.body, { childList: true, subtree: true })
256
+ _waitTimer = setTimeout(finish, ELEMENT_WAIT_TIMEOUT)
257
+ }
258
+
259
+ function _autoAdvance(step: TourStep): void {
260
+ _clickDelayTimer = setTimeout(() => next(), step.autoDelay ?? 600)
261
+ }
262
+
263
+ // ---------- 清理 ----------
264
+ function _cleanup(): void {
265
+ _stopTrackingRect()
266
+ if (_gateTimer) { clearInterval(_gateTimer); _gateTimer = null }
267
+ if (_waitObserver) { _waitObserver.disconnect(); _waitObserver = null }
268
+ if (_waitTimer) { clearTimeout(_waitTimer); _waitTimer = null }
269
+ if (_clickDelayTimer) { clearTimeout(_clickDelayTimer); _clickDelayTimer = null }
270
+ }
package/src/types.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 漫游式引导引擎 · 类型定义
3
+ * -----------------------------------------------------------------
4
+ * 交互式引导:蒙版 + 元素高亮 + 事件驱动推步(用户真实操作才推进)。
5
+ *
6
+ * StepTrigger 语义:
7
+ * click — 用户点击高亮元素后自动前进
8
+ * input — 高亮的输入框值满足 condition 后前进(如非空)
9
+ * change — 高亮元素的值变化后前进(select / tree-select)
10
+ * wait-dom — 等待 waitForElement 选择器匹配到 DOM 节点(弹窗出现、路由切换)
11
+ * auto — 自动前进(延时后,用于页面跳转/加载过渡)
12
+ * manual — 显示"下一步"按钮,用户手动推进(信息展示步骤)
13
+ */
14
+
15
+ /** 弹窗提示框相对高亮区域的弹出方位 */
16
+ export type PopPlacement = 'top' | 'bottom' | 'left' | 'right' | 'center'
17
+
18
+ /** 引导步骤 */
19
+ export interface TourStep {
20
+ /** 步骤唯一 ID(config 内不重复) */
21
+ id: string
22
+ /** 高亮目标 CSS 选择器(`[data-tour="xxx"]`、`#tab-tasks` 等) */
23
+ target: string
24
+ /** 动态扩展选择器:当该选择器匹配的元素可见时,高亮区域扩展为 target + expand 联合矩形。
25
+ * 典型场景:el-select 展开后 popper teleport 到 body,需要把下拉面板纳入可交互区域。 */
26
+ expandSelector?: string
27
+ /** 弹窗提示框弹出方位 */
28
+ placement?: PopPlacement
29
+ /** 步骤标题 */
30
+ title: string
31
+ /** 步骤正文(支持 \n 换行) */
32
+ content: string
33
+ /** 推进触发方式 */
34
+ trigger: StepTrigger
35
+ /** 推进门控条件(trigger=input/change 时,引擎轮询检查;返回 true 才解锁"下一步")。
36
+ * 参数 resolve 是宿主提供的元素解析器,gate 内可调用 resolve('nameRef:input') 取 DOM */
37
+ gate?: (resolve: (target: string) => HTMLElement | null) => boolean
38
+ /** 门控未通过时"下一步"按钮下方提示文案 */
39
+ gateHint?: string
40
+ /** trigger=wait-dom 时,等待该选择器出现在 DOM */
41
+ waitForElement?: string
42
+ /** trigger=auto 时,自动前进延时(ms),默认 600 */
43
+ autoDelay?: number
44
+ /** trigger=click 时,前进延时(ms),等 click 引起的 DOM 变化完成,默认 350 */
45
+ clickDelay?: number
46
+ /** 额外等待:click 后等该选择器出现才推下一步(如"点了新建 → 等弹窗") */
47
+ afterClickWait?: string
48
+ /** 小贴士(可选,绿色块) */
49
+ tip?: string
50
+ /** 注意事项(可选,橙色块) */
51
+ warn?: string
52
+ /** 本步的强调色覆盖(默认用 tour 全局色) */
53
+ accent?: string
54
+ /** 是否在进入本步前执行导航(如 router.push) */
55
+ beforeEnter?: () => void | Promise<void>
56
+ }
57
+
58
+ /** 步骤触发方式 */
59
+ export type StepTrigger = 'click' | 'input' | 'change' | 'wait-dom' | 'auto' | 'manual'
60
+
61
+ /** 一个完整引导 */
62
+ export interface TourConfig {
63
+ /** 引导唯一 key */
64
+ key: string
65
+ /** 引导名(如「新建任务引导」) */
66
+ name: string
67
+ /** 全局强调色 */
68
+ accent: string
69
+ /** 引导步骤列表(按序播放) */
70
+ steps: TourStep[]
71
+ }
72
+
73
+ /** 高亮矩形(viewport 坐标系,引擎每帧更新) */
74
+ export interface HighlightRect {
75
+ top: number
76
+ left: number
77
+ width: number
78
+ height: number
79
+ }