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
pnpm i lib-flexible less
pnpm i postcss-pxtorem -D
module.exports = {
plugins: {
"postcss-pxtorem": {
rootValue: 37.5,
propList: ["*"],
},
},
};
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");
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>
src\router\index.js
import { createRouter, createWebHistory } from "vue-router";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [],
});
export default router;
pnpm i unplugin-vue-components vant
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))
}
}
})
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')
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);
}
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>
pnpm i unplugin-auto-import
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))
}
}
})
.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'
}
}
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>
git init
pnpm install husky -D
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"
pnpm install @commitlint/cli @commitlint/config-conventional -D
npx husky add .husky/commit-msg "npx --no-install commitlint --edit `echo "\$1"`"
commitlint.config.cjs
module.exports = {
extends: ["@commitlint/config-conventional"],
};
.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'
}
}
src\App.vue
<script setup>
</script>
<template>
<RouterView></RouterView>
</template>
<style scoped lang="less">
</style>
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;
src\views\Home.vue
<template>Home</template>
src\views\Cart.vue
<template>Cart</template>
src\views\User.vue
<template>User</template>
src\views\Category.vue
<template>Category</template>
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>
src\App.vue
<script setup></script>
const isVisible = ref(false)
<template>
<RouterView></RouterView>
<NavBar></NavBar>
</template>
<style scoped lang="less"></style>
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))
}
}
})
src\assets\theme.css
:root:root {
--van-primary-color: #1baeae;
}
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')
src\assets\var.less
@primary: #1baeae;
src\views\Home.vue
<template>
+ <h1>Home</h1>
</template>
+<style scoped lang="less">
+h1 {
+ color: @primary;
+}
+</style>
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";'
+ }
+ }
+ }
})
src\views\Home.vue
<script setup></script>
<template>
<home-header></home-header>
</template>
<style scoped lang="less"></style>
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>
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>
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>
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();
src\api\home.js
import apiClient from "./";
export const endpoints = {
INDEX_INFOS: "/index-infos",
};
export function fetchIndexInfo() {
return apiClient.get(endpoints.INDEX_INFOS);
}
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>
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>
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,
},
];
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')
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;
}
};
}
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>
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>
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
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>
pnpm i blueimp-md5
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>
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),
});
};
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>
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;
}
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
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()
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>
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>
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>
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>
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>
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
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}`);
};
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,
};
});
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,
});
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>
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>
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>
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>
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
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>
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,
};
});
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);
+}
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>
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
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>
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>
ESLint 是一个开源的 JavaScript 代码检查工具,由 Nicholas C. Zakas 于 2013 年创建。它能够帮助开发者找出代码中可能存在的问题,并且可以根据团队或项目的代码规范来自定义检查规则。下面是对 ESLint 的一些基本介绍和功能点的说明:
代码错误检查:
代码风格检查:
可自定义规则:
自动修复:
插件和集成:
可配置性:
支持最新的 ECMAScript 版本:
与其他工具的集成:
社区支持:
通过以上的功能,ESLint 能够帮助团队和项目保持代码质量,确保代码的可维护性和可读性,从而提高开发效率和减少错误。
Prettier 是一个自动格式化代码的工具,能帮助开发者保持代码的一致性和可读性。以下是 Prettier 的一些核心特点和使用方法:
核心特点:
支持多种语言:
与编辑器集成:
配置灵活:
与 ESLint 整合:
使用方法:
安装:
npm install --save-dev --save-exact prettier
# 或者
yarn add --dev --exact prettier
配置:
.prettierrc
文件在项目根目录,然后在其中添加你的配置规则。例如:{
"singleQuote": true,
"trailingComma": "all"
}
运行:
npx prettier --write .
# 或者
yarn prettier --write .
编辑器集成:
与 ESLint 整合:
eslint-plugin-prettier
和 eslint-config-prettier
,以整合 Prettier 和 ESLint。Prettier 不仅能使代码更整洁、统一,而且能节省开发者的时间,让他们专注于编写高质量的代码,而不是花时间在调整代码格式上。
"format": "prettier --write src/"
是一个包含在 package.json
文件中的脚本命令,通常用于 JavaScript 或 TypeScript 项目中。它通过使用 Prettier,一个流行的代码格式化工具,来格式化你的代码。下面详细解释各个部分:
"format":
package.json
文件中定义的脚本名。通过这个名字,你可以在命令行中运行该脚本。prettier:
--write:
--write
选项时,Prettier 会直接修改文件,将格式化后的代码写回文件。如果你不加 --write
选项,Prettier 将只是输出格式化后的代码,而不会修改任何文件。src/:
src/
是你项目源代码的目录。Prettier 会递归地找到 src/
目录下的所有支持的文件,并格式化它们。综上所述,当你在命令行中运行 npm run format
或 yarn format
(取决于你使用的包管理器),该脚本命令会启动 Prettier,并在 src/
目录及其子目录下的所有文件上应用 Prettier 的格式化规则。这是一个非常有用的命令,它可以帮助你保持代码的清晰和一致,特别是在多人协作的项目中。
lib-flexible
是一个用于移动端页面的适配解决方案,它可以帮助开发者轻松实现多屏适配。下面是关于 lib-flexible
的一些详细信息:
REM 单位:
lib-flexible
的核心是通过动态设置 HTML 根元素 (<html>
) 的 font-size
值来实现 REM 单位的适配。REM(Root EM)是一个相对单位,它是相对于根元素的 font-size
值来计算的。例如,如果根元素的 font-size
是 20px,那么 1rem
就等于 20px
。动态计算:
lib-flexible
会自动检测设备的屏幕宽度,并据此动态计算出一个合适的 font-size
值。通常,它会将屏幕宽度分成 10 个等份,每份的宽度就是 1rem
的值。易于使用:
lib-flexible
只需在项目中引入 lib-flexible
的脚本文件,并在 CSS 中用 REM 单位来编写样式。这样,你的页面就能在不同尺寸的屏幕上保持良好的布局和显示效果。与其他工具集成:
lib-flexible
可以与这些工具很好地集成,通过一些插件,例如 postcss-px2rem
, 可以自动将 px 单位转换为 rem 单位。兼容性:
lib-flexible
兼容大多数现代移动浏览器,但可能不支持一些非常旧的或非标准的浏览器。lib-flexible
是一种比较成熟和稳定的移动端适配方案,它可以大大简化移动端开发的适配工作,让开发者能够更加专注于功能开发和界面设计。
postcss-pxtorem
是一个用于将 CSS 中的像素(px)值转换为 REM 单位的 PostCSS 插件。它是在使用 REM 单位进行响应式设计时的一个很有用的工具。以下是 postcss-pxtorem
的一些主要特点和使用方法:
自动转换:
postcss-pxtorem
可以自动扫描你的 CSS 文件,找到所有的 px 单位,并将它们转换为 REM 单位。这样可以省去手动计算和转换的麻烦,使得代码更容易维护。配置选项:
postcss-pxtorem
的选项来控制转换的行为。例如,可以设置一个基准值(通常与你的 HTML 根元素的 font-size
值相匹配),以及选择是否保留原始的 px 单位。与构建工具集成:
postcss-pxtorem
可以很容易地集成到常见的构建工具和 CSS 处理流程中,例如 Webpack 和 Gulp。这让你可以在保存文件或构建项目时自动运行 px 到 rem 的转换。黑白名单:
与其他 PostCSS 插件配合:
postcss-pxtorem
是 PostCSS 生态系统的一部分,可以与其他 PostCSS 插件配合使用,以实现更复杂的 CSS 处理和优化。适用场景:
postcss-pxtorem
特别适用于移动端项目和响应式设计。通过使用 REM 单位,可以确保元素的大小和布局在不同大小的屏幕和设备上保持一致。使用 postcss-pxtorem
可以大大简化使用 REM 单位的流程,提高开发效率,同时保持代码的清晰和可维护性。它是实现响应式设计和移动端适配的有效工具。
// 指定 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 规范版本来解析代码
},
};
{
"$schema": "https://json.schemastore.org/prettierrc", // 指定配置的 schema URL,用于验证和提示此配置文件的结构
"semi": false, // 指定是否在语句末尾添加分号,false 表示不添加
"tabWidth": 2, // 设置缩进的宽度为 2 个空格
"singleQuote": true, // 指定使用单引号而非双引号
"printWidth": 100, // 设置代码的最大行宽度为 100 个字符,超过这个长度的代码将会被折行
"trailingComma": "none" // 设置在多行的对象或数组字面量中,不允许尾随逗号
}
// 导出一个配置对象,PostCSS 会使用这个对象中的配置选项来处理 CSS
module.exports = {
// 指定要使用的插件及其配置
plugins: {
// 使用 'postcss-pxtorem' 插件,这个插件可以将 px 单位自动转换为 rem 单位
"postcss-pxtorem": {
rootValue: 37.5, // 设置根元素的 font-size 值为 37.5,这个值是转换 px 到 rem 的基准值
propList: ["*"], // 设置需要转换的属性列表,'*' 表示所有属性都需要转换
},
},
};
// 导入 '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)),
},
},
});
unplugin-vue-components
是一个为 Vue.js 项目设计的插件,它可以自动导入和注册 Vue 组件,从而使开发者无需手动导入和注册每个组件。以下是关于 unplugin-vue-components
的一些重要信息:
自动导入和注册:
unplugin-vue-components
通过静态分析技术,自动检测你的代码中使用到的组件,并在编译时自动导入和注册它们。这可以简化代码,并减少手动错误。目录扫描:
库组件的按需导入:
unplugin-vue-components
可以实现按需导入,即只导入你实际使用到的组件,以减少最终构建的大小。与 Vite 和 Webpack 集成:
unplugin-vue-components
可以与 Vite 和 Webpack 构建工具集成,使你能够在这些流行的构建系统中自动管理 Vue 组件。自定义解析规则:
前缀匹配:
支持 Vue 2 和 Vue 3:
unplugin-vue-components
支持 Vue 2 和 Vue 3,使得它可以在不同版本的 Vue 项目中使用。通过使用 unplugin-vue-components
插件,开发者可以更加高效地管理和使用 Vue 组件,同时保持代码的清晰和整洁。它是 Vue 项目中一个非常实用的工具,特别是对于包含大量组件的大型项目。
unplugin-vue-components
插件中的 resolvers
选项允许开发者自定义组件解析逻辑。通过提供一个或多个解析器,你可以定制如何查找和注册 Vue 组件。以下是关于 unplugin-vue-components
插件中 resolvers
选项的一些详细信息:
自定义解析:
库组件解析:
unplugin-vue-components
提供了预定义的解析器,如 Ant Design Vue, Vant, Element Plus 等,这使得你可以轻松地实现库组件的按需导入。解析器顺序:
unplugin-vue-components
将按照数组中的顺序来应用它们,直到找到匹配的组件。异步解析:
返回对象:
null
。通配符和正则表达式:
示例:
// unplugin-vue-components 配置
{
resolvers: [
(name) => {
if (name.startsWith("MyComponent")) {
return {
importName: name,
path: `@/custom-components/${name}`,
};
}
},
];
}
在上述示例中,解析器函数检查组件名称是否以 "MyComponent" 开头,如果是,则从 "@/custom-components/" 目录中导入它。
通过 resolvers
选项,你可以实现高度定制化的组件解析逻辑,以适应不同的项目结构和需求。
app.config.globalProperties
是 Vue 3 中的一个新特性,它允许你向所有 Vue 组件实例添加自定义属性和方法。这个特性是 Vue 2 中 Vue.prototype
的替代方案。下面是一些关于 app.config.globalProperties
的详细信息:
添加全局属性和方法:
app.config.globalProperties
,你可以为所有的 Vue 组件实例添加自定义的属性和方法,无需在每个组件中单独定义它们。应用实例:
app
),然后可以通过 app.config.globalProperties
来设置全局属性。简化代码:
示例:
app.config.globalProperties
来添加一个全局属性的示例:import { createApp } from "vue";
const app = createApp(YourRootComponent);
// 添加一个全局属性
app.config.globalProperties.$myProperty = "some value";
// 现在你可以在任何组件中访问 this.$myProperty
app.mount("#app");
与插件和混入配合使用:
app.config.globalProperties
通常与 Vue 插件和混入配合使用,以提供跨组件的共享功能。替代旧有的 Vue.prototype
:
Vue.prototype
来添加全局属性。但在 Vue 3 中,这种方法已被 app.config.globalProperties
替代。通过使用 app.config.globalProperties
,你可以更容易地在 Vue 应用中共享通用的属性和方法,同时保持代码的清晰和组织化。
在给定的代码片段中,你正在从 vant
UI 库导入多个具体的模块或功能。以下是对每个导入模块的简要说明:
showToast
:
showToast
函数用于在页面上显示一个提示消息。它通常用于提供简短的反馈或信息,例如操作成功或失败的通知。showDialog
:
showDialog
函数用于显示一个对话框。它可以包含标题、文本、按钮等,并且通常用于获取用户的确认或输入。showNotify
:
showNotify
函数用于显示一个通知。它类似于 showToast
,但通常用于显示更为重要或持久的信息。showImagePreview
:
showImagePreview
函数用于显示一个图片预览。它允许用户查看一个或多个图片的全屏预览。showConfirmDialog
:
showConfirmDialog
函数用于显示一个确认对话框。它通常用于获取用户的确认,例如删除操作的确认。Lazyload
:
Lazyload
是一个用于实现图片和其他元素的懒加载的插件。通过懒加载,你可以延迟加载非视口内的图片或元素,从而提高页面的加载速度。通过这段代码,你可以在你的应用中方便地使用 Vant 提供的这些 UI 功能和懒加载功能。每个函数或插件都是独立导入的,这样你可以只导入你实际需要的功能,从而减少最终构建的大小。
Lazyload
是 Vant UI 库中提供的一个懒加载插件,它可以帮助开发者实现图片的懒加载,即只有当图片进入视口时,才会加载图片。这可以有效地提高页面的加载速度和性能。以下是关于 Vant 中 Lazyload
插件的一些详细信息:
Lazyload
插件。只需安装 vant
,然后在你的应用中导入并使用 Lazyload
插件。import { Lazyload } from "vant";
// 注册懒加载插件
Vue.use(Lazyload);
Lazyload
插件,你就可以在你的 HTML 中使用 v-lazy
指令来指定要懒加载的图片。<img v-lazy="imageSrc" />
Lazyload
插件提供了一些配置选项,例如占位符图片、加载图标、错误图标等,允许你定制懒加载的行为和显示。Vue.use(Lazyload, {
loading: "https://example.com/loading-spin.svg",
error: "https://example.com/error.png",
});
监听事件:
Lazyload
插件提供了一些事件,如 lazyload
和 error
,允许你在图片加载或出错时执行特定的操作。适用于任何元素:
Lazyload
插件也可以用于任何可以通过 src
属性加载内容的元素,例如 iframe
。与 Vant 其他组件集成:
Lazyload
插件可以与 Vant 的其他组件很好地集成,例如 List
组件,以实现更复杂的懒加载场景。通过使用 Vant 的 Lazyload
插件,你可以轻松实现图片和其他元素的懒加载,提高页面的加载速度和用户体验。
unplugin-auto-import
是一个插件,它可以自动导入在代码中使用的库,无需手动导入。它有一个专为 Vite 设计的版本,即 unplugin-auto-import/vite
。以下是 unplugin-auto-import/vite
的一些重要信息和功能:
自动导入:
支持多种框架:
unplugin-auto-import
支持多种前端框架,包括 Vue、React 和 Svelte。它能够根据不同的框架和编程环境生成正确的导入语句。配置简单:
unplugin-auto-import/vite
插件,就可以开始使用它。// vite.config.js
import AutoImport from "unplugin-auto-import/vite";
export default {
plugins: [
AutoImport({
imports: ["vue", "vue-router"],
dts: true,
}),
],
};
类型定义支持:
dts
选项为 true
,该插件可以生成 TypeScript 的类型定义文件,这对于 TypeScript 项目非常有用。自定义导入:
imports
选项来指定要自动导入的库。这样,插件只会处理你指定的库,避免不必要的导入。与 Vite 集成:
unplugin-auto-import/vite
插件是专为 Vite 设计的,它可以无缝集成到 Vite 的构建和开发流程中。通过使用 unplugin-auto-import/vite
插件,你可以简化代码的导入过程,提高开发效率,同时保持代码的清晰和整洁。
该 .eslintrc-auto-import.json
文件配置了一些全局变量,主要是 Vue 3 和 Pinia 相关的 API。通过在 "globals" 字段中设置为 true
,你告诉 ESLint 这些变量是全局可用的,不应标记为未定义。例如:
{
"globals": {
"Component": true, // 表明 Component 是一个全局可用的变量
"ComputedRef": true, // 表明 ComputedRef 是一个全局可用的变量
// ... 其他全局变量
"watchEffect": true // 表明 watchEffect 是一个全局可用的变量
}
}
这样配置后,当你在代码中使用这些全局变量时,ESLint 不会抛出 "未定义" 的错误。
Husky 是一个可以防止不良 git commit
、git push
和其他 git
钩子的工具。它允许你在这些 git 操作之前运行自定义脚本,以确保代码符合你的项目标准。例如,你可以配置 Husky 在每个 git commit
之前运行 ESLint 来检查代码错误。如果 ESLint 报告了任何错误,Husky 将阻止提交并显示错误消息,从而帮助保持代码库的清洁和一致性。
commitlint
是一个工具,它帮助你维护一个清晰、一致的 git commit 消息格式。它按照预定义或自定义的规则检查提交消息是否符合指定的格式。通常与 husky
钩子一起使用,以确保在提交到仓库之前验证提交消息。通过保持清晰、一致的提交消息,commitlint
帮助你和你的团队更好地管理和理解代码历史。
@commitlint/cli
是 commitlint
的命令行接口(CLI),它允许你通过命令行运行 commitlint
。你可以在本地或 CI 环境中使用它来检查项目的提交消息是否符合指定的规则。通过简单地运行 commitlint
命令并提供一个提交消息,@commitlint/cli
将验证该消息是否符合你的配置规则,并提供有关任何不符合规则的反馈。它通常与 husky
钩子或其他工具一起使用,以自动在每次提交时检查提交消息。
@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 | 测试⽤例新增、修改 |
CSS 变量,也称为 CSS 自定义属性,允许你存储特定的值以便在你的样式表中重复使用。这使得管理和更新样式更为简单和高效。你可以通过 --
前缀来定义一个 CSS 变量,并通过 var()
函数来使用它。
定义 CSS 变量:
:root {
--main-color: #ff6347;
}
使用 CSS 变量:
body {
background-color: var(--main-color);
}
在这个例子中,--main-color
是一个 CSS 变量,其值为 #ff6347
,var(--main-color)
是如何在你的 CSS 中引用该变量。
// 导出一个配置对象,通过 defineConfig 函数进行定义
export default defineConfig({
css: {
// 配置 CSS 相关选项
preprocessorOptions: {
// 配置预处理器选项
less: {
// 针对 Less 预处理器的配置
// 在所有 Less 文件之前自动导入指定的 Less 文件,用于共享变量或混合等
additionalData: '@import "/src/assets/var.less";',
},
},
},
});
在 CSS 中,:nth-child()
选择器用于匹配特定顺序的子元素。表达式 2n + 1
是一个用于选择奇数序号子元素的算术表达式。以下是对给定代码段的解释:
&:nth-child(2n + 1) {
border-right: 1px solid #e9e9e9;
}
选择器解释:
&
: 这是一个父选择器符号,它引用了当前 CSS 规则的父元素。:nth-child(2n + 1)
: 这个选择器匹配的是奇数顺序的子元素。表达式 2n + 1
是一个算术表达式,其中 n
是一个从 0
开始的计数器。所以,当 n
的值为 0, 1, 2, 3, ...
时,2n + 1
的结果分别是 1, 3, 5, 7, ...
,即奇数序号。样式应用:
border-right: 1px solid #e9e9e9;
: 此样式规则应用了一个右边框,宽度为 1px
,样式为 solid
,颜色为 #e9e9e9
,到所有奇数顺序的子元素。样式效果:
这种方式可以用于为元素列表中的奇数或偶数项应用不同的样式,例如,可以用于创建斑马线效果的表格行。
方法/属性 | 描述 |
---|---|
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 绘图上下文。 |
ctx.font
:
ctx.font = "20px Arial"
会设置字体大小为 20 像素,字体类型为 Arial。ctx.textBaseline
:
ctx.textBaseline = "middle"
会使文本垂直居中对齐。ctx.fillStyle
:
ctx.fillStyle = "red"
会将填充颜色设置为红色。ctx.save()
:
ctx.translate(x, y)
:
ctx.rotate(angle)
:
angle
参数是以弧度为单位的旋转角度。ctx.fillText(text, x, y)
:
ctx.restore()
:
ctx.save()
时的状态。ctx.fillRect(x, y, width, height)
:
ctx.beginPath()
:
ctx.moveTo(x, y)
:
ctx.lineTo(x, y)
:
ctx.strokeStyle
:
ctx.closePath()
:
ctx.stroke()
:
ctx.arc(x, y, radius, startAngle, endAngle)
:
ctx.fill()
:
ctx.fillStyle
所指定的颜色。canvasRef.value.getContext('2d')
:
canvas
元素的 2D 绘图上下文,它是 CanvasRenderingContext2D 对象的实例,提供了多种绘制 2D 图形的方法和属性。linear-gradient
是 CSS 中用于创建线性渐变效果的函数。它允许你在两个或多个指定的颜色之间创建平滑的过渡。这个函数通常用于设置元素的背景图片。
linear-gradient(90deg, #6bd8d8, #1baeae)
的详细解释如下:
90deg
:
90deg
表示渐变线的方向是从左到右。在这个方向上,第一个颜色会从左边开始,并向右过渡到第二个颜色。#6bd8d8
:
#1baeae
:
所以,linear-gradient(90deg, #6bd8d8, #1baeae)
会创建一个从左到右的线性渐变,从较淡的青色渐变到较深的青色。
你可以将这个函数用于 CSS 的 background-image
属性,例如:
.element {
background-image: linear-gradient(90deg, #6bd8d8, #1baeae);
}
这样,.element
类下的元素将会有一个从左到右的,从 #6bd8d8
到 #1baeae
的线性渐变背景。