一、技术方案
- 基于 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提交记录提示
- json2tsjson自动转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)












![[] == ! [] 为什么返回 true ?](https://img-blog.csdnimg.cn/direct/bef5b42486304260b429f7ae15ae5d4d.webp)






