首次提交by MimoCode

This commit is contained in:
tang1219
2026-06-15 14:50:15 +08:00
parent 584b31da50
commit ead13f863c
166 changed files with 14908 additions and 1 deletions
+1
View File
@@ -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)
+252
View File
@@ -1 +1,253 @@
# 药箱 · 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)) 助生成。
+75
View File
@@ -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
+53
View File
@@ -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
+48
View File
@@ -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
+219
View File
@@ -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
+540
View File
@@ -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()
```
View File
View File
+43
View File
@@ -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
+34
View File
@@ -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()
+153
View File
@@ -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', [])
View File
+14
View File
@@ -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=["系统设置"])
View File
+115
View File
@@ -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)}")
+42
View File
@@ -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": "密码修改成功"}
+104
View File
@@ -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))
+83
View File
@@ -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": "删除成功"}
+86
View File
@@ -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": "删除成功"}
+77
View File
@@ -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": "删除成功"}
+66
View File
@@ -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)}"}
+82
View File
@@ -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
)
+94
View File
@@ -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"}
+61
View File
@@ -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()
View File
+60
View File
@@ -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
+47
View File
@@ -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)
+35
View File
@@ -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
+37
View File
@@ -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)
+51
View File
@@ -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"}
+17
View File
@@ -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"
]
+23
View File
@@ -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")
+23
View File
@@ -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")
+22
View File
@@ -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")
+35
View File
@@ -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")
+20
View File
@@ -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")
+14
View File
@@ -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)
+24
View File
@@ -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")
+11
View File
@@ -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
+46
View File
@@ -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()
+27
View File
@@ -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
+25
View File
@@ -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
+51
View File
@@ -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
+56
View File
@@ -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
+54
View File
@@ -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
+92
View File
@@ -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())
+71
View File
@@ -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
+44
View File
@@ -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
+33
View File
@@ -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
)
+11
View File
@@ -0,0 +1,11 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class PasswordChangeRequest(BaseModel):
old_password: str
new_password: str
+42
View File
@@ -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)
+36
View File
@@ -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"] = []
+58
View File
@@ -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
+44
View File
@@ -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
View File
+47
View File
@@ -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
)
+37
View File
@@ -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
}
+89
View File
@@ -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
+56
View File
@@ -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
+64
View File
@@ -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)
+39
View File
@@ -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)
+60
View File
@@ -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
View File
+15
View File
@@ -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
+29
View File
@@ -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}"
+33
View File
@@ -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()
View File
+37
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto
+38
View File
@@ -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
View File
+25
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+728
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+834
View File
@@ -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 传递
**方式1Authorization Header(推荐)**
```
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**方式2Query 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)
```
+530
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
# API 配置
VITE_API_BASE_URL=/api
# 应用配置
VITE_APP_TITLE=药箱
+106
View File
@@ -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.*
+163
View File
@@ -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
+543
View File
@@ -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. 添加导航入口
+17
View File
@@ -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>
+35
View File
@@ -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"
}
}
+6
View File
@@ -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

+23
View File
@@ -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"
}
]
}
+16
View File
@@ -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;
+16
View File
@@ -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 });
}
};
+32
View File
@@ -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 });
}
};
+28
View File
@@ -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}`);
}
};
+38
View File
@@ -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;
+7
View File
@@ -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';
+55
View File
@@ -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' }
});
}
};
+27
View File
@@ -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}`);
}
};
+25
View File
@@ -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 });
}
};
+28
View File
@@ -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