实战|SpringBoot+Vue3 医院智能预约挂号系统(含 AI 助手)
我们团队基于 SpringBoot+Vue3 技术栈打造了「灵码医院智能预约挂号系统」,针对传统挂号模式中信息孤岛、号源垄断、候诊漫长等痛点问题有效解决。本文从需求分析到部署上线,拆解全流程开发细节、核心技术实现与踩坑解决方案,附带关键代码示例与架构设计图。
一、项目核心亮点
1. 技术栈选型
| 层面 | 核心技术 | 选型理由 |
|---|---|---|
| 前端 | Vue3+Element Plus+Vuex+Vue Router+Axios+Echarts | 组件化开发效率高,Element Plus 适配医疗场景 UI,Echarts 支持多维数据可视化 |
| 后端 | SpringBoot2.7+MyBatis+MyBatis-Plus+Spring Security+Redis | 快速开发无配置冗余,MyBatis-Plus 简化 CRUD,Redis 缓存热点号源提升响应速度 |
| 数据库 | MySQL8.0(主从复制)+ MongoDB | MySQL 保障事务一致性,MongoDB 存储非结构化就诊记录,适配高并发访问 |
| 智能模块 | 通义千问 Max API + 自然语言处理(NLP) | 实现智能挂号助手,支持语音交互与自然语言查询,降低老年患者使用门槛 |
| 开发 / 测试 | IDEA+VS Code+Postman+Knife4j+Git | 接口文档自动生成,团队协作高效,测试工具覆盖全流程验证 |
2. 核心创新功能
- AI 智能挂号助手:自然语言交互(例:"挂呼吸内科张医生明天上午的号"),自动解析需求并引导完成预约,支持语音输入;
- 动态号源调度:引入强化学习算法,基于历史就诊数据预测号源需求,专家号利用率提升 38%;
- 适老化设计:语音交互 + 大字体界面 + 代预约功能,解决老年患者操作障碍;
- 多维数据可视化:医院管理员通过 Echarts 查看预约趋势、科室流量、医生工作量,支持决策分析;
- 安全机制:基于 Spring Security 的 RBAC 权限控制,用户密码 BCrypt 加密,患者隐私数据脱敏存储。
二、系统架构设计
1.系统架构图

2. 功能模块图
3.流程图
4. 核心架构设计代码示例(后端配置使用若依框架)
(1)SpringBoot 核心配置(application.yml)
# 项目相关配置
ruoyi:# 名称name: RuoYi# 版本version: 3.9.0# 版权年份copyrightYear: 2025# 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)profile: D:/ruoyi/uploadPath# 获取ip地址开关addressEnabled: false# 验证码类型 math 数字计算 char 字符验证captchaType: math# 开发环境配置
server:# 服务器的HTTP端口,默认为8080port: 8081servlet:# 应用的访问路径context-path: /tomcat:# tomcat的URI编码uri-encoding: UTF-8# 连接数满后的排队数,默认为100accept-count: 1000threads:# tomcat最大线程数,默认为200max: 800# Tomcat启动初始化的线程数,默认值10min-spare: 100# 日志配置
logging:level:com.ruoyi: debugorg.springframework: warn# 用户配置
user:password:# 密码最大错误次数maxRetryCount: 5# 密码锁定时间(默认10分钟)lockTime: 10# Spring配置
spring:# 资源信息messages:# 国际化资源文件路径basename: i18n/messagesprofiles:active: druid# 文件上传servlet:multipart:# 单个文件大小max-file-size: 10MB# 设置总上传的文件大小max-request-size: 20MB# 服务模块devtools:restart:# 热部署开关enabled: true# redis 配置redis:# 地址host: localhost# 端口,默认为6379port: 6379# 数据库索引database: 0# 密码password:# 连接超时时间timeout: 10slettuce:pool:# 连接池中的最小空闲连接min-idle: 0# 连接池中的最大空闲连接max-idle: 8# 连接池的最大数据库连接数max-active: 8# #连接池最大阻塞等待时间(使用负值表示没有限制)max-wait: -1ms# token配置
token:# 令牌自定义标识header: Authorization# 令牌密钥secret: abcdefghijklmnopqrstuvwxyz# 令牌有效期(默认30分钟)expireTime: 30# MyBatis Plus配置
mybatis-plus:# 搜索指定包别名typeAliasesPackage: com.ruoyi.**.domain# 配置mapper的扫描,找到所有的mapper.xml映射文件mapperLocations: classpath*:mapper/**/*Mapper.xml# 加载全局的配置文件configLocation: classpath:mybatis/mybatis-config.xml# PageHelper分页插件
pagehelper:helperDialect: mysqlsupportMethodsArguments: trueparams: count=countSql# Swagger配置
swagger:# 是否开启swaggerenabled: true# 请求前缀pathMapping: /dev-api# 防止XSS攻击
xss:# 过滤开关enabled: true# 排除链接(多个用逗号分隔)excludes: /system/notice# 匹配链接urlPatterns: /system/*,/monitor/*,/tool/*
(2)前端请求拦截器(request.js)
import axios from 'axios'
import { Notification, MessageBox, Message, Loading } from 'element-ui'
import store from '@/store'
import { getToken } from '@/utils/auth'
import errorCode from '@/utils/errorCode'
import { tansParams, blobValidate } from "@/utils/ruoyi"
import cache from '@/plugins/cache'
import { saveAs } from 'file-saver'let downloadLoadingInstance
// 是否显示重新登录
export let isRelogin = { show: false }axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service = axios.create({// axios中请求配置有baseURL选项,表示请求URL公共部分baseURL: process.env.VUE_APP_BASE_API,// 超时timeout: 10000
})// request拦截器
service.interceptors.request.use(config => {// 是否需要设置 tokenconst isToken = (config.headers || {}).isToken === false// 是否需要防止数据重复提交const isRepeatSubmit = (config.headers || {}).repeatSubmit === falseif (getToken() && !isToken) {config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改}// get请求映射params参数if (config.method === 'get' && config.params) {let url = config.url + '?' + tansParams(config.params)url = url.slice(0, -1)config.params = {}config.url = url}if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {const requestObj = {url: config.url,data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data,time: new Date().getTime()}const requestSize = Object.keys(JSON.stringify(requestObj)).length // 请求数据大小const limitSize = 5 * 1024 * 1024 // 限制存放数据5Mif (requestSize >= limitSize) {console.warn(`[${config.url}]: ` + '请求数据大小超出允许的5M限制,无法进行防重复提交验证。')return config}const sessionObj = cache.session.getJSON('sessionObj')if (sessionObj === undefined || sessionObj === null || sessionObj === '') {cache.session.setJSON('sessionObj', requestObj)} else {const s_url = sessionObj.url // 请求地址const s_data = sessionObj.data // 请求数据const s_time = sessionObj.time // 请求时间const interval = 1000 // 间隔时间(ms),小于此时间视为重复提交if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url) {const message = '数据正在处理,请勿重复提交'console.warn(`[${s_url}]: ` + message)return Promise.reject(new Error(message))} else {cache.session.setJSON('sessionObj', requestObj)}}}return config
}, error => {console.log(error)Promise.reject(error)
})// 响应拦截器
service.interceptors.response.use(res => {// 未设置状态码则默认成功状态const code = res.data.code || 200// 获取错误信息const msg = errorCode[code] || res.data.msg || errorCode['default']// 二进制数据则直接返回if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {return res.data}if (code === 401) {if (!isRelogin.show) {isRelogin.show = trueMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => {isRelogin.show = falsestore.dispatch('LogOut').then(() => {location.href = '/index'})}).catch(() => {isRelogin.show = false})}return Promise.reject('无效的会话,或者会话已过期,请重新登录。')} else if (code === 500) {Message({ message: msg, type: 'error' })return Promise.reject(new Error(msg))} else if (code === 601) {Message({ message: msg, type: 'warning' })return Promise.reject('error')} else if (code !== 200) {Notification.error({ title: msg })return Promise.reject('error')} else {return res.data}},error => {console.log('err' + error)let { message } = errorif (message == "Network Error") {message = "后端接口连接异常"} else if (message.includes("timeout")) {message = "系统接口请求超时"} else if (message.includes("Request failed with status code")) {message = "系统接口" + message.substr(message.length - 3) + "异常"}Message({ message: message, type: 'error', duration: 5 * 1000 })return Promise.reject(error)}
)// 通用下载方法
export function download(url, params, filename, config) {downloadLoadingInstance = Loading.service({ text: "正在下载数据,请稍候", spinner: "el-icon-loading", background: "rgba(0, 0, 0, 0.7)", })return service.post(url, params, {transformRequest: [(params) => { return tansParams(params) }],headers: { 'Content-Type': 'application/x-www-form-urlencoded' },responseType: 'blob',...config}).then(async (data) => {const isBlob = blobValidate(data)if (isBlob) {const blob = new Blob([data])saveAs(blob, filename)} else {const resText = await data.text()const rspObj = JSON.parse(resText)const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default']Message.error(errMsg)}downloadLoadingInstance.close()}).catch((r) => {console.error(r)Message.error('下载文件出现错误,请联系管理员!')downloadLoadingInstance.close()})
}export default service
三、核心功能模块实现(附关键代码)
1. 三大角色权限设计
- 患者:AI 助手挂号、预约查询 / 取消、医生 / 科室信息查询、就诊记录查看;
- 医生:排班管理、预约患者列表、就诊记录录入、个人信息维护;
- 管理员:用户 / 角色权限管控、医院 / 科室 / 医生信息 、号源分配、数据导出与统计。
2. AI 智能挂号助手
(1)后端 AI 服务接口(Assistant.java)
package com.tedu.lingma_agent.assistant;import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
import dev.langchain4j.service.spring.AiService;
import dev.langchain4j.service.spring.AiServiceWiringMode;
import reactor.core.publisher.Flux;@AiService(wiringMode = AiServiceWiringMode.EXPLICIT,streamingChatModel = "qwenStreamingChatModel",chatMemory = "chatMemory",chatMemoryProvider = "chatMemoryProvider",tools = "assistantTools")
public interface Assistant {@SystemMessage(fromResource = "static/my-prompt-template.txt")Flux<String> chat(@MemoryId int memoryId, @UserMessage String message);
}
(2)前端 AI 助手交互组件(ChatWindow.vue)
<template><div class="app-layout"><div class="sidebar"><div class="logo-section"><img src="@/assets/logo.png" alt="挂号小助手" width="120" height="120" /><span style="font-weight: bold;color: #535bf2">挂号小助手</span></div><el-button class="new-chat-button" @click="newChat"><i class="fa-solid fa-plus"></i> 新会话</el-button></div><div class="main-content"><div class="chat-container"><div class="message-list" ref="messaggListRef"><divv-for="(message, index) in messages":key="index":class="message.isUser ? 'message user-message' : 'message bot-message'"><!-- 会话图标 --><i:class="message.isUser? 'fa-solid fa-user message-icon': 'fa-solid fa-robot message-icon'"></i><!-- 会话内容 --><span><span v-html="message.content"></span><!-- loading --><spanclass="loading-dots"v-if="message.isThinking || message.isTyping"><span class="dot"></span><span class="dot"></span></span></span></div></div><div class="input-container"><el-inputv-model="inputMessage"placeholder="请输入消息"@keyup.enter="sendMessage"></el-input><el-button @click="sendMessage" :disabled="isSending" type="primary">发送</el-button></div></div></div></div>
</template><script setup>
import { onMounted, ref, watch } from 'vue'
import axios from 'axios'
import { v4 as uuidv4 } from 'uuid'const messaggListRef = ref()
const isSending = ref(false)
const uuid = ref()
const inputMessage = ref('')
const messages = ref([])onMounted(() => {initUUID()// 移除 setInterval,改用手动滚动watch(messages, () => scrollToBottom(), { deep: true })hello()
})const scrollToBottom = () => {if (messaggListRef.value) {messaggListRef.value.scrollTop = messaggListRef.value.scrollHeight}
}const hello = () => {sendRequest('你好')
}const sendMessage = () => {if (inputMessage.value.trim()) {sendRequest(inputMessage.value.trim())inputMessage.value = ''}
}const sendRequest = (message) => {isSending.value = trueconst userMsg = {isUser: true,content: message,isTyping: false,isThinking: false,}//第一条默认发送的用户消息”你好“不放入会话列表if(messages.value.length > 0){messages.value.push(userMsg)}// 添加机器人加载消息const botMsg = {isUser: false,content: '', // 增量填充isTyping: true, // 显示加载动画isThinking: false,}messages.value.push(botMsg)const lastMsg = messages.value[messages.value.length - 1]scrollToBottom()axios.post('/api/lingma/chat',{ memoryId: uuid.value, message },{responseType: 'stream', // 必须为合法值 "text"onDownloadProgress: (e) => {const fullText = e.event.target.responseText // 累积的完整文本let newText = fullText.substring(lastMsg.content.length)lastMsg.content += newText //增量更新console.log(lastMsg)scrollToBottom() // 实时滚动},}).then(() => {// 流结束后隐藏加载动画messages.value.at(-1).isTyping = falseisSending.value = false}).catch((error) => {console.error('流式错误:', error)messages.value.at(-1).content = '请求失败,请重试'messages.value.at(-1).isTyping = falseisSending.value = false})
}// 初始化 UUID
const initUUID = () => {let storedUUID = localStorage.getItem('user_uuid')if (!storedUUID) {storedUUID = uuidToNumber(uuidv4())localStorage.setItem('user_uuid', storedUUID)}uuid.value = storedUUID
}const uuidToNumber = (uuid) => {let number = 0for (let i = 0; i < uuid.length && i < 6; i++) {const hexValue = uuid[i]number = number * 16 + (parseInt(hexValue, 16) || 0)}return number % 1000000
}// 转换特殊字符
const convertStreamOutput = (output) => {return output.replace(/\n/g, '<br>').replace(/\t/g, ' ').replace(/&/g, '&') // 新增转义,避免 HTML 注入.replace(/</g, '<').replace(/>/g, '>')
}const newChat = () => {// 这里添加新会话的逻辑console.log('开始新会话')localStorage.removeItem('user_uuid')window.location.reload()
}</script>
<style scoped>
.app-layout {display: flex;height: 100vh;
}.sidebar {width: 200px;background-color: #f4f4f9;padding: 20px;display: flex;flex-direction: column;align-items: center;
}.logo-section {display: flex;flex-direction: column;align-items: center;
}.logo-text {font-size: 18px;font-weight: bold;margin-top: 10px;
}.new-chat-button {width: 100%;margin-top: 20px;
}.main-content {flex: 1;padding: 20px;overflow-y: auto;
}
.chat-container {display: flex;flex-direction: column;height: 100%;
}.message-list {flex: 1;overflow-y: auto;padding: 10px;border: 1px solid #e0e0e0;border-radius: 4px;background-color: #fff;margin-bottom: 10px;display: flex;flex-direction: column;
}.message {margin-bottom: 10px;padding: 10px;border-radius: 4px;display: flex;/* align-items: center; */
}.user-message {max-width: 70%;background-color: #e1f5fe;align-self: flex-end;flex-direction: row-reverse;
}.bot-message {max-width: 100%;background-color: #f1f8e9;align-self: flex-start;
}.message-icon {margin: 0 10px;font-size: 1.2em;
}.loading-dots {padding-left: 5px;
}.dot {display: inline-block;margin-left: 5px;width: 8px;height: 8px;background-color: #000000;border-radius: 50%;animation: pulse 1.2s infinite ease-in-out both;
}.dot:nth-child(2) {animation-delay: -0.6s;
}@keyframes pulse {0%,100% {transform: scale(0.6);opacity: 0.4;}50% {transform: scale(1);opacity: 1;}
}
.input-container {display: flex;
}.input-container .el-input {flex: 1;margin-right: 10px;
}/* 媒体查询,当设备宽度小于等于 768px 时应用以下样式 */
@media (max-width: 768px) {.main-content {padding: 10px 0 10px 0;}.app-layout {flex-direction: column;}.sidebar {/* display: none; */width: 100%;flex-direction: row;justify-content: space-between;align-items: center;padding: 10px;}.logo-section {flex-direction: row;align-items: center;}.logo-text {font-size: 20px;}.logo-section img {width: 40px;height: 40px;}.new-chat-button {margin-right: 30px;width: auto;margin-top: 5px;}
}/* 媒体查询,当设备宽度大于 768px 时应用原来的样式 */
@media (min-width: 769px) {.main-content {padding: 0 0 10px 10px;}.app-layout {display: flex;height: 100vh;}.sidebar {width: 200px;background-color: #f4f4f9;padding: 20px;display: flex;flex-direction: column;align-items: center;}.logo-section {display: flex;flex-direction: column;align-items: center;}.logo-text {font-size: 18px;font-weight: bold;margin-top: 10px;}.new-chat-button {width: 100%;margin-top: 20px;}
}
</style>
四、数据库设计
1. 核心表结构(MySQL)
(1)医院表(hospitals)
CREATE TABLE hospitals
(hospital_id INT PRIMARY KEY AUTO_INCREMENT,hospital_name VARCHAR(100) NOT NULL COMMENT '医院名称',hospital_level ENUM ('三级甲等', '三级乙等', '二级甲等', '一级', '其他') NOT NULL COMMENT '医院等级',address VARCHAR(200) NOT NULL COMMENT '地址',phone VARCHAR(20) COMMENT '联系电话',description TEXT COMMENT '医院简介',create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,deleted TINYINT(1) DEFAULT 0 COMMENT '删除标志(0=未删除,1=已删除)'
) COMMENT '医院信息表';
(2)科室表(departments)
CREATE TABLE departments
(dept_id INT PRIMARY KEY AUTO_INCREMENT,dept_name VARCHAR(50) NOT NULL COMMENT '科室名称',hospital_id INT NOT NULL COMMENT '所属医院ID',description TEXT COMMENT '科室简介',create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,deleted TINYINT(1) DEFAULT 0 COMMENT '删除标志(0=未删除,1=已删除)'
) COMMENT '科室信息表';
(3)医生表(doctors)
CREATE TABLE doctors
(doctor_id INT PRIMARY KEY AUTO_INCREMENT,dept_id INT NOT NULL COMMENT '所属科室ID',title ENUM ('主任医师', '副主任医师', '主治医师', '住院医师') NOT NULL COMMENT '职称',specialty VARCHAR(100) COMMENT '擅长领域',work_years INT COMMENT '从业年限',consultation_fee DECIMAL(10, 2) NOT NULL COMMENT '挂号费(元)',introduction TEXT COMMENT '医生简介',create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,deleted TINYINT(1) DEFAULT 0 COMMENT '删除标志(0=未删除,1=已删除)'
) COMMENT '医生信息表';
(4)医生排班表(schedules)
CREATE TABLE schedules
(schedule_id INT PRIMARY KEY AUTO_INCREMENT,doctor_id INT NOT NULL COMMENT '医生ID',date VARCHAR(20) NOT NULL COMMENT '排班日期',morning_start TIME COMMENT '上午开始时间',morning_end TIME COMMENT '上午结束时间',afternoon_start TIME COMMENT '下午开始时间',afternoon_end TIME COMMENT '下午结束时间',night_start TIME COMMENT '夜间开始时间',night_end TIME COMMENT '夜间结束时间',morning_quota INT DEFAULT 0 COMMENT '上午号源数量',afternoon_quota INT DEFAULT 0 COMMENT '下午号源数量',night_quota INT DEFAULT 0 COMMENT '夜间号源数量',morning_remaining INT DEFAULT 0 COMMENT '上午剩余号源',afternoon_remaining INT DEFAULT 0 COMMENT '下午剩余号源',night_remaining INT DEFAULT 0 COMMENT '夜间剩余号源',create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,deleted TINYINT(1) DEFAULT 0 COMMENT '删除标志(0=未删除,1=已删除)'
) COMMENT '医生排班表';
(5)预约表(appointments)
CREATE TABLE appointments
(appointment_id VARCHAR(32) PRIMARY KEY COMMENT '预约表(UUID生成)',patient_name VARCHAR(20) COMMENT '患者名称',id_card VARCHAR(20) COMMENT '身份证号',doctor_name VARCHAR(20) COMMENT '医生名称',hospital_name VARCHAR(20) COMMENT '医院名称',dept_name VARCHAR(20) COMMENT '科室名称',time_slot ENUM ('上午', '下午', '夜间') NOT NULL COMMENT '就诊时段',cancel_reason VARCHAR(200) COMMENT '取消原因',create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '预约时间',update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) COMMENT '预约订单表';
(6)就诊记录表(medical_records)
CREATE TABLE medical_records
(record_id INT PRIMARY KEY AUTO_INCREMENT,appointment_id VARCHAR(32) NOT NULL COMMENT '关联预约订单号',patient_name INT NOT NULL COMMENT '患者名称',doctor_id INT NOT NULL COMMENT '医生ID',diagnosis TEXT COMMENT '诊断结果',treatment_plan TEXT COMMENT '治疗方案',prescription TEXT COMMENT '处方内容',examination_items TEXT COMMENT '检查项目',visit_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '就诊时间',create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间'
) COMMENT '就诊记录表';
2. ER 图核心关系

五、系统效果与性能指标
1.后端登录页面设计

2.后端系统首页设计
3.用户管理页面

4.医院信息管理页面

5.科室信息管理页面

6.医生信息管理页面

7.医生排班信息管理页面

8.预约信息管理页面

9.AI助手挂号页面

六、未来优化方向
- 技术升级:引入微服务架构拆分核心模块,支持多医院接入;
- 功能拓展:集成医保支付、远程问诊、语音识别方便特殊群体功能、电子健康档案功能;
- 智能化深化:基于用户就诊历史推荐医生,通过 AI 算法预测疾病风险;
- 跨平台适配:开发小程序 / APP 版本,提升用户触达率。
尚未修改完善,持续修改完善中。

