S11-12 Vue-项目:mr-vue3-ts-consult-patient
[TOC]
环境搭建
接口文档
地址:https://apifox.com/apidoc/shared-aeb0d03e-c713-4f55-afaf-21cddf542751/api-160608919
技术栈
- Vue3:
@3.5.13 - TS5:
@5.6.3 - Vite6:
@6.0.5。使用create-vue创建项目 - Pinia2:
@2.3.0。 - VueRouter4:
@4.5.0 - Node20:
node@20.11.1 - Vant:
创建项目
使用create-vue 工具创建mr-vue3-ts-consult-patient项目。create-vue 是基于vite的脚手架工具
pnpm create vue@latest创建选项

项目配置
Git配置

Eslint配置
1、在.eslintrc.cjs中配置prettier代码风格
rules: {
'prettier/prettier': [
'warn',
{
singleQuote: true,
semi: false,
printWidth: 80,
trailingComma: 'none',
endOfLine: 'auto'
}
],
// 💡 添加未定义变量错误提示,create-vue@3.6.3 关闭,这里加上是为了支持下一个章节演示。
'no-undef': 'error'
}2、忽略vue组件多单词警告
rules: {
'vue/multi-word-component-names': [
'warn',
{
ignores: ['index']
}
],
}3、关闭props解构警告
rules: {
'vue/no-setup-props-destructure': ['off'],
}后面开启响应式语法糖就结构props就不会再丢失响应式。

Husky配置
1、初始化与安装
pnpm dlx husky-init && pnpm install2、修改 .husky/pre-commit 文件
pnpm lintlint-staged配置
1、安装
pnpm i lint-staged -D2、配置 package.json
{
"scripts": {
"lint-staged": "lint-staged"
}
"lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix"
]
}
}3、修改 .husky/pre-commit 文件
pnpm lint-staged目录结构
每一个目录结构的作用:
./src
├── assets `静态资源,图片...`
├── components `通用组件`
├── hook `组合功能通用函数`
├── icon `svg图标`
├── router `路由`
│ └── index.ts
├── service `接口服务API`
├── store `状态仓库`
├── style `样式`
│ └── main.scss
├── type `TS类型`
├── utils `工具函数`
├── views `页面`
├── main.ts `入口文件`
└──App.vue `根组件`集成-Vant
基本导入
1、安装 vant
pnpm i vant2、在main.ts中引入样式
import 'vant/lib/index.css'3、在组件中使用vant组件

4、推荐按需引入
组件自动注册
已过时
替代方法:使用vant的按需导入
痛点:使用手动导入组件的方法过于繁琐,每次使用时都需要按以下方法手动导入。

解决:使用 unplugin-vue-components 实现自动按需加载,和自动导入组件。
1、安装 unplugin-vue-components 插件
pnpm i unplugin-vue-components -D2、配置 vite.config.ts

3、优化:样式重复的优化。
问题:配置后会出现vant组件的样式重复。

原因分析:这是因为在main.ts中导入的vant样式和自动注册组件时导入的vant样式重复了,导入了2次样式。
解决:设置组件自动导入的配置,让自动注册时不要导入样式。

4、优化:类型声明文件重复的优化。(新版待确定)
问题:components.d.ts的类型声明文件时多余的,vant本身自带了类型声明。
解决:设置组件自动导入的配置,不生成类型声明文件 components.d.ts。

5、注意:安装了插件后,components目录下的组件也会自动注册,不需要再手动导入。
移动端适配
pnpm i postcss-px-to-viewport -D2、配置 postcss.config.js
// postcss.config.js
module.exports = {
plugins: {
'postcss-px-to-viewport': {
viewportWidth: 375,
},
},
};3、重启项目生效

主题定制
使用css变量定制项目主题,和修改vant主题
CSS变量定义/使用
:root {
--main-color: #999; /** 定义CSS全局变量 */
}
.footer {
--footer-color: #f0f; /** 定义CSS局部变量 */
}
a {
color: var(--main-color) /** 使用CSS变量 */
}项目主题
: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);
}
集成-Store
useUserStore
TS类型
在type/user.d.ts中定义User的TS类型
// 用户信息
export type User = {
/** token令牌 */
token: string
/** 用户ID */
id: string
/** 用户名称 */
account: string
/** 手机号 */
mobile: string
/** 头像 */
avatar: string
}创建Store
1、在store/user.ts中创建useUserStore
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 }
})2、在组件中设置/删除user

Store持久化
思路:使用 pinia-plugin-persistedstate 实现pinia仓库状态持久化
1、安装 pinia-plugin-persistedstate 插件
pnpm i pinia-plugin-persistedstate2、在main.ts中使用插件

3、在 store/user.ts中配置本地持久化

4、开启后store中的数据会被存储在localStorage中

统一管理
实现仓库统一从 store/index.ts 导出,代码简洁,职能单一,入口唯一
抽取实例
1、抽取pinia实例代码到 store/index.ts 中
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'
// 1. 创建pinia实例
const pinia = createPinia()
// 2. 使用pinia插件
pinia.use(persist)
// 3. 导出pinia实例,给main使用
export default pinia2、在 main.ts 中挂载pinia实例
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')统一导出模块
1、在store/index.ts 中统一导出Store模块
export * from './modules/user'2、在组件中使用导出的模块
-import { useUserStore } from './stores/user'
+import { useUserStore } from './stores'集成-axios
1、安装axios
pnpm i axios2、baseURL,timeout

3、携带token

4、验证携带token

响应成功,业务失败处理
业务失败:响应数据返回的code不是10000(项目后端自定义的规则)。
失败处理:
- 弹出轻提示
- 此时返回一个失败的promise,传递code给catch,以便后续根据code进行不同的处理
1、在axios响应拦截器中处理业务失败

2、测试:登录失败

返回核心数据
需求:业务逻辑成功,返回响应数据data,后续直接使用

代码实现:

401错误处理
401错误:token失效。
401错误处理:
- 删除用户信息
- 跳转登录,带上接口失效所在页面的地址,登录完成后回跳使用
// 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) {
// 1. 删除用户信息
const store = useUserStore()
store.delUser()
// 2. 跳转登录,带上接口失效所在页面的地址,登录完成后回跳使用
router.push({
path: '/login',
query: { returnUrl: router.currentRoute.value.fullPath }
})
}
return Promise.reject(err)
}
)封装请求函数
封装一个统一的请求函数,简化请求配置

测试

设置响应数据类型

问题:为什么T传递给了instance.request<any, T>的第二个参数。
解释:第一个参数是给res设置的类型,但是在响应拦截器中返回的是res.data的数据,如果想给它设置类型,只能通过第二个参数。
打包svg地图@
Login
路由规则
1、在router/index.ts中配置路由匹配规则

2、在App.vue中配置一级路由占位

组件:cp-nav-bar

页面布局
1、vant相关属性

2、在login/index.vue中使用组件

3、实现 <cp-nav-bar> 组件


4、修改样式
注意:使用 :deep() 修改vant组件内部样式。



功能
动态标题、右侧文字
思路:通过props来动态设置标题、右侧文字。
1、在子组件中,通过defineProps()接收传递的属性 title、rightText。

2、在父组件中,使用<cp-nav-bar> 组件时绑定属性 title、rightText。

右侧文字点击事件
思路:通过emit方法触发自定义右侧文字点击事件。
1、在子组件中,监听vant组件的@click-right事件,同时自定义@click-right事件向外发射该事件。

2、在父组件中,监听子组件传递过来的自定义事件@click-right。

返回功能

思路:通过 history.state访问历史记录信息。

1、在子组件中,监听@click-left事件

2、判断history.state.back是否有值,实现回退到不同页面。

组件类型提示@
问题:当前的组件<cp-nav-bar>是没有类型的:

解决:
思路一:在使用时,显示(手动)导入组件。

思路二:对于全局组件或自动注册的组件,可以在
components.d.ts文件中添加全局组件类型。
组件:login

页面布局
1、在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;
}2、页面结构
<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>3、样式
.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);
}
}
}4、在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;5、清除多余app容器


功能
表单校验
提取校验规则@
1、提取校验规则到utils/rule.ts文件。

2、设置校验规则的TS类型


基本校验
1、校验手机号

2、校验密码

3、表单整体校验,修改native-type属性

校验勾选协议
1、绑定 agree ,判断是否勾选协议

2、在submit事件处理方法中,校验是否勾选协议

密码登录

接口
URL:
/login/password类型:
POST参数:
ts{ password: string, // 密码 mobile: string // 手机号 }返回数据:

密码登录
1、在 service/user.ts 中发送网络请求

2、在 login/login.vue 中执行密码登录

短信登录

切换界面

1、根据 isPass 切换密码登录和短信验证码登录界面

2、表单项切换

3、校验验证码


获取验证码-接口
URL:/code
类型:GET
token:携带
参数:
{
mobile: string, // 手机号
type: 'login' | 'register' | 'changeMobile' | 'forgetPassword' | 'bindMobile'
}返回数据:

获取验证码

1、发送前校验

2、在 service/user.ts 中发送网络请求

3、在types/user.ts中定义CodeType联合类型

4、在login组件中,发送请求获取验证码,并设置倒计时

5、实现倒计时,并在结束时清理定时器

6、在组件销毁时清理定时器

7、显示倒计时


短信登录-接口
URL:/login
类型:POST
token:携带
参数:
{
code: string, // 验证码
mobile: string // 手机号
}返回数据:

短信登录

1、在 service/user.ts 中发送网络请求

2、在组件中将短信登录合并到密码登录逻辑中

密码是否可见


第三方登录:login-callback@

路由规则

页面布局
QQ登录
注册QQ互联
1、需要在 QQ互联 平台注册,并实名身份认证,审核通过。
3、创建web应用,需要有网站域名、域名备案号,设置登录成功回跳地址,审核通过。
4、得到 appid 和 回跳地址。
# 测试用 appid:102015968
# 测试用 redirect_uri:http://consult-patients.itheima.net/login/callback配置服务器
1、修改hosts

2、在vite.config.ts中修改项目服务器配置后重启,将端口改为80

3、在router/index.ts中修改之前配置的白名单 whiteList

生成跳转地址
1、 在 index.html 模板文件中,引入QQ互联提供的script文件

2、在login组件中,通过 QC.Login({}) 获取QQ登录的跳转地址,复制地址后该断代码就不需要了

3、将得到的跳转地址手动复制到此处

openId登录
1、在login-callback组件的onMounted钩子中获取openId

2、定义全局变量 QC 的TS类型


接口-第三方登录
URL:
/login/thirdparty类型:
POSTtoken:携带
参数:
ts{ openId: string // QQ登录返回的openid source: string // 默认传qq nickname?: string // 三方登录的昵称 avatar?: string // 三方登录的头像 }返回数据:

网络请求

实现第三方登录

手机绑定
页面布局
<template>
<div class="login-page" v-if="isNeedBind">
<cp-nav-bar></cp-nav-bar>
<div class="login-head">
<h3>手机绑定</h3>
</div>
<van-form autocomplete="off" ref="form">
<van-field name="mobile" placeholder="请输入手机号"></van-field>
<van-field name="code" placeholder="请输入验证码">
<template #button>
<span class="btn-send">发送验证码</span>
</template>
</van-field>
<div class="cp-cell">
<van-button
style="margin-top: 50px"
block
round
type="primary"
native-type="submit"
>
立即绑定
</van-button>
</div>
</van-form>
</div>
</template>封装-发送验证码
1、在hooks中封装发送验证码功能

2、使用封装的发送验证码钩子

表单处理
1、绑定表单项,表单验证

2、发送验证码

接口-绑定手机号
URL:
/login/binding类型:
POSTtoken:携带
参数:
ts{ mobile: string // 手机号 code: string // 验证码 openId: string // QQ登录返回的openid }返回数据:

网络请求

实现手机绑定
1、绑定 bind() 处理函数到form表单上

2、实现绑定手机号的逻辑

3、通过QQ登录成功后也调用登录成功的逻辑

回跳地址
1、在userStore中记录回跳地址 returnUrl

2、在 login.vue 组件中调用 setReturnUrl 记录地址栏的returnUrl

3、在 login-callback 组件中,等登录成功后跳转到记录的回跳地址,并清空store中的returnUrl

Layout

路由规则
1、在router/index.ts中配置路由匹配规则

2、在views/layout/layout.vue中配置二级路由占位

组件:van-tabbar


1、基本使用

2、开启路由模式

3、自定义图标

4、修改样式
.layout-page {
:deep() {
.van-tabbar-item {
&__icon {
font-size: 21px;
}
&__text {
font-size: 11px;
}
&:not(.van-tabbar-item--active) {
color: var(--cp-text3);
}
}
}
}功能
访问权限控制
router.beforeEach():(guard),全局前置导航守卫,用于在每次路由跳转前执行自定义逻辑,如权限校验、数据预加载等。

思路:在 router/index.ts 中添加全局前置导航守卫,校验是否有token、是否在白名单中。
白名单:不需要登录就可以访问的页面

页面标题
router.afterEach():(guard),全局后置守卫,用于注册一个在 导航完成之后 执行的钩子函数。它不会改变导航结果,常用于执行与导航结果无关的后续处理操作,如埋点统计、页面标题更新等。
1、在路由配置元信息 meta 中定义标题

2、在 router/index.ts 中添加全局后置导航守卫,获取 meta 并设置标题

3、TS:如果需要TS提示title,可以通过扩展元信息类型实现

解决:新建 types/vue-router.d.ts 文件,并扩展元信息类型

加载进度
依赖包:nprogress
1、安装 nprogress和@types/nprogress
pnpm i nprogress
pnpm i @types/nprogress -D # TS类型提示2、在 router/index.ts 中导入并在前置守卫中开启

3、在 router/index.ts 中的后置守卫中结束

4、取消进度条的小圆圈动画


5、在 style/main.less 中修改进度条样式

User
组件:user-info


TS类型
知识点:Omit
知识点:Pick
// 用户信息
export type User = {
token: string
id: string
account: string
mobile: string
avatar: string
}// 个人信息
type OmitUser = Omit<User, 'token'>
export type UserInfo = OmitUser & {
/** 关注 */
likeNumber: number
/** 收藏 */
collectionNumber: number
/** 积分 */
score: number
/** 优惠券 */
couponNumber: number
orderInfo: {
/** 待付款 */
paidNumber: number
/** 待发货 */
receivedNumber: number
/** 待收货 */
shippedNumber: number
/** 已完成 */
finishedNumber: number
}
}页面布局
<script setup lang="ts"></script>
<template>
<div class="user-page">
<div class="user-page-head">
<!-- 头部 -->
<div class="top">
<van-image
round
fit="cover"
src="https://yanxuan-item.nosdn.127.net/ef302fbf967ea8f439209bd747738aba.png"
/>
<div class="name">
<p>用户907456</p>
<p><van-icon name="edit" /></p>
</div>
</div>
<!-- 用户信息 -->
<van-row>
<van-col span="6">
<p>150</p>
<p>收藏</p>
</van-col>
<van-col span="6">
<p>23</p>
<p>关注</p>
</van-col>
<van-col span="6">
<p>270</p>
<p>积分</p>
</van-col>
<van-col span="6">
<p>3</p>
<p>优惠券</p>
</van-col>
</van-row>
</div>
<!-- 药品订单 -->
<div class="user-page-order">
<div class="head">
<h3>药品订单</h3>
<router-link to="/order">全部订单 <van-icon name="arrow" /></router-link>
</div>
<van-row>
<van-col span="6">
<cp-icon name="user-paid" />
<p>待付款</p>
</van-col>
<van-col span="6">
<cp-icon name="user-shipped" />
<p>待发货</p>
</van-col>
<van-col span="6">
<cp-icon name="user-received" />
<p>待收货</p>
</van-col>
<van-col span="6">
<cp-icon name="user-finished" />
<p>已完成</p>
</van-col>
</van-row>
</div>
</div>
</template>2、样式
.user-page {
background-color: var(--cp-bg);
min-height: calc(100vh - 50px);
padding: 0 15px 65px;
// 头部
&-head {
height: 200px;
background: linear-gradient(180deg, rgba(44, 181, 165, 0.46), rgba(44, 181, 165, 0));
margin: 0 -15px;
padding: 0 15px;
.top {
display: flex;
padding-top: 50px;
align-items: center;
.van-image {
width: 70px;
height: 70px;
}
.name {
padding-left: 10px;
p {
&:first-child {
font-size: 18px;
font-weight: 500;
}
&:last-child {
margin-top: 10px;
color: var(--cp-primary);
font-size: 16px;
}
}
}
}
.van-row {
margin: 0 -15px;
padding-top: 15px;
p {
text-align: center;
&:first-child {
font-size: 18px;
font-weight: 500;
}
&:last-child {
color: var(--cp-dark);
font-size: 12px;
padding-top: 4px;
}
}
}
}
// 订单
&-order {
background-color: #fff;
border-radius: 8px;
margin-bottom: 15px;
padding-bottom: 15px;
.head {
display: flex;
justify-content: space-between;
line-height: 50px;
padding: 0 15px;
a {
color: var(--cp-tip);
}
}
.van-col {
text-align: center;
.cp-icon {
font-size: 28px;
}
p {
font-size: 12px;
padding-top: 4px;
}
}
}
// 分组
&-group {
background-color: #fff;
border-radius: 8px;
overflow: hidden;
h3 {
padding-left: 16px;
line-height: 44px;
}
.van-cell {
align-items: center;
}
.cp-icon {
font-size: 17px;
margin-right: 10px;
}
}
.logout {
display: block;
margin: 20px auto;
width: 100px;
text-align: center;
color: var(--cp-price);
}
}接口
URL:
/patient/myUser类型:
GETtoken:携带
参数:无
返回数据:

渲染页面
1、在 service/user.ts 中发送网络请求

2、在组件中调用网路请求方法

2、在组件中渲染请求的数据


组件:user-quick-toolbar

页面布局
1、快捷工具数据

2、遍历渲染

3、退出登录

功能
退出登录
1、绑定点击事件

2、实现点击事件处理函数

Patient

路由规则

组件:patient-list


页面布局
1、HTML
<script setup lang="ts"></script>
<template>
<div class="patient-page">
<!-- 导航栏 -->
<cp-nav-bar title="家庭档案"></cp-nav-bar>
<!-- 患者列表 -->
<div class="patient-list">
<div class="patient-item">
<div class="info">
<span class="name">李富贵</span>
<span class="id">321111********6164</span>
<span>男</span>
<span>32岁</span>
</div>
<div class="icon"><cp-icon name="user-edit" /></div>
<div class="tag">默认</div>
</div>
<div class="patient-item">
<div class="info">
<span class="name">李富贵</span>
<span class="id">321333********6164</span>
<span>男</span>
<span>32岁</span>
</div>
<div class="icon"><cp-icon name="user-edit" /></div>
</div>
<!-- 添加患者 -->
<div class="patient-add">
<cp-icon name="user-add" />
<p>添加患者</p>
</div>
<!-- 提示 -->
<div class="patient-tip">最多可添加 6 人</div>
</div>
</div>
</template>2、样式
.patient-page {
padding: 46px 0 80px;
}
.patient-list {
padding: 15px;
}
.patient-item {
display: flex;
align-items: center;
padding: 15px;
background-color: var(--cp-bg);
border-radius: 8px;
margin-bottom: 15px;
position: relative;
border: 1px solid var(--cp-bg);
transition: all 0.3s;
overflow: hidden;
.info {
display: flex;
flex-wrap: wrap;
flex: 1;
span {
color: var(--cp-tip);
margin-right: 20px;
line-height: 30px;
&.name {
font-size: 16px;
color: var(--cp-text1);
width: 80px;
margin-right: 0;
}
&.id {
color: var(--cp-text2);
width: 180px;
}
}
}
.icon {
color: var(--cp-tag);
width: 20px;
text-align: center;
}
.tag {
position: absolute;
right: 60px;
top: 21px;
width: 30px;
height: 16px;
font-size: 10px;
color: #fff;
background-color: var(--cp-primary);
border-radius: 2px;
display: flex;
justify-content: center;
align-items: center;
}
&.selected {
border-color: var(--cp-primary);
background-color: var(--cp-plain);
.icon {
color: var(--cp-primary);
}
}
}
.patient-add {
background-color: var(--cp-bg);
color: var(--cp-primary);
text-align: center;
padding: 15px 0;
border-radius: 8px;
.cp-icon {
font-size: 24px;
}
}
.patient-tip {
color: var(--cp-tag);
padding: 12px 0;
}
.pb4 {
padding-bottom: 4px;
}接口
URL:
/patient/mylist类型:
GETtoken:携带
参数:无
返回数据:

TS类型
// 家庭档案-患者信息
export type Patient = {
/** 患者ID */
id: string
/** 患者名称 */
name: string
/** 身份证号 */
idCard: string
/** 0不默认 1默认 */
defaultFlag: 0 | 1
/** 0 女 1 男 */
gender: 0 | 1
/** 性别文字 */
genderValue: string
/** 年龄 */
age: number
}
// 家庭档案-患者信息列表
export type PatientList = Patient[]渲染页面
1、在 services/user.ts 中发送网络请求

2、在组件中,调用请求方法,获取数据

3、渲染数据

功能
身份证脱敏@
知识点:通过$1、$2可以获取正则匹配到的内容。

添加患者


组件:cp-radio-btn


页面布局
1、HTML
<script setup lang="ts"></script>
<template>
<div class="cp-radio-btn">
<a class="item" href="javascript:;">男</a>
<a class="item" href="javascript:;">女</a>
</div>
</template>2、样式
.cp-radio-btn {
display: flex;
flex-wrap: wrap;
.item {
height: 32px;
min-width: 60px;
line-height: 30px;
padding: 0 14px;
text-align: center;
border: 1px solid var(--cp-bg);
background-color: var(--cp-bg);
margin-right: 10px;
box-sizing: border-box;
color: var(--cp-text2);
margin-bottom: 10px;
border-radius: 4px;
transition: all 0.3s;
&.active {
border-color: var(--cp-primary);
background-color: var(--cp-plain);
}
}
}动态渲染选项
1、定义选项数据

2、使用组件,并传入数据

3、接收并遍历渲染数据


功能
切换选中项
1、在父组件中定义 gender 属性,并绑定到 modelValue上

2、在子组件中接收 gender 属性,并根据gender值设置active样式

3、绑定点击事件,向外发射自定义事件 @update:modelValue

4、在父组件中绑定子组件传递的自定义事件 @update:modelValue

5、重构:使用v-model重构

显示弹层
1、在 patient-list组件中,使用 van-popup 组件添加弹层


2、点击 添加患者 按钮,展示弹层


3、修改弹层样式
.patient-page {
padding: 46px 0 80px;
:deep() {
.van-popup {
width: 80%;
height: 100%;
}
}
}组件:patient-add



页面布局
<van-form autocomplete="off" ref="form">
<van-field label="真实姓名" placeholder="请输入真实姓名" />
<van-field label="身份证号" placeholder="请输入身份证号" />
<van-field label="性别" class="pb4">
<!-- 单选按钮组件 -->
<template #input>
<cp-radio-btn :options="options"></cp-radio-btn>
</template>
</van-field>
<van-field label="默认就诊人">
<template #input>
<van-checkbox :icon-size="18" round />
</template>
</van-field>
</van-form>接口-添加患者
URL:
/patient/add类型:
POSTtoken:携带
参数:
ts{ name: string // 患者姓名 idCard: string // 患者身份证号 defaultFlag: number // 是否设置为默认患者 gender: number // 性别,1:男,0:女 }返回数据:

TS类型修改
问题:patient表单只需要4个属性,而Patient类型有7个属性,因此需要将其他属性修改为可选属性。

解决:修改Patient类型为可选属性。

渲染表单

功能
重构cp-nav-bar
1、在 cp-nav-bar 组件中定义 back 属性,类型是一个回调函数

2、重构 onClickLeft 方法,如果传入了back,则执行该回调,而不是之前的逻辑

3、在父组件中,传入back属性,实现自定义的关闭弹层逻辑

默认就诊人类型转换
问题:默认就诊人给的数据是 0 和 1,而 van-checkbox 值的类型为 true 和 false,需要通过计算属性转换后才能使用。
解决:通过计算属性转换后使用。


重置表单
需求:每次打开侧滑弹层时,需要将上次弹层中的表单数据清空。

表单校验


1、表单项校验



2、提交时校验整个表单


3、性别确认提示
思路:身份证号倒数第二位,如果是偶数就是女,如果是奇数就是男。

实现添加患者

1、在 service/user.ts 中发送网络请求

2、在组件中调用请求方法,实现添加患者

3、注意:如果添加的身份证号已经存在于患者列表中会提示添加失败

编辑患者

思路:编辑患者和添加患者共用一个组件。

接口
URL:
/patient/update类型:
PUTtoken:携带
参数:
ts{ name: string // 患者姓名 idCard: string // 患者身份证号 defaultFlag: number // 是否设置为默认患者 gender: number // 性别,1:男,0:女 id: string // 患者信息id }返回数据:

功能
显示弹层


区分标题
思路:根据 patient 对象是否存在id属性,判断是否是编辑状态,显示不同的标题

实现编辑患者
1、在 service/user.ts 中发送网络请求

2、在组件中调用请求方法,将编辑患者逻辑合并到添加患者中

清空校验
需求:当再次打开弹层时,上次的校验结果依然存在,需要清空。

删除患者

接口
URL:
/patient/del/{id}类型:
DELETEtoken:携带
参数:
tsid: string // 患者信息id返回数据:

功能
删除按钮

需求:在弹层的底部添加一个删除按钮

修改样式
// 底部操作栏
.van-action-bar {
padding: 0 10px;
margin-bottom: 10px;
.van-button {
color: var(--cp-price);
background-color: var(--cp-bg);
}
}实现删除患者
1、在 service/user.ts 中发送网络请求

2、在组件中调用请求方法,实现删除患者


Home

页面布局

1、HTML
<script setup lang="ts"></script>
<template>
<div class="home-page">
<!-- 头部 -->
<div class="home-header">
<div class="con">
<h1>优医</h1>
<div class="search">
<cp-icon name="home-search" /> 搜一搜:疾病/症状/医生/健康知识
</div>
</div>
</div>
<!-- 导航 -->
<div class="home-navs">
<van-row>
<van-col span="8">
<router-link to="/" class="nav">
<cp-icon name="home-doctor"></cp-icon>
<p class="title">问医生</p>
<p class="desc">按科室查问医生</p>
</router-link>
</van-col>
<van-col span="8">
<router-link to="/consult/fast" class="nav">
<cp-icon name="home-graphic"></cp-icon>
<p class="title">极速问诊</p>
<p class="desc">20s医生极速回复</p>
</router-link>
</van-col>
<van-col span="8">
<router-link to="/" class="nav">
<cp-icon name="home-prescribe"></cp-icon>
<p class="title">开药门诊</p>
<p class="desc">线上买药更方便</p>
</router-link>
</van-col>
</van-row>
<van-row>
<van-col span="6">
<router-link to="/" class="nav min">
<cp-icon name="home-order"></cp-icon>
<p class="title">药品订单</p>
</router-link>
</van-col>
<van-col span="6">
<router-link to="/" class="nav min">
<cp-icon name="home-docs"></cp-icon>
<p class="title">健康档案</p>
</router-link>
</van-col>
<van-col span="6">
<router-link to="/" class="nav min">
<cp-icon name="home-rp"></cp-icon>
<p class="title">我的处方</p>
</router-link>
</van-col>
<van-col span="6">
<router-link to="/" class="nav min">
<cp-icon name="home-find"></cp-icon>
<p class="title">疾病查询</p>
</router-link>
</van-col>
</van-row>
</div>
<!-- 轮播图 -->
<div class="home-banner">
<van-swipe indicator-color="#fff">
<van-swipe-item>
<img src="@/assets/ad.png" alt="" />
</van-swipe-item>
<van-swipe-item>
<img src="@/assets/ad.png" alt="" />
</van-swipe-item>
</van-swipe>
</div>
<!-- 知识列表tab -->
<van-tabs shrink sticky v-model:active="active">
<van-tab title="关注">1</van-tab>
<van-tab title="推荐" >
<p v-for="i in 100" :key="i">内容</p>
</van-tab>
<van-tab title="减脂">3</van-tab>
<van-tab title="饮食">4</van-tab>
</van-tabs>
</div>
</template>2、样式
.home-page {
padding-bottom: 50px;
}
.home-header {
height: 100px;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 90px;
background: linear-gradient(180deg, rgba(62, 206, 197, 0.85), #26bcc6);
border-bottom-left-radius: 150px 20px;
border-bottom-right-radius: 150px 20px;
}
.con {
position: relative;
padding: 0 15px;
> h1 {
font-size: 18px;
color: #fff;
font-weight: normal;
padding: 20px 0;
line-height: 1;
padding-left: 5px;
}
.search {
height: 40px;
border-radius: 20px;
box-shadow: 0px 15px 22px -7px rgba(224, 236, 250, 0.8);
background-color: #fff;
display: flex;
align-items: center;
padding: 0 20px;
color: var(--cp-dark);
font-size: 13px;
.cp-icon {
font-size: 16px;
margin-right: 5px;
}
}
}
}
.home-navs {
padding: 10px 15px 0 15px;
.nav {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px 0;
.cp-icon {
font-size: 48px;
}
.title {
font-weight: 500;
margin-top: 5px;
color: var(--cp-text1);
}
.desc {
font-size: 11px;
color: var(--cp-tag);
margin-top: 2px;
}
&.min {
.cp-icon {
font-size: 31px;
}
.title {
font-size: 13px;
color: var(--cp-text2);
font-weight: normal;
}
}
}
}
.home-banner {
padding: 10px 15px;
height: 100px;
img {
width: 100%;
height: 100%;
}
}3、全局覆盖van-tab样式
// 全局覆盖van-tab样式
.van-tabs {
.van-tabs__nav {
padding: 0 0 15px 0;
}
.van-tabs__line {
width: 20px;
background-color: var(--cp-primary);
}
.van-tab {
padding: 0 15px;
}
}组件:knowledge-card
页面布局
1、HTML
<template>
<div class="knowledge-card van-hairline--bottom">
<div class="head">
<van-image
round
class="avatar"
src="https://yanxuan-item.nosdn.127.net/9ad83e8d9670b10a19b30596327cfd14.png"
></van-image>
<div class="info">
<p class="name">张医生</p>
<p class="dep van-ellipsis">积水潭医院 骨科 主任医师</p>
</div>
<van-button class="btn" size="small" round>+ 关注</van-button>
</div>
<div class="body">
<h3 class="title van-ellipsis">高血压是目前世界上最常见,发病率最高的慢性病之一</h3>
<p class="tag">
<span># 肥胖</span>
<span># 养生</span>
</p>
<p class="intro van-multi-ellipsis--l2">
据估计,全世界有 10
亿人患有高血压,来自美国全国健康和营养调查的数据(NHANES)显示,高血压的患病率呈逐年上升趋势。
但是,我国高血压的控制程度非常不乐观,不少朋友担心降压药对肾的影响,有些甚至因为担心伤肾,而不敢吃降压药。
我们就介绍一下,高血压对肾脏的危害,还有降压药对肾脏影响。
没有耐心看的朋友,可以直接记住这个结论:高血压比降压药伤肾。千万不要因为担心副作用不敢吃药,那是「丢西瓜捡芝麻」得不偿失的行为
</p>
<div class="imgs">
<van-image
src="https://yanxuan-item.nosdn.127.net/c1cdf62c5908659a9e4c8c2f9df218fd.png"
/>
<van-image
src="https://yanxuan-item.nosdn.127.net/c1cdf62c5908659a9e4c8c2f9df218fd.png"
/>
<van-image
src="https://yanxuan-item.nosdn.127.net/c1cdf62c5908659a9e4c8c2f9df218fd.png"
/>
</div>
<p class="logs">
<span>10 收藏</span>
<span>50 评论</span>
</p>
</div>
</div>
</template>2、样式
.knowledge-card {
padding: 20px 0 16px;
.head {
display: flex;
align-items: center;
.avatar {
width: 38px;
height: 38px;
margin-right: 10px;
}
.info {
width: 200px;
padding-right: 10px;
.name {
color: var(--cp-text2);
}
.dep {
color: var(--cp-tip);
font-size: 12px;
}
}
.btn {
padding: 0 12px;
border-color: var(--cp-primary);
color: var(--cp-primary);
height: 28px;
width: 72px;
}
}
.body {
.title {
font-size: 16px;
margin-top: 8px;
font-weight: normal;
}
.tag {
margin-top: 6px;
> span {
color: var(--cp-primary);
margin-right: 20px;
font-size: 12px;
}
}
.intro {
margin-top: 7px;
line-height: 2;
color: var(--cp-text3);
}
.imgs {
margin-top: 7px;
display: flex;
.van-image {
width: 106px;
height: 106px;
margin-right: 12px;
border-radius: 12px;
overflow: hidden;
&:last-child {
margin-right: 0;
}
}
&.large {
.van-image {
width: 185px;
height: 125px;
}
}
}
.logs {
margin-top: 10px;
> span {
color: var(--cp-tip);
margin-right: 16px;
font-size: 12px;
}
}
}
}组件:knowledge-list
页面布局
1、HTML
<template>
<div class="knowledge-list">
<knowledge-card v-for="i in 5" :key="i"></knowledge-card>
</div>
</template>2、样式
.knowledge-list {
padding: 0 15px;
}3、在Home组件中使用 knowlege-list
<van-tabs shrink sticky v-model:active="active">
<van-tab title="关注"><knowledge-list /></van-tab>
<van-tab title="推荐"><knowledge-list /></van-tab>
<van-tab title="减脂"><knowledge-list /></van-tab>
<van-tab title="饮食"><knowledge-list /></van-tab>
</van-tabs>功能
列表加载更多

1、使用 van-list 实现列表加载更多功能

2、模拟加载后台数据

3、遍历加载的数据

渲染请求数据

TS类型
1、响应数据类型
// 文章信息类型
export type Knowledge = {
id: string
/** 标题 */
title: string
/** 封面[] */
coverUrl: string[]
/** 标签[] */
topics: string[]
/** 收藏数 */
collectionNumber: number
/** 评论数 */
commentNumber: number
/** 医生名称 */
creatorName: string
/** 医生头像 */
creatorAvatar: string
/** 医生医院 */
creatorHospatalName: string
/** 关注文章 */
likeFlag: 0 | 1
/** 内容 */
content: string
/** 医生科室 */
creatorDep: string
/** 医生职称 */
creatorTitles: string
/** 医生ID */
creatorId: string
}
// 文章列表
export type KnowledgeList = Knowledge[]
// 文章列表带分页
export type KnowledgePage = {
pageTotal: number
total: number
rows: KnowledgeList
}2、查询参数类型
// props类型 recommend推荐,fatReduction减脂,food健康饮食,like关注医生页面文章
export type KnowledgeType = 'like' | 'recommend' | 'fatReduction' | 'food'
// 文章列表查询参数
export type KnowledgeParams = {
type: KnowledgeType
current: number
pageSize: number
}知识列表类型
1、在 kownlege-list 组件中,定义props类型

2、在使用组件时添加type属性

接口
URL:
/patient/home/knowledge类型:
GETtoken:携带
参数:
ts{ type: KnowledgeType // recommend: 推荐,fatReduction: 减脂,food: 健康饮食,like: 关注医生页面文章 current: number pageSize: number }返回数据:

渲染数据

1、在 services/home.ts 中发送网络请求

2、在组件中调用请求方法,获取知识列表数据

3、遍历并传递数据到 kownlege-card 中

4、渲染数据到 kownlege-card 中


5、修复:图片变形


6、修复:去除HTML代码


7、修复:一张配图展示

组件:doctor-card
页面布局
1、HTML
<template>
<div class="doctor-card">
<van-image
round
src="https://yanxuan-item.nosdn.127.net/3cb61b3fd4761555e56c4a5f19d1b4b1.png"
/>
<p class="name">周医生</p>
<p class="van-ellipsis">积水潭医院 神经内科</p>
<p>副主任医师</p>
<van-button round size="small" type="primary">+ 关注</van-button>
</div>
</template>2、样式
.doctor-card {
width: 135px;
height: 190px;
background: #fff;
border-radius: 20px;
box-shadow: 0px 0px 11px 0px rgba(229, 229, 229, 0.2);
text-align: center;
padding: 15px;
margin-left: 15px;
display: inline-block;
box-sizing: border-box;
> .van-image {
width: 58px;
height: 58px;
vertical-align: top;
border-radius: 50%;
margin: 0 auto 8px;
}
> p {
margin-bottom: 0;
font-size: 11px;
color: var(--cp-tip);
&.name {
font-size: 13px;
color: var(--cp-text1);
margin-bottom: 5px;
}
}
> .van-button {
padding: 0 12px;
height: 28px;
margin-top: 8px;
width: 72px;
}
}组件:follow-doctor


页面布局
1、使用组件

1、HTML
<template>
<div class="follow-doctor">
<div className="head">
<p>推荐关注</p>
<a href="javascript:;"> 查看更多<i class="van-icon van-icon-arrow" /></a>
</div>
<div class="body">
<!-- swipe 组件 -->
</div>
</div>
</template>2、使用 van-swipe 组件遍历包裹 doctor-card 组件

3、样式
.follow-doctor {
background-color: var(--cp-bg);
height: 250px;
.head {
display: flex;
justify-content: space-between;
height: 45px;
align-items: center;
padding: 0 15px;
font-size: 13px;
> a {
color: var(--cp-tip);
}
}
.body {
width: 100%;
overflow: hidden;
}
}渲染请求数据

TS类型
// 医生卡片对象
export type Doctor = {
/** 医生ID */
id: string
/** 医生名称 */
name: string
/** 头像 */
avatar: string
/** 医院名称 */
hospitalName: string
/** 医院等级 */
gradeName: string
/** 科室 */
depName: string
/** 职称 */
positionalTitles: string
/** 是否关注,0 未关注 1 已关注 */
likeFlag: 0 | 1
/** 接诊服务费 */
serviceFee: number
/** 接诊人数 */
consultationNum: number
/** 评分 */
score: number
/** 主攻方向 */
major: string
}接口
URL:
/home/page/doc类型:
GETtoken:携带
参数:
ts{ current: number pageSize: number }返回数据:

渲染数据
1、在 services/home.ts 中发送网络请求

2、在组件中调用请求方法,获取医生列表数据

3、遍历并传递数据到 doctor-card 中

4、渲染数据到 doctor-card 中


功能
调整卡片间距
需求:需要调整 van-swipe-item 宽度,让卡片更加紧凑

实现:

清除无限滚动

清除指示器

适配滑动宽度@


依赖包:vueuse
安装:
pnpm i @vueuse/core宽度公式: 375 / 150 = 设备宽度 / x,设备宽度可以通过 useWindowSize() 响应式获取。
实现:
1、通过 vueuse 的 useWindowSize() 方法响应式获取设备宽度

2、响应式设置 van-swipe 的宽度

扩展:使用原生的方式实现

关注医生


接口
URL:
/like类型:
POSTtoken:携带
参数:
ts{ type: string // topic: 百科话题, knowledge: 百科文章, doc: 医生, disease: 疾病 id: string // 对应的id }返回数据:

TS类型

实现关注
1、在 services/home.ts 中发送网络请求

2、在组件中,当点击关注/取消关注按钮时,调用请求方法实现关注/取消关注功能


关注文章
封装关注逻辑@


1、将关注逻辑封装到 hooks/index.ts 中

2、在 doctor-card 组件中导入并使用封装的hook


实现关注文章
1、修改封装的 useFollow() 方法,添加type参数,修改item类型

2、在 knowledge-card 组件中,使用 useFollow() 方法,实现关注文章

