Vue3尚医通
一、技术选型
- Vue3 + TS
- Vite
- vue-router
- pinia
- Element-plus
- Axios
二、项目初始化
2.1 使用 vite 创建 Vue3 项目
pnpm create vue@latest(推荐) 或 pnpm create vite
2.2 配置浏览器自动打开
修改 package.json
"scripts": {
"dev": "vite --open", // 自动打开浏览器
"build": "vue-tsc && vite build", // vue-tsc类型检查 vite build编译打包
"preview": "vite preview"
},2.3 src 别名的配置
若使用 pnpm create vue@latest 创建的项目会自动配置
2.4 eslint 和 prettier 配置
若使用 pnpm create vue@latest 创建项目,在创建项目时可以选择添加此配置功能
也可以修改 .prettierrc.json
{
"singleQuote": true, // 单括号
"semi": false,
"bracketSpacing": true, // 括号间距
"htmlWhitespaceSensitivity": "ignore",
"endOfLine": "auto",
"trailingComma": "all",
"tabWidth": 2 // tab键两字符
}和修改 .eslintrc.cjs
// @see https://eslint.bootcss.com/docs/rules/
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
jest: true,
},
/* 指定如何解析语法 */
parser: 'vue-eslint-parser',
/** 优先级低于 parse 的语法解析配置 */
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parser: '@typescript-eslint/parser',
jsxPragma: 'React',
ecmaFeatures: {
jsx: true,
},
},
/* 继承已有的规则 */
extends: [
'eslint:recommended',
'plugin:vue/vue3-essential',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
plugins: ['vue', '@typescript-eslint'],
/*
* "off" 或 0 ==> 关闭规则
* "warn" 或 1 ==> 打开的规则作为警告(不影响代码执行)
* "error" 或 2 ==> 规则作为一个错误(代码不能执行,界面报错)
*/
rules: {
// eslint(https://eslint.bootcss.com/docs/rules/)
'no-var': 'error', // 要求使用 let 或 const 而不是 var
'no-multiple-empty-lines': ['warn', { max: 1 }], // 不允许多个空行
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-unexpected-multiline': 'error', // 禁止空余的多行
'no-useless-escape': 'off', // 禁止不必要的转义字符
// typeScript (https://typescript-eslint.io/rules)
'@typescript-eslint/no-unused-vars': 'error', // 禁止定义未使用的变量
'@typescript-eslint/prefer-ts-expect-error': 'error', // 禁止使用 @ts-ignore
'@typescript-eslint/no-explicit-any': 'off', // 禁止使用 any 类型
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-namespace': 'off', // 禁止使用自定义 TypeScript 模块和命名空间。
'@typescript-eslint/semi': 'off',
// eslint-plugin-vue (https://eslint.vuejs.org/rules/)
'vue/multi-word-component-names': 'off', // 要求组件名称始终为 “-” 链接的单词
'vue/script-setup-uses-vars': 'error', // 防止<script setup>使用的变量<template>被标记为未使用
'vue/no-mutating-props': 'off', // 不允许组件 prop的改变
'vue/attribute-hyphenation': 'off', // 对模板中的自定义组件强制执行属性命名样式
},
}2.5 安装 vite-plugin-vue-setup-extend 插件
为组件指定名字
pnpm i vite-plugin-vue-setup-extend -D
配置 vite.config.ts
.....
// 1.引入
import VueSetupExtend from "vite-plugin-vue-setup-extend"
export default defineConfig({
plugins: [
vue(),
// 2.使用
VueSetupExtend(),
],
.....为组件添加 name属性(在开发者工具中区分不同的组件,且在使用递归组件时必须有name属性)
<script setup lang="ts" name="App">2.6 集成 Element-plus
2.6.1 安装 Element-plus:
pnpm i element-plus
2.6.2 按需引入:
安装
unplugin-vue-components和unplugin-auto-import这两款插件pnpm install -D unplugin-vue-components unplugin-auto-import修改 vite.config.ts
tsimport { defineConfig } from 'vite' // 1.引入插件 import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({、 plugins: [ // 2.使用 AutoImport({ resolvers: [ElementPlusResolver()], }), Components({ resolvers: [ElementPlusResolver()], }), ], })
2.6.3 使用ElementPlus提供的图标组件库
安装
pnpm i @element-plus/icons-vue注册所有图标
main.ts 或 在注册全局组件的插件中添加以下代码
ts// 引入element-plus提供全部的图标组件 import * as ElementPlusIconsVue from '@element-plus/icons-vue' // 将element-plus的图标注册为全局组件 for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) }
2.6.4 element-plus默认使用英语设置为中文
修改 main.ts
import { createApp } from 'vue'
import App from './App.vue'
// 1.引入element-plus插件与样式
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
//@ts-ignore 忽略当前文件ts类型的检测否则有红色提示(打包会失败)
// 2.配置element-plus国际化
import zhCn from 'element-plus/es/locale/lang/zh-cn'
// 获取应用实例对象
const app = createApp(App)
// 3.安装ElementPlus插件,并使用中文语言
app.use(ElementPlus, {
locale: zhCn,
})
// 将应用挂载到 #app 节点
app.mount('#app')2.7 集成sass
2.7.1 安装
pnpm i sass sass-loader -D
2.7.2 清除默认样式
在
scr/styles目录下创建一个index.scss文件,项目中需要用到清除默认样式,因此在index.scss内引入reset.scssscss@import "./reset.scss"在 npm 中搜索
reset.scss复制代码,放在src/reset.scss文件中进行重置样式(可以直接安装 normalize.css 插件并引入,会保留一部分样式)在
main.ts中引入index.scssjs// 引入模板的全局的样式 import "@/styles/index.scss"
但是此时无法在任意组件中使用
$定义的变量
2.7.3 引入全局变量 $
在
styles/variable.scss创建一个variable.scss文件,用于配置全局scss变量(可以在任何一个组件的style标签中使用)scss// 书写的变量可以在任意组件中使用 $base-color:red;配置
vite.config.ts文件:jsexport default defineConfig((config) => { .... css: { preprocessorOptions: { scss: { javascriptEnabled: true, additionalData: '@import "./src/styles/variable.scss";', }, }, }, .... })@import "./src/styles/variable.scss";此文件用于存储全局变量,且最后的;不能少
配置完毕后就可以在任意组件的 style 标签中使用 variable.scss 中定义的全局变量了
2.8 husky 配置
用于强制让开发人员按照代码规范来提交。
husky会在代码提交之前触发git hook(git在客户端的钩子),然后执行提交的代码和提交的备注进行格式检查
2.8.1 安装 husky
pnpm install -D husky
2.8.2 配置提交前格式化
执行:
npx husky-init会在根目录下生成个一个
.husky目录,在这个目录下面会有一个pre-commit文件,这个文件里面的命令在我们执行 commit 的时候就会执行在
.husky/pre-commit文件添加如下命令:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm run format当我们对代码进行commit操作的时候,就会执行命令,对代码进行格式化再提交。
2.8.3 配置提交规范
对于我们使用 git提交的commit 信息,也是有统一规范的,不能随便写,要让每个人都按照统一的标准来执行,我们可以利用commitlint来实现
安装
pnpm add @commitlint/config-conventional @commitlint/cli -D添加配置文件
新建
commitlint.config.cjs添加如下代码:jsmodule.exports = { extends: ['@commitlint/config-conventional'], // 校验规则 rules: { 'type-enum': [ 2, 'always', [ 'feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'revert', 'build', ], ], 'type-case': [0], 'type-empty': [0], 'scope-empty': [0], 'scope-case': [0], 'subject-full-stop': [0, 'never'], 'subject-case': [0, 'never'], 'header-max-length': [0, 'always', 72], }, }配置命令
在
package.json中配置scripts命令js# 在scrips中添加下面的代码 { "scripts": { "commitlint": "commitlint --config commitlint.config.cjs -e -V" }, }配置husky
npx husky add .husky/commit-msg在
husky文件夹下生成的commit-msg文件中添加下面的命令js#!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" pnpm commitlint配置结束,当我们
commit提交信息时,就不能再随意写了,必须是git commit -m 'fix: xxx'符合类型的才可以,前面就需要带着下面的subject
'feat',//新特性、新功能
'fix',//修改bug
'docs',//文档修改
'style',//代码格式修改, 注意不是 css 修改
'refactor',//代码重构
'perf',//优化相关,比如提升性能、体验
'test',//测试用例修改
'chore',//其他修改, 比如改变构建流程、或者增加依赖库、工具等
'revert',//回滚到上一个版本
'build',//编译相关的修改,例如发布版本、对项目构建或者依赖的改动注意:类型的后面需要用英文的
:,并且冒号后面是需要空一格的,这个是不能省略的
三、封装顶部和底部全局组件
在
components文件夹下创建全局组件HospitalTop和HospitalBottom在
components文件夹下创建index.ts用于自定义插件注册全局组件
/compents/index.ts
// 引入全局组件 顶部,底部
import HospitalTop from '@/components/hospital_top/index.vue'
import HospitalBottom from '@/components/hospital_bottom/index.vue'
import type { App } from 'vue'
// 对外暴露插件对象
export default {
install(app: App) {
// install方法自动接收第一个参数app是当前使用Vue实例对象
// 注册全局组件
app.component('HospitalTop', HospitalTop)
app.component('HospitalBottom', HospitalBottom)
}
}- 在
main.ts中引入index.ts自定义插件并使用
// 引入用于注册全局组件的自定义插件
import globalComponent from '@/components'
// 安装自定义插件
app.use(globalComponent)四、SVG 图标使用
1.注册为全局组件
在开发项目的时候经常会用到svg矢量图,如阿里图标库,使用SVG以后,页面上加载的不再是图片资源,这对页面性能来说是个很大的提升,在项目中几乎不占用资源。
安装
vite对SVG 的依赖插件pnpm install vite-plugin-svg-icons -D在
vite.config.ts中配置插件
// 1.引入svg需要用到的插件
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import path from 'path' // 处理路径
export default defineConfig({
plugins: [
vue(),
// 2.使用svg插件
createSvgIconsPlugin({
iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')], // icon图标的路径
symbolId: 'icon-[dir]-[name]',
}),
],
})- 入口文件导入
main.ts
import 'virtual:svg-icons-register'- 封装
SvgIcon组件src/components/SvgIcon
<template>
<!-- svg:图标外层容器节点,内部需要与use标签结合使用 -->
<svg :style="{width,height}">
<!-- 设置图标的大小 -->
<!-- xlink:href="#icon-svg文件名" 指定用哪个图标 -->
<!-- use标签的fill属性可以设置图标的颜色 -->
<use :xlink:href="prefix + name" :fill="color"></use>
</svg>
</template>
<script setup lang="ts">
// 接收父组件传递过来的参数
defineProps({
// xlink:href属性值的前缀
prefix: {
type: String,
default: '#icon-',
},
// 提供使用的图标的名字
name: String,
// 接收父组件传递的颜色
color: {
type: String,
default: '',
},
// 接收父组件传递的尺寸
width: {
type: String,
default: '18px',
},
height: {
type: String,
default: '18px',
},
})
</script>注册为全局组件
components/index.ts
// 引入SVG矢量图全局组件
import SvgIcon from '@/components/SvgIcon/index.vue'
// 对外暴露插件对象
export default {
install(app: App) {
....
// 注册svg组件
app.component('SvgIcon', SvgIcon)
},
}- 使用
先选择需要的图标,然后下载到项目的 src/assets/icons目录下,在组件用使用 <SvgIcon/>组件
<SvgIcon name="文件名" width="xxpx" height="xxpx" color='red'/>
// name:为下载图标的文件名 whith,height:为图标的尺寸 color:为图标的填充颜色2. 直接引入SVG代码使用
[Missing Image: image-20240527190842761.png]
将复制的SVG代码放在指定位置即可
<svg t="1716808050483" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4843" width="200" height="200"><path d="M512 121.6c217.6 0 390.4 172.8 390.4 390.4S729.6 902.4 512 902.4 121.6 729.6 121.6 512 294.4 121.6 512 121.6m0-89.6C246.4 32 32 249.6 32 512s217.6 480 480 480 480-217.6 480-480S774.4 32 512 32z" p-id="4844"></path><path d="M675.2 512H508.8V284.8c0-25.6-19.2-41.6-41.6-41.6H464c-25.6 0-41.6 19.2-41.6 41.6v272c0 25.6 19.2 41.6 41.6 41.6h214.4c25.6 0 44.8-22.4 44.8-44.8s-22.4-41.6-48-41.6z" p-id="4845"></path></svg>五、路由搭建与切换效果
5.1 搭建路由
安装 vue-router:
pnpm i vue-router创建路由器并引入使用
src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
// 创建路由器实例
const router = createRouter({
history: createWebHistory(), // 路由器工作模式为history模式 路由中无#
// 路由规则
routes: [
// 根路由
{
path: '/',
redirect: '/home' // 路由重定向
},
// 首页
{
path: '/home',
name:'home', // 命名路由,便于使用
component: () => import('@/pages/home/index.vue') // 路由懒加载
meat:{ // 路由元信息
title:'首页' // 页面标题
}
},
// 404路由
{
path: '/404',
component: () => import('@/pages/404/index.vue')
},
// 任意路由,当以上路由规则都匹配不上时触发
{
path: '/:pathMatch(.*)*', // 不能使用*通配符
redirect: '/404' // 重定向到404
}
]
})
// 将路由器暴露出去
export default router src/main.ts
// 引入路由器
import router from '@/router'
// 使用路由器
app.use(router)创建路由组件
在
src/pages或src/views文件夹下创建路由组件展示路由组件
App.vue
<template>
<div class="container">
<!-- 顶部全局组件 -->
<HospitalTop></HospitalTop>
<!-- 展示路由组件的区域 -->
<div class="content">
<!-- 展示一级路由组件 -->
<router-view></router-view>
</div>
<!-- 底部全局组件 -->
<HospitalBottom></HospitalBottom>
</div>
</template>5.2 路由滚动行为
每次切换路由时让滚动条回到最顶部
// 创建路由器对象
const router = createRouter({
// 设置路由器工作模式 history模式
history: createWebHistory(),
// 滚动行为
scrollBehavior: () => {
return {
top: 0,
left: 0,
}
},
// 配置路由规则
routes:[]
})5.3 路由切换集成第三方动画
安装animate.css 第三方动画库
pnpm install animate.css在
main.ts引入animate.csstsimport 'animate.css'使用
为
<transition>添加name="animate__animated"并指定进入和离开的动画效果,可以通过为其设置类名控制,持续和延迟时间vue<router-view v-slot="{ Component }"> <transition name="animate__animated" // 进入动画 enter-active-class="animate__fadeIn" // 离开动画 leave-active-class="animate__rotateOut" > <component :is="Component" /> </transition> </router-view>
六、首页
6.1 轮播图组件
<el-carousel
:interval="3000" // 自动切换间隔时间
arrow="hover" // 切换箭头显示的触发方式
trigger="click" // 指示器的触发方式
motion-blur="true"// 添加动态模糊以给走马灯注入活力和流畅性。
height="350px" // 高度
>
<el-carousel-item v-for="item in 4" :key="item"> // 轮播图内的每个元素
<img src="" data-missing="web-banner-1.png" style="width: 100%" />
</el-carousel-item>
</el-carousel>6.2 自动补全输入框组件
使用自动补全输入框组件:根据输入内容提供对应的输入建议,点击建议可以进行路由跳转。
<template>
<!--
v-model:绑定输入框的值
fetch-suggestions:的回调在输入框聚焦和输入停止.3s时执行(防抖延时)
trigger-on-focus:聚焦时是否执行fetch-suggestions的回调
@select:选择建议时触发
-->
<el-autocomplete
v-model="hosname"
:fetch-suggestions="fetchData"
:trigger-on-focus="false"
@select="goDetail"
placeholder="请您输入医院名称"
style="width: 600px; margin-right: 10px"
/>
</template>
<script>
// fetch-suggestions属性的回调函数 当用户输入完毕.3s后执行
// 根据输入的医院关键字获取医院列表,进行展示
// 自动注入两个参数:keyWord:输入框的值 callback:提供展示数据的回调函数
const fetchData = async (keyWord: string, callback: any) => {
let result = await reqHospitalInfo(keyWord)
if (result.code === 200) {
// callback返回用于展示的数组内对象必须有value属性
// 整理数据变成组件需要的格式
let showData = result.data.map((item) => {
return {
value: item.hosname, // value属性用于展示数据
hoscode: item.hoscode, // 存储医院编码用于日后进行路由跳转传参
}
})
// 调用callback函数,将整理好的数据传递给组件进行展示
if (showData.length === 0) {
callback([{ value: '暂无此医院数据' }])
} else {
callback(showData)
}
}
}
// 选择建议时的回调,进行路由跳转到详情页,并携带query参数
// 自动注入当前选择的对象 item:Proxy{value:'北京大学第一医院',hoscode:'1000_2'}
const goDetail = (item: any) => {
// 编程式路由导航进行跳转
router.push({
path: '/hospital',
query: {
hoscode: item.hoscode,
},
})
}
</script>6.3 深度选择器
原因:为style 标签设置了 scoped属性,该组件内所有标签会自动添加一个 data-v-xxxxx 的自定义属性用于样式的选择,不同组件的值不同,实现了样式隔离
作用:处于 scoped 样式中的选择器如果想要做更“深度”的选择,也即:影响到子组件(修改或覆盖第三方库组件样式)
使用:
- 原生CSS:
>>>选择器 - Less:
/deep/选择器 - Sass:
::v-deep(选择器)(推荐) - Vue3提供的深度选择器
:deep()(推荐)
<style scoped lang="scss">
// 用法
:deep(UI组件类名) {
样式
}
// 使用深度选择器修改inputUI组件的样式
::v-deep(.el-input__inner) {
height: 40px;
font-size: 16px;
}
</style>原理:深度选择器的工作原理是通过增加一个高优先级的选择器来确保目标样式能够应用于指定的子组件元素,它能够穿透组件的样式作用域,作用于组件的深层DOM元素上
注意:
- 使用深度选择器时需注意控制样式作用范围为
style标签添加scopd属性,避免不必要的全局样式污染。- 深度选择器可以在其他选择器内使用,只更改当前选择器下
子组件(UI组件)的样式- 深度选择器下可以继续嵌套其他选择器(但不可在深度选择器下嵌套深度选择器)
<style scoped lang="scss">
.container { // 改变container下的卡片样式
:deep(.el-card) {
color: $base-color;
.card-header {
font-size: 20px;
}
.content {
.top {
padding-bottom: 20px;
border-bottom: 1px solid #e4e7ed;
display: flex;
justify-content: space-between;
}
}
}
}
</style>6.4 Axios二次封装
- 安装 Axios:
pnpm i axios - 二次封装的目的:
- 使用请求拦截器,可以在请求拦截器为请求头携带公共参数
token - 使用响应拦截器,可以在响应拦截器,简化服务器返回的数据、处理http网络错误
- 使用请求拦截器,可以在请求拦截器为请求头携带公共参数
在项目根目录下创建 utils/request.ts 用于对 axios 进行二次封装
// 1.引入axios
import axios from 'axios'
import { ElMessage } from 'element-plus'
// 2.使用axios的create方法创建axios实例:可以设置基础路径和超时时间
const request = axios.create({
// baseURL: 'http://syt.atguigu.cn/api', // 基础路径生产环境
baseURL: '/api', // 基础路径 开发环境
timeout: 5000, // 超时时间
})
// 3.为request实例添加请求拦截器
request.interceptors.request.use((config) => {
// config 是请求拦截器注入的请求配置对象
// config.headers属性可以添加请求头token
config.headers.token = localStorage.getItem('token')
return config // 必须返回配置对象
})
// 4.响应拦截器
request.interceptors.response.use(
// 响应拦截器成功回调
(response) => {
// 简化返回的数据,axios返回的为一个对象,data属性内为响应数据
return response.data
},
// 响应拦截器失败回调
(error) => {
// 处理http网络错误 状态码500/400/300
// 获取错误状态码
let status = error.response.status
// 存储错误信息
let message = ''
switch (status) {
case 401:
message = 'Token过期'
break
case 403:
message = '无权访问'
break
case 404:
message = '请求地址错误'
break
case 500:
message = '服务器故障'
break
default:
message = '网络出现问题'
break
}
// 提示错误信息
ElMessage.error(message)
// 返回失败的Promise终结状态
return Promise.reject(new Error(error))
},
)
// 5.对外暴露axios实例
export default request6.5 配置代理跨域
- 修改 vite.config.ts
export default defineConfig({
.....
server: {
proxy: {
'/api': {
// 匹配代理路径 以/api开头的请求路径
// 目标地址
target: 'http://jsonplaceholder.typicode.com',
changeOrigin: true,
// 路径重写(本项目不需要进将请求前缀删除)
// rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})- 重启项目
注意:在修改了
vite.config.ts文件后要重启项目才能生效
6.6 API 接口统一管理
创建src/api 文件夹去统一管理项目的接口;
将所有的请求封装成不同的请求函数,在需要时进行引入调用并传递请求参数,提高代码的复用性
- 不同的文件夹用于存放不同组件的接口地址和请求方法,
index.ts内配置接口地址和请求方法type.ts内定义请求参数和返回数据的TS类型(或将类型声明文件存放到src/types/xxx.d.ts中)
示例:
api/home/index.ts
// 1.引入二次封装后的axios
import request from '@/utils/request'
import type { HospitalResponseData } from './type'
// 2.枚举首页需要的接口地址
enum API {
// 获取已有的医院的接口地址
HOSPITAL_URL = '/hosp/hospital/',
}
// 3.暴露请求方法
export const reqHospital = (page: number, limit: number) => // 需要的参数
// 返回一个promise对象
request.get<any, HospitalResponseData>(API.HOSPITAL_URL + `${page}/${limit}`)api/home/type.ts
// 定义请求通用的返回值类型
export interface ResponseData {
code: number
message: string
ok: boolean
}
// 已有医院的数据类型
export interface Hospital {
id: string // 医院id
param: {
hostypeString: string // 医院类型
fullAddress: string // 医院详细地址
}
hoscode: string // 医院编号
hosname: string // 医院名称
hostype: string // 医院等级
provinceCode: string // 省编码
cityCode: string // 市编码
districtCode: string // 区编码
address: string // 医院地址
logoData: string // 医院logo
intro: string // 医院简介
route: string // 医院路线
status: number // 状态
// 预约规则
bookingRule: {
cycle: number // 预约周期
releaseTime: string // 开始时间
stopTime: string // 结束时间
quitDay: number // 取消预约天数
quitTime: string // 取消预约时间
rule: string[] // 预约规则
}
}
// 全部已有医院数组的类型
export type Content = Hospital[]
// 获取医院列表接口返回的数据类型
export interface HospitalResponseData extends ResponseData {
data: {
content: Content
totalElements: number // 数据总条数
}
}6.7 组件通信:自定义事件
场景:父组件获取子组件中的数据
home组件(父组件)
<template>
<!-- 医院等级组件 -->
<Level @get-level="getLevel" /> // 自定义事件推荐使用kebab-case命名
</template>
<script>
// 自定义事件get-level回调
const getLevel = (level: string) => {
// 存储子组件传递的医院等级值
levelValue.value = level
// 获取医院列表
getHospital()
}
</script>level组件(子组件)
<script>
// 声明接收父组件绑定的自定义事件
const emit = defineEmits(['get-level']) // 使用emit或$emit获取自定义事件
// 点击医院等级的回调
const changeLevel = (level: string) => {
// 修改当前点击的医院的等级值进行高亮显示
levelValue.value = level
// 将level值传递给父组件去获取对应等级医院的数据
emit('get-level', level)
}
</script>七、医院详情
7.1 弹性布局或栅格布局
使用弹性盒子进行布局
vue<template> <div class="hospital"> <!-- 左侧导航区 --> <div class="menu"> 123 <!-- 面包屑导航 --> <!-- 导航菜单 --> </div> <!-- 右侧内容展示区 --> <div class="content">456</div> </div> </template> <style scoped lang="scss"> .hospital { width: 100%; display: flex; // 开启弹性盒 .menu { flex: 2; // 占比总宽度两份 也可指定宽度 } .content { flex: 8; // 占比总宽度八份 } } </style>使用layout布局
共24栏,组件默认使用 Flex 布局,
vue<template> <el-row :gutter="20"> // 栏间隙为20px <el-col :span="6"><div class="menu"></div></el-col> <el-col :span="18"><div class="content"></div></el-col> </el-row> </template>
7.2 导航菜单刷新问题
问题:若设置菜单项的默认激活项为第一项,则在其他菜单项刷新后,激活菜单项会回到第一项
解决办法:
将el-menu的 :default-active(默认激活项) 属性动态绑定为当前路由对象的 path/name属性,同时将el-menu-item的 index(唯一标识)属性设置为路由对象的path/name属性,即:
<el-menu :default-active="route.name"></el-menu>
<el-menu-item v-for="item in menuList" :index="item.pathName"></el-menu-item>
原理:el-menu的默认激活项的值与 el-menu-item的 index属性一致
<template>
<div class="hospital">
<!-- 导航菜单 -->
<el-menu
:default-active="route.name" // 默认激活的菜单项的index
text-color="#7f7f7f"
active-text-color="rgb(116, 200, 233)" // 激活时的颜色
>
<el-menu-item
v-for="item in menuList"
:index="item.pathName" // 每个菜单项的唯一标识
@click="goRoute" // 菜单项的单击事件
>
<el-icon>
<SvgIcon :name="item.svgName" width="18px" height="18px" />
</el-icon>
<span class="title">{{ item.name }}</span>
</el-menu-item>
</el-menu>
</div>
<!-- 右侧内容展示区 -->
<div class="content">
<router-view />
</div>
</div>
</template>
<script setup lang="ts" name="Hospital">
// 获取路由器
const router = useRouter()
// 获取路由对象
const route = useRoute()
// 存储菜单列表的信息
const menuList = reactive([
{ name: '预约挂号', pathName: 'register', svgName: 'book' },
{ name: '医院详情', pathName: 'detail', svgName: 'detail' },
{ name: '预约须知', pathName: 'notice', svgName: 'info' },
{ name: '停诊信息', pathName: 'close', svgName: 'close' },
{ name: '查询/取消', pathName: 'search', svgName: 'search' },
]) // name当前菜单项的名称,pathName当前菜单路由对象的name属性 svgName菜单项图标的name名
// <el-menu :default-active="route.name"></el-menu> 代替以下代码
// 根据当前路径获取当前激活路由的index,防止页面刷新后菜单自动选中第一个菜单的问题
//let activeIndex = ref('register')
// 将当前路径的name属性赋值给activeIndex
//activeIndex.value = route.name as string
// 点击导航菜单的事件回调进行路由跳转Menu-Item的click事件自动注入当前el-menu-item 实例
const goRoute = (vc: any) => {
// 可以从vc上获取到当前组件的index属性
router.push({ name: vc.index })
}
</script>Menu-Item的index属性为当前菜单的唯一标识,可以将其设置为对应的路由组件的name或pathMenu-Item的click事件的回调会自动注入当前组件的实例对象,可以获取到index属性的值,通过index属性的值进行路由跳转
7.3 pinia仓库存储数据
路由组件之间通信无法使用props建议使用pinia
使用pinia仓库存储当前访问医院的全部数据,便于路由组件的使用
安装
piniapnpm i pinia创建
piniasrc/store/index.ts
tsimport { createPinia } from 'pinia' // 创建pinia大仓库 const pinia = createPinia() // 对外暴露pinia大仓库 export default pinia在
main.ts中安装piniatsimport pinia from './store' app.use(pinia)创建存储医院详情的仓库
src/store/modules/hospitalDetails.tsts// 引入定义仓库的方法 import type { Hospital } from '@/api/hospital/type' import type { BookingRule } from '@/api/hospital/type' import { defineStore } from 'pinia' import { ref } from 'vue' // 创建存储医院详情的仓库并暴露 // defineStore("仓库唯一标识",set函数/配置对象) 可以采用组合式API也可采用选项式API写法 const useHospitalDetailsStore = defineStore('hospitalDetails', () => { // 存储医院详情的数据 let hospitalDetails = ref<Hospital>() // 存储医院的预约规则 let bookingRule = ref<BookingRule>() // 将仓库中的数据进行暴露 return { hospitalDetails, bookingRule, } }) // 暴露获取仓库到的方法 export default useHospitalDetailsStore在路由组件中发请求获取数据存储到仓库中
ts// 获取医院详情的仓库 const hospitalDetailsStore = useHospitalDetailsStore() // 获取仓库中的医院数据和预约信息 storeToRefs将store中的数据解构为响应式数据,若不进行解构可以直接使用 hospitalDetailsStore.xxxx const { hospitalDetails, bookingRule } = storeToRefs(hospitalDetailsStore) ...... // 获取当前医院详情并存储到仓库中 const getHospitalDetail = async () => { // 使用前路由query属性中的医院code获取当前医院详情数据 const result = await reqHospitalDetail(route.query.hoscode as string) if (result.code === 200) { // 将医院详情数据存储到仓库中,便于子路由组件的使用 hospitalDetails.value = result.data.hospital bookingRule.value = result.data.bookingRule } else { ElMessage.error('获取医院详情失败') } }(如果在多个组件中都需要调用该方法)可以将获取数据的方法放在仓库中,在组件中通知仓库发请求获取数据,写法如下:
ts// 引入定义仓库的方法 import { reqHospitalDetail } from '@/api/hospital' import type { Hospital } from '@/api/hospital/type' import type { BookingRule } from '@/api/hospital/type' import { ElMessage } from 'element-plus' import { defineStore } from 'pinia' import { ref } from 'vue' // 创建存储医院详情的仓库并暴露 // defineStore("仓库唯一标识",set函数/配置对象) 可以采用组合式API也可采用选项式API写法 const useHospitalDetailsStore = defineStore('hospitalDetails', () => { // 存储医院详情的数据 let hospitalDetails = ref<Hospital>() // 存储医院的预约规则 let bookingRule = ref<BookingRule>() // 获取医院详情的方法,需要接收医院code const getHospitalDetails = async (hosCode: string) => { // 使用前路由query属性中的医院code获取当前医院详情数据 const result = await reqHospitalDetail(hosCode) if (result.code === 200) { // 将医院详情数据存储到仓库中,便于子路由组件的使用 hospitalDetails.value = result.data.hospital bookingRule.value = result.data.bookingRule } else { ElMessage.error('获取医院详情失败') } } // 将仓库中的数据和方法进行暴露 return { hospitalDetails, bookingRule, getHospitalDetails, } }) // 暴露获取仓库到的方法 export default useHospitalDetailsStore
7.4 解决路由切换后刷新获取数据失败问题
问题:在医院详情页刷新无法获取数据
原因:数据存储在 pinia仓库中是非持久化存储的刷新时数据会清空,刷新后会重新发请求获取数, 但路由跳转到医院详情时未传递query参数hoscode值,因此在详情页刷新发请求获无法获取到数据
解决办法:子路由互相跳转时,将hoscode作为query参数进行传递
// 点击菜单路由跳转
const goRoute = (vc: any) => {
// 可以从vc上获取到当前组件的index属性,并携带query参数
router.push({ name: vc.index, query: { hoscode: route.query.hoscode } })
}
onMounted(() => {
// 通知仓库根据hoscode发请求获取医院详情数据并存储到仓库中
getHospitalDetails(route.query.hoscode as string)
})7.5 控制窗口的滚动
隐藏滚动条
css.departmentInfo { flex: 1; height: 100%; overflow: auto; // 超出部分显示滚动条 // 隐藏滚动条 &::-webkit-scrollbar { display: none; } }scrollIntoView让指定元素滚动到父元素的指定位置element.scrollIntoView({ behavior: 'smooth', block: 'start' })element:要滚动的元素behavior:滚动的动画smooth instantblock:滚动到指定的位置strat,center、end
vue<template> <!-- 科室展示 --> <div class="department"> <!-- 左侧一级科室导航 --> <div class="leftNav"> <ul> <!-- 动态绑定类名显示激活状态 --> <li :class="{ active: currentIndex == index }" v-for="(item, index) in hospitalDepartment" :key="item.depcode" @mouseenter="changeDepartment(index)" > {{ item.depname }} </li> </ul> </div> <!-- 展示科室信息 --> <div class="departmentInfo"> <div class="showDepartment" v-for="department in hospitalDepartment" :key="department.depcode" > <!-- 展示一级科室 --> <h1 class="department_title">{{ department.depname }}</h1> <!-- 展示二级科室 --> <ul> <li v-for="item in department.children" :key="item.depcode"> {{ item.depname }} </li> </ul> </div> </div> </div> </template> <script> // 鼠标处于左侧导航标题的回调 const changeDepartment = (index: number) => { // 存储当前鼠标处于左侧导航的索引,用于高亮显示 currentIndex.value = index // 获取右侧展示区的全部h1 let allH1 = document.querySelectorAll('.department_title') // 将对应的h1滚动到顶部 behavior:过渡动画效果 block:滚动到的位置默认为起始位置 allH1[index].scrollIntoView({ behavior: 'smooth', block: 'start' }) } </script>
小优化:获取动态渲染标签的实例对象数组
ref的函数式写法,在回调函数中接收当前标签/组件的实例对象,将其存储到数组中
<template>
<!-- 科室展示区 -->
<div class="departmentInfo">
<!-- 遍历所有科室 -->
<div
class="showDepartment"
v-for="(department, index) in hospitalDepartment"
:key="department.depcode"
>
<!-- 展示一级科室 -->
<h1
:ref="(vc: any) => (allDepartmentRef[index] = vc)"
class="department_title"
:class="{ dark: dark }"
>
{{ department.depname }}
</h1>
<!-- 展示二级科室 -->
<ul>
<li
v-for="item in department.children"
:key="item.depcode"
@click="register(item)"
>
{{ item.depname }}
</li>
</ul>
</div>
</div>
</template>
<script>
// 获取所有的一级科室实例对象
const allDepartmentRef = ref<any[]>([])
// 鼠标处于左侧导航标题的回调
const changeDepartment = (index: number) => {
// 存储当前鼠标处于左侧导航的索引,用于高亮显示
currentIndex.value = index
// 获取右侧展示区的全部h1(通过ref进行获取)
// let allH1 = document.querySelectorAll('.department_title')
// 将对应的h1滚动到顶部 behavior:过渡动画效果 block:滚动到的位置默认为起始位置
allDepartmentRef.value[index].scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
</script>八、登录
8.1 手机验证码登录
1.业务逻辑
输入手机号校验通过后
点击获取验证码:向服务器端发请求,将手机号带给服务器,服务器向该手机号发送验证码
点击登录:表单校验通过后,携带用户手机号和验证码向服务器发请求,获取用户信息和Token
2.表单校验规则(正则匹配)
方法一、通过patter属性可以实现正则校验,代替自定义校验规则(只适用于逻辑简单只需要正则匹配)
const rules = reactive({
tel: [
// 要校验的属性值
{
required: true, // 必须校验
message: '手机号不能为空', // 校验不通过提示信息
trigger: 'change', // 触发校验的时机 change:文本发生变化,blur:失焦时触发
},
{
pattern: /^(?:(?:\+|00)86)?1\d{10}$/, // 正则校验
message: '手机号格式不正确',
trigger: 'change',
},
],
} 方法二、自定义校验规则 validator(适用于较于复杂的表单验证)
// 自定义校验规则 rule:表单校验规则对象 value:文本内容 callback:回调函数
const validatePass = (rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请输入密码')) //校验不通过时callback函数返回错误信息
} else {
// 正则校验
if (/^\S*(?=\S{6,})(?=\S*\d)(?=\S*[A-Z])(?=\S*[a-z])(?=\S*[!@#$%^&*? ])\S*$/.test(value)) {
callback() // 校验通过使用callback函数放行
}else{
callback(new Error('密码格式不对'))
}
}
// 校验规则对象
const rules = reactive({
pass: [{
validator: validatePass, // 自定义校验规则
trigger: 'blur'
}]
})3.输入框中显示校验结果反馈图标
当表单校验通过时,显示状态图标
方法一、使用 from组件的 status-icon属性(使用简单)
<el-form
status-icon
> 方法二、使用 input 组件的 suffix插槽(可自定义图标)
<template #suffix>
<SvgIcon // icon图标
// 当校验通过时展示
v-show="loginParams.code.length == 6"
name="check"
width="14px"
height="14px"
/>
</template>4.提交前校验&清空校验结果
1.提交前校验
虽然使用了表单校验,但校验不通过时依然可以点击登录按钮发送请求
form组件实例对象上的方法 validate可以校验指定/全部的表单项,返回一个Promise对象,当状态为成功时才能继续向下执行
<el-form ref="formRef" :model="loginParams" :rules="rules">
<script>
// 获取表单的ref
let formRef = ref()
// 登录前进行表单验证
await formRef.value.validate()
// ..... 发请求
</script>2.清空校验结果
每次显示登录对话框时,清空上次残留的用户信息,和表单校验的错误提示
form组件的 clearValidate方法可以移除该表单项的校验结果
将收集到的表单的值重新赋值为初始值,即可实现清空用户输入的信息
实现方法一、在对话框关闭/打开时清空
实现方法二、监视控制对话框组件显示/隐藏的属性,当属性值发生变化时清空
js// 监视showLogin属性当其发生变化时清空表单数据并清除上次的表单验证 watch( () => showLogin.value, () => { // 等组件渲染完毕后清空表单数据,否则获取不到表单的实例对象 nextTick(() => { Object.assign(loginParams, { tel: '', code: '', }) // 清除上次的表单验证 formRef.value.clearValidate() // 清除上次接收到的的验证码 trueCode.value = '' }) }, )方法三、控制对话框的显示与隐藏时使用
v-if关闭对话框时可以销毁login组件,需要展示时再重新进行挂载(推荐)vue<!-- 登录组件 --> <Login v-if="showLogin" /> <script> // 获取用户仓库中的数据 showLogin控制对话框的显示与隐藏 const { showLogin } = storeToRefs(useUserStore()) </script>注意:虽然组件被销毁了,但之前收集的数据可能并没有被清空,因此还需要手动进行清除
5.获取验证码加载与倒计时效果
加载效果
使用
el-button组件的loading属性或loading插槽vue<el-button type="primary" :loading-icon="自定义记载图标" loading>Loading</el-button>倒计时效果
登录组件
vue<!-- timeFlag用于控制是否显示倒计时组件 --> <span v-if="!timeFlag">获取验证码</span> <!-- 倒计时组件 --> <CountDown v-else :timeFlag="timeFlag" @change-time="timeFlag = false" />倒计时组件
vue<template> <div> <span style="color: rgb(116, 200, 233)">获取验证码({{ time }}s)</span> </div> </template> <script setup lang="ts" name="CountDown"> import { ref, watch } from 'vue' // 定义倒计时的时间 let time = ref(30) // 接收父组件传递的是否处于倒计时的状态 let props = defineProps(['timeFlag']) // 声明父组件绑定的自定义事件 let emit = defineEmits(['change-time']) // 监听付组件传递的props数据的变化 watch( () => props.timeFlag, (newValue) => { // 处于倒计时状态 if (newValue) { time.value = 30 // 开启定时器 let timer = setInterval(() => { if (time.value > 0) { time.value-- } else { // 清除定时器 clearInterval(timer) // 触发自定义事件将newValue变为false 变为非倒计时状态 emit('change-time') } }, 1000) } }, { // 要立即执行一次,否则不会监听到第一次值的变化 immediate: true, }, ) </script>
注意:
- 在倒计时期间按钮禁用,且倒计时结束后重新展示原本的文字
- 在获取到父组件传递过来的数据后,要对数据立即执行一次监视
6. 手机登录与二维码登录切换清除表单数据
使用 v-if 控制 form 表单的切换,使用 v-if可以将组件卸载,组件卸载后下次再挂载时就不会残留上一次的校验结果,但再次挂载时依然会使用上次收集的数据(数据在父组件上),因此需要手动清除上次输入的数据
8.2 微信扫码登录
1.微信扫码登录流程
第一步:请求code
引入微信提供的生成二维码的核心插件(前端)
html<script src="http://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"></script>在需要使用微信登录的地方实例以下JS对象,生成二维码页面(前端)
js// @ts-ignore new WxLogin({ self_redirect: true, //true:手机点击确定后可以在iframe内跳转到redirect_url id: 'login_container', // 显示二维码的容器 appid: '', // 应用的唯一标识,在微信开发平台申请用户成功后获取 scope: 'snsapi_login', // 应用授权的作用域,网页应用目前仅需填写snsapi_login redirect_uri: '', // 授权回调域,用户授权成功后微信服务器向此地址发送code参数 state: '', // 应用服务器重定向的地址,用户点击后跳转的地址,携带用户信息参数 style: 'black', // 二维码样式,黑色/白色 href: '', // 自定义二维码样式链接 })其中 appid,redirect_uri,state 需要后台服务器提供
用户扫码授权成功后,微信服务器向项目后台服务器发请求传递code(用户标识)
第二步:通过code获取 access_toke(用户标识)
- 项目后台服务器携带 code、appid(应用id)、secret(秘钥)向微信服务器发送请求获取 access_token
第三步:通过assess_token 获取用户数据
项目后台服务器携带 assess_token 向微信服务器发请求获取微信用户信息
服务器获取到用户信息,重定向到前端某一个页面,通过query参数将用户信息注入给前端
注意:重定向的路由组件会展示到原本二维码所在的区域
展示用户信息(前端)
2.实施步骤
在 index.html 中引入微信扫码登录需要的核心插件 wxlogin.js
html<script src="http://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"></script>在点击切换登录按钮的回调中添加以下代码
js// 点击切换登录方式的回调 const changeLogin = async () => { // 切换显示的页面 isPhone.value = !isPhone.value // 当切换到二维码登录页面 if (isPhone.value == false) { // 发请求获取微信扫码需要的参数 // 需要携带一个参数:告诉服务器用户授权成功后重定向项目的指定页面(服务器会将用户信息以query参数形式注入该页面) let wxRedirectUri = encodeURIComponent(window.location.origin + '/wxlogin') // 需要对Uri进行编码 let result = await reqWxLogin(wxRedirectUri) // 生成微信扫码登录的二维码页面 // @ts-ignore new WxLogin({ self_redirect: true, //true:手机点击确定后可以在iframe内跳转到redirect_url id: 'login_container', // 显示二维码的容器 appid: result.data.appid, // 应用的唯一标识,在微信开发平台申请用户成功后获取 scope: 'snsapi_login', // 应用授权的作用域,网页引用目前仅需填写snsapi_login redirect_uri: result.data.redirectUri, // 授权回调域,用户授权成功后微信服务器向此地址发送code参数 state: result.data.state, // 应用服务器重定向的地址,用户点击后跳转的地址,携带用户信息参数 style: 'black', // 二维码样式,黑色/白色 href: '', // 自定义二维码样式链接 }) } else { // 清除上次输入的数据 Object.assign(loginParams, { tel: '', code: '', }) } }准备微信扫码授权登录成功后重定向的路由组件
/wxlogin,并进行持久化存储数据vue<script setup lang="ts" name="WxLogin"> import { SET_TOKEN } from '@/utils/toke' import { SET_USER } from '@/utils/user' import { useRoute } from 'vue-router' // 获取路由对象中携带的query参数 const route = useRoute() // 持久化存储用户信息 SET_TOKEN(route.query.token as string) SET_USER(route.query.name as string) // 此路由组件代码执行:说明用户授权成功 // 隐藏重定向展示的页面 let html = document.querySelector('html') as HTMLElement html.style.display = 'none' </script>注意:重定向的路由组件会展示到原本二维码所在的位置
判断是否授权登录成功
生成二维码成功后,每隔一秒查看本地存储是否有数据,判断用户是否已扫码授权登录成功
当本地存储有用户数据:则用户授权登录成功,关闭登录对话框,向仓库中存储用户数据
Login.vue
ts// 生成微信扫码登录的二维码页面 // @ts-ignore new WxLogin({ self_redirect: true, //true:手机点击确定后可以在iframe内跳转到redirect_url id: 'login_container', // 显示二维码的容器 appid: result.data.appid, // 应用的唯一标识,在微信开发平台申请用户成功后获取 scope: 'snsapi_login', // 应用授权的作用域,网页引用目前仅需填写snsapi_login redirect_uri: result.data.redirectUri, // 授权回调域,用户授权成功后微信服务器向此地址发送code参数 state: result.data.state, // 应用服务器重定向的地址,用户点击后跳转的地址,携带用户信息参数 style: 'black', // 二维码样式,黑色/白色 href: '', // 自定义二维码样式链接 }) // 查看本地存储是否有数据,判断用户是否授权登录成功 queryState()判断本地存储是否有用户数据的方法存储在用户仓库中
store/modules/user.ts
js// 查询微信扫码的结果(查看本地存储是否存储数据) const queryState = () => { // 开启定时器每隔一秒:查看本地存储是否拥有用户信息 let timer = setInterval(() => { // 本地存储有用户数据 if (GET_TOKEN()) { // 存储用户数据到仓库中 userName.value = GET_USER() as string token.value = GET_TOKEN() as string // 关闭对话框 showLogin.value = false // 关闭定时器 clearInterval(timer) } }, 1000) }
微信开放平台官网地址 https://open.weixin.qq.com 查看微信扫码登录文档 https://mp.weixin.qq.com/
九、深色模式
如果您只需要暗色模式,只需在 html 上添加一个名为 dark 的类 。
<html class="dark">
<head></head>
<body></body>
</html>只需要如下在项目入口文件修改一行代码:
// main.ts
// 如果只想导入css变量
import 'element-plus/theme-chalk/dark/css-vars.css'1.通过switch开关切换深色模式
<template>
<el-switch
v-model="dark" // 绑定开关的值
inline-prompt // 控制图标/文本显示在开关内
// 设置开关打开的关闭的颜色
style="--el-switch-on-color: #333; --el-switch-off-color: #bbb"
active-text="Dark" // 开关打开时显示的文本
active-action-icon="Moon" // 开关打开时的图标
inactive-text="Light" // 开关关闭时按钮显示的文本
inactive-action-icon="Sunny" // 开关关闭时按钮显示的图标
size="large"
@change="changeDark" // 开关发生改变时触发的事件
></el-switch>
</template
<script>
// 收集开关的值是否为深色模式
let dark = ref(false)
// switch开关的change事件进行暗黑模式的切换
const changeDark = () => {
// 获取HTML根节点 为根节点添加一个类名class="dark"
let html = document.documentElement
// 根据dark的值为html根节点添加或者删除dark类名
dark.value ? (html.className = 'dark') : (html.className = '')
}
</script>注意:
ELementUIPlus提供的深色模式指只针对其自己提供的组件,和没有自定义背景色的元素通过为 html 标签添加类名的方式,不适合设置有背景色的组件(需要手动进行样式覆盖)
2.刷新深色模式不丢失
将控制是否为深色模式的属性进行持久化存储
在组件挂载时,调用一次设置主题颜色的函数
onMounted(() => {
// 初始化主题颜色
changeDark()
})3.当组件设置有背景色时手动进行样式覆盖
当组件内部设置有背景色时需要手动进行样式覆盖
将控制主题色的属性存储到仓库中
store/modules/theme.ts
ts// 用于存储主题色的仓库 const useThemeStore = defineStore('theme', () => { // 控制主题的的属性 let dark = ref(GET_THEME() || false) return { dark } }) export default useThemeStore在组件中为元素动态设置类名
方法一:行内样式
vue<template> <div class="bottom" :style="`background-color:${dark ? 'black' : '#eee'}`"></div> </template> <script> import useThemeStore from '@/store/modules/theme' import { storeToRefs } from 'pinia' // 从仓库中获取控制是否为深色模式的属性 const { dark } = storeToRefs(useThemeStore()) </script>方法二:动态类名(推荐)
为元素动态添加控制深色模式的类名
vue<template> <div class="bottom" :class="{ dark: dark }"> </template> <script> import useThemeStore from '@/store/modules/theme' import { storeToRefs } from 'pinia' // 从仓库中获取控制是否为深色模式的属性 const { dark } = storeToRefs(useThemeStore()) </script> <style scoped lang="scss"> .bottom { width: 100%; height: 30px; background-color: #eee; z-index: -100; &.dark { background-color: black; } } </style>方法三:使用JS实现(不推荐)
vue<script setup lang="ts" name="HospitalBottom"> import useThemeStore from '@/store/modules/theme' import { storeToRefs } from 'pinia' import { onMounted, watch } from 'vue' // 从仓库中获取控制是否为深色模式的属性 const { dark } = storeToRefs(useThemeStore()) // 当主题色切换时动态控制底部背景色 深色/亮色 watch( () => dark.value, (val) => { let bottom = document.querySelector('.bottom') as HTMLElement bottom.style.backgroundColor = val ? 'black' : '#eee' }, ) // 组件挂载时先根据dark属性初始化底部背景色 onMounted(() => { let bottom = document.querySelector('.bottom') as HTMLElement bottom.style.backgroundColor = dark.value ? 'black' : '#eee' }) </script>
十、预约挂号
1. 使用 moment.js 插件格式化时间
安装 :
pnpm i moment引入:
import moment from 'moment'使用:
格式化时间:
moment().format('YYYY-MM-DD HH:mm:ss')获取当前周几
js['日', '一', '二', '三', '四', '五', '六'][moment().day()] // moment().day()返回0-6 moment().format('dddd'); // Saturday在组件挂载时(
onMounted)开启定时器,每个一秒/一分钟更新一次时间,在组件卸载前(onBeforeUnmounted)清除定时器
2.Descriptions 描述列表
elementUIPlus 提供的组件用于列表形式展示多个字段
效果图:
[Missing Image: image-20240603103806958.png]
3. 已选择效果实现
通过对父盒子相对定位,选择效果绝对定位,将选择效果通过 display:none / opacity:0 隐藏到父盒子下方,
通过响应式数据 + 动态类名在合适的时候进行展示
通过旋转、放缩、设置不透明度、增加过渡动画,实现点击激活时的过渡效果
<template>
<!-- 已选择展示的盒子 -->
<div class="confirm" :class="{ active: isSelected }">已选择</div>
</template>
<style scoped lang="scss">
// 选择效果
.confirm {
position: absolute;
width: 50px;
height: 50px;
font-size: 10px;
color: $base-active-color;
text-align: center; // 文字水平居中
line-height: 50px; // 文字垂直居中
border: 1.3px dashed $base-active-color;
border-radius: 50%; // 变为圆
opacity: 0; // 不透明度为零(隐藏)
top: calc(50% - 25px); // 通过定位将其移动到盒子中心
left: calc(50% - 25px);
// 添加激活时的过渡动画
&.active {
opacity: 0.8; // 透明度为0.8让其显示
transform: scale(4) rotate(45deg); // 放大到4倍并旋转45度
transition: all linear 0.3s; // 过渡动画
}
}
</style>
<script setup lang="ts" name="VisitorCard">
// 获取父组件传递的就诊人的信息和当前卡片是否被选选中的标识
defineProps(['patient', 'isSelected'])
</script>父组件,通过就诊人的 id 判断其是否被选择
<!-- 就诊人卡片 -->
<div class="person">
<VisitorCard
v-for="item in allPatient"
:key="item.id"
:patient="item"
:isSelected="currentPatientId == item.id"
// 当前选择的就诊人id与当前就诊人对象的id相同时,为子组件传递isSelected属性且为true
@click="currentPatientId = item.id"
/>
</div>
// 存储当前选择的就诊人的id
let currentPatientId = ref('')4.设置过渡动画:
通过
css添加动态类名css// 添加激活时的过渡动画 &.active { opacity: 0.8; // 透明度为0.8让其显示 transform: scale(4) rotate(45deg); // 放大到4倍并旋转45度 transition: all linear 0.3s; // 过渡动画 }通过
<transition name='xx'></transition>标签包裹要添加过渡动画的元素设置过渡动画类名
.xx-enter-fromxx-enter-active.xx-enter-to实现过渡动画css.confirm-enter-from { transform: scale(1); // 初始状态 } .confirm-enter-active { transition: all linear 0.3s; // 过渡动画 } .confirm-enter-to { opacity: 0.8; transform: scale(4) rotate(45deg); // 结束状态 }
过渡动画由:
v-if或v-show触发
5.calc()函数的使用
calc() 此 CSS 函数允许在声明 CSS 属性值时执行一些计算,适用于单位不一致的计算
scss只适用于单位一致/无单位的计算
可以进行 +、-、*、/ 计算符号两侧要留有空格
top: calc(50% - 100px);注意:+、- 运算符的两边必须有空格
十一、用户模块
1. qrcode插件使用
作用:URL字符串转换为二维码图片地址(即可以通过扫码二维码访问指定URL)
项目中作用:将服务器返回的微信支付URL("weixin://wxpay/bizpayurl?pr=agDyP8oz1")转换为二维码图片的URL("data:image/png;base64,...")
安装 qrcode
pnpm i qrcode引入 qrcode
import QRCode from 'qrcode'使用
imgUrl.value = await QRCode.toDataURL(result.data.codeUrl)QRCode.toDataURL(text)返回一个Promise对象
<template>
<img :src="" data-missing="imgUrl" alt="qrCode" />
</template>
<script>
// 引入qrcode插件,用于生成二维码
// @ts-ignore
import QRCode from 'qrcode'
// 存储将服务器返回的二维码信息转换成的图片的URL
let imgUrl = ref('')
// 获取支付二维码的方法
const getQrCode = async () => {
let result = await reqQrCode(orderId.value)
if (result.code == 200) {
// 将服务器返回的字符串URL("weixin://wxpay/bizpayurl?pr=agDyP8oz1")生成为图片地址("data:image/png;base64,...")
imgUrl.value = await QRCode.toDataURL(result.data.codeUrl)
} else {
ElMessage.error('获取支付二维码失败')
}
}
</script>Tips:服务器返回的微信支付的URL可以直接跳转到微信进行支付,使用
qrcode插件将其链接转换为二维码,采用扫码的方式进行支付
2. 支付业务
点击支付按钮后:
- 显示支付对话框
- 发请求获取支付链接,将支付链接转换为二维码进行展示
- 长轮询支付状态,开启定时器每隔两秒发请求询问服务器是否已支付成功
- 支付成功,关闭支付对话框,清除定时器,重新获取订单状态
- 取消支付,关闭对话框,清除定时器
// 点击支付按钮的回调
const pay = async () => {
// 1.显示支付对话框
dialogVisible.value = true
// 2.发请求获取支付二维码
let result = await reqQrCode(orderId.value)
if (result.code == 200) {
// 将服务器返回的字符串URL("weixin://wxpay/bizpayurl?pr=agDyP8oz1")生成为图片地址("data:image/png;base64,...")
imgUrl.value = await QRCode.toDataURL(result.data.codeUrl)
} else {
ElMessage.error('获取支付二维码失败')
}
// 3.长轮询支付状态(只要支付二维码出现,每隔2秒向服务器询问支付状态一次)
timer.value = setInterval(async () => {
let result = await reqPayStatus(orderId.value)
// 4.支付成功:result.data为true
if (result.code == 200 && result.data == true) {
// 关闭对话框
dialogVisible.value = false
// 提示支付成功
ElMessage.success('支付成功')
// 清除定时器
clearInterval(timer.value)
// 重新获取订单详情数据
getOrderDetail()
}
}, 2000)
}
// 5.取消支付按钮的回调
const cancelPay = () => {
// 关闭支付对话框
dialogVisible.value = false
// 关闭定时器
clearInterval(timer.value)
}3. 上传图片(照片墙)
需求:最多只能上传一张照片,且大小不超过 2MB 格式为jpg/png,上传成功后,以照片墙的显示展示上传成功的照片,并支持预览和删除功能
效果图:
<template>
<!--
action:上传地址
list-type:文件列表的类型(设置为picture-card照片墙可以看到已上传的图片)
multiple:是否支持多选文件
limit:上传文件最大数量
v-model:file-list:展示并收集上传图片列表
before-upload:图片上传前触发的钩子(限制图片类型和大小)
on-success:图片上传成功触发的钩子(获取服务器返回的图片地址,进行存储)
on-exceed:图片超出限制触发的钩子(提示错误信息)
on-preview:点击文件列表中已上传的文件时的钩子(展示图片预览对话框)
on-remove:文件列表移除文件时的钩子(将已收集到的数据删除,会自动移除fileList中的数据)
-->
<el-upload
action="http://syt.atguigu.cn/api/oss/file/fileUpload?fileHost=userAuah"
:limit="1"
v-model:file-list="fileList"
list-type="picture-card"
:on-preview="onPreview"
:before-upload="beforeUpload"
:on-success="onSuccess"
:on-exceed="onExceed"
:on-remove="onRemove"
>
<img
src="" data-missing="auth_example.png
"
style="width: 140px"
/>
<!-- 上传文件类型大小提示 -->
<template #tip>
<div class="el-upload__tip">格式为 jpg/png 图片大小不超过 2MB</div>
</template>
</el-upload>
<!-- 预览对话框 -->
<el-dialog v-model="dialogVisible">
<img
:src="" data-missing="dialogImageUrl"
style="width: 100%; height: 100%"
alt="Preview Image"
/>
</el-dialog>
</template>
<script>
// 存储上传的照片 每个照片对象类型必须为UploadUserFile类型
const fileList = ref<UploadUserFile[]>([])
// 存储查看大图对话框内的照片URL
const dialogImageUrl = ref('')
// 控制查看大图对话框的显示
const dialogVisible = ref(false)
// 图片上传前执行的钩子回调(限制图片类型和大小)
const beforeUpload = (uploadFile: UploadRawFile) => {
// 会将要上传文件作为参数传递,返回false则阻止上传
// 限制图片类型为 jpg/png且大小不超过2MB
if (
['image/jpeg', 'image/png'].includes(uploadFile.type) &&
uploadFile.size <= 2 * 1024 * 1024
) {
return true
} else {
ElMessage.error('上传图片格式不正确或图片大小超过2MB')
return false
}
}
// 图片上传成功的钩子回调(获取服务器返回的图片URL)
const onSuccess = (
response: any,
_uploadFile: UploadFile,
_uploadFiles: UploadFiles,
) => {
// response:服务器返回的数据
// uploadFile:当前上传的文件对象
// uploadFiles:所有上传的文件对象
// 存储服务器返回的图片URL
formData.certificatesUrl = response.data
}
// 文件列表移除图片的回调
const onRemove = () => {
// 删除收集到的图片的数据
formData.certificatesUrl = ''
ElMessage.success('删除成功')
}
// 图片上传超过限制的钩子回调
const onExceed = () => {
ElMessage.error('上传图片不能超过1张')
}
// 预览图片的回调
const onPreview = (uploadFile: UploadFile) => {
// 点击文件列表中已上传的文件时的钩子的回调,会注入当前点击图片对象
// 存储当前点击图片的url
dialogImageUrl.value = uploadFile.url as string
// 显示查看大图对话框
dialogVisible.value = true
}
</script>4. 表单校验
为
el-form添加model、rules、ref [status-icon 状态图标(可选)]这个三个属性,分别用于指定要校验的数据对象、检验规则、和获取form组件实例对象vue<el-form ref="formRef" :model="formData" :rules="rules" status-icon > <script> // 获取表单的ref对象 let formRef = ref() // 存储表单收集到的数据 let formData = reactive<UserAuth>({ certificatesNo: '', // 证件号 certificatesType: '', // 证件类型 certificatesUrl: '', // 身份证照片URL 需要从fileList中获取 name: '', // 用户名 }) </script>为
el-form-item添加prop属性指定要校验的字段名vue<el-form-item label="用户姓名" prop="name">定义表单校验规则
jsconst rules = { name: [{ required: true, message: '请输入姓名', trigger: 'blur' }], certificatesType: [ { required: true, message: '请选择证件类型', trigger: 'blur' }, ], certificatesUrl: [ { required: true, message: '请上传证件' }, ], certificatesNo: [ { required: true, message: '请输入证件号码', trigger: 'blur' }, { pattern: /^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|10|11|12)(?:0[1-9]|[1-2]\d|30|31)\d{3}[\dXx]$/, message: '输入证件号错误', trigger: 'blur', }, ], }Tips:当仅需使用正则进行校验时,可以使用
pattern属性代替自定义校验规则自定义校验规则
js// 自定义校验规则对姓名进行正则校验 const validatorName = (_rule: any, value: any, callback: any) => { // rule:当前字段校验规则对象 // value:当前校验字段的值 // callback:校验成功放行函数、校验失败注入错误信息函数 if (/^(?:[\u4e00-\u9fa5·]{2,16})$/.test(value)) { callback() // 正则校验通过 } else { callback(new Error('请输入合法姓名')) // 正则校验不通过返回错误信息 } } // 自定义校验规则:certificatesType值只能为10/20 const validatorType = (_rule: any, value: any, callback: any) => { if (['10', '20'].includes(value)) { callback() } else { callback(new Error('请选择正确的证件类型')) } } // 校验规则对象 const rules = { // 用户名:必填,符合中国人姓名格式 name: [ { required: true, message: '请输入姓名', trigger: 'blur' }, { validator: validatorName, trigger: 'blur' }, // trigger 触发校验时机 ], // 证件类型:必填,必须是10或20 certificatesType: [ { required: true, message: '请选择证件类型', trigger: 'blur' }, { validator: validatorType, trigger: 'change' }, ], }Tips:相比
pattern属性,自定义校验规则更加灵活,适用于复杂的场景在表单提交前对所有字段进行校验
formRef.value.validate()对整个表单的内容进行验证,接收一个回调函数或返回Promisejs// 表单校验表单 await formRef.value.validate() // 全部字段校验通过返回一个成功的Promise继续执行后面语句,有一个字段校验不通过返回失败的Promise对象后面的语句会停止执行清除上次表单校验遗留的错误提示信息
formRef.value.clearValidate()清理某个字段或全部字段的表单验证信息表单提交成功、表单重写、和返回再次进入时,清除上次残留的校验信息
js// 清除上一次表单验证状态 formRef.value.clearValidate()
Tips:
upload组件不属于表单元素因此表单验证时不会在(blur,change)时自动触发验证和清除校验信息,需要手动进行验证,和清除验证信息
需要在
on-success钩子中清除表单校验信息或进行一次表单校验
clearValidate:清理某个字段的表单验证信息js// 文件上传成功的回调 const onSuccess = ( response: any, _uploadFile: UploadFile, _uploadFiles: UploadFiles, ) => { formData.certificatesUrl = response.data formRef.value.clearValidate('certificatesUrl') //formRef.value.validateFiled("certificatesUrl") }需要在
on-remove钩子中进行一次表单校验
validateField:验证具体的某个字段js// 文件列表移除图片的回调 const onRemove = () => { // 删除收集到的图片的数据 formData.certificatesUrl = '' ElMessage.success('删除成功') // 进行一次表单校验 formRef.value.validateField('certificatesUrl') }
5. JS函数调用(函数声明提升)
JavaScript中可以在定义函数语句上方调用该函数,这是由于函数声明提升的特性和解释型语言的特点共同作用的结果。
函数声明提升:
- JavaScript在执行代码前会进行预处理,将函数声明提升至作用域顶部。
- 这意味着无论函数在哪里声明,都可以在其定义之前调用。
作用域规则:
- JavaScript的作用域规则允许在函数声明的作用域内任意位置调用函数,只要函数在当前作用域内被声明。
调用函数时要先进行声明再调用
jstest() // 报错 const test = () => { console.log("11111"); };在一个A函数中调用B函数不限制B函数要先于A函数声明
jsconst test = () => { test1(); }; const test1 = () => { console.log("11111"); }; test(); // 输出 11111
6. 条件判断显示表格不同行样式
过指定 Table 组件的 row-class-name 属性来为 Table 中的某一行添加类名, 这样就可以自定义每一行的样式了
<template>
<el-table :row-class-name="tableRowClassName" ></el-table> // 为表格添加row-class-name属性
</template>
<script>
// 根据条件为不同的行添加类名
// tableRowClassName回调会注入一个对象(data: { row: any, rowIndex: number }) => string返回一个类名
const tableRowClassName = ({ row }: { row: UserOrder }) => {
if (row.orderStatus == 0) { // 根据每行指定列的数据动态指定该行类名
// 待支付状态
return 'warning-row' // 为返符合条件的行,添加一个类名
} else if (row.orderStatus == 1) {
// 支付成功状态
return 'success-row'
} else {
// 取消支付状态
return 'danger-row'
}
}
</script>
<style scoped lang="scss">
// 设置不同类名的样式
:deep(.el-table) {
.warning-row {
color: #409eff;
font-weight: bold;
}
.success-row {
color: rgb(8, 227, 92);
font-weight: bold;
}
.danger-row {
color: rgb(224, 87, 87);
font-weight: bold;
}
}
</style>7. 动态加载的级联选择器
根据当前级所选选项,动态加载下一级选项的值
<template>
<el-cascader :props="props" placeholder="请选择当前住址" />
</template>
<script>
// 级联选择器配置项
const props: CascaderProps = {
// 1.开启懒加载
lazy: true,
/* lazyLoad加载级联选择器数据的函数
两个参数:node当前点击的节点,resolve数据加载完成的回调
函数在初始时执行一次,选择当前一级的选项时执行,加载下一级选项 */
async lazyLoad(node: any, resolve: any) {
// 2.初始获取一级城市的id为86,以后根据上一级城市的id获取下一级城市
let id = node.data.value || '86' // // node.data.value为空时86,否则取当前节点的id
// 3.获取当前级城市数据
let result = await reqCity(id)
/* 4.整理数据:
nodes(节点)的数据类型必须为{value:当前选项的值,label:标签的值,leaf:是否为叶子节点} */
const nodes = result.data.map((item) => {
return {
label: item.name, // 标签值
leaf: !item.hasChildren, // 是否为叶子节点
value: item.id,// 存储当前节点的id值用于获取下一级节点的值
}
})
// 5.数据加载完成的回调,将数据注入到选择器中
resolve(nodes)
},
}
</script>8.模板字符串
模板字符串内可以为一个js表达式(返回一个值)
${id ? "更新" : "添加"}9. 跳转到来时的路由
场景:从A路由组件跳转到B路由组件,完成业务后自动返回到A路由组件
方法一:将A路由的 fullpath作为 query参数携带到 B路由
// 路由跳转到添加就诊人页面,并将当前路由作为query参数传递
router.push({
name: 'patient',
query: {
redirect: route.fullPath, // 注意一定要使用fullpath 路径中可能存在query参数
},
})
// 返回到来时路由
router.push(route.query.redirect as string) 方法二:router.back() 返回上一个路由
十二、路由鉴权
1. 用户路由权限分析
用户未登录能访问的路由名: home、hospital、register、detail、notice、close、search、wxlogin(用于微信登录的路由)其余路由均不可访问
用户登录全部路由均可访问
根据仓库|本地存储中的token来判断用户是否已经登录
2.实施
创建src/permission.ts 在main.ts引入
3. 进度条使用
安装 nprogress 插件:
pnpm i nprogress引入:
js// 引入进度条插件 // @ts-ignore // 忽略ts检测 import nprogress from 'nprogress' // 引入进度条的样式 import 'nprogress/nprogress.css' // 取消加载的小圆球 nprogress.configure({ showSpinner: false })使用:
全局前置路由守卫:
nprogress.start()开启进度条全局后置路由守卫:
nprogress.done()关闭进度条修改进度条样式
修改
node_modules\nprogress\nprogress.css中的样式,修改后需要重启项目css/* Make clicks pass-through */ #nprogress { pointer-events: none; } #nprogress .bar { /* 加载进度条样式 */ background: #29d; /* background: linear-gradient( to right, rgb(222, 188, 17), rgb(34, 221, 187), #2299dd ); */ position: fixed; z-index: 1031; top: 0; left: 0; width: 100%; /* 高度 */ height: 2px; } /* Remove these to get rid of the spinner 移除以下代码关闭加载小球*/ #nprogress .spinner { display: block; position: fixed; z-index: 1031; top: 15px; right: 15px; } #nprogress .spinner-icon { width: 18px; height: 18px; box-sizing: border-box; border: solid 2px transparent; border-top-color: #29d; border-left-color: #29d; border-radius: 50%; -webkit-animation: nprogress-spinner 400ms linear infinite; animation: nprogress-spinner 400ms linear infinite; }
4. 获取路由器对象
- 在组件内:使用
useRouter()获取路由器对象 - 在组件外的
ts文件内:使用import router from '@/router'获取路由器对象
permission.ts
// 引入路由器对象
import router from '@/router'
// 引入nprogress插件
// @ts-ignore
import nprogress from 'nprogress'
// 引入进度条样式
import 'nprogress/nprogress.css'
import { GET_TOKEN } from './utils/toke'
import { storeToRefs } from 'pinia'
import useUserStore from './store/modules/user'
import pinia from './store'
// 取消加载的小圆球
nprogress.configure({ showSpinner: false })
// 引入用户仓库获取控制登录对话框显示与隐藏的属性showLogin
const { showLogin } = storeToRefs(useUserStore(pinia)) // 在组件外获取仓库对象需要传递参数pinia
// 全局前置路由守卫(路由鉴权,进度条)
router.beforeEach((to, from, next) => {
// to:要跳转的路由对象
// from:当前页面路由对象
// next:放行函数
// 开启进度条
nprogress.start()
// 路由鉴权根据用户仓库/本地存储存储中是否有token
if (GET_TOKEN()) {
// 有token用户已登录 放行
next()
} else {
// 未登录 只可访问指定路由
if (
[
'home',
'hospital',
'register',
'detail',
'notice',
'close',
'search',
].includes(to.name as string)
) {
next()
} else {
// 修改用户仓库中的showLogin显示登录对话框
showLogin.value = true
// 返回当前路由 并将用户要前往的路径作为query参数传递
// 注意:
// 1.要将当前路由原本存在的query参数也传递到当前路由中
// 2.redirect要使用fullPath属性包含path+query参数
next({
name: from.name as string,
query: {
hoscode: from.query.hoscode,
redirect: to.fullPath, // 登录成功后跳转的地址(包括需要携带的query参数)
},
})
// 关闭进度条
nprogress.done()
}
}
})
// 全局后置路由守卫(更改页面标题)
router.afterEach((to) => {
// to:要跳转的路由对象
// from:当前页面路由对象
// 关闭进度条
nprogress.done()
// 设置页面标题
document.title = ('尚医通-' + to.meta.title) as string
})5. 登录成功后路由跳转
login.vue
// 点击登录按钮的回调
const login = async () => {
.......
// 登录成功
// 如果当前路由对象有query参数redirect则跳转到该路由
if (route.query.redirect) {
router.push(route.query.redirect as string)
} else {
// 跳转到首页
router.push('/')
}
}十三、接口地址
服务器地址:http://syt.atguigu.cn 医院接口:http://139.198.34.216:8201/swagger-ui.html 公共数据接口:http://139.198.34.216:8202/swagger-ui.html 会员接口:http://139.198.34.216:8203/swagger-ui.html 短信验证码接口:http://139.198.34.216:8204/swagger-ui.html 订单接口:http://139.198.34.216:8206/swagger-ui.html 文件上传接口:http://139.198.34.216:8205/swagger-ui.html 后台用户接口:http://139.198.34.216:8212/swagger-ui.html