Skip to content

优医问诊-项目起步

项目介绍

知道:整体项目概况,并且知道课程中会实现哪些功能

手机端演示:

技术方案与特色业务矩阵

维度分类业务痛点 / 真实特色业务具体技术实现方案
基础工程中大型项目起步与类型安全Vue 3 + TypeScript + Vite (create-vue) + pnpm 包管理
状态 & 路由全局状态、状态持久化与路由Vue Router 前端路由 + Pinia 状态管理 + pinia-plugin-persistedstate 自动持久化
页面 & 适配H5组件快速构建、视觉规范与多端适配Vant 组件库 + postcss-px-to-viewport 移动端适配 + CSS 变量 主题定制
网络 & 请求规范化接口请求、数据解耦与并行开发Axios 二次封装 + Services API 接口分层 + Mock 本地数据模拟
特色业务 (核心)医生与文章推荐业务
快速问诊业务
问诊室业务
• 智能化双向卡片推荐与多维度筛选机制
• 极速响应机制,利用 自定义 Hook 封装倒计时与轮询状态控制
• 基于 Socket.io 实时通讯,支持文字/图片/病历发送与状态同步
特色业务 (支付/地图)问诊及药品订单支付
实时物流追踪
• 集成 支付宝 H5 支付 方案(沙箱环境测试、唤起 App/网页支付、支付状态轮询追踪)
• 集成 高德地图 SDK,实现物流轨迹可视化、司机定位实时标记与动态路线规划
特色业务 (接入)QQ 快捷登录• 基于 OAuth 2.0 协议的第三方登录,支持 Token 本地存储、过期自动拦截与状态同步
开发效率按需引入、SVG管理、模板定制与常用工具unplugin-vue-components 自动注册 + vite-plugin-svg-icons + vite-plugin-html + @vueuse/core
运维 & 调试移动端真机调试与自动化部署Eruda 移动端调试 + 生产环境配置优化 + CI/CD 持续集成自动部署

pnpm介绍&安装

掌握:pnpm 的安装和使用

本质上他是一个包管理工具,和npm/yarn没有区别,主要优势在于

  • 包安装速度极快
  • 磁盘空间利用效率高

安装:

sh
npm i pnpm -g
powershell
Invoke-WebRequest https://get.pnpm.io/install.ps1 -UseBasicParsing | Invoke-Expression

使用:

npm命令pnpm等效vitePlus等效
npm installpnpm installvp install
npm i axiospnpm add axiosvp add zxios
npm i webpack -Dpnpm add webpack -Dvp add webpack -D
npm run devpnpm devvp run dev

项目创建

使用 create-vue 脚手架创建项目

create-vue参考地址:https://github.com/vuejs/create-vue

步骤:

  1. 执行创建命令

    bash
    pnpm create vue
  2. 选择项目依赖内容

    bash
     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

vscode插件安装

安装:项目开发需要的一些插件

必装:

可选:

oxlint & oxfmt 预制配置

使用:oxlint(代码检查)和 oxfmt(代码格式化)的预制配置,且了解配置作用

在 vite.config.ts 中统一配置,由 vite-plus 插件驱动:

ts
// vite.config.ts
import { defineConfig } from 'vite-plus'

export default defineConfig({
  // oxlint 代码检查配置
  lint: {
    options: { typeAware: true, typeCheck: true },
    plugins: ['typescript', 'unicorn', 'import', 'vue'],
  },
  // oxfmt 代码格式化配置
  fmt: {
    printWidth: 120,
    tabWidth: 2,
    useTabs: false,
    semi: false,
    singleQuote: true,
    quoteProps: 'as-needed',
    bracketSpacing: true,
    arrowParens: 'avoid',
    htmlWhitespaceSensitivity: 'ignore',
    bracketSameLine: true,
    sortPackageJson: false,
    embeddedLanguageFormatting: 'auto',
  },
})
  • lint 配置:开启类型感知检查(typeAware: true)和类型检查(typeCheck: true),启用 typescript、unicorn、import、vue 四个插件规则集
  • fmt 格式:单引号,无分号,行宽 120 字符,对象/数组有空格,箭头函数无括号,HTML 后括号同行

执行:

bash
# 代码检查
pnpm oxlint

# 代码格式化
pnpm oxfmt
bash
# 代码检查
vp run oxlint

# 代码格式化
vp run oxfmt

Oxc VSCode 自动修复

安装 Oxc VSCode 扩展 后,参考以下 VSCode 配置启用保存时自动修复:

json
{
  // 保存时自动格式化
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "oxc.oxc-vscode",
  // 保存时自动修复 oxlint 问题
  "editor.codeActionsOnSave": {
    "source.fixAll.oxc": "explicit"
  },
  // oxc 插件配置
  "oxc.enable": true,
  "oxc.enable.oxlint": true,
  "oxc.enable.oxfmt": true,
  "oxc.typeAware": true,
  "oxc.fixKind": "safe_fix_or_suggestion"
}
配置项说明
editor.formatOnSave保存时触发格式化
editor.defaultFormatter指定 oxc 为默认格式化工具
source.fixAll.oxc保存时自动修复 oxlint 可修复的问题
oxc.enable启用 oxc 插件
oxc.enable.oxlint启用 oxlint 代码检查
oxc.enable.oxfmt启用 oxfmt 代码格式化
oxc.typeAware开启类型感知检查,与 vite.config.ts 的 typeAware 对应
oxc.fixKind修复等级:safe 只做安全修复,safe_fix_or_suggestion 同时包含建议

小结:

代码检查工作流

husky 配置(不推荐配置)

  • 初始化与安装
bash
pnpm dlx husky-init && pnpm install
  • 修改 .husky/pre-commit 文件
bash
pnpm lint

lint-staged 配置

  • 安装
bash
pnpm i lint-staged -D
  • 配置 package.json

    package.json
    json
    {
      // ... 省略 ...
      "lint-staged": {
        "*.{js,ts,vue}": ["eslint --fix"]
      }
    }
    package.json
    json
    {
      "scripts": {
        // ... 省略 ...
        "lint-staged": "lint-staged"
      }
    }
  • 修改 .husky/pre-commit 文件

    .husky/pre-commit
    bash
    pnpm lint-staged

项目结构调整

了解:每一个目录结构的作用

bash
./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语法:

bash
pnpm add sass -D

路由代码解析

知道:默认生成的路由代码的含义

ts
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.tsbase 属性的值
  • base 作用是什么?

    • 项目的基础路径前缀,默认是 /

vant组件库

实现:完整使用vant组件库

文档

安装:

bash
# Vue 3 项目,安装最新版 Vant
pnpm add vant
main.ts
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

App.vue
vue
<script setup lang="ts">
import { Button as VanButton } from 'vant'
</script>

<template>
  <van-button>按钮</van-button>
</template>

<style scoped></style>

提问:为什么不全局使用?

  • 全局使用是全量加载,是项目体积变大,加载慢

移动端适配

实现:使用 vw 完成移动端适配

文档

安装:

bash
pnpm add -D postcss-px-to-viewport

配置: postcss.config.js

postcss.config.js
js
// eslint-disable-next-line no-undef
module.exports = {
  plugins: {
    'postcss-px-to-viewport': {
      // 设备宽度375计算vw的值
      viewportWidth: 375,
    },
  },
}

测试:

image-20220731214535978

  • 有一个控制台警告可忽略,或者使用 postcss-px-to-viewport-8-plugin 代替当前插件

css变量主题定制

实现:使用css变量定制项目主题,和修改vant主题

  • 如果定义 css 变量使用 css 变量
css
:root {
  --main: #999;
}
a {
  color: var(--main);
}
  • 定义项目的颜色风格,覆盖vant的主题色 官方文档

styles/main.scss

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

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>

用户状态仓库

完成:用户信息仓库创建,提供用户信息,修改用信息,删除用户信息的方法

  • 请求工具需要携带token,访问权限控制需要token,所以用户信息仓库先完成

需求:

  • 用户信息仓库创建
  • 提供用户信息
  • 修改用信息的方法
  • 删除用信息的方法

代码:

types/user.d.ts

ts
// 用户信息
export type User = {
  /** token令牌 */
  token: string
  /** 用户ID */
  id: string
  /** 用户名称 */
  account: string
  /** 手机号 */
  mobile: string
  /** 头像 */
  avatar: string
}

stores/user.ts

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内存中,需要进行本地存储(持久化)

数据持久化

掌握:使用 pinia-plugin-persistedstate 实现pinia仓库状态持久化,且完成测试

参考文档

image-20220730222310940

  • 安装
bash
pnpm i pinia-plugin-persistedstate
# or
npm i pinia-plugin-persistedstate
# or
yarn add pinia-plugin-persistedstate
  • 使用 main.ts
ts
import persist from 'pinia-plugin-persistedstate'
const app = createApp(App)

app.use(createPinia().use(persist))
  • 配置 stores/user.ts
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
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>

stores统一导出

实现:仓库的导出统一从 ./stores 代码简洁,职能单一,入口唯一

  • 抽取pinia实例代码,职能单一

stores/index

ts
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

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

ts
export * from './modules/user'

App.vue

diff
-import { useUserStore } from './stores/user'
+import { useUserStore } from './stores'

小结:

  • 统一导出是什么意思?
    • 一个模块下的所有资源通过index导出

请求工具函数

拦截器逻辑

实现:token请求头携带,错误响应处理,401错误处理

utils/request.ts

模板代码:

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

代码实现:

ts
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 }

提问:

  • baseURL 导出的目的是啥?

    • 其他模块可能需要使用
  • 为什么使用函数 useXxxStore 函数,建议在拦截器使用?

    • 模块中的话,store可能还没初始化
  • 业务成功是什么意思?

    • 响应成功,且后台业务操作完毕

工具函数封装

实现:导出一个通用的请求工具函数,支持设置响应数据类型

  • 导出一个通用的请求工具函数
ts
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,
  })
}
  • 支持不同接口设不同的响应数据的类型

加上泛型

ts
// 这个需要替换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

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>

测试:

  • 登录的时候把密码改错,是测试?
    • 业务逻辑失败
  • 登录成功,看 res 打印,是测试?
    • 剥离一层数据
  • 获取用户信息成功,是测试?
    • 是否携带token
  • 把 token 删除或修改,获取用户信息失败,是测试?
    • 401 token 失效跳转 login 页面

📌 评论规则

需要 GitHub 账号登录 禁止发布广告、无关内容 请保持友善讨论