Vue3 + TS + TailwindCSS 操作引导组件开发逐行解析

Vue3 + TS + TailwindCSS 操作引导组件开发逐行解析

1.设计稿&效果图

设计稿:

效果图:

2.业务背景&业务需求

业务背景:

产品中使用了 3D 高斯游览(3D Gaussian Splatting)技术,用户可以在三维场景中进行自由浏览、旋转视角、缩放等交互操作。然而,对于首次使用的用户来说,这类 3D 交互存在一定的学习成本。

业务需求:

为降低用户上手难度,需要在 3D 游览开始时在右下角提供一段 操作引导。

该引导用于:

  • 清晰说明 "移动视角 / 旋转视角 / 缩放" 的操作方式
  • 通过图示和说明文案提升理解速度
  • 提高用户在 3D 场景中的交互效率与体验
  • 避免用户因不会操作而误以为产品不够友好

功能概述:

系统在进入 3D 游览界面时弹出一个指南组件,引导用户理解基础操作。用户可通过左右切换示意图,阅读提示信息,并在了解后点击"知道了"关闭提示

3.代码设计

文件结构:

bash 复制代码
GSview/
├─ components/
│  └─ Guide.vue         # 操作引导组件
├─ index.vue            # 页面入口
└─ settings.ts          # 操作引导配置

本次的目标就是实现 Guide.vue,一些相关的配置写在 settings.ts 中

3.1 html骨架

Guide.vue 中:

html 复制代码
<template>
  <div class="guide-container" v-if="isShow">
    <div class="guide-container-header">
      <div class="guide-container-tabs">
        <div class="guide-container-tabs-header">移动视角</div>
        <div class="guide-container-tabs-content">
          <div
            :class="[
              'guide-container-tabs-content-item',
              activeTab === item.value ? 'tab-active' : '',
            ]"
            v-for="item in GuideMoveTabConfig"
            :key="item.value"
            @click="handleTabClick(item.value)"
          >
            <span>{{ item.label }}</span>
          </div>
        </div>
      </div>
      <div class="guide-container-tabs-line"></div>
      <div class="guide-container-tabs">
        <div class="guide-container-tabs-header">旋转视角</div>
        <div class="guide-container-tabs-content">
          <div
            :class="[
              'guide-container-tabs-content-item',
              activeTab === item.value ? 'tab-active' : '',
            ]"
            v-for="item in GuideRotateTabConfig"
            :key="item.value"
            @click="handleTabClick(item.value)"
          >
            <span>{{ item.label }}</span>
          </div>
        </div>
      </div>
      <div class="guide-container-tabs-line"></div>
      <div class="guide-container-tabs guide-container-tabs-last">
        <div class="guide-container-tabs-header"></div>
        <div class="guide-container-tabs-content">
          <div
            :class="[
              'guide-container-tabs-content-item',
              activeTab === item.value ? 'tab-active' : '',
            ]"
            v-for="item in GuideScaleTabConfig"
            :key="item.value"
            @click="handleTabClick(item.value)"
          >
            <span>{{ item.label }}</span>
          </div>
        </div>
      </div>
    </div>
    <div class="guide-container-content">
      <div class="guide-tips">{{ guideTips }}</div>
      <div class="guide-img-wrapper">
        <div class="guide-arrow-icon" @click="handlePrev">
          <ArrowLeftIcon :width="24" :height="24" color="#FFFFFF"/>
        </div>
        <img :src="guideImg" class="guide-img" />
        <div class="guide-arrow-icon" @click="handleNext">
          <GuideArrowRight :width="24" :height="24" color="#FFFFFF" />
        </div>
      </div>
      <div class="confim-btn" @click="handleConfirm">知道了</div>
    </div>
  </div>
</template>

组件结构概要:

bash 复制代码
guide-container (整个遮罩层)
 ├ guide-container-header (顶部三组 tab)
 │   ├ guide-container-tabs(移动视角)
 │   │   ├ header
 │   │   └ content -> item
 │   ├ line
 │   ├ guide-container-tabs(旋转视角)
 │   ├ line
 │   ├ guide-container-tabs-last(缩放视角)
 │
 ├ guide-container-content(下半部分)
 │   ├ guide-tips(提示文字)
 │   ├ guide-img-wrapper(左右箭头 + 图片)
 │   ├ confirm-btn(底部按钮)

部分代码分析:

css 复制代码
:class="[
  'guide-container-tabs-content-item',
  activeTab === item.value ? 'tab-active' : '',
]"

这是Vue 的动态 class 数组写法

上述代码的含义是

  • 无论如何都加上 "guide-container-tabs-content-item"
  • 如果当前项是选中项 → 加上 "tab-active"
  • 如果不是选中项 → 加上空字符串(不会渲染)

Index.vue中:

html 复制代码
<Guide v-model:is-show="isShowGuide" />

3.2 css样式

Guide.vue 中:

css 复制代码
.guide-container {
  @apply flex flex-col absolute bottom-[26px] right-[32px] z-50 bg-[#00000099] rounded-[10px] overflow-hidden;
  width: 800px;
}

.guide-container-header {
  @apply flex gap-[32px] bg-[#1E2128] px-[40px] pt-[16px] pb-[8px] box-border text-[#88909B] h-[72px];
}

.guide-container-tabs {
  @apply flex gap-[24px] items-center h-[28px];
}

.guide-container-tabs-last {
  flex: 1;
  position: relative;
}

.guide-container-tabs-last .guide-container-tabs-header {
  display: none;
}

.guide-container-tabs-last .guide-container-tabs-content {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
}

.guide-container-tabs-line {
  @apply w-[1px] h-[16px] mx-[9.5px] my-[6px] bg-[#505968];
}

.guide-container-tabs-header {
  @apply text-[20px];
  color: #4884FD;
}

.guide-container-tabs-content {
  @apply text-[16px] flex gap-[24px] h-[28px] items-center;
}

.guide-container-tabs-content-item {
  @apply cursor-pointer hover:text-[#FFFFFF];
  white-space: nowrap;
}

.tab-active {
  @apply text-[#FFFFFF] relative;
}

.tab-active::before {
  content: '';
  position: absolute;
  bottom: -16.75px;
  left: 50%;
  transform: translateX(-50%);
  width: 0;
  height: 0;
  border-left: 6.25px solid transparent;
  border-right: 6.25px solid transparent;
  border-top: 7.5px solid #2061fd;
}

.guide-container-content {
  @apply p-[32px] box-border;
}

.guide-tips {
  @apply text-[#FFFFFF] text-[20px] mb-[32px] text-center;
  letter-spacing: 0.02em;
}

.guide-img-wrapper {
  @apply relative flex items-center justify-center mb-[48px] px-[56px];
}

.guide-arrow-icon {
  @apply absolute top-1/2 -translate-y-1/2 flex items-center justify-center cursor-pointer;
  width: 44px;
  height: 44px;
  background: #FFFFFF26;
  border-radius: 37px;
}

.guide-arrow-icon:first-child {
  left: 0;
}

.guide-arrow-icon:last-child {
  right: 0;
}

.guide-img {
  height: 210px;
  max-width: 580px;
  width: auto;
  object-fit: contain;
}

.confim-btn {
  @apply w-[156px] h-[48px] bg-[#216BFF] text-[#FFFFFF] text-[18px] rounded-[6px] text-center cursor-pointer flex items-center justify-center mx-auto;
  letter-spacing: 0.02em;
}

css分析:

css 复制代码
.guide-container {
  @apply flex flex-col absolute bottom-[26px] right-[32px] z-50 bg-[#00000099] rounded-[10px] overflow-hidden;
  width: 800px;
}

flex flex-col相当于display: flex; flex-direction: column;

flex布局,文字方向是纵向,也就是垂直排列

absolute:让这个容器变成 绝对定位。

相对于最近的 position: relative 的祖先元素

如果没有,则相对于 浏览器视口(viewport)

bottom-26px right-32px

让这个容器固定在右下角,离底部 26px,离右侧 32px。

z-index: 50;

让它 叠在几乎所有元素之上

防止被页面其他内容遮住。

bg-#00000099

#00000099 #000000 = 黑色 99 = 透明度 (~60%)

这是一个半透明黑色的背景 -> 类似"深色面板"

rounded-10px

rounded-10px

overflow-hidden

让内部内容裁剪,不会溢出容器外。

width: 800px;

固定宽度为 800px。

这让弹窗有固定的大宽度,适合显示图片 + 文案。

css 复制代码
.guide-container-header {
  @apply flex gap-[32px] bg-[#1E2128] px-[40px] pt-[16px] pb-[8px] box-border text-[#88909B] h-[72px];
}

flex gap-32px bg-#1E2128

flex布局默认横向排列 设置深灰背景色

gap 只会在兄弟元素之间产生空间32px

移动视角 tabs\] \[竖线\] \[旋转视角 tabs\] \[竖线\] \[缩放视角 tabs

px-40px pt-16px pb-8px

px是左右 的padding-left: 40px;padding-right: 40px;

pt是padding-top: 16px; pb是padding-bottom: 8px;

| ← padding-left 40px → | A | gap 32 | B | gap 32 | C | ← padding-right 40px → |

box-border

box-sizing: border-box; padding 和 border 会包含在 height 内部

设定 h-72px 时不会撑大元素

h-72px

高度固定为 72px。

为什么视觉上,文字下部的空隙要大?

因为头部固定了高度,上下padding定死了,文字会贴着上padding,所以显得下面大

css 复制代码
.guide-container-tabs {
  @apply flex gap-[24px] items-center h-[28px];
}

元素间距24px,items-center 表示垂直方向上居中

css 复制代码
.guide-container-tabs-line {
  @apply w-[1px] h-[16px] mx-[9.5px] my-[6px] bg-[#505968];
}

定好的分割线

css 复制代码
.guide-container-tabs-last {
  flex: 1;
  position: relative;
}

.guide-container-tabs-last .guide-container-tabs-header {
  display: none;
}

.guide-container-tabs-last .guide-container-tabs-content {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
}

最后一个tab样式和之前的不太同

所以flex: 1,让他占据剩下的所有空间, position: relative;,为他的子元素绝对定位用

display: none;消除guide-container-tabs-header本身的影响

position: absolute; left: 50%;transform: translateX(-50%); 水平方向居中三件套

css 复制代码
.guide-container-tabs-content {
  @apply text-[16px] flex gap-[24px] h-[28px] items-center;
}
css 复制代码
.tab-active::before {
  content: '';
  position: absolute;
  bottom: -16.75px;
  left: 50%;
  transform: translateX(-50%);
  width: 0;
  height: 0;
  border-left: 6.25px solid transparent;
  border-right: 6.25px solid transparent;
  border-top: 7.5px solid #2061fd;
}

纯css三角形技巧,tab项下面的三角形

css 复制代码
.guide-container-content {
  @apply p-[32px] box-border;
}
css 复制代码
.guide-tips {
  @apply text-[#FFFFFF] text-[20px] mb-[32px] text-center;
  letter-spacing: 0.02em;
}

在视觉上,看着可能要大一些,因为本身的图片就有一段空白高度

css 复制代码
.guide-img-wrapper {
  @apply relative flex items-center justify-center mb-[48px] px-[56px];
}

其中紫色部分是图片并没有被拉满 wrapper,object-fit: contain 自动填的空白

css 复制代码
.guide-img {
  height: 210px;
  max-width: 580px;
  width: auto;
  object-fit: contain;
}

width: auto 宽度跟着图片本身比例自适应

object-fit: contain; ------ 图片保持原比例,不能被裁剪,保持原始比例,宁愿留白,也不能拉伸和裁剪

css 复制代码
.guide-arrow-icon {
  @apply absolute top-1/2 -translate-y-1/2 flex items-center justify-center cursor-pointer;
  width: 44px;
  height: 44px;
  background: #FFFFFF26;
  border-radius: 37px;
}

.guide-arrow-icon:first-child {
  left: 0;
}

.guide-arrow-icon:last-child {
  right: 0;
}

内部垂直水平居中

外部左右间距设置为0,紧贴

3.2 JS交互

settings.ts配置

javascript 复制代码
export const GuideMoveTabConfig = [
  {
    label: '前后',
    value: 'move-forward-backward',
    img: MoveForwardBackwardImg,
    info: '键盘W/S键 或 按住鼠标滚轮上下滚动',
  },
  {
    label: '左右',
    value: 'move-left-right',
    img: MoveLeftRightImg,
    info: '键盘A/D键 或 按住鼠标右键左右拖动',
  },
  {
    label: '上下',
    value: 'move-up-down',
    img: MoveUpDownImg,
    info: '按住鼠标右键上下拖动',
  },
]

export const GuideRotateTabConfig = [
  {
    label: '俯仰/航向',
    value: 'rotate-pitch',
    img: RotatePitchImg,
    info: '按住鼠标左键拖动',
  },
  {
    label: '横滚',
    value: 'rotate-roll',
    img: RotateRollImg,
    info: '键盘Q/E键',
  },
]

export const GuideScaleTabConfig = [
  {
    label: '聚集环绕',
    value: 'scale',
    img: ScaleImg,
    info: '鼠标左键双击,聚集目标点环绕查看',
  },
]

Guide.vue 中的 JS 交互

javascript 复制代码
<script setup lang="ts">
import { ref, computed } from 'vue'
import { GuideMoveTabConfig, GuideRotateTabConfig, GuideScaleTabConfig } from '../settings'
import ArrowLeftIcon from '@/assets/svgs/ArrowLeftIcon.vue'
import GuideArrowRight from '@/assets/svgs/GuideArrowRight.vue'

const AllConfig = [...GuideMoveTabConfig, ...GuideRotateTabConfig, ...GuideScaleTabConfig]

const isShow = defineModel<boolean>('isShow', {
  type: Boolean,
  default: false,
})

const activeTab = ref('move-forward-backward')
const currentConfig = computed(() => {
  return AllConfig.find((item) => item.value === activeTab.value)
})
const guideTips = computed(() => {
  return currentConfig.value?.info
})
const guideImg = computed(() => {
  return currentConfig.value?.img
})

const handleTabClick = (tab: string) => {
  activeTab.value = tab
}

const handlePrev = () => {
  const currentIndex = AllConfig.findIndex((item) => item.value === activeTab.value)
  if (currentIndex === -1) return
  const prevIndex = (currentIndex - 1 + AllConfig.length) % AllConfig.length
  const prevConfig = AllConfig[prevIndex]
  if (prevConfig) {
    activeTab.value = prevConfig.value
  }
}

const handleNext = () => {
  const currentIndex = AllConfig.findIndex((item) => item.value === activeTab.value)
  if (currentIndex === -1) return
  const nextIndex = (currentIndex + 1) % AllConfig.length
  const nextConfig = AllConfig[nextIndex]
  if (nextConfig) {
    activeTab.value = nextConfig.value
  }
}

const handleConfirm = () => {
  isShow.value = false
}
</script>

1.从 settings 里面拿到三个 tab 的配置

javascript 复制代码
const AllConfig = [...GuideMoveTabConfig, ...GuideRotateTabConfig, ...GuideScaleTabConfig]

2.解构拼接成一个大数组

javascript 复制代码
const isShow = defineModel<boolean>('isShow', {
  type: Boolean,
  default: false,
})

defineModel的用途

  • 声明一个"模型字段"(用于 v-model 绑定)
  • 自动生成 props + emits
  • 返回一个 ref(可直接修改,自动触发父组件更新)

boolean(泛型)

defineModel<boolean>,这个 model 的类型是 boolean

isShow.value 必须是 boolean,父组件传进来的数据也必须是 boolean

javascript 复制代码
defineModel<boolean>('isShow', ...)

表示这个 model 的名字叫 isShow

vbnet 复制代码
type: Boolean,
default: false,

声明这个 prop 的类型是 Boolean(Vue 的运行时类型检查用)

父组件不传值时,默认值是 false

在子组件里可以直接使用,父组件也会同时更新(双向绑定效果)

javascript 复制代码
isShow.value = true

3.默认绑定一个 tab

javascript 复制代码
const activeTab = ref('move-forward-backward')

4.维护当前的配置项

javascript 复制代码
const currentConfig = computed(() => {
  return AllConfig.find((item) => item.value === activeTab.value)
})

使用计算属性,每次AllConfig 或者activeTab 发生变化的时候触发,保证currentConfig 拿到了当前选中的这项

javascript 复制代码
const guideTips = computed(() => {
  return currentConfig.value?.info
})
const guideImg = computed(() => {
  return currentConfig.value?.img
})

currentConfig.value?: 因为 currentConfig 是一个计算属性,所以需要用 .value 来访问它的结果。? 是可选链操作符,它防止在 currentConfig.valuenullundefined 时程序崩溃。

...?.info: 它尝试获取当前配置对象中的 info 属性。

作用: guideTips 响应式地保存着当前激活标签对应的提示信息。

图片也同理,看 setting 中的配置就行

5.更新当前的点击项

javascript 复制代码
const handleTabClick = (tab: string) => {
  activeTab.value = tab
}

更新当前点击的 tab

6.切换上一项切换下一项的核心函数

javascript 复制代码
const handlePrev = () => {
  const currentIndex = AllConfig.findIndex((item) => item.value === activeTab.value)
  if (currentIndex === -1) return
  const prevIndex = (currentIndex - 1 + AllConfig.length) % AllConfig.length
  const prevConfig = AllConfig[prevIndex]
  if (prevConfig) {
    activeTab.value = prevConfig.value
  }
}

const handleNext = () => {
  const currentIndex = AllConfig.findIndex((item) => item.value === activeTab.value)
  if (currentIndex === -1) return
  const nextIndex = (currentIndex + 1) % AllConfig.length
  const nextConfig = AllConfig[nextIndex]
  if (nextConfig) {
    activeTab.value = nextConfig.value
  }
}

核心函数:切换上一项和切换下一项

以切换上一项为例(切换下一项同理):

  • 首先拿到当前下标
  • 如果没拿到就返回(防御式编程)
  • 利用当前下标,计算出上一项下标(实现循环)
  • 更新当前的 active.tab
相关推荐
leoZ2317 小时前
Claude 驱动的全栈开发:Spring Boot + Vue 的 BS 架构实践
vue.js·spring boot·架构
荒诞英雄11 小时前
从 17MB Vendor 大包到按需加载:Vite 多入口 Vue 组件库首屏优化实践
vue.js·vite
jsonbro11 小时前
手把手带你实现发布订阅模式
前端·vue.js·设计模式
Cobyte13 小时前
23.Vue Vapor 组件 attrs 的实现
前端·javascript·vue.js
斯特凡今天也很帅14 小时前
xml页面可以打开,不报错,但是显示数据的地方也不显示,我是怎么解决的
xml·vue.js
柯克七七15 小时前
给老项目加了 TypeScript,本来只想自己爽,结果全公司代码审查标准被我抬高了
前端·vue.js·typescript
自然 醒17 小时前
前端如何实现在线预览office常见文件功能?
前端·vue.js
肉肉不吃 肉18 小时前
前端调试跨域如何解决
前端·vue.js
卓怡学长18 小时前
w268基于springboot + vue 物流系统
java·数据库·vue.js·spring boot·spring·intellij-idea
2601_9628517418 小时前
计算机毕业设计之基于SSM的网络安全课程资源分享平台
vue.js·算法·安全·web安全·yolo·目标检测·课程设计