TechTabs.vue 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. <template>
  2. <div class="tech-tabs-container" :class="`is-${type}`" @mouseenter="pauseTimer" @mouseleave="resumeTimer">
  3. <div class="tabs-header">
  4. <div v-if="type === 'segmented'" class="segmented-slider" :style="sliderStyle"></div>
  5. <div
  6. v-for="(pane, index) in panes"
  7. :key="index"
  8. class="tab-item"
  9. :class="{ 'is-active': value === pane.name }"
  10. @click="handleTabClick(pane.name)"
  11. >
  12. {{ pane.label }}
  13. </div>
  14. </div>
  15. <div class="tabs-content">
  16. <slot></slot>
  17. </div>
  18. </div>
  19. </template>
  20. <script>
  21. export default {
  22. name: 'TechTabs',
  23. props: {
  24. // 绑定的当前选中的 tab name (v-model)
  25. value: { type: [String, Number], required: true },
  26. // 风格类型:'underline' (下划线大Tab) 或 'segmented' (分段胶囊小Tab)
  27. type: { type: String, default: 'underline' },
  28. // 是否开启自动轮播
  29. autoPlay: { type: Boolean, default: false },
  30. // 轮播间隔时间 (毫秒),默认 3 秒
  31. interval: { type: Number, default: 3000 }
  32. },
  33. data() {
  34. return {
  35. panes: [], // 自动收集的所有子面板实例
  36. timer: null, // 定时器实例
  37. isHovering: false // 记录鼠标是否悬浮在组件上
  38. };
  39. },
  40. computed: {
  41. // 计算当前选中项的索引
  42. activeIndex() {
  43. const index = this.panes.findIndex(pane => pane.name === this.value);
  44. return index === -1 ? 0 : index;
  45. },
  46. // 动态计算滑块的宽度和偏移量
  47. sliderStyle() {
  48. const total = this.panes.length || 1;
  49. return {
  50. // 宽度平分
  51. width: `${100 / total}%`,
  52. // 根据索引移动自己的宽度倍数 (100% 就是移动一个滑块的距离)
  53. transform: `translateX(${this.activeIndex * 100}%)`
  54. };
  55. }
  56. },
  57. watch: {
  58. // 监听 autoPlay 的变化,支持动态开启/关闭
  59. autoPlay(newVal) {
  60. newVal ? this.startTimer() : this.stopTimer();
  61. }
  62. },
  63. mounted() {
  64. // 等待所有的 TechTabPane 子组件注册完毕后,启动定时器
  65. this.$nextTick(() => {
  66. if (this.autoPlay) {
  67. this.startTimer();
  68. }
  69. });
  70. },
  71. beforeDestroy() {
  72. // 销毁时务必清理定时器,防止内存泄漏
  73. this.stopTimer();
  74. },
  75. methods: {
  76. handleTabClick(name) {
  77. if (this.value !== name) {
  78. this.$emit('input', name);
  79. this.$emit('tab-click', name);
  80. // 用户手动干预后,重置定时器,重新开始倒计时
  81. if (this.autoPlay && !this.isHovering) {
  82. this.startTimer();
  83. }
  84. }
  85. },
  86. // 切换到下一个
  87. switchToNext() {
  88. if (this.panes.length <= 1) return;
  89. // 找到当前选中项的索引
  90. const currentIndex = this.panes.findIndex(pane => pane.name === this.value);
  91. // 计算下一个索引 (到了最后一个就回到第一个)
  92. let nextIndex = currentIndex + 1;
  93. if (nextIndex >= this.panes.length) {
  94. nextIndex = 0;
  95. }
  96. // 拿到下一个的 name 并触发更新
  97. const nextName = this.panes[nextIndex].name;
  98. this.$emit('input', nextName);
  99. this.$emit('tab-click', nextName);
  100. },
  101. // 启动定时器
  102. startTimer() {
  103. this.stopTimer(); // 启动前先清空旧的,防止多开定时器飙车
  104. if (this.panes.length > 1) {
  105. this.timer = setInterval(() => {
  106. this.switchToNext();
  107. }, this.interval);
  108. }
  109. },
  110. // 停止定时器
  111. stopTimer() {
  112. if (this.timer) {
  113. clearInterval(this.timer);
  114. this.timer = null;
  115. }
  116. },
  117. // 鼠标悬浮时暂停自动切换,方便用户阅读数据
  118. pauseTimer() {
  119. this.isHovering = true;
  120. if (this.autoPlay) this.stopTimer();
  121. },
  122. // 鼠标离开后恢复自动切换
  123. resumeTimer() {
  124. this.isHovering = false;
  125. if (this.autoPlay) this.startTimer();
  126. },
  127. // 供子组件 TechTabPane 注册自己
  128. addPane(pane) {
  129. this.panes.push(pane);
  130. },
  131. // 供子组件销毁时移除自己
  132. removePane(pane) {
  133. const index = this.panes.indexOf(pane);
  134. if (index !== -1) this.panes.splice(index, 1);
  135. }
  136. }
  137. };
  138. </script>
  139. <style scoped>
  140. .tech-tabs-container {
  141. width: 100%;
  142. display: flex;
  143. flex-direction: column;
  144. }
  145. .tabs-content {
  146. flex: 1;
  147. padding-top: 15px; /* 内容与头部的间距 */
  148. }
  149. /* ================== 风格 1:下划线类型 (is-underline) ================== */
  150. .is-underline .tabs-header {
  151. display: flex;
  152. align-items: center;
  153. gap: 20px;
  154. padding: 0 10px;
  155. justify-content: space-between;
  156. }
  157. .is-underline .tab-item {
  158. color: #ffffff;
  159. font-size: 18px;
  160. line-height: 25px;
  161. font-weight: bold;
  162. padding: 10px 4px;
  163. cursor: pointer;
  164. position: relative;
  165. transition: all 0.3s;
  166. letter-spacing: 1px;
  167. user-select: none;
  168. }
  169. .is-underline>.tabs-header .tab-item:hover { color: #e2e8f0; }
  170. .is-underline>.tabs-header .tab-item.is-active {
  171. color: #1FCEFB;
  172. text-shadow: 0 0 8px rgba(0, 229, 255, 0.4);
  173. }
  174. /* 选中态的底部发光下划线 */
  175. .is-underline>.tabs-header .tab-item::after {
  176. content: '';
  177. position: absolute;
  178. bottom: -1px;
  179. left: 50%;
  180. transform: translateX(-50%);
  181. width: 0;
  182. height: 2px;
  183. background-color: #1FCEFB;
  184. box-shadow: 0 0 8px #1FCEFB;
  185. transition: width 0.3s ease;
  186. }
  187. .is-underline>.tabs-header .tab-item.is-active::after { width: 100%; }
  188. /* ================== 风格 2:分段胶囊类型 (is-segmented) ================== */
  189. .is-segmented .tabs-header {
  190. position: relative;
  191. display: flex;
  192. align-items: center;
  193. background: rgba(119,161,255,0.14);
  194. border: 1px solid rgba(119,161,255,0.2);
  195. overflow: hidden;
  196. height: 28px;
  197. gap: 0;
  198. padding: 0;
  199. }
  200. /* 独立的高亮滑块 */
  201. .segmented-slider {
  202. position: absolute;
  203. top: 0;
  204. left: 0;
  205. height: 100%;
  206. background: linear-gradient( 180deg, rgba(119,161,255,0) 0%, #77A1FF 100%);
  207. border: 1px solid rgba(161,190,255,0.7);
  208. border-radius: 2px;
  209. box-sizing: border-box;
  210. z-index: 0;
  211. transition: transform 0.35s cubic-bezier(0.645, 0.045, 0.355, 1);
  212. }
  213. .is-segmented .tab-item {
  214. flex: 1;
  215. position: relative;
  216. z-index: 1;
  217. display: flex;
  218. justify-content: center;
  219. align-items: center;
  220. height: 100%;
  221. color: rgba(255, 255, 255, 0.65);
  222. font-size: 14px;
  223. line-height: 20px;
  224. cursor: pointer;
  225. transition: all 0.3s;
  226. border-right: 1px solid rgba(119,161,255,0.2);
  227. padding: 0;
  228. }
  229. .is-segmented .tab-item:last-child { border-right: none; }
  230. .is-segmented .tab-item:hover:not(.is-active) {
  231. background: rgba(119,161,255,0.1);
  232. /* color: #ffffff; */
  233. }
  234. .is-segmented .tab-item.is-active {
  235. color: #ffffff;
  236. font-weight: bold;
  237. border-right-color: transparent;
  238. }
  239. </style>