来'特斯拉官网'3D看车吧(Vue3+Pinia+Koa+Three.js 入门全栈实战项目)

33,188 阅读9分钟

本文正在参加「金石计划」

背景: 这个项目是去年刚开始学 Vue3 自己动手做的一个入门项目,当时做的磕磕绊绊的,现在翻出来对原先的项目进行了一些功能的添加以及对原先项目的一些优雅处理。项目地址

项目描述:

这个项目是对 Tesla 官网的一个模仿,对于其官网新增的亮点为:实现3D看车功能,可以通过3D模型实现一键换车漆的功能。

同时使用 MongoDB 数据库实现了一个登录注册的功能;添加购物车的功能模块,实现保存用户购物信息的功能。

技术栈Vue3 + Pinia + Three.js + Koa

总体效果

  • 首页

首页 (2).gif

  • 登录

账号登录.gif

  • 注册

注册.gif

  • 其他页面

其他页面 (2).gif

项目思路

针对于官网的看车方式,增加一个 3D 看车的模块,将不同的页面封装成一个组件,然后通过 Vue-router 对路由进行集中管理,实现不同路由页面的切换;同时借助 Pinia 保存当前账号的登录状态,在登录的时候向后端发起接口请求,将当前账号的数据返回给前端,借助 Pinia 将部分数据存储在仓库,实现不同页面的数据共享。

项目部分结构:

client  // 客户端目录结构
     ├── public   // 公共资源,汽车的3D文件
     ├── src   
         ├── assets   // 图片资源
         ├── components   // 组件
         ├── router   // 路由
         ├── stores   // 仓库
         ├── views   // 页面
server  // 服务端目录结构
     ├── controllers   // 
     ├── modles   // 创建表结构
     ├── routes   // 路由
     ├── app.js   // 主入口

前端

  • 组件库:Element Plus
  • 动画:Animate.css
  • CSS预处理器:less
  • 地图api:百度地图api

组件

在官网上看到头部页面导航栏在很多页面都展示,所以我将它做成了一个组件,便于各个页面的引用。同时也将首页的主体内容也做成了一个组件(我当时到底是咋想的,要把这玩意做成一个组件?)。组件的文件目录:src/components 完整代码点击此处

这是头部导航栏:

QQ图片20230413103939.png

简单介绍一下这个部分,通过点击上方的文字部分跳转不同的页面,实现路由跳转的效果。文件目录:src/components/Header

路由

这部分代码在 src/routes.index.js 文件夹里,创建一个路由实例,然后配置不同的路由页面,集中管理再抛出。完整代码点击此处

// 部分代码
import { createRouter, createWebHistory } from 'vue-router'


const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      redirect: '/home'
    },
    {
      path: '/home',
      name: 'home',
      component: () => import('../views/Home.vue')
    },
    {
      path: '/map',
      name: 'map',
      component: () => import('../views/Map.vue')
    }
    ...
  ]
})

export default router

仓库

仓库用来在用户登录的时候,获取后端返回的数据并保存用户的部分信息,然后在不同的页面进行数据共享。

当用户点击登录的时候,如果数据库存在该账号的信息,就会将该账号以及该账号的购物车信息返回过来,然后使用 Pinia 中的数据仓库保存这些信息,进行数据的共享,在部分页面将这些信息渲染上去;同时如果已登录,如果需要修改信息,同样使用仓库对数据进行修改,然后再将仓库中的数据发送给后端,让后端及时的更新数据库数据。文件目录:src/stores/account.js 完整代码点击此处

// 部分代码  该仓库为用户信息仓库
import { defineStore } from 'pinia'
const useAccountStore = defineStore('accountStore', {
  state: () => {
    return {
      useAccount: '',
      commodity: '',  // 购物车信息
      userId:''
    }
  },
  actions: {  // 方法
    saveAccount(account) {
      this.useAccount = account
    },
    saveUserId(id){
      this.userId=id
    },
    useLogOut() {
      this.useAccount = ''
    },
    useSaveCommodity(item) {
      this.commodity = item
    },
    addCars(item) {
      if (!this.commodity[item]) {
        this.commodity[item] = item
        this.commodity[item] = 1
      } else {
        this.commodity[item] += 1
      }
    },
    deleteCars(item) {
      if (this.commodity[item] === 0) {
        this.commodity[item] = 0
      } else {
        this.commodity[item] -= 1
      }
    }
  }
})

export default useAccountStore

账户页面

账户 ( src/views/Account.vue )页面实现了注册以及登录两个功能,进来默认展示登录模块,通过 v-if 来控制登录以及注册模块的展示。 完整代码点击此处

同时在账户页面添加了组件路由守卫:为的是防止直接修改 url 跳转到某个用户的界面。

效果:

account.gif

<script>
// 组件路由守卫
import { defineComponent } from "vue";
import { useRouter } from "vue-router";

const router = useRouter();
const accountStore = useAccountStore();
const { useAccount } = storeToRefs(accountStore);
export default defineComponent({
  beforeRouteEnter(to, from, next) {
    console.log(accountStore.useAccount);
    if (accountStore.useAccount !== "") {
      next();
    } else {
      router.push({
        path: "/teslaaccount"
      });
    }
  }
});
</script>
登录

输入账号密码点击登录按钮则发起接口请求,后端验证账号密码是否能对应上。当然如果账号或者密码框未输入东西就点击登录的话就会提示用户未输入内容。

注册

当注册模块显示出来时,同样顺从登录的逻辑,这里用 Canvas 做了一个验证模块,防止恶意注册程序

登录注册效果:

login.gif

// 部分代码
<template>
...
</template>

<script setup>
// 引入
import { ref, reactive, onMounted } from "vue";
import { ElMessage } from "element-plus";
...

onMounted(() => {
  // 判断当前用户是否已经登录
  if (accountStore.useAccount) {
    router.push({
      path: "/teslaaccount",
      query: {
        account: accountStore.useAccount.value
      }
    });
  }
});

// 声明变量
const router = useRouter();
let login = ref(true);

// 点击下一步,判断是否输入账号与密码
let account = ref("");
let password = ref("");
const open = () => {
  if (account.value === "") {
    ElMessage.error("请输入账号");
  }
  if (account.value != "" && password.value === "") {
    ElMessage.error("请输入密码");
  }

  if (account.value && password.value) {
    // 如果输入了账号与密码就发接口请求
    axios({
      method: "post",
      url: "http://localhost:3000/login",
      data: {
        account: account.value,
        password: password.value
      }
    })
      .then(res => {
        console.log(res);
        if (res.data.code === 1) {
          console.log(res.data.data.account);
          accountStore.saveAccount(res.data.data.user.account);
          accountStore.useSaveCommodity(res.data.data.shoppingcarts.commodity)
          accountStore.saveUserId(res.data.data.user._id)
          console.log(accountStore.useAccount);
          console.log(accountStore.commodity);
          console.log(accountStore.userId);
          router.push({
            path: "/teslaaccount",
            query: {
              account: account.value
            }
          });
          ElMessage.success("登录成功");
        } else {
          ElMessage.error(res.data.msg);
        }
      })
      .catch(err => {
        console.log(err);
      });
  }
};

// 点击取消,返回home页
const cancel = () => {
  router.push({
    path: "/home"
  });
};

// 注册账号
let reAccount = ref("");
let rePassword = ref("");
let rePasswordAgain = ref("");
let checked = ref(false);
let dis = ref(true);
let verifyInput = ref("");

const changeCheck = () => {
  checked = !checked;
  if (checked === false) {
    dis.value = true;
  } else {
    dis.value = false; // 取消 创建用户 按钮禁用
  }
};
const register = () => {  // 注册
...
};

</script>

<style lang="less" scoped>
...
</style>

这里看看验证码模块逻辑:声明一个 Canvas 画布,然后使用一个 pool 来承载26个字母以及 0~9 这10个数据,然后每次随机抽取一个, Canvas 使用随机生成的颜色作为背景颜色,就生成了一个验证码模块。

// 生成验证码
const verify = ref(null); // 验证码 ref里面传入 null 这个ref是可以作为html的标记
const state = reactive({
  pool: "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
  imgCode: "" // 承接生成的验证码
});
// 声明函数
const createAccount = () => {
  login.value = !login.value;
  // handleDraw();
  // state.imgCode = draw();
};
const loginAccount = () => {
  login.value = !login.value;
};
// 生成随机数
const randomNum = (min, max) => {
  return parseInt(Math.random() * (max - min) + min);
};
// 随机生成颜色
const randomColor = (min, max) => {
  const r = randomNum(min, max);
  const g = randomNum(min, max);
  const b = randomNum(min, max);
  return `rgb(${r},${g},${b})`;
};
// 将随机生成的颜色与随机挑选的颜色合并
const draw = () => {
  const ctx = verify.value.getContext("2d"); // 创建2d的画布
  // ctx.fillStyle = "#eeeeee";
  ctx.fillStyle = randomColor(10, 300);

  ctx.fillRect(0, 0, 340, 90);
  let imgCode = "";
  for (let i = 0; i < 4; i++) {
    const text = state.pool[randomNum(0, 36)];
    imgCode += text;
    const fontSize = randomNum(20, 60);
    const deg = randomNum(-30, 30);
    ctx.font = fontSize + "px Simhei";
    ctx.textBaseline = "top";
    ctx.fillStyle = randomColor(80, 150);
    ctx.save();
    ctx.translate(30 * i + 15, 15);
    ctx.rotate((deg * Math.PI) / 180);
    ctx.fillText(text, 40, 20);
    ctx.restore();
  }

  return imgCode;
};
const handleDraw = () => {
  state.imgCode = draw();
};

个人账号页面

效果:

账号-min.gif

3D 车模

该模块代码逻辑是看 B 站上的 老陈打码 实现的,个人觉得讲的很好,于是偷偷的用在自己的项目上了,嘿嘿,欢迎大家去学习

使用 Three.js 将引入的车模嵌套到页面内,由于车子的 3D 模型也是由一个又一个的模块构成的,于是通过同时更换这些模块的颜色达到一键更换车漆的效果。给大家推荐一下 3D 模型库:sketchfab.com

文件地址:src/views/Models 完整代码地址

部分代码:

    import * as THREE from "three";
    import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"; // 控制器
    import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
    import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader";
        // 创建渲染器
        const renderer = new THREE.WebGLRenderer({
          antialias: true // 抗锯齿效果
        });
        renderer.setSize(window.innerWidth * 0.8, window.innerHeight * 0.8); // 渲染区域

        // 相机
        const camera = new THREE.PerspectiveCamera(
          40, // 镜头展开角度
          window.innerWidth / window.innerHeight, // 宽高比
          0.1, // 离物体最近的距离
          1000 // 离物体最远的距离
        );
        camera.position.set(3, 2, 2); // 设置相机位置

        // 创建场景
        const scene = new THREE.Scene();

        const render = () => {
          renderer.render(scene, camera);
          requestAnimationFrame(render);
        };

        // 材质
        const wheelsMaterial = new THREE.MeshPhysicalMaterial({
          color: 0x424449,
          metalness: 1,
          roughness: 0.5
        });
        const bodyMaterial = new THREE.MeshPhysicalMaterial({
          color: 0x424449,
          metalness: 1,
          roughness: 0.5,
          clearcoat: 1,
          clearcoatRoughness: 0
          //   map :carTexture
        });
        const frontMaterial = new THREE.MeshPhysicalMaterial({
          color: 0x424449,
          metalness: 1,
          roughness: 0.5,
          clearcoat: 1,
          clearcoatRoughness: 0
        });
        const hoodMaterial = new THREE.MeshPhysicalMaterial({
          color: 0x424449,
          metalness: 1,
          roughness: 0.5,
          clearcoat: 1,
          clearcoatRoughness: 0
        });
        const glassMaterial = new THREE.MeshPhysicalMaterial({
          color: 0xffffff,
          metalness: 0,
          roughness: 0,
          transmission: 1,
          transparent: true
        });

        onMounted(() => {
          containerRef.value.appendChild(renderer.domElement);
          renderer.setClearColor("#000"); //
          scene.background = new THREE.Color("#fff"); // 场景颜色
          scene.environment = new THREE.Color("#fff");
          render();

          controls = new OrbitControls(camera, renderer.domElement); // 镜头 canvas
          controls.update();

          const gridHelper = new THREE.GridHelper(10, 10); // 网格地面
          gridHelper.material.opacity = 0.2; // 网格透明度
          gridHelper.material.transparent = true;
          scene.add(gridHelper); // 场景添加网格

          // 添加汽车模型
          const loader = new GLTFLoader(); // 加载器
          const dracoLoader = new DRACOLoader();
          dracoLoader.setDecoderPath("../../public/roadSter/draco/gltf/");
          loader.setDRACOLoader(dracoLoader);
          loader.load("../../public/roadSter/model/bmw01.glb", gltf => {
            const bmw = gltf.scene;
            console.log(gltf);
            bmw.traverse(child => {
              if (child.isMesh) {
                // console.log(child);
              }
              // 轮毂
              if (child.isMesh && child.name.includes("轮毂")) {
                child.material = wheelsMaterial;
                wheels.push(child);
              }
              // 车身
              if (child.isMesh && child.name.includes("Mesh002")) {
                carBody = child;
                carBody.material = bodyMaterial;
              }
              // 前脸
              if (child.isMesh && child.name.includes("前脸")) {
                frontCar = child;
                frontCar.material = frontMaterial;
              }
              // 引擎盖
              if (child.isMesh && child.name.includes("引擎盖_1")) {
                hoodCar = child;
                hoodCar.material = hoodMaterial;
              }
              // 挡风玻璃
              if (child.isMesh && child.name.includes("挡风玻璃")) {
                glassCar = child;
                glassCar.material = glassMaterial;
              }
            });

            scene.add(bmw);
          });

          // 灯光
          const light1 = new THREE.DirectionalLight(0xffffff, 1);
          light1.position.set(0, 0, 10);
          scene.add(light1);
          const light2 = new THREE.DirectionalLight(0xffffff, 1);
          light2.position.set(0, 0, -10);
          scene.add(light2);
          const light3 = new THREE.DirectionalLight(0xffffff, 1);
          light3.position.set(10, 0, 0);
          scene.add(light3);
          const light4 = new THREE.DirectionalLight(0xffffff, 1);
          light4.position.set(-10, 0, 0);
          scene.add(light4);
          const light5 = new THREE.DirectionalLight(0xffffff, 1);
          light5.position.set(0, 10, 0);
          scene.add(light5);
          const light6 = new THREE.DirectionalLight(0xffffff, 1);
          light6.position.set(5, 10, 0);
          scene.add(light6);
          const light7 = new THREE.DirectionalLight(0xffffff, 1);
          light7.position.set(0, 10, 5);
          scene.add(light7);
          const light8 = new THREE.DirectionalLight(0xffffff, 1);
          light8.position.set(0, 10, -5);
          scene.add(light8);
          const light9 = new THREE.DirectionalLight(0xffffff, 1);
          light9.position.set(-5, 10, 0);
          scene.add(light9);
        });

        
        // 创建加载器
        const loader = new THREE.TextureLoader();
        const texture = loader.load("../../public/imgs/1.jpg");
        texture.minFilter = THREE.LinearFilter;

        const containerRef = ref(null);
        let controls = null;
        let wheels = [];
        let carBody, frontCar, hoodCar, glassCar;

        const selectColor = color => {
          wheelsMaterial.color.set(color);
          bodyMaterial.color.set(color);
          frontMaterial.color.set(color);
          hoodMaterial.color.set(color);
        };

效果:

car.gif

后端

使用 Koa 框架进行搭建的后端开发环境。

 server  // 服务端目录结构
         ├── controllers   // 控制层
             ├── login  // 登录逻辑
             ├── register   // 注册逻辑
             ├── shoppingcart   // 购物车逻辑
         ├── modles   // 创建表结构
             ├── login.js   // 用户信息表
             ├── shoppingCart.js   // 用户购物车表
         ├── routes   // 路由
             ├── login  // 登录路由
             ├── register   // 注册路由
             ├── shoppingcart   // 购物车路由
         ├── app.js   // 主入口

本次使用的数据库是 MongoDB 虽然不用像 MySql 一样写 CRUD 语句,中间也碰到一些问题,谷歌也找了半天没解决,最后还是找大佬解决的;但是优点也很明显,那就是写起代码来就像写原生 JavaScript 语句一样。

后端分了三个层:

  • 路由层:定义接口
  • 控制层: 当接口被请求时,需要向前端响应的操作
  • 模板层:在向前端响应之前,需要连接数据库查找数据

后端创建了两个表,一个用户信息表用来存储用户账号密码,另外一个是购物车表,用于存储某个用户的购物车信息。在 controllers 控制层实现登录注册等接口请求的逻辑处理。

下面是登录部分的逻辑:server/controllers/login/inedx

登录部分逻辑
const userInfo = require('../../modles/login')   // UserInfo 就是生成的user表
const shoppingCart = require('../../modles/shoppingCart')  // 存储购物车的表
// 登录操作
const login = async (ctx, next) => {
    console.log(ctx.request.body);
    const { account, password } = ctx.request.body   // 从请求体里面获取账号密码,然后拿到这个区数据库匹配
    // 在 user 表中查找 account 是否存在
    const user = await userInfo.findOne({
        account: account
    })
    if (!user) {
        ctx.status = 200
        ctx.body = {
            code: 0,
            msg: '用户名不存在'
        }
        return
    } else {
        // 匹配密码
        if (user.password !== password) {   // 密码不匹配
            ctx.body = {
                code: 0,
                msg: '密码错误'
            }
            // console.log('密码错误');
        } else {  // 密码正确
            // 去购物车表里面找对应账号的数据
            const userAccount = user.account
            const shoppingcarts = await shoppingCart.findOne({
                account: userAccount
            })
            ctx.body = {
                code: 1,
                msg: '登录成功',
                data: {    // 将账号密码以及账号购物车信息返回给前端
                    user: user,
                    shoppingcarts: shoppingcarts
                },
            }

        }
    }

}

module.exports = {
    login
}




后端详细代码:点击此处

总结

本次对项目功能的完善,让我深刻的感受到了阅读文档的重要性,之前也稍微看了一点文档,在实际的开发中对于某个知识点有了更深入的理解;对于知识的掌握不能只停留在会用的水平,而是要更深层次的理解其中的作用,这样在调试 bug 的时候才会更加得心应手,当然本项目可以完善的功能还有待输出。嘿嘿。

项目地址