首次提交by MimoCode
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# API 配置
|
||||
VITE_API_BASE_URL=/api
|
||||
|
||||
# 应用配置
|
||||
VITE_APP_TITLE=药箱
|
||||
@@ -0,0 +1,106 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# parcel-bundler cache
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# Yarn
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
@@ -0,0 +1,163 @@
|
||||
# 药箱前端修改记录
|
||||
|
||||
## 版本历史
|
||||
|
||||
### v1.0.0 (2026-06-15)
|
||||
|
||||
#### 新增功能
|
||||
|
||||
**项目配置**
|
||||
- 创建 Vite 项目配置 (`vite.config.ts`)
|
||||
- 配置 TypeScript (`tsconfig.json`, `tsconfig.node.json`)
|
||||
- 配置 PWA 支持 (vite-plugin-pwa)
|
||||
- 配置开发服务器代理
|
||||
- 添加环境变量配置 (`.env.example`)
|
||||
|
||||
**TypeScript 类型定义**
|
||||
- 用户类型 (`types/user.ts`)
|
||||
- 药品类型 (`types/medicine.ts`)
|
||||
- 批次类型 (`types/batch.ts`)
|
||||
- 分类类型 (`types/category.ts`)
|
||||
- 通知类型 (`types/notification.ts`)
|
||||
- API 响应类型 (`types/api.ts`)
|
||||
|
||||
**API 调用层**
|
||||
- Axios 实例配置 (`api/client.ts`)
|
||||
- 认证 API (`api/auth.ts`)
|
||||
- 药品管理 API (`api/medicines.ts`)
|
||||
- 批次管理 API (`api/batches.ts`)
|
||||
- 分类管理 API (`api/categories.ts`)
|
||||
- 搜索 API (`api/search.ts`)
|
||||
- 通知 API (`api/notifications.ts`)
|
||||
- 用户管理 API (`api/users.ts`)
|
||||
|
||||
**状态管理 (Zustand)**
|
||||
- 认证状态 (`stores/authStore.ts`)
|
||||
- 药品状态 (`stores/medicineStore.ts`)
|
||||
- 分类状态 (`stores/categoryStore.ts`)
|
||||
- 通知状态 (`stores/notificationStore.ts`)
|
||||
- UI 状态 (`stores/uiStore.ts`)
|
||||
|
||||
**自定义 Hooks**
|
||||
- 认证 Hook (`hooks/useAuth.ts`)
|
||||
- 药品 Hook (`hooks/useMedicine.ts`)
|
||||
- 摄像头 Hook (`hooks/useCamera.ts`)
|
||||
- 通知 Hook (`hooks/useNotification.ts`)
|
||||
|
||||
**公共组件**
|
||||
- 布局组件 (`components/Layout`) - 含导航栏和底部 TabBar
|
||||
- 药品卡片 (`components/MedicineCard`) - 显示药品基本信息
|
||||
- 数量选择器 (`components/QuantitySelector`) - 支持大屏模式
|
||||
- 搜索栏 (`components/SearchBar`) - 支持防抖搜索
|
||||
- 摄像头捕获 (`components/CameraCapture`) - 用于 AI 识别
|
||||
- 分类树 (`components/CategoryTree`) - 二级分类展示
|
||||
|
||||
**页面组件**
|
||||
- 首页 (`pages/Home`) - 库存概览、快捷操作
|
||||
- 登录页 (`pages/Login`) - 用户认证
|
||||
- 药品列表 (`pages/MedicineList`) - 分页加载、搜索
|
||||
- 药品详情 (`pages/MedicineDetail`) - 信息展示、批次管理
|
||||
- 添加/编辑药品 (`pages/AddMedicine`) - 表单、AI 识别
|
||||
- 快速取药 (`pages/QuickDispense`) - 大屏模式、大按钮
|
||||
- 搜索页 (`pages/Search`) - 关键词搜索
|
||||
- 通知中心 (`pages Notifications`) - 通知列表、已读管理
|
||||
- 设置页 (`pages/Settings`) - 个人信息、系统设置
|
||||
|
||||
**工具函数**
|
||||
- 日期处理 (`utils/date.ts`)
|
||||
- 本地存储 (`utils/storage.ts`)
|
||||
- 表单验证 (`utils/validators.ts`)
|
||||
- 常量定义 (`utils/constants.ts`)
|
||||
|
||||
**样式文件**
|
||||
- 全局样式 (`styles/global.css`)
|
||||
- CSS 变量 (`styles/variables.css`)
|
||||
- CSS 混入 (`styles/mixins.css`)
|
||||
|
||||
**项目配置**
|
||||
- package.json - 项目依赖配置
|
||||
- .gitignore - Git 忽略文件
|
||||
- README.md - 项目说明
|
||||
|
||||
**文档**
|
||||
- DEVELOPMENT.md - 开发文档
|
||||
- CHANGELOG.md - 修改记录
|
||||
|
||||
---
|
||||
|
||||
## 待开发功能
|
||||
|
||||
### 计划中
|
||||
|
||||
- [ ] 用户管理页面 (UserManagement)
|
||||
- [ ] 审计日志页面 (AuditLog)
|
||||
- [ ] 药品图片上传功能
|
||||
- [ ] 说明书图片管理
|
||||
- [ ] 深色模式完整实现
|
||||
- [ ] 离线缓存支持
|
||||
- [ ] 推送通知支持
|
||||
- [ ] 国际化支持 (i18n)
|
||||
- [ ] 单元测试用例
|
||||
- [ ] E2E 测试用例
|
||||
|
||||
### 已知问题
|
||||
|
||||
- 暂无
|
||||
|
||||
---
|
||||
|
||||
## 更新说明
|
||||
|
||||
### 2026-06-15
|
||||
|
||||
**初始版本发布**
|
||||
|
||||
完成药箱前端系统的初始开发,包括:
|
||||
|
||||
1. **核心功能实现**
|
||||
- 用户登录认证
|
||||
- 药品 CRUD 操作
|
||||
- 批次管理(取药、入库)
|
||||
- 分类管理(二级分类)
|
||||
- 搜索功能(关键词搜索)
|
||||
- 通知系统
|
||||
- AI 识别接口集成
|
||||
|
||||
2. **UI/UX 设计**
|
||||
- 移动端优先设计
|
||||
- 大屏模式支持(快速取药)
|
||||
- 底部 TabBar 导航
|
||||
- 卡片式布局
|
||||
- 流畅的交互体验
|
||||
|
||||
3. **架构设计**
|
||||
- 组件化开发
|
||||
- 状态管理 (Zustand)
|
||||
- API 抽象层
|
||||
- 类型安全 (TypeScript)
|
||||
- 路由管理 (React Router)
|
||||
|
||||
4. **PWA 支持**
|
||||
- Service Worker 配置
|
||||
- 离线缓存
|
||||
- 可安装
|
||||
|
||||
---
|
||||
|
||||
## 贡献指南
|
||||
|
||||
如需提交修改,请遵循以下规范:
|
||||
|
||||
1. 代码风格遵循项目 ESLint 配置
|
||||
2. 提交信息使用中文
|
||||
3. 新增功能请添加相应的类型定义
|
||||
4. 修改记录请更新此文档
|
||||
|
||||
---
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题或建议,请通过以下方式联系:
|
||||
|
||||
- 项目地址: https://github.com/your-username/yaoxiang
|
||||
- 问题反馈: https://github.com/your-username/yaoxiang/issues
|
||||
@@ -0,0 +1,543 @@
|
||||
# 药箱前端开发文档
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
药箱(YaoXiang)是一个家庭药品与应急物资管理系统,前端采用 React + TypeScript + Vite 技术栈,支持 PWA 和大屏模式。
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 技术 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| React | 18.2.0 | UI 框架 |
|
||||
| TypeScript | 5.2.2 | 类型系统 |
|
||||
| Vite | 5.0.0 | 构建工具 |
|
||||
| React Router | 6.20.0 | 路由管理 |
|
||||
| Zustand | 4.4.7 | 状态管理 |
|
||||
| Ant Design Mobile | 5.34.0 | UI 组件库 |
|
||||
| Axios | 1.6.2 | HTTP 客户端 |
|
||||
| Day.js | 1.11.10 | 日期处理 |
|
||||
|
||||
## 3. 项目结构
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── public/ # 静态资源
|
||||
│ ├── favicon.svg # 网站图标
|
||||
│ ├── manifest.json # PWA 配置
|
||||
│ └── icons/ # 应用图标
|
||||
│
|
||||
├── src/
|
||||
│ ├── api/ # API 调用层
|
||||
│ │ ├── client.ts # Axios 实例配置
|
||||
│ │ ├── auth.ts # 认证相关 API
|
||||
│ │ ├── medicines.ts # 药品管理 API
|
||||
│ │ ├── batches.ts # 批次管理 API
|
||||
│ │ ├── categories.ts # 分类管理 API
|
||||
│ │ ├── search.ts # 搜索 API
|
||||
│ │ ├── notifications.ts # 通知 API
|
||||
│ │ ├── users.ts # 用户管理 API
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── components/ # 公共组件
|
||||
│ │ ├── Layout/ # 布局组件(含 TabBar)
|
||||
│ │ ├── MedicineCard/ # 药品卡片
|
||||
│ │ ├── QuantitySelector/ # 数量选择器
|
||||
│ │ ├── SearchBar/ # 搜索栏
|
||||
│ │ ├── CameraCapture/ # 摄像头捕获
|
||||
│ │ ├── CategoryTree/ # 分类树
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── Home/ # 首页(库存概览)
|
||||
│ │ ├── Login/ # 登录页
|
||||
│ │ ├── MedicineList/ # 药品列表
|
||||
│ │ ├── MedicineDetail/ # 药品详情
|
||||
│ │ ├── AddMedicine/ # 添加/编辑药品
|
||||
│ │ ├── QuickDispense/ # 快速取药(大屏模式)
|
||||
│ │ ├── Search/ # 搜索页
|
||||
│ │ ├── Notifications/ # 通知中心
|
||||
│ │ ├── Settings/ # 设置页
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── stores/ # 状态管理
|
||||
│ │ ├── authStore.ts # 认证状态
|
||||
│ │ ├── medicineStore.ts # 药品状态
|
||||
│ │ ├── categoryStore.ts # 分类状态
|
||||
│ │ ├── notificationStore.ts # 通知状态
|
||||
│ │ ├── uiStore.ts # UI 状态
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ │ ├── useAuth.ts # 认证 Hook
|
||||
│ │ ├── useMedicine.ts # 药品 Hook
|
||||
│ │ ├── useCamera.ts # 摄像头 Hook
|
||||
│ │ ├── useNotification.ts # 通知 Hook
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ │ ├── user.ts # 用户类型
|
||||
│ │ ├── medicine.ts # 药品类型
|
||||
│ │ ├── batch.ts # 批次类型
|
||||
│ │ ├── category.ts # 分类类型
|
||||
│ │ ├── notification.ts # 通知类型
|
||||
│ │ ├── api.ts # API 响应类型
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── date.ts # 日期处理
|
||||
│ │ ├── storage.ts # 本地存储
|
||||
│ │ ├── validators.ts # 表单验证
|
||||
│ │ ├── constants.ts # 常量定义
|
||||
│ │ └── index.ts # 导出汇总
|
||||
│ │
|
||||
│ ├── styles/ # 样式文件
|
||||
│ │ ├── global.css # 全局样式
|
||||
│ │ ├── variables.css # CSS 变量
|
||||
│ │ ├── mixins.css # CSS 混入
|
||||
│ │ └── index.css # 导入汇总
|
||||
│ │
|
||||
│ ├── App.tsx # 根组件
|
||||
│ ├── main.tsx # 入口文件
|
||||
│ └── router.tsx # 路由配置
|
||||
│
|
||||
├── index.html # HTML 模板
|
||||
├── package.json # 依赖配置
|
||||
├── vite.config.ts # Vite 配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
├── tsconfig.node.json # Node TypeScript 配置
|
||||
├── .env.example # 环境变量示例
|
||||
└── .gitignore # Git 忽略文件
|
||||
```
|
||||
|
||||
## 4. 快速开始
|
||||
|
||||
### 4.1 环境准备
|
||||
|
||||
```bash
|
||||
# 进入前端目录
|
||||
cd frontend
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4.2 配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制环境变量示例文件
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件
|
||||
VITE_API_BASE_URL=/api
|
||||
```
|
||||
|
||||
### 4.3 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:5173
|
||||
|
||||
### 4.4 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建产物位于 `dist/` 目录。
|
||||
|
||||
### 4.5 预览生产版本
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 5. 路由配置
|
||||
|
||||
### 5.1 路由表
|
||||
|
||||
| 路径 | 页面 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| `/login` | Login | 登录页 | 公开 |
|
||||
| `/` | Home | 首页 | 登录用户 |
|
||||
| `/medicines` | MedicineList | 药品列表 | 登录用户 |
|
||||
| `/medicines/add` | AddMedicine | 添加药品 | admin/user |
|
||||
| `/medicines/:id` | MedicineDetail | 药品详情 | 登录用户 |
|
||||
| `/medicines/edit/:id` | AddMedicine | 编辑药品 | admin/user |
|
||||
| `/quick-dispense` | QuickDispense | 快速取药 | 登录用户 |
|
||||
| `/search` | Search | 搜索页 | 登录用户 |
|
||||
| `/notifications` | Notifications | 通知中心 | 登录用户 |
|
||||
| `/settings` | Settings | 设置页 | 登录用户 |
|
||||
|
||||
### 5.2 路由守卫
|
||||
|
||||
路由守卫通过 `useAuth` Hook 实现:
|
||||
|
||||
```tsx
|
||||
import { useAuth } from '../hooks';
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
```
|
||||
|
||||
## 6. 状态管理
|
||||
|
||||
### 6.1 Store 结构
|
||||
|
||||
| Store | 说明 | 主要状态 |
|
||||
|-------|------|----------|
|
||||
| authStore | 认证状态 | user, token, isAuthenticated |
|
||||
| medicineStore | 药品状态 | medicines, currentMedicine, loading |
|
||||
| categoryStore | 分类状态 | categories, loading |
|
||||
| notificationStore | 通知状态 | notifications, unreadCount |
|
||||
| uiStore | UI 状态 | isLargeScreen, isDarkMode |
|
||||
|
||||
### 6.2 使用示例
|
||||
|
||||
```tsx
|
||||
import { useMedicineStore } from '../stores';
|
||||
|
||||
const MyComponent = () => {
|
||||
const { medicines, loading, fetchMedicines } = useMedicineStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchMedicines();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loading ? '加载中...' : medicines.map(m => <div key={m.id}>{m.name}</div>)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 7. API 调用
|
||||
|
||||
### 7.1 API 客户端配置
|
||||
|
||||
```typescript
|
||||
// api/client.ts
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// 请求拦截器 - 添加 Token
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器 - 处理 401
|
||||
client.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 7.2 API 调用示例
|
||||
|
||||
```typescript
|
||||
import { medicineApi } from '../api';
|
||||
|
||||
// 获取药品列表
|
||||
const { data, total } = await medicineApi.list({ page: 1, pageSize: 20 });
|
||||
|
||||
// 创建药品
|
||||
const medicine = await medicineApi.create({ name: '布洛芬', specification: '0.3g' });
|
||||
|
||||
// AI 识别药盒
|
||||
const result = await medicineApi.recognize(imageFile);
|
||||
```
|
||||
|
||||
## 8. 组件开发
|
||||
|
||||
### 8.1 添加新组件
|
||||
|
||||
1. 在 `src/components/` 目录下创建组件文件夹
|
||||
2. 创建 `index.tsx` 和 `index.css`
|
||||
3. 在 `src/components/index.ts` 中导出
|
||||
|
||||
```tsx
|
||||
// components/MyComponent/index.tsx
|
||||
import React from 'react';
|
||||
import './index.css';
|
||||
|
||||
interface MyComponentProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const MyComponent: React.FC<MyComponentProps> = ({ title }) => {
|
||||
return <div className="my-component">{title}</div>;
|
||||
};
|
||||
|
||||
export default MyComponent;
|
||||
```
|
||||
|
||||
### 8.2 添加新页面
|
||||
|
||||
1. 在 `src/pages/` 目录下创建页面文件夹
|
||||
2. 创建 `index.tsx` 和 `index.css`
|
||||
3. 在 `src/pages/index.ts` 中导出
|
||||
4. 在 `src/router.tsx` 中添加路由
|
||||
|
||||
```tsx
|
||||
// pages/MyPage/index.tsx
|
||||
import React from 'react';
|
||||
import './index.css';
|
||||
|
||||
const MyPage: React.FC = () => {
|
||||
return <div className="my-page">My Page</div>;
|
||||
};
|
||||
|
||||
export default MyPage;
|
||||
```
|
||||
|
||||
### 8.3 添加新 Hook
|
||||
|
||||
1. 在 `src/hooks/` 目录下创建 Hook 文件
|
||||
2. 在 `src/hooks/index.ts` 中导出
|
||||
|
||||
```tsx
|
||||
// hooks/useMyHook.ts
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export const useMyHook = () => {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
// 实现逻辑
|
||||
}, []);
|
||||
|
||||
return { data, fetchData };
|
||||
};
|
||||
```
|
||||
|
||||
## 9. 样式开发
|
||||
|
||||
### 9.1 CSS 变量
|
||||
|
||||
项目使用 CSS 变量管理主题:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--adm-color-primary: #1677ff;
|
||||
--adm-color-success: #52c41a;
|
||||
--adm-color-warning: #faad14;
|
||||
--adm-color-danger: #ff4d4f;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 大屏模式适配
|
||||
|
||||
```css
|
||||
/* 基础样式 */
|
||||
.quantity-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
/* 大屏模式 */
|
||||
@media (min-width: 768px) {
|
||||
.quantity-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 使用 Ant Design Mobile 样式
|
||||
|
||||
```tsx
|
||||
import { Button } from 'antd-mobile';
|
||||
|
||||
// 使用组件自带样式
|
||||
<Button color="primary" size="large">按钮</Button>
|
||||
|
||||
// 使用自定义样式
|
||||
<div className="custom-wrapper">
|
||||
<Button>按钮</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 10. 类型定义
|
||||
|
||||
### 10.1 添加新类型
|
||||
|
||||
1. 在 `src/types/` 目录下创建类型文件
|
||||
2. 在 `src/types/index.ts` 中导出
|
||||
|
||||
```typescript
|
||||
// types/myType.ts
|
||||
export interface MyType {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MyTypeCreate {
|
||||
name: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 使用类型
|
||||
|
||||
```typescript
|
||||
import { MyType, MyTypeCreate } from '../types';
|
||||
|
||||
const myFunction = (data: MyTypeCreate): MyType => {
|
||||
return { id: 1, ...data, createdAt: new Date().toISOString() };
|
||||
};
|
||||
```
|
||||
|
||||
## 11. 开发规范
|
||||
|
||||
### 11.1 代码风格
|
||||
|
||||
- 使用 TypeScript 严格模式
|
||||
- 遵循 ESLint 规则
|
||||
- 使用 Prettier 格式化
|
||||
|
||||
### 11.2 命名规范
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
|------|------|------|
|
||||
| 组件 | PascalCase | MedicineCard |
|
||||
| Hook | use + PascalCase | useAuth |
|
||||
| 函数 | camelCase | fetchMedicines |
|
||||
| 变量 | camelCase | medicineList |
|
||||
| 常量 | UPPER_SNAKE_CASE | API_BASE_URL |
|
||||
| 文件 | PascalCase (组件) / camelCase (其他) | MedicineCard/index.tsx |
|
||||
| CSS 类 | kebab-case | medicine-card |
|
||||
|
||||
### 11.3 文件组织
|
||||
|
||||
- 每个组件/页面单独一个文件夹
|
||||
- 包含 `index.tsx` 和 `index.css`
|
||||
- 通过 `index.ts` 导出
|
||||
|
||||
### 11.4 提交规范
|
||||
|
||||
```
|
||||
feat: 新功能
|
||||
fix: 修复 bug
|
||||
docs: 文档更新
|
||||
style: 代码格式调整
|
||||
refactor: 重构
|
||||
test: 测试相关
|
||||
chore: 构建/工具相关
|
||||
```
|
||||
|
||||
## 12. 构建与部署
|
||||
|
||||
### 12.1 开发环境
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 12.2 生产构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 12.3 部署到 Nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
root /path/to/dist;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 13. 常见问题
|
||||
|
||||
### 13.1 开发服务器启动失败
|
||||
|
||||
检查端口是否被占用:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
netstat -ano | findstr :5173
|
||||
|
||||
# Mac/Linux
|
||||
lsof -i :5173
|
||||
```
|
||||
|
||||
### 13.2 API 请求失败
|
||||
|
||||
1. 检查后端服务是否启动
|
||||
2. 检查 `.env` 中的 `VITE_API_BASE_URL` 配置
|
||||
3. 检查浏览器控制台错误
|
||||
|
||||
### 13.3 类型错误
|
||||
|
||||
确保所有组件和函数都有正确的类型注解:
|
||||
|
||||
```typescript
|
||||
// 错误
|
||||
const handleClick = (e) => { ... }
|
||||
|
||||
// 正确
|
||||
const handleClick = (e: React.MouseEvent) => { ... }
|
||||
```
|
||||
|
||||
### 13.4 样式不生效
|
||||
|
||||
1. 检查 CSS 文件是否正确导入
|
||||
2. 检查选择器是否正确
|
||||
3. 使用浏览器开发者工具检查样式
|
||||
|
||||
## 14. 扩展开发
|
||||
|
||||
### 14.1 添加新的 AI 识别功能
|
||||
|
||||
1. 在 `src/api/medicines.ts` 中添加 API 调用
|
||||
2. 在页面中使用摄像头组件捕获图片
|
||||
3. 调用 AI 接口识别
|
||||
|
||||
### 14.2 添加新的通知渠道
|
||||
|
||||
1. 在后端添加通知 Provider
|
||||
2. 在前端通知页面展示
|
||||
|
||||
### 14.3 添加新的页面
|
||||
|
||||
1. 创建页面组件
|
||||
2. 添加路由配置
|
||||
3. 添加导航入口
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#1677ff" />
|
||||
<meta name="description" content="家庭药品与应急物资管理系统" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>药箱 - 家庭药品管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "yaoxiang-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"antd-mobile": "^5.34.0",
|
||||
"antd-mobile-icons": "^0.3.0",
|
||||
"zustand": "^4.4.7",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-pwa": "^0.17.0",
|
||||
"eslint": "^8.53.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect x="20" y="30" width="60" height="50" rx="5" fill="#1677ff"/>
|
||||
<rect x="35" y="20" width="30" height="15" rx="3" fill="#1677ff"/>
|
||||
<line x1="50" y1="40" x2="50" y2="70" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="35" y1="55" x2="65" y2="55" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 401 B |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "药箱 - 家庭药品管理",
|
||||
"short_name": "药箱",
|
||||
"description": "家庭药品与应急物资管理系统",
|
||||
"theme_color": "#1677ff",
|
||||
"background_color": "#f5f5f5",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd-mobile';
|
||||
import zhCN from 'antd-mobile/es/locales/zh-CN';
|
||||
import router from './router';
|
||||
import './styles/index.css';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<RouterProvider router={router} />
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,16 @@
|
||||
import client from './client';
|
||||
import { LoginRequest, LoginResponse, User } from '../types';
|
||||
|
||||
export const authApi = {
|
||||
login: (data: LoginRequest): Promise<LoginResponse> => {
|
||||
return client.post('/v1/auth/login', data);
|
||||
},
|
||||
|
||||
getCurrentUser: (): Promise<User> => {
|
||||
return client.get('/v1/auth/me');
|
||||
},
|
||||
|
||||
changePassword: (oldPassword: string, newPassword: string): Promise<void> => {
|
||||
return client.put('/v1/auth/password', { old_password: oldPassword, new_password: newPassword });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import client from './client';
|
||||
import { Batch, BatchCreate, BatchUpdate } from '../types';
|
||||
|
||||
export const batchApi = {
|
||||
listByMedicine: (medicineId: number): Promise<Batch[]> => {
|
||||
return client.get(`/v1/batches/medicine/${medicineId}`);
|
||||
},
|
||||
|
||||
get: (id: number): Promise<Batch> => {
|
||||
return client.get(`/v1/batches/${id}`);
|
||||
},
|
||||
|
||||
create: (medicineId: number, data: BatchCreate): Promise<Batch> => {
|
||||
return client.post(`/v1/batches/medicine/${medicineId}`, data);
|
||||
},
|
||||
|
||||
update: (id: number, data: BatchUpdate): Promise<Batch> => {
|
||||
return client.put(`/v1/batches/${id}`, data);
|
||||
},
|
||||
|
||||
delete: (id: number): Promise<void> => {
|
||||
return client.delete(`/v1/batches/${id}`);
|
||||
},
|
||||
|
||||
dispense: (id: number, quantity: number): Promise<Batch> => {
|
||||
return client.post(`/v1/batches/${id}/dispense`, { quantity });
|
||||
},
|
||||
|
||||
addStock: (id: number, quantity: number): Promise<Batch> => {
|
||||
return client.post(`/v1/batches/${id}/add-stock`, { quantity });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import client from './client';
|
||||
import { Category, CategoryWithChildren, CategoryCreate, CategoryUpdate } from '../types';
|
||||
|
||||
export const categoryApi = {
|
||||
list: (params?: { level?: number; parentId?: number }): Promise<Category[]> => {
|
||||
return client.get('/v1/categories', { params });
|
||||
},
|
||||
|
||||
tree: (): Promise<CategoryWithChildren[]> => {
|
||||
return client.get('/v1/categories/tree');
|
||||
},
|
||||
|
||||
get: (id: number): Promise<Category> => {
|
||||
return client.get(`/v1/categories/${id}`);
|
||||
},
|
||||
|
||||
create: (data: CategoryCreate): Promise<Category> => {
|
||||
return client.post('/v1/categories', data);
|
||||
},
|
||||
|
||||
update: (id: number, data: CategoryUpdate): Promise<Category> => {
|
||||
return client.put(`/v1/categories/${id}`, data);
|
||||
},
|
||||
|
||||
delete: (id: number): Promise<void> => {
|
||||
return client.delete(`/v1/categories/${id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default client;
|
||||
@@ -0,0 +1,7 @@
|
||||
export { authApi } from './auth';
|
||||
export { medicineApi } from './medicines';
|
||||
export { batchApi } from './batches';
|
||||
export { categoryApi } from './categories';
|
||||
export { searchApi } from './search';
|
||||
export { notificationApi } from './notifications';
|
||||
export { userApi } from './users';
|
||||
@@ -0,0 +1,55 @@
|
||||
import client from './client';
|
||||
import { Medicine, MedicineWithStock, MedicineCreate, MedicineUpdate, MedicineListResponse } from '../types';
|
||||
|
||||
interface MedicineQueryParams {
|
||||
categoryId?: number;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const medicineApi = {
|
||||
list: (params?: MedicineQueryParams): Promise<MedicineListResponse> => {
|
||||
return client.get('/v1/medicines', { params });
|
||||
},
|
||||
|
||||
get: (id: number): Promise<Medicine> => {
|
||||
return client.get(`/v1/medicines/${id}`);
|
||||
},
|
||||
|
||||
create: (data: MedicineCreate): Promise<Medicine> => {
|
||||
return client.post('/v1/medicines', data);
|
||||
},
|
||||
|
||||
update: (id: number, data: MedicineUpdate): Promise<Medicine> => {
|
||||
return client.put(`/v1/medicines/${id}`, data);
|
||||
},
|
||||
|
||||
delete: (id: number): Promise<void> => {
|
||||
return client.delete(`/v1/medicines/${id}`);
|
||||
},
|
||||
|
||||
recognize: (file: File): Promise<{ genericName?: string; brandName?: string; manufacturer?: string; specification?: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return client.post('/v1/ai/recognize-medicine', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
},
|
||||
|
||||
recognizeDates: (file: File): Promise<{ productionDate?: string; expiryDate?: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return client.post('/v1/ai/recognize-dates', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
},
|
||||
|
||||
recognizeLeaflet: (file: File): Promise<{ indications: string; adultDose: string; childDose?: string; contraindications: string; notes?: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return client.post('/v1/ai/recognize-leaflet', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import client from './client';
|
||||
import { Notification, NotificationListResponse } from '../types';
|
||||
|
||||
interface NotificationQueryParams {
|
||||
isRead?: boolean;
|
||||
type?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const notificationApi = {
|
||||
list: (params?: NotificationQueryParams): Promise<NotificationListResponse> => {
|
||||
return client.get('/v1/notifications', { params });
|
||||
},
|
||||
|
||||
markAsRead: (id: number): Promise<void> => {
|
||||
return client.put(`/v1/notifications/${id}/read`);
|
||||
},
|
||||
|
||||
markAllAsRead: (): Promise<{ count: number }> => {
|
||||
return client.put('/v1/notifications/read-all');
|
||||
},
|
||||
|
||||
delete: (id: number): Promise<void> => {
|
||||
return client.delete(`/v1/notifications/${id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import client from './client';
|
||||
import { SearchResult } from '../types';
|
||||
|
||||
interface SearchResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
genericName?: string;
|
||||
indications?: string;
|
||||
totalQuantity: number;
|
||||
}
|
||||
|
||||
interface NaturalSearchResponse {
|
||||
results: SearchResult[];
|
||||
aiResponse: string;
|
||||
}
|
||||
|
||||
export const searchApi = {
|
||||
search: (query: string, type: string = 'name'): Promise<SearchResponse[]> => {
|
||||
return client.get('/v1/search', { params: { q: query, type } });
|
||||
},
|
||||
|
||||
naturalSearch: (query: string): Promise<NaturalSearchResponse> => {
|
||||
return client.post('/v1/search/natural', { query });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import client from './client';
|
||||
import { User, UserCreate, UserUpdate } from '../types';
|
||||
|
||||
export const userApi = {
|
||||
list: (): Promise<User[]> => {
|
||||
return client.get('/v1/users');
|
||||
},
|
||||
|
||||
get: (id: number): Promise<User> => {
|
||||
return client.get(`/v1/users/${id}`);
|
||||
},
|
||||
|
||||
create: (data: UserCreate): Promise<User> => {
|
||||
return client.post('/v1/users', data);
|
||||
},
|
||||
|
||||
update: (id: number, data: UserUpdate): Promise<User> => {
|
||||
return client.put(`/v1/users/${id}`, data);
|
||||
},
|
||||
|
||||
delete: (id: number): Promise<void> => {
|
||||
return client.delete(`/v1/users/${id}`);
|
||||
},
|
||||
|
||||
resetPassword: (id: number, newPassword: string): Promise<void> => {
|
||||
return client.post(`/v1/users/${id}/reset-password`, { new_password: newPassword });
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useAuth } from './useAuth';
|
||||
export { useMedicine } from './useMedicine';
|
||||
export { useCamera } from './useCamera';
|
||||
export { useNotification } from './useNotification';
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
.settings-page {
|
||||
padding: var(--adm-spacing-md);
|
||||
}
|
||||
|
||||
.logout-section {
|
||||
margin-top: var(--adm-spacing-xl);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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 })
|
||||
}));
|
||||
@@ -0,0 +1,5 @@
|
||||
export { useAuthStore } from './authStore';
|
||||
export { useMedicineStore } from './medicineStore';
|
||||
export { useCategoryStore } from './categoryStore';
|
||||
export { useNotificationStore } from './notificationStore';
|
||||
export { useUIStore } from './uiStore';
|
||||
@@ -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 })
|
||||
}));
|
||||
@@ -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 })
|
||||
}));
|
||||
@@ -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 }))
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@import './global.css';
|
||||
@import './variables.css';
|
||||
@import './mixins.css';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './user';
|
||||
export * from './medicine';
|
||||
export * from './batch';
|
||||
export * from './category';
|
||||
export * from './notification';
|
||||
export * from './api';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './date';
|
||||
export * from './storage';
|
||||
export * from './validators';
|
||||
export * from './constants';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user