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
相关推荐
m0_740043731 小时前
Axios 请求示例 res.data.data
前端·javascript·vue.js
韩立学长1 小时前
【开题答辩实录分享】以《基于Vue Node.js的露营场地管理系统的设计与实现》为例进行选题答辩实录分享
数据库·vue.js·node.js
q_19132846951 小时前
基于SpringBoot2+Vue2+uniapp的考研社区论坛网站及小程序
java·vue.js·spring boot·后端·小程序·uni-app·毕业设计
憨逗君1 小时前
vite学习
vue.js
Apeng_09192 小时前
vue+canvas实现按下鼠标绘制箭头
前端·javascript·vue.js
源码方舟2 小时前
【华为云DevUI开发实战】
前端·vue.js·华为云
VOLUN2 小时前
封装通用可视化大屏布局组件:Vue3打造高复用性的 ChartFlex/ChartFlexItem
前端·vue.js
细心细心再细心2 小时前
响应式记录
前端·vue.js
北辰alk2 小时前
Vue打包后静态资源图片失效?一网打尽所有解决方案!
vue.js