58 KiB
58 KiB
📊 医药管理系统 - 项目文档
📋 项目概述
项目名称
PharmaManagement System - 医药管理系统
技术栈
- 后端: Java 17 + Spring Boot 3.1 + MyBatis Plus 3.5 + MySQL 8.0
- 前端: Vue 3 + TypeScript + Element Plus + Pinia + Vite
- 安全: Spring Security + JWT
- 工具: Maven + Node.js + Docker
系统功能模块
- 用户管理: 登录、权限控制、个人信息管理
- 药品管理: 药品CRUD、库存管理、分类管理
- 销售管理: 销售记录、销售统计、报表生成
- 库存预警: 库存不足预警、补货提醒
- 数据统计: 销售数据可视化、药品销量排行
📁 项目结构
后端项目结构
pharma-backend/
├── src/main/java/com/pharma/
│ ├── PharmaApplication.java # 启动类
│ ├── common/ # 通用组件
│ │ ├── config/ # 配置类
│ │ │ ├── MyBatisConfig.java # MyBatis配置
│ │ │ ├── SecurityConfig.java # 安全配置
│ │ │ ├── CorsConfig.java # 跨域配置
│ │ │ └── RedisConfig.java # Redis配置
│ │ ├── constant/ # 常量类
│ │ │ ├── CacheConstants.java # 缓存常量
│ │ │ └── CommonConstants.java # 通用常量
│ │ ├── exception/ # 异常处理
│ │ │ ├── GlobalExceptionHandler.java # 全局异常处理
│ │ │ └── BusinessException.java # 业务异常
│ │ ├── result/ # 统一返回结果
│ │ │ ├── Result.java # 响应结果封装
│ │ │ └── ResultCode.java # 响应状态码
│ │ ├── utils/ # 工具类
│ │ │ ├── JwtUtil.java # JWT工具
│ │ │ ├── SecurityUtil.java # 安全工具
│ │ │ ├── DateUtil.java # 日期工具
│ │ │ └── ExcelUtil.java # Excel工具
│ │ └── web/ # Web相关
│ │ ├── BaseController.java # 控制器基类
│ │ └── LogAspect.java # 日志切面
│ ├── module/ # 业务模块
│ │ ├── auth/ # 认证模块
│ │ │ ├── controller/ # 控制器
│ │ │ ├── service/ # 服务接口
│ │ │ ├── service/impl/ # 服务实现
│ │ │ ├── mapper/ # Mapper接口
│ │ │ ├── entity/ # 实体类
│ │ │ └── dto/ # 数据传输对象
│ │ ├── user/ # 用户模块
│ │ ├── medicine/ # 药品模块
│ │ ├── category/ # 分类模块
│ │ └── sale/ # 销售模块
│ └── resources/
│ ├── application.yml # 主配置文件
│ ├── application-dev.yml # 开发环境配置
│ ├── application-prod.yml # 生产环境配置
│ ├── mapper/ # XML映射文件
│ └── static/ # 静态资源
├── pom.xml # Maven依赖
└── Dockerfile # Docker配置
前端项目结构
pharma-frontend/
├── public/ # 静态资源
├── src/
│ ├── api/ # API接口
│ │ ├── modules/ # 模块接口
│ │ │ ├── auth.ts # 认证接口
│ │ │ ├── user.ts # 用户接口
│ │ │ ├── medicine.ts # 药品接口
│ │ │ ├── category.ts # 分类接口
│ │ │ └── sale.ts # 销售接口
│ │ └── index.ts # API统一导出
│ ├── assets/ # 静态资源
│ │ ├── styles/ # 样式文件
│ │ └── images/ # 图片资源
│ ├── components/ # 公共组件
│ │ ├── common/ # 通用组件
│ │ │ ├── SearchBar.vue # 搜索组件
│ │ │ ├── Pagination.vue # 分页组件
│ │ │ └── UploadImage.vue # 图片上传
│ │ ├── layout/ # 布局组件
│ │ │ ├── Header.vue # 头部
│ │ │ ├── Sidebar.vue # 侧边栏
│ │ │ └── AppMain.vue # 主内容
│ │ └── charts/ # 图表组件
│ │ ├── SalesChart.vue # 销售图表
│ │ └── InventoryChart.vue # 库存图表
│ ├── router/ # 路由配置
│ │ └── index.ts
│ ├── store/ # Pinia状态管理
│ │ ├── modules/ # 模块store
│ │ │ ├── user.ts # 用户store
│ │ │ ├── medicine.ts # 药品store
│ │ │ └── common.ts # 公共store
│ │ └── index.ts # store入口
│ ├── types/ # TypeScript类型定义
│ │ ├── api.ts # API类型
│ │ ├── medicine.ts # 药品类型
│ │ ├── user.ts # 用户类型
│ │ └── index.ts # 类型导出
│ ├── utils/ # 工具函数
│ │ ├── auth.ts # 认证工具
│ │ ├── request.ts # 请求封装
│ │ ├── validate.ts # 表单验证
│ │ └── date.ts # 日期处理
│ ├── views/ # 页面组件
│ │ ├── login/ # 登录页
│ │ ├── dashboard/ # 仪表板
│ │ ├── medicine/ # 药品管理
│ │ ├── category/ # 分类管理
│ │ ├── sale/ # 销售管理
│ │ ├── user/ # 用户管理
│ │ └── report/ # 报表统计
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── package.json # 项目依赖
├── tsconfig.json # TypeScript配置
├── vite.config.ts # Vite配置
└── Dockerfile # Docker配置
🗄️ 数据库设计详细说明
1. 数据库关系图
erDiagram
tb_user ||--o{ tb_sale_detail : "operates"
tb_medicine ||--o{ tb_sale_detail : "is_sold"
tb_category ||--o{ tb_medicine : "categorizes"
tb_user {
int id PK "主键"
varchar username "用户名"
varchar password "密码"
varchar name "姓名"
char tel "手机号"
datetime create_time "创建时间"
datetime update_time "更新时间"
}
tb_medicine {
int id PK "主键"
varchar med_no UK "药品编码"
varchar name "名称"
varchar manufacturer "厂家"
text description "描述"
decimal price "单价"
int med_count "库存数量"
int req_count "补货阈值"
varchar photo_path "图片路径"
int category_id FK "分类ID"
datetime create_time "创建时间"
datetime update_time "更新时间"
}
tb_sale_detail {
int id PK "主键"
int medicine_id FK "药品ID"
int operator_id FK "操作员ID"
int quantity "销售数量"
decimal unit_price "销售单价"
datetime sale_time "销售时间"
datetime create_time "创建时间"
datetime update_time "更新时间"
}
tb_category {
int id PK "主键"
varchar name UK "分类名称"
text description "描述"
datetime create_time "创建时间"
datetime update_time "更新时间"
}
2. 实体类设计
用户实体 (User)
package com.pharma.module.user.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("tb_user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("username")
private String username;
@TableField("password")
private String password;
@TableField("name")
private String name;
@TableField("tel")
private String tel;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
药品实体 (Medicine)
package com.pharma.module.medicine.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("tb_medicine")
public class Medicine {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("med_no")
private String medNo;
@TableField("name")
private String name;
@TableField("manufacturer")
private String manufacturer;
@TableField("description")
private String description;
@TableField("price")
private BigDecimal price;
@TableField("med_count")
private Integer medCount;
@TableField("req_count")
private Integer reqCount;
@TableField("photo_path")
private String photoPath;
@TableField("category_id")
private Long categoryId;
@TableField(exist = false)
private String categoryName; // 关联查询字段
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
销售明细实体 (SaleDetail)
package com.pharma.module.sale.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("tb_sale_detail")
public class SaleDetail {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("medicine_id")
private Long medicineId;
@TableField("operator_id")
private Long operatorId;
@TableField("quantity")
private Integer quantity;
@TableField("unit_price")
private BigDecimal unitPrice;
@TableField("sale_time")
private LocalDateTime saleTime;
@TableField(exist = false)
private String medicineName; // 关联查询字段
@TableField(exist = false)
private String operatorName; // 关联查询字段
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
分类实体 (Category)
package com.pharma.module.category.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("tb_category")
public class Category {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("name")
private String name;
@TableField("description")
private String description;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
🔧 环境配置
1. 后端环境配置
Maven依赖 (pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.5</version>
<relativePath/>
</parent>
<groupId>com.pharma</groupId>
<artifactId>pharma-management</artifactId>
<version>1.0.0</version>
<name>pharma-management</name>
<description>医药管理系统</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.3.1</mybatis-plus.version>
<jjwt.version>0.11.5</jjwt.version>
<fastjson.version>2.0.38</fastjson.version>
<hutool.version>5.8.21</hutool.version>
</properties>
<dependencies>
<!-- Spring Boot 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- 数据库相关 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.18</version>
</dependency>
<!-- Redis缓存 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JWT认证 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson.version}</version>
</dependency>
<!-- 开发工具 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
配置文件 (application.yml)
# 主配置文件
server:
port: 8080
servlet:
context-path: /api
tomcat:
uri-encoding: UTF-8
spring:
application:
name: pharma-management
profiles:
active: dev
# 数据库配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/pharma_mgmt_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
type: com.alibaba.druid.pool.DruidDataSource
druid:
initial-size: 5
min-idle: 5
max-active: 20
max-wait: 60000
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 300000
validation-query: SELECT 1 FROM DUAL
test-while-idle: true
test-on-borrow: false
test-on-return: false
pool-prepared-statements: true
max-pool-prepared-statement-per-connection-size: 20
# Redis配置
redis:
host: localhost
port: 6379
password:
database: 0
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
servlet:
multipart:
max-file-size: 10MB
max-request-size: 20MB
# MyBatis Plus配置
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
mapper-locations: classpath*:/mapper/**/*.xml
# 自定义配置
pharma:
jwt:
secret: pharma-secret-key-2024-jwt-token-signature
expiration: 86400000 # 24小时
header: Authorization
security:
exclude-paths: /api/auth/login,/api/auth/register,/api/common/captcha,/swagger-ui/**,/webjars/**,/swagger-resources/**,/v2/api-docs,/doc.html
upload:
path: /upload/
max-size: 10MB
allowed-types: jpg,jpeg,png,gif
2. 前端环境配置
package.json
{
"name": "pharma-frontend",
"version": "1.0.0",
"description": "医药管理系统前端",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
},
"dependencies": {
"vue": "^3.3.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"element-plus": "^2.3.0",
"@element-plus/icons-vue": "^2.2.0",
"echarts": "^5.4.3",
"vue-echarts": "^6.6.0",
"dayjs": "^1.11.0",
"lodash": "^4.17.21",
"js-cookie": "^3.0.5",
"vuedraggable": "^4.1.0",
"print-js": "^1.6.0",
"xlsx": "^0.18.5",
"vite-plugin-svg-icons": "^2.0.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"typescript": "^5.0.0",
"vue-tsc": "^1.8.0",
"vite": "^4.5.0",
"@types/node": "^20.0.0",
"@types/lodash": "^4.14.197",
"@types/js-cookie": "^3.0.6",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.45.0",
"eslint-plugin-vue": "^9.17.0",
"unplugin-auto-import": "^0.16.7",
"unplugin-vue-components": "^0.25.2",
"@iconify/json": "^2.2.0"
}
}
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
export default defineConfig({
plugins: [
vue(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
dts: 'src/auto-imports.d.ts',
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
createSvgIconsPlugin({
iconDirs: [resolve(process.cwd(), 'src/assets/icons')],
symbolId: 'icon-[dir]-[name]',
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
open: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: '@use "@/assets/styles/variables.scss" as *;',
},
},
},
})
🚀 核心功能实现
1. 用户认证与授权
JWT工具类
package com.pharma.common.utils;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtUtil {
@Value("${pharma.jwt.secret}")
private String secret;
@Value("${pharma.jwt.expiration}")
private Long expiration;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes());
}
public String generateToken(String username) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
return Jwts.builder()
.setClaims(claims)
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public String getUsernameFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.getSubject();
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
private Claims getClaimsFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
}
Spring Security配置
package com.pharma.common.config;
import com.pharma.common.filter.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/login",
"/api/auth/register",
"/api/common/captcha",
"/swagger-ui/**",
"/v3/api-docs/**"
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
2. 药品管理模块
药品服务实现
package com.pharma.module.medicine.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.pharma.common.exception.BusinessException;
import com.pharma.module.medicine.dto.MedicineDTO;
import com.pharma.module.medicine.entity.Medicine;
import com.pharma.module.medicine.mapper.MedicineMapper;
import com.pharma.module.medicine.service.MedicineService;
import com.pharma.module.medicine.vo.MedicineVO;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class MedicineServiceImpl extends ServiceImpl<MedicineMapper, Medicine>
implements MedicineService {
@Override
public Page<MedicineVO> getMedicinePage(Integer pageNum, Integer pageSize,
String name, Long categoryId) {
Page<Medicine> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<Medicine> queryWrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(name)) {
queryWrapper.like(Medicine::getName, name);
}
if (categoryId != null) {
queryWrapper.eq(Medicine::getCategoryId, categoryId);
}
queryWrapper.orderByDesc(Medicine::getUpdateTime);
Page<Medicine> medicinePage = this.page(page, queryWrapper);
Page<MedicineVO> voPage = new Page<>();
BeanUtils.copyProperties(medicinePage, voPage, "records");
List<MedicineVO> voList = medicinePage.getRecords().stream()
.map(medicine -> {
MedicineVO vo = new MedicineVO();
BeanUtils.copyProperties(medicine, vo);
return vo;
})
.collect(Collectors.toList());
voPage.setRecords(voList);
return voPage;
}
@Override
public void addMedicine(MedicineDTO dto) {
// 检查药品编码是否重复
LambdaQueryWrapper<Medicine> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Medicine::getMedNo, dto.getMedNo());
if (this.count(queryWrapper) > 0) {
throw new BusinessException("药品编码已存在");
}
Medicine medicine = new Medicine();
BeanUtils.copyProperties(dto, medicine);
this.save(medicine);
}
@Override
public void updateMedicine(MedicineDTO dto) {
Medicine medicine = this.getById(dto.getId());
if (medicine == null) {
throw new BusinessException("药品不存在");
}
// 检查药品编码是否与其他记录重复
LambdaQueryWrapper<Medicine> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Medicine::getMedNo, dto.getMedNo())
.ne(Medicine::getId, dto.getId());
if (this.count(queryWrapper) > 0) {
throw new BusinessException("药品编码已存在");
}
BeanUtils.copyProperties(dto, medicine);
this.updateById(medicine);
}
@Override
public void updateStock(Long medicineId, Integer quantity) {
Medicine medicine = this.getById(medicineId);
if (medicine == null) {
throw new BusinessException("药品不存在");
}
int newCount = medicine.getMedCount() + quantity;
if (newCount < 0) {
throw new BusinessException("库存不足");
}
medicine.setMedCount(newCount);
this.updateById(medicine);
}
@Override
public List<MedicineVO> getLowStockList() {
LambdaQueryWrapper<Medicine> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.lt(Medicine::getMedCount, Medicine::getReqCount);
queryWrapper.orderByAsc(Medicine::getMedCount);
List<Medicine> medicines = this.list(queryWrapper);
return medicines.stream()
.map(medicine -> {
MedicineVO vo = new MedicineVO();
BeanUtils.copyProperties(medicine, vo);
return vo;
})
.collect(Collectors.toList());
}
}
MyBatis Mapper接口
package com.pharma.module.medicine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pharma.module.medicine.entity.Medicine;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
@Mapper
public interface MedicineMapper extends BaseMapper<Medicine> {
@Select("SELECT m.*, c.name as category_name " +
"FROM tb_medicine m " +
"LEFT JOIN tb_category c ON m.category_id = c.id " +
"WHERE m.id = #{id}")
Medicine selectWithCategory(Long id);
@Select("SELECT category_id, COUNT(*) as count, SUM(med_count) as total " +
"FROM tb_medicine " +
"GROUP BY category_id")
List<Map<String, Object>> groupByCategory();
@Select("SELECT name, med_count " +
"FROM tb_medicine " +
"ORDER BY med_count ASC " +
"LIMIT 10")
List<Map<String, Object>> getLowStockTop10();
}
3. 销售管理模块
销售服务实现
package com.pharma.module.sale.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.pharma.common.utils.SecurityUtil;
import com.pharma.module.medicine.service.MedicineService;
import com.pharma.module.sale.dto.SaleDTO;
import com.pharma.module.sale.entity.SaleDetail;
import com.pharma.module.sale.mapper.SaleDetailMapper;
import com.pharma.module.sale.service.SaleService;
import com.pharma.module.sale.vo.SaleVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Service
public class SaleServiceImpl extends ServiceImpl<SaleDetailMapper, SaleDetail>
implements SaleService {
@Autowired
private MedicineService medicineService;
@Override
@Transactional(rollbackFor = Exception.class)
public void saleMedicine(SaleDTO dto) {
// 1. 扣减库存
medicineService.updateStock(dto.getMedicineId(), -dto.getQuantity());
// 2. 创建销售记录
SaleDetail saleDetail = new SaleDetail();
saleDetail.setMedicineId(dto.getMedicineId());
saleDetail.setOperatorId(SecurityUtil.getCurrentUserId());
saleDetail.setQuantity(dto.getQuantity());
saleDetail.setUnitPrice(dto.getUnitPrice());
saleDetail.setSaleTime(LocalDateTime.now());
this.save(saleDetail);
}
@Override
public BigDecimal getTodaySales() {
LocalDate today = LocalDate.now();
LocalDateTime startOfDay = today.atStartOfDay();
LocalDateTime endOfDay = today.plusDays(1).atStartOfDay();
BigDecimal total = this.baseMapper.getSalesTotalByTimeRange(
startOfDay, endOfDay);
return total != null ? total : BigDecimal.ZERO;
}
@Override
public List<Map<String, Object>> getSalesByDay(LocalDate startDate, LocalDate endDate) {
return this.baseMapper.getDailySales(startDate, endDate);
}
@Override
public List<SaleVO> getRecentSales(Integer limit) {
return this.baseMapper.selectRecentSales(limit);
}
}
4. 前端实现示例
药品列表页面
<template>
<div class="medicine-container">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<el-form :model="searchForm" inline>
<el-form-item label="药品名称">
<el-input
v-model="searchForm.name"
placeholder="请输入药品名称"
clearable
/>
</el-form-item>
<el-form-item label="分类">
<el-select
v-model="searchForm.categoryId"
placeholder="请选择分类"
clearable
>
<el-option
v-for="category in categoryList"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作区域 -->
<el-card shadow="never" class="operation-card">
<div class="operation-buttons">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增药品
</el-button>
<el-button type="warning" @click="handleExport">
<el-icon><Download /></el-icon>导出Excel
</el-button>
</div>
</el-card>
<!-- 表格区域 -->
<el-card shadow="never">
<el-table
:data="tableData"
v-loading="loading"
style="width: 100%"
border
>
<el-table-column prop="medNo" label="药品编码" width="120" />
<el-table-column prop="name" label="药品名称" width="180" />
<el-table-column prop="categoryName" label="分类" width="120" />
<el-table-column prop="manufacturer" label="生产厂家" width="180" />
<el-table-column prop="price" label="单价(元)" width="100" align="right">
<template #default="{ row }">
{{ formatCurrency(row.price) }}
</template>
</el-table-column>
<el-table-column prop="medCount" label="库存数量" width="100" align="right">
<template #default="{ row }">
<el-tag :type="getStockStatus(row.medCount, row.reqCount)">
{{ row.medCount }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="reqCount" label="补货阈值" width="100" align="right" />
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleView(row)">
详情
</el-button>
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button
type="success"
size="small"
@click="handleSale(row)"
:disabled="row.medCount <= 0"
>
销售
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 新增/编辑对话框 -->
<MedicineDialog
v-model="dialogVisible"
:type="dialogType"
:data="currentRow"
@success="handleDialogSuccess"
/>
<!-- 销售对话框 -->
<SaleDialog
v-model="saleDialogVisible"
:medicine="currentRow"
@success="handleSaleSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Search, Refresh, Plus, Download } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import MedicineDialog from './components/MedicineDialog.vue'
import SaleDialog from './components/SaleDialog.vue'
import { useMedicineStore } from '@/store/modules/medicine'
import { useCategoryStore } from '@/store/modules/category'
import type { Medicine } from '@/types/medicine'
import { formatCurrency, formatDate } from '@/utils/format'
const medicineStore = useMedicineStore()
const categoryStore = useCategoryStore()
// 搜索表单
const searchForm = reactive({
name: '',
categoryId: null as number | null
})
// 表格数据
const tableData = ref<Medicine[]>([])
const loading = ref(false)
// 分页
const pagination = reactive({
current: 1,
size: 10,
total: 0
})
// 分类列表
const categoryList = ref<any[]>([])
// 对话框控制
const dialogVisible = ref(false)
const saleDialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const currentRow = ref<Medicine | null>(null)
// 获取库存状态
const getStockStatus = (count: number, reqCount: number) => {
if (count === 0) return 'danger'
if (count <= reqCount) return 'warning'
return 'success'
}
// 加载数据
const loadData = async () => {
loading.value = true
try {
const params = {
pageNum: pagination.current,
pageSize: pagination.size,
...searchForm
}
const result = await medicineStore.getMedicineList(params)
tableData.value = result.records || []
pagination.total = result.total || 0
} catch (error) {
console.error('加载数据失败:', error)
} finally {
loading.value = false
}
}
// 加载分类
const loadCategories = async () => {
try {
const result = await categoryStore.getCategoryList()
categoryList.value = result || []
} catch (error) {
console.error('加载分类失败:', error)
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
loadData()
}
// 重置
const handleReset = () => {
searchForm.name = ''
searchForm.categoryId = null
handleSearch()
}
// 新增
const handleAdd = () => {
dialogType.value = 'add'
currentRow.value = null
dialogVisible.value = true
}
// 编辑
const handleEdit = (row: Medicine) => {
dialogType.value = 'edit'
currentRow.value = { ...row }
dialogVisible.value = true
}
// 查看详情
const handleView = (row: Medicine) => {
ElMessageBox.alert(`
<div style="line-height: 1.8;">
<p><strong>药品编码:</strong>${row.medNo}</p>
<p><strong>药品名称:</strong>${row.name}</p>
<p><strong>生产厂家:</strong>${row.manufacturer}</p>
<p><strong>分类:</strong>${row.categoryName}</p>
<p><strong>单价:</strong>${formatCurrency(row.price)}</p>
<p><strong>库存数量:</strong>${row.medCount}</p>
<p><strong>补货阈值:</strong>${row.reqCount}</p>
<p><strong>描述:</strong>${row.description}</p>
<p><strong>创建时间:</strong>${formatDate(row.createTime)}</p>
</div>
`, '药品详情', {
dangerouslyUseHTMLString: true,
confirmButtonText: '确定'
})
}
// 销售
const handleSale = (row: Medicine) => {
currentRow.value = { ...row }
saleDialogVisible.value = true
}
// 导出
const handleExport = async () => {
try {
await medicineStore.exportMedicine(searchForm)
ElMessage.success('导出成功')
} catch (error) {
ElMessage.error('导出失败')
}
}
// 分页大小改变
const handleSizeChange = (size: number) => {
pagination.size = size
pagination.current = 1
loadData()
}
// 页码改变
const handleCurrentChange = (page: number) => {
pagination.current = page
loadData()
}
// 对话框成功回调
const handleDialogSuccess = () => {
dialogVisible.value = false
loadData()
}
// 销售成功回调
const handleSaleSuccess = () => {
saleDialogVisible.value = false
loadData()
}
// 初始化
onMounted(() => {
loadData()
loadCategories()
})
</script>
<style scoped lang="scss">
.medicine-container {
padding: 20px;
.search-card {
margin-bottom: 20px;
}
.operation-card {
margin-bottom: 20px;
.operation-buttons {
display: flex;
gap: 10px;
}
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
</style>
销售统计图表组件
<template>
<div class="sales-chart">
<div class="chart-header">
<h3>销售统计</h3>
<div class="date-range">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
/>
</div>
</div>
<div class="chart-container">
<v-chart
v-if="chartOption"
:option="chartOption"
:autoresize="true"
style="height: 400px;"
/>
</div>
<div class="chart-footer">
<el-row :gutter="20">
<el-col :span="6">
<div class="stat-card">
<div class="stat-label">今日销售额</div>
<div class="stat-value">{{ formatCurrency(todaySales) }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="stat-label">本月销售额</div>
<div class="stat-value">{{ formatCurrency(monthSales) }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="stat-label">销售总量</div>
<div class="stat-value">{{ totalQuantity }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="stat-label">平均单价</div>
<div class="stat-value">{{ formatCurrency(avgPrice) }}</div>
</div>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
ToolboxComponent
} from 'echarts/components'
import VChart from 'vue-echarts'
import { formatCurrency } from '@/utils/format'
import { useSaleStore } from '@/store/modules/sale'
import dayjs from 'dayjs'
use([
CanvasRenderer,
LineChart,
BarChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
ToolboxComponent
])
const saleStore = useSaleStore()
// 日期范围
const dateRange = ref([
dayjs().subtract(30, 'day').toDate(),
dayjs().toDate()
])
// 统计数据
const todaySales = ref(0)
const monthSales = ref(0)
const totalQuantity = ref(0)
const avgPrice = ref(0)
// 图表配置
const chartOption = ref<any>(null)
// 加载数据
const loadData = async () => {
if (!dateRange.value || dateRange.value.length !== 2) return
const [startDate, endDate] = dateRange.value
try {
// 获取销售统计数据
const result = await saleStore.getSalesStatistics(
dayjs(startDate).format('YYYY-MM-DD'),
dayjs(endDate).format('YYYY-MM-DD')
)
// 更新统计数据
todaySales.value = result.todaySales || 0
monthSales.value = result.monthSales || 0
totalQuantity.value = result.totalQuantity || 0
avgPrice.value = result.avgPrice || 0
// 更新图表
updateChart(result.chartData)
} catch (error) {
console.error('加载销售数据失败:', error)
}
}
// 更新图表
const updateChart = (chartData: any[]) => {
if (!chartData || chartData.length === 0) {
chartOption.value = {
title: {
text: '暂无数据',
left: 'center',
top: 'center',
textStyle: {
color: '#999'
}
}
}
return
}
const dates = chartData.map(item => item.date)
const amounts = chartData.map(item => item.amount)
const quantities = chartData.map(item => item.quantity)
chartOption.value = {
title: {
text: '销售趋势图',
left: 'center'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['销售额', '销售量'],
top: 30
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: 80,
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates
},
yAxis: [
{
type: 'value',
name: '销售额(元)',
position: 'left',
axisLine: {
show: true
},
axisLabel: {
formatter: '{value}'
}
},
{
type: 'value',
name: '销售量',
position: 'right',
axisLine: {
show: true
},
axisLabel: {
formatter: '{value}'
}
}
],
series: [
{
name: '销售额',
type: 'line',
yAxisIndex: 0,
data: amounts,
smooth: true,
itemStyle: {
color: '#409EFF'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: 'rgba(64, 158, 255, 0.3)'
}, {
offset: 1, color: 'rgba(64, 158, 255, 0.05)'
}]
}
}
},
{
name: '销售量',
type: 'bar',
yAxisIndex: 1,
data: quantities,
itemStyle: {
color: '#67C23A'
}
}
]
}
}
// 日期改变
const handleDateChange = () => {
loadData()
}
// 初始化
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.sales-chart {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #303133;
}
}
.chart-container {
margin: 20px 0;
}
.chart-footer {
margin-top: 30px;
.stat-card {
background: #f5f7fa;
border-radius: 6px;
padding: 20px;
text-align: center;
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
}
}
}
</style>
📡 API接口文档
认证模块
1. 用户登录
POST /api/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "123456"
}
2. 获取当前用户信息
GET /api/user/info
Authorization: Bearer {token}
药品模块
1. 分页查询药品列表
GET /api/medicine/list?pageNum=1&pageSize=10&name=感冒&categoryId=1
Authorization: Bearer {token}
2. 新增药品
POST /api/medicine/add
Authorization: Bearer {token}
Content-Type: application/json
{
"medNo": "MED1001",
"name": "测试药品",
"manufacturer": "测试药厂",
"price": 25.50,
"medCount": 100,
"reqCount": 20,
"categoryId": 1,
"description": "测试药品描述"
}
3. 更新药品库存
PUT /api/medicine/stock/{id}
Authorization: Bearer {token}
Content-Type: application/json
{
"quantity": 10 // 正数增加,负数减少
}
销售模块
1. 药品销售
POST /api/sale/create
Authorization: Bearer {token}
Content-Type: application/json
{
"medicineId": 1,
"quantity": 2,
"unitPrice": 25.50
}
2. 销售统计
GET /api/sale/statistics?startDate=2024-01-01&endDate=2024-01-31
Authorization: Bearer {token}
🐳 Docker部署
Docker Compose配置
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: pharma-mysql
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: pharma_mgmt_db
MYSQL_USER: pharma_user
MYSQL_PASSWORD: pharma123
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- pharma-network
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
redis:
image: redis:7-alpine
container_name: pharma-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- pharma-network
command: redis-server --appendonly yes
backend:
build:
context: ./pharma-backend
dockerfile: Dockerfile
container_name: pharma-backend
depends_on:
- mysql
- redis
environment:
SPRING_PROFILES_ACTIVE: prod
DATABASE_HOST: mysql
REDIS_HOST: redis
ports:
- "8080:8080"
volumes:
- ./logs:/app/logs
- ./upload:/app/upload
networks:
- pharma-network
restart: unless-stopped
frontend:
build:
context: ./pharma-frontend
dockerfile: Dockerfile
container_name: pharma-frontend
depends_on:
- backend
ports:
- "80:80"
networks:
- pharma-network
restart: unless-stopped
nginx:
image: nginx:alpine
container_name: pharma-nginx
depends_on:
- backend
- frontend
ports:
- "8000:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/conf.d:/etc/nginx/conf.d
networks:
- pharma-network
networks:
pharma-network:
driver: bridge
volumes:
mysql_data:
redis_data:
upload:
logs:
后端Dockerfile
FROM openjdk:17-jdk-slim
# 设置工作目录
WORKDIR /app
# 复制Maven构建文件
COPY target/*.jar app.jar
# 创建非root用户
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
# 暴露端口
EXPOSE 8080
# 启动应用
ENTRYPOINT ["java", "-jar", "app.jar"]
前端Dockerfile
# 构建阶段
FROM node:18-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# 生产阶段
FROM nginx:alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
🧪 测试
1. 单元测试示例
package com.pharma.module.medicine.service;
import com.pharma.PharmaApplication;
import com.pharma.module.medicine.entity.Medicine;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(classes = PharmaApplication.class)
@Transactional
class MedicineServiceTest {
@Autowired
private MedicineService medicineService;
@Test
void testAddMedicine() {
Medicine medicine = new Medicine();
medicine.setMedNo("TEST001");
medicine.setName("测试药品");
medicine.setPrice(new BigDecimal("25.50"));
medicine.setMedCount(100);
medicine.setReqCount(20);
boolean result = medicineService.save(medicine);
assertTrue(result);
assertNotNull(medicine.getId());
}
@Test
void testUpdateStock() {
// 先创建测试数据
Medicine medicine = new Medicine();
medicine.setMedNo("TEST002");
medicine.setName("测试药品2");
medicine.setPrice(new BigDecimal("15.00"));
medicine.setMedCount(50);
medicine.setReqCount(10);
medicineService.save(medicine);
// 增加库存
medicineService.updateStock(medicine.getId(), 10);
Medicine updated = medicineService.getById(medicine.getId());
assertEquals(60, updated.getMedCount());
// 减少库存
medicineService.updateStock(medicine.getId(), -20);
updated = medicineService.getById(medicine.getId());
assertEquals(40, updated.getMedCount());
}
}
2. API测试示例 (Postman)
// 登录测试
pm.test("登录成功", function () {
var jsonData = pm.response.json();
pm.expect(jsonData.code).to.eql(200);
pm.expect(jsonData.data.token).to.not.be.null;
});
// 药品列表测试
pm.test("获取药品列表成功", function () {
var jsonData = pm.response.json();
pm.expect(jsonData.code).to.eql(200);
pm.expect(jsonData.data.records).to.be.an('array');
});
// 库存预警测试
pm.test("获取低库存药品", function () {
var jsonData = pm.response.json();
pm.expect(jsonData.code).to.eql(200);
jsonData.data.forEach(function(item) {
pm.expect(item.medCount).to.be.lessThan(item.reqCount);
});
});
📊 项目进度规划
第一阶段 (1周) - 基础框架搭建
- 项目初始化
- 数据库设计和初始化
- Spring Boot + MyBatis Plus整合
- Vue3 + Element Plus环境搭建
- 用户认证模块
第二阶段 (1周) - 核心功能实现
- 药品管理模块
- 分类管理模块
- 销售管理模块
- 库存管理模块
- 基础前端页面
第三阶段 (3-5天) - 高级功能
- 数据统计和图表
- 报表导出功能
- 库存预警系统
- 权限管理系统
- 系统日志管理
第四阶段 (2-3天) - 优化部署
- 性能优化
- 安全加固
- Docker容器化
- 生产环境部署
- 监控告警配置
🔍 故障排除
常见问题及解决方案
- 数据库连接失败
# 检查MySQL服务
sudo systemctl status mysql
# 检查连接配置
# 确保application.yml中的数据库连接信息正确
- 前端跨域问题
# 检查后端CORS配置
# 确保SecurityConfig中的corsConfigurationSource配置正确
- JWT认证失败
// 检查JWT配置
// 确保application.yml中的pharma.jwt.secret与前端保持一致
- MyBatis Plus分页失效
// 添加分页拦截器配置
@Configuration
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}
📚 学习资源
- Spring Boot官方文档: https://spring.io/projects/spring-boot
- MyBatis Plus官方文档: https://baomidou.com/
- Vue3官方文档: https://cn.vuejs.org/
- Element Plus文档: https://element-plus.org/zh-CN/
- MySQL文档: https://dev.mysql.com/doc/
📞 技术支持
如遇到问题,可通过以下方式获取支持:
- 查看日志文件:
logs/application.log - 检查数据库连接: 确认MySQL服务正常运行
- API调试: 使用Postman测试API接口
- 提交Issue: 在项目仓库提交问题
- 联系开发团队: 通过邮件或即时通讯工具
本文档最后更新于: 2024年1月
项目版本: v1.0.0
技术栈: Java 17 + Spring Boot 3.1 + Vue 3 + MySQL 8.0