首次提交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
+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 });
}
};
@@ -0,0 +1,49 @@
.camera-capture {
position: relative;
width: 100%;
max-width: 400px;
margin: 0 auto;
}
.camera-video {
width: 100%;
border-radius: var(--adm-radius-md);
background: #000;
}
.camera-canvas {
display: none;
}
.camera-controls {
display: flex;
justify-content: center;
gap: var(--adm-spacing-md);
margin-top: var(--adm-spacing-md);
}
.capture-btn {
width: 120px;
height: 120px;
border-radius: 50%;
background: var(--adm-color-primary);
color: white;
font-size: var(--adm-font-size-lg);
display: flex;
align-items: center;
justify-content: center;
}
.capture-btn:disabled {
opacity: 0.5;
}
.camera-error {
text-align: center;
padding: var(--adm-spacing-xl);
}
.camera-error p {
margin-bottom: var(--adm-spacing-md);
color: var(--color-danger);
}
@@ -0,0 +1,67 @@
import React, { useEffect } from 'react';
import { Button, Toast } from 'antd-mobile';
import { useCamera } from '../../hooks';
import './index.css';
interface CameraCaptureProps {
onCapture: (file: File) => void;
onClose?: () => void;
}
const CameraCapture: React.FC<CameraCaptureProps> = ({ onCapture, onClose }) => {
const { videoRef, canvasRef, isReady, error, start, stop, capture } = useCamera();
useEffect(() => {
start();
return () => {
stop();
};
}, [start, stop]);
const handleCapture = async () => {
const file = await capture();
if (file) {
onCapture(file);
} else {
Toast.show({ content: '拍照失败', icon: 'fail' });
}
};
if (error) {
return (
<div className="camera-error">
<p>{error}</p>
<Button onClick={start}></Button>
</div>
);
}
return (
<div className="camera-capture">
<video
ref={videoRef}
className="camera-video"
playsInline
autoPlay
/>
<canvas ref={canvasRef} className="camera-canvas" />
<div className="camera-controls">
<Button
className="capture-btn"
onClick={handleCapture}
disabled={!isReady}
>
</Button>
{onClose && (
<Button onClick={onClose}></Button>
)}
</div>
</div>
);
};
export default CameraCapture;
@@ -0,0 +1,13 @@
.category-tree {
background: var(--adm-color-background-card);
border-radius: var(--adm-radius-md);
padding: var(--adm-spacing-md);
}
.category-tree .adm-tree-node-content {
padding: var(--adm-spacing-sm);
}
.category-tree .adm-tree-node-title {
font-size: var(--adm-font-size-md);
}
@@ -0,0 +1,44 @@
import React from 'react';
import { Tree } from 'antd-mobile';
import { CategoryWithChildren } from '../../types';
import './index.css';
interface CategoryTreeProps {
categories: CategoryWithChildren[];
onSelect?: (categoryId: number) => void;
selectedId?: number;
}
const CategoryTree: React.FC<CategoryTreeProps> = ({
categories,
onSelect,
selectedId
}) => {
const convertToTreeData = (cats: CategoryWithChildren[]): any[] => {
return cats.map(cat => ({
title: cat.name,
key: cat.id,
children: cat.children ? convertToTreeData(cat.children) : []
}));
};
const treeData = convertToTreeData(categories);
const handleSelect = (keys: any[]) => {
if (keys.length > 0 && onSelect) {
onSelect(keys[0]);
}
};
return (
<div className="category-tree">
<Tree
treeData={treeData}
defaultExpandedKeys={categories.map(c => c.id)}
onSelect={handleSelect}
/>
</div>
);
};
export default CategoryTree;
+29
View File
@@ -0,0 +1,29 @@
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.layout-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--adm-color-background-card);
box-shadow: var(--shadow-sm);
}
.layout-content {
flex: 1;
padding: var(--adm-spacing-md);
padding-bottom: calc(60px + var(--adm-spacing-md));
}
.layout-tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--adm-color-background-card);
box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.08);
padding-bottom: env(safe-area-inset-bottom);
}
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { NavBar, TabBar, Badge } from 'antd-mobile';
import {
AppOutline,
SearchOutline,
AddCircleOutline,
SetOutline,
BellOutline
} from 'antd-mobile-icons';
import { useNotificationStore } from '../../stores';
import './index.css';
interface LayoutProps {
children: React.ReactNode;
title?: string;
showBack?: boolean;
showTabBar?: boolean;
}
const Layout: React.FC<LayoutProps> = ({
children,
title = '药箱',
showBack = false,
showTabBar = true
}) => {
const navigate = useNavigate();
const { unreadCount } = useNotificationStore();
const tabs = [
{
key: '/',
title: '首页',
icon: <AppOutline />
},
{
key: '/medicines',
title: '药品',
icon: <SearchOutline />
},
{
key: '/quick-dispense',
title: '取药',
icon: <AddCircleOutline />
},
{
key: '/notifications',
title: '通知',
icon: <Badge content={unreadCount > 0 ? unreadCount : null}>
<BellOutline />
</Badge>
},
{
key: '/settings',
title: '设置',
icon: <SetOutline />
}
];
const handleTabChange = (key: string) => {
navigate(key);
};
return (
<div className="layout">
<div className="layout-header">
<NavBar
onBack={showBack ? () => navigate(-1) : undefined}
backArrow={showBack}
>
{title}
</NavBar>
</div>
<div className="layout-content">
{children}
</div>
{showTabBar && (
<div className="layout-tabbar">
<TabBar activeKey={tabs.find(t => window.location.pathname.startsWith(t.key))?.key || '/'} onChange={handleTabChange}>
{tabs.map(item => (
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
))}
</TabBar>
</div>
)}
</div>
);
};
export default Layout;
@@ -0,0 +1,61 @@
.medicine-card {
margin-bottom: var(--adm-spacing-md);
cursor: pointer;
transition: transform 0.2s;
}
.medicine-card:active {
transform: scale(0.98);
}
.medicine-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--adm-spacing-sm);
}
.medicine-name {
font-size: var(--adm-font-size-lg);
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.medicine-card-body {
display: flex;
flex-wrap: wrap;
gap: var(--adm-spacing-sm);
}
.medicine-info {
display: flex;
align-items: center;
gap: var(--adm-spacing-xs);
}
.medicine-info .label {
color: var(--color-text-light);
font-size: var(--adm-font-size-sm);
}
.medicine-info .value {
color: var(--color-text);
font-size: var(--adm-font-size-sm);
}
.medicine-info .value.low-stock {
color: var(--color-danger);
font-weight: 600;
}
.medicine-card-footer {
margin-top: var(--adm-spacing-sm);
padding-top: var(--adm-spacing-sm);
border-top: 1px solid var(--border-color);
}
.brand-name {
font-size: var(--adm-font-size-xs);
color: var(--color-text-light);
}
@@ -0,0 +1,72 @@
import React from 'react';
import { Card, Tag } from 'antd-mobile';
import { useNavigate } from 'react-router-dom';
import { MedicineWithStock } from '../../types';
import { getExpiryStatus } from '../../utils/date';
import './index.css';
interface MedicineCardProps {
medicine: MedicineWithStock;
}
const MedicineCard: React.FC<MedicineCardProps> = ({ medicine }) => {
const navigate = useNavigate();
const expiryStatus = medicine.nearestExpiryDate
? getExpiryStatus(medicine.nearestExpiryDate, medicine.expiryGraceDays)
: null;
const getExpiryTag = () => {
if (!expiryStatus) return null;
switch (expiryStatus) {
case 'expired':
return <Tag color="danger"></Tag>;
case 'warning':
return <Tag color="warning"></Tag>;
default:
return null;
}
};
return (
<Card
className="medicine-card"
onClick={() => navigate(`/medicines/${medicine.id}`)}
>
<div className="medicine-card-header">
<h3 className="medicine-name">{medicine.name}</h3>
{getExpiryTag()}
</div>
<div className="medicine-card-body">
{medicine.specification && (
<div className="medicine-info">
<span className="label">:</span>
<span className="value">{medicine.specification}</span>
</div>
)}
<div className="medicine-info">
<span className="label">:</span>
<span className={`value ${medicine.totalQuantity < 5 ? 'low-stock' : ''}`}>
{medicine.totalQuantity}
</span>
</div>
<div className="medicine-info">
<span className="label">:</span>
<span className="value">{medicine.batchCount}</span>
</div>
</div>
{medicine.brandName && (
<div className="medicine-card-footer">
<span className="brand-name">{medicine.brandName}</span>
</div>
)}
</Card>
);
};
export default MedicineCard;
@@ -0,0 +1,52 @@
.quantity-selector {
display: flex;
align-items: center;
justify-content: center;
gap: var(--adm-spacing-md);
}
.quantity-selector.large {
gap: var(--adm-spacing-xl);
}
.quantity-btn {
width: 48px;
height: 48px;
font-size: var(--adm-font-size-xl);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.quantity-selector.large .quantity-btn {
width: 80px;
height: 80px;
font-size: var(--adm-font-size-xxl);
}
.quantity-btn.decrement {
background: var(--adm-color-background);
}
.quantity-btn.increment {
background: var(--adm-color-primary);
color: white;
}
.quantity-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quantity-value {
font-size: var(--adm-font-size-xl);
font-weight: bold;
min-width: 60px;
text-align: center;
}
.quantity-selector.large .quantity-value {
font-size: var(--adm-font-size-xxl);
min-width: 100px;
}
@@ -0,0 +1,55 @@
import React from 'react';
import { Button } from 'antd-mobile';
import './index.css';
interface QuantitySelectorProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
large?: boolean;
disabled?: boolean;
}
const QuantitySelector: React.FC<QuantitySelectorProps> = ({
value,
onChange,
min = 0,
max = 999,
step = 1,
large = false,
disabled = false
}) => {
const handleDecrement = () => {
const newValue = Math.max(min, value - step);
onChange(newValue);
};
const handleIncrement = () => {
const newValue = Math.min(max, value + step);
onChange(newValue);
};
return (
<div className={`quantity-selector ${large ? 'large' : ''}`}>
<Button
className="quantity-btn decrement"
onClick={handleDecrement}
disabled={disabled || value <= min}
>
-
</Button>
<span className="quantity-value">{value}</span>
<Button
className="quantity-btn increment"
onClick={handleIncrement}
disabled={disabled || value >= max}
>
+
</Button>
</div>
);
};
export default QuantitySelector;
@@ -0,0 +1,12 @@
.search-bar {
margin-bottom: var(--adm-spacing-md);
}
.search-bar .adm-search-bar {
background: var(--adm-color-background-card);
border-radius: var(--adm-radius-md);
}
.search-bar .adm-search-bar-input {
font-size: var(--adm-font-size-md);
}
@@ -0,0 +1,56 @@
import React, { useState, useCallback } from 'react';
import { SearchBar as AdmSearchBar } from 'antd-mobile';
import './index.css';
interface SearchBarProps {
placeholder?: string;
onSearch: (value: string) => void;
onClear?: () => void;
debounce?: number;
}
const SearchBar: React.FC<SearchBarProps> = ({
placeholder = '搜索药品...',
onSearch,
onClear,
debounce = 300
}) => {
const [value, setValue] = useState('');
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
const handleChange = useCallback((val: string) => {
setValue(val);
if (timer) {
clearTimeout(timer);
}
const newTimer = setTimeout(() => {
onSearch(val);
}, debounce);
setTimer(newTimer);
}, [timer, debounce, onSearch]);
const handleClear = useCallback(() => {
setValue('');
if (onClear) {
onClear();
}
onSearch('');
}, [onClear, onSearch]);
return (
<div className="search-bar">
<AdmSearchBar
placeholder={placeholder}
value={value}
onChange={handleChange}
onClear={handleClear}
showCancelButton={() => false}
/>
</div>
);
};
export default SearchBar;
+6
View File
@@ -0,0 +1,6 @@
export { default as Layout } from './Layout';
export { default as MedicineCard } from './MedicineCard';
export { default as QuantitySelector } from './QuantitySelector';
export { default as SearchBar } from './SearchBar';
export { default as CameraCapture } from './CameraCapture';
export { default as CategoryTree } from './CategoryTree';
+4
View File
@@ -0,0 +1,4 @@
export { useAuth } from './useAuth';
export { useMedicine } from './useMedicine';
export { useCamera } from './useCamera';
export { useNotification } from './useNotification';
+32
View File
@@ -0,0 +1,32 @@
import { useCallback } from 'react';
import { useAuthStore } from '../stores';
export const useAuth = () => {
const { user, token, isAuthenticated, login, logout, setUser, setToken } = useAuthStore();
const isAdmin = user?.role === 'admin';
const isUser = user?.role === 'user' || user?.role === 'admin';
const isReadonly = user?.role === 'readonly';
const hasPermission = useCallback((requiredRole: string | string[]) => {
if (!user) return false;
if (user.role === 'admin') return true;
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
return roles.includes(user.role);
}, [user]);
return {
user,
token,
isAuthenticated,
isAdmin,
isUser,
isReadonly,
login,
logout,
setUser,
setToken,
hasPermission
};
};
+101
View File
@@ -0,0 +1,101 @@
import { useState, useRef, useCallback } from 'react';
interface UseCameraOptions {
facingMode?: 'user' | 'environment';
width?: number;
height?: number;
}
interface UseCameraReturn {
videoRef: React.RefObject<HTMLVideoElement>;
canvasRef: React.RefObject<HTMLCanvasElement>;
isReady: boolean;
error: string | null;
start: () => Promise<void>;
stop: () => void;
capture: () => Promise<File | null>;
}
export const useCamera = (options: UseCameraOptions = {}): UseCameraReturn => {
const {
facingMode = 'environment',
width = 1920,
height = 1080
} = options;
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const start = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode,
width: { ideal: width },
height: { ideal: height }
}
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
setIsReady(true);
setError(null);
}
} catch (err: any) {
setError(err.message || '无法访问摄像头');
setIsReady(false);
}
}, [facingMode, width, height]);
const stop = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setIsReady(false);
}, []);
const capture = useCallback(async (): Promise<File | null> => {
if (!videoRef.current || !canvasRef.current || !isReady) {
return null;
}
const video = videoRef.current;
const canvas = canvasRef.current;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(video, 0, 0);
return new Promise((resolve) => {
canvas.toBlob((blob) => {
if (blob) {
const file = new File([blob], 'capture.jpg', { type: 'image/jpeg' });
resolve(file);
} else {
resolve(null);
}
}, 'image/jpeg', 0.9);
});
}, [isReady]);
return {
videoRef,
canvasRef,
isReady,
error,
start,
stop,
capture
};
};
+54
View File
@@ -0,0 +1,54 @@
import { useCallback } from 'react';
import { useMedicineStore } from '../stores';
import { MedicineCreate, MedicineUpdate } from '../types';
export const useMedicine = () => {
const {
medicines,
currentMedicine,
loading,
error,
total,
page,
pageSize,
fetchMedicines,
fetchMedicine,
addMedicine,
updateMedicine,
deleteMedicine,
setPage,
clearError
} = useMedicineStore();
const getMedicineById = useCallback((id: number) => {
return medicines.find(m => m.id === id);
}, [medicines]);
const searchMedicines = useCallback((query: string) => {
fetchMedicines({ search: query });
}, [fetchMedicines]);
const filterByCategory = useCallback((categoryId: number) => {
fetchMedicines({ categoryId });
}, [fetchMedicines]);
return {
medicines,
currentMedicine,
loading,
error,
total,
page,
pageSize,
fetchMedicines,
fetchMedicine,
addMedicine,
updateMedicine,
deleteMedicine,
getMedicineById,
searchMedicines,
filterByCategory,
setPage,
clearError
};
};
+38
View File
@@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { useNotificationStore } from '../stores';
export const useNotification = () => {
const {
notifications,
unreadCount,
loading,
error,
fetchNotifications,
markAsRead,
markAllAsRead,
deleteNotification,
clearError
} = useNotificationStore();
const getUnreadNotifications = useCallback(() => {
return notifications.filter(n => !n.isRead);
}, [notifications]);
const getNotificationsByType = useCallback((type: string) => {
return notifications.filter(n => n.type === type);
}, [notifications]);
return {
notifications,
unreadCount,
loading,
error,
fetchNotifications,
markAsRead,
markAllAsRead,
deleteNotification,
getUnreadNotifications,
getNotificationsByType,
clearError
};
};
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+17
View File
@@ -0,0 +1,17 @@
.add-medicine-page {
padding: var(--adm-spacing-md);
}
.camera-section {
margin-bottom: var(--adm-spacing-md);
}
.medicine-form {
background: var(--adm-color-background-card);
border-radius: var(--adm-radius-md);
padding: var(--adm-spacing-md);
}
.medicine-form .adm-form-item {
padding-left: 0;
}
+153
View File
@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Form, Input, Button, Toast, DatePicker, Picker } from 'antd-mobile';
import { CameraCapture } from '../../components';
import { useMedicineStore, useCategoryStore } from '../../stores';
import { medicineApi } from '../../api';
import './index.css';
const AddMedicine: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { addMedicine, updateMedicine, currentMedicine } = useMedicineStore();
const { categories, fetchCategories } = useCategoryStore();
const [loading, setLoading] = useState(false);
const [showCamera, setShowCamera] = useState(false);
const [recognizing, setRecognizing] = useState(false);
React.useEffect(() => {
fetchCategories();
}, []);
const handleRecognize = async (file: File) => {
setShowCamera(false);
setRecognizing(true);
try {
const result = await medicineApi.recognize(file);
Toast.show({ content: '识别成功', icon: 'success' });
// 填充表单
if (result.genericName) {
// 这里需要根据实际表单结构调整
}
} catch (error) {
Toast.show({ content: '识别失败', icon: 'fail' });
} finally {
setRecognizing(false);
}
};
const handleSubmit = async (values: any) => {
setLoading(true);
try {
if (id) {
await updateMedicine(Number(id), values);
Toast.show({ content: '更新成功', icon: 'success' });
} else {
await addMedicine(values);
Toast.show({ content: '添加成功', icon: 'success' });
}
navigate('/medicines');
} catch (error) {
Toast.show({ content: '操作失败', icon: 'fail' });
} finally {
setLoading(false);
}
};
return (
<div className="add-medicine-page">
<div className="camera-section">
<Button
block
onClick={() => setShowCamera(true)}
disabled={recognizing}
>
{recognizing ? '识别中...' : '拍照识别'}
</Button>
</div>
{showCamera && (
<CameraCapture
onCapture={handleRecognize}
onClose={() => setShowCamera(false)}
/>
)}
<Form
className="medicine-form"
onFinish={handleSubmit}
initialValues={currentMedicine || {}}
footer={
<Button
block
type="submit"
color="primary"
size="large"
loading={loading}
>
{id ? '更新' : '添加'}
</Button>
}
>
<Form.Item
name="name"
label="药品名称"
rules={[{ required: true, message: '请输入药品名称' }]}
>
<Input placeholder="请输入药品名称" />
</Form.Item>
<Form.Item name="genericName" label="通用名称">
<Input placeholder="请输入通用名称" />
</Form.Item>
<Form.Item name="brandName" label="商品名称">
<Input placeholder="请输入商品名称" />
</Form.Item>
<Form.Item name="manufacturer" label="生产厂家">
<Input placeholder="请输入生产厂家" />
</Form.Item>
<Form.Item name="specification" label="规格">
<Input placeholder="请输入规格" />
</Form.Item>
<Form.Item name="categoryId" label="分类">
<Picker
columns={categories.map(c => ({ label: c.name, value: c.id }))}
placeholder="请选择分类"
/>
</Form.Item>
<Form.Item name="indications" label="适应症">
<Input placeholder="请输入适应症" />
</Form.Item>
<Form.Item name="adultDose" label="成人用量">
<Input placeholder="请输入成人用量" />
</Form.Item>
<Form.Item name="childDose" label="儿童用量">
<Input placeholder="请输入儿童用量" />
</Form.Item>
<Form.Item name="contraindications" label="禁忌">
<Input placeholder="请输入禁忌" />
</Form.Item>
<Form.Item name="notes" label="注意事项">
<Input placeholder="请输入注意事项" />
</Form.Item>
<Form.Item name="expiryGraceDays" label="宽限天数">
<Input type="number" placeholder="0-60天" />
</Form.Item>
</Form>
</div>
);
};
export default AddMedicine;
+60
View File
@@ -0,0 +1,60 @@
.home-page {
padding: var(--adm-spacing-md);
}
.stats-section {
margin-bottom: var(--adm-spacing-lg);
}
.stat-item {
text-align: center;
padding: var(--adm-spacing-sm);
}
.stat-value {
font-size: var(--adm-font-size-xxl);
font-weight: bold;
color: var(--adm-color-primary);
}
.stat-label {
font-size: var(--adm-font-size-sm);
color: var(--color-text-secondary);
margin-top: var(--adm-spacing-xs);
}
.quick-actions {
margin-bottom: var(--adm-spacing-lg);
}
.section-title {
font-size: var(--adm-font-size-lg);
font-weight: 600;
margin-bottom: var(--adm-spacing-md);
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--adm-spacing-md);
background: var(--adm-color-background-card);
border-radius: var(--adm-radius-md);
cursor: pointer;
transition: transform 0.2s;
}
.action-item:active {
transform: scale(0.95);
}
.action-icon {
font-size: 32px;
margin-bottom: var(--adm-spacing-sm);
color: var(--adm-color-primary);
}
.action-title {
font-size: var(--adm-font-size-sm);
color: var(--color-text);
}
+85
View File
@@ -0,0 +1,85 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Grid, Card, Badge } from 'antd-mobile';
import {
AppOutline,
SearchOutline,
AddCircleOutline,
SetOutline
} from 'antd-mobile-icons';
import { useMedicineStore } from '../../stores';
import './index.css';
const Home: React.FC = () => {
const navigate = useNavigate();
const { medicines, total } = useMedicineStore();
const quickActions = [
{ icon: <SearchOutline />, title: '搜索', path: '/search' },
{ icon: <AddCircleOutline />, title: '添加药品', path: '/medicines/add' },
{ icon: <AppOutline />, title: '快速取药', path: '/quick-dispense' },
{ icon: <SetOutline />, title: '设置', path: '/settings' }
];
const stats = {
totalMedicines: total || medicines.length,
totalQuantity: medicines.reduce((sum, m) => sum + m.totalQuantity, 0),
expiringCount: medicines.filter(m => {
if (!m.nearestExpiryDate) return false;
const days = Math.ceil(
(new Date(m.nearestExpiryDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
return days <= 30;
}).length
};
return (
<div className="home-page">
<div className="stats-section">
<Card>
<Grid columns={3} gap={16}>
<Grid.Item>
<div className="stat-item">
<div className="stat-value">{stats.totalMedicines}</div>
<div className="stat-label"></div>
</div>
</Grid.Item>
<Grid.Item>
<div className="stat-item">
<div className="stat-value">{stats.totalQuantity}</div>
<div className="stat-label"></div>
</div>
</Grid.Item>
<Grid.Item>
<div className="stat-item">
<Badge content={stats.expiringCount > 0 ? stats.expiringCount : null}>
<div className="stat-value">{stats.expiringCount}</div>
</Badge>
<div className="stat-label"></div>
</div>
</Grid.Item>
</Grid>
</Card>
</div>
<div className="quick-actions">
<h3 className="section-title"></h3>
<Grid columns={4} gap={8}>
{quickActions.map((action) => (
<Grid.Item key={action.path}>
<div
className="action-item"
onClick={() => navigate(action.path)}
>
<div className="action-icon">{action.icon}</div>
<div className="action-title">{action.title}</div>
</div>
</Grid.Item>
))}
</Grid>
</div>
</div>
);
};
export default Home;
+48
View File
@@ -0,0 +1,48 @@
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--adm-spacing-xl);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-header {
text-align: center;
margin-bottom: var(--adm-spacing-xl);
color: white;
}
.login-header .logo {
font-size: 64px;
margin-bottom: var(--adm-spacing-md);
}
.login-header h1 {
font-size: 32px;
font-weight: bold;
margin-bottom: var(--adm-spacing-sm);
}
.login-header p {
font-size: var(--adm-font-size-md);
opacity: 0.8;
}
.login-form {
width: 100%;
max-width: 400px;
background: white;
border-radius: var(--adm-radius-lg);
padding: var(--adm-spacing-lg);
box-shadow: var(--shadow-lg);
}
.login-form .adm-form-item {
padding-left: 0;
}
.login-form .adm-list-item-content-main {
padding: var(--adm-spacing-sm) 0;
}
+68
View File
@@ -0,0 +1,68 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, Toast } from 'antd-mobile';
import { useAuthStore } from '../../stores';
import './index.css';
const Login: React.FC = () => {
const navigate = useNavigate();
const { login } = useAuthStore();
const [loading, setLoading] = useState(false);
const handleSubmit = async (values: { username: string; password: string }) => {
setLoading(true);
try {
await login(values.username, values.password);
Toast.show({ content: '登录成功', icon: 'success' });
navigate('/');
} catch (error: any) {
Toast.show({ content: error.message || '登录失败', icon: 'fail' });
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<div className="login-header">
<div className="logo">💊</div>
<h1></h1>
<p></p>
</div>
<Form
className="login-form"
onFinish={handleSubmit}
footer={
<Button
block
type="submit"
color="primary"
size="large"
loading={loading}
>
</Button>
}
>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input type="password" placeholder="请输入密码" />
</Form.Item>
</Form>
</div>
);
};
export default Login;
@@ -0,0 +1,89 @@
.medicine-detail-page {
padding: var(--adm-spacing-md);
}
.medicine-info-card {
margin-bottom: var(--adm-spacing-md);
}
.medicine-name {
font-size: var(--adm-font-size-xl);
font-weight: bold;
margin-bottom: var(--adm-spacing-md);
}
.info-row {
display: flex;
margin-bottom: var(--adm-spacing-sm);
}
.info-row .label {
color: var(--color-text-secondary);
min-width: 80px;
}
.info-row .value {
color: var(--color-text);
flex: 1;
}
.batches-card {
margin-bottom: var(--adm-spacing-md);
}
.no-batches {
text-align: center;
color: var(--color-text-light);
padding: var(--adm-spacing-lg);
}
.batch-list {
display: flex;
flex-direction: column;
gap: var(--adm-spacing-md);
}
.batch-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--adm-spacing-sm);
background: var(--adm-color-background);
border-radius: var(--adm-radius-sm);
}
.batch-info {
flex: 1;
}
.batch-no {
font-weight: 600;
margin-bottom: var(--adm-spacing-xs);
}
.batch-quantity {
font-size: var(--adm-font-size-sm);
color: var(--color-text-secondary);
}
.batch-expiry {
font-size: var(--adm-font-size-sm);
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: var(--adm-spacing-xs);
}
.action-buttons {
display: flex;
flex-direction: column;
gap: var(--adm-spacing-md);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
color: var(--color-text-light);
}
+193
View File
@@ -0,0 +1,193 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Button, Dialog, Toast, Tag } from 'antd-mobile';
import { batchApi } from '../../api';
import { useMedicineStore } from '../../stores';
import { Batch, Medicine } from '../../types';
import { formatDate, getExpiryStatus } from '../../utils/date';
import './index.css';
const MedicineDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { fetchMedicine, currentMedicine, deleteMedicine } = useMedicineStore();
const [batches, setBatches] = useState<Batch[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (id) {
fetchMedicine(Number(id));
loadBatches(Number(id));
}
}, [id]);
const loadBatches = async (medicineId: number) => {
try {
const data = await batchApi.listByMedicine(medicineId);
setBatches(data);
} catch (error) {
console.error('Failed to load batches:', error);
}
};
const handleDelete = async () => {
const result = await Dialog.confirm({
content: '确定要删除这个药品吗?'
});
if (result) {
try {
await deleteMedicine(Number(id));
Toast.show({ content: '删除成功', icon: 'success' });
navigate('/medicines');
} catch (error) {
Toast.show({ content: '删除失败', icon: 'fail' });
}
}
};
const handleDispense = async (batch: Batch) => {
const result = await Dialog.confirm({
content: `确定要从批次 ${batch.batchNo || '默认'} 取药吗?`
});
if (result) {
try {
await batchApi.dispense(batch.id, 1);
Toast.show({ content: '取药成功', icon: 'success' });
loadBatches(Number(id));
} catch (error) {
Toast.show({ content: '取药失败', icon: 'fail' });
}
}
};
if (!currentMedicine) {
return <div className="loading">...</div>;
}
return (
<div className="medicine-detail-page">
<Card className="medicine-info-card">
<h1 className="medicine-name">{currentMedicine.name}</h1>
{currentMedicine.brandName && (
<div className="info-row">
<span className="label">:</span>
<span className="value">{currentMedicine.brandName}</span>
</div>
)}
{currentMedicine.genericName && (
<div className="info-row">
<span className="label">:</span>
<span className="value">{currentMedicine.genericName}</span>
</div>
)}
{currentMedicine.manufacturer && (
<div className="info-row">
<span className="label">:</span>
<span className="value">{currentMedicine.manufacturer}</span>
</div>
)}
{currentMedicine.specification && (
<div className="info-row">
<span className="label">:</span>
<span className="value">{currentMedicine.specification}</span>
</div>
)}
{currentMedicine.indications && (
<div className="info-row">
<span className="label">:</span>
<span className="value">{currentMedicine.indications}</span>
</div>
)}
{currentMedicine.adultDose && (
<div className="info-row">
<span className="label">:</span>
<span className="value">{currentMedicine.adultDose}</span>
</div>
)}
{currentMedicine.childDose && (
<div className="info-row">
<span className="label">:</span>
<span className="value">{currentMedicine.childDose}</span>
</div>
)}
{currentMedicine.contraindications && (
<div className="info-row">
<span className="label">:</span>
<span className="value">{currentMedicine.contraindications}</span>
</div>
)}
{currentMedicine.notes && (
<div className="info-row">
<span className="label">:</span>
<span className="value">{currentMedicine.notes}</span>
</div>
)}
</Card>
<Card title="库存批次" className="batches-card">
{batches.length === 0 ? (
<div className="no-batches"></div>
) : (
<div className="batch-list">
{batches.map((batch) => {
const expiryStatus = getExpiryStatus(batch.expiryDate);
return (
<div key={batch.id} className="batch-item">
<div className="batch-info">
<div className="batch-no">: {batch.batchNo || '默认'}</div>
<div className="batch-quantity">: {batch.quantity}</div>
<div className="batch-expiry">
: {formatDate(batch.expiryDate)}
{expiryStatus === 'expired' && <Tag color="danger"></Tag>}
{expiryStatus === 'warning' && <Tag color="warning"></Tag>}
</div>
</div>
<Button
size="small"
color="primary"
onClick={() => handleDispense(batch)}
disabled={batch.quantity <= 0}
>
</Button>
</div>
);
})}
</div>
)}
</Card>
<div className="action-buttons">
<Button
block
color="primary"
size="large"
onClick={() => navigate(`/medicines/edit/${id}`)}
>
</Button>
<Button
block
color="danger"
size="large"
onClick={handleDelete}
>
</Button>
</div>
</div>
);
};
export default MedicineDetail;
+13
View File
@@ -0,0 +1,13 @@
.medicine-list-page {
padding: var(--adm-spacing-md);
}
.medicine-list {
display: flex;
flex-direction: column;
gap: var(--adm-spacing-md);
}
.adm-infinite-scroll {
padding: var(--adm-spacing-md) 0;
}
+56
View File
@@ -0,0 +1,56 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { InfiniteScroll, Empty, SpinLoading } from 'antd-mobile';
import { useMedicineStore } from '../../stores';
import { MedicineCard, SearchBar } from '../../components';
import './index.css';
const MedicineList: React.FC = () => {
const navigate = useNavigate();
const { medicines, loading, total, fetchMedicines, setPage, page } = useMedicineStore();
const [hasMore, setHasMore] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
fetchMedicines({ page: 1, pageSize: 20 });
}, []);
const handleSearch = (query: string) => {
setSearchQuery(query);
fetchMedicines({ search: query, page: 1, pageSize: 20 });
setHasMore(true);
};
const loadMore = async () => {
const nextPage = page + 1;
await fetchMedicines({
search: searchQuery || undefined,
page: nextPage,
pageSize: 20
});
setPage(nextPage);
setHasMore(medicines.length < total);
};
return (
<div className="medicine-list-page">
<SearchBar onSearch={handleSearch} />
{medicines.length === 0 && !loading ? (
<Empty description="暂无药品" />
) : (
<div className="medicine-list">
{medicines.map((medicine) => (
<MedicineCard key={medicine.id} medicine={medicine} />
))}
</div>
)}
<InfiniteScroll loadMore={loadMore} hasMore={hasMore}>
{loading && <SpinLoading />}
</InfiniteScroll>
</div>
);
};
export default MedicineList;
@@ -0,0 +1,54 @@
.notifications-page {
padding: var(--adm-spacing-md);
}
.notifications-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--adm-spacing-md);
}
.notifications-header h2 {
font-size: var(--adm-font-size-xl);
}
.notification-list {
display: flex;
flex-direction: column;
gap: var(--adm-spacing-md);
}
.notification-item {
cursor: pointer;
transition: background-color 0.2s;
}
.notification-item.unread {
background: #e6f4ff;
}
.notification-icon {
font-size: 24px;
margin-right: var(--adm-spacing-md);
}
.notification-content {
flex: 1;
}
.notification-title {
font-weight: 600;
margin-bottom: var(--adm-spacing-xs);
}
.notification-text {
font-size: var(--adm-font-size-sm);
color: var(--color-text-secondary);
margin-bottom: var(--adm-spacing-xs);
}
.notification-time {
font-size: var(--adm-font-size-xs);
color: var(--color-text-light);
}
@@ -0,0 +1,88 @@
import React, { useEffect } from 'react';
import { Card, Button, Empty, SwipeAction } from 'antd-mobile';
import { useNotificationStore } from '../../stores';
import { formatDateTime } from '../../utils/date';
import './index.css';
const Notifications: React.FC = () => {
const {
notifications,
loading,
fetchNotifications,
markAsRead,
markAllAsRead,
deleteNotification
} = useNotificationStore();
useEffect(() => {
fetchNotifications();
}, []);
const handleMarkAllRead = async () => {
await markAllAsRead();
};
const handleDelete = async (id: number) => {
await deleteNotification(id);
};
const getNotificationIcon = (type: string) => {
switch (type) {
case 'expiry_warning':
return '⏰';
case 'low_stock':
return '⚠️';
default:
return '📢';
}
};
return (
<div className="notifications-page">
<div className="notifications-header">
<h2></h2>
<Button size="small" onClick={handleMarkAllRead}>
</Button>
</div>
{notifications.length === 0 ? (
<Empty description="暂无通知" />
) : (
<div className="notification-list">
{notifications.map((notification) => (
<SwipeAction
key={notification.id}
rightActions={[
{
key: 'delete',
text: '删除',
color: 'danger',
onClick: () => handleDelete(notification.id)
}
]}
>
<Card
className={`notification-item ${!notification.isRead ? 'unread' : ''}`}
onClick={() => markAsRead(notification.id)}
>
<div className="notification-icon">
{getNotificationIcon(notification.type)}
</div>
<div className="notification-content">
<div className="notification-title">{notification.title}</div>
<div className="notification-text">{notification.content}</div>
<div className="notification-time">
{formatDateTime(notification.createdAt)}
</div>
</div>
</Card>
</SwipeAction>
))}
</div>
)}
</div>
);
};
export default Notifications;
@@ -0,0 +1,91 @@
.quick-dispense-page {
padding: var(--adm-spacing-md);
}
.medicine-selection h2,
.batch-selection h2,
.dispense-section h2 {
font-size: var(--adm-font-size-xl);
margin-bottom: var(--adm-spacing-md);
}
.medicine-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--adm-spacing-md);
}
.medicine-item {
cursor: pointer;
transition: transform 0.2s;
}
.medicine-item:active {
transform: scale(0.95);
}
.medicine-item .medicine-name {
font-weight: 600;
margin-bottom: var(--adm-spacing-xs);
}
.medicine-item .medicine-quantity {
font-size: var(--adm-font-size-sm);
color: var(--color-text-secondary);
}
.back-btn {
margin-bottom: var(--adm-spacing-md);
}
.batch-list {
display: flex;
flex-direction: column;
gap: var(--adm-spacing-md);
}
.batch-item {
cursor: pointer;
transition: transform 0.2s;
}
.batch-item:active {
transform: scale(0.95);
}
.batch-item .batch-info {
font-size: var(--adm-font-size-md);
}
.no-batches {
text-align: center;
color: var(--color-text-light);
padding: var(--adm-spacing-lg);
}
.dispense-section {
text-align: center;
}
.batch-info {
margin-bottom: var(--adm-spacing-lg);
font-size: var(--adm-font-size-lg);
}
.quantity-section {
margin-bottom: var(--adm-spacing-xl);
}
.quantity-section h3 {
margin-bottom: var(--adm-spacing-md);
}
.action-buttons {
display: flex;
gap: var(--adm-spacing-md);
}
.cancel-btn,
.confirm-btn {
flex: 1;
}
+144
View File
@@ -0,0 +1,144 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Button, Dialog, Toast } from 'antd-mobile';
import { useMedicineStore } from '../../stores';
import { QuantitySelector } from '../../components';
import { batchApi } from '../../api';
import { Batch } from '../../types';
import './index.css';
const QuickDispense: React.FC = () => {
const navigate = useNavigate();
const { medicines, fetchMedicines } = useMedicineStore();
const [selectedMedicine, setSelectedMedicine] = useState<any>(null);
const [selectedBatch, setSelectedBatch] = useState<Batch | null>(null);
const [quantity, setQuantity] = useState(1);
const [batches, setBatches] = useState<Batch[]>([]);
useEffect(() => {
fetchMedicines();
}, []);
useEffect(() => {
if (selectedMedicine) {
loadBatches(selectedMedicine.id);
}
}, [selectedMedicine]);
const loadBatches = async (medicineId: number) => {
try {
const data = await batchApi.listByMedicine(medicineId);
setBatches(data.filter(b => b.quantity > 0 && !b.isExpired));
} catch (error) {
console.error('Failed to load batches:', error);
}
};
const handleDispense = async () => {
if (!selectedBatch) return;
try {
await batchApi.dispense(selectedBatch.id, quantity);
Toast.show({ content: '取药成功', icon: 'success' });
fetchMedicines();
setSelectedMedicine(null);
setSelectedBatch(null);
setQuantity(1);
} catch (error) {
Toast.show({ content: '取药失败', icon: 'fail' });
}
};
return (
<div className="quick-dispense-page">
{!selectedMedicine ? (
<div className="medicine-selection">
<h2></h2>
<div className="medicine-grid">
{medicines.map((medicine) => (
<Card
key={medicine.id}
className="medicine-item"
onClick={() => setSelectedMedicine(medicine)}
>
<div className="medicine-name">{medicine.name}</div>
<div className="medicine-quantity">
: {medicine.totalQuantity}
</div>
</Card>
))}
</div>
</div>
) : !selectedBatch ? (
<div className="batch-selection">
<h2> - {selectedMedicine.name}</h2>
<Button
className="back-btn"
onClick={() => setSelectedMedicine(null)}
>
</Button>
<div className="batch-list">
{batches.map((batch) => (
<Card
key={batch.id}
className="batch-item"
onClick={() => setSelectedBatch(batch)}
>
<div className="batch-info">
<div>: {batch.batchNo || '默认'}</div>
<div>: {batch.quantity}</div>
<div>: {batch.expiryDate}</div>
</div>
</Card>
))}
{batches.length === 0 && (
<div className="no-batches"></div>
)}
</div>
</div>
) : (
<div className="dispense-section">
<h2> - {selectedMedicine.name}</h2>
<div className="batch-info">
<p>: {selectedBatch.batchNo || '默认'}</p>
<p>: {selectedBatch.quantity}</p>
</div>
<div className="quantity-section">
<h3></h3>
<QuantitySelector
value={quantity}
onChange={setQuantity}
min={1}
max={selectedBatch.quantity}
large
/>
</div>
<div className="action-buttons">
<Button
className="cancel-btn"
onClick={() => {
setSelectedBatch(null);
setQuantity(1);
}}
>
</Button>
<Button
className="confirm-btn"
color="primary"
size="large"
onClick={handleDispense}
>
</Button>
</div>
</div>
)}
</div>
);
};
export default QuickDispense;
+47
View File
@@ -0,0 +1,47 @@
.search-page {
padding: var(--adm-spacing-md);
}
.searching {
text-align: center;
color: var(--color-text-light);
padding: var(--adm-spacing-lg);
}
.search-results {
display: flex;
flex-direction: column;
gap: var(--adm-spacing-md);
}
.result-item {
cursor: pointer;
transition: transform 0.2s;
}
.result-item:active {
transform: scale(0.98);
}
.result-name {
font-size: var(--adm-font-size-lg);
font-weight: 600;
margin-bottom: var(--adm-spacing-xs);
}
.result-generic {
font-size: var(--adm-font-size-sm);
color: var(--color-text-secondary);
margin-bottom: var(--adm-spacing-xs);
}
.result-indications {
font-size: var(--adm-font-size-sm);
color: var(--color-text-light);
margin-bottom: var(--adm-spacing-xs);
}
.result-quantity {
font-size: var(--adm-font-size-sm);
color: var(--adm-color-primary);
}
+77
View File
@@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Button, Empty } from 'antd-mobile';
import { SearchBar } from '../../components';
import { searchApi } from '../../api';
import './index.css';
interface SearchResult {
id: number;
name: string;
genericName?: string;
indications?: string;
totalQuantity: number;
}
const Search: React.FC = () => {
const navigate = useNavigate();
const [results, setResults] = useState<SearchResult[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [searching, setSearching] = useState(false);
const handleSearch = async (query: string) => {
setSearchQuery(query);
if (!query.trim()) {
setResults([]);
return;
}
setSearching(true);
try {
const data = await searchApi.search(query);
setResults(data);
} catch (error) {
console.error('Search failed:', error);
} finally {
setSearching(false);
}
};
return (
<div className="search-page">
<SearchBar
onSearch={handleSearch}
placeholder="搜索药品名称、适应症..."
/>
{searching && <div className="searching">...</div>}
{!searching && results.length === 0 && searchQuery && (
<Empty description="未找到相关药品" />
)}
<div className="search-results">
{results.map((result) => (
<Card
key={result.id}
className="result-item"
onClick={() => navigate(`/medicines/${result.id}`)}
>
<div className="result-name">{result.name}</div>
{result.genericName && (
<div className="result-generic">{result.genericName}</div>
)}
{result.indications && (
<div className="result-indications">{result.indications}</div>
)}
<div className="result-quantity">
: {result.totalQuantity}
</div>
</Card>
))}
</div>
</div>
);
};
export default Search;
+7
View File
@@ -0,0 +1,7 @@
.settings-page {
padding: var(--adm-spacing-md);
}
.logout-section {
margin-top: var(--adm-spacing-xl);
}
+93
View File
@@ -0,0 +1,93 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { List, Switch, Button, Dialog, Toast } from 'antd-mobile';
import { useAuthStore, useUIStore } from '../../stores';
import './index.css';
const Settings: React.FC = () => {
const navigate = useNavigate();
const { user, logout, isAdmin } = useAuthStore();
const { isDarkMode, toggleDarkMode } = useUIStore();
const handleLogout = async () => {
const result = await Dialog.confirm({
content: '确定要退出登录吗?'
});
if (result) {
logout();
navigate('/login');
}
};
return (
<div className="settings-page">
<List header="账户">
<List.Item
prefix={<span>👤</span>}
onClick={() => navigate('/settings/profile')}
>
</List.Item>
<List.Item
prefix={<span>🔑</span>}
onClick={() => navigate('/settings/password')}
>
</List.Item>
</List>
<List header="通用">
<List.Item
prefix={<span>🌙</span>}
extra={
<Switch
checked={isDarkMode}
onChange={toggleDarkMode}
/>
}
>
</List.Item>
</List>
{isAdmin && (
<List header="管理">
<List.Item
prefix={<span>👥</span>}
onClick={() => navigate('/users')}
>
</List.Item>
<List.Item
prefix={<span></span>}
onClick={() => navigate('/settings/system')}
>
</List.Item>
</List>
)}
<List header="关于">
<List.Item
prefix={<span>📱</span>}
>
1.0.0
</List.Item>
</List>
<div className="logout-section">
<Button
block
color="danger"
size="large"
onClick={handleLogout}
>
退
</Button>
</div>
</div>
);
};
export default Settings;
+9
View File
@@ -0,0 +1,9 @@
export { default as Home } from './Home';
export { default as Login } from './Login';
export { default as MedicineList } from './MedicineList';
export { default as MedicineDetail } from './MedicineDetail';
export { default as AddMedicine } from './AddMedicine';
export { default as QuickDispense } from './QuickDispense';
export { default as Search } from './Search';
export { default as Notifications } from './Notifications';
export { default as Settings } from './Settings';
+62
View File
@@ -0,0 +1,62 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { Layout } from './components';
import {
Home,
Login,
MedicineList,
MedicineDetail,
AddMedicine,
QuickDispense,
Search,
Notifications,
Settings
} from './pages';
const router = createBrowserRouter([
{
path: '/login',
element: <Login />
},
{
path: '/',
element: <Layout><Home /></Layout>
},
{
path: '/medicines',
element: <Layout title="药品列表" showBack><MedicineList /></Layout>
},
{
path: '/medicines/add',
element: <Layout title="添加药品" showBack><AddMedicine /></Layout>
},
{
path: '/medicines/:id',
element: <Layout title="药品详情" showBack><MedicineDetail /></Layout>
},
{
path: '/medicines/edit/:id',
element: <Layout title="编辑药品" showBack><AddMedicine /></Layout>
},
{
path: '/quick-dispense',
element: <Layout title="快速取药"><QuickDispense /></Layout>
},
{
path: '/search',
element: <Layout title="搜索" showBack><Search /></Layout>
},
{
path: '/notifications',
element: <Layout title="通知中心"><Notifications /></Layout>
},
{
path: '/settings',
element: <Layout title="设置"><Settings /></Layout>
},
{
path: '*',
element: <Navigate to="/" replace />
}
]);
export default router;
+52
View File
@@ -0,0 +1,52 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { User } from '../types';
import { authApi } from '../api';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
setUser: (user: User) => void;
setToken: (token: string) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (username, password) => {
const response = await authApi.login({ username, password });
set({
user: response.user,
token: response.accessToken,
isAuthenticated: true
});
},
logout: () => {
set({
user: null,
token: null,
isAuthenticated: false
});
},
setUser: (user) => set({ user }),
setToken: (token) => set({ token })
}),
{
name: 'auth-storage',
partialize: (state) => ({
token: state.token,
user: state.user
})
}
)
);
+30
View File
@@ -0,0 +1,30 @@
import { create } from 'zustand';
import { CategoryWithChildren } from '../types';
import { categoryApi } from '../api';
interface CategoryState {
categories: CategoryWithChildren[];
loading: boolean;
error: string | null;
fetchCategories: () => Promise<void>;
clearError: () => void;
}
export const useCategoryStore = create<CategoryState>((set) => ({
categories: [],
loading: false,
error: null,
fetchCategories: async () => {
set({ loading: true, error: null });
try {
const response = await categoryApi.tree();
set({ categories: response, loading: false });
} catch (error: any) {
set({ error: error.message, loading: false });
}
},
clearError: () => set({ error: null })
}));
+5
View File
@@ -0,0 +1,5 @@
export { useAuthStore } from './authStore';
export { useMedicineStore } from './medicineStore';
export { useCategoryStore } from './categoryStore';
export { useNotificationStore } from './notificationStore';
export { useUIStore } from './uiStore';
+99
View File
@@ -0,0 +1,99 @@
import { create } from 'zustand';
import { MedicineWithStock, MedicineCreate, MedicineUpdate } from '../types';
import { medicineApi } from '../api';
interface MedicineQueryParams {
categoryId?: number;
search?: string;
page?: number;
pageSize?: number;
}
interface MedicineState {
medicines: MedicineWithStock[];
currentMedicine: MedicineWithStock | null;
loading: boolean;
error: string | null;
total: number;
page: number;
pageSize: number;
fetchMedicines: (params?: MedicineQueryParams) => Promise<void>;
fetchMedicine: (id: number) => Promise<void>;
addMedicine: (data: MedicineCreate) => Promise<void>;
updateMedicine: (id: number, data: MedicineUpdate) => Promise<void>;
deleteMedicine: (id: number) => Promise<void>;
setPage: (page: number) => void;
clearError: () => void;
}
export const useMedicineStore = create<MedicineState>((set) => ({
medicines: [],
currentMedicine: null,
loading: false,
error: null,
total: 0,
page: 1,
pageSize: 20,
fetchMedicines: async (params) => {
set({ loading: true, error: null });
try {
const response = await medicineApi.list(params);
set({
medicines: response.data,
total: response.total,
page: response.page,
loading: false
});
} catch (error: any) {
set({ error: error.message, loading: false });
}
},
fetchMedicine: async (id) => {
set({ loading: true, error: null });
try {
const response = await medicineApi.get(id);
set({ currentMedicine: response as MedicineWithStock, loading: false });
} catch (error: any) {
set({ error: error.message, loading: false });
}
},
addMedicine: async (data) => {
set({ loading: true, error: null });
try {
await medicineApi.create(data);
set({ loading: false });
} catch (error: any) {
set({ error: error.message, loading: false });
throw error;
}
},
updateMedicine: async (id, data) => {
set({ loading: true, error: null });
try {
await medicineApi.update(id, data);
set({ loading: false });
} catch (error: any) {
set({ error: error.message, loading: false });
throw error;
}
},
deleteMedicine: async (id) => {
set({ loading: true, error: null });
try {
await medicineApi.delete(id);
set({ loading: false });
} catch (error: any) {
set({ error: error.message, loading: false });
throw error;
}
},
setPage: (page) => set({ page }),
clearError: () => set({ error: null })
}));
+77
View File
@@ -0,0 +1,77 @@
import { create } from 'zustand';
import { Notification } from '../types';
import { notificationApi } from '../api';
interface NotificationState {
notifications: Notification[];
unreadCount: number;
loading: boolean;
error: string | null;
fetchNotifications: (params?: { isRead?: boolean }) => Promise<void>;
markAsRead: (id: number) => Promise<void>;
markAllAsRead: () => Promise<void>;
deleteNotification: (id: number) => Promise<void>;
clearError: () => void;
}
export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [],
unreadCount: 0,
loading: false,
error: null,
fetchNotifications: async (params) => {
set({ loading: true, error: null });
try {
const response = await notificationApi.list(params);
const unread = response.data.filter(n => !n.isRead).length;
set({
notifications: response.data,
unreadCount: unread,
loading: false
});
} catch (error: any) {
set({ error: error.message, loading: false });
}
},
markAsRead: async (id) => {
try {
await notificationApi.markAsRead(id);
set((state) => ({
notifications: state.notifications.map(n =>
n.id === id ? { ...n, isRead: true } : n
),
unreadCount: Math.max(0, state.unreadCount - 1)
}));
} catch (error: any) {
set({ error: error.message });
}
},
markAllAsRead: async () => {
try {
await notificationApi.markAllAsRead();
set((state) => ({
notifications: state.notifications.map(n => ({ ...n, isRead: true })),
unreadCount: 0
}));
} catch (error: any) {
set({ error: error.message });
}
},
deleteNotification: async (id) => {
try {
await notificationApi.delete(id);
set((state) => ({
notifications: state.notifications.filter(n => n.id !== id)
}));
} catch (error: any) {
set({ error: error.message });
}
},
clearError: () => set({ error: null })
}));
+21
View File
@@ -0,0 +1,21 @@
import { create } from 'zustand';
interface UIState {
isLargeScreen: boolean;
isDarkMode: boolean;
sidebarCollapsed: boolean;
setLargeScreen: (value: boolean) => void;
toggleDarkMode: () => void;
toggleSidebar: () => void;
}
export const useUIStore = create<UIState>((set) => ({
isLargeScreen: window.innerWidth >= 768,
isDarkMode: false,
sidebarCollapsed: false,
setLargeScreen: (value) => set({ isLargeScreen: value }),
toggleDarkMode: () => set((state) => ({ isDarkMode: !state.isDarkMode })),
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }))
}));
+107
View File
@@ -0,0 +1,107 @@
:root {
/* 颜色 */
--color-primary: #1677ff;
--color-primary-light: #4096ff;
--color-primary-dark: #0958d9;
--color-success: #52c41a;
--color-warning: #faad14;
--color-danger: #ff4d4f;
/* 背景色 */
--color-bg: #f5f5f5;
--color-bg-card: #ffffff;
/* 文字色 */
--color-text: #333333;
--color-text-secondary: #666666;
--color-text-light: #999999;
/* 边框 */
--border-color: #e8e8e8;
--border-radius: 8px;
--border-radius-lg: 12px;
/* 间距 */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
/* 字体 */
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-xxl: 24px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: var(--font-size-md);
color: var(--color-text);
background-color: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
color: var(--color-primary-light);
}
img {
max-width: 100%;
height: auto;
}
button {
cursor: pointer;
border: none;
background: none;
font: inherit;
}
input, textarea, select {
font: inherit;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #bfbfbf;
}
+3
View File
@@ -0,0 +1,3 @@
@import './global.css';
@import './variables.css';
@import './mixins.css';
+64
View File
@@ -0,0 +1,64 @@
/* Flex 布局 */
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
display: flex;
flex-direction: column;
}
/* 文本省略 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-ellipsis-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 安全区域 */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* 卡片样式 */
.card {
background: var(--adm-color-background-card);
border-radius: var(--adm-radius-md);
padding: var(--adm-spacing-md);
box-shadow: var(--shadow-sm);
}
/* 分割线 */
.divider {
height: 1px;
background: var(--border-color);
margin: var(--adm-spacing-md) 0;
}
/* 大屏模式 */
@media (min-width: 768px) {
.large-btn {
width: 80px !important;
height: 80px !important;
font-size: 24px !important;
}
.large-text {
font-size: var(--adm-font-size-xl) !important;
}
}
+35
View File
@@ -0,0 +1,35 @@
/* 颜色变量 */
:root {
--adm-color-primary: #1677ff;
--adm-color-success: #52c41a;
--adm-color-warning: #faad14;
--adm-color-danger: #ff4d4f;
--adm-color-background: #f5f5f5;
--adm-color-background-card: #ffffff;
}
/* 间距变量 */
:root {
--adm-spacing-xs: 4px;
--adm-spacing-sm: 8px;
--adm-spacing-md: 16px;
--adm-spacing-lg: 24px;
--adm-spacing-xl: 32px;
}
/* 圆角变量 */
:root {
--adm-radius-xs: 4px;
--adm-radius-sm: 8px;
--adm-radius-md: 12px;
--adm-radius-lg: 16px;
}
/* 字体大小变量 */
:root {
--adm-font-size-xs: 12px;
--adm-font-size-sm: 14px;
--adm-font-size-md: 16px;
--adm-font-size-lg: 18px;
--adm-font-size-xl: 20px;
}
+53
View File
@@ -0,0 +1,53 @@
export interface ApiResponse<T = any> {
code: number;
message: string;
data?: T;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
}
export interface VisionResult {
genericName?: string;
brandName?: string;
manufacturer?: string;
specification?: string;
}
export interface DateResult {
productionDate?: string;
expiryDate?: string;
}
export interface LeafletResult {
indications: string;
adultDose: string;
childDose?: string;
contraindications: string;
notes?: string;
}
export interface SearchResult {
medicineId: number;
name: string;
reason?: string;
matchScore?: number;
}
export interface AuditLog {
id: number;
medicineId: number;
batchId?: number;
userId?: number;
action: string;
quantityChange: number;
quantityAfter: number;
remark?: string;
createdAt: string;
medicineName?: string;
userName?: string;
}
+36
View File
@@ -0,0 +1,36 @@
export interface Batch {
id: number;
medicineId: number;
batchNo?: string;
productionDate?: string;
expiryDate: string;
quantity: number;
location?: string;
isExpired: boolean;
createdAt: string;
updatedAt: string;
}
export interface BatchCreate {
batchNo?: string;
productionDate?: string;
expiryDate: string;
quantity?: number;
location?: string;
}
export interface BatchUpdate {
batchNo?: string;
productionDate?: string;
expiryDate?: string;
quantity?: number;
location?: string;
}
export interface BatchDispense {
quantity: number;
}
export interface BatchAddStock {
quantity: number;
}
+30
View File
@@ -0,0 +1,30 @@
export interface Category {
id: number;
name: string;
parentId?: number;
level: number;
icon?: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface CategoryWithChildren extends Category {
children?: Category[];
}
export interface CategoryCreate {
name: string;
parentId?: number;
level?: number;
icon?: string;
sortOrder?: number;
}
export interface CategoryUpdate {
name?: string;
parentId?: number;
level?: number;
icon?: string;
sortOrder?: number;
}
+6
View File
@@ -0,0 +1,6 @@
export * from './user';
export * from './medicine';
export * from './batch';
export * from './category';
export * from './notification';
export * from './api';
+67
View File
@@ -0,0 +1,67 @@
export interface Medicine {
id: number;
name: string;
genericName?: string;
brandName?: string;
manufacturer?: string;
specification?: string;
categoryId?: number;
description?: string;
indications?: string;
adultDose?: string;
childDose?: string;
contraindications?: string;
notes?: string;
imageFrontPath?: string;
imageExpiryPath?: string;
imageLeafletPaths?: string[];
expiryGraceDays: number;
createdBy?: number;
createdAt: string;
updatedAt: string;
}
export interface MedicineWithStock extends Medicine {
totalQuantity: number;
nearestExpiryDate?: string;
batchCount: number;
}
export interface MedicineCreate {
name: string;
genericName?: string;
brandName?: string;
manufacturer?: string;
specification?: string;
categoryId?: number;
description?: string;
indications?: string;
adultDose?: string;
childDose?: string;
contraindications?: string;
notes?: string;
expiryGraceDays?: number;
}
export interface MedicineUpdate {
name?: string;
genericName?: string;
brandName?: string;
manufacturer?: string;
specification?: string;
categoryId?: number;
description?: string;
indications?: string;
adultDose?: string;
childDose?: string;
contraindications?: string;
notes?: string;
expiryGraceDays?: number;
}
export interface MedicineListResponse {
data: MedicineWithStock[];
total: number;
page: number;
pageSize: number;
}
+16
View File
@@ -0,0 +1,16 @@
export interface Notification {
id: number;
type: 'expiry_warning' | 'low_stock' | 'system';
title: string;
content: string;
isRead: boolean;
relatedId?: number;
createdAt: string;
}
export interface NotificationListResponse {
data: Notification[];
total: number;
page: number;
pageSize: number;
}
+39
View File
@@ -0,0 +1,39 @@
export interface User {
id: number;
username: string;
displayName?: string;
email?: string;
role: 'admin' | 'user' | 'readonly';
notificationLevel: 'none' | 'low' | 'normal' | 'high';
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface UserCreate {
username: string;
password: string;
displayName?: string;
email?: string;
role?: 'admin' | 'user' | 'readonly';
notificationLevel?: 'none' | 'low' | 'normal' | 'high';
}
export interface UserUpdate {
displayName?: string;
email?: string;
role?: 'admin' | 'user' | 'readonly';
notificationLevel?: 'none' | 'low' | 'normal' | 'high';
isActive?: boolean;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
tokenType: string;
user: User;
}
+40
View File
@@ -0,0 +1,40 @@
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
export const ROLES = {
ADMIN: 'admin',
USER: 'user',
READONLY: 'readonly'
} as const;
export const NOTIFICATION_LEVELS = {
NONE: 'none',
LOW: 'low',
NORMAL: 'normal',
HIGH: 'high'
} as const;
export const EXPIRY_WARNING_DAYS = [90, 30, 7];
export const LOW_STOCK_THRESHOLD = 5;
export const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
export const CATEGORY_ICONS: Record<string, string> = {
medicine: '💊',
medical: '🩺',
emergency: '🚑',
consumable: '🩹'
};
export const ACTION_LABELS: Record<string, string> = {
add_stock: '入库',
dispense: '取药',
adjust: '调整',
delete: '删除',
modify: '修改'
};
export const DATE_FORMAT = 'YYYY-MM-DD';
export const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
+26
View File
@@ -0,0 +1,26 @@
import dayjs from 'dayjs';
export const formatDate = (date: string | Date, format: string = 'YYYY-MM-DD'): string => {
return dayjs(date).format(format);
};
export const formatDateTime = (date: string | Date): string => {
return dayjs(date).format('YYYY-MM-DD HH:mm:ss');
};
export const getDaysUntilExpiry = (expiryDate: string): number => {
const today = dayjs();
const expiry = dayjs(expiryDate);
return expiry.diff(today, 'day');
};
export const getExpiryStatus = (expiryDate: string, graceDays: number = 0): 'expired' | 'warning' | 'normal' => {
const days = getDaysUntilExpiry(expiryDate) + graceDays;
if (days <= 0) return 'expired';
if (days <= 30) return 'warning';
return 'normal';
};
export const isExpiringSoon = (expiryDate: string, days: number = 30): boolean => {
return getDaysUntilExpiry(expiryDate) <= days;
};
+4
View File
@@ -0,0 +1,4 @@
export * from './date';
export * from './storage';
export * from './validators';
export * from './constants';
+53
View File
@@ -0,0 +1,53 @@
export const localStorage = {
get: <T>(key: string, defaultValue: T): T => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch {
return defaultValue;
}
},
set: <T>(key: string, value: T): void => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
console.error('Failed to save to localStorage');
}
},
remove: (key: string): void => {
try {
window.localStorage.removeItem(key);
} catch {
console.error('Failed to remove from localStorage');
}
}
};
export const sessionStorage = {
get: <T>(key: string, defaultValue: T): T => {
try {
const item = window.sessionStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch {
return defaultValue;
}
},
set: <T>(key: string, value: T): void => {
try {
window.sessionStorage.setItem(key, JSON.stringify(value));
} catch {
console.error('Failed to save to sessionStorage');
}
},
remove: (key: string): void => {
try {
window.sessionStorage.removeItem(key);
} catch {
console.error('Failed to remove from sessionStorage');
}
}
};
+60
View File
@@ -0,0 +1,60 @@
export const validators = {
required: (value: any): string | true => {
if (value === undefined || value === null || value === '') {
return '此字段为必填项';
}
return true;
},
minLength: (min: number) => (value: string): string | true => {
if (value && value.length < min) {
return `最少${min}个字符`;
}
return true;
},
maxLength: (max: number) => (value: string): string | true => {
if (value && value.length > max) {
return `最多${max}个字符`;
}
return true;
},
email: (value: string): string | true => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (value && !emailRegex.test(value)) {
return '请输入有效的邮箱地址';
}
return true;
},
phone: (value: string): string | true => {
const phoneRegex = /^1[3-9]\d{9}$/;
if (value && !phoneRegex.test(value)) {
return '请输入有效的手机号码';
}
return true;
},
number: (value: any): string | true => {
if (value && isNaN(Number(value))) {
return '请输入有效的数字';
}
return true;
},
positiveNumber: (value: any): string | true => {
if (value && (isNaN(Number(value)) || Number(value) <= 0)) {
return '请输入正数';
}
return true;
},
date: (value: string): string | true => {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (value && !dateRegex.test(value)) {
return '请输入有效的日期格式 (YYYY-MM-DD)';
}
return true;
}
};
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+53
View File
@@ -0,0 +1,53 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'icons/*.png'],
manifest: {
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',
purpose: 'any maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
}
})
],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
sourcemap: true
}
});