mdk-skills 2.2.2 → 2.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.claude/.install.log +9 -40
  2. package/.claude/backups/20260510.151953/.install.log +1 -0
  3. package/package.json +1 -1
  4. package/scripts/web-ui/server.js +8 -1
  5. package/.claude/backups/20260510.145547/.install.log +0 -18
  6. package/.claude/backups/20260510.145547/settings.json +0 -35
  7. package/.claude/backups/20260510.145547/skills/test1/.meta.json +0 -6
  8. package/.claude/backups/20260510.145547/skills/test2/.meta.json +0 -6
  9. package/.claude/backups/20260510.145651/.install.log +0 -19
  10. package/.claude/backups/20260510.145651/profiles.json +0 -67
  11. package/.claude/backups/20260510.145651/skills/frontend-code-review/.meta.json +0 -6
  12. package/.claude/backups/20260510.145651/skills/frontend-code-review/SKILL.md +0 -167
  13. package/.claude/backups/20260510.145651/skills/frontend-code-review/references/checklist.md +0 -298
  14. package/.claude/backups/20260510.145651/skills/frontend-design/.meta.json +0 -6
  15. package/.claude/backups/20260510.145651/skills/frontend-design/LICENSE.txt +0 -177
  16. package/.claude/backups/20260510.145651/skills/frontend-design/SKILL.md +0 -42
  17. package/.claude/backups/20260510.145651/skills/skill-creator/.meta.json +0 -6
  18. package/.claude/backups/20260510.145651/skills/skill-creator/SKILL.md +0 -356
  19. package/.claude/backups/20260510.145651/skills/skill-creator/references/output-patterns.md +0 -82
  20. package/.claude/backups/20260510.145651/skills/skill-creator/references/workflows.md +0 -28
  21. package/.claude/backups/20260510.145651/skills/skill-creator/scripts/init_skill.py +0 -303
  22. package/.claude/backups/20260510.145651/skills/skill-creator/scripts/package_skill.py +0 -110
  23. package/.claude/backups/20260510.145651/skills/skill-creator/scripts/quick_validate.py +0 -95
  24. package/.claude/backups/20260510.145651/skills/test1/.meta.json +0 -6
  25. package/.claude/backups/20260510.145651/skills/test2/.meta.json +0 -6
  26. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/.meta.json +0 -6
  27. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/SKILL.md +0 -228
  28. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/charts.csv +0 -26
  29. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/colors.csv +0 -97
  30. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/landing.csv +0 -31
  31. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/products.csv +0 -97
  32. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/prompts.csv +0 -24
  33. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/stacks/flutter.csv +0 -53
  34. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/stacks/html-tailwind.csv +0 -56
  35. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/stacks/nextjs.csv +0 -53
  36. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv +0 -51
  37. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv +0 -59
  38. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/stacks/react-native.csv +0 -52
  39. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/stacks/react.csv +0 -54
  40. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/stacks/svelte.csv +0 -54
  41. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/stacks/swiftui.csv +0 -51
  42. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/stacks/vue.csv +0 -50
  43. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/styles.csv +0 -59
  44. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/typography.csv +0 -58
  45. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/data/ux-guidelines.csv +0 -100
  46. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/scripts/core.py +0 -238
  47. package/.claude/backups/20260510.145651/skills/ui-ux-pro-max/scripts/search.py +0 -61
  48. package/.claude/backups/20260510.145651/skills/v3-fe-biz-patterns/.meta.json +0 -6
  49. package/.claude/backups/20260510.145651/skills/v3-fe-biz-patterns/SKILL.md +0 -26
  50. package/.claude/backups/20260510.145651/skills/v3-fe-biz-patterns/references/infinite-scroll.md +0 -292
  51. package/.claude/backups/20260510.145651/skills/v3-fe-biz-patterns/references/pinia-store.md +0 -174
  52. package/.claude/backups/20260510.145651/skills/v3-fe-biz-patterns/references/service-layer.md +0 -198
  53. package/.claude/backups/20260510.145651/skills/v3-fe-biz-patterns/references/tab-anchor.md +0 -1125
  54. package/.claude/backups/20260510.145651/skills/v3-fe-biz-patterns/references/use-loading.md +0 -114
  55. package/.claude/backups/20260510.145651/skills/vue/.meta.json +0 -6
  56. package/.claude/backups/20260510.145651/skills/vue/SKILL.md +0 -103
  57. package/.claude/backups/20260510.145651/skills/vue/references/components.md +0 -323
  58. package/.claude/backups/20260510.145651/skills/vue/references/composables.md +0 -358
  59. package/.claude/backups/20260510.145651/skills/vue/references/directives.md +0 -225
  60. package/.claude/backups/20260510.145651/skills/vue/references/gotchas.md +0 -438
  61. package/.claude/backups/20260510.145651/skills/vue/references/provide-inject.md +0 -174
  62. package/.claude/backups/20260510.145651/skills/vue/references/reactivity.md +0 -289
  63. package/.claude/backups/20260510.145651/skills/vue/references/router.md +0 -181
  64. package/.claude/backups/20260510.145651/skills/vue/references/testing.md +0 -294
  65. package/.claude/backups/20260510.145651/skills/vue/references/typescript.md +0 -172
  66. package/.claude/backups/20260510.145651/skills/vue/references/utils-client.md +0 -156
  67. package/.claude/backups/20260510.150310/.install.log +0 -30
  68. package/.claude/backups/20260510.150310/profiles.json +0 -67
  69. package/.claude/backups/20260510.150310/settings.json +0 -29
  70. package/.claude/backups/20260510.150310/skills/frontend-code-review/.meta.json +0 -6
  71. package/.claude/backups/20260510.150310/skills/frontend-code-review/SKILL.md +0 -167
  72. package/.claude/backups/20260510.150310/skills/frontend-code-review/references/checklist.md +0 -298
  73. package/.claude/backups/20260510.150310/skills/frontend-design/.meta.json +0 -6
  74. package/.claude/backups/20260510.150310/skills/frontend-design/LICENSE.txt +0 -177
  75. package/.claude/backups/20260510.150310/skills/frontend-design/SKILL.md +0 -42
  76. package/.claude/backups/20260510.150310/skills/skill-creator/.meta.json +0 -6
  77. package/.claude/backups/20260510.150310/skills/skill-creator/SKILL.md +0 -356
  78. package/.claude/backups/20260510.150310/skills/skill-creator/references/output-patterns.md +0 -82
  79. package/.claude/backups/20260510.150310/skills/skill-creator/references/workflows.md +0 -28
  80. package/.claude/backups/20260510.150310/skills/skill-creator/scripts/init_skill.py +0 -303
  81. package/.claude/backups/20260510.150310/skills/skill-creator/scripts/package_skill.py +0 -110
  82. package/.claude/backups/20260510.150310/skills/skill-creator/scripts/quick_validate.py +0 -95
  83. package/.claude/backups/20260510.150310/skills/test1/.meta.json +0 -6
  84. package/.claude/backups/20260510.150310/skills/test2/.meta.json +0 -6
  85. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/.meta.json +0 -6
  86. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/SKILL.md +0 -228
  87. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/charts.csv +0 -26
  88. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/colors.csv +0 -97
  89. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/landing.csv +0 -31
  90. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/products.csv +0 -97
  91. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/prompts.csv +0 -24
  92. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/stacks/flutter.csv +0 -53
  93. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/stacks/html-tailwind.csv +0 -56
  94. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/stacks/nextjs.csv +0 -53
  95. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv +0 -51
  96. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv +0 -59
  97. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/stacks/react-native.csv +0 -52
  98. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/stacks/react.csv +0 -54
  99. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/stacks/svelte.csv +0 -54
  100. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/stacks/swiftui.csv +0 -51
  101. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/stacks/vue.csv +0 -50
  102. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/styles.csv +0 -59
  103. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/typography.csv +0 -58
  104. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/data/ux-guidelines.csv +0 -100
  105. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/scripts/core.py +0 -238
  106. package/.claude/backups/20260510.150310/skills/ui-ux-pro-max/scripts/search.py +0 -61
  107. package/.claude/backups/20260510.150310/skills/v3-fe-biz-patterns/.meta.json +0 -6
  108. package/.claude/backups/20260510.150310/skills/v3-fe-biz-patterns/SKILL.md +0 -26
  109. package/.claude/backups/20260510.150310/skills/v3-fe-biz-patterns/references/infinite-scroll.md +0 -292
  110. package/.claude/backups/20260510.150310/skills/v3-fe-biz-patterns/references/pinia-store.md +0 -174
  111. package/.claude/backups/20260510.150310/skills/v3-fe-biz-patterns/references/service-layer.md +0 -198
  112. package/.claude/backups/20260510.150310/skills/v3-fe-biz-patterns/references/tab-anchor.md +0 -1125
  113. package/.claude/backups/20260510.150310/skills/v3-fe-biz-patterns/references/use-loading.md +0 -114
  114. package/.claude/backups/20260510.150310/skills/vue/.meta.json +0 -6
  115. package/.claude/backups/20260510.150310/skills/vue/SKILL.md +0 -103
  116. package/.claude/backups/20260510.150310/skills/vue/references/components.md +0 -323
  117. package/.claude/backups/20260510.150310/skills/vue/references/composables.md +0 -358
  118. package/.claude/backups/20260510.150310/skills/vue/references/directives.md +0 -225
  119. package/.claude/backups/20260510.150310/skills/vue/references/gotchas.md +0 -438
  120. package/.claude/backups/20260510.150310/skills/vue/references/provide-inject.md +0 -174
  121. package/.claude/backups/20260510.150310/skills/vue/references/reactivity.md +0 -289
  122. package/.claude/backups/20260510.150310/skills/vue/references/router.md +0 -181
  123. package/.claude/backups/20260510.150310/skills/vue/references/testing.md +0 -294
  124. package/.claude/backups/20260510.150310/skills/vue/references/typescript.md +0 -172
  125. package/.claude/backups/20260510.150310/skills/vue/references/utils-client.md +0 -156
  126. package/.claude/backups/CLAUDE.md.20260510.145155 +0 -131
  127. package/.claude/backups/CLAUDE.md.20260510.145651 +0 -131
  128. package/.claude/backups/CLAUDE.md.20260510.150310 +0 -131
  129. package/.claude/skills/test1/.meta.json +0 -6
  130. package/.claude/skills/test2/.meta.json +0 -6
  131. /package/.claude/backups/{20260510.145547 → 20260510.151953}/profiles.json +0 -0
  132. /package/.claude/backups/{20260510.145651 → 20260510.151953}/settings.json +0 -0
  133. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/frontend-code-review/.meta.json +0 -0
  134. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/frontend-code-review/SKILL.md +0 -0
  135. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/frontend-code-review/references/checklist.md +0 -0
  136. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/frontend-design/.meta.json +0 -0
  137. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/frontend-design/LICENSE.txt +0 -0
  138. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/frontend-design/SKILL.md +0 -0
  139. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/skill-creator/.meta.json +0 -0
  140. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/skill-creator/SKILL.md +0 -0
  141. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/skill-creator/references/output-patterns.md +0 -0
  142. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/skill-creator/references/workflows.md +0 -0
  143. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/skill-creator/scripts/init_skill.py +0 -0
  144. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/skill-creator/scripts/package_skill.py +0 -0
  145. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/skill-creator/scripts/quick_validate.py +0 -0
  146. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/.meta.json +0 -0
  147. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/SKILL.md +0 -0
  148. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/charts.csv +0 -0
  149. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/colors.csv +0 -0
  150. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/landing.csv +0 -0
  151. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/products.csv +0 -0
  152. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/prompts.csv +0 -0
  153. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/stacks/flutter.csv +0 -0
  154. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/stacks/html-tailwind.csv +0 -0
  155. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/stacks/nextjs.csv +0 -0
  156. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv +0 -0
  157. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv +0 -0
  158. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/stacks/react-native.csv +0 -0
  159. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/stacks/react.csv +0 -0
  160. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/stacks/svelte.csv +0 -0
  161. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/stacks/swiftui.csv +0 -0
  162. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/stacks/vue.csv +0 -0
  163. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/styles.csv +0 -0
  164. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/typography.csv +0 -0
  165. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/data/ux-guidelines.csv +0 -0
  166. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/scripts/core.py +0 -0
  167. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/ui-ux-pro-max/scripts/search.py +0 -0
  168. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/v3-fe-biz-patterns/.meta.json +0 -0
  169. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/v3-fe-biz-patterns/SKILL.md +0 -0
  170. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/v3-fe-biz-patterns/references/infinite-scroll.md +0 -0
  171. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/v3-fe-biz-patterns/references/pinia-store.md +0 -0
  172. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/v3-fe-biz-patterns/references/service-layer.md +0 -0
  173. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/v3-fe-biz-patterns/references/tab-anchor.md +0 -0
  174. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/v3-fe-biz-patterns/references/use-loading.md +0 -0
  175. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/vue/.meta.json +0 -0
  176. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/vue/SKILL.md +0 -0
  177. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/vue/references/components.md +0 -0
  178. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/vue/references/composables.md +0 -0
  179. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/vue/references/directives.md +0 -0
  180. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/vue/references/gotchas.md +0 -0
  181. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/vue/references/provide-inject.md +0 -0
  182. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/vue/references/reactivity.md +0 -0
  183. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/vue/references/router.md +0 -0
  184. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/vue/references/testing.md +0 -0
  185. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/vue/references/typescript.md +0 -0
  186. /package/.claude/backups/{20260510.145547 → 20260510.151953}/skills/vue/references/utils-client.md +0 -0
  187. /package/.claude/backups/{CLAUDE.md.20260510.144501 → CLAUDE.md.20260510.151953} +0 -0
@@ -1,1125 +0,0 @@
1
- # TabAnchor 锚点 Tab 组件
2
-
3
- ## 概述
4
-
5
- TabAnchor 是一个带锚点滚动功能的 Tab 组件,支持:
6
-
7
- - 自动 / 手动两种模式
8
- - 水平栏 / 侧边栏两种布局
9
- - slide / scale 两种指示器样式
10
- - sticky 吸顶 / float 浮动两种定位方式
11
- - floatThreshold 阈值控制浮动栏显示时机
12
- - IntersectionObserver 自动追踪可见 section,驱动 activeKey 切换
13
- - 自定义 Tab 按钮渲染
14
-
15
- **文件构成:**
16
-
17
- | 文件 | 职责 |
18
- |------|------|
19
- | `src/components/TabAnchor.vue` | 组件入口:模板、逻辑、样式 |
20
- | `src/hooks/useTabAnchor.ts` | 核心锚点滚动逻辑(IntersectionObserver、scrollIntoView) |
21
- | `src/hooks/useTabFloat.ts` | 浮动模式(position: fixed + 阈值控制) |
22
- | `src/hooks/useTabIndicator.ts` | 滑动指示器位置计算 |
23
- | `src/types/anchor.ts` | 类型定义 |
24
-
25
- ## 完整源码
26
-
27
- ### types/anchor.ts
28
-
29
- ```ts
30
- /** Tab 配置项 */
31
- export interface TabItem<T extends string | number = string> {
32
- /** 显示文案 */
33
- label: string
34
- /** 唯一标识 */
35
- key: T
36
- /** 是否禁用 */
37
- disabled?: boolean
38
- }
39
-
40
- /** Tab 栏布局方向 */
41
- export type TabLayout = 'top' | 'left'
42
-
43
- /** 滑动指示器类型 */
44
- export type IndicatorType = 'slide' | 'scale'
45
-
46
- /** TabAnchor 组件 Props */
47
- export interface TabAnchorProps<T extends string | number = string> {
48
- /** Tab 配置数组 */
49
- tabs: TabItem<T>[]
50
- /** 当前激活的 key(v-model) */
51
- modelValue?: T
52
- /** 默认激活的 key */
53
- defaultActiveKey?: T
54
- /** 是否吸顶 */
55
- sticky?: boolean
56
- /** 吸顶偏移量 */
57
- stickyTop?: number
58
- /** 手动模式——组件只渲染 Tab 栏,内容由外部管理 */
59
- manual?: boolean
60
- /** 手动模式下,接收外部内容区元素的 refs 数组(支持 Ref 或普通数组) */
61
- contentRefs?: import('vue').MaybeRef<(HTMLElement | null)[]>
62
- /** 滚动容器元素,用于局部滚动容器(如 overflow:auto 的父容器) */
63
- scrollContainer?: HTMLElement | null
64
- /** 滑动指示器类型,默认 slide */
65
- indicatorType?: IndicatorType
66
- /** 滑动指示器颜色,默认 red */
67
- color?: string
68
- /** 布局方向:top(上方栏)| left(左侧栏),默认 top */
69
- layout?: TabLayout
70
- /** 滚动到内容区时额外的顶部偏移像素,在 stickyTop + tabBar 高度基础上追加 */
71
- scrollOffset?: number
72
-
73
- /** 是否启用浮动模式(position: fixed),脱离文档流固定在视口位置 */
74
- float?: boolean
75
- /** 浮动模式下 left 布局的手动 left 偏移,不传则自动取元素初始位置 */
76
- floatLeft?: number
77
- /** 滚动超过此高度(px)后显示浮动栏,默认 0 表示一直显示 */
78
- floatThreshold?: number
79
- }
80
- ```
81
-
82
- ### useTabAnchor.ts
83
-
84
- ```ts
85
- import { ref, shallowRef, computed, watch, onMounted, onUnmounted } from 'vue'
86
- import type { Ref, MaybeRef } from 'vue'
87
- import { unref } from 'vue'
88
- import type { TabItem } from '@/types/anchor'
89
-
90
- interface UseTabAnchorOptions<T extends string | number = string> {
91
- /** Tab 配置数组 */
92
- tabs: MaybeRef<TabItem<T>[]>
93
- /** 各 tab 对应的内容区 DOM 引用 */
94
- contentRefs: Ref<(HTMLElement | null)[]>
95
- /** 受控的 activeKey(v-model) */
96
- activeKey?: Ref<T>
97
- /** 默认激活的 key */
98
- defaultActiveKey?: T
99
- /** sticky Tab 栏高度 + stickyTop 偏移 */
100
- headerOffset?: MaybeRef<number>
101
- /** 点击滚动时内容区的 scroll-margin-top 补偿 */
102
- scrollMarginTop?: MaybeRef<number>
103
- /** 滚动容器引用,作为 IntersectionObserver 的 root */
104
- scrollContainer?: Ref<HTMLElement | null>
105
- }
106
-
107
- interface UseTabAnchorReturn<T> {
108
- /** 当前激活的 key */
109
- activeKey: Ref<T>
110
- /** 是否正在动画滚动中(用于防连点) */
111
- isScrolling: Ref<boolean>
112
- /** 滚动到指定 Tab 对应的内容区 */
113
- scrollToTab: (key: T) => void
114
- }
115
-
116
- export function useTabAnchor<T extends string | number = string>(
117
- options: UseTabAnchorOptions<T>,
118
- ): UseTabAnchorReturn<T> {
119
- const {
120
- tabs: tabsRef,
121
- contentRefs,
122
- activeKey: externalActiveKey,
123
- defaultActiveKey,
124
- headerOffset = 0,
125
- scrollMarginTop = 0,
126
- scrollContainer,
127
- } = options
128
-
129
- const tabList = computed(() => unref(tabsRef))
130
-
131
- // 优先用外部受控 key,否则内部维护
132
- const internalActiveKey = shallowRef<T>((defaultActiveKey ?? tabList.value[0]?.key ?? '') as T)
133
- const activeKey = (externalActiveKey ?? internalActiveKey) as Ref<T>
134
- const isScrolling = ref(false)
135
-
136
- let observer: IntersectionObserver | null = null
137
- let resizeObserver: ResizeObserver | null = null
138
- let scrollTimer: number | null = null
139
-
140
- /* ---------- 获取某个 key 对应的 DOM 元素 ---------- */
141
- const getContentEl = (key: T): HTMLElement | null => {
142
- const index = tabList.value.findIndex((t) => t.key === key)
143
- return contentRefs.value[index] ?? null
144
- }
145
-
146
- /* ---------- 初始化/重建 IntersectionObserver ---------- */
147
- const initObserver = () => {
148
- destroyObserver()
149
-
150
- const elements: HTMLElement[] = []
151
- tabList.value.forEach((_, i) => {
152
- const el = contentRefs.value[i]
153
- if (el) elements.push(el)
154
- })
155
- if (elements.length === 0) return
156
-
157
- // rootMargin: 顶部留出 headerOffset,底部留出一半作为触发缓冲
158
- const offset = unref(headerOffset)
159
- observer = new IntersectionObserver(
160
- (entries) => {
161
- if (isScrolling.value) return
162
-
163
- // 只取可见元素,按可见面积降序排列
164
- const visible = entries
165
- .filter((e) => e.isIntersecting)
166
- .sort((a, b) => b.intersectionRatio - a.intersectionRatio)
167
-
168
- if (visible.length > 0) {
169
- const key = (visible[0]!.target as HTMLElement).dataset.key as T | undefined
170
- if (key !== undefined && key !== activeKey.value) {
171
- activeKey.value = key
172
- }
173
- }
174
- },
175
- {
176
- root: scrollContainer?.value ?? null,
177
- rootMargin: `-${offset}px 0px -${Math.max(offset * 0.5, 40)}px 0px`,
178
- threshold: [0, 0.25, 0.5, 0.75, 1],
179
- },
180
- )
181
- elements.forEach((el) => observer!.observe(el))
182
-
183
- // 内容区尺寸变化时重建 Observer
184
- resizeObserver = new ResizeObserver(() => {
185
- destroyObserver()
186
- initObserver()
187
- })
188
- const container = contentRefs.value[0]?.parentElement
189
- if (container) resizeObserver.observe(container)
190
- }
191
-
192
- const destroyObserver = () => {
193
- observer?.disconnect()
194
- observer = null
195
- resizeObserver?.disconnect()
196
- resizeObserver = null
197
- }
198
-
199
- /* ---------- 点击 Tab 滚动到对应内容区 ---------- */
200
- const scrollToTab = (key: T) => {
201
- const tab = tabList.value.find((t) => t.key === key)
202
- if (!tab || tab.disabled) return
203
-
204
- const el = getContentEl(key)
205
- if (!el) return
206
-
207
- isScrolling.value = true
208
- activeKey.value = key
209
-
210
- el.scrollIntoView({ behavior: 'smooth', block: 'start' })
211
-
212
- // 根据可视距离估算动画时长,防连点
213
- const distance = Math.abs(el.getBoundingClientRect().top - unref(scrollMarginTop))
214
- const duration = Math.min(Math.max(distance * 0.3, 200), 600)
215
-
216
- if (scrollTimer !== null) clearTimeout(scrollTimer)
217
- scrollTimer = window.setTimeout(() => {
218
- isScrolling.value = false
219
- }, duration)
220
- }
221
-
222
- /* ---------- 数据变化时重建 observer ---------- */
223
- watch(
224
- [() => tabList.value.length, () => unref(headerOffset)],
225
- () => initObserver(),
226
- )
227
-
228
- onMounted(() => {
229
- initObserver()
230
- })
231
-
232
- onUnmounted(() => {
233
- destroyObserver()
234
- if (scrollTimer !== null) clearTimeout(scrollTimer)
235
- })
236
-
237
- return { activeKey, isScrolling, scrollToTab }
238
- }
239
- ```
240
-
241
- ### useTabFloat.ts
242
-
243
- ```ts
244
- import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
245
- import type { Ref } from 'vue'
246
- import type { TabLayout } from '@/types/anchor'
247
-
248
- interface UseTabFloatOptions {
249
- tabBarRef: Ref<HTMLElement | null>
250
- float: Ref<boolean>
251
- layout: Ref<TabLayout>
252
- stickyTop: Ref<number>
253
- floatThreshold: Ref<number>
254
- floatLeft: Ref<number | undefined>
255
- /** 滚动容器,不传则监听 window */
256
- scrollContainer?: Ref<HTMLElement | null>
257
- }
258
-
259
- export function useTabFloat(options: UseTabFloatOptions) {
260
- const { tabBarRef, float, layout, stickyTop, floatThreshold, floatLeft, scrollContainer } =
261
- options
262
-
263
- /* 阈值 > 0 时初始隐藏,否则一直可见 */
264
- const floatVisible = ref(floatThreshold.value <= 0)
265
- /* 左侧布局下浮动的 left 值(自动取元素初始位置 / 用户指定) */
266
- const floatLeftValue = ref(0)
267
-
268
- /* 浮动栏定位样式 */
269
- const tabBarStyle = computed(() => {
270
- if (!float.value) return null
271
-
272
- const style: Record<string, string | number> = {
273
- position: 'fixed',
274
- top: `${stickyTop.value}px`,
275
- zIndex: 1000,
276
- }
277
- if (layout.value === 'left') {
278
- style.left = `${floatLeftValue.value}px`
279
- } else {
280
- style.left = '0'
281
- style.right = '0'
282
- style.width = '100%'
283
- }
284
- return style
285
- })
286
-
287
- /* 浮动占位元素尺寸 */
288
- const tabBarHeight = computed(() => tabBarRef.value?.offsetHeight ?? 44)
289
-
290
- /* mount 后初始化:监听滚动、计算 left 值 */
291
- onMounted(() => {
292
- if (!float.value) return
293
-
294
- // 无阈值(一直显示)时,直接测量 left 位置
295
- if (layout.value === 'left' && floatThreshold.value <= 0) {
296
- floatLeftValue.value = floatLeft.value ?? tabBarRef.value?.getBoundingClientRect().left ?? 0
297
- }
298
-
299
- // 阈值控制:监听滚动位置
300
- if (floatThreshold.value > 0) {
301
- const container = scrollContainer?.value ?? window
302
-
303
- const update = () => {
304
- const top =
305
- container === window
306
- ? window.scrollY || document.documentElement.scrollTop
307
- : (container as HTMLElement).scrollTop
308
- floatVisible.value = top > floatThreshold.value
309
- }
310
-
311
- update() // 初始化
312
- container.addEventListener('scroll', update, { passive: true })
313
- onUnmounted(() => container.removeEventListener('scroll', update))
314
- }
315
- })
316
-
317
- /* 有阈值时:首次显示浮动栏再测量 left 位置(此时元素可见,rect 才有效) */
318
- watch(floatVisible, (visible) => {
319
- if (visible && layout.value === 'left' && floatThreshold.value > 0 && floatLeftValue.value === 0) {
320
- floatLeftValue.value = floatLeft.value ?? tabBarRef.value?.getBoundingClientRect().left ?? 0
321
- }
322
- })
323
-
324
- return { tabBarStyle, tabBarHeight, floatVisible }
325
- }
326
- ```
327
-
328
- ### useTabIndicator.ts
329
-
330
- ```ts
331
- import { ref, watch, nextTick } from 'vue'
332
- import type { Ref } from 'vue'
333
- import type { IndicatorType, TabLayout } from '@/types/anchor'
334
-
335
- interface UseTabIndicatorOptions {
336
- /** Tab 栏 DOM 引用 */
337
- tabBarRef: Ref<HTMLElement | null>
338
- /** 当前激活 key */
339
- activeKey: Ref<string | number>
340
- /** 指示器类型,非 slide 模式不计算位置 */
341
- indicatorType: IndicatorType
342
- /** 布局方向,影响指示器运动轴(水平/垂直) */
343
- layout: TabLayout
344
- }
345
-
346
- /**
347
- * 滑动指示器位置计算
348
- * 监听 activeKey 变化,计算激活 tab 在 tabBar 中的偏移位置,
349
- * 通过 translate 驱动指示器跟随动画,支持 top/left 两种布局。
350
- */
351
- export function useTabIndicator(options: UseTabIndicatorOptions) {
352
- const { tabBarRef, activeKey, indicatorType, layout } = options
353
-
354
- const isHorizontal = layout !== 'left'
355
- const indicatorStyle = ref<Record<string, string>>(
356
- isHorizontal
357
- ? { width: '0', transform: 'translateX(0)' }
358
- : { height: '0', transform: 'translateY(0)' },
359
- )
360
-
361
- watch(
362
- activeKey,
363
- async () => {
364
- if (indicatorType !== 'slide') return
365
- await nextTick()
366
- if (!tabBarRef.value) return
367
- const activeEl = tabBarRef.value.querySelector('.tab-item--active') as HTMLElement | null
368
- if (!activeEl) return
369
-
370
- const parentRect = tabBarRef.value.getBoundingClientRect()
371
- const rect = activeEl.getBoundingClientRect()
372
-
373
- if (layout === 'left') {
374
- indicatorStyle.value = {
375
- height: `${rect.height * 0.6}px`,
376
- transform: `translateY(${rect.top - parentRect.top + rect.height * 0.2}px)`,
377
- }
378
- } else {
379
- indicatorStyle.value = {
380
- width: `${rect.width * 0.6}px`,
381
- transform: `translateX(${rect.left - parentRect.left + rect.width * 0.2}px)`,
382
- }
383
- }
384
- },
385
- { immediate: true },
386
- )
387
-
388
- return { indicatorStyle }
389
- }
390
- ```
391
-
392
- ### TabAnchor.vue
393
-
394
- ```vue
395
- <template>
396
- <div ref="containerRef" class="tab-anchor" :class="{ 'tab-anchor--left': layout === 'left' }">
397
- <!-- ==================== 浮动占位(防止布局塌陷) ==================== -->
398
- <div
399
- v-if="float && floatVisible"
400
- class="tab-bar-placeholder"
401
- :class="{ 'tab-bar-placeholder--left': layout === 'left' }"
402
- />
403
-
404
- <!-- ==================== Tab 栏 ==================== -->
405
- <div
406
- ref="tabBarRef"
407
- class="tab-bar"
408
- :class="{
409
- 'tab-bar--sticky': sticky && !float,
410
- 'tab-bar--float': float,
411
- 'tab-bar--scale': indicatorType === 'scale',
412
- 'tab-bar--left': layout === 'left',
413
- }"
414
- :style="tabBarStyle"
415
- v-show="!float || floatVisible"
416
- >
417
- <button
418
- v-for="tab in tabs"
419
- :key="tab.key"
420
- class="tab-item"
421
- :class="{
422
- 'tab-item--active': activeKey === tab.key,
423
- 'tab-item--disabled': tab.disabled,
424
- }"
425
- :disabled="tab.disabled"
426
- @click="scrollToTab(tab.key)"
427
- >
428
- <slot name="tab" :item="tab" :active="activeKey === tab.key" :index="tabs.indexOf(tab)">
429
- <span class="tab-label">{{ tab.label }}</span>
430
- </slot>
431
- </button>
432
-
433
- <!-- 滑动指示器(仅 slide 模式) -->
434
- <span v-if="indicatorType === 'slide'" class="tab-indicator" :style="indicatorStyle" />
435
- </div>
436
-
437
- <!-- ==================== 内容区(自动模式) ==================== -->
438
- <div v-if="!manual" class="tab-content" :class="{ 'tab-content--left': layout === 'left' }">
439
- <section
440
- v-for="(tab, i) in tabs"
441
- :key="tab.key"
442
- :ref="(el: any) => setContentRef(el, i)"
443
- :data-key="tab.key"
444
- class="tab-section"
445
- :style="{ scrollMarginTop: `${scrollMarginTop}px` }"
446
- >
447
- <slot :item="tab" :index="i" />
448
- </section>
449
- </div>
450
- </div>
451
- </template>
452
-
453
- <script setup lang="ts" generic="T extends string | number">
454
- import { ref, computed, toValue } from 'vue'
455
- import type { TabAnchorProps } from '@/types/anchor'
456
- import { useTabAnchor } from '@/hooks/useTabAnchor'
457
- import { useTabIndicator } from '@/hooks/useTabIndicator'
458
- import { useTabFloat } from '@/hooks/useTabFloat'
459
-
460
- /* ==================== 组件接口 ==================== */
461
-
462
- const props = withDefaults(defineProps<TabAnchorProps<T>>(), {
463
- sticky: false,
464
- stickyTop: 0,
465
- manual: false,
466
- indicatorType: 'slide',
467
- color: 'red',
468
- layout: 'top',
469
- float: false,
470
- floatThreshold: 0,
471
- })
472
-
473
- const emit = defineEmits<{
474
- 'update:modelValue': [value: T]
475
- }>()
476
-
477
- /* ==================== DOM 引用 ==================== */
478
-
479
- const containerRef = ref<HTMLElement | null>(null)
480
- const tabBarRef = ref<HTMLElement | null>(null)
481
- const contentRefs = ref<(HTMLElement | null)[]>([])
482
-
483
- /* ==================== 滚动容器解析 ==================== */
484
- /* 优先级:外部传入 > 父级可滚动元素 > 组件自身 */
485
-
486
- const scrollContainer = computed(
487
- () => props.scrollContainer ?? containerRef.value?.parentElement ?? containerRef.value,
488
- )
489
-
490
- /* ==================== 浮动模式 ==================== */
491
-
492
- const { tabBarStyle: floatStyle, tabBarHeight, floatVisible } = useTabFloat({
493
- tabBarRef,
494
- float: computed(() => props.float),
495
- layout: computed(() => props.layout),
496
- stickyTop: computed(() => props.stickyTop),
497
- floatThreshold: computed(() => props.floatThreshold),
498
- floatLeft: computed(() => props.floatLeft),
499
- scrollContainer: scrollContainer,
500
- })
501
-
502
- /* float 优先,fallback 到 sticky */
503
- const tabBarStyle = computed(() => {
504
- if (props.float) return floatStyle.value
505
- if (props.sticky) return { top: `${props.stickyTop}px`, zIndex: 10 }
506
- return undefined
507
- })
508
-
509
- /* ==================== 内容区引用管理 ==================== */
510
- /* 自动模式:收集模板中 section 的 DOM 引用到数组;手动模式:使用外部传入的 contentRefs */
511
-
512
- const setContentRef = (el: any, index: number) => {
513
- contentRefs.value[index] = el as HTMLElement | null
514
- }
515
-
516
- const resolvedContentRefs = computed(() =>
517
- props.manual && props.contentRefs ? toValue(props.contentRefs) : contentRefs.value,
518
- )
519
-
520
- /* sticky 吸顶 + tab 栏高度偏移量,用于 IntersectionObserver rootMargin 和 scrollIntoView 补偿 */
521
- const headerOffset = computed(() => {
522
- if (props.layout === 'left') return props.stickyTop
523
- return props.stickyTop + (tabBarRef.value?.offsetHeight ?? 44)
524
- })
525
-
526
- /* ==================== ActiveKey 双向绑定 ==================== */
527
-
528
- const activeKey = computed({
529
- get: () => props.modelValue ?? ((props.tabs[0]?.key ?? '') as T),
530
- set: (val: T) => emit('update:modelValue', val),
531
- })
532
-
533
- /* ==================== 滑动指示器 ==================== */
534
-
535
- const { indicatorStyle } = useTabIndicator({
536
- tabBarRef,
537
- activeKey,
538
- indicatorType: props.indicatorType,
539
- layout: props.layout,
540
- })
541
-
542
- const indicatorColor = computed(() => props.color)
543
-
544
- /* ==================== 核心锚点滚动逻辑 ==================== */
545
-
546
- const scrollMarginTop = computed(() => headerOffset.value + (props.scrollOffset ?? 0))
547
-
548
- const { scrollToTab } = useTabAnchor({
549
- tabs: computed(() => props.tabs),
550
- contentRefs: resolvedContentRefs,
551
- activeKey,
552
- headerOffset,
553
- scrollMarginTop,
554
- scrollContainer: scrollContainer,
555
- })
556
-
557
- /* ==================== 暴露方法 ==================== */
558
-
559
- defineExpose({ scrollToTab })
560
- </script>
561
-
562
- <style scoped>
563
- .tab-anchor {
564
- position: relative;
565
- }
566
-
567
- /* ===== Tab 栏 ===== */
568
- .tab-bar {
569
- display: flex;
570
- align-items: stretch;
571
- position: relative;
572
- background: #fff;
573
- border-bottom: 1px solid #f0f0f0;
574
- width: 100%;
575
- overflow-x: auto;
576
- scrollbar-width: none;
577
- }
578
-
579
- .tab-bar::-webkit-scrollbar {
580
- display: none;
581
- }
582
-
583
- .tab-bar--sticky {
584
- position: sticky;
585
- }
586
-
587
- /* ===== 浮动模式 ===== */
588
- .tab-bar--float {
589
- position: fixed;
590
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
591
- }
592
-
593
- .tab-bar--float.tab-bar--left {
594
- box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
595
- }
596
-
597
- /* 浮动占位元素:防止布局塌陷 */
598
- .tab-bar-placeholder {
599
- height: v-bind('tabBarHeight + "px"');
600
- }
601
-
602
- .tab-bar-placeholder--left {
603
- width: var(--tab-bar-width);
604
- height: auto;
605
- flex-shrink: 0;
606
- }
607
-
608
- /* ===== 单个 Tab ===== */
609
- .tab-item {
610
- flex: 1;
611
- display: flex;
612
- align-items: center;
613
- justify-content: center;
614
- padding: 12px 8px;
615
- border: none;
616
- background: transparent;
617
- cursor: pointer;
618
- color: #666;
619
- font-size: 14px;
620
- line-height: 1.4;
621
- transition: color 0.25s;
622
- user-select: none;
623
- -webkit-tap-highlight-color: transparent;
624
- }
625
-
626
- .tab-item:hover {
627
- color: #333;
628
- }
629
-
630
- .tab-item--active {
631
- color: #333;
632
- font-weight: 600;
633
- }
634
-
635
- .tab-item--disabled {
636
- color: #ccc;
637
- cursor: not-allowed;
638
- }
639
-
640
- .tab-label {
641
- white-space: nowrap;
642
- overflow: hidden;
643
- text-overflow: ellipsis;
644
- }
645
-
646
- /* ===== 滑动指示器 ===== */
647
- .tab-indicator {
648
- position: absolute;
649
- bottom: 0;
650
- left: 0;
651
- height: 2px;
652
- border-radius: 1px;
653
- background: v-bind('indicatorColor');
654
- transition:
655
- transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1),
656
- width 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
657
- pointer-events: none;
658
- }
659
-
660
- /* ===== Scale 模式:每个 tab 自带伪元素指示器 ===== */
661
- .tab-bar--scale .tab-item {
662
- position: relative;
663
- }
664
-
665
- .tab-bar--scale .tab-item::after {
666
- content: '';
667
- position: absolute;
668
- bottom: 0;
669
- left: 25%;
670
- right: 25%;
671
- height: 2px;
672
- border-radius: 1px;
673
- background: v-bind('indicatorColor');
674
- transform: scaleX(0);
675
- transition: transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
676
- }
677
-
678
- .tab-bar--scale .tab-item--active::after {
679
- transform: scaleX(1);
680
- }
681
-
682
- /* ===== 内容区 ===== */
683
- .tab-content {
684
- position: relative;
685
- flex: 1;
686
- }
687
-
688
- /* ====================================================
689
- 左侧布局
690
- ==================================================== */
691
- .tab-anchor--left {
692
- display: flex;
693
- flex-direction: row;
694
- align-items: flex-start;
695
- --tab-bar-width: 85px;
696
- }
697
-
698
- .tab-bar--left {
699
- flex-direction: column;
700
- width: var(--tab-bar-width);
701
- flex-shrink: 0;
702
- overflow-x: hidden;
703
- overflow-y: auto;
704
- border-bottom: none;
705
- border-right: 1px solid #f0f0f0;
706
- }
707
-
708
- .tab-bar--left .tab-item {
709
- flex: none;
710
- width: 100%;
711
- padding: 14px 8px;
712
- }
713
-
714
- /* 左侧布局滑动指示器 → 右侧竖条 */
715
- .tab-bar--left .tab-indicator {
716
- bottom: auto;
717
- top: 0;
718
- left: auto;
719
- right: 0;
720
- width: 2px;
721
- height: 20px;
722
- transition:
723
- transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1),
724
- height 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
725
- }
726
-
727
- /* 左侧布局 Scale 指示器 */
728
- .tab-bar--scale.tab-bar--left .tab-item::after {
729
- bottom: auto;
730
- top: 25%;
731
- bottom: 25%;
732
- left: auto;
733
- right: 0;
734
- width: 2px;
735
- height: auto;
736
- transform: scaleY(0);
737
- }
738
-
739
- .tab-bar--scale.tab-bar--left .tab-item--active::after {
740
- transform: scaleY(1);
741
- }
742
-
743
- /* sticky 吸左 */
744
- .tab-bar--sticky.tab-bar--left {
745
- position: sticky;
746
- top: v-bind('stickyTop + "px"');
747
- }
748
- </style>
749
- ```
750
-
751
- ## 核心机制说明
752
-
753
- ### IntersectionObserver 自动追踪
754
-
755
- 组件通过 `useTabAnchor` 创建 IntersectionObserver,监听各内容区 section 的可见性:
756
-
757
- - **root**:取 `props.scrollContainer` 或父级可滚动元素
758
- - **rootMargin**:`-${offset}px 0px -${Math.max(offset * 0.5, 40)}px 0px`,顶部切除 Tab 栏高度,底部留缓冲
759
- - **threshold**:`[0, 0.25, 0.5, 0.75, 1]` 精细追踪
760
- - **排序**:可见元素按 `intersectionRatio` 降序取最大的
761
- - **防连点**:动画滚动期间 `isScrolling = true`,跳过 Observer 回调
762
-
763
- ### scrollIntoView 点击滚动
764
-
765
- 点击 Tab 按钮时调用 `el.scrollIntoView({ behavior: 'smooth', block: 'start' })`,并估算动画时长防止连点:
766
-
767
- ```ts
768
- const distance = Math.abs(el.getBoundingClientRect().top - unref(scrollMarginTop))
769
- const duration = Math.min(Math.max(distance * 0.3, 200), 600)
770
- ```
771
-
772
- ### float 浮动模式
773
-
774
- - `float` 启用 `position: fixed`
775
- - `floatThreshold > 0` 时初始隐藏,滚动超过阈值后显示
776
- - 浮动模式产生占位元素防止布局塌陷
777
- - float 优先级高于 sticky
778
-
779
- ### slide 指示器位置计算
780
-
781
- - 取 `.tab-item--active` 元素和 `tabBarRef` 的 `getBoundingClientRect`
782
- - 水平布局:width = 60%,translateX = left offset + 20% width
783
- - 垂直布局:height = 60%,translateY = top offset + 20% height
784
-
785
- ## Props 总览
786
-
787
- | Prop | 类型 | 默认值 | 说明 |
788
- |------|------|--------|------|
789
- | `tabs` | `TabItem<T>[]` | — | Tab 配置数组 |
790
- | `modelValue` | `T` | 第一个 tab 的 key | v-model 双向绑定 |
791
- | `defaultActiveKey` | `T` | 第一个 tab 的 key | 非受控默认值 |
792
- | `sticky` | `boolean` | `false` | sticky 吸顶 |
793
- | `stickyTop` | `number` | `0` | 吸顶/浮动偏移量 |
794
- | `manual` | `boolean` | `false` | 手动模式 |
795
- | `contentRefs` | `MaybeRef<(HTMLElement\|null)[]>` | — | 手动模式传 refs |
796
- | `scrollContainer` | `HTMLElement\|null` | 父级滚动元素 | 局部滚动容器 |
797
- | `indicatorType` | `'slide'\|'scale'` | `'slide'` | 指示器类型 |
798
- | `color` | `string` | `'red'` | 指示器颜色 |
799
- | `layout` | `'top'\|'left'` | `'top'` | 布局方向 |
800
- | `scrollOffset` | `number` | — | 额外偏移补偿 |
801
- | `float` | `boolean` | `false` | 浮动模式 |
802
- | `floatLeft` | `number` | — | 左侧浮动 left 值 |
803
- | `floatThreshold` | `number` | `0` | 浮动显示阈值 |
804
-
805
- ## 插槽 & 事件 & Expose
806
-
807
- ### Slots
808
-
809
- | 名称 | 绑定值 | 说明 |
810
- |------|--------|------|
811
- | `default` | `{ item: TabItem<T>, index: number }` | 内容区 |
812
- | `tab` | `{ item: TabItem<T>, active: boolean, index: number }` | 自定义 Tab 按钮 |
813
-
814
- ### Events
815
-
816
- | 事件 | 回调 | 说明 |
817
- |------|------|------|
818
- | `update:modelValue` | `(value: T) => void` | v-model |
819
-
820
- ### Expose
821
-
822
- | 方法 | 签名 | 说明 |
823
- |------|------|------|
824
- | `scrollToTab` | `(key: T) => void` | 滚动到指定 Tab |
825
-
826
- ## 完整示例
827
-
828
- ### 01 — 基础 v-model
829
-
830
- ```vue
831
- <script setup lang="ts">
832
- import { ref } from 'vue'
833
- import TabAnchor from '@/components/TabAnchor.vue'
834
- import type { TabItem } from '@/types/anchor'
835
-
836
- const tabs: TabItem[] = [
837
- { label: '推荐', key: 'recommend' },
838
- { label: '景点', key: 'spot' },
839
- { label: '酒店', key: 'hotel' },
840
- ]
841
- const activeKey = ref('recommend')
842
- </script>
843
-
844
- <template>
845
- <TabAnchor v-model="activeKey" :tabs="tabs">
846
- <template #default="{ item }">
847
- <div style="padding: 16px; min-height: 300px">{{ item.label }} 详情内容...</div>
848
- </template>
849
- </TabAnchor>
850
- </template>
851
- ```
852
-
853
- ### 02 — sticky 吸顶
854
-
855
- ```vue
856
- <TabAnchor v-model="activeKey" :tabs="tabs" sticky :stickyTop="0" color="#07c160">
857
- <template #default="{ item }">
858
- <div style="height: 600px">{{ item.label }} 内容</div>
859
- </template>
860
- </TabAnchor>
861
- ```
862
-
863
- ### 03 — float 浮动 + threshold
864
-
865
- ```vue
866
- <TabAnchor
867
- v-model="activeKey" :tabs="tabs"
868
- float :stickyTop="0" :floatThreshold="300" color="#1989fa"
869
- >
870
- <template #default="{ item }">
871
- <div style="height: 800px">{{ item.label }} 内容</div>
872
- </template>
873
- </TabAnchor>
874
- ```
875
-
876
- ### 04 — left 侧边布局
877
-
878
- ```vue
879
- <TabAnchor v-model="activeKey" :tabs="tabs" layout="left" color="#ee0a24">
880
- <template #default="{ item }">
881
- <div style="height: 400px; padding: 16px">{{ item.label }} 内容</div>
882
- </template>
883
- </TabAnchor>
884
- ```
885
-
886
- ### 05 — left + float
887
-
888
- ```vue
889
- <TabAnchor
890
- v-model="activeKey" :tabs="tabs"
891
- layout="left" float :stickyTop="0" color="#7232dd"
892
- >
893
- <template #default="{ item }">
894
- <div style="height: 600px; padding: 16px">{{ item.label }} 内容</div>
895
- </template>
896
- </TabAnchor>
897
- ```
898
-
899
- ### 06 — left + float + 自定义偏移
900
-
901
- ```vue
902
- <TabAnchor
903
- v-model="activeKey" :tabs="tabs"
904
- layout="left" float :floatLeft="76" color="#fa36"
905
- >
906
- <template #default="{ item }">
907
- <div style="height: 400px; padding: 16px">{{ item.label }} 内容</div>
908
- </template>
909
- </TabAnchor>
910
- ```
911
-
912
- ### 07 — scale 指示器
913
-
914
- ```vue
915
- <TabAnchor v-model="activeKey" :tabs="tabs" indicator-type="scale" color="#07c160">
916
- <template #default="{ item }">
917
- <div style="height: 500px">{{ item.label }}</div>
918
- </template>
919
- </TabAnchor>
920
- ```
921
-
922
- ### 08 — left + scale
923
-
924
- ```vue
925
- <TabAnchor
926
- v-model="activeKey" :tabs="tabs"
927
- layout="left" indicator-type="scale" color="#ff976a"
928
- >
929
- <template #default="{ item }">
930
- <div style="height: 400px; padding: 16px">{{ item.label }}</div>
931
- </template>
932
- </TabAnchor>
933
- ```
934
-
935
- ### 09 — 自定义 Tab 按钮
936
-
937
- ```vue
938
- <TabAnchor v-model="activeKey" :tabs="tabs">
939
- <template #tab="{ item, active }">
940
- <span :style="{ fontWeight: active ? 700 : 400, color: active ? '#ee0a24' : '#666' }">
941
- {{ item.label }}
942
- </span>
943
- </template>
944
- <template #default="{ item }">
945
- <div style="height: 400px; padding: 16px">{{ item.label }} 内容</div>
946
- </template>
947
- </TabAnchor>
948
- ```
949
-
950
- ### 10 — 手动模式
951
-
952
- ```vue
953
- <script setup lang="ts">
954
- import { ref } from 'vue'
955
- import TabAnchor from '@/components/TabAnchor.vue'
956
- import type { TabItem } from '@/types/anchor'
957
-
958
- const tabs: TabItem[] = [
959
- { label: '简介', key: 'intro' },
960
- { label: '参数', key: 'params' },
961
- { label: '评价', key: 'reviews' },
962
- ]
963
- const activeKey = ref('intro')
964
- const contentRefs = ref<(HTMLElement | null)[]>([])
965
- const tabAnchorRef = ref<InstanceType<typeof TabAnchor>>()
966
- </script>
967
-
968
- <template>
969
- <TabAnchor ref="tabAnchorRef" v-model="activeKey" :tabs="tabs" manual :contentRefs="contentRefs" sticky :stickyTop="0" />
970
-
971
- <div ref="el => contentRefs[0] = el" data-key="intro" style="height: 400px">简介</div>
972
- <div ref="el => contentRefs[1] = el" data-key="params" style="height: 400px">参数</div>
973
- <div ref="el => contentRefs[2] = el" data-key="reviews" style="height: 400px">评价</div>
974
-
975
- <van-button @click="tabAnchorRef?.scrollToTab('reviews')">跳转评价</van-button>
976
- </template>
977
- ```
978
-
979
- ### 11 — 局部滚动容器
980
-
981
- ```vue
982
- <script setup lang="ts">
983
- import { ref } from 'vue'
984
- import TabAnchor from '@/components/TabAnchor.vue'
985
- import type { TabItem } from '@/types/anchor'
986
-
987
- const tabs: TabItem[] = [
988
- { label: '区域一', key: 'zone1' },
989
- { label: '区域二', key: 'zone2' },
990
- ]
991
- const activeKey = ref('zone1')
992
- const scrollContainerRef = ref<HTMLElement | null>(null)
993
- </script>
994
-
995
- <template>
996
- <div ref="scrollContainerRef" style="height: 400px; overflow-y: auto; border: 1px solid #eee">
997
- <TabAnchor v-model="activeKey" :tabs="tabs" sticky :scrollContainer="scrollContainerRef">
998
- <template #default="{ item }">
999
- <div style="height: 800px; padding: 16px">{{ item.label }} 内容</div>
1000
- </template>
1001
- </TabAnchor>
1002
- </div>
1003
- </template>
1004
- ```
1005
-
1006
- ### 12 — scrollOffset 补偿
1007
-
1008
- 页面顶部有固定导航栏和二级栏,合计 92px:
1009
-
1010
- ```vue
1011
- <TabAnchor
1012
- v-model="activeKey" :tabs="tabs"
1013
- sticky :stickyTop="46" :scrollOffset="46"
1014
- >
1015
- <template #default="{ item }">
1016
- <div style="height: 600px">{{ item.label }}</div>
1017
- </template>
1018
- </TabAnchor>
1019
- ```
1020
-
1021
- ### 13 — 禁用 Tab + 正上方导航
1022
-
1023
- ```vue
1024
- <script setup lang="ts">
1025
- const tabs: TabItem[] = [
1026
- { label: '可用', key: 'active' },
1027
- { label: '已禁用', key: 'disabled', disabled: true },
1028
- { label: '可用二', key: 'active2' },
1029
- ]
1030
- </script>
1031
-
1032
- <TabAnchor v-model="activeKey" :tabs="tabs" sticky :stickyTop="46" color="#07c160">
1033
- <template #default="{ item }">
1034
- <div style="height: 600px">{{ item.label }} 内容</div>
1035
- </template>
1036
- </TabAnchor>
1037
- ```
1038
-
1039
- ### 14 — 完整的页面级示例
1040
-
1041
- ```vue
1042
- <script setup lang="ts">
1043
- import { ref } from 'vue'
1044
- import TabAnchor from '@/components/TabAnchor.vue'
1045
- import type { TabItem } from '@/types/anchor'
1046
-
1047
- const tabs: TabItem[] = [
1048
- { label: '景点', key: 'spot' },
1049
- { label: '酒店', key: 'hotel' },
1050
- { label: '美食', key: 'food' },
1051
- { label: '玩乐', key: 'fun' },
1052
- ]
1053
- const activeKey = ref('spot')
1054
- </script>
1055
-
1056
- <template>
1057
- <div class="page">
1058
- <!-- 顶部导航栏 -->
1059
- <div class="page-header">详情页</div>
1060
-
1061
- <!-- Tab 锚点组件 -->
1062
- <TabAnchor
1063
- v-model="activeKey"
1064
- :tabs="tabs"
1065
- sticky
1066
- :stickyTop="46"
1067
- color="#ff6b35"
1068
- >
1069
- <template #default="{ item }">
1070
- <div class="section-content" v-if="item.key === 'spot'">
1071
- <img src="https://example.com/scenic.jpg" alt="景点" />
1072
- <p>景点详细介绍...</p>
1073
- </div>
1074
- <div class="section-content" v-else-if="item.key === 'hotel'">
1075
- <h3>推荐酒店</h3>
1076
- <div class="hotel-card">酒店 1</div>
1077
- <div class="hotel-card">酒店 2</div>
1078
- </div>
1079
- <div class="section-content" v-else-if="item.key === 'food'">
1080
- <p>美食推荐...</p>
1081
- </div>
1082
- <div class="section-content" v-else>
1083
- <p>娱乐项目...</p>
1084
- </div>
1085
- </template>
1086
- </TabAnchor>
1087
- </div>
1088
- </template>
1089
-
1090
- <style scoped>
1091
- .page-header {
1092
- position: sticky;
1093
- top: 0;
1094
- z-index: 100;
1095
- height: 46px;
1096
- display: flex;
1097
- align-items: center;
1098
- justify-content: center;
1099
- background: #fff;
1100
- border-bottom: 1px solid #f0f0f0;
1101
- }
1102
- .section-content {
1103
- padding: 16px;
1104
- min-height: 500px;
1105
- }
1106
- .hotel-card {
1107
- padding: 12px;
1108
- margin-bottom: 8px;
1109
- background: #f5f5f5;
1110
- border-radius: 8px;
1111
- }
1112
- </style>
1113
- ```
1114
-
1115
- ## 常见问题
1116
-
1117
- | 场景 | 处理方式 |
1118
- |------|---------|
1119
- | 页面顶部有固定导航栏 | `:stickyTop="导航栏高度"` 让 Tab 栏定位在导航栏下方 |
1120
- | 导航栏 + 二级栏 | `:stickyTop="46" :scrollOffset="46"` |
1121
- | 需要跳到非当前 Tab | 调用 `scrollToTab(key)` 或直接改 `modelValue` |
1122
- | 内容区有图片加载导致高度变化 | `ResizeObserver` 自动重建 Observer |
1123
- | 浮动模式下左侧布局位置不准 | 用 `:floatLeft="数值"` 手动指定 left |
1124
- | 只想要 Tab 切换不要自动检测 | 用 `manual` 模式自行管理内容区 |
1125
- | 禁用 Tab | `disabled: true`,灰色不可点击 |