首次提交by MimoCode
This commit is contained in:
@@ -1 +1,2 @@
|
||||
.vscode/
|
||||
plan.md
|
||||
@@ -0,0 +1,89 @@
|
||||
# 生成详细开发文档计划 - 已完成
|
||||
|
||||
## 用户需求
|
||||
根据 plan.md 中的需求,生成适合 AI IDE 后续开发的详细文档,包含前后端的设计方法、通信API、暴露的API等,尽量详细。不包含给使用者看的部署文档。
|
||||
|
||||
## 完成的工作
|
||||
|
||||
### 已生成的文档
|
||||
|
||||
1. **系统架构设计文档** (`docs/architecture.md`)
|
||||
- 系统整体架构图(前后端分离)
|
||||
- 模块划分说明
|
||||
- 数据流设计
|
||||
- AI Provider 抽象层设计
|
||||
- 通知系统设计
|
||||
- 认证授权设计
|
||||
- 插件系统设计
|
||||
- MCP 协议支持
|
||||
|
||||
2. **数据库设计文档** (`docs/database.md`)
|
||||
- ER 图
|
||||
- 表结构定义(完整字段说明)
|
||||
- 索引设计
|
||||
- 关系说明
|
||||
- 视图设计
|
||||
- 数据迁移策略
|
||||
|
||||
3. **前端开发文档** (`docs/frontend.md`)
|
||||
- 项目结构
|
||||
- 组件设计规范
|
||||
- 页面路由设计
|
||||
- 状态管理方案
|
||||
- API 调用层设计
|
||||
- Hooks 设计
|
||||
- 样式设计
|
||||
- PWA 配置
|
||||
|
||||
4. **后端开发文档** (`docs/backend.md`)
|
||||
- 项目结构
|
||||
- 路由设计
|
||||
- 服务层设计
|
||||
- 数据访问层设计
|
||||
- AI Provider 实现
|
||||
- 通知系统实现
|
||||
- 认证授权实现
|
||||
- 异步任务设计
|
||||
|
||||
5. **API 接口文档** (`docs/api.md`)
|
||||
- 认证接口
|
||||
- 药品管理接口
|
||||
- 批次管理接口
|
||||
- 分类管理接口
|
||||
- 搜索接口
|
||||
- 通知接口
|
||||
- AI 识别接口
|
||||
- 用户管理接口
|
||||
- 审计日志接口
|
||||
- 系统设置接口
|
||||
- 外部 API(供插件/MCP调用)
|
||||
|
||||
6. **通信协议文档** (`docs/communication.md`)
|
||||
- 前后端通信协议
|
||||
- 数据格式规范
|
||||
- 错误处理规范
|
||||
- 文件上传协议
|
||||
- 分页协议
|
||||
- 搜索协议
|
||||
- 缓存协议
|
||||
- 安全协议
|
||||
|
||||
7. **项目 README** (`README.md`)
|
||||
- 项目简介
|
||||
- 技术栈
|
||||
- 文档目录
|
||||
- 快速开始
|
||||
- 项目结构
|
||||
- 功能特性
|
||||
- 开发规范
|
||||
- 环境变量配置
|
||||
- 测试
|
||||
- 部署
|
||||
- 常见问题
|
||||
|
||||
## 验证结果
|
||||
- 所有文档已生成
|
||||
- 文档内容完整详细
|
||||
- 技术选型一致
|
||||
- API 设计完整
|
||||
- 符合用户需求(适合AI IDE后续开发,包含前后端设计方法、通信API、暴露的API)
|
||||
@@ -1 +1,253 @@
|
||||
# 药箱 · YaoXiang
|
||||
# 药箱 · YaoXiang
|
||||
|
||||
家庭药品与应急物资管理系统
|
||||
|
||||
## 功能介绍
|
||||
|
||||
### 核心功能
|
||||
|
||||
- **药品管理**: 添加、编辑、删除药品信息,支持通用名、商品名、规格等
|
||||
- **批次管理**: 每个药品可有多个批次,独立管理库存和有效期
|
||||
- **分类管理**: 二级分类(药品/医疗器械/应急用品/消耗品)
|
||||
- **智能搜索**: 支持按名称、症状搜索,AI 自然语言搜索
|
||||
- **AI 识别**: 拍照识别药盒、有效期、说明书,自动建档
|
||||
- **到期提醒**: 90天/30天/7天过期提醒,支持有效期宽限(最多60天)
|
||||
- **库存提醒**: 低库存自动提醒
|
||||
- **用户管理**: 多用户支持,角色权限划分(管理员/普通用户/只读)
|
||||
- **通知系统**: 支持 Server酱、PushPlus、Bark 等多种通知渠道
|
||||
- **大屏模式**: 平板快速取药,大按钮方便操作
|
||||
|
||||
### 技术特性
|
||||
|
||||
- **前端**: React 18 + TypeScript + Vite + Ant Design Mobile
|
||||
- **后端**: FastAPI + SQLAlchemy + SQLite
|
||||
- **AI**: 支持 OpenAI、Gemini、Claude、DeepSeek、Ollama
|
||||
- **部署**: Docker 单容器部署
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
YaoXiang/
|
||||
├── frontend/ # 前端代码
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 调用层
|
||||
│ │ ├── components/ # 公共组件
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ ├── stores/ # 状态管理
|
||||
│ │ ├── hooks/ # 自定义 Hooks
|
||||
│ │ ├── types/ # TypeScript 类型
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ └── package.json
|
||||
│
|
||||
├── backend/ # 后端代码
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ ├── schemas/ # Pydantic 模式
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ ├── repositories/ # 数据访问
|
||||
│ │ ├── ai/ # AI Provider
|
||||
│ │ └── notifications/ # 通知系统
|
||||
│ └── requirements.txt
|
||||
│
|
||||
├── docs/ # 开发文档
|
||||
├── start.ps1 # Windows 启动脚本
|
||||
├── start.sh # Linux/Mac 启动脚本
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 安装与运行
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js 18+
|
||||
- Python 3.11+
|
||||
- npm
|
||||
|
||||
### 快速开始
|
||||
|
||||
**Windows:**
|
||||
|
||||
```powershell
|
||||
# 安装依赖
|
||||
.\start.ps1 install
|
||||
|
||||
# 启动服务
|
||||
.\start.ps1 start
|
||||
```
|
||||
|
||||
**Linux / Mac:**
|
||||
|
||||
```bash
|
||||
# 赋予执行权限(首次)
|
||||
chmod +x start.sh
|
||||
|
||||
# 安装依赖
|
||||
./start.sh install
|
||||
|
||||
# 启动服务
|
||||
./start.sh start
|
||||
```
|
||||
|
||||
**手动安装:**
|
||||
|
||||
```bash
|
||||
# 安装前端依赖
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# 安装后端依赖
|
||||
cd ../backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: .\venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 启动后端
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# 启动前端(新终端)
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 访问地址
|
||||
|
||||
- 前端: http://localhost:5173
|
||||
- 后端 API: http://localhost:8000
|
||||
- API 文档: http://localhost:8000/docs
|
||||
|
||||
### 默认账号
|
||||
|
||||
首次启动会自动创建管理员账号:
|
||||
- 用户名: admin
|
||||
- 密码: admin123
|
||||
|
||||
## 配置说明
|
||||
|
||||
在 `backend/` 目录下创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### AI API 配置
|
||||
|
||||
支持多种 AI Provider,选择一种配置即可:
|
||||
|
||||
**OpenAI:**
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=openai
|
||||
OPENAI_API_KEY=sk-your-api-key
|
||||
OPENAI_MODEL=gpt-4o
|
||||
```
|
||||
|
||||
**Gemini:**
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=gemini
|
||||
GEMINI_API_KEY=your-api-key
|
||||
GEMINI_MODEL=gemini-pro-vision
|
||||
```
|
||||
|
||||
**DeepSeek:**
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=deepseek
|
||||
DEEPSEEK_API_KEY=your-api-key
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
```
|
||||
|
||||
**Ollama (本地部署):**
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=ollama
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=llava
|
||||
```
|
||||
|
||||
### 通知配置
|
||||
|
||||
配置通知渠道,药品过期和库存不足时会发送提醒:
|
||||
|
||||
**Server酱:**
|
||||
|
||||
1. 访问 https://sct.ftqq.com/ 注册获取 SendKey
|
||||
2. 配置:
|
||||
```bash
|
||||
NOTIFICATION_PROVIDERS=serverchan
|
||||
SERVERCHAN_KEY=your-send-key
|
||||
```
|
||||
|
||||
**PushPlus:**
|
||||
|
||||
1. 访问 https://www.pushplus.plus/ 注册获取 Token
|
||||
2. 配置:
|
||||
```bash
|
||||
NOTIFICATION_PROVIDERS=pushplus
|
||||
PUSHPLUS_TOKEN=your-token
|
||||
```
|
||||
|
||||
**多渠道同时启用:**
|
||||
|
||||
```bash
|
||||
NOTIFICATION_PROVIDERS=serverchan,pushplus
|
||||
SERVERCHAN_KEY=your-key
|
||||
PUSHPLUS_TOKEN=your-token
|
||||
```
|
||||
|
||||
### 其他配置
|
||||
|
||||
```bash
|
||||
# 安全配置(请修改为自己的密钥)
|
||||
JWT_SECRET_KEY=your-secret-key
|
||||
|
||||
# 数据库配置(默认 SQLite)
|
||||
DATABASE_URL=sqlite+aiosqlite:///./data/yaoxiang.db
|
||||
|
||||
# 文件上传配置
|
||||
UPLOAD_DIR=./data/uploads
|
||||
MAX_UPLOAD_SIZE=10485760 # 10MB
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
.\start.ps1 install # 安装依赖
|
||||
.\start.ps1 start # 启动所有服务
|
||||
.\start.ps1 start:f # 仅启动前端
|
||||
.\start.ps1 start:b # 仅启动后端
|
||||
.\start.ps1 build # 构建生产版本
|
||||
.\start.ps1 clean # 清理缓存
|
||||
.\start.ps1 help # 显示帮助
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / Mac
|
||||
./start.sh install # 安装依赖
|
||||
./start.sh start # 启动所有服务
|
||||
./start.sh start:f # 仅启动前端
|
||||
./start.sh start:b # 仅启动后端
|
||||
./start.sh build # 构建生产版本
|
||||
./start.sh clean # 清理缓存
|
||||
./start.sh help # 显示帮助
|
||||
```
|
||||
|
||||
## 开发文档
|
||||
|
||||
详细文档请查看 `docs/` 目录:
|
||||
|
||||
- [系统架构设计](docs/architecture.md)
|
||||
- [数据库设计](docs/database.md)
|
||||
- [前端开发文档](frontend/DEVELOPMENT.md)
|
||||
- [后端开发文档](backend/DEVELOPMENT.md)
|
||||
- [API 接口文档](docs/api.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 声明
|
||||
|
||||
本项目由 [小米 MiMoCode]([mimo.xiaomi.com/zh/mimocode](https://mimo.xiaomi.com/zh/mimocode)) 助生成。
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# 药箱启动脚本使用说明
|
||||
|
||||
## Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
# 安装所有依赖
|
||||
.\start.ps1 install
|
||||
|
||||
# 启动所有服务
|
||||
.\start.ps1 start
|
||||
|
||||
# 仅启动前端
|
||||
.\start.ps1 start:f
|
||||
|
||||
# 仅启动后端
|
||||
.\start.ps1 start:b
|
||||
|
||||
# 构建生产版本
|
||||
.\start.ps1 build
|
||||
|
||||
# 清理缓存
|
||||
.\start.ps1 clean
|
||||
|
||||
# 显示帮助
|
||||
.\start.ps1 help
|
||||
```
|
||||
|
||||
## Linux / Mac (Bash)
|
||||
|
||||
```bash
|
||||
# 首次使用需要赋予执行权限
|
||||
chmod +x start.sh
|
||||
|
||||
# 安装所有依赖
|
||||
./start.sh install
|
||||
|
||||
# 启动所有服务
|
||||
./start.sh start
|
||||
|
||||
# 仅启动前端
|
||||
./start.sh start:f
|
||||
|
||||
# 仅启动后端
|
||||
./start.sh start:b
|
||||
|
||||
# 构建生产版本
|
||||
./start.sh build
|
||||
|
||||
# 清理缓存
|
||||
./start.sh clean
|
||||
|
||||
# 显示帮助
|
||||
./start.sh help
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 安装依赖:
|
||||
- Windows: `.\start.ps1 install`
|
||||
- Linux/Mac: `./start.sh install`
|
||||
|
||||
2. 启动服务:
|
||||
- Windows: `.\start.ps1 start`
|
||||
- Linux/Mac: `./start.sh start`
|
||||
|
||||
3. 访问应用:
|
||||
- 前端: http://localhost:5173
|
||||
- 后端: http://localhost:8000
|
||||
- API 文档: http://localhost:8000/docs
|
||||
|
||||
## 前置要求
|
||||
|
||||
- Node.js 18+
|
||||
- Python 3.11+
|
||||
- npm
|
||||
@@ -0,0 +1,53 @@
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite+aiosqlite:///./data/yaoxiang.db
|
||||
|
||||
# 安全配置
|
||||
JWT_SECRET_KEY=your-secret-key-change-in-production
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRATION_HOURS=24
|
||||
|
||||
# AI Provider 配置
|
||||
AI_PROVIDER=openai
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4o
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=gemini-pro-vision
|
||||
ANTHROPIC_API_KEY=
|
||||
ANTHROPIC_MODEL=claude-3-opus-20240229
|
||||
DEEPSEEK_API_KEY=
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=llava
|
||||
|
||||
# 通知配置
|
||||
NOTIFICATION_PROVIDERS=[]
|
||||
SERVERCHAN_KEY=
|
||||
PUSHPLUS_TOKEN=
|
||||
BARK_URL=
|
||||
WECHAT_WEBHOOK_URL=
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
|
||||
# 文件存储配置
|
||||
UPLOAD_DIR=./data/uploads
|
||||
MAX_UPLOAD_SIZE=10485760
|
||||
|
||||
# 到期提醒配置
|
||||
EXPIRY_WARNING_DAYS=90,30,7
|
||||
|
||||
# 低库存阈值
|
||||
LOW_STOCK_THRESHOLD=5
|
||||
|
||||
# CORS 配置
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# 宽限天数最大值
|
||||
EXPIRY_GRACE_DAYS_MAX=60
|
||||
|
||||
# 调试模式
|
||||
DEBUG=true
|
||||
@@ -0,0 +1,48 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
.env
|
||||
data/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,219 @@
|
||||
# 药箱后端修改记录
|
||||
|
||||
## 版本历史
|
||||
|
||||
### v1.0.0 (2026-06-15)
|
||||
|
||||
#### 新增功能
|
||||
|
||||
**核心框架**
|
||||
- 创建 FastAPI 应用入口 (`app/main.py`)
|
||||
- 实现配置管理系统 (`app/config.py`)
|
||||
- 实现数据库连接和会话管理 (`app/database.py`)
|
||||
- 实现异常处理器 (`app/core/exceptions.py`)
|
||||
|
||||
**数据模型**
|
||||
- 用户模型 (`app/models/user.py`)
|
||||
- 药品模型 (`app/models/medicine.py`)
|
||||
- 批次模型 (`app/models/batch.py`)
|
||||
- 分类模型 (`app/models/category.py`)
|
||||
- 审计日志模型 (`app/models/audit_log.py`)
|
||||
- 通知模型 (`app/models/notification.py`)
|
||||
- 设置模型 (`app/models/setting.py`)
|
||||
|
||||
**Pydantic 模式**
|
||||
- 用户请求/响应模式 (`app/schemas/user.py`)
|
||||
- 药品请求/响应模式 (`app/schemas/medicine.py`)
|
||||
- 批次请求/响应模式 (`app/schemas/batch.py`)
|
||||
- 分类请求/响应模式 (`app/schemas/category.py`)
|
||||
- 认证请求模式 (`app/schemas/auth.py`)
|
||||
|
||||
**数据访问层**
|
||||
- 用户仓库 (`app/repositories/user.py`)
|
||||
- 药品仓库 (`app/repositories/medicine.py`)
|
||||
- 批次仓库 (`app/repositories/batch.py`)
|
||||
- 分类仓库 (`app/repositories/category.py`)
|
||||
- 审计日志仓库 (`app/repositories/audit_log.py`)
|
||||
- 通知仓库 (`app/repositories/notification.py`)
|
||||
|
||||
**服务层**
|
||||
- 认证服务 (`app/services/auth.py`)
|
||||
- 用户服务 (`app/services/user.py`)
|
||||
- 药品服务 (`app/services/medicine.py`)
|
||||
- 批次服务 (`app/services/batch.py`)
|
||||
- 分类服务 (`app/services/category.py`)
|
||||
- 审计日志服务 (`app/services/audit.py`)
|
||||
- 通知服务 (`app/services/notification.py`)
|
||||
|
||||
**API 接口**
|
||||
- 认证接口 (`app/api/v1/auth.py`)
|
||||
- POST /api/v1/auth/login - 用户登录
|
||||
- GET /api/v1/auth/me - 获取当前用户信息
|
||||
- PUT /api/v1/auth/password - 修改密码
|
||||
|
||||
- 药品管理接口 (`app/api/v1/medicines.py`)
|
||||
- GET /api/v1/medicines - 获取药品列表
|
||||
- GET /api/v1/medicines/{id} - 获取药品详情
|
||||
- POST /api/v1/medicines - 创建药品
|
||||
- PUT /api/v1/medicines/{id} - 更新药品
|
||||
- DELETE /api/v1/medicines/{id} - 删除药品
|
||||
|
||||
- 批次管理接口 (`app/api/v1/batches.py`)
|
||||
- GET /api/v1/batches/medicine/{medicine_id} - 获取药品的所有批次
|
||||
- GET /api/v1/batches/{id} - 获取批次详情
|
||||
- POST /api/v1/batches/medicine/{medicine_id} - 创建批次
|
||||
- PUT /api/v1/batches/{id} - 更新批次
|
||||
- DELETE /api/v1/batches/{id} - 删除批次
|
||||
- POST /api/v1/batches/{id}/dispense - 取药
|
||||
- POST /api/v1/batches/{id}/add-stock - 入库
|
||||
|
||||
- 分类管理接口 (`app/api/v1/categories.py`)
|
||||
- GET /api/v1/categories - 获取分类列表
|
||||
- GET /api/v1/categories/tree - 获取分类树
|
||||
- GET /api/v1/categories/{id} - 获取分类详情
|
||||
- POST /api/v1/categories - 创建分类
|
||||
- PUT /api/v1/categories/{id} - 更新分类
|
||||
- DELETE /api/v1/categories/{id} - 删除分类
|
||||
|
||||
- 搜索接口 (`app/api/v1/search.py`)
|
||||
- GET /api/v1/search - 关键词搜索
|
||||
- POST /api/v1/search/natural - 自然语言搜索
|
||||
|
||||
- AI 识别接口 (`app/api/v1/ai.py`)
|
||||
- POST /api/v1/ai/recognize-medicine - 识别药盒
|
||||
- POST /api/v1/ai/recognize-dates - 识别日期
|
||||
- POST /api/v1/ai/recognize-leaflet - 识别说明书
|
||||
|
||||
- 用户管理接口 (`app/api/v1/users.py`)
|
||||
- GET /api/v1/users - 获取用户列表
|
||||
- GET /api/v1/users/{id} - 获取用户详情
|
||||
- POST /api/v1/users - 创建用户
|
||||
- PUT /api/v1/users/{id} - 更新用户
|
||||
- DELETE /api/v1/users/{id} - 删除用户
|
||||
- POST /api/v1/users/{id}/reset-password - 重置密码
|
||||
|
||||
- 通知接口 (`app/api/v1/notifications.py`)
|
||||
- GET /api/v1/notifications - 获取通知列表
|
||||
- PUT /api/v1/notifications/{id}/read - 标记为已读
|
||||
- PUT /api/v1/notifications/read-all - 全部标记为已读
|
||||
- DELETE /api/v1/notifications/{id} - 删除通知
|
||||
|
||||
- 系统设置接口 (`app/api/v1/settings.py`)
|
||||
- GET /api/v1/settings - 获取所有设置
|
||||
- GET /api/v1/settings/{key} - 获取单个设置
|
||||
- PUT /api/v1/settings/{key} - 更新设置
|
||||
|
||||
**AI Provider 模块**
|
||||
- 抽象基类 (`app/ai/base.py`)
|
||||
- OpenAI Provider 实现 (`app/ai/openai_provider.py`)
|
||||
- Provider 管理器 (`app/ai/manager.py`)
|
||||
|
||||
**通知系统**
|
||||
- 抽象基类 (`app/notifications/base.py`)
|
||||
- Server酱 Provider (`app/notifications/serverchan.py`)
|
||||
- PushPlus Provider (`app/notifications/pushplus.py`)
|
||||
- 通知管理器 (`app/notifications/manager.py`)
|
||||
|
||||
**文件存储**
|
||||
- 抽象基类 (`app/storage/base.py`)
|
||||
- 本地存储实现 (`app/storage/local.py`)
|
||||
- 存储管理器 (`app/storage/manager.py`)
|
||||
|
||||
**安全模块**
|
||||
- JWT 和密码工具 (`app/core/security.py`)
|
||||
- 依赖注入 (`app/core/deps.py`)
|
||||
|
||||
**定时任务**
|
||||
- 到期检查任务 (`app/tasks/expiry_check.py`)
|
||||
|
||||
**项目配置**
|
||||
- requirements.txt - Python 依赖
|
||||
- .env.example - 环境变量示例
|
||||
- .gitignore - Git 忽略文件
|
||||
- pytest.ini - 测试配置
|
||||
- tests/conftest.py - 测试配置
|
||||
|
||||
**文档**
|
||||
- DEVELOPMENT.md - 开发文档
|
||||
- CHANGELOG.md - 修改记录
|
||||
|
||||
---
|
||||
|
||||
## 待开发功能
|
||||
|
||||
### 计划中
|
||||
|
||||
- [ ] 文件上传接口
|
||||
- [ ] 审计日志查询接口
|
||||
- [ ] Gemini Provider 实现
|
||||
- [ ] Claude Provider 实现
|
||||
- [ ] DeepSeek Provider 实现
|
||||
- [ ] Ollama Provider 实现
|
||||
- [ ] Bark 通知 Provider
|
||||
- [ ] 企业微信通知 Provider
|
||||
- [ ] Telegram 通知 Provider
|
||||
- [ ] 邮件通知 Provider
|
||||
- [ ] 数据库迁移支持 (Alembic)
|
||||
- [ ] 单元测试用例
|
||||
- [ ] 集成测试用例
|
||||
|
||||
### 已知问题
|
||||
|
||||
- 暂无
|
||||
|
||||
---
|
||||
|
||||
## 更新说明
|
||||
|
||||
### 2026-06-15
|
||||
|
||||
**初始版本发布**
|
||||
|
||||
完成药箱后端系统的初始开发,包括:
|
||||
|
||||
1. **核心功能实现**
|
||||
- 用户认证和授权 (JWT)
|
||||
- 药品 CRUD 操作
|
||||
- 批次管理(含取药、入库)
|
||||
- 分类管理(支持二级分类)
|
||||
- 搜索功能(关键词搜索)
|
||||
- 通知系统框架
|
||||
- AI 识别接口框架
|
||||
|
||||
2. **架构设计**
|
||||
- 采用分层架构:路由层 → 服务层 → 数据访问层
|
||||
- 使用 SQLAlchemy 2.0 异步 ORM
|
||||
- 支持多种 AI Provider 扩展
|
||||
- 支持多种通知渠道扩展
|
||||
|
||||
3. **代码规范**
|
||||
- 遵循 PEP 8 编码规范
|
||||
- 使用 Pydantic 进行数据验证
|
||||
- 完整的类型注解
|
||||
- 清晰的模块划分
|
||||
|
||||
4. **安全特性**
|
||||
- 密码 bcrypt 加密
|
||||
- JWT Token 认证
|
||||
- 基于角色的访问控制
|
||||
- 输入验证和过滤
|
||||
|
||||
---
|
||||
|
||||
## 贡献指南
|
||||
|
||||
如需提交修改,请遵循以下规范:
|
||||
|
||||
1. 代码风格遵循 PEP 8
|
||||
2. 提交信息使用中文
|
||||
3. 新增功能请添加相应的测试
|
||||
4. 修改记录请更新此文档
|
||||
|
||||
---
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题或建议,请通过以下方式联系:
|
||||
|
||||
- 项目地址: https://github.com/your-username/yaoxiang
|
||||
- 问题反馈: https://github.com/your-username/yaoxiang/issues
|
||||
@@ -0,0 +1,540 @@
|
||||
# 药箱后端开发文档
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
药箱(YaoXiang)是一个家庭药品与应急物资管理系统,后端采用 FastAPI 框架,提供 RESTful API 服务。
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 技术 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| Python | 3.11+ | 运行环境 |
|
||||
| FastAPI | 0.104.1 | Web 框架 |
|
||||
| SQLAlchemy | 2.0.23 | ORM 框架 |
|
||||
| Alembic | 1.13.0 | 数据库迁移 |
|
||||
| SQLite | - | 数据库 |
|
||||
| Pydantic | 2.5.2 | 数据验证 |
|
||||
| python-jose | 3.3.0 | JWT 认证 |
|
||||
| passlib | 1.7.4 | 密码加密 |
|
||||
| OpenAI | 1.6.1 | AI 服务 |
|
||||
|
||||
## 3. 项目结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/ # 主应用目录
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI 应用入口
|
||||
│ ├── config.py # 配置管理
|
||||
│ ├── database.py # 数据库连接
|
||||
│ │
|
||||
│ ├── api/ # API 路由层
|
||||
│ │ ├── router.py # 路由汇总
|
||||
│ │ └── v1/ # API 版本 1
|
||||
│ │ ├── auth.py # 认证接口
|
||||
│ │ ├── medicines.py # 药品管理接口
|
||||
│ │ ├── batches.py # 批次管理接口
|
||||
│ │ ├── categories.py # 分类管理接口
|
||||
│ │ ├── search.py # 搜索接口
|
||||
│ │ ├── notifications.py # 通知接口
|
||||
│ │ ├── ai.py # AI 识别接口
|
||||
│ │ ├── users.py # 用户管理接口
|
||||
│ │ └── settings.py # 系统设置接口
|
||||
│ │
|
||||
│ ├── models/ # SQLAlchemy 数据模型
|
||||
│ │ ├── user.py # 用户模型
|
||||
│ │ ├── medicine.py # 药品模型
|
||||
│ │ ├── batch.py # 批次模型
|
||||
│ │ ├── category.py # 分类模型
|
||||
│ │ ├── audit_log.py # 审计日志模型
|
||||
│ │ ├── notification.py # 通知模型
|
||||
│ │ └── setting.py # 设置模型
|
||||
│ │
|
||||
│ ├── schemas/ # Pydantic 数据模式
|
||||
│ │ ├── user.py
|
||||
│ │ ├── medicine.py
|
||||
│ │ ├── batch.py
|
||||
│ │ ├── category.py
|
||||
│ │ └── auth.py
|
||||
│ │
|
||||
│ ├── services/ # 业务服务层
|
||||
│ │ ├── auth.py # 认证服务
|
||||
│ │ ├── user.py # 用户服务
|
||||
│ │ ├── medicine.py # 药品服务
|
||||
│ │ ├── batch.py # 批次服务
|
||||
│ │ ├── category.py # 分类服务
|
||||
│ │ ├── audit.py # 审计日志服务
|
||||
│ │ └── notification.py # 通知服务
|
||||
│ │
|
||||
│ ├── repositories/ # 数据访问层
|
||||
│ │ ├── user.py
|
||||
│ │ ├── medicine.py
|
||||
│ │ ├── batch.py
|
||||
│ │ ├── category.py
|
||||
│ │ ├── audit_log.py
|
||||
│ │ └── notification.py
|
||||
│ │
|
||||
│ ├── ai/ # AI Provider 模块
|
||||
│ │ ├── base.py # 抽象基类
|
||||
│ │ ├── openai_provider.py # OpenAI 实现
|
||||
│ │ └── manager.py # Provider 管理器
|
||||
│ │
|
||||
│ ├── notifications/ # 通知系统
|
||||
│ │ ├── base.py # 抽象基类
|
||||
│ │ ├── serverchan.py # Server酱
|
||||
│ │ ├── pushplus.py # PushPlus
|
||||
│ │ └── manager.py # 通知管理器
|
||||
│ │
|
||||
│ ├── storage/ # 文件存储
|
||||
│ │ ├── base.py # 抽象基类
|
||||
│ │ ├── local.py # 本地存储
|
||||
│ │ └── manager.py # 存储管理器
|
||||
│ │
|
||||
│ ├── core/ # 核心功能
|
||||
│ │ ├── security.py # 安全工具 (JWT, 密码)
|
||||
│ │ ├── deps.py # 依赖注入
|
||||
│ │ └── exceptions.py # 异常处理
|
||||
│ │
|
||||
│ └── tasks/ # 异步任务
|
||||
│ └── expiry_check.py # 到期检查任务
|
||||
│
|
||||
├── tests/ # 测试目录
|
||||
├── data/ # 数据目录
|
||||
├── requirements.txt # Python 依赖
|
||||
├── .env.example # 环境变量示例
|
||||
└── .env # 环境变量配置
|
||||
```
|
||||
|
||||
## 4. 快速开始
|
||||
|
||||
### 4.1 环境准备
|
||||
|
||||
```bash
|
||||
# 进入后端目录
|
||||
cd backend
|
||||
|
||||
# 创建虚拟环境 (可选)
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows
|
||||
# source venv/bin/activate # Linux/Mac
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4.2 配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制环境变量示例文件
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件,配置必要的参数
|
||||
```
|
||||
|
||||
### 4.3 启动服务
|
||||
|
||||
```bash
|
||||
# 开发模式启动
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# 生产模式启动
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
```
|
||||
|
||||
### 4.4 访问 API 文档
|
||||
|
||||
- Swagger UI: http://localhost:8000/docs
|
||||
- ReDoc: http://localhost:8000/redoc
|
||||
- 健康检查: http://localhost:8000/health
|
||||
|
||||
## 5. 配置说明
|
||||
|
||||
### 5.1 环境变量配置
|
||||
|
||||
| 变量名 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| DATABASE_URL | sqlite+aiosqlite:///./data/yaoxiang.db | 数据库连接字符串 |
|
||||
| JWT_SECRET_KEY | your-secret-key-change-in-production | JWT 密钥 |
|
||||
| JWT_ALGORITHM | HS256 | JWT 算法 |
|
||||
| JWT_EXPIRATION_HOURS | 24 | JWT 过期时间(小时) |
|
||||
| AI_PROVIDER | openai | AI 服务提供者 |
|
||||
| OPENAI_API_KEY | - | OpenAI API Key |
|
||||
| OPENAI_MODEL | gpt-4o | OpenAI 模型 |
|
||||
| UPLOAD_DIR | ./data/uploads | 文件上传目录 |
|
||||
| CORS_ORIGINS | http://localhost:5173 | CORS 允许的源 |
|
||||
|
||||
### 5.2 AI Provider 配置
|
||||
|
||||
支持的 AI Provider:
|
||||
- **openai**: OpenAI GPT-4o
|
||||
- **gemini**: Google Gemini
|
||||
- **claude**: Anthropic Claude
|
||||
- **deepseek**: DeepSeek
|
||||
- **ollama**: 本地 Ollama
|
||||
|
||||
## 6. 数据库设计
|
||||
|
||||
### 6.1 数据表
|
||||
|
||||
| 表名 | 说明 |
|
||||
|------|------|
|
||||
| users | 用户表 |
|
||||
| categories | 分类表 |
|
||||
| medicines | 药品表 |
|
||||
| batches | 批次表 |
|
||||
| audit_logs | 审计日志表 |
|
||||
| notifications | 通知表 |
|
||||
| settings | 系统设置表 |
|
||||
|
||||
### 6.2 核心表结构
|
||||
|
||||
#### users 表
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) DEFAULT 'user',
|
||||
display_name VARCHAR(100),
|
||||
email VARCHAR(100),
|
||||
notification_level VARCHAR(20) DEFAULT 'normal',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
#### medicines 表
|
||||
```sql
|
||||
CREATE TABLE medicines (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
generic_name VARCHAR(200),
|
||||
brand_name VARCHAR(200),
|
||||
manufacturer VARCHAR(200),
|
||||
specification VARCHAR(200),
|
||||
category_id INTEGER REFERENCES categories(id),
|
||||
indications TEXT,
|
||||
adult_dose TEXT,
|
||||
child_dose TEXT,
|
||||
contraindications TEXT,
|
||||
notes TEXT,
|
||||
image_front_path VARCHAR(500),
|
||||
image_expiry_path VARCHAR(500),
|
||||
image_leaflet_paths JSON,
|
||||
expiry_grace_days INTEGER DEFAULT 0,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
#### batches 表
|
||||
```sql
|
||||
CREATE TABLE batches (
|
||||
id INTEGER PRIMARY KEY,
|
||||
medicine_id INTEGER REFERENCES medicines(id),
|
||||
batch_no VARCHAR(100),
|
||||
production_date DATE,
|
||||
expiry_date DATE NOT NULL,
|
||||
quantity INTEGER DEFAULT 0,
|
||||
location VARCHAR(200),
|
||||
is_expired BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
## 7. API 接口
|
||||
|
||||
### 7.1 认证接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/v1/auth/login | 用户登录 |
|
||||
| GET | /api/v1/auth/me | 获取当前用户信息 |
|
||||
| PUT | /api/v1/auth/password | 修改密码 |
|
||||
|
||||
### 7.2 药品管理接口
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | /api/v1/medicines | 获取药品列表 | 登录用户 |
|
||||
| GET | /api/v1/medicines/{id} | 获取药品详情 | 登录用户 |
|
||||
| POST | /api/v1/medicines | 创建药品 | admin/user |
|
||||
| PUT | /api/v1/medicines/{id} | 更新药品 | admin/user |
|
||||
| DELETE | /api/v1/medicines/{id} | 删除药品 | admin |
|
||||
|
||||
### 7.3 批次管理接口
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | /api/v1/batches/medicine/{medicine_id} | 获取药品的所有批次 | 登录用户 |
|
||||
| GET | /api/v1/batches/{id} | 获取批次详情 | 登录用户 |
|
||||
| POST | /api/v1/batches/medicine/{medicine_id} | 创建批次 | admin/user |
|
||||
| PUT | /api/v1/batches/{id} | 更新批次 | admin/user |
|
||||
| DELETE | /api/v1/batches/{id} | 删除批次 | admin |
|
||||
| POST | /api/v1/batches/{id}/dispense | 取药 | admin/user |
|
||||
| POST | /api/v1/batches/{id}/add-stock | 入库 | admin/user |
|
||||
|
||||
### 7.4 分类管理接口
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | /api/v1/categories | 获取分类列表 | 登录用户 |
|
||||
| GET | /api/v1/categories/tree | 获取分类树 | 登录用户 |
|
||||
| GET | /api/v1/categories/{id} | 获取分类详情 | 登录用户 |
|
||||
| POST | /api/v1/categories | 创建分类 | admin |
|
||||
| PUT | /api/v1/categories/{id} | 更新分类 | admin |
|
||||
| DELETE | /api/v1/categories/{id} | 删除分类 | admin |
|
||||
|
||||
### 7.5 搜索接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/search?q=关键词 | 关键词搜索 |
|
||||
| POST | /api/v1/search/natural | 自然语言搜索 |
|
||||
|
||||
### 7.6 AI 识别接口
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| POST | /api/v1/ai/recognize-medicine | 识别药盒 | admin/user |
|
||||
| POST | /api/v1/ai/recognize-dates | 识别日期 | admin/user |
|
||||
| POST | /api/v1/ai/recognize-leaflet | 识别说明书 | admin/user |
|
||||
|
||||
### 7.7 用户管理接口
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | /api/v1/users | 获取用户列表 | admin |
|
||||
| GET | /api/v1/users/{id} | 获取用户详情 | admin |
|
||||
| POST | /api/v1/users | 创建用户 | admin |
|
||||
| PUT | /api/v1/users/{id} | 更新用户 | admin |
|
||||
| DELETE | /api/v1/users/{id} | 删除用户 | admin |
|
||||
| POST | /api/v1/users/{id}/reset-password | 重置密码 | admin |
|
||||
|
||||
### 7.8 通知接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/notifications | 获取通知列表 |
|
||||
| PUT | /api/v1/notifications/{id}/read | 标记为已读 |
|
||||
| PUT | /api/v1/notifications/read-all | 全部标记为已读 |
|
||||
| DELETE | /api/v1/notifications/{id} | 删除通知 |
|
||||
|
||||
### 7.9 系统设置接口
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | /api/v1/settings | 获取所有设置 | admin |
|
||||
| GET | /api/v1/settings/{key} | 获取单个设置 | admin |
|
||||
| PUT | /api/v1/settings/{key} | 更新设置 | admin |
|
||||
|
||||
## 8. 开发指南
|
||||
|
||||
### 8.1 添加新的 API 接口
|
||||
|
||||
1. 在 `app/api/v1/` 目录下创建或修改路由文件
|
||||
2. 定义请求和响应的 Pydantic 模式
|
||||
3. 实现业务逻辑(服务层)
|
||||
4. 在 `app/api/router.py` 中注册路由
|
||||
|
||||
示例:
|
||||
```python
|
||||
# app/api/v1/example.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.core.deps import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/")
|
||||
async def list_examples(current_user = Depends(get_current_user)):
|
||||
return {"message": "success"}
|
||||
```
|
||||
|
||||
### 8.2 添加新的数据模型
|
||||
|
||||
1. 在 `app/models/` 目录下创建模型文件
|
||||
2. 继承 `Base` 类
|
||||
3. 定义表名和字段
|
||||
4. 在 `app/models/__init__.py` 中导入
|
||||
|
||||
```python
|
||||
# app/models/example.py
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from app.database import Base
|
||||
|
||||
class Example(Base):
|
||||
__tablename__ = "examples"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
```
|
||||
|
||||
### 8.3 添加新的服务
|
||||
|
||||
1. 在 `app/services/` 目录下创建服务文件
|
||||
2. 实现业务逻辑
|
||||
3. 使用 Repository 进行数据访问
|
||||
|
||||
```python
|
||||
# app/services/example.py
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.repositories.example import ExampleRepository
|
||||
|
||||
class ExampleService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = ExampleRepository(db)
|
||||
|
||||
async def get_example(self, example_id: int):
|
||||
return await self.repo.get_by_id(example_id)
|
||||
```
|
||||
|
||||
### 8.4 添加新的 AI Provider
|
||||
|
||||
1. 在 `app/ai/` 目录下创建 Provider 文件
|
||||
2. 继承 `VisionProvider` 或 `TextProvider`
|
||||
3. 实现抽象方法
|
||||
4. 在 `app/ai/manager.py` 中注册
|
||||
|
||||
```python
|
||||
# app/ai/custom_provider.py
|
||||
from app.ai.base import VisionProvider, VisionResult
|
||||
|
||||
class CustomVisionProvider(VisionProvider):
|
||||
async def recognize_medicine(self, image_bytes: bytes) -> VisionResult:
|
||||
# 实现识别逻辑
|
||||
return VisionResult(generic_name="药品名称")
|
||||
|
||||
async def recognize_dates(self, image_bytes: bytes):
|
||||
# 实现日期识别
|
||||
pass
|
||||
```
|
||||
|
||||
### 8.5 添加新的通知 Provider
|
||||
|
||||
1. 在 `app/notifications/` 目录下创建 Provider 文件
|
||||
2. 继承 `NotificationProvider`
|
||||
3. 实现 `send` 和 `validate_config` 方法
|
||||
|
||||
```python
|
||||
# app/notifications/custom.py
|
||||
from app.notifications.base import NotificationProvider
|
||||
|
||||
class CustomNotificationProvider(NotificationProvider):
|
||||
def __init__(self):
|
||||
self.config = "your-config"
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
return bool(self.config)
|
||||
|
||||
async def send(self, title: str, content: str) -> bool:
|
||||
# 实现发送逻辑
|
||||
return True
|
||||
```
|
||||
|
||||
## 9. 测试
|
||||
|
||||
### 9.1 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
pytest
|
||||
|
||||
# 运行特定测试
|
||||
pytest tests/test_auth.py
|
||||
|
||||
# 运行带详细输出的测试
|
||||
pytest -v
|
||||
```
|
||||
|
||||
### 9.2 编写测试
|
||||
|
||||
```python
|
||||
# tests/test_example.py
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check(client: AsyncClient):
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "healthy"
|
||||
```
|
||||
|
||||
## 10. 部署
|
||||
|
||||
### 10.1 Docker 部署
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
### 10.2 生产环境配置
|
||||
|
||||
```bash
|
||||
# 设置环境变量
|
||||
export DATABASE_URL=sqlite+aiosqlite:///./data/yaoxiang.db
|
||||
export JWT_SECRET_KEY=your-production-secret-key
|
||||
export DEBUG=false
|
||||
|
||||
# 启动服务
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
```
|
||||
|
||||
## 11. 常见问题
|
||||
|
||||
### 11.1 数据库初始化失败
|
||||
|
||||
检查 `data/` 目录是否存在,确保有写入权限。
|
||||
|
||||
### 11.2 AI 服务连接失败
|
||||
|
||||
检查 `.env` 文件中的 API Key 配置是否正确。
|
||||
|
||||
### 11.3 文件上传失败
|
||||
|
||||
检查 `UPLOAD_DIR` 配置的目录是否存在,确保有写入权限。
|
||||
|
||||
## 12. 扩展开发
|
||||
|
||||
### 12.1 添加新的通知渠道
|
||||
|
||||
参考 `app/notifications/serverchan.py` 实现新的通知 Provider。
|
||||
|
||||
### 12.2 添加新的 AI 能力
|
||||
|
||||
1. 扩展 `app/ai/base.py` 中的抽象基类
|
||||
2. 实现新的 Provider
|
||||
3. 在 `app/ai/manager.py` 中注册
|
||||
|
||||
### 12.3 添加新的定时任务
|
||||
|
||||
使用 APScheduler 添加定时任务:
|
||||
|
||||
```python
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
async def my_task():
|
||||
# 任务逻辑
|
||||
pass
|
||||
|
||||
scheduler.add_job(my_task, 'cron', hour=9, minute=0)
|
||||
scheduler.start()
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class VisionResult(BaseModel):
|
||||
generic_name: Optional[str] = None
|
||||
brand_name: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
specification: Optional[str] = None
|
||||
|
||||
|
||||
class DateResult(BaseModel):
|
||||
production_date: Optional[str] = None
|
||||
expiry_date: Optional[str] = None
|
||||
|
||||
|
||||
class LeafletResult(BaseModel):
|
||||
indications: str
|
||||
adult_dose: str
|
||||
child_dose: Optional[str] = None
|
||||
contraindications: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class VisionProvider(ABC):
|
||||
@abstractmethod
|
||||
async def recognize_medicine(self, image_bytes: bytes) -> VisionResult:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def recognize_dates(self, image_bytes: bytes) -> DateResult:
|
||||
pass
|
||||
|
||||
|
||||
class TextProvider(ABC):
|
||||
@abstractmethod
|
||||
async def summarize_leaflet(self, text: str) -> LeafletResult:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def natural_language_search(self, query: str, medicines: list) -> list:
|
||||
pass
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Optional
|
||||
from app.ai.base import VisionProvider, TextProvider
|
||||
|
||||
|
||||
class AIManager:
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self.vision_providers: dict[str, VisionProvider] = {}
|
||||
self.text_providers: dict[str, TextProvider] = {}
|
||||
self._initialized = True
|
||||
|
||||
def register_vision_provider(self, name: str, provider: VisionProvider):
|
||||
self.vision_providers[name] = provider
|
||||
|
||||
def register_text_provider(self, name: str, provider: TextProvider):
|
||||
self.text_providers[name] = provider
|
||||
|
||||
def get_vision_provider(self, name: str) -> Optional[VisionProvider]:
|
||||
return self.vision_providers.get(name)
|
||||
|
||||
def get_text_provider(self, name: str) -> Optional[TextProvider]:
|
||||
return self.text_providers.get(name)
|
||||
|
||||
|
||||
ai_manager = AIManager()
|
||||
@@ -0,0 +1,153 @@
|
||||
import base64
|
||||
import json
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.ai.base import VisionProvider, TextProvider, VisionResult, DateResult, LeafletResult
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class OpenAIVisionProvider(VisionProvider):
|
||||
def __init__(self):
|
||||
self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
self.model = settings.OPENAI_MODEL
|
||||
|
||||
async def recognize_medicine(self, image_bytes: bytes) -> VisionResult:
|
||||
base64_image = base64.b64encode(image_bytes).decode('utf-8')
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": """请识别这张药品包装图片中的信息,返回JSON格式:
|
||||
{
|
||||
"generic_name": "通用名称",
|
||||
"brand_name": "商品名称",
|
||||
"manufacturer": "生产厂家",
|
||||
"specification": "规格"
|
||||
}
|
||||
只提取图片中真实出现的内容,不要猜测。"""
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return VisionResult(**result)
|
||||
|
||||
async def recognize_dates(self, image_bytes: bytes) -> DateResult:
|
||||
base64_image = base64.b64encode(image_bytes).decode('utf-8')
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": """请识别这张图片中的日期信息,返回JSON格式:
|
||||
{
|
||||
"production_date": "生产日期(YYYY-MM-DD格式,如果无法识别则为null)",
|
||||
"expiry_date": "有效期/过期日期(YYYY-MM-DD格式,如果无法识别则为null)"
|
||||
}
|
||||
只提取图片中真实出现的日期,不要猜测或推理。"""
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return DateResult(**result)
|
||||
|
||||
|
||||
class OpenAITextProvider(TextProvider):
|
||||
def __init__(self):
|
||||
self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
self.model = "gpt-4"
|
||||
|
||||
async def summarize_leaflet(self, text: str) -> LeafletResult:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个医疗信息提取助手。请从药品说明书中提取关键信息。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"""请从以下药品说明书中提取关键信息,返回JSON格式:
|
||||
{{
|
||||
"indications": "适应症",
|
||||
"adult_dose": "成人用法用量",
|
||||
"child_dose": "儿童用法用量(如果没有则为null)",
|
||||
"contraindications": "禁忌",
|
||||
"notes": "注意事项(如果有)"
|
||||
}}
|
||||
|
||||
说明书内容:
|
||||
{text}"""
|
||||
}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return LeafletResult(**result)
|
||||
|
||||
async def natural_language_search(self, query: str, medicines: list) -> list:
|
||||
medicines_text = "\n".join([
|
||||
f"- {m['name']}: {m.get('indications', '')}"
|
||||
for m in medicines
|
||||
])
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个药品搜索助手。根据用户描述的症状,从药品列表中找出可能适用的药品。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"""用户描述:{query}
|
||||
|
||||
可用药品列表:
|
||||
{medicines_text}
|
||||
|
||||
请返回JSON格式的搜索结果:
|
||||
{{
|
||||
"results": [
|
||||
{{
|
||||
"medicine_id": 药品ID,
|
||||
"name": "药品名称",
|
||||
"reason": "匹配原因"
|
||||
}}
|
||||
]
|
||||
}}"""
|
||||
}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return result.get('results', [])
|
||||
@@ -0,0 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, medicines, batches, categories, search, notifications, ai, users, settings
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(auth.router, prefix="/v1/auth", tags=["认证"])
|
||||
api_router.include_router(medicines.router, prefix="/v1/medicines", tags=["药品管理"])
|
||||
api_router.include_router(batches.router, prefix="/v1/batches", tags=["批次管理"])
|
||||
api_router.include_router(categories.router, prefix="/v1/categories", tags=["分类管理"])
|
||||
api_router.include_router(search.router, prefix="/v1/search", tags=["搜索"])
|
||||
api_router.include_router(notifications.router, prefix="/v1/notifications", tags=["通知"])
|
||||
api_router.include_router(ai.router, prefix="/v1/ai", tags=["AI 识别"])
|
||||
api_router.include_router(users.router, prefix="/v1/users", tags=["用户管理"])
|
||||
api_router.include_router(settings.router, prefix="/v1/settings", tags=["系统设置"])
|
||||
@@ -0,0 +1,115 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user, require_role
|
||||
from app.models.user import User
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/recognize-medicine")
|
||||
async def recognize_medicine(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
from app.ai.manager import ai_manager
|
||||
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="请上传图片文件")
|
||||
|
||||
image_bytes = await file.read()
|
||||
if len(image_bytes) > settings.MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=413, detail="文件过大")
|
||||
|
||||
vision_provider = ai_manager.get_vision_provider(settings.AI_PROVIDER)
|
||||
if not vision_provider:
|
||||
raise HTTPException(status_code=500, detail="AI 服务未配置")
|
||||
|
||||
try:
|
||||
result = await vision_provider.recognize_medicine(image_bytes)
|
||||
return result.model_dump()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/recognize-dates")
|
||||
async def recognize_dates(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
from app.ai.manager import ai_manager
|
||||
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="请上传图片文件")
|
||||
|
||||
image_bytes = await file.read()
|
||||
if len(image_bytes) > settings.MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=413, detail="文件过大")
|
||||
|
||||
vision_provider = ai_manager.get_vision_provider(settings.AI_PROVIDER)
|
||||
if not vision_provider:
|
||||
raise HTTPException(status_code=500, detail="AI 服务未配置")
|
||||
|
||||
try:
|
||||
result = await vision_provider.recognize_dates(image_bytes)
|
||||
return result.model_dump()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/recognize-leaflet")
|
||||
async def recognize_leaflet(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
from app.ai.manager import ai_manager
|
||||
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="请上传图片文件")
|
||||
|
||||
image_bytes = await file.read()
|
||||
if len(image_bytes) > settings.MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=413, detail="文件过大")
|
||||
|
||||
text_provider = ai_manager.get_text_provider(settings.AI_PROVIDER)
|
||||
if not text_provider:
|
||||
raise HTTPException(status_code=500, detail="AI 服务未配置")
|
||||
|
||||
try:
|
||||
import base64
|
||||
base64_image = base64.b64encode(image_bytes).decode('utf-8')
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
|
||||
response = await client.chat.completions.create(
|
||||
model=settings.OPENAI_MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "请识别这张说明书图片中的文字内容,返回纯文本。"
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
ocr_text = response.choices[0].message.content
|
||||
result = await text_provider.summarize_leaflet(ocr_text)
|
||||
return result.model_dump()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
|
||||
@@ -0,0 +1,42 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.services.auth import AuthService
|
||||
from app.schemas.user import UserLogin, Token, UserResponse
|
||||
from app.schemas.auth import PasswordChangeRequest
|
||||
from app.services.user import UserService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||
service = AuthService(db)
|
||||
result = await service.login(data.username, data.password)
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
return Token(
|
||||
access_token=result["access_token"],
|
||||
token_type=result["token_type"],
|
||||
user=UserResponse.model_validate(result["user"])
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_info(current_user=Depends(get_current_user)):
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@router.put("/password")
|
||||
async def change_password(
|
||||
data: PasswordChangeRequest,
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
service = UserService(db)
|
||||
success = await service.change_password(current_user.id, data.old_password, data.new_password)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
return {"message": "密码修改成功"}
|
||||
@@ -0,0 +1,104 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user, require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.batch import BatchCreate, BatchUpdate, BatchResponse, BatchDispense, BatchAddStock
|
||||
from app.services.batch import BatchService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/medicine/{medicine_id}", response_model=List[BatchResponse])
|
||||
async def list_batches_by_medicine(
|
||||
medicine_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = BatchService(db)
|
||||
batches = await service.get_batches_by_medicine(medicine_id)
|
||||
return [BatchResponse.model_validate(b) for b in batches]
|
||||
|
||||
|
||||
@router.get("/{batch_id}", response_model=BatchResponse)
|
||||
async def get_batch(
|
||||
batch_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = BatchService(db)
|
||||
batch = await service.get_batch(batch_id)
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="批次不存在")
|
||||
return BatchResponse.model_validate(batch)
|
||||
|
||||
|
||||
@router.post("/medicine/{medicine_id}", response_model=BatchResponse)
|
||||
async def create_batch(
|
||||
medicine_id: int,
|
||||
data: BatchCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
service = BatchService(db)
|
||||
batch = await service.create_batch(medicine_id, data)
|
||||
return BatchResponse.model_validate(batch)
|
||||
|
||||
|
||||
@router.put("/{batch_id}", response_model=BatchResponse)
|
||||
async def update_batch(
|
||||
batch_id: int,
|
||||
data: BatchUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
service = BatchService(db)
|
||||
batch = await service.update_batch(batch_id, data)
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="批次不存在")
|
||||
return BatchResponse.model_validate(batch)
|
||||
|
||||
|
||||
@router.delete("/{batch_id}")
|
||||
async def delete_batch(
|
||||
batch_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = BatchService(db)
|
||||
success = await service.delete_batch(batch_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="批次不存在")
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/{batch_id}/dispense", response_model=BatchResponse)
|
||||
async def dispense_batch(
|
||||
batch_id: int,
|
||||
data: BatchDispense,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
service = BatchService(db)
|
||||
try:
|
||||
batch = await service.dispense(batch_id, data.quantity, current_user.id)
|
||||
return BatchResponse.model_validate(batch)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{batch_id}/add-stock", response_model=BatchResponse)
|
||||
async def add_stock_batch(
|
||||
batch_id: int,
|
||||
data: BatchAddStock,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
service = BatchService(db)
|
||||
try:
|
||||
batch = await service.add_stock(batch_id, data.quantity, current_user.id)
|
||||
return BatchResponse.model_validate(batch)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -0,0 +1,83 @@
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user, require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse
|
||||
from app.services.category import CategoryService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_categories(
|
||||
level: Optional[int] = Query(None),
|
||||
parent_id: Optional[int] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = CategoryService(db)
|
||||
categories = await service.get_categories(level=level, parent_id=parent_id)
|
||||
return [CategoryResponse.model_validate(c) for c in categories]
|
||||
|
||||
|
||||
@router.get("/tree")
|
||||
async def get_category_tree(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = CategoryService(db)
|
||||
return await service.get_category_tree()
|
||||
|
||||
|
||||
@router.get("/{category_id}", response_model=CategoryResponse)
|
||||
async def get_category(
|
||||
category_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = CategoryService(db)
|
||||
category = await service.get_category(category_id)
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="分类不存在")
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.post("/", response_model=CategoryResponse)
|
||||
async def create_category(
|
||||
data: CategoryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = CategoryService(db)
|
||||
category = await service.create_category(data.model_dump())
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.put("/{category_id}", response_model=CategoryResponse)
|
||||
async def update_category(
|
||||
category_id: int,
|
||||
data: CategoryUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = CategoryService(db)
|
||||
category = await service.update_category(category_id, data.model_dump(exclude_unset=True))
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="分类不存在")
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.delete("/{category_id}")
|
||||
async def delete_category(
|
||||
category_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = CategoryService(db)
|
||||
success = await service.delete_category(category_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="分类不存在")
|
||||
return {"message": "删除成功"}
|
||||
@@ -0,0 +1,86 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user, require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.medicine import MedicineCreate, MedicineUpdate, MedicineResponse, MedicineWithStock
|
||||
from app.services.medicine import MedicineService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_medicines(
|
||||
category_id: Optional[int] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = MedicineService(db)
|
||||
medicines, total = await service.get_medicines(
|
||||
category_id=category_id,
|
||||
search=search,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
return {
|
||||
"data": [MedicineWithStock.model_validate(m) for m in medicines],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{medicine_id}", response_model=MedicineResponse)
|
||||
async def get_medicine(
|
||||
medicine_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = MedicineService(db)
|
||||
medicine = await service.get_medicine(medicine_id)
|
||||
if not medicine:
|
||||
raise HTTPException(status_code=404, detail="药品不存在")
|
||||
return MedicineResponse.model_validate(medicine)
|
||||
|
||||
|
||||
@router.post("/", response_model=MedicineResponse)
|
||||
async def create_medicine(
|
||||
data: MedicineCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
service = MedicineService(db)
|
||||
medicine = await service.create_medicine(data, current_user.id)
|
||||
return MedicineResponse.model_validate(medicine)
|
||||
|
||||
|
||||
@router.put("/{medicine_id}", response_model=MedicineResponse)
|
||||
async def update_medicine(
|
||||
medicine_id: int,
|
||||
data: MedicineUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
service = MedicineService(db)
|
||||
medicine = await service.update_medicine(medicine_id, data)
|
||||
if not medicine:
|
||||
raise HTTPException(status_code=404, detail="药品不存在")
|
||||
return MedicineResponse.model_validate(medicine)
|
||||
|
||||
|
||||
@router.delete("/{medicine_id}")
|
||||
async def delete_medicine(
|
||||
medicine_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = MedicineService(db)
|
||||
success = await service.delete_medicine(medicine_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="药品不存在")
|
||||
return {"message": "删除成功"}
|
||||
@@ -0,0 +1,77 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.notification import NotificationService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_notifications(
|
||||
is_read: Optional[bool] = Query(None),
|
||||
type: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = NotificationService(db)
|
||||
notifications = await service.get_notifications(
|
||||
user_id=current_user.id,
|
||||
is_read=is_read,
|
||||
type=type,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": n.id,
|
||||
"type": n.type,
|
||||
"title": n.title,
|
||||
"content": n.content,
|
||||
"is_read": n.is_read,
|
||||
"related_id": n.related_id,
|
||||
"created_at": n.created_at
|
||||
}
|
||||
for n in notifications
|
||||
]
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read")
|
||||
async def mark_notification_read(
|
||||
notification_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = NotificationService(db)
|
||||
success = await service.mark_as_read(notification_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="通知不存在")
|
||||
return {"message": "success"}
|
||||
|
||||
|
||||
@router.put("/read-all")
|
||||
async def mark_all_notifications_read(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = NotificationService(db)
|
||||
count = await service.mark_all_as_read(current_user.id)
|
||||
return {"message": "success", "count": count}
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
async def delete_notification(
|
||||
notification_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = NotificationService(db)
|
||||
success = await service.delete_notification(notification_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="通知不存在")
|
||||
return {"message": "删除成功"}
|
||||
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.medicine import MedicineService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NaturalSearchRequest(BaseModel):
|
||||
query: str
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def search_medicines(
|
||||
q: str = Query(...),
|
||||
type: str = Query("name"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = MedicineService(db)
|
||||
medicines = await service.search_medicines(q)
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"generic_name": m.generic_name,
|
||||
"indications": m.indications,
|
||||
"total_quantity": sum(b.quantity for b in m.batches if not b.is_expired)
|
||||
}
|
||||
for m in medicines
|
||||
]
|
||||
|
||||
|
||||
@router.post("/natural")
|
||||
async def natural_language_search(
|
||||
data: NaturalSearchRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
from app.ai.manager import ai_manager
|
||||
from app.config import settings
|
||||
|
||||
service = MedicineService(db)
|
||||
medicines = await service.search_medicines(data.query)
|
||||
|
||||
if not medicines:
|
||||
return {"results": [], "ai_response": "未找到相关药品"}
|
||||
|
||||
text_provider = ai_manager.get_text_provider(settings.AI_PROVIDER)
|
||||
if not text_provider:
|
||||
return {"results": [], "ai_response": "AI 服务未配置"}
|
||||
|
||||
medicines_data = [
|
||||
{"name": m.name, "indications": m.indications or ""}
|
||||
for m in medicines
|
||||
]
|
||||
|
||||
try:
|
||||
results = await text_provider.natural_language_search(data.query, medicines_data)
|
||||
return {"results": results, "ai_response": "搜索完成"}
|
||||
except Exception as e:
|
||||
return {"results": [], "ai_response": f"搜索失败: {str(e)}"}
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import require_role
|
||||
from app.models.user import User
|
||||
from app.models.setting import Setting
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SettingResponse(BaseModel):
|
||||
key: str
|
||||
value: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class SettingUpdate(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class BulkSettingUpdate(BaseModel):
|
||||
settings: List[SettingUpdate]
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
result = await db.execute(select(Setting))
|
||||
settings = list(result.scalars().all())
|
||||
return [
|
||||
{
|
||||
"key": s.key,
|
||||
"value": s.value,
|
||||
"description": s.description
|
||||
}
|
||||
for s in settings
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingResponse)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
result = await db.execute(select(Setting).where(Setting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if not setting:
|
||||
raise HTTPException(status_code=404, detail="设置不存在")
|
||||
return SettingResponse(
|
||||
key=setting.key,
|
||||
value=setting.value,
|
||||
description=setting.description
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=SettingResponse)
|
||||
async def update_setting(
|
||||
key: str,
|
||||
data: SettingUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
result = await db.execute(select(Setting).where(Setting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if not setting:
|
||||
setting = Setting(key=key, value=data.value)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.value = data.value
|
||||
await db.commit()
|
||||
return SettingResponse(
|
||||
key=setting.key,
|
||||
value=setting.value,
|
||||
description=setting.description
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserUpdate, UserResponse
|
||||
from app.services.user import UserService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = UserService(db)
|
||||
users = await service.get_all_users()
|
||||
return [UserResponse.model_validate(u) for u in users]
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = UserService(db)
|
||||
user = await service.get_user(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.post("/", response_model=UserResponse)
|
||||
async def create_user(
|
||||
data: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = UserService(db)
|
||||
existing_user = await service.get_user_by_username(data.username)
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=409, detail="用户名已存在")
|
||||
user = await service.create_user(data.model_dump())
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
data: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = UserService(db)
|
||||
user = await service.update_user(user_id, data.model_dump(exclude_unset=True))
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = UserService(db)
|
||||
success = await service.delete_user(user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/{user_id}/reset-password")
|
||||
async def reset_password(
|
||||
user_id: int,
|
||||
data: ResetPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = UserService(db)
|
||||
success = await service.reset_password(user_id, data.new_password)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return {"message": "success"}
|
||||
@@ -0,0 +1,61 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "药箱"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./data/yaoxiang.db"
|
||||
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRATION_HOURS: int = 24
|
||||
|
||||
AI_PROVIDER: str = "openai"
|
||||
OPENAI_API_KEY: str = ""
|
||||
OPENAI_MODEL: str = "gpt-4o"
|
||||
GEMINI_API_KEY: str = ""
|
||||
GEMINI_MODEL: str = "gemini-pro-vision"
|
||||
ANTHROPIC_API_KEY: str = ""
|
||||
ANTHROPIC_MODEL: str = "claude-3-opus-20240229"
|
||||
DEEPSEEK_API_KEY: str = ""
|
||||
DEEPSEEK_MODEL: str = "deepseek-chat"
|
||||
OLLAMA_BASE_URL: str = "http://localhost:11434"
|
||||
OLLAMA_MODEL: str = "llava"
|
||||
|
||||
NOTIFICATION_PROVIDERS: List[str] = []
|
||||
SERVERCHAN_KEY: str = ""
|
||||
PUSHPLUS_TOKEN: str = ""
|
||||
BARK_URL: str = ""
|
||||
WECHAT_WEBHOOK_URL: str = ""
|
||||
TELEGRAM_BOT_TOKEN: str = ""
|
||||
TELEGRAM_CHAT_ID: str = ""
|
||||
SMTP_HOST: str = ""
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM: str = ""
|
||||
|
||||
UPLOAD_DIR: str = "./data/uploads"
|
||||
MAX_UPLOAD_SIZE: int = 10485760
|
||||
|
||||
EXPIRY_WARNING_DAYS: List[int] = [90, 30, 7]
|
||||
LOW_STOCK_THRESHOLD: int = 5
|
||||
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
EXPIRY_GRACE_DAYS_MAX: int = 60
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,60 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
from app.services.user import UserService
|
||||
from app.models.user import User
|
||||
|
||||
token = credentials.credentials
|
||||
payload = decode_access_token(token)
|
||||
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证令牌"
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证令牌"
|
||||
)
|
||||
|
||||
user_service = UserService(db)
|
||||
user = await user_service.get_user(int(user_id))
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="用户已被禁用"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def require_role(roles: list[str]):
|
||||
async def role_checker(current_user=Depends(get_current_user)):
|
||||
if current_user.role not in roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足"
|
||||
)
|
||||
return current_user
|
||||
return role_checker
|
||||
@@ -0,0 +1,47 @@
|
||||
from fastapi import Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
def __init__(self, code: int, message: str, detail: any = None):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.detail = detail
|
||||
|
||||
|
||||
async def app_exception_handler(request: Request, exc: AppException):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"code": exc.code,
|
||||
"message": exc.message,
|
||||
"detail": exc.detail
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"code": exc.status_code,
|
||||
"message": exc.detail
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"code": 500,
|
||||
"message": "服务器内部错误"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app):
|
||||
from fastapi import FastAPI
|
||||
app.add_exception_handler(AppException, app_exception_handler)
|
||||
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
@@ -0,0 +1,35 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRATION_HOURS)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
@@ -0,0 +1,37 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,51 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.config import settings
|
||||
from app.database import init_db
|
||||
from app.api.router import api_router
|
||||
from app.core.exceptions import register_exception_handlers
|
||||
from app.notifications.manager import notification_manager
|
||||
from app.ai.manager import ai_manager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
|
||||
notification_manager.load_providers()
|
||||
|
||||
from app.ai.openai_provider import OpenAIVisionProvider, OpenAITextProvider
|
||||
if settings.AI_PROVIDER == "openai":
|
||||
ai_manager.register_vision_provider("openai", OpenAIVisionProvider())
|
||||
ai_manager.register_text_provider("openai", OpenAITextProvider())
|
||||
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="药箱 API",
|
||||
description="家庭药品与应急物资管理系统 API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if settings.DEBUG else None,
|
||||
redoc_url="/redoc" if settings.DEBUG else None,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "version": "1.0.0"}
|
||||
@@ -0,0 +1,17 @@
|
||||
from app.models.user import User
|
||||
from app.models.medicine import Medicine
|
||||
from app.models.batch import Batch
|
||||
from app.models.category import Category
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.setting import Setting
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Medicine",
|
||||
"Batch",
|
||||
"Category",
|
||||
"AuditLog",
|
||||
"Notification",
|
||||
"Setting"
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
medicine_id = Column(Integer, ForeignKey("medicines.id"), nullable=False, index=True)
|
||||
batch_id = Column(Integer, ForeignKey("batches.id"))
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
action = Column(String(50), nullable=False)
|
||||
quantity_change = Column(Integer, nullable=False)
|
||||
quantity_after = Column(Integer, nullable=False)
|
||||
remark = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
medicine = relationship("Medicine", back_populates="audit_logs")
|
||||
batch = relationship("Batch", back_populates="audit_logs")
|
||||
user = relationship("User", back_populates="audit_logs")
|
||||
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import Column, Integer, String, Date, Boolean, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Batch(Base):
|
||||
__tablename__ = "batches"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
medicine_id = Column(Integer, ForeignKey("medicines.id"), nullable=False, index=True)
|
||||
batch_no = Column(String(100))
|
||||
production_date = Column(Date)
|
||||
expiry_date = Column(Date, nullable=False)
|
||||
quantity = Column(Integer, nullable=False, default=0)
|
||||
location = Column(String(200))
|
||||
is_expired = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
medicine = relationship("Medicine", back_populates="batches")
|
||||
audit_logs = relationship("AuditLog", back_populates="batch")
|
||||
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
parent_id = Column(Integer, ForeignKey("categories.id"))
|
||||
level = Column(Integer, nullable=False, default=1)
|
||||
icon = Column(String(50))
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
parent = relationship("Category", remote_side=[id])
|
||||
children = relationship("Category", back_populates="parent")
|
||||
medicines = relationship("Medicine", back_populates="category")
|
||||
@@ -0,0 +1,35 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Medicine(Base):
|
||||
__tablename__ = "medicines"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False, index=True)
|
||||
generic_name = Column(String(200), index=True)
|
||||
brand_name = Column(String(200))
|
||||
manufacturer = Column(String(200))
|
||||
specification = Column(String(200))
|
||||
category_id = Column(Integer, ForeignKey("categories.id"))
|
||||
description = Column(Text)
|
||||
indications = Column(Text)
|
||||
adult_dose = Column(Text)
|
||||
child_dose = Column(Text)
|
||||
contraindications = Column(Text)
|
||||
notes = Column(Text)
|
||||
image_front_path = Column(String(500))
|
||||
image_expiry_path = Column(String(500))
|
||||
image_leaflet_paths = Column(JSON)
|
||||
expiry_grace_days = Column(Integer, default=0)
|
||||
created_by = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
category = relationship("Category", back_populates="medicines")
|
||||
creator = relationship("User", back_populates="medicines")
|
||||
batches = relationship("Batch", back_populates="medicine", cascade="all, delete-orphan")
|
||||
audit_logs = relationship("AuditLog", back_populates="medicine")
|
||||
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Text, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
type = Column(String(50), nullable=False)
|
||||
title = Column(String(200), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
is_read = Column(Boolean, default=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
related_id = Column(Integer)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="notifications")
|
||||
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(100), unique=True, index=True, nullable=False)
|
||||
value = Column(Text)
|
||||
description = Column(String(500))
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
role = Column(String(20), nullable=False, default="user")
|
||||
display_name = Column(String(100))
|
||||
email = Column(String(100))
|
||||
notification_level = Column(String(20), default="normal")
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
medicines = relationship("Medicine", back_populates="creator")
|
||||
audit_logs = relationship("AuditLog", back_populates="user")
|
||||
notifications = relationship("Notification", back_populates="user")
|
||||
@@ -0,0 +1,11 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class NotificationProvider(ABC):
|
||||
@abstractmethod
|
||||
async def send(self, title: str, content: str) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def validate_config(self) -> bool:
|
||||
pass
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import List, Optional
|
||||
from app.notifications.base import NotificationProvider
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class NotificationManager:
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self.providers: List[NotificationProvider] = []
|
||||
self._initialized = True
|
||||
|
||||
def add_provider(self, provider: NotificationProvider):
|
||||
self.providers.append(provider)
|
||||
|
||||
async def send_notification(self, title: str, content: str):
|
||||
for provider in self.providers:
|
||||
try:
|
||||
await provider.send(title, content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def load_providers(self):
|
||||
from app.notifications.serverchan import ServerChanProvider
|
||||
from app.notifications.pushplus import PushPlusProvider
|
||||
|
||||
if "serverchan" in settings.NOTIFICATION_PROVIDERS:
|
||||
provider = ServerChanProvider()
|
||||
if provider.validate_config():
|
||||
self.add_provider(provider)
|
||||
|
||||
if "pushplus" in settings.NOTIFICATION_PROVIDERS:
|
||||
provider = PushPlusProvider()
|
||||
if provider.validate_config():
|
||||
self.add_provider(provider)
|
||||
|
||||
|
||||
notification_manager = NotificationManager()
|
||||
@@ -0,0 +1,27 @@
|
||||
import httpx
|
||||
from app.notifications.base import NotificationProvider
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class PushPlusProvider(NotificationProvider):
|
||||
def __init__(self):
|
||||
self.token = settings.PUSHPLUS_TOKEN
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
return bool(self.token)
|
||||
|
||||
async def send(self, title: str, content: str) -> bool:
|
||||
if not self.validate_config():
|
||||
return False
|
||||
|
||||
url = "https://www.pushplus.plus/send"
|
||||
data = {
|
||||
"token": self.token,
|
||||
"title": title,
|
||||
"content": content
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=data)
|
||||
result = response.json()
|
||||
return result.get("code") == 200
|
||||
@@ -0,0 +1,25 @@
|
||||
import httpx
|
||||
from app.notifications.base import NotificationProvider
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class ServerChanProvider(NotificationProvider):
|
||||
def __init__(self):
|
||||
self.key = settings.SERVERCHAN_KEY
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
return bool(self.key)
|
||||
|
||||
async def send(self, title: str, content: str) -> bool:
|
||||
if not self.validate_config():
|
||||
return False
|
||||
|
||||
url = f"https://sctapi.ftqq.com/{self.key}.send"
|
||||
data = {
|
||||
"title": title,
|
||||
"desp": content
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, data=data)
|
||||
return response.status_code == 200
|
||||
@@ -0,0 +1,51 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
class AuditLogRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, log_id: int) -> Optional[AuditLog]:
|
||||
result = await self.db.execute(
|
||||
select(AuditLog)
|
||||
.options(selectinload(AuditLog.medicine), selectinload(AuditLog.user))
|
||||
.where(AuditLog.id == log_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
medicine_id: Optional[int] = None,
|
||||
user_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> List[AuditLog]:
|
||||
query = select(AuditLog).options(
|
||||
selectinload(AuditLog.medicine),
|
||||
selectinload(AuditLog.user)
|
||||
)
|
||||
|
||||
if medicine_id:
|
||||
query = query.where(AuditLog.medicine_id == medicine_id)
|
||||
if user_id:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
|
||||
query = query.order_by(AuditLog.created_at.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict) -> AuditLog:
|
||||
log = AuditLog(**data)
|
||||
self.db.add(log)
|
||||
await self.db.flush()
|
||||
return log
|
||||
@@ -0,0 +1,56 @@
|
||||
from typing import Optional, List
|
||||
from datetime import date
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.batch import Batch
|
||||
|
||||
|
||||
class BatchRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, batch_id: int) -> Optional[Batch]:
|
||||
result = await self.db.execute(select(Batch).where(Batch.id == batch_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_medicine_id(self, medicine_id: int) -> List[Batch]:
|
||||
result = await self.db.execute(
|
||||
select(Batch)
|
||||
.where(Batch.medicine_id == medicine_id)
|
||||
.order_by(Batch.expiry_date)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_expiring_before(self, target_date: date) -> List[Batch]:
|
||||
result = await self.db.execute(
|
||||
select(Batch)
|
||||
.where(
|
||||
Batch.expiry_date <= target_date,
|
||||
Batch.is_expired == False,
|
||||
Batch.quantity > 0
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict) -> Batch:
|
||||
batch = Batch(**data)
|
||||
self.db.add(batch)
|
||||
await self.db.flush()
|
||||
return batch
|
||||
|
||||
async def update(self, batch_id: int, data: dict) -> Optional[Batch]:
|
||||
batch = await self.get_by_id(batch_id)
|
||||
if not batch:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
setattr(batch, key, value)
|
||||
await self.db.flush()
|
||||
return batch
|
||||
|
||||
async def delete(self, batch_id: int) -> bool:
|
||||
batch = await self.get_by_id(batch_id)
|
||||
if not batch:
|
||||
return False
|
||||
await self.db.delete(batch)
|
||||
return True
|
||||
@@ -0,0 +1,54 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.category import Category
|
||||
|
||||
|
||||
class CategoryRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, category_id: int) -> Optional[Category]:
|
||||
result = await self.db.execute(select(Category).where(Category.id == category_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all(self, level: Optional[int] = None, parent_id: Optional[int] = None) -> List[Category]:
|
||||
query = select(Category)
|
||||
if level:
|
||||
query = query.where(Category.level == level)
|
||||
if parent_id:
|
||||
query = query.where(Category.parent_id == parent_id)
|
||||
query = query.order_by(Category.sort_order)
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_children(self, parent_id: int) -> List[Category]:
|
||||
result = await self.db.execute(
|
||||
select(Category)
|
||||
.where(Category.parent_id == parent_id)
|
||||
.order_by(Category.sort_order)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict) -> Category:
|
||||
category = Category(**data)
|
||||
self.db.add(category)
|
||||
await self.db.flush()
|
||||
return category
|
||||
|
||||
async def update(self, category_id: int, data: dict) -> Optional[Category]:
|
||||
category = await self.get_by_id(category_id)
|
||||
if not category:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
setattr(category, key, value)
|
||||
await self.db.flush()
|
||||
return category
|
||||
|
||||
async def delete(self, category_id: int) -> bool:
|
||||
category = await self.get_by_id(category_id)
|
||||
if not category:
|
||||
return False
|
||||
await self.db.delete(category)
|
||||
return True
|
||||
@@ -0,0 +1,92 @@
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.medicine import Medicine
|
||||
|
||||
|
||||
class MedicineRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, medicine_id: int) -> Optional[Medicine]:
|
||||
result = await self.db.execute(
|
||||
select(Medicine)
|
||||
.options(selectinload(Medicine.batches))
|
||||
.where(Medicine.id == medicine_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
category_id: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> Tuple[List[Medicine], int]:
|
||||
query = select(Medicine).options(selectinload(Medicine.batches))
|
||||
|
||||
if category_id:
|
||||
query = query.where(Medicine.category_id == category_id)
|
||||
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
query = query.where(
|
||||
Medicine.name.ilike(search_filter) |
|
||||
Medicine.generic_name.ilike(search_filter) |
|
||||
Medicine.brand_name.ilike(search_filter)
|
||||
)
|
||||
|
||||
count_query = select(func.count()).select_from(Medicine)
|
||||
if category_id:
|
||||
count_query = count_query.where(Medicine.category_id == category_id)
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
count_query = count_query.where(
|
||||
Medicine.name.ilike(search_filter) |
|
||||
Medicine.generic_name.ilike(search_filter) |
|
||||
Medicine.brand_name.ilike(search_filter)
|
||||
)
|
||||
|
||||
total_result = await self.db.execute(count_query)
|
||||
total = total_result.scalar()
|
||||
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await self.db.execute(query)
|
||||
medicines = list(result.scalars().all())
|
||||
|
||||
return medicines, total
|
||||
|
||||
async def create(self, data: dict) -> Medicine:
|
||||
medicine = Medicine(**data)
|
||||
self.db.add(medicine)
|
||||
await self.db.flush()
|
||||
return medicine
|
||||
|
||||
async def update(self, medicine_id: int, data: dict) -> Optional[Medicine]:
|
||||
medicine = await self.get_by_id(medicine_id)
|
||||
if not medicine:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
setattr(medicine, key, value)
|
||||
await self.db.flush()
|
||||
return medicine
|
||||
|
||||
async def delete(self, medicine_id: int) -> bool:
|
||||
medicine = await self.get_by_id(medicine_id)
|
||||
if not medicine:
|
||||
return False
|
||||
await self.db.delete(medicine)
|
||||
return True
|
||||
|
||||
async def search(self, query: str) -> List[Medicine]:
|
||||
search_filter = f"%{query}%"
|
||||
result = await self.db.execute(
|
||||
select(Medicine).options(selectinload(Medicine.batches)).where(
|
||||
Medicine.name.ilike(search_filter) |
|
||||
Medicine.generic_name.ilike(search_filter) |
|
||||
Medicine.indications.ilike(search_filter)
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,71 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification import Notification
|
||||
|
||||
|
||||
class NotificationRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, notification_id: int) -> Optional[Notification]:
|
||||
result = await self.db.execute(
|
||||
select(Notification).where(Notification.id == notification_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
user_id: Optional[int] = None,
|
||||
is_read: Optional[bool] = None,
|
||||
type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> List[Notification]:
|
||||
query = select(Notification)
|
||||
|
||||
if user_id:
|
||||
query = query.where(Notification.user_id == user_id)
|
||||
if is_read is not None:
|
||||
query = query.where(Notification.is_read == is_read)
|
||||
if type:
|
||||
query = query.where(Notification.type == type)
|
||||
|
||||
query = query.order_by(Notification.created_at.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict) -> Notification:
|
||||
notification = Notification(**data)
|
||||
self.db.add(notification)
|
||||
await self.db.flush()
|
||||
return notification
|
||||
|
||||
async def mark_as_read(self, notification_id: int) -> bool:
|
||||
notification = await self.get_by_id(notification_id)
|
||||
if not notification:
|
||||
return False
|
||||
notification.is_read = True
|
||||
return True
|
||||
|
||||
async def mark_all_as_read(self, user_id: int) -> int:
|
||||
result = await self.db.execute(
|
||||
select(Notification).where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.is_read == False
|
||||
)
|
||||
)
|
||||
notifications = list(result.scalars().all())
|
||||
for notification in notifications:
|
||||
notification.is_read = True
|
||||
return len(notifications)
|
||||
|
||||
async def delete(self, notification_id: int) -> bool:
|
||||
notification = await self.get_by_id(notification_id)
|
||||
if not notification:
|
||||
return False
|
||||
await self.db.delete(notification)
|
||||
return True
|
||||
@@ -0,0 +1,44 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class UserRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, user_id: int) -> Optional[User]:
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_username(self, username: str) -> Optional[User]:
|
||||
result = await self.db.execute(select(User).where(User.username == username))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all(self) -> List[User]:
|
||||
result = await self.db.execute(select(User))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict) -> User:
|
||||
user = User(**data)
|
||||
self.db.add(user)
|
||||
await self.db.flush()
|
||||
return user
|
||||
|
||||
async def update(self, user_id: int, data: dict) -> Optional[User]:
|
||||
user = await self.get_by_id(user_id)
|
||||
if not user:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
setattr(user, key, value)
|
||||
await self.db.flush()
|
||||
return user
|
||||
|
||||
async def delete(self, user_id: int) -> bool:
|
||||
user = await self.get_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
await self.db.delete(user)
|
||||
return True
|
||||
@@ -0,0 +1,33 @@
|
||||
from app.schemas.user import (
|
||||
UserBase,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
UserResponse,
|
||||
UserLogin,
|
||||
Token
|
||||
)
|
||||
from app.schemas.medicine import (
|
||||
MedicineBase,
|
||||
MedicineCreate,
|
||||
MedicineUpdate,
|
||||
MedicineResponse,
|
||||
MedicineWithStock
|
||||
)
|
||||
from app.schemas.batch import (
|
||||
BatchBase,
|
||||
BatchCreate,
|
||||
BatchUpdate,
|
||||
BatchResponse,
|
||||
BatchDispense,
|
||||
BatchAddStock
|
||||
)
|
||||
from app.schemas.category import (
|
||||
CategoryBase,
|
||||
CategoryCreate,
|
||||
CategoryUpdate,
|
||||
CategoryResponse
|
||||
)
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
PasswordChangeRequest
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
@@ -0,0 +1,42 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
class BatchBase(BaseModel):
|
||||
batch_no: Optional[str] = None
|
||||
production_date: Optional[date] = None
|
||||
expiry_date: date
|
||||
quantity: int = Field(default=0, ge=0)
|
||||
location: Optional[str] = None
|
||||
|
||||
|
||||
class BatchCreate(BatchBase):
|
||||
pass
|
||||
|
||||
|
||||
class BatchUpdate(BaseModel):
|
||||
batch_no: Optional[str] = None
|
||||
production_date: Optional[date] = None
|
||||
expiry_date: Optional[date] = None
|
||||
quantity: Optional[int] = None
|
||||
location: Optional[str] = None
|
||||
|
||||
|
||||
class BatchResponse(BatchBase):
|
||||
id: int
|
||||
medicine_id: int
|
||||
is_expired: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BatchDispense(BaseModel):
|
||||
quantity: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class BatchAddStock(BaseModel):
|
||||
quantity: int = Field(..., gt=0)
|
||||
@@ -0,0 +1,36 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
parent_id: Optional[int] = None
|
||||
level: int = Field(default=1, ge=1, le=2)
|
||||
icon: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
pass
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
level: Optional[int] = None
|
||||
icon: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class CategoryResponse(CategoryBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CategoryWithChildren(CategoryResponse):
|
||||
children: List["CategoryResponse"] = []
|
||||
@@ -0,0 +1,58 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
|
||||
|
||||
class MedicineBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
generic_name: Optional[str] = None
|
||||
brand_name: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
specification: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
indications: Optional[str] = None
|
||||
adult_dose: Optional[str] = None
|
||||
child_dose: Optional[str] = None
|
||||
contraindications: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
expiry_grace_days: int = Field(default=0, ge=0, le=60)
|
||||
|
||||
|
||||
class MedicineCreate(MedicineBase):
|
||||
pass
|
||||
|
||||
|
||||
class MedicineUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
generic_name: Optional[str] = None
|
||||
brand_name: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
specification: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
indications: Optional[str] = None
|
||||
adult_dose: Optional[str] = None
|
||||
child_dose: Optional[str] = None
|
||||
contraindications: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
expiry_grace_days: Optional[int] = None
|
||||
|
||||
|
||||
class MedicineResponse(MedicineBase):
|
||||
id: int
|
||||
image_front_path: Optional[str] = None
|
||||
image_expiry_path: Optional[str] = None
|
||||
image_leaflet_paths: Optional[List[str]] = None
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MedicineWithStock(MedicineResponse):
|
||||
total_quantity: int = 0
|
||||
nearest_expiry_date: Optional[date] = None
|
||||
batch_count: int = 0
|
||||
@@ -0,0 +1,44 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
display_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
role: str = Field(default="user", pattern="^(admin|user|readonly)$")
|
||||
notification_level: str = Field(default="normal", pattern="^(none|low|normal|high)$")
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
notification_level: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserResponse
|
||||
@@ -0,0 +1,47 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.repositories.audit_log import AuditLogRepository
|
||||
|
||||
|
||||
class AuditService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = AuditLogRepository(db)
|
||||
|
||||
async def log_action(
|
||||
self,
|
||||
medicine_id: int,
|
||||
batch_id: Optional[int],
|
||||
user_id: Optional[int],
|
||||
action: str,
|
||||
quantity_change: int,
|
||||
quantity_after: int,
|
||||
remark: Optional[str] = None
|
||||
):
|
||||
data = {
|
||||
"medicine_id": medicine_id,
|
||||
"batch_id": batch_id,
|
||||
"user_id": user_id,
|
||||
"action": action,
|
||||
"quantity_change": quantity_change,
|
||||
"quantity_after": quantity_after,
|
||||
"remark": remark
|
||||
}
|
||||
return await self.repo.create(data)
|
||||
|
||||
async def get_audit_logs(
|
||||
self,
|
||||
medicine_id: Optional[int] = None,
|
||||
user_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> List:
|
||||
return await self.repo.get_list(
|
||||
medicine_id=medicine_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
from app.services.user import UserService
|
||||
from app.core.security import create_access_token
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.user_service = UserService(db)
|
||||
|
||||
async def login(self, username: str, password: str) -> Optional[dict]:
|
||||
user = await self.user_service.authenticate(username, password)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": user
|
||||
}
|
||||
|
||||
async def register(self, data: dict) -> Optional[dict]:
|
||||
existing_user = await self.user_service.get_user_by_username(data["username"])
|
||||
if existing_user:
|
||||
return None
|
||||
|
||||
user = await self.user_service.create_user(data)
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": user
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
from typing import List, Optional
|
||||
from datetime import date, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.batch import Batch
|
||||
from app.schemas.batch import BatchCreate, BatchUpdate
|
||||
from app.repositories.batch import BatchRepository
|
||||
from app.services.audit import AuditService
|
||||
|
||||
|
||||
class BatchService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = BatchRepository(db)
|
||||
self.audit_service = AuditService(db)
|
||||
|
||||
async def get_batches_by_medicine(self, medicine_id: int) -> List[Batch]:
|
||||
return await self.repo.get_by_medicine_id(medicine_id)
|
||||
|
||||
async def get_batch(self, batch_id: int) -> Optional[Batch]:
|
||||
return await self.repo.get_by_id(batch_id)
|
||||
|
||||
async def create_batch(self, medicine_id: int, data: BatchCreate) -> Batch:
|
||||
batch_data = data.model_dump()
|
||||
batch_data['medicine_id'] = medicine_id
|
||||
return await self.repo.create(batch_data)
|
||||
|
||||
async def update_batch(self, batch_id: int, data: BatchUpdate) -> Optional[Batch]:
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
return await self.repo.update(batch_id, update_data)
|
||||
|
||||
async def delete_batch(self, batch_id: int) -> bool:
|
||||
return await self.repo.delete(batch_id)
|
||||
|
||||
async def dispense(self, batch_id: int, quantity: int, user_id: int) -> Optional[Batch]:
|
||||
batch = await self.repo.get_by_id(batch_id)
|
||||
if not batch:
|
||||
raise ValueError("批次不存在")
|
||||
|
||||
if batch.quantity < quantity:
|
||||
raise ValueError("库存不足")
|
||||
|
||||
await self.audit_service.log_action(
|
||||
medicine_id=batch.medicine_id,
|
||||
batch_id=batch_id,
|
||||
user_id=user_id,
|
||||
action="dispense",
|
||||
quantity_change=-quantity,
|
||||
quantity_after=batch.quantity - quantity
|
||||
)
|
||||
|
||||
batch.quantity -= quantity
|
||||
await self.db.commit()
|
||||
|
||||
return batch
|
||||
|
||||
async def add_stock(self, batch_id: int, quantity: int, user_id: int) -> Optional[Batch]:
|
||||
batch = await self.repo.get_by_id(batch_id)
|
||||
if not batch:
|
||||
raise ValueError("批次不存在")
|
||||
|
||||
await self.audit_service.log_action(
|
||||
medicine_id=batch.medicine_id,
|
||||
batch_id=batch_id,
|
||||
user_id=user_id,
|
||||
action="add_stock",
|
||||
quantity_change=quantity,
|
||||
quantity_after=batch.quantity + quantity
|
||||
)
|
||||
|
||||
batch.quantity += quantity
|
||||
await self.db.commit()
|
||||
|
||||
return batch
|
||||
|
||||
async def check_expiring_batches(self, warning_days: List[int]) -> List[dict]:
|
||||
expiring = []
|
||||
today = date.today()
|
||||
|
||||
for days in warning_days:
|
||||
target_date = today + timedelta(days=days)
|
||||
batches = await self.repo.get_expiring_before(target_date)
|
||||
for batch in batches:
|
||||
expiring.append({
|
||||
'batch': batch,
|
||||
'days_until_expiry': days
|
||||
})
|
||||
|
||||
return expiring
|
||||
@@ -0,0 +1,56 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.category import Category
|
||||
from app.repositories.category import CategoryRepository
|
||||
|
||||
|
||||
class CategoryService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = CategoryRepository(db)
|
||||
|
||||
async def get_categories(self, level: Optional[int] = None, parent_id: Optional[int] = None) -> List[Category]:
|
||||
return await self.repo.get_all(level=level, parent_id=parent_id)
|
||||
|
||||
async def get_category(self, category_id: int) -> Optional[Category]:
|
||||
return await self.repo.get_by_id(category_id)
|
||||
|
||||
async def get_category_with_children(self, category_id: int) -> Optional[Category]:
|
||||
category = await self.repo.get_by_id(category_id)
|
||||
if category:
|
||||
category.children = await self.repo.get_children(category_id)
|
||||
return category
|
||||
|
||||
async def create_category(self, data: dict) -> Category:
|
||||
return await self.repo.create(data)
|
||||
|
||||
async def update_category(self, category_id: int, data: dict) -> Optional[Category]:
|
||||
return await self.repo.update(category_id, data)
|
||||
|
||||
async def delete_category(self, category_id: int) -> bool:
|
||||
return await self.repo.delete(category_id)
|
||||
|
||||
async def get_category_tree(self) -> List[dict]:
|
||||
root_categories = await self.repo.get_all(level=1)
|
||||
tree = []
|
||||
for category in root_categories:
|
||||
children = await self.repo.get_children(category.id)
|
||||
tree.append({
|
||||
"id": category.id,
|
||||
"name": category.name,
|
||||
"level": category.level,
|
||||
"icon": category.icon,
|
||||
"sort_order": category.sort_order,
|
||||
"children": [
|
||||
{
|
||||
"id": child.id,
|
||||
"name": child.name,
|
||||
"level": child.level,
|
||||
"icon": child.icon,
|
||||
"sort_order": child.sort_order
|
||||
}
|
||||
for child in children
|
||||
]
|
||||
})
|
||||
return tree
|
||||
@@ -0,0 +1,64 @@
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.medicine import Medicine
|
||||
from app.schemas.medicine import MedicineCreate, MedicineUpdate, MedicineWithStock
|
||||
from app.repositories.medicine import MedicineRepository
|
||||
|
||||
|
||||
class MedicineService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = MedicineRepository(db)
|
||||
|
||||
async def get_medicines(
|
||||
self,
|
||||
category_id: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> Tuple[List[MedicineWithStock], int]:
|
||||
medicines, total = await self.repo.get_list(
|
||||
category_id=category_id,
|
||||
search=search,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
|
||||
result = []
|
||||
for medicine in medicines:
|
||||
total_quantity = sum(b.quantity for b in medicine.batches if not b.is_expired)
|
||||
|
||||
nearest_expiry = None
|
||||
for batch in medicine.batches:
|
||||
if not batch.is_expired:
|
||||
if nearest_expiry is None or batch.expiry_date < nearest_expiry:
|
||||
nearest_expiry = batch.expiry_date
|
||||
|
||||
medicine_with_stock = MedicineWithStock(
|
||||
**medicine.__dict__,
|
||||
total_quantity=total_quantity,
|
||||
nearest_expiry_date=nearest_expiry,
|
||||
batch_count=len([b for b in medicine.batches if not b.is_expired])
|
||||
)
|
||||
result.append(medicine_with_stock)
|
||||
|
||||
return result, total
|
||||
|
||||
async def get_medicine(self, medicine_id: int) -> Optional[Medicine]:
|
||||
return await self.repo.get_by_id(medicine_id)
|
||||
|
||||
async def create_medicine(self, data: MedicineCreate, user_id: int) -> Medicine:
|
||||
medicine_data = data.model_dump()
|
||||
medicine_data['created_by'] = user_id
|
||||
return await self.repo.create(medicine_data)
|
||||
|
||||
async def update_medicine(self, medicine_id: int, data: MedicineUpdate) -> Optional[Medicine]:
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
return await self.repo.update(medicine_id, update_data)
|
||||
|
||||
async def delete_medicine(self, medicine_id: int) -> bool:
|
||||
return await self.repo.delete(medicine_id)
|
||||
|
||||
async def search_medicines(self, query: str) -> List[Medicine]:
|
||||
return await self.repo.search(query)
|
||||
@@ -0,0 +1,39 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification import Notification
|
||||
from app.repositories.notification import NotificationRepository
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = NotificationRepository(db)
|
||||
|
||||
async def get_notifications(
|
||||
self,
|
||||
user_id: Optional[int] = None,
|
||||
is_read: Optional[bool] = None,
|
||||
type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> List[Notification]:
|
||||
return await self.repo.get_list(
|
||||
user_id=user_id,
|
||||
is_read=is_read,
|
||||
type=type,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
|
||||
async def create_notification(self, data: dict) -> Notification:
|
||||
return await self.repo.create(data)
|
||||
|
||||
async def mark_as_read(self, notification_id: int) -> bool:
|
||||
return await self.repo.mark_as_read(notification_id)
|
||||
|
||||
async def mark_all_as_read(self, user_id: int) -> int:
|
||||
return await self.repo.mark_all_as_read(user_id)
|
||||
|
||||
async def delete_notification(self, notification_id: int) -> bool:
|
||||
return await self.repo.delete(notification_id)
|
||||
@@ -0,0 +1,60 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = UserRepository(db)
|
||||
|
||||
async def get_user(self, user_id: int) -> Optional[User]:
|
||||
return await self.repo.get_by_id(user_id)
|
||||
|
||||
async def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
return await self.repo.get_by_username(username)
|
||||
|
||||
async def get_all_users(self) -> List[User]:
|
||||
return await self.repo.get_all()
|
||||
|
||||
async def create_user(self, data: dict) -> User:
|
||||
if "password" in data:
|
||||
data["password_hash"] = get_password_hash(data.pop("password"))
|
||||
return await self.repo.create(data)
|
||||
|
||||
async def update_user(self, user_id: int, data: dict) -> Optional[User]:
|
||||
if "password" in data:
|
||||
data["password_hash"] = get_password_hash(data.pop("password"))
|
||||
return await self.repo.update(user_id, data)
|
||||
|
||||
async def delete_user(self, user_id: int) -> bool:
|
||||
return await self.repo.delete(user_id)
|
||||
|
||||
async def authenticate(self, username: str, password: str) -> Optional[User]:
|
||||
user = await self.repo.get_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
if not user.is_active:
|
||||
return None
|
||||
return user
|
||||
|
||||
async def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
||||
user = await self.repo.get_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
if not verify_password(old_password, user.password_hash):
|
||||
return False
|
||||
await self.repo.update(user_id, {"password_hash": get_password_hash(new_password)})
|
||||
return True
|
||||
|
||||
async def reset_password(self, user_id: int, new_password: str) -> bool:
|
||||
user = await self.repo.get_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
await self.repo.update(user_id, {"password_hash": get_password_hash(new_password)})
|
||||
return True
|
||||
@@ -0,0 +1,15 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class StorageProvider(ABC):
|
||||
@abstractmethod
|
||||
async def save(self, file_path: str, data: bytes) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, file_path: str) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_url(self, file_path: str) -> str:
|
||||
pass
|
||||
@@ -0,0 +1,29 @@
|
||||
import os
|
||||
import aiofiles
|
||||
from app.storage.base import StorageProvider
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class LocalStorageProvider(StorageProvider):
|
||||
def __init__(self):
|
||||
self.upload_dir = settings.UPLOAD_DIR
|
||||
os.makedirs(self.upload_dir, exist_ok=True)
|
||||
|
||||
async def save(self, file_path: str, data: bytes) -> str:
|
||||
full_path = os.path.join(self.upload_dir, file_path)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
|
||||
async with aiofiles.open(full_path, 'wb') as f:
|
||||
await f.write(data)
|
||||
|
||||
return full_path
|
||||
|
||||
async def delete(self, file_path: str) -> bool:
|
||||
full_path = os.path.join(self.upload_dir, file_path)
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_url(self, file_path: str) -> str:
|
||||
return f"/uploads/{file_path}"
|
||||
@@ -0,0 +1,33 @@
|
||||
from app.storage.base import StorageProvider
|
||||
from app.storage.local import LocalStorageProvider
|
||||
|
||||
|
||||
class StorageManager:
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self.provider: StorageProvider = LocalStorageProvider()
|
||||
self._initialized = True
|
||||
|
||||
def set_provider(self, provider: StorageProvider):
|
||||
self.provider = provider
|
||||
|
||||
async def save(self, file_path: str, data: bytes) -> str:
|
||||
return await self.provider.save(file_path, data)
|
||||
|
||||
async def delete(self, file_path: str) -> bool:
|
||||
return await self.provider.delete(file_path)
|
||||
|
||||
async def get_url(self, file_path: str) -> str:
|
||||
return await self.provider.get_url(file_path)
|
||||
|
||||
|
||||
storage_manager = StorageManager()
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import date, timedelta
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.models.batch import Batch
|
||||
from app.models.medicine import Medicine
|
||||
from app.config import settings
|
||||
|
||||
|
||||
async def check_expiring_medicines():
|
||||
async with async_session_factory() as db:
|
||||
today = date.today()
|
||||
|
||||
for days in settings.EXPIRY_WARNING_DAYS:
|
||||
target_date = today + timedelta(days=days)
|
||||
|
||||
query = select(Batch, Medicine).join(
|
||||
Medicine, Batch.medicine_id == Medicine.id
|
||||
).where(
|
||||
Batch.expiry_date <= target_date,
|
||||
Batch.is_expired == False,
|
||||
Batch.quantity > 0
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
batches = result.all()
|
||||
|
||||
if batches:
|
||||
from app.notifications.manager import notification_manager
|
||||
title = f"药品过期提醒 ({days}天内)"
|
||||
content = "以下药品即将过期,请及时处理:\n\n"
|
||||
|
||||
for batch, medicine in batches:
|
||||
content += f"- {medicine.name}: {batch.batch_no or '默认批次'} "
|
||||
content += f"(过期日期: {batch.expiry_date})\n"
|
||||
|
||||
await notification_manager.send_notification(title, content)
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
@@ -0,0 +1,38 @@
|
||||
# Web 框架
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# 数据库
|
||||
sqlalchemy==2.0.23
|
||||
alembic==1.13.0
|
||||
aiosqlite==0.19.0
|
||||
|
||||
# 认证
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
|
||||
# 数据验证
|
||||
pydantic==2.5.2
|
||||
pydantic-settings==2.1.0
|
||||
|
||||
# AI 服务
|
||||
openai==1.6.1
|
||||
anthropic==0.8.0
|
||||
httpx==0.25.2
|
||||
|
||||
# 通知
|
||||
aiohttp==3.9.1
|
||||
|
||||
# 文件处理
|
||||
aiofiles==23.2.1
|
||||
Pillow==10.1.0
|
||||
|
||||
# 工具
|
||||
python-dotenv==1.0.0
|
||||
loguru==0.7.2
|
||||
apscheduler==3.10.4
|
||||
|
||||
# 测试
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.23.2
|
||||
@@ -0,0 +1,25 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
from app.database import init_db
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def setup_db():
|
||||
await init_db()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(setup_db):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
+1249
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,728 @@
|
||||
# 系统架构设计文档
|
||||
|
||||
## 1. 系统概述
|
||||
|
||||
家庭药品与应急物资管理系统(YaoXiang)是一个基于 Web 的家庭药品库存管理系统,支持 AI 自动录入、智能搜索、到期提醒等功能。系统采用前后端分离架构,支持 Docker 单容器部署。
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
### 前端
|
||||
- **框架**: React 18+
|
||||
- **语言**: TypeScript
|
||||
- **构建工具**: Vite
|
||||
- **UI 库**: Ant Design Mobile 5.x(移动端优化)
|
||||
- **状态管理**: Zustand
|
||||
- **路由**: React Router 6
|
||||
- **HTTP 客户端**: Axios
|
||||
- **PWA**: vite-plugin-pwa
|
||||
|
||||
### 后端
|
||||
- **框架**: FastAPI
|
||||
- **语言**: Python 3.11+
|
||||
- **ORM**: SQLAlchemy 2.0
|
||||
- **数据库迁移**: Alembic
|
||||
- **文件存储**: 本地文件系统
|
||||
- **任务队列**: 无(采用异步任务)
|
||||
|
||||
### 数据库
|
||||
- **主数据库**: SQLite(可升级 PostgreSQL)
|
||||
- **缓存**: 无(可选 Redis)
|
||||
|
||||
### AI Provider
|
||||
- **多模态模型**: OpenAI GPT-4o / Gemini / Claude
|
||||
- **文本模型**: DeepSeek / Ollama
|
||||
- **抽象层**: 统一 Provider 接口
|
||||
|
||||
### 部署
|
||||
- **容器化**: Docker
|
||||
- **编排**: Docker Compose
|
||||
- **反向代理**: Nginx(前端静态文件)
|
||||
|
||||
## 3. 系统架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 客户端层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 手机浏览器 │ │ 平板浏览器 │ │ PC 浏览器 │ │
|
||||
│ │ (PWA) │ │ (大屏模式) │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端应用层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ React App │ │ PWA 配置 │ │ 状态管理 │ │
|
||||
│ │ (Vite) │ │ Service Worker │ │ (Zustand) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 页面组件 │ │ 业务组件 │ │ API 调用层 │ │
|
||||
│ │ (Router) │ │ (Ant Design)│ │ (Axios) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ API 通信层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ RESTful API │ │
|
||||
│ │ POST /api/auth/login │ │
|
||||
│ │ GET /api/medicines │ │
|
||||
│ │ POST /api/medicines │ │
|
||||
│ │ POST /api/medicines/recognize │ │
|
||||
│ │ ... │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 后端应用层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ FastAPI │ │ 路由层 │ │ 中间件 │ │
|
||||
│ │ (Router) │ │ (APIRouter)│ │ (Auth) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 服务层 │ │ 数据访问层 │ │ 模型层 │ │
|
||||
│ │ (Service) │ │ (Repository)│ │ (Model) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ AI Provider│ │ 通知系统 │ │ 文件存储 │ │
|
||||
│ │ (Abstract) │ │ (Provider) │ │ (Local) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 数据存储层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ SQLite │ │ 文件系统 │ │ 缓存 │ │
|
||||
│ │ (Database) │ │ (Uploads) │ │ (可选) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 模块划分
|
||||
|
||||
### 4.1 前端模块
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── api/ # API 调用层
|
||||
│ │ ├── client.ts # Axios 实例配置
|
||||
│ │ ├── auth.ts # 认证相关 API
|
||||
│ │ ├── medicines.ts # 药品管理 API
|
||||
│ │ ├── categories.ts # 分类管理 API
|
||||
│ │ ├── batches.ts # 批次管理 API
|
||||
│ │ └── notifications.ts # 通知相关 API
|
||||
│ ├── components/ # 业务组件
|
||||
│ │ ├── MedicineCard/ # 药品卡片
|
||||
│ │ ├── BatchForm/ # 批次表单
|
||||
│ │ ├── CategoryTree/ # 分类树
|
||||
│ │ └── SearchBar/ # 搜索栏
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── Home/ # 首页(库存概览)
|
||||
│ │ ├── MedicineList/ # 药品列表
|
||||
│ │ ├── MedicineDetail/ # 药品详情
|
||||
│ │ ├── AddMedicine/ # 添加药品
|
||||
│ │ ├── QuickDispense/ # 快速取药(大屏模式)
|
||||
│ │ ├── Scanner/ # AI 识别
|
||||
│ │ ├── Search/ # 搜索页面
|
||||
│ │ ├── Notifications/ # 通知中心
|
||||
│ │ ├── Settings/ # 设置页面
|
||||
│ │ └── Login/ # 登录页面
|
||||
│ ├── stores/ # 状态管理
|
||||
│ │ ├── authStore.ts # 认证状态
|
||||
│ │ ├── medicineStore.ts # 药品状态
|
||||
│ │ └── uiStore.ts # UI 状态
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ │ ├── useAuth.ts # 认证 Hook
|
||||
│ │ ├── useMedicine.ts # 药品 Hook
|
||||
│ │ └── useCamera.ts # 摄像头 Hook
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── date.ts # 日期处理
|
||||
│ │ ├── storage.ts # 本地存储
|
||||
│ │ └── validators.ts # 表单验证
|
||||
│ ├── types/ # TypeScript 类型
|
||||
│ │ ├── medicine.ts # 药品类型
|
||||
│ │ ├── batch.ts # 批次类型
|
||||
│ │ ├── user.ts # 用户类型
|
||||
│ │ └── api.ts # API 响应类型
|
||||
│ ├── styles/ # 样式文件
|
||||
│ │ ├── global.css # 全局样式
|
||||
│ │ └── variables.css # CSS 变量
|
||||
│ ├── App.tsx # 根组件
|
||||
│ ├── main.tsx # 入口文件
|
||||
│ └── router.tsx # 路由配置
|
||||
├── public/ # 静态资源
|
||||
├── index.html # HTML 模板
|
||||
├── vite.config.ts # Vite 配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
└── package.json # 依赖配置
|
||||
```
|
||||
|
||||
### 4.2 后端模块
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/ # 路由层
|
||||
│ │ ├── v1/ # API 版本
|
||||
│ │ │ ├── auth.py # 认证路由
|
||||
│ │ │ ├── medicines.py # 药品路由
|
||||
│ │ │ ├── batches.py # 批次路由
|
||||
│ │ │ ├── categories.py # 分类路由
|
||||
│ │ │ ├── search.py # 搜索路由
|
||||
│ │ │ ├── notifications.py # 通知路由
|
||||
│ │ │ ├── ai.py # AI 识别路由
|
||||
│ │ │ └── users.py # 用户管理路由
|
||||
│ │ └── router.py # 路由汇总
|
||||
│ ├── core/ # 核心配置
|
||||
│ │ ├── config.py # 配置管理
|
||||
│ │ ├── security.py # 安全工具
|
||||
│ │ └── deps.py # 依赖注入
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── medicine.py # 药品模型
|
||||
│ │ ├── batch.py # 批次模型
|
||||
│ │ ├── category.py # 分类模型
|
||||
│ │ ├── user.py # 用户模型
|
||||
│ │ ├── notification.py # 通知模型
|
||||
│ │ └── audit.py # 审计日志模型
|
||||
│ ├── schemas/ # Pydantic 模型
|
||||
│ │ ├── medicine.py # 药品 Schema
|
||||
│ │ ├── batch.py # 批次 Schema
|
||||
│ │ ├── category.py # 分类 Schema
|
||||
│ │ ├── user.py # 用户 Schema
|
||||
│ │ └── auth.py # 认证 Schema
|
||||
│ ├── services/ # 服务层
|
||||
│ │ ├── medicine.py # 药品服务
|
||||
│ │ ├── batch.py # 批次服务
|
||||
│ │ ├── category.py # 分类服务
|
||||
│ │ ├── user.py # 用户服务
|
||||
│ │ ├── auth.py # 认证服务
|
||||
│ │ ├── notification.py # 通知服务
|
||||
│ │ └── search.py # 搜索服务
|
||||
│ ├── repositories/ # 数据访问层
|
||||
│ │ ├── medicine.py # 药品仓库
|
||||
│ │ ├── batch.py # 批次仓库
|
||||
│ │ ├── category.py # 分类仓库
|
||||
│ │ └── user.py # 用户仓库
|
||||
│ ├── ai/ # AI Provider
|
||||
│ │ ├── provider.py # 抽象基类
|
||||
│ │ ├── openai.py # OpenAI 实现
|
||||
│ │ ├── gemini.py # Gemini 实现
|
||||
│ │ ├── claude.py # Claude 实现
|
||||
│ │ ├── deepseek.py # DeepSeek 实现
|
||||
│ │ ├── ollama.py # Ollama 实现
|
||||
│ │ └── manager.py # Provider 管理器
|
||||
│ ├── notifications/ # 通知系统
|
||||
│ │ ├── provider.py # 抽象基类
|
||||
│ │ ├── serverchan.py # Server酱
|
||||
│ │ ├── pushplus.py # PushPlus
|
||||
│ │ ├── bark.py # Bark
|
||||
│ │ ├── wechat.py # 企业微信
|
||||
│ │ ├── telegram.py # Telegram
|
||||
│ │ ├── email.py # 邮件
|
||||
│ │ └── manager.py # 通知管理器
|
||||
│ ├── storage/ # 文件存储
|
||||
│ │ ├── local.py # 本地存储
|
||||
│ │ └── manager.py # 存储管理器
|
||||
│ ├── tasks/ # 异步任务
|
||||
│ │ ├── expiry_check.py # 到期检查任务
|
||||
│ │ └── stock_check.py # 库存检查任务
|
||||
│ ├── database.py # 数据库连接
|
||||
│ └── main.py # 应用入口
|
||||
├── alembic/ # 数据库迁移
|
||||
│ ├── versions/
|
||||
│ └── env.py
|
||||
├── tests/ # 测试文件
|
||||
├── requirements.txt # 依赖配置
|
||||
├── alembic.ini # Alembic 配置
|
||||
├── Dockerfile # Docker 配置
|
||||
└── .env.example # 环境变量示例
|
||||
```
|
||||
|
||||
## 5. 数据流设计
|
||||
|
||||
### 5.1 AI 自动录入流程
|
||||
|
||||
```
|
||||
用户上传图片
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 图片预处理 │
|
||||
│ (压缩/格式化) │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 调用 Vision │
|
||||
│ Provider │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 返回识别结果 │
|
||||
│ (JSON) │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 用户确认/编辑 │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 保存到数据库 │
|
||||
│ + 保存图片 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 快速取药流程
|
||||
|
||||
```
|
||||
用户选择药品
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 显示药品详情 │
|
||||
│ (可用批次列表) │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 用户选择批次 │
|
||||
│ 输入取药数量 │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 扣减库存 │
|
||||
│ 记录审计日志 │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 检查库存阈值 │
|
||||
│ 触发通知(可选) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### 5.3 到期提醒流程
|
||||
|
||||
```
|
||||
定时任务触发
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 查询即将过期 │
|
||||
│ 批次 (90/30/7天)│
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 生成提醒内容 │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 调用通知系统 │
|
||||
│ 发送提醒 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 6. AI Provider 抽象层设计
|
||||
|
||||
### 6.1 抽象接口
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
class VisionResult(BaseModel):
|
||||
"""视觉识别结果"""
|
||||
generic_name: str
|
||||
brand_name: Optional[str]
|
||||
manufacturer: Optional[str]
|
||||
specification: Optional[str]
|
||||
|
||||
class DateResult(BaseModel):
|
||||
"""日期识别结果"""
|
||||
production_date: Optional[str]
|
||||
expiry_date: Optional[str]
|
||||
|
||||
class LeafletResult(BaseModel):
|
||||
"""说明书识别结果"""
|
||||
indications: str
|
||||
adult_dose: str
|
||||
child_dose: Optional[str]
|
||||
contraindications: str
|
||||
notes: Optional[str]
|
||||
|
||||
class VisionProvider(ABC):
|
||||
"""视觉模型提供者抽象基类"""
|
||||
|
||||
@abstractmethod
|
||||
async def recognize_medicine(self, image_bytes: bytes) -> VisionResult:
|
||||
"""识别药盒信息"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def recognize_dates(self, image_bytes: bytes) -> DateResult:
|
||||
"""识别日期信息"""
|
||||
pass
|
||||
|
||||
class TextProvider(ABC):
|
||||
"""文本模型提供者抽象基类"""
|
||||
|
||||
@abstractmethod
|
||||
async def summarize_leaflet(self, text: str) -> LeafletResult:
|
||||
"""总结说明书内容"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def natural_language_search(self, query: str, medicines: list) -> list:
|
||||
"""自然语言搜索"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 6.2 Provider 管理器
|
||||
|
||||
```python
|
||||
class AIManager:
|
||||
"""AI Provider 管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.vision_providers: dict[str, VisionProvider] = {}
|
||||
self.text_providers: dict[str, TextProvider] = {}
|
||||
|
||||
def register_vision_provider(self, name: str, provider: VisionProvider):
|
||||
"""注册视觉模型提供者"""
|
||||
self.vision_providers[name] = provider
|
||||
|
||||
def register_text_provider(self, name: str, provider: TextProvider):
|
||||
"""注册文本模型提供者"""
|
||||
self.text_providers[name] = provider
|
||||
|
||||
def get_vision_provider(self, name: str) -> VisionProvider:
|
||||
"""获取视觉模型提供者"""
|
||||
return self.vision_providers.get(name)
|
||||
|
||||
def get_text_provider(self, name: str) -> TextProvider:
|
||||
"""获取文本模型提供者"""
|
||||
return self.text_providers.get(name)
|
||||
```
|
||||
|
||||
## 7. 通知系统设计
|
||||
|
||||
### 7.1 通知 Provider 抽象
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
class NotificationProvider(ABC):
|
||||
"""通知提供者抽象基类"""
|
||||
|
||||
@abstractmethod
|
||||
async def send(self, title: str, content: str) -> bool:
|
||||
"""发送通知"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def validate_config(self) -> bool:
|
||||
"""验证配置"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 7.2 通知管理器
|
||||
|
||||
```python
|
||||
class NotificationManager:
|
||||
"""通知管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.providers: list[NotificationProvider] = []
|
||||
|
||||
def add_provider(self, provider: NotificationProvider):
|
||||
"""添加通知提供者"""
|
||||
self.providers.append(provider)
|
||||
|
||||
async def send_notification(self, title: str, content: str):
|
||||
"""发送通知到所有提供者"""
|
||||
for provider in self.providers:
|
||||
try:
|
||||
await provider.send(title, content)
|
||||
except Exception as e:
|
||||
# 记录错误但不中断
|
||||
pass
|
||||
```
|
||||
|
||||
## 8. 认证授权设计
|
||||
|
||||
### 8.1 用户角色
|
||||
|
||||
- **admin**: 管理员,拥有所有权限
|
||||
- **user**: 普通用户,可查看、添加库存、取药
|
||||
- **readonly**: 只读用户,仅可查看
|
||||
|
||||
### 8.2 权限矩阵
|
||||
|
||||
| 功能 | admin | user | readonly |
|
||||
|------|-------|------|----------|
|
||||
| 查看药品 | ✓ | ✓ | ✓ |
|
||||
| 添加药品 | ✓ | ✓ | ✗ |
|
||||
| 修改药品 | ✓ | ✓ | ✗ |
|
||||
| 删除药品 | ✓ | ✗ | ✗ |
|
||||
| 取药 | ✓ | ✓ | ✗ |
|
||||
| 用户管理 | ✓ | ✗ | ✗ |
|
||||
| 系统设置 | ✓ | ✗ | ✗ |
|
||||
| 通知管理 | ✓ | ✓ | ✗ |
|
||||
|
||||
### 8.3 JWT 认证流程
|
||||
|
||||
```
|
||||
用户登录
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 验证用户名密码 │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 生成 JWT Token │
|
||||
│ (Access Token) │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 返回 Token │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 前端存储 Token │
|
||||
│ (localStorage) │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 后续请求携带 │
|
||||
│ Authorization │
|
||||
│ Header │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 9. 插件系统设计
|
||||
|
||||
### 9.1 插件接口
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
class Plugin(ABC):
|
||||
"""插件抽象基类"""
|
||||
|
||||
@abstractmethod
|
||||
def get_name(self) -> str:
|
||||
"""获取插件名称"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_description(self) -> str:
|
||||
"""获取插件描述"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def initialize(self, app):
|
||||
"""初始化插件"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 9.2 插件管理器
|
||||
|
||||
```python
|
||||
class PluginManager:
|
||||
"""插件管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.plugins: dict[str, Plugin] = {}
|
||||
|
||||
def register_plugin(self, plugin: Plugin):
|
||||
"""注册插件"""
|
||||
name = plugin.get_name()
|
||||
self.plugins[name] = plugin
|
||||
|
||||
def get_plugin(self, name: str) -> Plugin:
|
||||
"""获取插件"""
|
||||
return self.plugins.get(name)
|
||||
|
||||
def list_plugins(self) -> list[str]:
|
||||
"""列出所有插件"""
|
||||
return list(self.plugins.keys())
|
||||
```
|
||||
|
||||
## 10. MCP 协议支持
|
||||
|
||||
### 10.1 MCP 工具定义
|
||||
|
||||
```python
|
||||
from mcp import Tool
|
||||
|
||||
# 查询库存工具
|
||||
query_inventory_tool = Tool(
|
||||
name="query_inventory",
|
||||
description="查询家庭药品库存",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"medicine_name": {
|
||||
"type": "string",
|
||||
"description": "药品名称"
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# 取药工具
|
||||
dispense_medicine_tool = Tool(
|
||||
name="dispense_medicine",
|
||||
description="取药操作",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"medicine_id": {
|
||||
"type": "integer",
|
||||
"description": "药品ID"
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"description": "取药数量"
|
||||
}
|
||||
},
|
||||
"required": ["medicine_id", "quantity"]
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## 11. 部署架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Docker 容器 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Nginx │ │ FastAPI │ │
|
||||
│ │ (静态文件) │ │ (后端) │ │
|
||||
│ │ :80 │ │ :8000 │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ SQLite │ │ 文件存储 │ │
|
||||
│ │ (数据库) │ │ (图片) │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 12. 环境变量配置
|
||||
|
||||
```bash
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite:///./data/yaoxiang.db
|
||||
|
||||
# AI Provider 配置
|
||||
AI_PROVIDER=openai
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
OPENAI_MODEL=gpt-4o
|
||||
|
||||
# 通知配置
|
||||
NOTIFICATION_PROVIDERS=serverchan,pushplus
|
||||
SERVERCHAN_KEY=xxx
|
||||
PUSHPLUS_TOKEN=xxx
|
||||
|
||||
# 安全配置
|
||||
JWT_SECRET_KEY=xxx
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRATION_HOURS=24
|
||||
|
||||
# 文件存储配置
|
||||
UPLOAD_DIR=./data/uploads
|
||||
MAX_UPLOAD_SIZE=10485760 # 10MB
|
||||
|
||||
# 应用配置
|
||||
APP_NAME=药箱
|
||||
APP_VERSION=1.0.0
|
||||
DEBUG=false
|
||||
```
|
||||
|
||||
## 13. 开发规范
|
||||
|
||||
### 13.1 代码风格
|
||||
|
||||
- **Python**: 遵循 PEP 8,使用 Black 格式化
|
||||
- **TypeScript**: 遵循 ESLint 规则,使用 Prettier 格式化
|
||||
- **Git**: 使用 Conventional Commits 规范
|
||||
|
||||
### 13.2 分支管理
|
||||
|
||||
- `main`: 生产分支
|
||||
- `develop`: 开发分支
|
||||
- `feature/*`: 功能分支
|
||||
- `bugfix/*`: 修复分支
|
||||
- `release/*`: 发布分支
|
||||
|
||||
### 13.3 提交规范
|
||||
|
||||
```
|
||||
feat: 新功能
|
||||
fix: 修复 bug
|
||||
docs: 文档更新
|
||||
style: 代码格式调整
|
||||
refactor: 重构
|
||||
test: 测试相关
|
||||
chore: 构建/工具相关
|
||||
```
|
||||
|
||||
## 14. 性能优化
|
||||
|
||||
### 14.1 前端优化
|
||||
|
||||
- 路由懒加载
|
||||
- 图片懒加载
|
||||
- 虚拟列表(长列表优化)
|
||||
- Service Worker 缓存
|
||||
|
||||
### 14.2 后端优化
|
||||
|
||||
- 数据库连接池
|
||||
- 查询优化(索引、分页)
|
||||
- 异步处理耗时任务
|
||||
- 响应缓存
|
||||
|
||||
## 15. 安全设计
|
||||
|
||||
### 15.1 认证安全
|
||||
|
||||
- 密码使用 bcrypt 加密存储
|
||||
- JWT Token 定期轮换
|
||||
- 登录失败次数限制
|
||||
|
||||
### 15.2 数据安全
|
||||
|
||||
- 敏感配置使用环境变量
|
||||
- 文件上传类型验证
|
||||
- SQL 注入防护(ORM)
|
||||
- XSS 防护(前端)
|
||||
|
||||
### 15.3 传输安全
|
||||
|
||||
- 支持 HTTPS
|
||||
- CORS 配置
|
||||
- 请求限流
|
||||
+1439
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,834 @@
|
||||
# 通信协议文档
|
||||
|
||||
## 1. 概述
|
||||
|
||||
本文档定义了药箱系统前后端之间的通信协议,包括数据格式、错误处理、文件上传等内容。
|
||||
|
||||
## 2. 通信架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端应用 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ API 调用层 │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
|
||||
│ │ │ Axios │ │ 请求拦截器 │ │ 响应拦截器 │ │ │
|
||||
│ │ │ Client │ │ (Auth) │ │ (Error) │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ HTTP/HTTPS
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 后端服务 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ FastAPI │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
|
||||
│ │ │ 路由层 │ │ 中间件 │ │ 依赖注入 │ │ │
|
||||
│ │ │ (Router) │ │ (Auth) │ │ (Deps) │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 3. 数据格式规范
|
||||
|
||||
### 3.1 请求格式
|
||||
|
||||
**Content-Type:**
|
||||
- JSON: `application/json`
|
||||
- 文件上传: `multipart/form-data`
|
||||
- 表单: `application/x-www-form-urlencoded`
|
||||
|
||||
**请求头:**
|
||||
```
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <token>
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
### 3.2 响应格式
|
||||
|
||||
**成功响应(单个对象):**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"name": "布洛芬"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应(列表):**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"data": [...],
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应(无数据):**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "删除成功"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应:**
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "请求参数错误",
|
||||
"detail": "name 字段不能为空"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 HTTP 状态码
|
||||
|
||||
| 状态码 | 说明 | 使用场景 |
|
||||
|--------|------|----------|
|
||||
| 200 | OK | 请求成功 |
|
||||
| 201 | Created | 创建成功 |
|
||||
| 204 | No Content | 删除成功(无响应体) |
|
||||
| 400 | Bad Request | 请求参数错误 |
|
||||
| 401 | Unauthorized | 未认证或令牌过期 |
|
||||
| 403 | Forbidden | 权限不足 |
|
||||
| 404 | Not Found | 资源不存在 |
|
||||
| 409 | Conflict | 资源冲突(如用户名已存在) |
|
||||
| 413 | Payload Too Large | 文件过大 |
|
||||
| 415 | Unsupported Media Type | 不支持的文件类型 |
|
||||
| 422 | Unprocessable Entity | 请求体格式正确但语义错误 |
|
||||
| 500 | Internal Server Error | 服务器内部错误 |
|
||||
|
||||
### 3.4 业务状态码
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|--------|------|
|
||||
| 1000 | 成功 |
|
||||
| 2000 | 参数错误 |
|
||||
| 3000 | 认证错误 |
|
||||
| 4000 | 权限错误 |
|
||||
| 5000 | 业务逻辑错误 |
|
||||
| 6000 | 外部服务错误 |
|
||||
| 9000 | 系统错误 |
|
||||
|
||||
## 4. 认证协议
|
||||
|
||||
### 4.1 JWT Token 格式
|
||||
|
||||
**Header:**
|
||||
```json
|
||||
{
|
||||
"alg": "HS256",
|
||||
"typ": "JWT"
|
||||
}
|
||||
```
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"sub": "1",
|
||||
"username": "admin",
|
||||
"role": "admin",
|
||||
"iat": 1704067200,
|
||||
"exp": 1704153600
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Token 传递
|
||||
|
||||
**方式1:Authorization Header(推荐)**
|
||||
```
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
**方式2:Query Parameter(不推荐,仅用于特殊情况)**
|
||||
```
|
||||
GET /api/v1/medicines?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
### 4.3 Token 过期处理
|
||||
|
||||
**前端处理流程:**
|
||||
```
|
||||
1. 发送请求
|
||||
2. 收到 401 响应
|
||||
3. 尝试刷新 Token(如果有 Refresh Token)
|
||||
4. 刷新失败 → 跳转到登录页
|
||||
5. 刷新成功 → 重新发送原请求
|
||||
```
|
||||
|
||||
**前端代码示例:**
|
||||
```typescript
|
||||
// api/client.ts
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// 尝试刷新 Token
|
||||
const refreshToken = useAuthStore.getState().refreshToken;
|
||||
if (refreshToken) {
|
||||
const response = await axios.post('/api/v1/auth/refresh', {
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
const { access_token } = response.data.data;
|
||||
useAuthStore.getState().setToken(access_token);
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return client(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,跳转到登录页
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## 5. 文件上传协议
|
||||
|
||||
### 5.1 单文件上传
|
||||
|
||||
**请求格式:**
|
||||
```http
|
||||
POST /api/v1/upload/image HTTP/1.1
|
||||
Host: localhost:8000
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="file"; filename="medicine.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
<二进制数据>
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="category"
|
||||
|
||||
medicine
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||
```
|
||||
|
||||
**前端实现:**
|
||||
```typescript
|
||||
const uploadImage = async (file: File, category: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('category', category);
|
||||
|
||||
const response = await client.post('/upload/image', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
```
|
||||
|
||||
### 5.2 多文件上传
|
||||
|
||||
**请求格式:**
|
||||
```http
|
||||
POST /api/v1/upload/images HTTP/1.1
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="files"; filename="image1.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
<二进制数据>
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="files"; filename="image2.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
<二进制数据>
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||
```
|
||||
|
||||
### 5.3 文件大小限制
|
||||
|
||||
- 图片文件:最大 10MB
|
||||
- 说明书图片:最大 20MB
|
||||
|
||||
**前端检查:**
|
||||
```typescript
|
||||
const validateFileSize = (file: File, maxSize: number): boolean => {
|
||||
return file.size <= maxSize;
|
||||
};
|
||||
|
||||
const validateImageFile = (file: File): boolean => {
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
if (!validateFileSize(file, maxSize)) {
|
||||
Toast.show({ content: '文件大小不能超过10MB' });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
Toast.show({ content: '只支持 JPG、PNG、WebP 格式' });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
```
|
||||
|
||||
### 5.4 图片压缩
|
||||
|
||||
**前端压缩实现:**
|
||||
```typescript
|
||||
const compressImage = async (
|
||||
file: File,
|
||||
maxWidth: number = 1920,
|
||||
quality: number = 0.8
|
||||
): Promise<File> => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (width > maxWidth) {
|
||||
height = (height * maxWidth) / width;
|
||||
width = maxWidth;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx?.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
const compressedFile = new File([blob!], file.name, {
|
||||
type: 'image/jpeg',
|
||||
lastModified: Date.now()
|
||||
});
|
||||
resolve(compressedFile);
|
||||
},
|
||||
'image/jpeg',
|
||||
quality
|
||||
);
|
||||
};
|
||||
img.src = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## 6. 分页协议
|
||||
|
||||
### 6.1 请求分页参数
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| page | integer | 1 | 页码(从1开始) |
|
||||
| page_size | integer | 20 | 每页数量(最大100) |
|
||||
|
||||
**示例:**
|
||||
```
|
||||
GET /api/v1/medicines?page=2&page_size=10
|
||||
```
|
||||
|
||||
### 6.2 响应分页数据
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"data": [...],
|
||||
"total": 100,
|
||||
"page": 2,
|
||||
"page_size": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 前端分页实现
|
||||
|
||||
```typescript
|
||||
// 使用 antd-mobile 的 InfiniteScroll
|
||||
const MedicineList: React.FC = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [medicines, setMedicines] = useState<Medicine[]>([]);
|
||||
|
||||
const loadMore = async () => {
|
||||
try {
|
||||
const response = await medicineApi.list({ page, page_size: 20 });
|
||||
const newData = response.data.data;
|
||||
|
||||
setMedicines(prev => [...prev, ...newData]);
|
||||
setPage(prev => prev + 1);
|
||||
setHasMore(newData.length === 20);
|
||||
} catch (error) {
|
||||
console.error('加载失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InfiniteScroll loadMore={loadMore} hasMore={hasMore}>
|
||||
{medicines.map(medicine => (
|
||||
<MedicineCard key={medicine.id} medicine={medicine} />
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 7. 错误处理协议
|
||||
|
||||
### 7.1 错误响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "请求参数错误",
|
||||
"detail": {
|
||||
"field": "name",
|
||||
"error": "不能为空"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 前端错误处理
|
||||
|
||||
```typescript
|
||||
// api/client.ts
|
||||
client.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
const { response } = error;
|
||||
|
||||
if (response) {
|
||||
switch (response.status) {
|
||||
case 400:
|
||||
Toast.show({ content: response.data.message || '请求参数错误' });
|
||||
break;
|
||||
case 401:
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
break;
|
||||
case 403:
|
||||
Toast.show({ content: '权限不足' });
|
||||
break;
|
||||
case 404:
|
||||
Toast.show({ content: '资源不存在' });
|
||||
break;
|
||||
case 500:
|
||||
Toast.show({ content: '服务器错误,请稍后重试' });
|
||||
break;
|
||||
default:
|
||||
Toast.show({ content: '请求失败' });
|
||||
}
|
||||
} else {
|
||||
Toast.show({ content: '网络错误,请检查网络连接' });
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 7.3 表单验证错误
|
||||
|
||||
**错误响应格式:**
|
||||
```json
|
||||
{
|
||||
"code": 422,
|
||||
"message": "请求体格式正确但语义错误",
|
||||
"detail": [
|
||||
{
|
||||
"field": "name",
|
||||
"message": "字段不能为空",
|
||||
"type": "value_error.missing"
|
||||
},
|
||||
{
|
||||
"field": "expiry_date",
|
||||
"message": "日期格式错误",
|
||||
"type": "value_error.date"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**前端处理:**
|
||||
```typescript
|
||||
const handleFormError = (error: any) => {
|
||||
if (error.response?.status === 422) {
|
||||
const details = error.response.data.detail;
|
||||
if (Array.isArray(details)) {
|
||||
details.forEach((item: any) => {
|
||||
form.setFields([
|
||||
{
|
||||
name: item.field,
|
||||
errors: [item.message]
|
||||
}
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 8. 搜索协议
|
||||
|
||||
### 8.1 关键词搜索
|
||||
|
||||
**请求:**
|
||||
```
|
||||
GET /api/v1/search?q=发烧&type=indications
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "布洛芬",
|
||||
"match_type": "indications",
|
||||
"match_text": "用于退热",
|
||||
"relevance_score": 0.95
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 自然语言搜索
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
POST /api/v1/search/natural
|
||||
{
|
||||
"query": "孩子发烧了应该吃什么药?"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"medicine_id": 1,
|
||||
"name": "布洛芬",
|
||||
"reason": "适用于退热,可缓解发热症状",
|
||||
"match_score": 0.95,
|
||||
"recommendation": "建议在医生指导下使用"
|
||||
}
|
||||
],
|
||||
"ai_response": "根据您的描述,家中有布洛芬可用于退热。请注意按照说明书用量使用,如果症状持续请就医。"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 9. 实时更新协议
|
||||
|
||||
### 9.1 轮询机制
|
||||
|
||||
**库存变化轮询:**
|
||||
```typescript
|
||||
const useInventoryPolling = (interval: number = 30000) => {
|
||||
const { fetchMedicines } = useMedicineStore();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
fetchMedicines();
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [interval]);
|
||||
};
|
||||
```
|
||||
|
||||
### 9.2 通知轮询
|
||||
|
||||
```typescript
|
||||
const useNotificationPolling = (interval: number = 60000) => {
|
||||
const { fetchNotifications } = useNotificationStore();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
fetchNotifications({ is_read: false });
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [interval]);
|
||||
};
|
||||
```
|
||||
|
||||
## 10. 缓存协议
|
||||
|
||||
### 10.1 前端缓存策略
|
||||
|
||||
**localStorage 缓存:**
|
||||
```typescript
|
||||
const CACHE_KEYS = {
|
||||
AUTH_TOKEN: 'auth_token',
|
||||
USER_INFO: 'user_info',
|
||||
SETTINGS: 'app_settings'
|
||||
};
|
||||
|
||||
const cache = {
|
||||
get: (key: string) => {
|
||||
const value = localStorage.getItem(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
set: (key: string, value: any) => {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
},
|
||||
remove: (key: string) => {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Session Storage 缓存:**
|
||||
```typescript
|
||||
const sessionCache = {
|
||||
get: (key: string) => {
|
||||
const value = sessionStorage.getItem(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
set: (key: string, value: any) => {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
},
|
||||
remove: (key: string) => {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 10.2 HTTP 缓存头
|
||||
|
||||
**后端响应头:**
|
||||
```python
|
||||
@router.get("/medicines")
|
||||
async def list_medicines(
|
||||
# ...
|
||||
response: Response
|
||||
):
|
||||
# 设置缓存头
|
||||
response.headers["Cache-Control"] = "private, max-age=60"
|
||||
response.headers["ETag"] = generate_etag(data)
|
||||
|
||||
return data
|
||||
```
|
||||
|
||||
**前端缓存处理:**
|
||||
```typescript
|
||||
const fetchWithCache = async (url: string, options?: RequestInit) => {
|
||||
const cacheKey = `cache_${url}`;
|
||||
const cached = sessionCache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < 60000) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
|
||||
sessionCache.set(cacheKey, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
```
|
||||
|
||||
## 11. WebSocket 协议(可选)
|
||||
|
||||
### 11.1 连接建立
|
||||
|
||||
```typescript
|
||||
const useWebSocket = (url: string) => {
|
||||
const [socket, setSocket] = useState<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket 连接已建立');
|
||||
setSocket(ws);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
handleMessage(data);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket 连接已关闭');
|
||||
setSocket(null);
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return socket;
|
||||
};
|
||||
```
|
||||
|
||||
### 11.2 消息格式
|
||||
|
||||
**客户端发送:**
|
||||
```json
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "inventory_updates"
|
||||
}
|
||||
```
|
||||
|
||||
**服务端推送:**
|
||||
```json
|
||||
{
|
||||
"type": "inventory_update",
|
||||
"data": {
|
||||
"medicine_id": 1,
|
||||
"medicine_name": "布洛芬",
|
||||
"old_quantity": 30,
|
||||
"new_quantity": 25,
|
||||
"action": "dispense",
|
||||
"user": "admin",
|
||||
"timestamp": "2024-01-01T12:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 12. API 版本控制
|
||||
|
||||
### 12.1 URL 路径版本
|
||||
|
||||
```
|
||||
/api/v1/medicines
|
||||
/api/v2/medicines
|
||||
```
|
||||
|
||||
### 12.2 请求头版本
|
||||
|
||||
```
|
||||
Accept: application/vnd.yaoxiang.v1+json
|
||||
```
|
||||
|
||||
### 12.3 版本迁移策略
|
||||
|
||||
```python
|
||||
# 旧版本路由(v1)
|
||||
@router_v1.get("/medicines")
|
||||
async def list_medicines_v1():
|
||||
# v1 逻辑
|
||||
pass
|
||||
|
||||
# 新版本路由(v2)
|
||||
@router_v2.get("/medicines")
|
||||
async def list_medicines_v2():
|
||||
# v2 逻辑
|
||||
pass
|
||||
```
|
||||
|
||||
## 13. 安全协议
|
||||
|
||||
### 13.1 CORS 配置
|
||||
|
||||
```python
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:5173", # 开发环境
|
||||
"http://localhost:3000", # 生产环境
|
||||
"https://your-domain.com" # 域名
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
```
|
||||
|
||||
### 13.2 请求限流
|
||||
|
||||
```python
|
||||
from fastapi import Request, HTTPException
|
||||
from collections import defaultdict
|
||||
import time
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, max_requests: int = 100, window_seconds: int = 60):
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self.requests = defaultdict(list)
|
||||
|
||||
def check(self, client_ip: str):
|
||||
now = time.time()
|
||||
window_start = now - self.window_seconds
|
||||
|
||||
# 清理过期记录
|
||||
self.requests[client_ip] = [
|
||||
req_time for req_time in self.requests[client_ip]
|
||||
if req_time > window_start
|
||||
]
|
||||
|
||||
if len(self.requests[client_ip]) >= self.max_requests:
|
||||
raise HTTPException(status_code=429, detail="请求过于频繁")
|
||||
|
||||
self.requests[client_ip].append(now)
|
||||
|
||||
limiter = RateLimiter()
|
||||
|
||||
@app.middleware("http")
|
||||
async def rate_limit_middleware(request: Request, call_next):
|
||||
client_ip = request.client.host
|
||||
limiter.check(client_ip)
|
||||
response = await call_next(request)
|
||||
return response
|
||||
```
|
||||
|
||||
### 13.3 输入验证
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
class MedicineCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
expiry_date: str = Field(..., pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
|
||||
@validator('name')
|
||||
def validate_name(cls, v):
|
||||
# 防止 XSS
|
||||
import html
|
||||
return html.escape(v)
|
||||
```
|
||||
@@ -0,0 +1,530 @@
|
||||
# 数据库设计文档
|
||||
|
||||
## 1. 数据库概述
|
||||
|
||||
系统使用 SQLite 作为主数据库,支持未来升级到 PostgreSQL。数据库设计遵循第三范式,确保数据一致性和查询效率。
|
||||
|
||||
## 2. ER 图
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ users │ │ categories │
|
||||
├─────────────────┤ ├─────────────────┤
|
||||
│ id (PK) │ │ id (PK) │
|
||||
│ username │ │ name │
|
||||
│ password_hash │ │ parent_id (FK) │
|
||||
│ role │ │ level │
|
||||
│ display_name │ │ icon │
|
||||
│ email │ │ sort_order │
|
||||
│ notification_ │ │ created_at │
|
||||
│ level │ │ updated_at │
|
||||
│ is_active │ └─────────────────┘
|
||||
│ created_at │ │
|
||||
│ updated_at │ │
|
||||
└─────────────────┘ │
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ medicines │
|
||||
├─────────────────────────────────────────┤
|
||||
│ id (PK) │
|
||||
│ name │
|
||||
│ generic_name │
|
||||
│ brand_name │
|
||||
│ manufacturer │
|
||||
│ specification │
|
||||
│ category_id (FK) │
|
||||
│ description │
|
||||
│ indications │
|
||||
│ adult_dose │
|
||||
│ child_dose │
|
||||
│ contraindications │
|
||||
│ notes │
|
||||
│ image_front_path │
|
||||
│ image_expiry_path │
|
||||
│ image_leaflet_paths (JSON) │
|
||||
│ expiry_grace_days (默认0,最大60) │
|
||||
│ created_by (FK → users) │
|
||||
│ created_at │
|
||||
│ updated_at │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ batches │ │ audit_logs │
|
||||
├─────────────────┤ ├─────────────────┤
|
||||
│ id (PK) │ │ id (PK) │
|
||||
│ medicine_id (FK)│ │ medicine_id (FK)│
|
||||
│ batch_no │ │ batch_id (FK) │
|
||||
│ production_date │ │ user_id (FK) │
|
||||
│ expiry_date │ │ action │
|
||||
│ quantity │ │ quantity_change │
|
||||
│ location │ │ quantity_after │
|
||||
│ is_expired │ │ remark │
|
||||
│ created_at │ │ created_at │
|
||||
│ updated_at │ └─────────────────┘
|
||||
└─────────────────┘
|
||||
│
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│notifications │ │ settings │
|
||||
├─────────────────┤ ├─────────────────┤
|
||||
│ id (PK) │ │ id (PK) │
|
||||
│ type │ │ key │
|
||||
│ title │ │ value │
|
||||
│ content │ │ description │
|
||||
│ is_read │ │ updated_at │
|
||||
│ user_id (FK) │ └─────────────────┘
|
||||
│ created_at │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 3. 表结构定义
|
||||
|
||||
### 3.1 users 表(用户表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user', 'readonly')),
|
||||
display_name VARCHAR(100),
|
||||
email VARCHAR(100),
|
||||
notification_level VARCHAR(20) DEFAULT 'normal' CHECK(notification_level IN ('none', 'low', 'normal', 'high')),
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_users_role ON users(role);
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| id | INTEGER | 是 | 自增 | 主键 |
|
||||
| username | VARCHAR(50) | 是 | - | 用户名,唯一 |
|
||||
| password_hash | VARCHAR(255) | 是 | - | 密码哈希值 |
|
||||
| role | VARCHAR(20) | 是 | 'user' | 角色:admin/user/readonly |
|
||||
| display_name | VARCHAR(100) | 否 | NULL | 显示名称 |
|
||||
| email | VARCHAR(100) | 否 | NULL | 邮箱 |
|
||||
| notification_level | VARCHAR(20) | 否 | 'normal' | 通知等级 |
|
||||
| is_active | BOOLEAN | 否 | 1 | 是否启用 |
|
||||
| created_at | TIMESTAMP | 否 | CURRENT_TIMESTAMP | 创建时间 |
|
||||
| updated_at | TIMESTAMP | 否 | CURRENT_TIMESTAMP | 更新时间 |
|
||||
|
||||
### 3.2 categories 表(分类表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
parent_id INTEGER,
|
||||
level INTEGER NOT NULL DEFAULT 1 CHECK(level IN (1, 2)),
|
||||
icon VARCHAR(50),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_categories_parent_id ON categories(parent_id);
|
||||
CREATE INDEX idx_categories_level ON categories(level);
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| id | INTEGER | 是 | 自增 | 主键 |
|
||||
| name | VARCHAR(100) | 是 | - | 分类名称 |
|
||||
| parent_id | INTEGER | 否 | NULL | 父分类ID |
|
||||
| level | INTEGER | 是 | 1 | 分类层级:1或2 |
|
||||
| icon | VARCHAR(50) | 否 | NULL | 图标 |
|
||||
| sort_order | INTEGER | 否 | 0 | 排序顺序 |
|
||||
| created_at | TIMESTAMP | 否 | CURRENT_TIMESTAMP | 创建时间 |
|
||||
| updated_at | TIMESTAMP | 否 | CURRENT_TIMESTAMP | 更新时间 |
|
||||
|
||||
**预设分类数据:**
|
||||
|
||||
```sql
|
||||
-- 一级分类
|
||||
INSERT INTO categories (name, level, icon, sort_order) VALUES
|
||||
('药品', 1, 'medicine', 1),
|
||||
('医疗器械', 1, 'medical', 2),
|
||||
('应急用品', 1, 'emergency', 3),
|
||||
('消耗品', 1, 'consumable', 4);
|
||||
|
||||
-- 二级分类 - 药品
|
||||
INSERT INTO categories (name, parent_id, level, sort_order) VALUES
|
||||
('感冒药', 1, 2, 1),
|
||||
('退烧药', 1, 2, 2),
|
||||
('止泻药', 1, 2, 3),
|
||||
('消炎药', 1, 2, 4),
|
||||
('外用药', 1, 2, 5);
|
||||
|
||||
-- 二级分类 - 医疗器械
|
||||
INSERT INTO categories (name, parent_id, level, sort_order) VALUES
|
||||
('血压计', 2, 2, 1),
|
||||
('血糖仪', 2, 2, 2),
|
||||
('体温计', 2, 2, 3);
|
||||
|
||||
-- 二级分类 - 应急用品
|
||||
INSERT INTO categories (name, parent_id, level, sort_order) VALUES
|
||||
('创可贴', 3, 2, 1),
|
||||
('绷带', 3, 2, 2),
|
||||
('止血带', 3, 2, 3);
|
||||
|
||||
-- 二级分类 - 消耗品
|
||||
INSERT INTO categories (name, parent_id, level, sort_order) VALUES
|
||||
('酒精棉片', 4, 2, 1),
|
||||
('N95', 4, 2, 2),
|
||||
('医用手套', 4, 2, 3);
|
||||
```
|
||||
|
||||
### 3.3 medicines 表(药品表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE medicines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
generic_name VARCHAR(200),
|
||||
brand_name VARCHAR(200),
|
||||
manufacturer VARCHAR(200),
|
||||
specification VARCHAR(200),
|
||||
category_id INTEGER,
|
||||
description TEXT,
|
||||
indications TEXT,
|
||||
adult_dose TEXT,
|
||||
child_dose TEXT,
|
||||
contraindications TEXT,
|
||||
notes TEXT,
|
||||
image_front_path VARCHAR(500),
|
||||
image_expiry_path VARCHAR(500),
|
||||
image_leaflet_paths JSON,
|
||||
expiry_grace_days INTEGER DEFAULT 0 CHECK(expiry_grace_days >= 0 AND expiry_grace_days <= 60),
|
||||
created_by INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_medicines_name ON medicines(name);
|
||||
CREATE INDEX idx_medicines_category_id ON medicines(category_id);
|
||||
CREATE INDEX idx_medicines_created_by ON medicines(created_by);
|
||||
CREATE INDEX idx_medicines_generic_name ON medicines(generic_name);
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| id | INTEGER | 是 | 自增 | 主键 |
|
||||
| name | VARCHAR(200) | 是 | - | 药品名称 |
|
||||
| generic_name | VARCHAR(200) | 否 | NULL | 通用名称 |
|
||||
| brand_name | VARCHAR(200) | 否 | NULL | 商品名称 |
|
||||
| manufacturer | VARCHAR(200) | 否 | NULL | 生产厂家 |
|
||||
| specification | VARCHAR(200) | 否 | NULL | 规格 |
|
||||
| category_id | INTEGER | 否 | NULL | 分类ID |
|
||||
| description | TEXT | 否 | NULL | 描述 |
|
||||
| indications | TEXT | 否 | NULL | 适应症(用于搜索) |
|
||||
| adult_dose | TEXT | 否 | NULL | 成人用量 |
|
||||
| child_dose | TEXT | 否 | NULL | 儿童用量 |
|
||||
| contraindications | TEXT | 否 | NULL | 禁忌 |
|
||||
| notes | TEXT | 否 | NULL | 注意事项 |
|
||||
| image_front_path | VARCHAR(500) | 否 | NULL | 药盒正面图片路径 |
|
||||
| image_expiry_path | VARCHAR(500) | 否 | NULL | 有效期图片路径 |
|
||||
| image_leaflet_paths | JSON | 否 | NULL | 说明书图片路径数组 |
|
||||
| expiry_grace_days | INTEGER | 否 | 0 | 有效期宽限天数(最大60天) |
|
||||
| created_by | INTEGER | 否 | NULL | 创建者用户ID |
|
||||
| created_at | TIMESTAMP | 否 | CURRENT_TIMESTAMP | 创建时间 |
|
||||
| updated_at | TIMESTAMP | 否 | CURRENT_TIMESTAMP | 更新时间 |
|
||||
|
||||
### 3.4 batches 表(批次表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
medicine_id INTEGER NOT NULL,
|
||||
batch_no VARCHAR(100),
|
||||
production_date DATE,
|
||||
expiry_date DATE NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 0 CHECK(quantity >= 0),
|
||||
location VARCHAR(200),
|
||||
is_expired BOOLEAN DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (medicine_id) REFERENCES medicines(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_batches_medicine_id ON batches(medicine_id);
|
||||
CREATE INDEX idx_batches_expiry_date ON batches(expiry_date);
|
||||
CREATE INDEX idx_batches_is_expired ON batches(is_expired);
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| id | INTEGER | 是 | 自增 | 主键 |
|
||||
| medicine_id | INTEGER | 是 | - | 药品ID |
|
||||
| batch_no | VARCHAR(100) | 否 | NULL | 批次号 |
|
||||
| production_date | DATE | 否 | NULL | 生产日期 |
|
||||
| expiry_date | DATE | 是 | - | 过期日期 |
|
||||
| quantity | INTEGER | 是 | 0 | 库存数量 |
|
||||
| location | VARCHAR(200) | 否 | NULL | 存放位置 |
|
||||
| is_expired | BOOLEAN | 否 | 0 | 是否已过期 |
|
||||
| created_at | TIMESTAMP | 否 | CURRENT_TIMESTAMP | 创建时间 |
|
||||
| updated_at | TIMESTAMP | 否 | CURRENT_TIMESTAMP | 更新时间 |
|
||||
|
||||
### 3.5 audit_logs 表(审计日志表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
medicine_id INTEGER NOT NULL,
|
||||
batch_id INTEGER,
|
||||
user_id INTEGER,
|
||||
action VARCHAR(50) NOT NULL CHECK(action IN ('add_stock', 'dispense', 'adjust', 'delete', 'modify')),
|
||||
quantity_change INTEGER NOT NULL,
|
||||
quantity_after INTEGER NOT NULL,
|
||||
remark TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (medicine_id) REFERENCES medicines(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (batch_id) REFERENCES batches(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_audit_logs_medicine_id ON audit_logs(medicine_id);
|
||||
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
|
||||
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| id | INTEGER | 是 | 自增 | 主键 |
|
||||
| medicine_id | INTEGER | 是 | - | 药品ID |
|
||||
| batch_id | INTEGER | 否 | NULL | 批次ID |
|
||||
| user_id | INTEGER | 否 | NULL | 操作用户ID |
|
||||
| action | VARCHAR(50) | 是 | - | 操作类型 |
|
||||
| quantity_change | INTEGER | 是 | - | 数量变化(正数增加,负数减少) |
|
||||
| quantity_after | INTEGER | 是 | - | 操作后数量 |
|
||||
| remark | TEXT | 否 | NULL | 备注 |
|
||||
| created_at | TIMESTAMP | 否 | CURRENT_TIMESTAMP | 创建时间 |
|
||||
|
||||
### 3.6 notifications 表(通知表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type VARCHAR(50) NOT NULL CHECK(type IN ('expiry_warning', 'low_stock', 'system')),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
is_read BOOLEAN DEFAULT 0,
|
||||
user_id INTEGER,
|
||||
related_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
|
||||
CREATE INDEX idx_notifications_type ON notifications(type);
|
||||
CREATE INDEX idx_notifications_is_read ON notifications(is_read);
|
||||
CREATE INDEX idx_notifications_created_at ON notifications(created_at);
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| id | INTEGER | 是 | 自增 | 主键 |
|
||||
| type | VARCHAR(50) | 是 | - | 通知类型 |
|
||||
| title | VARCHAR(200) | 是 | - | 通知标题 |
|
||||
| content | TEXT | 是 | - | 通知内容 |
|
||||
| is_read | BOOLEAN | 否 | 0 | 是否已读 |
|
||||
| user_id | INTEGER | 否 | NULL | 用户ID |
|
||||
| related_id | INTEGER | 否 | NULL | 关联ID(药品/批次) |
|
||||
| created_at | TIMESTAMP | 否 | CURRENT_TIMESTAMP | 创建时间 |
|
||||
|
||||
### 3.7 settings 表(系统设置表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key VARCHAR(100) NOT NULL UNIQUE,
|
||||
value TEXT,
|
||||
description VARCHAR(500),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_settings_key ON settings(key);
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| id | INTEGER | 是 | 自增 | 主键 |
|
||||
| key | VARCHAR(100) | 是 | - | 设置键名,唯一 |
|
||||
| value | TEXT | 否 | NULL | 设置值 |
|
||||
| description | VARCHAR(500) | 否 | NULL | 设置描述 |
|
||||
| updated_at | TIMESTAMP | 否 | CURRENT_TIMESTAMP | 更新时间 |
|
||||
|
||||
**预设设置数据:**
|
||||
|
||||
```sql
|
||||
INSERT INTO settings (key, value, description) VALUES
|
||||
('ai_provider', 'openai', 'AI 服务提供者'),
|
||||
('openai_api_key', '', 'OpenAI API Key'),
|
||||
('openai_model', 'gpt-4o', 'OpenAI 模型'),
|
||||
('notification_providers', '[]', '启用的通知提供者列表'),
|
||||
('expiry_warning_days', '90,30,7', '到期提醒天数(逗号分隔)'),
|
||||
('low_stock_threshold', '5', '低库存阈值'),
|
||||
('max_upload_size', '10485760', '最大上传文件大小(字节)'),
|
||||
('expiry_grace_days_max', '60', '有效期宽限最大天数');
|
||||
```
|
||||
|
||||
## 4. 关系说明
|
||||
|
||||
### 4.1 一对多关系
|
||||
|
||||
- **users → medicines**: 一个用户可以创建多个药品
|
||||
- **users → audit_logs**: 一个用户可以有多条审计日志
|
||||
- **users → notifications**: 一个用户可以有多条通知
|
||||
- **categories → medicines**: 一个分类可以包含多个药品
|
||||
- **categories → categories**: 一个分类可以有多个子分类
|
||||
- **medicines → batches**: 一个药品可以有多个批次
|
||||
- **medicines → audit_logs**: 一个药品可以有多条审计日志
|
||||
|
||||
### 4.2 级联操作
|
||||
|
||||
- 删除用户:相关药品、审计日志、通知保留(created_by/set NULL)
|
||||
- 删除分类:相关药品的 category_id 设为 NULL
|
||||
- 删除药品:相关批次、审计日志级联删除
|
||||
- 删除批次:相关审计日志的 batch_id 设为 NULL
|
||||
|
||||
## 5. 视图设计
|
||||
|
||||
### 5.1 药品库存视图
|
||||
|
||||
```sql
|
||||
CREATE VIEW v_medicine_stock AS
|
||||
SELECT
|
||||
m.id,
|
||||
m.name,
|
||||
m.generic_name,
|
||||
m.brand_name,
|
||||
m.specification,
|
||||
c.name as category_name,
|
||||
COALESCE(SUM(b.quantity), 0) as total_quantity,
|
||||
MIN(b.expiry_date) as nearest_expiry_date,
|
||||
COUNT(b.id) as batch_count
|
||||
FROM medicines m
|
||||
LEFT JOIN batches b ON m.id = b.medicine_id AND b.is_expired = 0
|
||||
LEFT JOIN categories c ON m.category_id = c.id
|
||||
GROUP BY m.id;
|
||||
```
|
||||
|
||||
### 5.2 即将过期药品视图
|
||||
|
||||
```sql
|
||||
CREATE VIEW v_expiring_medicines AS
|
||||
SELECT
|
||||
m.id,
|
||||
m.name,
|
||||
m.expiry_grace_days,
|
||||
b.id as batch_id,
|
||||
b.batch_no,
|
||||
b.expiry_date,
|
||||
b.quantity,
|
||||
julianday(b.expiry_date) - julianday('now') as days_until_expiry
|
||||
FROM medicines m
|
||||
JOIN batches b ON m.id = b.medicine_id
|
||||
WHERE b.is_expired = 0
|
||||
AND b.expiry_date <= date('now', '+' || (90 + m.expiry_grace_days) || ' days');
|
||||
```
|
||||
|
||||
## 6. 索引策略
|
||||
|
||||
### 6.1 主要索引
|
||||
|
||||
| 表名 | 索引名 | 字段 | 用途 |
|
||||
|------|--------|------|------|
|
||||
| users | idx_users_username | username | 用户登录查询 |
|
||||
| medicines | idx_medicines_name | name | 药品搜索 |
|
||||
| medicines | idx_medicines_category_id | category_id | 分类筛选 |
|
||||
| batches | idx_batches_medicine_id | medicine_id | 药品批次查询 |
|
||||
| batches | idx_batches_expiry_date | expiry_date | 到期提醒查询 |
|
||||
| audit_logs | idx_audit_logs_created_at | created_at | 审计日志时间查询 |
|
||||
|
||||
### 6.2 复合索引
|
||||
|
||||
```sql
|
||||
-- 药品搜索复合索引
|
||||
CREATE INDEX idx_medicines_search ON medicines(name, generic_name, brand_name);
|
||||
|
||||
-- 批次库存查询复合索引
|
||||
CREATE INDEX idx_batches_stock ON batches(medicine_id, is_expired, expiry_date);
|
||||
```
|
||||
|
||||
## 7. 数据迁移策略
|
||||
|
||||
### 7.1 使用 Alembic
|
||||
|
||||
```bash
|
||||
# 初始化 Alembic
|
||||
alembic init alembic
|
||||
|
||||
# 生成迁移脚本
|
||||
alembic revision --autogenerate -m "initial"
|
||||
|
||||
# 执行迁移
|
||||
alembic upgrade head
|
||||
|
||||
# 回滚迁移
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
### 7.2 版本控制
|
||||
|
||||
- 每次数据库变更都生成迁移脚本
|
||||
- 迁移脚本存储在 `alembic/versions/` 目录
|
||||
- 支持向前和向后迁移
|
||||
|
||||
## 8. 数据备份策略
|
||||
|
||||
### 8.1 备份方案
|
||||
|
||||
```bash
|
||||
# SQLite 备份
|
||||
cp data/yaoxiang.db data/yaoxiang_backup_$(date +%Y%m%d).db
|
||||
|
||||
# 或使用 sqlite3 命令
|
||||
sqlite3 data/yaoxiang.db ".backup 'data/yaoxiang_backup_$(date +%Y%m%d).db'"
|
||||
```
|
||||
|
||||
### 8.2 自动备份
|
||||
|
||||
可通过 cron 任务定期备份:
|
||||
|
||||
```bash
|
||||
# 每天凌晨2点备份
|
||||
0 2 * * * /path/to/backup_script.sh
|
||||
```
|
||||
+1246
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
# API 配置
|
||||
VITE_API_BASE_URL=/api
|
||||
|
||||
# 应用配置
|
||||
VITE_APP_TITLE=药箱
|
||||
@@ -0,0 +1,106 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# parcel-bundler cache
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# Yarn
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
@@ -0,0 +1,163 @@
|
||||
# 药箱前端修改记录
|
||||
|
||||
## 版本历史
|
||||
|
||||
### v1.0.0 (2026-06-15)
|
||||
|
||||
#### 新增功能
|
||||
|
||||
**项目配置**
|
||||
- 创建 Vite 项目配置 (`vite.config.ts`)
|
||||
- 配置 TypeScript (`tsconfig.json`, `tsconfig.node.json`)
|
||||
- 配置 PWA 支持 (vite-plugin-pwa)
|
||||
- 配置开发服务器代理
|
||||
- 添加环境变量配置 (`.env.example`)
|
||||
|
||||
**TypeScript 类型定义**
|
||||
- 用户类型 (`types/user.ts`)
|
||||
- 药品类型 (`types/medicine.ts`)
|
||||
- 批次类型 (`types/batch.ts`)
|
||||
- 分类类型 (`types/category.ts`)
|
||||
- 通知类型 (`types/notification.ts`)
|
||||
- API 响应类型 (`types/api.ts`)
|
||||
|
||||
**API 调用层**
|
||||
- Axios 实例配置 (`api/client.ts`)
|
||||
- 认证 API (`api/auth.ts`)
|
||||
- 药品管理 API (`api/medicines.ts`)
|
||||
- 批次管理 API (`api/batches.ts`)
|
||||
- 分类管理 API (`api/categories.ts`)
|
||||
- 搜索 API (`api/search.ts`)
|
||||
- 通知 API (`api/notifications.ts`)
|
||||
- 用户管理 API (`api/users.ts`)
|
||||
|
||||
**状态管理 (Zustand)**
|
||||
- 认证状态 (`stores/authStore.ts`)
|
||||
- 药品状态 (`stores/medicineStore.ts`)
|
||||
- 分类状态 (`stores/categoryStore.ts`)
|
||||
- 通知状态 (`stores/notificationStore.ts`)
|
||||
- UI 状态 (`stores/uiStore.ts`)
|
||||
|
||||
**自定义 Hooks**
|
||||
- 认证 Hook (`hooks/useAuth.ts`)
|
||||
- 药品 Hook (`hooks/useMedicine.ts`)
|
||||
- 摄像头 Hook (`hooks/useCamera.ts`)
|
||||
- 通知 Hook (`hooks/useNotification.ts`)
|
||||
|
||||
**公共组件**
|
||||
- 布局组件 (`components/Layout`) - 含导航栏和底部 TabBar
|
||||
- 药品卡片 (`components/MedicineCard`) - 显示药品基本信息
|
||||
- 数量选择器 (`components/QuantitySelector`) - 支持大屏模式
|
||||
- 搜索栏 (`components/SearchBar`) - 支持防抖搜索
|
||||
- 摄像头捕获 (`components/CameraCapture`) - 用于 AI 识别
|
||||
- 分类树 (`components/CategoryTree`) - 二级分类展示
|
||||
|
||||
**页面组件**
|
||||
- 首页 (`pages/Home`) - 库存概览、快捷操作
|
||||
- 登录页 (`pages/Login`) - 用户认证
|
||||
- 药品列表 (`pages/MedicineList`) - 分页加载、搜索
|
||||
- 药品详情 (`pages/MedicineDetail`) - 信息展示、批次管理
|
||||
- 添加/编辑药品 (`pages/AddMedicine`) - 表单、AI 识别
|
||||
- 快速取药 (`pages/QuickDispense`) - 大屏模式、大按钮
|
||||
- 搜索页 (`pages/Search`) - 关键词搜索
|
||||
- 通知中心 (`pages Notifications`) - 通知列表、已读管理
|
||||
- 设置页 (`pages/Settings`) - 个人信息、系统设置
|
||||
|
||||
**工具函数**
|
||||
- 日期处理 (`utils/date.ts`)
|
||||
- 本地存储 (`utils/storage.ts`)
|
||||
- 表单验证 (`utils/validators.ts`)
|
||||
- 常量定义 (`utils/constants.ts`)
|
||||
|
||||
**样式文件**
|
||||
- 全局样式 (`styles/global.css`)
|
||||
- CSS 变量 (`styles/variables.css`)
|
||||
- CSS 混入 (`styles/mixins.css`)
|
||||
|
||||
**项目配置**
|
||||
- package.json - 项目依赖配置
|
||||
- .gitignore - Git 忽略文件
|
||||
- README.md - 项目说明
|
||||
|
||||
**文档**
|
||||
- DEVELOPMENT.md - 开发文档
|
||||
- CHANGELOG.md - 修改记录
|
||||
|
||||
---
|
||||
|
||||
## 待开发功能
|
||||
|
||||
### 计划中
|
||||
|
||||
- [ ] 用户管理页面 (UserManagement)
|
||||
- [ ] 审计日志页面 (AuditLog)
|
||||
- [ ] 药品图片上传功能
|
||||
- [ ] 说明书图片管理
|
||||
- [ ] 深色模式完整实现
|
||||
- [ ] 离线缓存支持
|
||||
- [ ] 推送通知支持
|
||||
- [ ] 国际化支持 (i18n)
|
||||
- [ ] 单元测试用例
|
||||
- [ ] E2E 测试用例
|
||||
|
||||
### 已知问题
|
||||
|
||||
- 暂无
|
||||
|
||||
---
|
||||
|
||||
## 更新说明
|
||||
|
||||
### 2026-06-15
|
||||
|
||||
**初始版本发布**
|
||||
|
||||
完成药箱前端系统的初始开发,包括:
|
||||
|
||||
1. **核心功能实现**
|
||||
- 用户登录认证
|
||||
- 药品 CRUD 操作
|
||||
- 批次管理(取药、入库)
|
||||
- 分类管理(二级分类)
|
||||
- 搜索功能(关键词搜索)
|
||||
- 通知系统
|
||||
- AI 识别接口集成
|
||||
|
||||
2. **UI/UX 设计**
|
||||
- 移动端优先设计
|
||||
- 大屏模式支持(快速取药)
|
||||
- 底部 TabBar 导航
|
||||
- 卡片式布局
|
||||
- 流畅的交互体验
|
||||
|
||||
3. **架构设计**
|
||||
- 组件化开发
|
||||
- 状态管理 (Zustand)
|
||||
- API 抽象层
|
||||
- 类型安全 (TypeScript)
|
||||
- 路由管理 (React Router)
|
||||
|
||||
4. **PWA 支持**
|
||||
- Service Worker 配置
|
||||
- 离线缓存
|
||||
- 可安装
|
||||
|
||||
---
|
||||
|
||||
## 贡献指南
|
||||
|
||||
如需提交修改,请遵循以下规范:
|
||||
|
||||
1. 代码风格遵循项目 ESLint 配置
|
||||
2. 提交信息使用中文
|
||||
3. 新增功能请添加相应的类型定义
|
||||
4. 修改记录请更新此文档
|
||||
|
||||
---
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题或建议,请通过以下方式联系:
|
||||
|
||||
- 项目地址: https://github.com/your-username/yaoxiang
|
||||
- 问题反馈: https://github.com/your-username/yaoxiang/issues
|
||||
@@ -0,0 +1,543 @@
|
||||
# 药箱前端开发文档
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
药箱(YaoXiang)是一个家庭药品与应急物资管理系统,前端采用 React + TypeScript + Vite 技术栈,支持 PWA 和大屏模式。
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 技术 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| React | 18.2.0 | UI 框架 |
|
||||
| TypeScript | 5.2.2 | 类型系统 |
|
||||
| Vite | 5.0.0 | 构建工具 |
|
||||
| React Router | 6.20.0 | 路由管理 |
|
||||
| Zustand | 4.4.7 | 状态管理 |
|
||||
| Ant Design Mobile | 5.34.0 | UI 组件库 |
|
||||
| Axios | 1.6.2 | HTTP 客户端 |
|
||||
| Day.js | 1.11.10 | 日期处理 |
|
||||
|
||||
## 3. 项目结构
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── public/ # 静态资源
|
||||
│ ├── favicon.svg # 网站图标
|
||||
│ ├── manifest.json # PWA 配置
|
||||
│ └── icons/ # 应用图标
|
||||
│
|
||||
├── src/
|
||||
│ ├── api/ # API 调用层
|
||||
│ │ ├── client.ts # Axios 实例配置
|
||||
│ │ ├── auth.ts # 认证相关 API
|
||||
│ │ ├── medicines.ts # 药品管理 API
|
||||
│ │ ├── batches.ts # 批次管理 API
|
||||
│ │ ├── categories.ts # 分类管理 API
|
||||
│ │ ├── search.ts # 搜索 API
|
||||
│ │ ├── notifications.ts # 通知 API
|
||||
│ │ ├── users.ts # 用户管理 API
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── components/ # 公共组件
|
||||
│ │ ├── Layout/ # 布局组件(含 TabBar)
|
||||
│ │ ├── MedicineCard/ # 药品卡片
|
||||
│ │ ├── QuantitySelector/ # 数量选择器
|
||||
│ │ ├── SearchBar/ # 搜索栏
|
||||
│ │ ├── CameraCapture/ # 摄像头捕获
|
||||
│ │ ├── CategoryTree/ # 分类树
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── Home/ # 首页(库存概览)
|
||||
│ │ ├── Login/ # 登录页
|
||||
│ │ ├── MedicineList/ # 药品列表
|
||||
│ │ ├── MedicineDetail/ # 药品详情
|
||||
│ │ ├── AddMedicine/ # 添加/编辑药品
|
||||
│ │ ├── QuickDispense/ # 快速取药(大屏模式)
|
||||
│ │ ├── Search/ # 搜索页
|
||||
│ │ ├── Notifications/ # 通知中心
|
||||
│ │ ├── Settings/ # 设置页
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── stores/ # 状态管理
|
||||
│ │ ├── authStore.ts # 认证状态
|
||||
│ │ ├── medicineStore.ts # 药品状态
|
||||
│ │ ├── categoryStore.ts # 分类状态
|
||||
│ │ ├── notificationStore.ts # 通知状态
|
||||
│ │ ├── uiStore.ts # UI 状态
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ │ ├── useAuth.ts # 认证 Hook
|
||||
│ │ ├── useMedicine.ts # 药品 Hook
|
||||
│ │ ├── useCamera.ts # 摄像头 Hook
|
||||
│ │ ├── useNotification.ts # 通知 Hook
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ │ ├── user.ts # 用户类型
|
||||
│ │ ├── medicine.ts # 药品类型
|
||||
│ │ ├── batch.ts # 批次类型
|
||||
│ │ ├── category.ts # 分类类型
|
||||
│ │ ├── notification.ts # 通知类型
|
||||
│ │ ├── api.ts # API 响应类型
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── date.ts # 日期处理
|
||||
│ │ ├── storage.ts # 本地存储
|
||||
│ │ ├── validators.ts # 表单验证
|
||||
│ │ ├── constants.ts # 常量定义
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── styles/ # 样式文件
|
||||
│ │ ├── global.css # 全局样式
|
||||
│ │ ├── variables.css # CSS 变量
|
||||
│ │ ├── mixins.css # CSS 混入
|
||||
│ │ └── index.css # 导入汇总
|
||||
│ │
|
||||
│ ├── App.tsx # 根组件
|
||||
│ ├── main.tsx # 入口文件
|
||||
│ └── router.tsx # 路由配置
|
||||
│
|
||||
├── index.html # HTML 模板
|
||||
├── package.json # 依赖配置
|
||||
├── vite.config.ts # Vite 配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
├── tsconfig.node.json # Node TypeScript 配置
|
||||
├── .env.example # 环境变量示例
|
||||
└── .gitignore # Git 忽略文件
|
||||
```
|
||||
|
||||
## 4. 快速开始
|
||||
|
||||
### 4.1 环境准备
|
||||
|
||||
```bash
|
||||
# 进入前端目录
|
||||
cd frontend
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4.2 配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制环境变量示例文件
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件
|
||||
VITE_API_BASE_URL=/api
|
||||
```
|
||||
|
||||
### 4.3 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:5173
|
||||
|
||||
### 4.4 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建产物位于 `dist/` 目录。
|
||||
|
||||
### 4.5 预览生产版本
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 5. 路由配置
|
||||
|
||||
### 5.1 路由表
|
||||
|
||||
| 路径 | 页面 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| `/login` | Login | 登录页 | 公开 |
|
||||
| `/` | Home | 首页 | 登录用户 |
|
||||
| `/medicines` | MedicineList | 药品列表 | 登录用户 |
|
||||
| `/medicines/add` | AddMedicine | 添加药品 | admin/user |
|
||||
| `/medicines/:id` | MedicineDetail | 药品详情 | 登录用户 |
|
||||
| `/medicines/edit/:id` | AddMedicine | 编辑药品 | admin/user |
|
||||
| `/quick-dispense` | QuickDispense | 快速取药 | 登录用户 |
|
||||
| `/search` | Search | 搜索页 | 登录用户 |
|
||||
| `/notifications` | Notifications | 通知中心 | 登录用户 |
|
||||
| `/settings` | Settings | 设置页 | 登录用户 |
|
||||
|
||||
### 5.2 路由守卫
|
||||
|
||||
路由守卫通过 `useAuth` Hook 实现:
|
||||
|
||||
```tsx
|
||||
import { useAuth } from '../hooks';
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
```
|
||||
|
||||
## 6. 状态管理
|
||||
|
||||
### 6.1 Store 结构
|
||||
|
||||
| Store | 说明 | 主要状态 |
|
||||
|-------|------|----------|
|
||||
| authStore | 认证状态 | user, token, isAuthenticated |
|
||||
| medicineStore | 药品状态 | medicines, currentMedicine, loading |
|
||||
| categoryStore | 分类状态 | categories, loading |
|
||||
| notificationStore | 通知状态 | notifications, unreadCount |
|
||||
| uiStore | UI 状态 | isLargeScreen, isDarkMode |
|
||||
|
||||
### 6.2 使用示例
|
||||
|
||||
```tsx
|
||||
import { useMedicineStore } from '../stores';
|
||||
|
||||
const MyComponent = () => {
|
||||
const { medicines, loading, fetchMedicines } = useMedicineStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchMedicines();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loading ? '加载中...' : medicines.map(m => <div key={m.id}>{m.name}</div>)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 7. API 调用
|
||||
|
||||
### 7.1 API 客户端配置
|
||||
|
||||
```typescript
|
||||
// api/client.ts
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// 请求拦截器 - 添加 Token
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器 - 处理 401
|
||||
client.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 7.2 API 调用示例
|
||||
|
||||
```typescript
|
||||
import { medicineApi } from '../api';
|
||||
|
||||
// 获取药品列表
|
||||
const { data, total } = await medicineApi.list({ page: 1, pageSize: 20 });
|
||||
|
||||
// 创建药品
|
||||
const medicine = await medicineApi.create({ name: '布洛芬', specification: '0.3g' });
|
||||
|
||||
// AI 识别药盒
|
||||
const result = await medicineApi.recognize(imageFile);
|
||||
```
|
||||
|
||||
## 8. 组件开发
|
||||
|
||||
### 8.1 添加新组件
|
||||
|
||||
1. 在 `src/components/` 目录下创建组件文件夹
|
||||
2. 创建 `index.tsx` 和 `index.css`
|
||||
3. 在 `src/components/index.ts` 中导出
|
||||
|
||||
```tsx
|
||||
// components/MyComponent/index.tsx
|
||||
import React from 'react';
|
||||
import './index.css';
|
||||
|
||||
interface MyComponentProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const MyComponent: React.FC<MyComponentProps> = ({ title }) => {
|
||||
return <div className="my-component">{title}</div>;
|
||||
};
|
||||
|
||||
export default MyComponent;
|
||||
```
|
||||
|
||||
### 8.2 添加新页面
|
||||
|
||||
1. 在 `src/pages/` 目录下创建页面文件夹
|
||||
2. 创建 `index.tsx` 和 `index.css`
|
||||
3. 在 `src/pages/index.ts` 中导出
|
||||
4. 在 `src/router.tsx` 中添加路由
|
||||
|
||||
```tsx
|
||||
// pages/MyPage/index.tsx
|
||||
import React from 'react';
|
||||
import './index.css';
|
||||
|
||||
const MyPage: React.FC = () => {
|
||||
return <div className="my-page">My Page</div>;
|
||||
};
|
||||
|
||||
export default MyPage;
|
||||
```
|
||||
|
||||
### 8.3 添加新 Hook
|
||||
|
||||
1. 在 `src/hooks/` 目录下创建 Hook 文件
|
||||
2. 在 `src/hooks/index.ts` 中导出
|
||||
|
||||
```tsx
|
||||
// hooks/useMyHook.ts
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export const useMyHook = () => {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
// 实现逻辑
|
||||
}, []);
|
||||
|
||||
return { data, fetchData };
|
||||
};
|
||||
```
|
||||
|
||||
## 9. 样式开发
|
||||
|
||||
### 9.1 CSS 变量
|
||||
|
||||
项目使用 CSS 变量管理主题:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--adm-color-primary: #1677ff;
|
||||
--adm-color-success: #52c41a;
|
||||
--adm-color-warning: #faad14;
|
||||
--adm-color-danger: #ff4d4f;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 大屏模式适配
|
||||
|
||||
```css
|
||||
/* 基础样式 */
|
||||
.quantity-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
/* 大屏模式 */
|
||||
@media (min-width: 768px) {
|
||||
.quantity-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 使用 Ant Design Mobile 样式
|
||||
|
||||
```tsx
|
||||
import { Button } from 'antd-mobile';
|
||||
|
||||
// 使用组件自带样式
|
||||
<Button color="primary" size="large">按钮</Button>
|
||||
|
||||
// 使用自定义样式
|
||||
<div className="custom-wrapper">
|
||||
<Button>按钮</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 10. 类型定义
|
||||
|
||||
### 10.1 添加新类型
|
||||
|
||||
1. 在 `src/types/` 目录下创建类型文件
|
||||
2. 在 `src/types/index.ts` 中导出
|
||||
|
||||
```typescript
|
||||
// types/myType.ts
|
||||
export interface MyType {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MyTypeCreate {
|
||||
name: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 使用类型
|
||||
|
||||
```typescript
|
||||
import { MyType, MyTypeCreate } from '../types';
|
||||
|
||||
const myFunction = (data: MyTypeCreate): MyType => {
|
||||
return { id: 1, ...data, createdAt: new Date().toISOString() };
|
||||
};
|
||||
```
|
||||
|
||||
## 11. 开发规范
|
||||
|
||||
### 11.1 代码风格
|
||||
|
||||
- 使用 TypeScript 严格模式
|
||||
- 遵循 ESLint 规则
|
||||
- 使用 Prettier 格式化
|
||||
|
||||
### 11.2 命名规范
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
|------|------|------|
|
||||
| 组件 | PascalCase | MedicineCard |
|
||||
| Hook | use + PascalCase | useAuth |
|
||||
| 函数 | camelCase | fetchMedicines |
|
||||
| 变量 | camelCase | medicineList |
|
||||
| 常量 | UPPER_SNAKE_CASE | API_BASE_URL |
|
||||
| 文件 | PascalCase (组件) / camelCase (其他) | MedicineCard/index.tsx |
|
||||
| CSS 类 | kebab-case | medicine-card |
|
||||
|
||||
### 11.3 文件组织
|
||||
|
||||
- 每个组件/页面单独一个文件夹
|
||||
- 包含 `index.tsx` 和 `index.css`
|
||||
- 通过 `index.ts` 导出
|
||||
|
||||
### 11.4 提交规范
|
||||
|
||||
```
|
||||
feat: 新功能
|
||||
fix: 修复 bug
|
||||
docs: 文档更新
|
||||
style: 代码格式调整
|
||||
refactor: 重构
|
||||
test: 测试相关
|
||||
chore: 构建/工具相关
|
||||
```
|
||||
|
||||
## 12. 构建与部署
|
||||
|
||||
### 12.1 开发环境
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 12.2 生产构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 12.3 部署到 Nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
root /path/to/dist;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 13. 常见问题
|
||||
|
||||
### 13.1 开发服务器启动失败
|
||||
|
||||
检查端口是否被占用:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
netstat -ano | findstr :5173
|
||||
|
||||
# Mac/Linux
|
||||
lsof -i :5173
|
||||
```
|
||||
|
||||
### 13.2 API 请求失败
|
||||
|
||||
1. 检查后端服务是否启动
|
||||
2. 检查 `.env` 中的 `VITE_API_BASE_URL` 配置
|
||||
3. 检查浏览器控制台错误
|
||||
|
||||
### 13.3 类型错误
|
||||
|
||||
确保所有组件和函数都有正确的类型注解:
|
||||
|
||||
```typescript
|
||||
// 错误
|
||||
const handleClick = (e) => { ... }
|
||||
|
||||
// 正确
|
||||
const handleClick = (e: React.MouseEvent) => { ... }
|
||||
```
|
||||
|
||||
### 13.4 样式不生效
|
||||
|
||||
1. 检查 CSS 文件是否正确导入
|
||||
2. 检查选择器是否正确
|
||||
3. 使用浏览器开发者工具检查样式
|
||||
|
||||
## 14. 扩展开发
|
||||
|
||||
### 14.1 添加新的 AI 识别功能
|
||||
|
||||
1. 在 `src/api/medicines.ts` 中添加 API 调用
|
||||
2. 在页面中使用摄像头组件捕获图片
|
||||
3. 调用 AI 接口识别
|
||||
|
||||
### 14.2 添加新的通知渠道
|
||||
|
||||
1. 在后端添加通知 Provider
|
||||
2. 在前端通知页面展示
|
||||
|
||||
### 14.3 添加新的页面
|
||||
|
||||
1. 创建页面组件
|
||||
2. 添加路由配置
|
||||
3. 添加导航入口
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#1677ff" />
|
||||
<meta name="description" content="家庭药品与应急物资管理系统" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>药箱 - 家庭药品管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "yaoxiang-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"antd-mobile": "^5.34.0",
|
||||
"antd-mobile-icons": "^0.3.0",
|
||||
"zustand": "^4.4.7",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-pwa": "^0.17.0",
|
||||
"eslint": "^8.53.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect x="20" y="30" width="60" height="50" rx="5" fill="#1677ff"/>
|
||||
<rect x="35" y="20" width="30" height="15" rx="3" fill="#1677ff"/>
|
||||
<line x1="50" y1="40" x2="50" y2="70" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="35" y1="55" x2="65" y2="55" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 401 B |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "药箱 - 家庭药品管理",
|
||||
"short_name": "药箱",
|
||||
"description": "家庭药品与应急物资管理系统",
|
||||
"theme_color": "#1677ff",
|
||||
"background_color": "#f5f5f5",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd-mobile';
|
||||
import zhCN from 'antd-mobile/es/locales/zh-CN';
|
||||
import router from './router';
|
||||
import './styles/index.css';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<RouterProvider router={router} />
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,16 @@
|
||||
import client from './client';
|
||||
import { LoginRequest, LoginResponse, User } from '../types';
|
||||
|
||||
export const authApi = {
|
||||
login: (data: LoginRequest): Promise<LoginResponse> => {
|
||||
return client.post('/v1/auth/login', data);
|
||||
},
|
||||
|
||||
getCurrentUser: (): Promise<User> => {
|
||||
return client.get('/v1/auth/me');
|
||||
},
|
||||
|
||||
changePassword: (oldPassword: string, newPassword: string): Promise<void> => {
|
||||
return client.put('/v1/auth/password', { old_password: oldPassword, new_password: newPassword });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import client from './client';
|
||||
import { Batch, BatchCreate, BatchUpdate } from '../types';
|
||||
|
||||
export const batchApi = {
|
||||
listByMedicine: (medicineId: number): Promise<Batch[]> => {
|
||||
return client.get(`/v1/batches/medicine/${medicineId}`);
|
||||
},
|
||||
|
||||
get: (id: number): Promise<Batch> => {
|
||||
return client.get(`/v1/batches/${id}`);
|
||||
},
|
||||
|
||||
create: (medicineId: number, data: BatchCreate): Promise<Batch> => {
|
||||
return client.post(`/v1/batches/medicine/${medicineId}`, data);
|
||||
},
|
||||
|
||||
update: (id: number, data: BatchUpdate): Promise<Batch> => {
|
||||
return client.put(`/v1/batches/${id}`, data);
|
||||
},
|
||||
|
||||
delete: (id: number): Promise<void> => {
|
||||
return client.delete(`/v1/batches/${id}`);
|
||||
},
|
||||
|
||||
dispense: (id: number, quantity: number): Promise<Batch> => {
|
||||
return client.post(`/v1/batches/${id}/dispense`, { quantity });
|
||||
},
|
||||
|
||||
addStock: (id: number, quantity: number): Promise<Batch> => {
|
||||
return client.post(`/v1/batches/${id}/add-stock`, { quantity });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import client from './client';
|
||||
import { Category, CategoryWithChildren, CategoryCreate, CategoryUpdate } from '../types';
|
||||
|
||||
export const categoryApi = {
|
||||
list: (params?: { level?: number; parentId?: number }): Promise<Category[]> => {
|
||||
return client.get('/v1/categories', { params });
|
||||
},
|
||||
|
||||
tree: (): Promise<CategoryWithChildren[]> => {
|
||||
return client.get('/v1/categories/tree');
|
||||
},
|
||||
|
||||
get: (id: number): Promise<Category> => {
|
||||
return client.get(`/v1/categories/${id}`);
|
||||
},
|
||||
|
||||
create: (data: CategoryCreate): Promise<Category> => {
|
||||
return client.post('/v1/categories', data);
|
||||
},
|
||||
|
||||
update: (id: number, data: CategoryUpdate): Promise<Category> => {
|
||||
return client.put(`/v1/categories/${id}`, data);
|
||||
},
|
||||
|
||||
delete: (id: number): Promise<void> => {
|
||||
return client.delete(`/v1/categories/${id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default client;
|
||||
@@ -0,0 +1,7 @@
|
||||
export { authApi } from './auth';
|
||||
export { medicineApi } from './medicines';
|
||||
export { batchApi } from './batches';
|
||||
export { categoryApi } from './categories';
|
||||
export { searchApi } from './search';
|
||||
export { notificationApi } from './notifications';
|
||||
export { userApi } from './users';
|
||||
@@ -0,0 +1,55 @@
|
||||
import client from './client';
|
||||
import { Medicine, MedicineWithStock, MedicineCreate, MedicineUpdate, MedicineListResponse } from '../types';
|
||||
|
||||
interface MedicineQueryParams {
|
||||
categoryId?: number;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const medicineApi = {
|
||||
list: (params?: MedicineQueryParams): Promise<MedicineListResponse> => {
|
||||
return client.get('/v1/medicines', { params });
|
||||
},
|
||||
|
||||
get: (id: number): Promise<Medicine> => {
|
||||
return client.get(`/v1/medicines/${id}`);
|
||||
},
|
||||
|
||||
create: (data: MedicineCreate): Promise<Medicine> => {
|
||||
return client.post('/v1/medicines', data);
|
||||
},
|
||||
|
||||
update: (id: number, data: MedicineUpdate): Promise<Medicine> => {
|
||||
return client.put(`/v1/medicines/${id}`, data);
|
||||
},
|
||||
|
||||
delete: (id: number): Promise<void> => {
|
||||
return client.delete(`/v1/medicines/${id}`);
|
||||
},
|
||||
|
||||
recognize: (file: File): Promise<{ genericName?: string; brandName?: string; manufacturer?: string; specification?: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return client.post('/v1/ai/recognize-medicine', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
},
|
||||
|
||||
recognizeDates: (file: File): Promise<{ productionDate?: string; expiryDate?: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return client.post('/v1/ai/recognize-dates', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
},
|
||||
|
||||
recognizeLeaflet: (file: File): Promise<{ indications: string; adultDose: string; childDose?: string; contraindications: string; notes?: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return client.post('/v1/ai/recognize-leaflet', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import client from './client';
|
||||
import { Notification, NotificationListResponse } from '../types';
|
||||
|
||||
interface NotificationQueryParams {
|
||||
isRead?: boolean;
|
||||
type?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const notificationApi = {
|
||||
list: (params?: NotificationQueryParams): Promise<NotificationListResponse> => {
|
||||
return client.get('/v1/notifications', { params });
|
||||
},
|
||||
|
||||
markAsRead: (id: number): Promise<void> => {
|
||||
return client.put(`/v1/notifications/${id}/read`);
|
||||
},
|
||||
|
||||
markAllAsRead: (): Promise<{ count: number }> => {
|
||||
return client.put('/v1/notifications/read-all');
|
||||
},
|
||||
|
||||
delete: (id: number): Promise<void> => {
|
||||
return client.delete(`/v1/notifications/${id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import client from './client';
|
||||
import { SearchResult } from '../types';
|
||||
|
||||
interface SearchResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
genericName?: string;
|
||||
indications?: string;
|
||||
totalQuantity: number;
|
||||
}
|
||||
|
||||
interface NaturalSearchResponse {
|
||||
results: SearchResult[];
|
||||
aiResponse: string;
|
||||
}
|
||||
|
||||
export const searchApi = {
|
||||
search: (query: string, type: string = 'name'): Promise<SearchResponse[]> => {
|
||||
return client.get('/v1/search', { params: { q: query, type } });
|
||||
},
|
||||
|
||||
naturalSearch: (query: string): Promise<NaturalSearchResponse> => {
|
||||
return client.post('/v1/search/natural', { query });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import client from './client';
|
||||
import { User, UserCreate, UserUpdate } from '../types';
|
||||
|
||||
export const userApi = {
|
||||
list: (): Promise<User[]> => {
|
||||
return client.get('/v1/users');
|
||||
},
|
||||
|
||||
get: (id: number): Promise<User> => {
|
||||
return client.get(`/v1/users/${id}`);
|
||||
},
|
||||
|
||||
create: (data: UserCreate): Promise<User> => {
|
||||
return client.post('/v1/users', data);
|
||||
},
|
||||
|
||||
update: (id: number, data: UserUpdate): Promise<User> => {
|
||||
return client.put(`/v1/users/${id}`, data);
|
||||
},
|
||||
|
||||
delete: (id: number): Promise<void> => {
|
||||
return client.delete(`/v1/users/${id}`);
|
||||
},
|
||||
|
||||
resetPassword: (id: number, newPassword: string): Promise<void> => {
|
||||
return client.post(`/v1/users/${id}/reset-password`, { new_password: newPassword });
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user