一、技术方案
- 基于 vue3+typescript 中大型项目开发解决方案
- 基于 vant 组件库快速构建H5界面解决方案
- 基于 vue-router 的前端路由解决方案
- 基于 vite 的 create-vue 构建vue3项目解决方案
- 基于 pinia 的状态管理解决方案
- 基于 pinia-plugin-persistedstate 状态持久化解决方案
- 基于 @vuecore/use 的组合式API工具库解决方案
- 身份证信息校验解决方案
- 基于 postcss-px-to-viewport 移动端适配解决方案
- 基于 vite-plugin-svg-icons 的svg图标组件解决方案
- 基于 vite-plugin-html 自定义html模板解决方案
- 基于 unplugin-vue-components 组件自动注册解决方案
- 基于 socket.io 的即时通讯问诊室解决方案
- 第三方登录解决方案
- 第三方支付解决方案
- 第三方地图解决方案
- pnpm 包管理方案
- css 变量主题定制方案
- 自定义 hook 解决方案
- axios 二次封装解决方案
- services API接口分层解决方案
- 基于 vant 的通用组件封装解决方案
- mock 本地数据模拟解决方案
- 基于 eruda 的移动端调试解决方案
- 生产环境配置方案
- CI/CD 持续集成自动部署方案
二、业务涵盖
- 医生与文章推荐业务
- 快速问诊业务
- 问诊费用支付宝支付业务
- 问诊室业务
- 药品订单支付宝支付业务
- 实时物流高德地图业务
- QQ登录业务
三、 项目搭建
1.创建项目
pnpm create vue
# or
npm init vue@latest
# or
yarn create vue
2.选择项目依赖
✔ Project name: … patients-h5-100
✔ Add TypeScript? … No / `Yes`
✔ Add JSX Support? … `No` / Yes
✔ Add Vue Router for Single Page Application development? … No / `Yes`
✔ Add Pinia for state management? … No / `Yes`
✔ Add Vitest for Unit Testing? … `No` / Yes
✔ Add Cypress for both Unit and End-to-End testing? … `No` / Yes
✔ Add ESLint for code quality? … No / `Yes`
✔ Add Prettier for code formatting? … No / `Yes`
Scaffolding project in /Users/zhousg/Desktop/patient-h5-100...
Done. Now run:
cd patient-h5-100
pnpm install
pnpm lint
pnpm dev
3.插件推荐
必装:
Vue Language Features (Volar)
vue3语法支持TypeScript Vue Plugin (Volar)
vue3中更好的ts提示Eslint
代码风格校验
注意
- vscode 安装了
Prettier
插件的可以先禁用
,或者关闭保存自动格式化功能,避免和项目的Eslint
风格冲突。
可选:
gitLens
代码git提交记录提示json2ts
json自动转ts类型Error Lens
行内错误提示
提示:
- 大中型项目建议开启 TS托管模式 , 更好更快的类型提示。
4.eslint 预制配置
rules: {
'prettier/prettier': [
'warn',
{
singleQuote: true,
semi: false,
printWidth: 80,
trailingComma: 'none',
endOfLine: 'auto'
}
],
'vue/multi-word-component-names': [
'warn',
{
ignores: ['index']
}
],
'vue/no-setup-props-destructure': ['off'],
// 💡 添加未定义变量错误提示,create-vue@3.6.3 关闭,这里加上是为了支持下一个章节演示。
'no-undef': 'error'
}
- 格式:单引号,没有分号,行宽度80字符,没有对象数组最后一个逗号,换行字符串自动(系统不一样换行符号不一样)
- vue 组件需要大驼峰命名,除去 index 之外,App 是默认支持的
- 允许对 props 进行解构,我们会开启解构保持响应式的语法糖
执行:
# 修复格式
pnpm lint
vscode 开启 eslint 自动修复
"editor.codeActionsOnSave": {
"source.fixAll": true,
},
- 如果公司中会有自己的代码风格规则,大家只需遵守即可
- Options · Prettier 常见规则
5.代码检查工作流
husky 配置
- 初始化与安装
pnpm dlx husky-init && pnpm install
- 修改 .husky/pre-commit 文件
pnpm lint
lint-staged 配置
- 安装
pnpm i lint-staged -D
- 配置
package.json
{
// ... 省略 ...
"lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix"
]
}
}
{
"scripts": {
// ... 省略 ...
"lint-staged": "lint-staged"
}
}
- 修改 .husky/pre-commit 文件
pnpm lint-staged
6.项目结构调整
./src
├── assets `静态资源,图片...`
├── components `通用组件`
├── composable `组合功能通用函数`
├── icons `svg图标`
├── router `路由`
│ └── index.ts
├── services `接口服务API`
├── stores `状态仓库`
├── styles `样式`
│ └── main.scss
├── types `TS类型`
├── utils `工具函数`
├── views `页面`
├── main.ts `入口文件`
└──App.vue `根组件`
项目使用sass预处理器,安装sass,即可支持scss语法:
pnpm add sass -D
7.路由代码解析
import { createRouter, createWebHistory } from 'vue-router'
// createRouter 创建路由实例,===> new VueRouter()
// history 是路由模式,hash模式,history模式
// createWebHistory() 是开启history模块 http://xxx/user
// createWebHashHistory() 是开启hash模式 http://xxx/#/user
// vite 的配置 import.meta.env.BASE_URL 是路由的基准地址,默认是 ’/‘
// https://vitejs.dev/guide/build.html#public-base-path
// 如果将来你部署的域名路径是:http://xxx/my-path/user
// vite.config.ts 添加配置 base: my-path,路由这就会加上 my-path 前缀了
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: []
})
export default router
-
如何创建实例的方式?
createRouter()
-
如何设置路由模式?
createWebHistory()
或者createWebHashHistory()
-
import.meta.env.BASE_URL
值来自哪里?vite.config.ts
的base
属性的值
-
base
作用是什么?- 项目的基础路径前缀,默认是
/
- 项目的基础路径前缀,默认是
8.vant组件库
安装:
# Vue 3 项目,安装最新版 Vant
npm i vant
# 通过 yarn 安装
yarn add vant
# 通过 pnpm 安装
pnpm add vant
样式:main.ts
import { createApp } from 'vue'
import App from './App.vue'
import pinia from './stores'
import router from './router'
// 样式全局使用
import 'vant/lib/index.css'
import './styles/main.scss'
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')
组件按需使用:App.vue
<script setup lang="ts">
import { Button as VanButton } from 'vant'
</script>
<template>
<van-button>按钮</van-button>
</template>
<style scoped></style>
- 注:全局使用是全量加载,是项目体积变大,加载慢
9.移动端适配
实现:使用 vw 完成移动端适配
安装:
npm install postcss-px-to-viewport -D
# or
yarn add -D postcss-px-to-viewport
# or
pnpm add -D postcss-px-to-viewport
配置: postcss.config.js
// eslint-disable-next-line no-undef
module.exports = {
plugins: {
'postcss-px-to-viewport': {
// 设备宽度375计算vw的值
viewportWidth: 375,
},
},
};
测试:
- 有一个控制台警告可忽略,或者使用
postcss-px-to-viewport-8-plugin
代替当前插件
10.css变量主题定制
实现:使用css变量定制项目主题,和修改vant主题
- 定义 css 变量使用 css 变量
:root {
--main: #999;
}
a {
color: var(--main)
}
- 定义项目的颜色风格,覆盖vant的主题色 官方文档
styles/main.scss
:root {
// 问诊患者:色板
--cp-primary: #16C2A3;
--cp-plain: #EAF8F6;
--cp-orange: #FCA21C;
--cp-text1: #121826;
--cp-text2: #3C3E42;
--cp-text3: #6F6F6F;
--cp-tag: #848484;
--cp-dark: #979797;
--cp-tip: #C3C3C5;
--cp-disable: #D9DBDE;
--cp-line: #EDEDED;
--cp-bg: #F6F7F9;
--cp-price: #EB5757;
// 覆盖vant主体色
--van-primary-color: var(--cp-primary);
}
App.vue
<script setup lang="ts"></script>
<template>
<!-- 验证vant颜色被覆盖 -->
<van-button type="primary">按钮</van-button>
<a href="#">123</a>
</template>
<style scoped lang="scss">
// 使用 css 变量
a {
color: var(--cp-primary);
}
</style>
11.用户状态仓库
完成:用户信息仓库创建,提供用户信息,修改用信息,删除用户信息的方法
- 请求工具需要携带token,访问权限控制需要token,所以用户信息仓库先完成
需求:
- 用户信息仓库创建
- 提供用户信息
- 修改用信息的方法
- 删除用信息的方法
代码:
types/user.d.ts
// 用户信息
export type User = {
/** token令牌 */
token: string
/** 用户ID */
id: string
/** 用户名称 */
account: string
/** 手机号 */
mobile: string
/** 头像 */
avatar: string
}
stores/user.ts
import type { User } from '@/types/user'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('cp-user', () => {
// 用户信息
const user = ref<User>()
// 设置用户,登录后使用
const setUser = (u: User) => {
user.value = u
}
// 清空用户,退出后使用
const delUser = () => {
user.value = undefined
}
return { user, setUser, delUser }
})
-
pinia存储这个数据的意义?
- 数据共享,提供给项目中任何位置使用
-
如果存储了数据,刷新页面后数据还在吗?
- 不在,现在仅仅是js内存中,需要进行本地存储(持久化)
12.数据持久化
使用
pinia-plugin-persistedstate
实现pinia仓库状态持久化,且完成测试
参考文档
- 安装
pnpm i pinia-plugin-persistedstate
# or
npm i pinia-plugin-persistedstate
# or
yarn add pinia-plugin-persistedstate
- 使用
main.ts
import persist from 'pinia-plugin-persistedstate'
const app = createApp(App)
app.use(createPinia().use(persist))
- 配置
stores/user.ts
import type { User } from '@/types/user'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore(
'cp-user',
() => {
// 用户信息
const user = ref<User>()
// 设置用户,登录后使用
const setUser = (u: User) => {
user.value = u
}
// 清空用户,退出后使用
const delUser = () => {
user.value = undefined
}
return { user, setUser, delUser }
},
{
persist: true
}
)
- 测试
App.vue
<script setup lang="ts">
import { useUserStore } from './stores/user'
const store = useUserStore()
</script>
<template>
<p>{{ store.user }}</p>
<button @click="store.setUser({ id: '1', mobile: '1', account: '1', avatar: '1', token: '1' })">
登录
</button>
<button @click="store.delUser()">退出</button>
</template>
13.stores统一导出
仓库的导出统一从
./stores
代码简洁,职能单一,入口唯一
- 抽取pinia实例代码,职能单一
stores/index
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'
// 创建pinia实例
const pinia = createPinia()
// 使用pinia插件
pinia.use(persist)
// 导出pinia实例,给main使用
export default pinia
main.ts
import { createApp } from 'vue'
import App from './App.vue'
import pinia from './stores'
import router from './router'
import './styles/main.scss'
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')
- 统一导出,代码简洁,入口唯一
stores/index
export * from './modules/user'
App.vue
-import { useUserStore } from './stores/user'
+import { useUserStore } from './stores'
14.请求工具函数
实现:token请求头携带,错误响应处理,401错误处理
utils/request.ts
模板代码:
import axios from 'axios'
const instance = axios.create({
// TODO 1. 基础地址,超时时间
})
instance.interceptors.request.use(
(config) => {
// TODO 2. 携带token
return config
},
(err) => Promise.reject(err)
)
instance.interceptors.response.use(
(res) => {
// TODO 3. 处理业务失败
// TODO 4. 摘取核心响应数据
return res
},
(err) => {
// TODO 5. 处理401错误
return Promise.reject(err)
}
)
export default instance
代码实现:
import { useUserStore } from '@/stores'
import router from '@/router'
import axios from 'axios'
import { showToast } from 'vant'
// 1. 新axios实例,基础配置
const instance = axios.create({
baseURL: 'https://consult-api.itheima.net/',
timeout: 10000
})
// 2. 请求拦截器,携带token
instance.interceptors.request.use(
(config) => {
const store = useUserStore()
if (store.user?.token && config.headers) {
config.headers['Authorization'] = `Bearer ${store.user?.token}`
}
return config
},
(err) => Promise.reject(err)
)
// 3. 响应拦截器,剥离无效数据,401拦截
instance.interceptors.response.use(
(res) => {
// 后台约定,响应成功,但是code不是10000,是业务逻辑失败
if (res.data?.code !== 10000) {
showToast(res.data?.message || '业务失败')
return Promise.reject(res.data)
}
// 业务逻辑成功,返回响应数据,作为axios成功的结果
return res.data
},
(err) => {
if (err.response.status === 401) {
// 删除用户信息
const store = useUserStore()
store.delUser()
// 跳转登录,带上接口失效所在页面的地址,登录完成后回跳使用
router.push({
path: '/login',
query: { returnUrl: router.currentRoute.value.fullPath }
})
}
return Promise.reject(err)
}
)
export { baseURL, instance }
导出一个通用的请求工具函数,支持设置响应数据类型
- 导出一个通用的请求工具函数
import axios, { AxiosError, type Method } from 'axios'
// 4. 请求工具函数
const request = (url: string, method: Method = 'GET', submitData?: object) => {
return instance.request({
url,
method,
[method.toUpperCase() === 'GET' ? 'params' : 'data']: submitData
})
}
- 支持不同接口设不同的响应数据的类型
加上泛型
// 这个需要替换axsio.request默认的响应成功后的结果类型
// 之前是:传 { name: string } 然后res是 res = { data: { name: string } }
// 但现在:在响应拦截器中返回了 res.data 也就是将来响应成功后的结果,和上面的类型一致吗?
// 所以要:request<数据类型,数据类型>() 这样才指定了 res.data 的类型
// 但是呢:后台返回的数据结构相同,所以可以抽取相同的类型
type Data<T> = {
code: number
message: string
data: T
}
// 4. 请求工具函数
const request = <T>(url: string, method: Method = 'get', submitData?: object) => {
return instance.request<T, Data<T>>({
url,
method,
[method.toLowerCase() === 'get' ? 'params' : 'data']: submitData
})
}
测试封装好的请求工具函数
App.vue
<script setup lang="ts">
import { request } from '@/utils/request'
import type { User } from './types/user'
import { Button as VanButton } from 'vant'
import { useUserStore } from './stores'
// 测试,请求拦截器,是否携带token,响应拦截器401拦截到登录地址
const getUserInfo = async () => {
const res = await request('/patient/myUser')
console.log(res)
}
// 测试,响应拦截器,出现非10000的情况,和返回剥离后的数据
const store = useUserStore()
const login = async () => {
const res = await request<User>('/login/password', 'POST', {
mobile: '13211112222',
// 密码 abc123456 测试:出现非10000的情况
password: 'abc12345'
})
store.setUser(res.data)
}
</script>
<template>
<van-button type="primary" @click="getUserInfo">获取个人信息</van-button>
<van-button type="primary" @click="login">登录</van-button>
</template>
四、登录模块
1.约定路由规则
/
是布局容器,是一级路由/home
/article
/notify
/user
是二级路由- 他们的配置需要嵌套,其他的页面路由都是一级路由
2.路由与组件
- 基础组件结构
views/Login/index.vue
<script setup lang="ts"></script>
<template>
<div class="login-page">login</div>
</template>
<style lang="scss" scoped></style>
- 路由规则的配置
router/index.ts
routes: [{ path: '/login', component: () => import('@/views/Login/index.vue') }]
- app组件路由出口
App.vue
<script setup lang="ts"></script>
<template>
<router-view></router-view>
</template>
3. 自动按需加载
配置函数自动按需导入
# 通过 npm 安装
npm i unplugin-vue-components -D
# 通过 yarn 安装
yarn add unplugin-vue-components -D
# 通过 pnpm 安装
pnpm add unplugin-vue-components -D
- 配置:
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
// 解析单文件组件的插件
vue(),
// 自动导入的插件,解析器可以是 vant element and-vue
Components({
dts: false,
// 原因:Toast Confirm 这类组件的样式还是需要单独引入,样式全局引入了,关闭自动引入
resolvers: [VantResolver({ importStyle: false })]
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
4.cp-nav-bar 组件结构
抽离组件:components/CpNavBar.vue
<script setup lang="ts">
import { useRouter } from 'vue-router'
//1. 一定有的功能:返回图标,返回效果,固定定位(组件内部实现)
const router = useRouter()
const onClickLeft = () => {
// 判断历史记录中是否有回退
if (history.state?.back) {
router.back()
} else {
router.push('/')
}
}
// 2. 使用组件时候才能确定的功能:标题,右侧文字,点击右侧文字行为(props传入)
defineProps<{
title?: string
rightText?: string
}>()
const emit = defineEmits<{
(e: 'click-right'): void
}>()
const onClickRight = () => {
emit('click-right')
}
</script>
<template>
<van-nav-bar
left-arrow
@click-left="onClickLeft"
fixed
:title="title"
:right-text="rightText"
@click-right="onClickRight"
></van-nav-bar>
</template>
<style lang="scss" scoped>
:deep() {
.van-nav-bar {
&__arrow {
font-size: 18px;
color: var(--cp-text1);
}
&__text {
font-size: 15px;
}
}
}
</style>
5.页面布局
- 准备全局重置样式
style/main.scss
// 全局样式
body {
font-size: 14px;
color: var(--cp-text1);
}
a {
color: var(--cp-text2);
}
h1,h2,h3,h4,h5,h6,p,ul,ol {
margin: 0;
padding: 0;
}
- 登录页面整体结构
vies/Login/index.vue
<script setup lang="ts"></script>
<template>
<div class="login-page">
<cp-nav-bar
right-text="注册"
@click-right="$router.push('/register')"
></cp-nav-bar>
<!-- 头部 -->
<div class="login-head">
<h3>密码登录</h3>
<a href="javascript:;">
<span>短信验证码登录</span>
<van-icon name="arrow"></van-icon>
</a>
</div>
<!-- 表单 -->
<van-form autocomplete="off">
<van-field placeholder="请输入手机号" type="tel"></van-field>
<van-field placeholder="请输入密码" type="password"></van-field>
<div class="cp-cell">
<van-checkbox>
<span>我已同意</span>
<a href="javascript:;">用户协议</a>
<span>及</span>
<a href="javascript:;">隐私条款</a>
</van-checkbox>
</div>
<div class="cp-cell">
<van-button block round type="primary">登 录</van-button>
</div>
<div class="cp-cell">
<a href="javascript:;">忘记密码?</a>
</div>
</van-form>
<!-- 底部 -->
<div class="login-other">
<van-divider>第三方登录</van-divider>
<div class="icon">
<img src="@/assets/qq.svg" alt="" />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.login {
&-page {
padding-top: 46px;
}
&-head {
display: flex;
padding: 30px 30px 50px;
justify-content: space-between;
align-items: flex-end;
line-height: 1;
h3 {
font-weight: normal;
font-size: 24px;
}
a {
font-size: 15px;
}
}
&-other {
margin-top: 60px;
padding: 0 30px;
.icon {
display: flex;
justify-content: center;
img {
width: 36px;
height: 36px;
padding: 4px;
}
}
}
}
.van-form {
padding: 0 14px;
.cp-cell {
height: 52px;
line-height: 24px;
padding: 14px 16px;
box-sizing: border-box;
display: flex;
align-items: center;
.van-checkbox {
a {
color: var(--cp-primary);
padding: 0 5px;
}
}
}
.btn-send {
color: var(--cp-primary);
&.active {
color: rgba(22,194,163,0.5);
}
}
}
</style>
- 定制样式
style/main.scss
// 覆盖vant主体色
--van-primary-color: var(--cp-primary);
// 单元格上下间距
--van-cell-vertical-padding: 14px;
// 复选框大小
--van-checkbox-size: 14px;
// 默认按钮文字大小
--van-button-normal-font-size: 16px;
五、其他模块不再过多描述,需要参考的可以底部看源码;下面介绍一下项目用到的技术模块详细介绍
1.问诊支付
支付流程:
- 点击支付按钮,调用生成订单接口,得到
订单ID
,打开选择支付方式对话框 - 选择
支付方式
,(测试环境需要配置回跳地址
)调用获取支付地址接口,得到支付地址,跳转到支付宝页面- 使用支付宝APP支付(在手机上且安装沙箱支付宝)
- 使用浏览器账号密码支付 (测试推荐)
- 支付成功回跳到问诊室页面
回跳地址:
http://localhost:5173/room
支付宝沙箱账号:
买家账号:scobys4865@sandbox.com
登录密码:111111
支付密码:111111
OR
买家账号:askgxl8276@sandbox.com
登录密码:111111
支付密码:111111
2.websocket
什么是 websocket ? WebSockets handbook | WebSocket.org
- 是一种网络通信协议,和 HTTP 协议 一样。
为什么需要websocket ?
- 因为 HTTP 协议有一个缺陷:通信只能由客户端发起。
理解 websokect 通讯过程
websocket api
var ws = new WebSocket("wss://javascript.info/article/websocket/demo/hello");
ws.onopen = function(evt) {
console.log("Connection open ...");
ws.send("Hello WebSockets!");
};
ws.onmessage = function(evt) {
console.log( "Received Message: " + evt.data);
ws.close();
};
ws.onclose = function(evt) {
console.log("Connection closed.");
};
ws://121.40.165.18:8800
备用 ws 服务,有广告。
项目中使用 socket.io-client 来实现客户端代码,它是基于 websocket 的库。
3.问诊室-socket.io使用
-
socket.io 什么?
- socket.io 是一个基于 WebSocket 的 CS(客户端-服务端)的实时通信库
- 使用它可以在后端提供一个即时通讯服务
- 它也提供一个 js 库,在前端可以去链接后端的 socket.io 创建的服务
- 总结:它是一套基于 websocket 前后端即时通讯解决方案
-
socket.io 如何使用?
- 大家可以体验下这个 官方Demo
关于这个模块会单独说一下项目使用过程
源码
lien0219/consult-patient-h5: 在线医疗h5项目,可以在线问诊,使用vue3+ts+pinia+websocket技术栈,项目包含第三方授权登录,订单支付,暂时支持支付宝支付,问诊咨询即时通讯等技术 (github.com)