1.初始化项目 #

pnpm create vite

√ Project name: ... mall
√ Select a framework: » Vue
√ Select a variant: » Customize with create-vue ↗
√ Add TypeScript? ... No
√ Add JSX Support? ... Yes
√ Add Vue Router for Single Page Application development? ... Yes
√ Add Pinia for state management? ... Yes
√ Add Vitest for Unit Testing? ... No
√ Add an End-to-End Testing Solution? » No
√ Add ESLint for code quality? ... Yes
√ Add Prettier for code formatting? ... Yes

Scaffolding project in D:\aprepare\mall...

Done. Now run:

cd mall
pnpm install
pnpm format
pnpm dev

2.移动端适配 #

2.1 安装 #

pnpm i lib-flexible  less
pnpm i postcss-pxtorem -D

2.2 postcss.config.cjs #

module.exports = {
  plugins: {
    "postcss-pxtorem": {
      rootValue: 37.5,
      propList: ["*"],
    },
  },
};

2.3 src\main.js #

src\main.js

import { createApp } from "vue";
import { createPinia } from "pinia";
import "lib-flexible";

import App from "./App.vue";
import router from "./router";

const app = createApp(App);

app.use(createPinia());
app.use(router);

app.mount("#app");

2.4 App.vue #

src\App.vue

<script setup></script>

<template>
  <div id="app"></div>
</template>

<style scoped lang="less">
html,
body {
  height: 100%;
  overflow-x: hidden;
  overflow-y: scroll;
}
#app {
  width: 375px;
  height: 100%;
}
</style>

2.5 router\index.js #

src\router\index.js

import { createRouter, createWebHistory } from "vue-router";

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [],
});

export default router;

3.配置 vant #

3.1 安装 #

pnpm i unplugin-vue-components vant

3.2 vite.config.js #

vite.config.js

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

+import Components from 'unplugin-vue-components/vite'
+import { VantResolver } from 'unplugin-vue-components/resolvers'

// https://vitejs.dev/config/
export default defineConfig({
+ plugins: [vue(), vueJsx(), Components({ resolvers: [VantResolver()] })],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

3.3 main.js #

src\main.js

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'lib-flexible'
+import { installVant } from './installVant'
import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)
+installVant(app)
app.mount('#app')

3.4 installVant.js #

src\installVant.js

import {
  showToast,
  showDialog,
  showNotify,
  showImagePreview,
  showConfirmDialog,
  Lazyload,
} from "vant";

import "vant/es/toast/style";
import "vant/es/dialog/style";
import "vant/es/notify/style";
import "vant/es/image-preview/style";

export function installVant(app) {
  app.config.globalProperties.$showToast = showToast;
  app.config.globalProperties.$showDialog = showDialog;
  app.config.globalProperties.$showNotify = showNotify;
  app.config.globalProperties.$showImagePreview = showImagePreview;
  app.config.globalProperties.$showConfirmDialog = showConfirmDialog;

  app.use(Lazyload);
}

3.5 App.vue #

src\App.vue

<script setup></script>

<template>
  <div id="app">
    <van-button @click="this.$showToast('Toast')">Toast</van-button>
  </div>
</template>

<style scoped lang="less">
html,
body {
  height: 100%;
  overflow-x: hidden;
  overflow-y: scroll;
}
#app {
  width: 375px;
  height: 100%;
}
</style>

4.方法自动导入 #

4.1 安装 #

pnpm i unplugin-auto-import

4.2 vite.config.js #

vite.config.js

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'
+import AutoImport from 'unplugin-auto-import/vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
    Components({ resolvers: [VantResolver()] }),
+   AutoImport({
+     imports: ['vue', 'vue-router', 'pinia'],
+     eslintrc: {
+       enabled: false
+     }
+   })
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

4.3 .eslintrc.cjs #

.eslintrc.cjs

/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')

module.exports = {
  env: {
    node: true,
    browser: true
  },
  root: true,
  extends: [
    'plugin:vue/vue3-essential',
    'eslint:recommended',
    '@vue/eslint-config-prettier/skip-formatting',
+   './.eslintrc-auto-import.json'
  ],
  parserOptions: {
    ecmaVersion: 'latest'
  }
}

4.4 App.vue #

src\App.vue

<script setup>
+const { proxy } = getCurrentInstance()
+console.log(proxy)
</script>

<template>
  <div id="app">
    <van-button @click="this.$showToast('Toast')">Toast</van-button>
  </div>
</template>

<style scoped lang="less">
html,
body {
  height: 100%;
  overflow-x: hidden;
  overflow-y: scroll;
}
#app {
  width: 375px;
  height: 100%;
}
</style>

5.lint #

5.1 安装 #

git init
pnpm install husky -D

5.2 添加钩子 #

git init  // 初始化一个新的 Git 仓库或重新初始化现有的仓库
pnpm install husky -D  // 使用 pnpm 包管理器安装 Husky,并将其保存为开发依赖
npm pkg set scripts.prepare="husky install"  // 在 package.json 中设置 prepare 脚本,以便在依赖项安装后运行 "husky install"
pnpm prepare  // 执行 prepare 脚本,安装 Husky 钩子
npx husky add .husky/pre-commit "pnpm lint"  // 使用 Husky CLI 添加一个 pre-commit 钩子,该钩子会在每次提交前运行 "pnpm lint" 命令
git add -A
git commit -m"init"

6.commitlint #

6.1 安装 #

pnpm install @commitlint/cli @commitlint/config-conventional -D

6.2 添加钩子 #

npx husky add .husky/commit-msg "npx --no-install commitlint --edit `echo "\$1"`"

6.3 commitlint.config.cjs #

commitlint.config.cjs

module.exports = {
  extends: ["@commitlint/config-conventional"],
};

7.配置路由 #

7.1 .eslintrc.cjs #

.eslintrc.cjs

/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')

module.exports = {
  env: {
    node: true,
    browser: true
  },
  root: true,
  extends: [
    'plugin:vue/vue3-essential',
    'eslint:recommended',
    '@vue/eslint-config-prettier/skip-formatting',
    './.eslintrc-auto-import.json'
  ],
+ rules: {
+   'vue/multi-word-component-names': 'off'
+ },
  parserOptions: {
    ecmaVersion: 'latest'
  }
}

7.2 App.vue #

src\App.vue

<script setup>

</script>

<template>
  <RouterView></RouterView>
</template>

<style scoped lang="less">

</style>

7.3 router\index.js #

src\router\index.js

import { createRouter, createWebHistory } from "vue-router";
import Home from "@/views/Home.vue";
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: "/", redirect: "/home" },
    { path: "/home", name: "home", component: Home },
    {
      path: "/category",
      name: "category",
      component: () => import("@/views/Category.vue"),
    },
    {
      path: "/cart",
      name: "cart",
      component: () => import("@/views/Cart.vue"),
    },
    {
      path: "/user",
      name: "user",
      component: () => import("@/views/User.vue"),
    },
  ],
});
export default router;

7.4 Home.vue #

src\views\Home.vue

<template>Home</template>

7.5 Cart.vue #

src\views\Cart.vue

<template>Cart</template>

7.6 User.vue #

src\views\User.vue

<template>User</template>

7.7 Category.vue #

src\views\Category.vue

<template>Category</template>

8.NavBar #

src\components\NavBar.vue

<template>
  <van-tabbar v-if="isVisible" route>
    <van-tabbar-item to="/home">
      <template #icon><van-icon name="wap-home-o" /></template>首页
    </van-tabbar-item>
    <van-tabbar-item to="/category">
      <template #icon><van-icon name="gem-o" /></template>分类
    </van-tabbar-item>
    <van-tabbar-item to="/cart">
      <template #icon><van-icon name="shopping-cart-o" /></template>购物车
    </van-tabbar-item>
    <van-tabbar-item to="/user">
      <template #icon><van-icon name="user-o" /></template>我的
    </van-tabbar-item>
  </van-tabbar>
</template>

<script setup>
const route = useRoute()
const isVisible = ref(false)
watchEffect(() => {
  const showList = ['/home', '/category', '/cart', '/user']
  isVisible.value = showList.includes(route.path)
})
</script>

8.2 App.vue #

src\App.vue

<script setup></script>
const isVisible = ref(false)
<template>
  <RouterView></RouterView>
  <NavBar></NavBar>
</template>

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

8.3 vite.config.js #

vite.config.js

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'
import AutoImport from 'unplugin-auto-import/vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
+   Components({ dirs: ['src/components'], resolvers: [VantResolver()] }),
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      eslintrc: {
        enabled: false
      }
    })
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

9.主题色 #

9.1 theme.css #

src\assets\theme.css

:root:root {
  --van-primary-color: #1baeae;
}

9.2 main.js #

src\main.js

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'lib-flexible'
import { installVant } from './installVant'
import App from './App.vue'
import router from './router'
+import '@/assets/theme.css'

const app = createApp(App)

app.use(createPinia())
app.use(router)
installVant(app)
app.mount('#app')

9.3 var.less #

src\assets\var.less

@primary: #1baeae;

9.4 src\views\Home.vue #

src\views\Home.vue

<template>
+  <h1>Home</h1>
</template>
+<style scoped lang="less">
+h1 {
+  color: @primary;
+}
+</style>

9.5 vite.config.js #

vite.config.js

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'
import AutoImport from 'unplugin-auto-import/vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
    Components({ dirs: ['src/components'], resolvers: [VantResolver()] }),
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      eslintrc: {
        enabled: false
      }
    })
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
+ css: {
+   preprocessorOptions: {
+     less: {
+       additionalData: '@import "/src/assets/var.less";'
+     }
+   }
+ }
})

10.首页导航 #

10.1 Home.vue #

src\views\Home.vue

<script setup></script>
<template>
  <home-header></home-header>
</template>

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

10.2 HomeHeader.vue #

src\components\HomeHeader.vue

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const isActive = ref(false)
const setIsActive = () => {
  let scrollTop = document.documentElement.scrollTop || document.body.scrollTop
  isActive.value = scrollTop > 100
}
onMounted(() => {
  window.addEventListener('scroll', setIsActive)
})
onUnmounted(() => {
  window.removeEventListener('scroll', setIsActive)
})
</script>
<template>
  <van-sticky>
    <header class="home-header" :class="{ active: isActive }">
      <span class="category" @click="$router.push('/category')"><van-icon name="wap-nav" /></span>
      <div class="header-search">
       <span class="app-name">新蜂商城</span>
       <span class="search-title" @click="$router.push('/search')">山河无恙,人间皆安</span>
      </div>
      <span class="user">登录</span>
    </header>
  </van-sticky>
</template>
<style scoped lang="less">
.home-header {
  position: fixed;
  display: flex;
  justify-content: space-between;
  align-items: center;
  left: 0;
  top: 0;
  width: 100%;
  height: 50px;
  line-height: 50px;
  padding: 0 15px;
  box-sizing: border-box;
  font-size: 15px;
  color: @primary;
  z-index: 10000;
  &.active {
    background-color: @primary;
    .category,
    .user {
      color: #fff;
    }
  }
  .category {
    font-size: 20px;
    font-weight: bold;
  }
  .header-search {
    display: flex;
    justify-content: space-evenly;
    align-items: center;
    height: 30px;
    width: 75%;
    background: rgba(255, 255, 255, 0.7);
    border-radius: 20px;
    .app-name {
      font-size: 20px;
      font-weight: bold;
    }
    .search-title {
      color: #666;
    }
  }
}
</style>

10.轮播图 #

10.1 Home.vue #

src\views\Home.vue

<script setup>
+import { fetchIndexInfo } from '@/api/home'
+const carousels = ref([])
+const loading = ref(false)
+onMounted(async () => {
+  loading.value = true
+  const data = await fetchIndexInfo()
+  carousels.value = data.carousels
+})
</script>
<template>
  <home-header></home-header>
+ <home-swiper :carousels="carousels"></home-swiper>
</template>
<style scoped lang="less"></style>

10.2 HomeSwiper.vue #

src\components\HomeSwiper.vue

<script setup>
defineProps(['carousels'])
</script>
<template v-if="carousels.length">
  <van-swipe class="home-swipe" lazy-render :autoplay="3000">
    <van-swipe-item v-for="(item, index) in carousels" :key="index">
      <img :src="item.carouselUrl" alt="" />
    </van-swipe-item>
  </van-swipe>
</template>
<style lang="less" scoped>
.home-swipe {
  img {
    width: 100%;
    height: 100%;
  }
}
</style>

10.3 api\index.js #

src\api\index.js

pnpm i axios
import axios from "axios";

class ApiClient {
  constructor(baseURL = "/", timeout = 3000) {
    const env = import.meta.env.DEV
      ? "http://backend-api-01.newbee.ltd/api/v1"
      : baseURL;
    this.client = axios.create({
      baseURL: env,
      timeout,
    });
    this.setInterceptors();
  }

  setInterceptors() {
    this.client.interceptors.request.use(
      (config) => config,
      (error) => Promise.reject(error)
    );
    this.client.interceptors.response.use(
      (response) => {
        if (response.data.resultCode === 416) {
          window.location.reload();
        }
        return response.data;
      },
      (error) => Promise.reject(error)
    );
  }

  request(options) {
    return this.client(options)
      .then((response) => Promise.resolve(response.data))
      .catch((error) => Promise.reject(error));
  }

  get(url, params) {
    return this.request({
      method: "get",
      url,
      params,
    });
  }

  post(url, data) {
    return this.request({
      method: "post",
      url,
      data,
    });
  }
}

export default new ApiClient();

10.4 home.js #

src\api\home.js

import apiClient from "./";

export const endpoints = {
  INDEX_INFOS: "/index-infos",
};

export function fetchIndexInfo() {
  return apiClient.get(endpoints.INDEX_INFOS);
}

11.分类菜单 #

11.1 Home.vue #

src\views\Home.vue

<script setup>
import { fetchIndexInfo } from '@/api/home'
const carousels = ref([])
onMounted(async () => {
  loading.value = true
  const data = await fetchIndexInfo()
  carousels.value = data.carousels
})
</script>
<template>
  <home-header></home-header>
  <home-swiper :carousels="carousels"></home-swiper>
+ <category-list></category-list>
</template>
<style scoped lang="less"></style>

11.2 CategoryList.vue #

src\components\CategoryList.vue

<script setup>
<script setup>
import categoryList from '@/config/categoryList'
const { proxy } = getCurrentInstance()
const tips = () => {
  proxy.$showToast('敬请期待')
}
</script>
<template>
  <div class="category-list">
    <div v-for="item in categoryList" v-bind:key="item.categoryId" @click="tips">
      <img :src="item.imgUrl" />
      <span>{{ item.name }}</span>
    </div>
  </div>
</template>
<style lang="less" scoped>
.category-list {
  display: flex;
  flex-wrap: wrap;
  width: 100%;
  padding-bottom: 13px;
  div {
    width: 20%;
    box-sizing: border-box;
    padding: 10px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    img {
      width: 36px;
      height: 36px;
    }
  }
}
</style>

11.3 categoryList.js #

src\config\categoryList.js

export default [
  {
    name: "新蜂超市",
    imgUrl: "https://s.yezgea02.com/1604041127880/%E8%B6%85%E5%B8%82%402x.png",
    categoryId: 100001,
  },
  {
    name: "新蜂服饰",
    imgUrl: "https://s.yezgea02.com/1604041127880/%E6%9C%8D%E9%A5%B0%402x.png",
    categoryId: 100003,
  },
  {
    name: "全球购",
    imgUrl:
      "https://s.yezgea02.com/1604041127880/%E5%85%A8%E7%90%83%E8%B4%AD%402x.png",
    categoryId: 100002,
  },
  {
    name: "新蜂生鲜",
    imgUrl: "https://s.yezgea02.com/1604041127880/%E7%94%9F%E9%B2%9C%402x.png",
    categoryId: 100004,
  },
  {
    name: "新蜂到家",
    imgUrl: "https://s.yezgea02.com/1604041127880/%E5%88%B0%E5%AE%B6%402x.png",
    categoryId: 100005,
  },
  {
    name: "充值缴费",
    imgUrl: "https://s.yezgea02.com/1604041127880/%E5%85%85%E5%80%BC%402x.png",
    categoryId: 100006,
  },
  {
    name: "9.9元拼",
    imgUrl: "https://s.yezgea02.com/1604041127880/9.9%402x.png",
    categoryId: 100007,
  },
  {
    name: "领劵",
    imgUrl: "https://s.yezgea02.com/1604041127880/%E9%A2%86%E5%88%B8%402x.png",
    categoryId: 100008,
  },
  {
    name: "省钱",
    imgUrl: "https://s.yezgea02.com/1604041127880/%E7%9C%81%E9%92%B1%402x.png",
    categoryId: 100009,
  },
  {
    name: "全部",
    imgUrl: "https://s.yezgea02.com/1604041127880/%E5%85%A8%E9%83%A8%402x.png",
    categoryId: 100010,
  },
];

12.首页商品 #

12.1 src\main.js #

src\main.js

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'lib-flexible'
import installVant from './installVant'
+import installUtils from './installUtils'
import App from './App.vue'
import router from './router'
import '@/assets/theme.css'

const app = createApp(App)

app.use(createPinia())
app.use(router)
installVant(app)
+installUtils(app)
app.mount('#app')

12.2 installUtils.js #

src\installUtils.js

export default function (app) {
  app.config.globalProperties.addUrlPrefix = (url) => {
    if (url && url.startsWith("http")) {
      return url;
    } else {
      url = `http://backend-api-01.newbee.ltd${url}`;
      return url;
    }
  };
}

12.3 Home.vue #

src\views\Home.vue

<script setup>
import { fetchIndexInfo } from '@/api/home'
const carousels = ref([])
+const newGoodses = ref([])
+const hotGoodses = ref([])
+const recommendGoodses = ref([])
+const loading = ref(false)
onMounted(async () => {
  loading.value = true
  const data = await fetchIndexInfo()
  carousels.value = data.carousels
+ newGoodses.value = data.newGoodses
+ hotGoodses.value = data.hotGoodses
+ recommendGoodses.value = data.recommendGoodses
+ loading.value = false
})
</script>
<template>
  <home-header></home-header>
  <home-swiper></home-swiper>
  <category-list></category-list>
+ <goods title="新品上线" :goodes="newGoodses" :loading="loading"></goods>
+ <goods title="热门商品" :goodes="hotGoodses" :loading="loading"></goods>
+ <goods title="最新推荐" :goodes="recommendGoodses" :loading="loading"></goods>
</template>
<style scoped lang="less"></style>

12.4 Goods.vue #

src\components\Goods.vue

<script setup>
defineProps(['title', 'loading', 'goodes'])
</script>
<template>
  <div class="good">
    <header class="good-header">{{ title }}</header>
    <van-skeleton title :row="3" :loading="loading">
      <div class="good-wrapper">
        <div class="good-item" v-for="goods in goodes" :key="goods.goodsId">
          <img :src="addUrlPrefix(goods.goodsCoverImg)" :alt="goods.goodsName" />
          <div class="good-desc">
            <div class="title">{{ goods.goodsName }}</div>
            <div class="price">¥ {{ goods.sellingPrice }}</div>
          </div>
        </div>
      </div>
    </van-skeleton>
  </div>
</template>
<style lang="less" scoped>
.good {
  .good-header {
    background: #f9f9f9;
    height: 50px;
    line-height: 50px;
    text-align: center;
    color: @primary;
    font-size: 16px;
  }
  .good-wrapper {
    display: flex;
    justify-content: flex-start;
    flex-wrap: wrap;
    .good-item {
      box-sizing: border-box;
      width: 50%;
      border: 1px solid #e9e9e9;
      padding: 10px;
      img {
        display: block;
        width: 120px;
        margin: 0 auto;
      }
      .good-desc {
        text-align: center;
        font-size: 14px;
        padding: 10px 0;
        .title {
          color: #222333;
        }
        .price {
          color: @primary;
        }
      }
      &:nth-child(2n + 1) {
        border-right: 1px solid #e9e9e9;
      }
    }
  }
}
</style>

13.路由守卫 #

13.1 router\index.js #

src\router\index.js

import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', redirect: '/home' },
    { path: '/home', name: 'home', component: Home },
    { path: '/category', name: 'category', component: () => import('@/views/Category.vue') },
    {
      path: '/cart',
      name: 'cart',
      component: () => import('@/views/Cart.vue'),
+     meta: { needLogin: true }
    },
    {
      path: '/user',
      name: 'user',
      component: () => import('@/views/User.vue'),
+     meta: { needLogin: true }
    },
    { path: '/login', name: 'login', component: () => import('@/views/Login.vue') }
  ]
})
+router.beforeEach((to) => {
+  if (to.matched.some((item) => item.meta.needLogin)) {
+    return {
+      path: '/login',
+      query: {
+        redirect: to.path,
+        ...to.query
+      }
+    }
+  }
+})
export default router

13.2 Login.vue #

src\views\Login.vue

<script setup></script>

<template>
  <Navigator :title="'注册'"></Navigator>
</template>

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

src\components\Navigator.vue

<template>
  <div class="navigator">
    <van-nav-bar left-arrow safe-area-inset-top fixed @click-left="$router.back()">
      <template #title>{{ title }}</template>
    </van-nav-bar>
  </div>
</template>
<script setup>
defineProps(['title'])
</script>
<style scoped></style>

14.用户注册 #

pnpm i blueimp-md5

14.1 Login.vue #

src\views\Login.vue

<script setup>
import logo from '@/assets/images/newbee-mall-vue3-app-logo.png'
import { userLogin, userRegister } from '@/api/user'
const { proxy } = getCurrentInstance()
const userForm = reactive({ loginName: '13888888888', password: '123456' })
const isLogin = ref(true)
const form = ref(null)
function changeLogin() {
  userForm.loginName = ''
  userForm.password = ''
  form.value.resetValidation()
  isLogin.value = !isLogin.value
}
const router = useRouter()
const route = useRoute()
async function submit() {
  try {
    if (isLogin.value) {
      await userLogin(userForm.loginName, userForm.password)
      proxy.$showToast('恭喜您,登录成功!')
      let to = route.query.redirect
      if (to) {
        delete route.query.redirect
        router.replace({
          path: to,
          query: route.query
        })
      } else {
        router.push('/')
      }
    } else {
      await userRegister(userForm.loginName, userForm.password)
      proxy.$showToast('注册成功,请登录吧~')
      changeLogin()
    }
  } catch (error) {
    proxy.$showToast(`${isLogin.value ? '登录失败' : '注册失败'} ${error.message}`)
  }
}
</script>

<template>
  <Navigator :title="isLogin ? '登录' : '注册'"></Navigator>
  <div class="login">
    <img :src="logo" class="logo" />
    <van-form @submit="submit" ref="form">
      <van-field
        label="手机号"
        name="loginName"
        v-model.trim="userForm.loginName"
        :rules="[
          { required: true, message: '手机号是必填项' },
          { pattern: /^1\d{10}$/, message: '手机号格式有误' }
        ]"
      />
      <van-field
        label="密码"
        type="password"
        name="password"
        v-model.trim="userForm.password"
        :rules="[{ required: true, message: '密码是必填项' }]"
      />
      <a class="text" @click="changeLogin">
        {{ isLogin ? '还没账号,请点此注册' : '已有账号,请点此登录' }}
      </a>
      <div style="margin: 16px">
        <van-button round block color="#1baeae" native-type="submit"> 确认提交 </van-button>
      </div>
    </van-form>
  </div>
</template>

<style lang="less" scoped>
.login {
  padding: 20px;
  .logo {
    display: block;
    margin: 75px auto 20px;
    width: 140px;
    height: 140px;
  }
  .text {
    display: inline-block;
    margin: 20px 0;
    padding: 0 15px;
    color: @primary;
    font-size: 14px;
  }
}
</style>

14.2 user.js #

src\api\user.js

import md5 from "blueimp-md5";
import apiClient from ".";

export const endpoints = {
  USER_LOGIN: "/user/login",
  USER_REGISTER: "/user/register",
};
export const userRegister = (loginName, password) => {
  return apiClient.post(endpoints.USER_REGISTER, {
    loginName,
    password,
  });
};
export const userLogin = (loginName, password) => {
  return apiClient.post(endpoints.USER_LOGIN, {
    loginName,
    passwordMd5: md5(password),
  });
};

15.Token #

15.1 Login.vue #

src\views\Login.vue

<script setup>
import logo from '@/assets/images/newbee-mall-vue3-app-logo.png'
import { userLogin, userRegister } from '@/api/user'
+import { setLocal } from '@/utils'
const { proxy } = getCurrentInstance()
const userForm = reactive({ loginName: '13888888888', password: '123456' })
const isLogin = ref(true)
const form = ref(null)
function changeLogin() {
  userForm.loginName = ''
  userForm.password = ''
  form.value.resetValidation()
  isLogin.value = !isLogin.value
}
const router = useRouter()
const route = useRoute()
async function submit() {
  try {
    if (isLogin.value) {
+     let token = await userLogin(userForm.loginName, userForm.password)
+     setLocal('token', token)
      proxy.$showToast('恭喜您,登录成功!')
      let to = route.query.redirect
      if (to) {
        delete route.query.redirect
        router.replace({
          path: to,
          query: route.query
        })
      } else {
        router.push('/')
      }
    } else {
      await userRegister(userForm.loginName, userForm.password)
      proxy.$showToast('注册成功,请登录吧~')
      changeLogin()
    }
  } catch (error) {
     proxy.$showToast(`${isLogin.value ? '登录失败' : '注册失败'} ${error.message}`)
  }
}
</script>

<template>
  <Navigator :title="isLogin ? '登录' : '注册'"></Navigator>
  <div class="login">
    <img :src="logo" class="logo" />
    <van-form @submit="submit" ref="form">
      <van-field
        label="手机号"
        name="loginName"
        v-model.trim="userForm.loginName"
        :rules="[
          { required: true, message: '手机号是必填项' },
          { pattern: /^1\d{10}$/, message: '手机号格式有误' }
        ]"
      />
      <van-field
        label="密码"
        type="password"
        name="password"
        v-model.trim="userForm.password"
        :rules="[{ required: true, message: '密码是必填项' }]"
      />
      <a class="text" @click="changeLogin">
        {{ isLogin ? '还没账号,请点此注册' : '已有账号,请点此登录' }}
      </a>
      <div style="margin: 16px">
        <van-button round block color="#1baeae" native-type="submit"> 确认提交 </van-button>
      </div>
    </van-form>
  </div>
</template>

<style lang="less" scoped>
.login {
  padding: 20px;
  .logo {
    display: block;
    margin: 75px auto 20px;
    width: 140px;
    height: 140px;
  }
  .text {
    display: inline-block;
    margin: 20px 0;
    padding: 0 15px;
    color: @primary;
    font-size: 14px;
  }
}
</style>

15.2 utils.js #

src\utils.js

export function setLocal(key, value) {
  if (typeof value == "object") {
    localStorage.setItem(key, JSON.stringify(value));
  } else if (typeof value == "string") {
    localStorage.setItem(key, value);
  }
}
export function clearLocal(key) {
  localStorage.removeItem(key);
}
export function getLocal(key) {
  const local = localStorage.getItem(key);
  return local;
}

15.3 router\index.js #

src\router\index.js

import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
+import { getLocal } from '@/utils'
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', redirect: '/home' },
    { path: '/home', name: 'home', component: Home },
    { path: '/category', name: 'category', component: () => import('@/views/Category.vue') },
    {
      path: '/cart',
      name: 'cart',
      component: () => import('@/views/Cart.vue'),
      meta: { needLogin: true }
    },
    {
      path: '/user',
      name: 'user',
      component: () => import('@/views/User.vue'),
      meta: { needLogin: true }
    },
    { path: '/login', name: 'login', component: () => import('@/views/Login.vue') }
  ]
})
router.beforeEach((to) => {
+  const hasToken = getLocal('token')
+  if (hasToken) {
+    if (to.path === '/login') {
+      return {
+        path: '/',
+        replace: true
+      }
+    }
+    return true
+  } else {
    if (to.matched.some((item) => item.meta.needLogin)) {
      return {
        path: '/login',
        query: {
          redirect: to.path,
          ...to.query
        }
      }
    }
+  }
})
export default router

15.4 api\index.js #

src\api\index.js

import axios from 'axios'
+import { getLocal } from '@/utils'
class ApiClient {
  constructor(baseURL = '/', timeout = 3000) {
    const env = import.meta.env.DEV ? 'http://backend-api-01.newbee.ltd/api/v1' : baseURL
    this.client = axios.create({
      baseURL: env,
      timeout
    })
    this.setInterceptors()
  }

  setInterceptors() {
    this.client.interceptors.request.use(
      (config) => {
+       const token = getLocal('token')
+       if (token) {
+         config.headers['token'] = token
+       }
+       return config
+     },
      (error) => Promise.reject(error)
    )
    this.client.interceptors.response.use(
      (response) => {
+       if (response.data.resultCode === 500) {
+         return Promise.reject(new Error(response.data.message))
+       }
        return response.data
      },
      (error) => Promise.reject(error)
    )
  }

  request(options) {
    return this.client(options)
      .then((response) => Promise.resolve(response.data))
      .catch((error) => Promise.reject(error))
  }

  get(url, params) {
    return this.request({
      method: 'get',
      url,
      params
    })
  }

  post(url, data) {
    return this.request({
      method: 'post',
      url,
      data
    })
  }
}

export default new ApiClient()

16.验证码 #

16.1 Login.vue #

src\views\Login.vue

<script setup>
import logo from '@/assets/images/newbee-mall-vue3-app-logo.png'
import { userLogin, userRegister } from '@/api/user'
import { setLocal } from '@/utils'
const { proxy } = getCurrentInstance()
const userForm = reactive({ loginName: '13888888888', password: '123456' })
const isLogin = ref(true)
const form = ref(null)
+const captchaText = ref('')
+const captchaImg = ref(null)
+function validatorCaptcha() {
+  if (captchaText.value !== userForm.captcha) {
+    captchaImg.value.reset()
+    return false
+  }
+  return true
+}
function changeLogin() {
  userForm.loginName = ''
  userForm.password = ''
  form.value.resetValidation()
  isLogin.value = !isLogin.value
}
const router = useRouter()
const route = useRoute()
async function submit() {
  try {
    if (isLogin.value) {
      let token = await userLogin(userForm.loginName, userForm.password)
      setLocal('token', token)
      proxy.$showToast('恭喜您,登录成功!')
      let to = route.query.redirect
      if (to) {
        delete route.query.redirect
        router.replace({
          path: to,
          query: route.query
        })
      } else {
        router.push('/')
      }
    } else {
      await userRegister(userForm.loginName, userForm.password)
      proxy.$showToast('注册成功,请登录吧~')
      changeLogin()
    }
  } catch (error) {
    proxy.$showToast(`${isLogin.value ? '登录失败' : '注册失败'} ${error.message}`)
  }
}
</script>

<template>
  <Navigator :title="isLogin ? '登录' : '注册'"></Navigator>
  <div class="login">
    <img :src="logo" class="logo" />
    <van-form @submit="submit" ref="form">
      <van-field
        label="手机号"
        name="loginName"
        v-model.trim="userForm.loginName"
        :rules="[
          { required: true, message: '手机号是必填项' },
          { pattern: /^1\d{10}$/, message: '手机号格式有误' }
        ]"
      />
      <van-field
        label="密码"
        type="password"
        name="password"
        v-model.trim="userForm.password"
        :rules="[{ required: true, message: '密码是必填项' }]"
      />
+     <van-field
+       label="验证码"
+       name="captcha"
+       v-model.trim="userForm.captcha"
+       :rules="[{ validator: validatorCaptcha, message: '验证码输入有误' }]"
+     >
+       <template #button>
+         <CaptchaImg v-model:text="captchaText" ref="captchaImg" width="120" height="40" />
+       </template>
+     </van-field>
      <a class="text" @click="changeLogin">
        {{ isLogin ? '还没账号,请点此注册' : '已有账号,请点此登录' }}
      </a>
      <div style="margin: 16px">
        <van-button round block color="#1baeae" native-type="submit"> 确认提交 </van-button>
      </div>
    </van-form>
  </div>
</template>

<style lang="less" scoped>
.login {
  padding: 20px;
  .logo {
    display: block;
    margin: 75px auto 20px;
    width: 140px;
    height: 140px;
  }
  .text {
    display: inline-block;
    margin: 20px 0;
    padding: 0 15px;
    color: @primary;
    font-size: 14px;
  }
}
</style>

16.2 CaptchaImg.vue #

src\components\CaptchaImg.vue

<template>
  <div class="captcha">
    <canvas ref="captchaRef" :width="width" :height="height" @click="reset"></canvas>
  </div>
</template>
<script setup>
import { onMounted, ref, defineProps, defineEmits, defineExpose } from 'vue'
const emit = defineEmits(['update:text'])
const { width = 120, height = 40 } = defineProps(['width', 'height'])
const captchaRef = ref(null)
const pool = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
onMounted(() => {
  reset()
})
const randomNum = (min, max) => Math.floor(Math.random() * (max - min) + min)
const randomColor = (min, max) => {
  const [r, g, b] = [randomNum(min, max), randomNum(min, max), randomNum(min, max)]
  return `rgb(${r},${g},${b})`
}
const drawText = (ctx) => {
  let code = ''
  for (let i = 0; i < 4; i++) {
    const text = pool[randomNum(0, pool.length)]
    code += text
    const fontSize = randomNum(24, 40)
    const deg = randomNum(-30, 30)
    ctx.font = `${fontSize}px Simhei`
    ctx.textBaseline = 'middle'
    ctx.fillStyle = randomColor(80, 150)
    ctx.save()
    ctx.translate(30 * i + 15, height / 2)
    ctx.rotate((deg * Math.PI) / 180)
    ctx.fillText(text, 0, 0)
    ctx.restore()
  }
  return code
}
const drawInterferenceLines = (ctx) => {
  for (let i = 0; i < 5; i++) {
    ctx.beginPath()
    ctx.moveTo(randomNum(0, width), randomNum(0, height))
    ctx.lineTo(randomNum(0, width), randomNum(0, height))
    ctx.strokeStyle = randomColor(180, 230)
    ctx.closePath()
    ctx.stroke()
  }
}
const drawInterferenceDots = (ctx) => {
  for (let i = 0; i < 40; i++) {
    ctx.beginPath()
    ctx.arc(randomNum(0, width), randomNum(0, height), 1, 0, 2 * Math.PI)
    ctx.closePath()
    ctx.fillStyle = randomColor(150, 200)
    ctx.fill()
  }
}
const draw = () => {
  const ctx = captchaRef.value.getContext('2d')
  ctx.fillStyle = randomColor(180, 230)
  ctx.fillRect(0, 0, width, height)
  const code = drawText(ctx)
  drawInterferenceLines(ctx)
  drawInterferenceDots(ctx)
  return code
}
const reset = () => {
  emit('update:text', draw())
}
defineExpose({ reset })
</script>
<style scoped>
.captcha canvas {
  cursor: pointer;
}
</style>

17.登录状态 #

17.1 HomeHeader.vue #

src\components\HomeHeader.vue

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
+import { getLocal } from '@/utils'
const isActive = ref(false)
+const alreadyLogin = ref(false)
const setIsActive = () => {
  let scrollTop = document.documentElement.scrollTop || document.body.scrollTop
  isActive.value = scrollTop > 100
}
onMounted(() => {
  window.addEventListener('scroll', setIsActive)
+ const token = getLocal('token')
+ if (token) {
+   alreadyLogin.value = true
+ }
})
onUnmounted(() => {
  window.removeEventListener('scroll', setIsActive)
})
</script>
<template>
  <van-sticky>
    <header class="home-header" :class="{ active: isActive }">
      <span class="category" @click="$router.push('/category')"><van-icon name="wap-nav" /></span>
      <div class="header-search">
        <span class="app-name">新蜂商城</span>
        <span class="search-title" @click="$router.push('/search')">山河无恙,人间皆安</span>
      </div>
+     <van-icon name="manager" @click="$router.push('/user')" v-if="alreadyLogin" />
+     <span class="user" @click="$router.push('/user')" v-else>登录</span>
    </header>
  </van-sticky>
</template>
<style scoped lang="less">
.home-header {
  position: fixed;
  display: flex;
  justify-content: space-between;
  align-items: center;
  left: 0;
  top: 0;
  width: 100%;
  height: 50px;
  line-height: 50px;
  padding: 0 15px;
  box-sizing: border-box;
  font-size: 15px;
  color: @primary;
  z-index: 10000;
  &.active {
    background-color: @primary;
    .category,
    .user {
      color: #fff;
    }
  }
  .category {
    font-size: 20px;
    font-weight: bold;
  }
  .header-search {
    display: flex;
    justify-content: space-evenly;
    align-items: center;
    height: 30px;
    width: 75%;
    background: rgba(255, 255, 255, 0.7);
    border-radius: 20px;
    .app-name {
      font-size: 20px;
      font-weight: bold;
    }
    .search-title {
      color: #666;
    }
  }
}
</style>

18.商品详情页 #

18.1 Goods.vue #

src\components\Goods.vue

<script setup>
defineProps(['title', 'loading', 'goodes'])
+const router = useRouter()
+const goToDetail = (item) => {
+  router.push({ path: `/product/${item.goodsId}` })
+}
</script>
<template>
  <div class="good">
    <header class="good-header">{{ title }}</header>
    <van-skeleton title :row="3" :loading="loading">
      <div class="good-wrapper">
+       <div
+         class="good-item"
+         v-for="goods in goodes"
+         :key="goods.goodsId"
+         @click="goToDetail(goods)"
+       >
          <img :src="addUrlPrefix(goods.goodsCoverImg)" :alt="goods.goodsName" />
          <div class="good-desc">
            <div class="title">{{ goods.goodsName }}</div>
            <div class="price">¥ {{ goods.sellingPrice }}</div>
          </div>
        </div>
      </div>
    </van-skeleton>
  </div>
</template>
<style lang="less" scoped>
.good {
  .good-header {
    background: #f9f9f9;
    height: 50px;
    line-height: 50px;
    text-align: center;
    color: @primary;
    font-size: 16px;
  }
  .good-wrapper {
    display: flex;
    justify-content: flex-start;
    flex-wrap: wrap;
    .good-item {
      box-sizing: border-box;
      width: 50%;
      border: 1px solid #e9e9e9;
      padding: 10px;
      img {
        display: block;
        width: 120px;
        margin: 0 auto;
      }
      .good-desc {
        text-align: center;
        font-size: 14px;
        padding: 10px 0;
        .title {
          color: #222333;
        }
        .price {
          color: @primary;
        }
      }
      &:nth-child(2n + 1) {
        border-right: 1px solid #e9e9e9;
      }
    }
  }
}
</style>

18.2 ProductDetail.vue #

src\views\ProductDetail.vue

<script setup>
import { queryGoodsInfo } from '@/api/goods'
const route = useRoute()
const goodsInfo = ref(null)
onMounted(async () => {
  const data = await queryGoodsInfo(route.params.id)
  goodsInfo.value = data
  console.log(goodsInfo.value)
})
</script>
<template>
  <div class="detail-wrapper">
    <Navigator title="商品详情"></Navigator>
    <van-skeleton :row="5" :loading="!goodsInfo">
      <div class="info">
        <van-image lazy-load :src="addUrlPrefix(goodsInfo.goodsCoverImg)" />
        <div class="desc">
          <h3 class="name">{{ goodsInfo.goodsName }}</h3>
          <p class="intro">{{ goodsInfo.goodsIntro }}</p>
          <p class="price">¥ {{ goodsInfo.sellingPrice }}</p>
        </div>
        <div class="tab">
          <a href="javascript:;">概述</a>
          <span>|</span>
          <a href="javascript:;">参数</a>
          <span>|</span>
          <a href="javascript:;">安装服务</a>
          <span>|</span>
          <a href="javascript:;">常见问题</a>
        </div>
        <div class="content" v-html="goodsInfo.goodsDetailContent"></div>
      </div>
    </van-skeleton>
    <van-action-bar>
      <van-action-bar-icon icon="chat-o" text="客服" />
      <van-action-bar-icon icon="cart-o" text="购物车" @click="$router.push('/cart')" />
      <van-action-bar-button color="linear-gradient(90deg,#6bd8d8,#1baeae)" text="加入购物车" />
      <van-action-bar-button color="linear-gradient(90deg,#0dc3c3,#098888)" text="立即购买" />
    </van-action-bar>
  </div>
</template>
<style lang="less" scoped>
.detail-wrapper {
  padding: 50px 0;
  .info {
    padding: 0 10px;
    .desc {
      .name {
        font-size: 18px;
        color: #555;
        font-weight: normal;
      }
      .intro {
        padding: 10px 0;
        font-size: 14px;
        color: #999;
      }
      .price {
        font-size: 18px;
        color: #f63515;
      }
    }
  }
}

.tab {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 15px 0;
  a,
  span {
    font-size: 15px;
    color: #555;
    padding: 0 10px;
  }
}
</style>

18.3 router\index.js #

src\router\index.js

import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import { getLocal } from '@/utils'
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', redirect: '/home' },
    { path: '/home', name: 'home', component: Home },
    { path: '/category', name: 'category', component: () => import('@/views/Category.vue') },
    {
      path: '/cart',
      name: 'cart',
      component: () => import('@/views/Cart.vue'),
      meta: { needLogin: true }
    },
    {
      path: '/user',
      name: 'user',
      component: () => import('@/views/User.vue'),
      meta: { needLogin: true }
    },
    { path: '/login', name: 'login', component: () => import('@/views/Login.vue') },
+   {
+     path: '/product/:id',
+     name: 'product',
+     component: () => import('@/views/ProductDetail.vue')
+   }
  ]
})
router.beforeEach((to) => {
  const hasToken = getLocal('token')
  if (hasToken) {
    if (to.path === '/login') {
      return {
        path: '/',
        replace: true
      }
    }
    return true
  } else {
    if (to.matched.some((item) => item.meta.needLogin)) {
      return {
        path: '/login',
        query: {
          redirect: to.path,
          ...to.query
        }
      }
    }
  }
})
export default router

18.4 goods.js #

src\api\goods.js

import apiClient from ".";

export const endpoints = {
  GOODS_DETAIL: "/goods/detail",
};
export const queryGoodsInfo = (id) => {
  return apiClient.get(`${endpoints.GOODS_DETAIL}/${id}`);
};

19.购物车 #

19.1 cart.js #

src\stores\cart.js

import {
  queryShopCart,
  setCartCount,
  removeCart,
  addNewCart,
} from "@/api/cart";

export const useCartStore = defineStore("cart", () => {
  const cartItems = ref([]);

  async function getCartList() {
    cartItems.value = await queryShopCart();
  }
  async function addList(goodsId, goodsCount = 1) {
    await addNewCart(goodsId, goodsCount);
    await getCartList();
  }
  async function removeList(cartItemId) {
    await removeCart(cartItemId);
    cartItems.value = cartItems.value.filter((item) => {
      return item.cartItemId != cartItemId;
    });
  }
  async function updateList(cartItemId, goodsCount) {
    await setCartCount(cartItemId, goodsCount);
    cartItems.value.map((item) => {
      if (item.cartItemId == cartItemId) {
        item.goodsCount = goodsCount;
      }
      return item;
    });
  }
  function setChecked({ checked }) {
    cartItems.value.map((item) => {
      item.checked = checked;
      return item;
    });
  }
  const totalAmount = computed(() => {
    if (cartItems.value.length == 0) {
      return 0;
    }
    return cartItems.value.reduce((result) => {
      return result + 1;
    }, 0);
  });
  const totalPrice = computed(() => {
    return (
      cartItems.value.reduce((memo, current) => {
        if (current.checked) {
          memo += current.goodsCount * current.sellingPrice;
        }
        return memo;
      }, 0) * 100
    );
  });
  const isCheckedAll = computed({
    get() {
      if (cartItems.value.length == 0) return false;
      return cartItems.value.every((item) => item.checked);
    },
    set(newVal) {
      cartItems.value.forEach((item) => (item.checked = newVal));
    },
  });
  return {
    cartItems,
    getCartList,
    addList,
    removeList,
    updateList,
    setChecked,
    totalAmount,
    totalPrice,
    isCheckedAll,
  };
});

19.2 cart.js #

src\api\cart.js

import apiClient from ".";

export const endpoints = {
  SHOP_CART: "/shop-cart",
};

export const queryShopCart = () => apiClient.get(endpoints.SHOP_CART);

export const setCartCount = (cartItemId, goodsCount) =>
  apiClient.request({
    url: endpoints.SHOP_CART,
    method: "PUT",
    data: {
      cartItemId,
      goodsCount,
    },
  });

export const removeCart = (cartItemId) =>
  apiClient.request({
    url: `${endpoints.SHOP_CART}/${cartItemId}`,
    method: "DELETE",
  });

export const addNewCart = (goodsId, goodsCount = 1) =>
  apiClient.post(endpoints.SHOP_CART, {
    goodsId,
    goodsCount,
  });

19.3 Cart.vue #

src\views\Cart.vue

<script setup>
import empty from '@/assets/images/empty-car.png'
import { useCartStore } from '../stores/cart'
import { storeToRefs } from 'pinia'
const store = useCartStore()
const { cartItems, totalPrice } = storeToRefs(store)
const removeItemFromCart = (cartItemId) => store.removeList(cartItemId)
</script>
<template>
  <Navigator title="购物车"></Navigator>
  <van-empty description="购物车空空如也" :image="empty" v-if="!cartItems.length" />
  <div class="cart" v-else>
    <van-swipe-cell v-for="item in cartItems" :key="item.goodsId">
      <div class="item">
        <van-checkbox checked-color="#1baeae" v-model="item.checked"></van-checkbox>
        <GoodsItem :info="item" />
      </div>
      <template #right>
        <van-button square type="danger" text="删除" @click="removeItemFromCart(item.cartItemId)" />
      </template>
    </van-swipe-cell>
  </div>
  <van-submit-bar :price="totalPrice" button-text="结算" button-type="primary">
    <van-checkbox v-model="store.isCheckedAll">全选</van-checkbox>
  </van-submit-bar>
</template>
<style lang="less" scoped>
.cart {
  padding: 50px 20px 100px 20px;
  .item {
    display: flex;
  }
}
.van-submit-bar {
  bottom: 50px;
}
</style>

19.4 ProductDetail.vue #

src\views\ProductDetail.vue

<script setup>
import { queryGoodsInfo } from '@/api/goods'
+import { useCartStore } from '@/stores/cart.js'
const route = useRoute()
const goodsInfo = ref(null)
+const store = useCartStore()
+const { cartItems } = storeToRefs(store)
+function findCartItem(goodsId) {
+  return cartItems.value.find((item) => item.goodsId == goodsId)
+}
+const { proxy } = getCurrentInstance()
+function addCart() {
+  const cartItem = findCartItem(route.params.id)
+  if (cartItem) {
+    if (cartItem.goodsCount == 5) {
+      return proxy.$showToast('超出最大购买限制')
+    }
+    store.updateList(cartItem.cartItemId, cartItem.goodsCount + 1)
+  } else {
+    store.addList(route.params.id)
+  }
+}
onMounted(async () => {
  const data = await queryGoodsInfo(route.params.id)
  goodsInfo.value = data
  console.log(goodsInfo.value)
})
</script>
<template>
  <div class="detail-wrapper">
    <Navigator title="商品详情"></Navigator>
    <van-skeleton :row="5" :loading="!goodsInfo">
      <div class="info">
        <van-image lazy-load :src="addUrlPrefix(goodsInfo.goodsCoverImg)" />
        <div class="desc">
          <h3 class="name">{{ goodsInfo.goodsName }}</h3>
          <p class="intro">{{ goodsInfo.goodsIntro }}</p>
          <p class="price">¥ {{ goodsInfo.sellingPrice }}</p>
        </div>
        <div class="tab">
          <a href="javascript:;">概述</a>
          <span>|</span>
          <a href="javascript:;">参数</a>
          <span>|</span>
          <a href="javascript:;">安装服务</a>
          <span>|</span>
          <a href="javascript:;">常见问题</a>
        </div>
        <div class="content" v-html="goodsInfo.goodsDetailContent"></div>
      </div>
    </van-skeleton>
    <van-action-bar>
      <van-action-bar-icon icon="chat-o" text="客服" />
+     <van-action-bar-icon
+       icon="cart-o"
+       text="购物车"
+       @click="$router.push('/cart')"
+       :badge="store.totalAmount"
+     />
+     <van-action-bar-button
+       color="linear-gradient(90deg,#6bd8d8,#1baeae)"
+       text="加入购物车"
+       @click="addCart"
+     />
      <van-action-bar-button color="linear-gradient(90deg,#0dc3c3,#098888)" text="立即购买" />
    </van-action-bar>
  </div>
</template>
<style lang="less" scoped>
.detail-wrapper {
  padding: 50px 0;
  .info {
    padding: 0 10px;
    .desc {
      .name {
        font-size: 18px;
        color: #555;
        font-weight: normal;
      }
      .intro {
        padding: 10px 0;
        font-size: 14px;
        color: #999;
      }
      .price {
        font-size: 18px;
        color: #f63515;
      }
    }
+   .content {
+     :deep(img) {
+       width: 100%;
+     }
+   }
  }
}

.tab {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 15px 0;
  a,
  span {
    font-size: 15px;
    color: #555;
    padding: 0 10px;
  }
}
</style>

19.5 GoodsItem.vue #

src\components\GoodsItem.vue

<template>
  <router-link :to="`/product/${info.goodsId}`">
    <div class="cart-item">
      <van-image :src="addUrlPrefix(info.goodsCoverImg)" />
      <div class="desc">
        <h3 class="name">{{ info.goodsName }}</h3>
        <p class="info">
          <span>{{ info.sellingPrice }}</span>
          <van-stepper max="5" :modelValue="info.goodsCount" @change="updateItemCount" />
        </p>
      </div>
    </div>
  </router-link>
</template>

<script setup>
import { useCartStore } from '@/stores/cart'
const props = defineProps(['cart', 'info'])
const store = useCartStore()
const updateItemCount = (goodsCount) => store.updateList(props.info.cartItemId, goodsCount)
</script>
<style lang="less" scoped>
.cart-item {
  display: flex;
  justify-content: center;
  align-items: center;
  .van-image {
    width: 100px;
    height: 100px;
  }
  .desc {
    display: flex;
    flex-direction: column;
    .name {
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      max-width: 180px;
    }
    .info {
      display: flex;
      justify-content: space-between;
    }
  }
}
</style>

19.6 src\components\NavBar.vue #

src\components\NavBar.vue

<template>
  <van-tabbar v-if="isVisible" route>
    <van-tabbar-item to="/home">
      <template #icon><van-icon name="wap-home-o" /></template>首页
    </van-tabbar-item>
    <van-tabbar-item to="/category">
      <template #icon><van-icon name="gem-o" /></template>分类
    </van-tabbar-item>
    <van-tabbar-item to="/cart">
+     <template #icon>
+       <van-icon
+         name="shopping-cart-o"
+         :badge="!store.totalAmount ? '' : store.totalAmount" />
+     </template>
          购物车
    </van-tabbar-item>
    <van-tabbar-item to="/user">
      <template #icon><van-icon name="user-o" /></template>我的
    </van-tabbar-item>
  </van-tabbar>
</template>

<script setup>
+import { useCartStore } from '../stores/cart'
+const store = useCartStore()
const route = useRoute()
const isVisible = ref(false)
watchEffect(() => {
  const showList = ['/home', '/category', '/cart', '/user']
  isVisible.value = showList.includes(route.path)
})
</script>

19.7 router\index.js #

src\router\index.js

import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import { getLocal } from '@/utils'
+import { useCartStore } from '@/stores/cart'
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', redirect: '/home' },
    { path: '/home', name: 'home', component: Home },
    { path: '/category', name: 'category', component: () => import('@/views/Category.vue') },
    {
      path: '/cart',
      name: 'cart',
      component: () => import('@/views/Cart.vue'),
      meta: { needLogin: true }
    },
    {
      path: '/user',
      name: 'user',
      component: () => import('@/views/User.vue'),
      meta: { needLogin: true }
    },
    { path: '/login', name: 'login', component: () => import('@/views/Login.vue') },
    {
      path: '/product/:id',
      name: 'product',
      component: () => import('@/views/ProductDetail.vue')
    }
  ]
})
+router.beforeEach(async (to) => {
  const hasToken = getLocal('token')
  if (hasToken) {
+   const store = useCartStore()
+   if (hasToken && store.cartItems.length == 0) {
+     await store.getCartList()
+   }
    if (to.path === '/login') {
      return {
        path: '/',
        replace: true
      }
    }
    return true
  } else {
    if (to.matched.some((item) => item.meta.needLogin)) {
      return {
        path: '/login',
        query: {
          redirect: to.path,
          ...to.query
        }
      }
    }
  }
})
export default router

20.商品分类 #

20.1 Category.vue #

src\views\Category.vue

<script setup>
import classify from '@/assets/images/classify.png'
import { useCategoryStore } from '@/stores/Category'
import { computed, onMounted, ref } from 'vue'

const store = useCategoryStore()
const { categories } = toRefs(store)

onMounted(async () => {
  if (!categories.value.length) {
    await store.fetchCategories()
  }
})

const activeIndex = ref(1)
const secondLevelCategories = computed(() => {
  return categories.value[activeIndex.value]?.secondLevelCategoryVOS
})
</script>

<template>
  <Navigator title="搜索需要的产品"></Navigator>
  <div class="category-wrapper">
    <van-sidebar v-model="activeIndex">
      <van-sidebar-item
        :title="category.categoryName"
        v-for="category in categories"
        :key="category.categoryName"
      />
    </van-sidebar>
    <div
      class="second-level-category"
      v-if="secondLevelCategories && secondLevelCategories.length > 0"
    >
      <div
        v-for="secondLevelCategory in secondLevelCategories"
        :key="secondLevelCategory.categoryId"
      >
        <h3 class="title">{{ secondLevelCategory.categoryName }}</h3>
        <div class="third-level-categories">
          <div
            v-for="thirdLevelCategory in secondLevelCategory.thirdLevelCategoryVOS"
            :key="thirdLevelCategory.categoryId"
            class="third-level-category"
          >
            <van-image :src="classify" />
            <span>{{ thirdLevelCategory.categoryName }}</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped lang="less">
.category-wrapper {
  padding: 50px 10px;
  display: flex;
  .van-sidebar {
    width: 120px;
    flex-shrink: 0;
  }
  .second-level-category {
    padding: 10px;
    width: 100%;
    .third-level-categories {
      display: flex;
      flex-wrap: wrap;
      .third-level-category {
        width: 33.3%;
        box-sizing: border-box;
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
        padding:5px;
        :deep(.van-image__img) {
          width: 30px;
        }
      }
    }
  }
}
</style>

20.2 category.js #

src\stores\category.js

import { queryCategories } from "@/api/goods";
export const useCategoryStore = defineStore("category", () => {
  const categories = ref([]);
  async function fetchCategories() {
    try {
      categories.value = await queryCategories();
    } catch (error) {
      console.error("Failed to fetch categories:", error);
    }
  }
  return {
    categories,
    fetchCategories,
  };
});

20.3 goods.js #

src\api\goods.js

import apiClient from ".";

export const endpoints = {
  GOODS_DETAIL: "/goods/detail",
  CATEGORIES: "/categories",
};
export const queryGoodsInfo = (id) => {
  return apiClient.get(`${endpoints.GOODS_DETAIL}/${id}`);
};
+export function queryCategories() {
+  return apiClient.get(endpoints.CATEGORIES);
+}

21.搜索 #

21.1 goods.js #

src\api\goods.js

import apiClient from '.'

export const endpoints = {
  GOODS_DETAIL: '/goods/detail',
  CATEGORIES: '/categories',
+ SEARCH: '/search'
}
export const queryGoodsInfo = (id) => {
  return apiClient.get(`${endpoints.GOODS_DETAIL}/${id}`)
}
export function queryCategories() {
  return apiClient.get(endpoints.CATEGORIES)
}
+export function searchGoods(pageNumber, keyword, orderBy, goodsCategoryId) {
+  let params = { pageNumber, orderBy }
+  if (keyword) {
+    params.keyword = keyword
+  }
+  if (goodsCategoryId) {
+    params.goodsCategoryId = goodsCategoryId
+  }
+  return apiClient.get(endpoints.SEARCH, params)
+}

src\components\Navigator.vue

<template>
  <div class="navigator">
    <van-nav-bar left-arrow safe-area-inset-top fixed @click-left="$router.back()">
      <template #title>
        <slot name="title">
          {{ title }}
        </slot>
      </template>
+     <template #right>
+       <slot name="right"></slot>
+     </template>
    </van-nav-bar>
  </div>
</template>
<script setup>
defineProps(['title'])
</script>
<style scoped></style>

21.3 src\router\index.js #

src\router\index.js

import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import { getLocal } from '@/utils'
import { useCartStore } from '@/stores/cart'
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', redirect: '/home' },
    { path: '/home', name: 'home', component: Home },
    { path: '/category', name: 'category', component: () => import('@/views/Category.vue') },
    {
      path: '/cart',
      name: 'cart',
      component: () => import('@/views/Cart.vue'),
      meta: { needLogin: true }
    },
    {
      path: '/user',
      name: 'user',
      component: () => import('@/views/User.vue'),
      meta: { needLogin: true }
    },
    { path: '/login', name: 'login', component: () => import('@/views/Login.vue') },
    {
      path: '/product/:id',
      name: 'product',
      component: () => import('@/views/ProductDetail.vue')
    },
+   {
+     path: '/search',
+     name: 'search',
+     component: () => import('@/views/Search.vue')
+   }
  ]
})
router.beforeEach(async (to) => {
  const hasToken = getLocal('token')
  if (hasToken) {
    const store = useCartStore()
    if (hasToken && store.cartItems.length == 0) {
      await store.getCartList()
    }
    if (to.path === '/login') {
      return {
        path: '/',
        replace: true
      }
    }
    return true
  } else {
    if (to.matched.some((item) => item.meta.needLogin)) {
      return {
        path: '/login',
        query: {
          redirect: to.path,
          ...to.query
        }
      }
    }
  }
})
export default router

21.4 src\views\Category.vue #

src\views\Category.vue

<script setup>
import classify from '@/assets/images/classify.png'
import { useCategoryStore } from '@/stores/Category'
import { computed, onMounted, ref } from 'vue'

const store = useCategoryStore()
const { categories } = toRefs(store)

onMounted(async () => {
  if (!categories.value.length) {
    await store.fetchCategories()
  }
})

+const activeIndex = ref(0)
const secondLevelCategories = computed(() => {
+  return categories.value?.[activeIndex.value]?.secondLevelCategoryVOS
})
</script>

<template>
  <Navigator title="搜索需要的产品">
+   <template #title>
+     <van-search placeholder="搜索需要的产品" @click="$router.push('/search')"></van-search>
+   </template>
  </Navigator>
  <div class="category-wrapper">
    <van-sidebar v-model="activeIndex">
      <van-sidebar-item
        :title="category.categoryName"
        v-for="category in categories"
        :key="category.categoryName"
      />
    </van-sidebar>
    <div
      class="second-level-category"
      v-if="secondLevelCategories && secondLevelCategories.length > 0"
    >
      <div
        v-for="secondLevelCategory in secondLevelCategories"
        :key="secondLevelCategory.categoryId"
      >
        <h3 class="title">{{ secondLevelCategory.categoryName }}</h3>
+       <template
+         v-if="secondLevelCategory.thirdLevelCategoryVOS"
+       >
+         <div class="third-level-categories">
+           <router-link
+             :to="{
+               path: '/search',
+               query: {
+                 categoryId: secondLevelCategory.categoryId
+               }
+             }"
+             v-for="thirdLevelCategory in secondLevelCategory.thirdLevelCategoryVOS"
+             :key="thirdLevelCategory.categoryId"
+             class="third-level-category"
+           >
+             <van-image :src="classify" />
+             <span>{{ thirdLevelCategory.categoryName }}</span>
+           </router-link>
+         </div>
+       </template>
      </div>
    </div>
  </div>
</template>

<style scoped lang="less">
.category-wrapper {
  padding: 50px 10px;
  display: flex;
  .van-sidebar {
    width: 120px;
    flex-shrink: 0;
  }
  .second-level-category {
    padding: 10px;
    width: 100%;
    .third-level-categories {
      display: flex;
      flex-wrap: wrap;
      .third-level-category {
        width: 33.3%;
        box-sizing: border-box;
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
        padding: 5px;
        :deep(.van-image__img) {
          width: 30px;
        }
      }
    }
  }
}
</style>

21.5 Search.vue #

src\views\Search.vue

<script setup>
import { searchGoods } from '@/api/goods'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

const state = reactive({
  keyword: '',
  orderBy: '',
  currPage: 1,
  pageSize: 10,
  totalPage: 0,
  loading: false,
  productList: [],
  finished: false,
  refreshing: false,
  isEmpty: false
})

const queryProducts = async () => {
  state.loading = true
  const { list, totalPage } = await searchGoods(
    state.currPage,
    state.keyword,
    state.orderBy,
    route.query.categoryId || ''
  )
  state.productList = state.productList.concat(list)
  state.totalPage = totalPage
  state.isEmpty = state.productList.length === 0
  state.loading = false

  if (state.currPage >= totalPage) state.finished = true
}
const refreshProducts = async () => {
  state.refreshing = true
  await searchProducts()
  state.refreshing = false
}
const searchProducts = async () => {
  state.finished = false
  state.currPage = 1
  state.productList = []
  await queryProducts()
}
const loadProducts = async () => {
  if (state.currPage < state.totalPage) {
    state.currPage = state.currPage + 1
  }
  await queryProducts()
}

const productDetail = (item) => {
  router.push({ path: `/product/${item.goodsId}` })
}

const changeTab = ({ name }) => {
  state.orderBy = name
  refreshProducts()
}
</script>

<template>
  <Navigator>
    <template #title>
      <van-search v-model="state.keyword" />
    </template>
    <template #right>
      <van-button type="primary" size="small" @click="searchProducts">搜索</van-button>
    </template>
  </Navigator>
  <div class="search-wrapper">
    <van-tabs type="card" color="#1baeae" @click-tab="changeTab">
      <van-tab title="推荐" name=""></van-tab>
      <van-tab title="新品" name="new"></van-tab>
      <van-tab title="价格" name="price"></van-tab>
    </van-tabs>
    <van-pull-refresh
      v-model="state.refreshing"
      @refresh="refreshProducts"
      class="product-list-refresh"
    >
      <van-list
        v-model:loading="state.loading"
        :finished="state.finished"
        :finished-text="state.productList.length ? '没有更多了' : '搜索想要的商品'"
        @load="loadProducts"
        @offset="state.pageSize"
      >
        <div
          v-for="item in state.productList"
          :key="item.goodsId"
          @click="productDetail(item)"
          class="product"
        >
          <img :src="addUrlPrefix(item.goodsCoverImg)" class="cover" />
          <div class="info">
            <span class="name">{{ item.goodsName }}</span>
            <span>{{ item.goodsIntro }}</span>
            <span class="price">¥ {{ item.sellingPrice }}</span>
          </div>
        </div>
      </van-list>
    </van-pull-refresh>
    <img
      class="empty"
      v-show="state.isEmpty"
      src="https://s.yezgea02.com/1604041313083/kesrtd.png"
    />
  </div>
</template>
<style scoped lang="less">
.search-wrapper {
  padding: 50px 0;
  .product {
    display: flex;
    padding: 5px;
    .cover {
      width: 120px;
      height: 120px;
    }
    .info {
      display: flex;
      flex-direction: column;
      justify-content: space-evenly;
      padding: 5px;
      .name {
        font-weight: 700;
      }
      .price {
        color: @primary;
      }
    }
  }
  .empty {
    width: 100%;
  }
}
</style>

参考 #

1.ESLint #

ESLint 是一个开源的 JavaScript 代码检查工具,由 Nicholas C. Zakas 于 2013 年创建。它能够帮助开发者找出代码中可能存在的问题,并且可以根据团队或项目的代码规范来自定义检查规则。下面是对 ESLint 的一些基本介绍和功能点的说明:

  1. 代码错误检查

    • ESLint 能够帮助开发者在代码编写阶段就发现语法错误、拼写错误或其他常见的编程错误,从而避免在运行时出现问题。
  2. 代码风格检查

    • ESLint 可以根据自定义的编码规范来检查代码的格式和风格,包括缩进、空格、换行等,帮助保持代码的一致性和可读性。
  3. 可自定义规则

    • ESLint 允许开发者基于自己的需求来定制检查规则,非常灵活。这对于团队协作和维护大型项目来说非常有用。
  4. 自动修复

    • ESLint 提供了一些可以自动修复问题的规则,这可以大大节省开发者的时间,特别是在修复一些简单常见的问题时。
  5. 插件和集成

    • ESLint 提供了丰富的插件系统,可以通过插件来扩展 ESLint 的功能。同时,它也可以很容易地集成到多种文本编辑器和构建系统中。
  6. 可配置性

    • ESLint 提供了丰富的配置选项,开发者可以通过配置文件来控制 ESLint 的行为,使其符合项目的需求。
  7. 支持最新的 ECMAScript 版本

    • ESLint 支持最新的 ECMAScript 语法,包括 ES6, ES7 等,这对于开发现代 JavaScript 应用来说非常重要。
  8. 与其他工具的集成

    • ESLint 可以轻松与其他工具如编辑器、构建系统和持续集成/持续部署(CI/CD) 系统集成,使得代码检查和修复更为自动化。
  9. 社区支持

    • ESLint 有一个活跃的社区,提供了大量的文档、教程和第三方插件,可以帮助开发者更好地使用和定制 ESLint。

通过以上的功能,ESLint 能够帮助团队和项目保持代码质量,确保代码的可维护性和可读性,从而提高开发效率和减少错误。

2.Prettier #

Prettier 是一个自动格式化代码的工具,能帮助开发者保持代码的一致性和可读性。以下是 Prettier 的一些核心特点和使用方法:

核心特点:

  1. 自动格式化
    • Prettier 可以自动地调整代码的格式,使其符合一定的编码规范,比如调整缩进、空格、换行等。
  2. 支持多种语言

    • Prettier 支持多种编程语言,包括 JavaScript、TypeScript、CSS、SCSS、HTML、Markdown、GraphQL 等。
  3. 与编辑器集成

    • Prettier 可以很容易地与多种编辑器集成,比如 VS Code、Atom、Sublime Text 等,这样开发者可以在保存文件时自动格式化代码,或者通过快捷键触发代码格式化。
  4. 配置灵活

    • 开发者可以通过配置文件自定义 Prettier 的格式化规则,以符合团队的编码规范。
  5. 与 ESLint 整合

    • Prettier 可以与 ESLint 整合使用,以实现代码格式化和代码质量检查的统一。

使用方法:

  1. 安装

    • 通过 npm 或 yarn 安装 Prettier:
      npm install --save-dev --save-exact prettier
      # 或者
      yarn add --dev --exact prettier
      
  2. 配置

    • 创建一个 .prettierrc 文件在项目根目录,然后在其中添加你的配置规则。例如:
      {
        "singleQuote": true,
        "trailingComma": "all"
      }
      
  3. 运行

    • 通过命令行运行 Prettier,格式化你的代码文件:
      npx prettier --write .
      # 或者
      yarn prettier --write .
      
  4. 编辑器集成

    • 在你的编辑器中安装 Prettier 插件,并配置它以在保存文件时自动格式化代码。
  5. 与 ESLint 整合

    • 如果你也使用 ESLint,可以安装 eslint-plugin-prettiereslint-config-prettier,以整合 Prettier 和 ESLint。

Prettier 不仅能使代码更整洁、统一,而且能节省开发者的时间,让他们专注于编写高质量的代码,而不是花时间在调整代码格式上。

"format": "prettier --write src/" 是一个包含在 package.json 文件中的脚本命令,通常用于 JavaScript 或 TypeScript 项目中。它通过使用 Prettier,一个流行的代码格式化工具,来格式化你的代码。下面详细解释各个部分:

  1. "format":

    • 这是你在 package.json 文件中定义的脚本名。通过这个名字,你可以在命令行中运行该脚本。
  2. prettier:

    • Prettier 是一个开源的代码格式化工具,它支持多种语言,包括 JavaScript、TypeScript、HTML、CSS、GraphQL 等。它可以帮助你保持代码的一致性和可读性。
  3. --write:

    • 这是一个 Prettier 的命令行选项。当你使用 --write 选项时,Prettier 会直接修改文件,将格式化后的代码写回文件。如果你不加 --write 选项,Prettier 将只是输出格式化后的代码,而不会修改任何文件。
  4. src/:

    • 这是你想让 Prettier 格式化的文件或目录。在这个例子中,src/ 是你项目源代码的目录。Prettier 会递归地找到 src/ 目录下的所有支持的文件,并格式化它们。

综上所述,当你在命令行中运行 npm run formatyarn format(取决于你使用的包管理器),该脚本命令会启动 Prettier,并在 src/ 目录及其子目录下的所有文件上应用 Prettier 的格式化规则。这是一个非常有用的命令,它可以帮助你保持代码的清晰和一致,特别是在多人协作的项目中。

3. lib-flexible #

lib-flexible 是一个用于移动端页面的适配解决方案,它可以帮助开发者轻松实现多屏适配。下面是关于 lib-flexible 的一些详细信息:

  1. REM 单位:

    • lib-flexible 的核心是通过动态设置 HTML 根元素 (<html>) 的 font-size 值来实现 REM 单位的适配。REM(Root EM)是一个相对单位,它是相对于根元素的 font-size 值来计算的。例如,如果根元素的 font-size 是 20px,那么 1rem 就等于 20px
  2. 动态计算:

    • lib-flexible 会自动检测设备的屏幕宽度,并据此动态计算出一个合适的 font-size 值。通常,它会将屏幕宽度分成 10 个等份,每份的宽度就是 1rem 的值。
  3. 易于使用:

    • 使用 lib-flexible 只需在项目中引入 lib-flexible 的脚本文件,并在 CSS 中用 REM 单位来编写样式。这样,你的页面就能在不同尺寸的屏幕上保持良好的布局和显示效果。
  4. 与其他工具集成:

    • 在实际项目中,开发者可能会配合使用一些 CSS 预处理器(如 Less 或 Sass)和构建工具(如 Webpack)。lib-flexible 可以与这些工具很好地集成,通过一些插件,例如 postcss-px2rem, 可以自动将 px 单位转换为 rem 单位。
  5. 兼容性:

    • lib-flexible 兼容大多数现代移动浏览器,但可能不支持一些非常旧的或非标准的浏览器。

lib-flexible 是一种比较成熟和稳定的移动端适配方案,它可以大大简化移动端开发的适配工作,让开发者能够更加专注于功能开发和界面设计。

4.postcss-pxtorem #

postcss-pxtorem 是一个用于将 CSS 中的像素(px)值转换为 REM 单位的 PostCSS 插件。它是在使用 REM 单位进行响应式设计时的一个很有用的工具。以下是 postcss-pxtorem 的一些主要特点和使用方法:

  1. 自动转换:

    • postcss-pxtorem 可以自动扫描你的 CSS 文件,找到所有的 px 单位,并将它们转换为 REM 单位。这样可以省去手动计算和转换的麻烦,使得代码更容易维护。
  2. 配置选项:

    • 你可以通过配置 postcss-pxtorem 的选项来控制转换的行为。例如,可以设置一个基准值(通常与你的 HTML 根元素的 font-size 值相匹配),以及选择是否保留原始的 px 单位。
  3. 与构建工具集成:

    • postcss-pxtorem 可以很容易地集成到常见的构建工具和 CSS 处理流程中,例如 Webpack 和 Gulp。这让你可以在保存文件或构建项目时自动运行 px 到 rem 的转换。
  4. 黑白名单:

    • 通过配置,你可以指定哪些文件、选择器或属性应该被转换,哪些应该被忽略。这为复杂的项目提供了很高的灵活性。
  5. 与其他 PostCSS 插件配合:

    • postcss-pxtorem 是 PostCSS 生态系统的一部分,可以与其他 PostCSS 插件配合使用,以实现更复杂的 CSS 处理和优化。
  6. 适用场景:

    • postcss-pxtorem 特别适用于移动端项目和响应式设计。通过使用 REM 单位,可以确保元素的大小和布局在不同大小的屏幕和设备上保持一致。

使用 postcss-pxtorem 可以大大简化使用 REM 单位的流程,提高开发效率,同时保持代码的清晰和可维护性。它是实现响应式设计和移动端适配的有效工具。

5..eslintrc.cjs #

// 指定 ESLint 运行环境为 Node.js,这样 ESLint 就能理解 Node.js 的全局变量和环境
/* eslint-env node */

// 引入 '@rushstack/eslint-patch/modern-module-resolution' 模块
// 这个模块是用来解决 ESLint 在某些情况下可能存在的模块解析问题
require("@rushstack/eslint-patch/modern-module-resolution");

// 导出一个配置对象,ESLint 会使用这个对象中的配置选项来检查代码
module.exports = {
  // 设置环境变量,告诉 ESLint 该项目的代码既可以在 Node.js 环境中运行,也可以在浏览器环境中运行
  env: {
    node: true, // 启用 Node.js 环境
    browser: true, // 启用浏览器环境
  },
  root: true, // 指定当前的配置文件为 ESLint 配置的根文件,停止在父级目录中查找更多的配置文件

  // 使用 'extends' 选项来继承一些现有的 ESLint 配置或推荐的配置
  extends: [
    "plugin:vue/vue3-essential", // 从 'plugin:vue/vue3-essential' 中继承 Vue 3 的基本规则
    "eslint:recommended", // 从 ESLint 推荐的规则中继承
    "@vue/eslint-config-prettier/skip-formatting", // 从 '@vue/eslint-config-prettier/skip-formatting' 中继承 Prettier 的规则,并跳过格式化检查
  ],

  // 使用 'parserOptions' 选项来设置解析器的选项,例如 ECMAScript 的版本
  parserOptions: {
    ecmaVersion: "latest", // 使用最新的 ECMAScript 规范版本来解析代码
  },
};

6. .prettierrc.json #

{
  "$schema": "https://json.schemastore.org/prettierrc", // 指定配置的 schema URL,用于验证和提示此配置文件的结构
  "semi": false, // 指定是否在语句末尾添加分号,false 表示不添加
  "tabWidth": 2, // 设置缩进的宽度为 2 个空格
  "singleQuote": true, // 指定使用单引号而非双引号
  "printWidth": 100, // 设置代码的最大行宽度为 100 个字符,超过这个长度的代码将会被折行
  "trailingComma": "none" // 设置在多行的对象或数组字面量中,不允许尾随逗号
}

7. postcss.config.cjs #

// 导出一个配置对象,PostCSS 会使用这个对象中的配置选项来处理 CSS
module.exports = {
  // 指定要使用的插件及其配置
  plugins: {
    // 使用 'postcss-pxtorem' 插件,这个插件可以将 px 单位自动转换为 rem 单位
    "postcss-pxtorem": {
      rootValue: 37.5, // 设置根元素的 font-size 值为 37.5,这个值是转换 px 到 rem 的基准值
      propList: ["*"], // 设置需要转换的属性列表,'*' 表示所有属性都需要转换
    },
  },
};

8.vite.config.js #

// 导入 'node:url' 模块中的 'fileURLToPath' 和 'URL' 函数
import { fileURLToPath, URL } from "node:url";

// 导入 'vite' 模块中的 'defineConfig' 函数
import { defineConfig } from "vite";

// 导入 '@vitejs/plugin-vue' 模块,用于支持 Vue 项目
import vue from "@vitejs/plugin-vue";

// 导入 '@vitejs/plugin-vue-jsx' 模块,用于支持 Vue 的 JSX 语法
import vueJsx from "@vitejs/plugin-vue-jsx";

// 导出一个 Vite 配置对象,Vite 会使用这个对象中的配置选项来构建和运行项目
// 更多配置选项可以在 https://vitejs.dev/config/ 查找
export default defineConfig({
  plugins: [
    vue(), // 使用 vue 插件
    vueJsx(), // 使用 vueJsx 插件
  ],
  resolve: {
    alias: {
      // 设置路径别名 '@',指向项目的 src 目录
      // 'fileURLToPath' 和 'URL' 是用来将 URL 转换为文件路径
      // 'import.meta.url' 是当前模块的 URL
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
});

9.unplugin-vue-components #

unplugin-vue-components 是一个为 Vue.js 项目设计的插件,它可以自动导入和注册 Vue 组件,从而使开发者无需手动导入和注册每个组件。以下是关于 unplugin-vue-components 的一些重要信息:

  1. 自动导入和注册:

    • unplugin-vue-components 通过静态分析技术,自动检测你的代码中使用到的组件,并在编译时自动导入和注册它们。这可以简化代码,并减少手动错误。
  2. 目录扫描:

    • 你可以配置插件扫描特定的目录,自动导入该目录下的所有 Vue 组件。
  3. 库组件的按需导入:

    • 对于一些 UI 组件库(如 Vuetify 或 Element Plus),unplugin-vue-components 可以实现按需导入,即只导入你实际使用到的组件,以减少最终构建的大小。
  4. 与 Vite 和 Webpack 集成:

    • unplugin-vue-components 可以与 Vite 和 Webpack 构建工具集成,使你能够在这些流行的构建系统中自动管理 Vue 组件。
  5. 自定义解析规则:

    • 插件提供了自定义解析规则的能力,使你能够根据项目的需求,定制组件的导入和注册逻辑。
  6. 前缀匹配:

    • 你可以为组件设置前缀,插件会自动识别并导入匹配该前缀的组件。
  7. 支持 Vue 2 和 Vue 3:

    • unplugin-vue-components 支持 Vue 2 和 Vue 3,使得它可以在不同版本的 Vue 项目中使用。

通过使用 unplugin-vue-components 插件,开发者可以更加高效地管理和使用 Vue 组件,同时保持代码的清晰和整洁。它是 Vue 项目中一个非常实用的工具,特别是对于包含大量组件的大型项目。

10.unplugin-vue-components/resolvers #

unplugin-vue-components 插件中的 resolvers 选项允许开发者自定义组件解析逻辑。通过提供一个或多个解析器,你可以定制如何查找和注册 Vue 组件。以下是关于 unplugin-vue-components 插件中 resolvers 选项的一些详细信息:

  1. 自定义解析:

    • 你可以提供自定义函数来解析组件的名称和路径,使得插件能够根据你的项目结构和命名约定来自动导入组件。
  2. 库组件解析:

    • 对于某些 UI 组件库,unplugin-vue-components 提供了预定义的解析器,如 Ant Design Vue, Vant, Element Plus 等,这使得你可以轻松地实现库组件的按需导入。
  3. 解析器顺序:

    • 如果提供了多个解析器,unplugin-vue-components 将按照数组中的顺序来应用它们,直到找到匹配的组件。
  4. 异步解析:

    • 解析器函数可以是异步的,这意味着你可以在解析组件时执行异步操作,例如从远程服务器加载组件信息。
  5. 返回对象:

    • 解析器函数应返回一个对象,包含组件的名称和路径,或者在没有找到组件时返回 null
  6. 通配符和正则表达式:

    • 你可以使用通配符和正则表达式来匹配组件名称,这为复杂的命名和目录结构提供了很大的灵活性。
  7. 示例:

    • 例如,你可以创建一个解析器来查找特定前缀的组件,并从一个自定义目录中导入它们。
// unplugin-vue-components 配置
{
  resolvers: [
    (name) => {
      if (name.startsWith("MyComponent")) {
        return {
          importName: name,
          path: `@/custom-components/${name}`,
        };
      }
    },
  ];
}

在上述示例中,解析器函数检查组件名称是否以 "MyComponent" 开头,如果是,则从 "@/custom-components/" 目录中导入它。

通过 resolvers 选项,你可以实现高度定制化的组件解析逻辑,以适应不同的项目结构和需求。

11.globalProperties #

app.config.globalProperties 是 Vue 3 中的一个新特性,它允许你向所有 Vue 组件实例添加自定义属性和方法。这个特性是 Vue 2 中 Vue.prototype 的替代方案。下面是一些关于 app.config.globalProperties 的详细信息:

  1. 添加全局属性和方法:

    • 通过 app.config.globalProperties,你可以为所有的 Vue 组件实例添加自定义的属性和方法,无需在每个组件中单独定义它们。
  2. 应用实例:

    • 在 Vue 3 中,你首先需要创建一个应用实例(通常命名为 app),然后可以通过 app.config.globalProperties 来设置全局属性。
  3. 简化代码:

    • 通过全局属性,你可以避免重复的代码,并在整个应用中共享一些通用的功能和数据。
  4. 示例:

    • 以下是如何使用 app.config.globalProperties 来添加一个全局属性的示例:
    import { createApp } from "vue";
    const app = createApp(YourRootComponent);
    
    // 添加一个全局属性
    app.config.globalProperties.$myProperty = "some value";
    
    // 现在你可以在任何组件中访问 this.$myProperty
    app.mount("#app");
    
  5. 与插件和混入配合使用:

    • app.config.globalProperties 通常与 Vue 插件和混入配合使用,以提供跨组件的共享功能。
  6. 替代旧有的 Vue.prototype:

    • 在 Vue 2 中,你可以通过 Vue.prototype 来添加全局属性。但在 Vue 3 中,这种方法已被 app.config.globalProperties 替代。

通过使用 app.config.globalProperties,你可以更容易地在 Vue 应用中共享通用的属性和方法,同时保持代码的清晰和组织化。

12.show #

在给定的代码片段中,你正在从 vant UI 库导入多个具体的模块或功能。以下是对每个导入模块的简要说明:

  1. showToast:

    • showToast 函数用于在页面上显示一个提示消息。它通常用于提供简短的反馈或信息,例如操作成功或失败的通知。
  2. showDialog:

    • showDialog 函数用于显示一个对话框。它可以包含标题、文本、按钮等,并且通常用于获取用户的确认或输入。
  3. showNotify:

    • showNotify 函数用于显示一个通知。它类似于 showToast,但通常用于显示更为重要或持久的信息。
  4. showImagePreview:

    • showImagePreview 函数用于显示一个图片预览。它允许用户查看一个或多个图片的全屏预览。
  5. showConfirmDialog:

    • showConfirmDialog 函数用于显示一个确认对话框。它通常用于获取用户的确认,例如删除操作的确认。
  6. Lazyload:

    • Lazyload 是一个用于实现图片和其他元素的懒加载的插件。通过懒加载,你可以延迟加载非视口内的图片或元素,从而提高页面的加载速度。

通过这段代码,你可以在你的应用中方便地使用 Vant 提供的这些 UI 功能和懒加载功能。每个函数或插件都是独立导入的,这样你可以只导入你实际需要的功能,从而减少最终构建的大小。

13.Lazyload #

Lazyload 是 Vant UI 库中提供的一个懒加载插件,它可以帮助开发者实现图片的懒加载,即只有当图片进入视口时,才会加载图片。这可以有效地提高页面的加载速度和性能。以下是关于 Vant 中 Lazyload 插件的一些详细信息:

  1. 基本用法:
    • 你可以很容易地在项目中引入并使用 Lazyload 插件。只需安装 vant,然后在你的应用中导入并使用 Lazyload 插件。
import { Lazyload } from "vant";

// 注册懒加载插件
Vue.use(Lazyload);
  1. 懒加载图片:
    • 一旦注册了 Lazyload 插件,你就可以在你的 HTML 中使用 v-lazy 指令来指定要懒加载的图片。
<img v-lazy="imageSrc" />
  1. 配置选项:
    • Lazyload 插件提供了一些配置选项,例如占位符图片、加载图标、错误图标等,允许你定制懒加载的行为和显示。
Vue.use(Lazyload, {
  loading: "https://example.com/loading-spin.svg",
  error: "https://example.com/error.png",
});
  1. 监听事件:

    • Lazyload 插件提供了一些事件,如 lazyloaderror,允许你在图片加载或出错时执行特定的操作。
  2. 适用于任何元素:

    • 虽然通常用于图片,但 Lazyload 插件也可以用于任何可以通过 src 属性加载内容的元素,例如 iframe
  3. 与 Vant 其他组件集成:

    • Lazyload 插件可以与 Vant 的其他组件很好地集成,例如 List 组件,以实现更复杂的懒加载场景。

通过使用 Vant 的 Lazyload 插件,你可以轻松实现图片和其他元素的懒加载,提高页面的加载速度和用户体验。

14.unplugin-auto-import/vite #

unplugin-auto-import 是一个插件,它可以自动导入在代码中使用的库,无需手动导入。它有一个专为 Vite 设计的版本,即 unplugin-auto-import/vite。以下是 unplugin-auto-import/vite 的一些重要信息和功能:

  1. 自动导入:

    • 该插件可以自动识别你在代码中使用的库和变量,并在编译时自动导入它们。这样,你不需要在每个文件顶部手动写导入语句,使得代码更简洁。
  2. 支持多种框架:

    • unplugin-auto-import 支持多种前端框架,包括 Vue、React 和 Svelte。它能够根据不同的框架和编程环境生成正确的导入语句。
  3. 配置简单:

    • 你只需要在 Vite 的配置文件中安装并配置 unplugin-auto-import/vite 插件,就可以开始使用它。
// vite.config.js
import AutoImport from "unplugin-auto-import/vite";

export default {
  plugins: [
    AutoImport({
      imports: ["vue", "vue-router"],
      dts: true,
    }),
  ],
};
  1. 类型定义支持:

    • 通过设置 dts 选项为 true,该插件可以生成 TypeScript 的类型定义文件,这对于 TypeScript 项目非常有用。
  2. 自定义导入:

    • 你可以通过 imports 选项来指定要自动导入的库。这样,插件只会处理你指定的库,避免不必要的导入。
  3. 与 Vite 集成:

    • unplugin-auto-import/vite 插件是专为 Vite 设计的,它可以无缝集成到 Vite 的构建和开发流程中。

通过使用 unplugin-auto-import/vite 插件,你可以简化代码的导入过程,提高开发效率,同时保持代码的清晰和整洁。

15..eslintrc-auto-import.json #

.eslintrc-auto-import.json 文件配置了一些全局变量,主要是 Vue 3 和 Pinia 相关的 API。通过在 "globals" 字段中设置为 true,你告诉 ESLint 这些变量是全局可用的,不应标记为未定义。例如:

{
  "globals": {
    "Component": true,  // 表明 Component 是一个全局可用的变量
    "ComputedRef": true,  // 表明 ComputedRef 是一个全局可用的变量
    // ... 其他全局变量
    "watchEffect": true  // 表明 watchEffect 是一个全局可用的变量
  }
}

这样配置后,当你在代码中使用这些全局变量时,ESLint 不会抛出 "未定义" 的错误。

16.husky #

Husky 是一个可以防止不良 git commitgit push 和其他 git 钩子的工具。它允许你在这些 git 操作之前运行自定义脚本,以确保代码符合你的项目标准。例如,你可以配置 Husky 在每个 git commit 之前运行 ESLint 来检查代码错误。如果 ESLint 报告了任何错误,Husky 将阻止提交并显示错误消息,从而帮助保持代码库的清洁和一致性。

17.commitlint #

17.1 commitlint #

commitlint 是一个工具,它帮助你维护一个清晰、一致的 git commit 消息格式。它按照预定义或自定义的规则检查提交消息是否符合指定的格式。通常与 husky 钩子一起使用,以确保在提交到仓库之前验证提交消息。通过保持清晰、一致的提交消息,commitlint 帮助你和你的团队更好地管理和理解代码历史。

17.2 @commitlint/cli #

@commitlint/clicommitlint 的命令行接口(CLI),它允许你通过命令行运行 commitlint。你可以在本地或 CI 环境中使用它来检查项目的提交消息是否符合指定的规则。通过简单地运行 commitlint 命令并提供一个提交消息,@commitlint/cli 将验证该消息是否符合你的配置规则,并提供有关任何不符合规则的反馈。它通常与 husky 钩子或其他工具一起使用,以自动在每次提交时检查提交消息。

17.3 @commitlint/config-conventional #

@commitlint/config-conventional 是一个 commitlint 的配置预设,它遵循 Conventional Commits 规范。Conventional Commits 是一个帮助使用明确的消息格式的提交规范,使得你的代码提交更加清晰且容易跟踪。这个预设包含了一组规则,检查提交消息是否符合这种常规提交格式。例如,它要求提交消息必须有一个类型(如 fix、feat、chore 等),可选的范围和描述。通过使用这个预设,你可以确保你和你的团队遵循一致的提交消息格式,从而使得代码历史更容易理解和管理。

git commit -m"fix(): 修复了响应式布局"

类型 描述
build 主要⽬的是修改项⽬构建系统(例如 glup,webpack,rollup 的配置等)的提交
chore 不属于以上类型的其他类型 ci 主要⽬的是修改项⽬继续集成流程(例如 Travis,Jenkins,GitLab CI,Circle 等)的提交
docs ⽂档更新
feat 新功能、新特性
fix 修改 bug
perf 更改代码,以提⾼性能
refactor 代码重构(重构,在不影响代码内部⾏为、功能下的代码修改)
revert 恢复上⼀次提交
style 不影响程序逻辑的代码修改(修改空⽩字符,格式 缩进,补全缺失的分号等,没有改变代码逻辑)
test 测试⽤例新增、修改

18. CSS 变量 #

CSS 变量,也称为 CSS 自定义属性,允许你存储特定的值以便在你的样式表中重复使用。这使得管理和更新样式更为简单和高效。你可以通过 -- 前缀来定义一个 CSS 变量,并通过 var() 函数来使用它。

定义 CSS 变量:

:root {
  --main-color: #ff6347;
}

使用 CSS 变量:

body {
  background-color: var(--main-color);
}

在这个例子中,--main-color 是一个 CSS 变量,其值为 #ff6347var(--main-color) 是如何在你的 CSS 中引用该变量。

19.preprocessorOptions #

// 导出一个配置对象,通过 defineConfig 函数进行定义
export default defineConfig({
  css: {
    // 配置 CSS 相关选项
    preprocessorOptions: {
      // 配置预处理器选项
      less: {
        // 针对 Less 预处理器的配置
        // 在所有 Less 文件之前自动导入指定的 Less 文件,用于共享变量或混合等
        additionalData: '@import "/src/assets/var.less";',
      },
    },
  },
});

20. nth-child #

在 CSS 中,:nth-child() 选择器用于匹配特定顺序的子元素。表达式 2n + 1 是一个用于选择奇数序号子元素的算术表达式。以下是对给定代码段的解释:

&:nth-child(2n + 1) {
  border-right: 1px solid #e9e9e9;
}
  1. 选择器解释:

    • &: 这是一个父选择器符号,它引用了当前 CSS 规则的父元素。
    • :nth-child(2n + 1): 这个选择器匹配的是奇数顺序的子元素。表达式 2n + 1 是一个算术表达式,其中 n 是一个从 0 开始的计数器。所以,当 n 的值为 0, 1, 2, 3, ... 时,2n + 1 的结果分别是 1, 3, 5, 7, ... ,即奇数序号。
  2. 样式应用:

    • border-right: 1px solid #e9e9e9;: 此样式规则应用了一个右边框,宽度为 1px,样式为 solid,颜色为 #e9e9e9,到所有奇数顺序的子元素。
  3. 样式效果:

    • 结果是,所有奇数序号的子元素(即第 1 个,第 3 个,第 5 个,等等)将具有一个灰色的右边框。

这种方式可以用于为元素列表中的奇数或偶数项应用不同的样式,例如,可以用于创建斑马线效果的表格行。

21. CanvasRenderingContext2D #

方法/属性 描述
ctx.font 用于设置文本的字体样式。
ctx.textBaseline 用于设置文本的基线对齐方式。
ctx.fillStyle 用于设置图形填充颜色或样式。
ctx.save() 保存当前的图形状态到状态栈中。
ctx.translate(x, y) 将绘图原点 (0, 0) 平移到指定的 (x, y) 位置。
ctx.rotate(angle) 旋转绘图环境的坐标系统。angle 参数是以弧度为单位的旋转角度。
ctx.fillText(text, x, y) 在指定的 (x, y) 位置绘制填充文本。
ctx.restore() 从状态栈中恢复先前保存的图形状态。
ctx.fillRect(x, y, width, height) 在指定的位置绘制一个填充的矩形。
ctx.beginPath() 开始创建新的路径。在创建完路径并绘制图形后,可以使用 closePath 方法来关闭路径。
ctx.moveTo(x, y) 将路径的起点移动到指定的位置。
ctx.lineTo(x, y) 添加一个新点,并创建从该点到画布中最后指定点的线条。
ctx.strokeStyle 设置用于笔触的颜色、渐变或模式。
ctx.closePath() 关闭当前的子路径。
ctx.stroke() 绘制已定义的路径。
ctx.arc(x, y, radius, startAngle, endAngle) 创建弧/曲线(用于创建圆形或部分圆)。
ctx.fill() 填充当前的图形路径。
canvasRef.value.getContext('2d') 获取 canvas 元素的 2D 绘图上下文。
  1. ctx.font:

    • 用于设置文本的字体样式,如大小、字体类型等。例如,ctx.font = "20px Arial" 会设置字体大小为 20 像素,字体类型为 Arial。
  2. ctx.textBaseline:

    • 设置文本基线的对齐方式。例如,ctx.textBaseline = "middle" 会使文本垂直居中对齐。
  3. ctx.fillStyle:

    • 用于设置图形填充的颜色或样式。例如,ctx.fillStyle = "red" 会将填充颜色设置为红色。
  4. ctx.save():

    • 保存当前的图形状态到一个状态栈中,这包括当前的变换、剪裁区域、当前的路径和当前的样式设置等。
  5. ctx.translate(x, y):

    • 将绘图原点 (0, 0) 平移到指定的 (x, y) 位置,之后的绘图操作都会基于新的坐标原点进行。
  6. ctx.rotate(angle):

    • 旋转绘图环境的坐标系统。angle 参数是以弧度为单位的旋转角度。
  7. ctx.fillText(text, x, y):

    • 在指定的 (x, y) 位置绘制填充文本。
  8. ctx.restore():

    • 从状态栈中恢复先前保存的图形状态,恢复到最后一次调用 ctx.save() 时的状态。
  9. ctx.fillRect(x, y, width, height):

    • 在指定的位置绘制一个填充的矩形。
  10. ctx.beginPath():

    • 开始创建新的路径,它会清除任何之前创建的路径。
  11. ctx.moveTo(x, y):

    • 将路径的起点移动到指定的位置,不创建实际的线条。
  12. ctx.lineTo(x, y):

    • 添加一个新点,并创建从该点到画布中最后指定点的线条。
  13. ctx.strokeStyle:

    • 设置用于笔触的颜色、渐变或模式。
  14. ctx.closePath():

    • 关闭当前的子路径,即将当前点连接到路径起始点。
  15. ctx.stroke():

    • 绘制已定义的路径的轮廓。
  16. ctx.arc(x, y, radius, startAngle, endAngle):

    • 创建弧/曲线(用于创建圆形或部分圆)。
  17. ctx.fill():

    • 填充当前的图形路径,使用 ctx.fillStyle 所指定的颜色。
  18. canvasRef.value.getContext('2d'):

    • 获取 canvas 元素的 2D 绘图上下文,它是 CanvasRenderingContext2D 对象的实例,提供了多种绘制 2D 图形的方法和属性。

22.linear-gradient #

linear-gradient 是 CSS 中用于创建线性渐变效果的函数。它允许你在两个或多个指定的颜色之间创建平滑的过渡。这个函数通常用于设置元素的背景图片。

linear-gradient(90deg, #6bd8d8, #1baeae) 的详细解释如下:

所以,linear-gradient(90deg, #6bd8d8, #1baeae) 会创建一个从左到右的线性渐变,从较淡的青色渐变到较深的青色。

你可以将这个函数用于 CSS 的 background-image 属性,例如:

.element {
  background-image: linear-gradient(90deg, #6bd8d8, #1baeae);
}

这样,.element 类下的元素将会有一个从左到右的,从 #6bd8d8#1baeae 的线性渐变背景。