温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片!
1. 项目背景与意义
随着医疗信息化建设的不断深入,二级甲等(二甲)医院在日常行政办公中仍普遍存在流程繁琐、信息孤岛、文件流转效率低等问题。办公室作为医院行政运转的中枢,承担着公文收发、会议管理、值班安排、通知公告、档案归档等大量事务性工作。传统的人工登记、纸质流转和电话通知方式不仅耗时费力,还容易出现信息遗漏和追溯困难。
设计并开发一套面向二甲医院办公室的信息系统,具有以下现实意义:
- 提升办公效率:将公文流转、会议通知、值班排班等流程线上化,减少人工传递环节,缩短审批周期。
- 规范管理流程:通过统一的信息化平台固化工作流程,明确各岗位职责,减少人为随意性。
- 保障信息安全:建立分级权限管理和操作日志,确保敏感行政文件仅限授权人员查阅,满足医院信息安全合规要求。
- 降低运营成本:减少纸张、打印和通信费用,同时降低文件归档和检索的人力成本。
- 辅助管理决策:通过对公文量、会议频次、值班记录等数据的统计分析,为院办管理提供数据支撑。
2. 系统需求分析
2.1 用户角色分析
根据医院办公室的实际业务分工,系统用户可分为以下四类角色:
| 角色 | 主要职责 | 核心权限 |
|---|---|---|
| 系统管理员 | 系统配置、用户管理、权限分配 | 全部模块的配置与维护权限 |
| 办公室主任 | 公文审批、会议审批、综合管理 | 审批、统计、导出、归档 |
| 办公文员 | 公文起草、会议通知、值班排班 | 起草、提交、编辑、查询 |
| 普通职工 | 查看通知、接收会议邀请、查看值班表 | 只读查询、确认反馈 |
2.2 功能需求
系统核心功能模块包括:
- 公文管理:支持公文的起草、审核、签发、分发、归档全流程管理,记录流转痕迹。
- 会议管理:会议室预约、会议通知发布、参会回执、会议纪要归档。
- 值班管理:值班排班、值班调换申请、值班提醒与记录查询。
- 通知公告:院内通知的发布、置顶、撤回与已读统计。
- 档案管理:行政文件的分类归档、关键词检索、借阅登记。
- 系统管理:用户管理、角色权限、操作日志、数据备份。
3. 技术栈选型
3.1 总体架构
系统采用 B/S(浏览器/服务器)架构,前后端分离开发,部署于医院内网服务器。总体架构分为表现层、应用层、服务层和数据层四个层次。
flowchart TD A[浏览器客户端] --> B[Nginx 反向代理] B --> C[前端应用 Vue3 + Element Plus] C --> D[后端服务 Spring Boot] D --> E[业务模块: 公文/会议/值班/通知/档案] E --> F[数据访问 MyBatis-Plus] F --> G[(MySQL 数据库)] D --> H[Redis 缓存] D --> I[MinIO 文件存储]3.2 后端技术栈
| 技术 | 版本 | 用途说明 |
|---|---|---|
| Java | JDK 17 | 开发语言,长期支持版本 |
| Spring Boot | 3.2.x | 应用框架,快速构建 RESTful 服务 |
| MyBatis-Plus | 3.5.x | ORM 框架,简化数据库操作 |
| MySQL | 8.0 | 关系型数据库,存储业务数据 |
| Redis | 7.x | 缓存会话、验证码、热点数据 |
| MinIO | 最新稳定版 | 对象存储,保存附件和公文扫描件 |
| Spring Security | 6.x | 认证与授权框架 |
| JWT | 0.12.x | 无状态令牌认证 |
3.3 前端技术栈
| 技术 | 版本 | 用途说明 |
|---|---|---|
| Vue | 3.4.x | 前端渐进式框架 |
| Vite | 5.x | 构建工具,开发体验好 |
| Element Plus | 2.7.x | UI 组件库,适配后台管理系统 |
| Pinia | 2.x | 状态管理 |
| Axios | 1.x | HTTP 请求库 |
| ECharts | 5.x | 数据可视化图表 |
4. 数据库设计
4.1 核心表结构
系统共设计 12 张业务表,以下列出三个核心表的结构。
用户表(sys_user)
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号', password VARCHAR(100) NOT NULL COMMENT '加密密码', real_name VARCHAR(50) NOT NULL COMMENT '真实姓名', department VARCHAR(100) COMMENT '所属科室', phone VARCHAR(20) COMMENT '联系电话', role_id BIGINT COMMENT '角色ID', status TINYINT DEFAULT 1 COMMENT '状态: 1启用 0禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) COMMENT '系统用户表';公文表(doc_document)
CREATE TABLE doc_document ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', doc_no VARCHAR(50) NOT NULL COMMENT '公文编号', title VARCHAR(200) NOT NULL COMMENT '公文标题', content TEXT COMMENT '公文正文', doc_type VARCHAR(20) COMMENT '公文类型: 通知/请示/报告/批复', status TINYINT DEFAULT 0 COMMENT '状态: 0草稿 1待审 2已签发 3已归档', creator_id BIGINT COMMENT '起草人ID', approver_id BIGINT COMMENT '审批人ID', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', archive_time DATETIME COMMENT '归档时间' ) COMMENT '公文表';会议表(mt_meeting)
CREATE TABLE mt_meeting ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', title VARCHAR(200) NOT NULL COMMENT '会议主题', location VARCHAR(100) COMMENT '会议地点', start_time DATETIME NOT NULL COMMENT '开始时间', end_time DATETIME COMMENT '结束时间', host_id BIGINT COMMENT '主持人ID', content TEXT COMMENT '会议内容', status TINYINT DEFAULT 0 COMMENT '状态: 0待召开 1已召开 2已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) COMMENT '会议表';5. 核心功能实现
5.1 后端核心代码
公文审批流程控制
@Service public class DocumentService { @Autowired private DocumentMapper documentMapper; @Autowired private DocumentLogMapper logMapper; /** 提交公文审批 */ @Transactional(rollbackFor = Exception.class) public void submitForApproval(Long docId, Long approverId) { Document doc = documentMapper.selectById(docId); if (doc == null) { throw new BusinessException("公文不存在"); } if (!"草稿".equals(doc.getStatus())) { throw new BusinessException("当前状态不可提交审批"); } doc.setStatus("待审"); doc.setApproverId(approverId); documentMapper.updateById(doc); // 记录操作日志 DocumentLog log = new DocumentLog(); log.setDocId(docId); log.setAction("提交审批"); log.setOperatorId(SecurityUtils.getCurrentUserId()); logMapper.insert(log); } /** 审批公文 */ @Transactional(rollbackFor = Exception.class) public void approve(Long docId, boolean pass, String comment) { Document doc = documentMapper.selectById(docId); if (doc == null) { throw new BusinessException("公文不存在"); } if (!"待审".equals(doc.getStatus())) { throw new BusinessException("公文不在待审状态"); } doc.setStatus(pass ? "已签发" : "已驳回"); documentMapper.updateById(doc); DocumentLog log = new DocumentLog(); log.setDocId(docId); log.setAction(pass ? "审批通过" : "审批驳回"); log.setComment(comment); log.setOperatorId(SecurityUtils.getCurrentUserId()); logMapper.insert(log); } }值班排班算法
@Service public class DutyScheduleService { @Autowired private DutyMapper dutyMapper; /** 自动生成月度值班表 采用轮询算法,尽量均衡分配值班次数 */ public List<DutySchedule> generateMonthlySchedule( List<Long> userIds, int year, int month) { List<DutySchedule> result = new ArrayList<>(); int daysInMonth = getDaysInMonth(year, month); int userCount = userIds.size(); if (userCount == 0) { throw new BusinessException("值班人员列表为空"); } // 记录每人当月已值班次数,用于均衡分配 Map<Long, Integer> dutyCount = new HashMap<>(); for (Long uid : userIds) { dutyCount.put(uid, 0); } for (int day = 1; day <= daysInMonth; day++) { // 选择当前值班次数最少的人员 Long selected = userIds.stream() .min(Comparator.comparingInt(dutyCount::get)) .orElseThrow(() -> new BusinessException("无法选择值班人员")); DutySchedule schedule = new DutySchedule(); schedule.setUserId(selected); schedule.setDutyDate(LocalDate.of(year, month, day)); schedule.setDutyType(isWeekend(year, month, day) ? "节假日" : "工作日"); result.add(schedule); dutyCount.put(selected, dutyCount.get(selected) + 1); } return result; } private boolean isWeekend(int year, int month, int day) { DayOfWeek dow = LocalDate.of(year, month, day).getDayOfWeek(); return dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY; } private int getDaysInMonth(int year, int month) { return YearMonth.of(year, month).lengthOfMonth(); } }JWT 认证过滤器
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtTokenProvider tokenProvider; @Autowired private UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token = resolveToken(request); if (token != null &amp;&amp; tokenProvider.validateToken(token)) { String username = tokenProvider.getUsername(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails( new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } chain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer = request.getHeader("Authorization"); if (bearer != null && bearer.startsWith("Bearer ")) { return bearer.substring(7); } return null; } }5.2 前端核心代码
Axios 请求封装
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器:附加 JWT 令牌 service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = Bearer ${token} } return config }, error => { return Promise.reject(error) }) // 响应拦截器:统一处理错误 service.interceptors.response.use(response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') ElMessage.warning('登录已过期,请重新登录') } else { ElMessage.error('网络异常,请稍后重试') } return Promise.reject(error) }) export default service公文列表页面
<template> <div class="document-list"> <el-card> <div class="toolbar"> <el-input v-model="query.keyword" placeholder="搜索公文标题或编号" clearable style="width: 260px" @keyup.enter="loadData" /> <el-select v-model="query.status" placeholder="状态" clearable style="width: 140px"> <el-option label="草稿" value="草稿" /> <el-option label="待审" value="待审" /> <el-option label="已签发" value="已签发" /> <el-option label="已归档" value="已归档" /> </el-select> <el-button type="primary" @click="loadData">查询</el-button> <el-button type="success" @click="openCreateDialog">新建公文</el-button> </div> <el-table :data="list" v-loading="loading" border stripe> <el-table-column prop="docNo" label="公文编号" width="140" /> <el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip /> <el-table-column prop="docType" label="类型" width="100" /> <el-table-column prop="status" label="状态" width="100"> <template #default="{ row }"> <el-tag :type="statusTagType(row.status)">{{ row.status }}</el-tag> </template> </el-table-column> <el-table-column prop="createTime" label="创建时间" width="170" /> <el-table-column label="操作" width="220" fixed="right"> <template #default="{ row }"> <el-button size="small" @click="viewDetail(row