2114 lines
58 KiB
Markdown
2114 lines
58 KiB
Markdown
# 📊 医药管理系统 - 项目文档
|
|
|
|
## 📋 项目概述
|
|
|
|
### 项目名称
|
|
|
|
**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
|
|
|
|
### 系统功能模块
|
|
1. **用户管理**: 登录、权限控制、个人信息管理
|
|
2. **药品管理**: 药品CRUD、库存管理、分类管理
|
|
3. **销售管理**: 销售记录、销售统计、报表生成
|
|
4. **库存预警**: 库存不足预警、补货提醒
|
|
5. **数据统计**: 销售数据可视化、药品销量排行
|
|
|
|
---
|
|
|
|
## 📁 项目结构
|
|
|
|
### 后端项目结构
|
|
```
|
|
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. 数据库关系图
|
|
```mermaid
|
|
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)
|
|
```java
|
|
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)
|
|
```java
|
|
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)
|
|
```java
|
|
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)
|
|
```java
|
|
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
|
|
<?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)
|
|
|
|
```yaml
|
|
# 主配置文件
|
|
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
|
|
```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
|
|
```typescript
|
|
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工具类
|
|
```java
|
|
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配置
|
|
```java
|
|
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. 药品管理模块
|
|
|
|
#### 药品服务实现
|
|
```java
|
|
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接口
|
|
```java
|
|
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. 销售管理模块
|
|
|
|
#### 销售服务实现
|
|
```java
|
|
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. 前端实现示例
|
|
|
|
#### 药品列表页面
|
|
```vue
|
|
<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>
|
|
```
|
|
|
|
#### 销售统计图表组件
|
|
```vue
|
|
<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. 用户登录
|
|
|
|
```http
|
|
POST /api/auth/login
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"username": "admin",
|
|
"password": "123456"
|
|
}
|
|
```
|
|
|
|
#### 2. 获取当前用户信息
|
|
```http
|
|
GET /api/user/info
|
|
Authorization: Bearer {token}
|
|
```
|
|
|
|
### 药品模块
|
|
|
|
#### 1. 分页查询药品列表
|
|
```http
|
|
GET /api/medicine/list?pageNum=1&pageSize=10&name=感冒&categoryId=1
|
|
Authorization: Bearer {token}
|
|
```
|
|
|
|
#### 2. 新增药品
|
|
```http
|
|
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. 更新药品库存
|
|
```http
|
|
PUT /api/medicine/stock/{id}
|
|
Authorization: Bearer {token}
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"quantity": 10 // 正数增加,负数减少
|
|
}
|
|
```
|
|
|
|
### 销售模块
|
|
|
|
#### 1. 药品销售
|
|
```http
|
|
POST /api/sale/create
|
|
Authorization: Bearer {token}
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"medicineId": 1,
|
|
"quantity": 2,
|
|
"unitPrice": 25.50
|
|
}
|
|
```
|
|
|
|
#### 2. 销售统计
|
|
```http
|
|
GET /api/sale/statistics?startDate=2024-01-01&endDate=2024-01-31
|
|
Authorization: Bearer {token}
|
|
```
|
|
|
|
---
|
|
|
|
## 🐳 Docker部署
|
|
|
|
### Docker Compose配置
|
|
```yaml
|
|
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
|
|
```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
|
|
```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. 单元测试示例
|
|
```java
|
|
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)
|
|
```javascript
|
|
// 登录测试
|
|
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周) - 基础框架搭建
|
|
- [x] 项目初始化
|
|
- [x] 数据库设计和初始化
|
|
- [x] Spring Boot + MyBatis Plus整合
|
|
- [x] Vue3 + Element Plus环境搭建
|
|
- [x] 用户认证模块
|
|
|
|
### 第二阶段 (1周) - 核心功能实现
|
|
- [x] 药品管理模块
|
|
- [x] 分类管理模块
|
|
- [x] 销售管理模块
|
|
- [x] 库存管理模块
|
|
- [x] 基础前端页面
|
|
|
|
### 第三阶段 (3-5天) - 高级功能
|
|
- [ ] 数据统计和图表
|
|
- [ ] 报表导出功能
|
|
- [ ] 库存预警系统
|
|
- [ ] 权限管理系统
|
|
- [ ] 系统日志管理
|
|
|
|
### 第四阶段 (2-3天) - 优化部署
|
|
- [ ] 性能优化
|
|
- [ ] 安全加固
|
|
- [ ] Docker容器化
|
|
- [ ] 生产环境部署
|
|
- [ ] 监控告警配置
|
|
|
|
---
|
|
|
|
## 🔍 故障排除
|
|
|
|
### 常见问题及解决方案
|
|
|
|
1. **数据库连接失败**
|
|
```bash
|
|
# 检查MySQL服务
|
|
sudo systemctl status mysql
|
|
|
|
# 检查连接配置
|
|
# 确保application.yml中的数据库连接信息正确
|
|
```
|
|
|
|
2. **前端跨域问题**
|
|
```yaml
|
|
# 检查后端CORS配置
|
|
# 确保SecurityConfig中的corsConfigurationSource配置正确
|
|
```
|
|
|
|
3. **JWT认证失败**
|
|
```java
|
|
// 检查JWT配置
|
|
// 确保application.yml中的pharma.jwt.secret与前端保持一致
|
|
```
|
|
|
|
4. **MyBatis Plus分页失效**
|
|
```java
|
|
// 添加分页拦截器配置
|
|
@Configuration
|
|
public class MyBatisPlusConfig {
|
|
@Bean
|
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
|
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
|
return interceptor;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📚 学习资源
|
|
|
|
1. **Spring Boot官方文档**: https://spring.io/projects/spring-boot
|
|
2. **MyBatis Plus官方文档**: https://baomidou.com/
|
|
3. **Vue3官方文档**: https://cn.vuejs.org/
|
|
4. **Element Plus文档**: https://element-plus.org/zh-CN/
|
|
5. **MySQL文档**: https://dev.mysql.com/doc/
|
|
|
|
---
|
|
|
|
## 📞 技术支持
|
|
|
|
如遇到问题,可通过以下方式获取支持:
|
|
|
|
1. **查看日志文件**: `logs/application.log`
|
|
2. **检查数据库连接**: 确认MySQL服务正常运行
|
|
3. **API调试**: 使用Postman测试API接口
|
|
4. **提交Issue**: 在项目仓库提交问题
|
|
5. **联系开发团队**: 通过邮件或即时通讯工具
|
|
|
|
---
|
|
|
|
*本文档最后更新于: 2024年1月*
|
|
*项目版本: v1.0.0*
|
|
*技术栈: Java 17 + Spring Boot 3.1 + Vue 3 + MySQL 8.0* |