1247 lines
32 KiB
Markdown
1247 lines
32 KiB
Markdown
# 前端开发文档
|
|
|
|
## 1. 项目结构
|
|
|
|
```
|
|
frontend/
|
|
├── public/ # 静态资源
|
|
│ ├── favicon.ico
|
|
│ ├── manifest.json # PWA 配置
|
|
│ └── icons/
|
|
│ ├── icon-192x192.png
|
|
│ └── icon-512x512.png
|
|
├── src/
|
|
│ ├── api/ # API 调用层
|
|
│ │ ├── client.ts # Axios 实例配置
|
|
│ │ ├── auth.ts # 认证相关 API
|
|
│ │ ├── medicines.ts # 药品管理 API
|
|
│ │ ├── categories.ts # 分类管理 API
|
|
│ │ ├── batches.ts # 批次管理 API
|
|
│ │ ├── notifications.ts # 通知相关 API
|
|
│ │ ├── ai.ts # AI 识别 API
|
|
│ │ └── users.ts # 用户管理 API
|
|
│ ├── components/ # 业务组件
|
|
│ │ ├── MedicineCard/ # 药品卡片
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── BatchForm/ # 批次表单
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── CategoryTree/ # 分类树
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── SearchBar/ # 搜索栏
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── QuantitySelector/ # 数量选择器(大按钮)
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── CameraCapture/ # 摄像头捕获
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ └── Layout/ # 布局组件
|
|
│ │ ├── index.tsx
|
|
│ │ └── index.css
|
|
│ ├── pages/ # 页面组件
|
|
│ │ ├── Home/ # 首页(库存概览)
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── MedicineList/ # 药品列表
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── MedicineDetail/ # 药品详情
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── AddMedicine/ # 添加药品
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── QuickDispense/ # 快速取药(大屏模式)
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── Scanner/ # AI 识别
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── Search/ # 搜索页面
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── AuditLog/ # 审计日志
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── Notifications/ # 通知中心
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── Settings/ # 设置页面
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ ├── UserManagement/ # 用户管理
|
|
│ │ │ ├── index.tsx
|
|
│ │ │ └── index.css
|
|
│ │ └── Login/ # 登录页面
|
|
│ │ ├── index.tsx
|
|
│ │ └── index.css
|
|
│ ├── stores/ # 状态管理
|
|
│ │ ├── authStore.ts # 认证状态
|
|
│ │ ├── medicineStore.ts # 药品状态
|
|
│ │ ├── categoryStore.ts # 分类状态
|
|
│ │ └── uiStore.ts # UI 状态
|
|
│ ├── hooks/ # 自定义 Hooks
|
|
│ │ ├── useAuth.ts # 认证 Hook
|
|
│ │ ├── useMedicine.ts # 药品 Hook
|
|
│ │ ├── useCamera.ts # 摄像头 Hook
|
|
│ │ └── useNotification.ts # 通知 Hook
|
|
│ ├── utils/ # 工具函数
|
|
│ │ ├── date.ts # 日期处理
|
|
│ │ ├── storage.ts # 本地存储
|
|
│ │ ├── validators.ts # 表单验证
|
|
│ │ └── constants.ts # 常量定义
|
|
│ ├── types/ # TypeScript 类型
|
|
│ │ ├── medicine.ts # 药品类型
|
|
│ │ ├── batch.ts # 批次类型
|
|
│ │ ├── user.ts # 用户类型
|
|
│ │ ├── category.ts # 分类类型
|
|
│ │ └── api.ts # API 响应类型
|
|
│ ├── styles/ # 样式文件
|
|
│ │ ├── global.css # 全局样式
|
|
│ │ ├── variables.css # CSS 变量
|
|
│ │ └── mixins.css # CSS 混入
|
|
│ ├── App.tsx # 根组件
|
|
│ ├── main.tsx # 入口文件
|
|
│ └── router.tsx # 路由配置
|
|
├── index.html # HTML 模板
|
|
├── vite.config.ts # Vite 配置
|
|
├── tsconfig.json # TypeScript 配置
|
|
├── .eslintrc.cjs # ESLint 配置
|
|
├── .prettierrc # Prettier 配置
|
|
└── package.json # 依赖配置
|
|
```
|
|
|
|
## 2. 技术栈详解
|
|
|
|
### 2.1 核心依赖
|
|
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
"react": "^18.2.0",
|
|
"react-dom": "^18.2.0",
|
|
"react-router-dom": "^6.20.0",
|
|
"antd-mobile": "^5.34.0",
|
|
"zustand": "^4.4.7",
|
|
"axios": "^1.6.2",
|
|
"dayjs": "^1.11.10",
|
|
"antd-mobile-icons": "^0.3.0"
|
|
},
|
|
"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",
|
|
"prettier": "^3.1.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.2 Ant Design Mobile 使用
|
|
|
|
```tsx
|
|
import {
|
|
Button,
|
|
Card,
|
|
Input,
|
|
Form,
|
|
Dialog,
|
|
Toast,
|
|
NavBar,
|
|
TabBar,
|
|
PullToRefresh,
|
|
InfiniteScroll
|
|
} from 'antd-mobile';
|
|
```
|
|
|
|
## 3. 路由设计
|
|
|
|
### 3.1 路由配置
|
|
|
|
```tsx
|
|
// router.tsx
|
|
import { createBrowserRouter } from 'react-router-dom';
|
|
import Layout from './components/Layout';
|
|
import Home from './pages/Home';
|
|
import MedicineList from './pages/MedicineList';
|
|
import MedicineDetail from './pages/MedicineDetail';
|
|
import AddMedicine from './pages/AddMedicine';
|
|
import QuickDispense from './pages/QuickDispense';
|
|
import Scanner from './pages/Scanner';
|
|
import Search from './pages/Search';
|
|
import AuditLog from './pages/AuditLog';
|
|
import Notifications from './pages/Notifications';
|
|
import Settings from './pages/Settings';
|
|
import UserManagement from './pages/UserManagement';
|
|
import Login from './pages/Login';
|
|
|
|
const router = createBrowserRouter([
|
|
{
|
|
path: '/login',
|
|
element: <Login />
|
|
},
|
|
{
|
|
path: '/',
|
|
element: <Layout />,
|
|
children: [
|
|
{
|
|
index: true,
|
|
element: <Home />
|
|
},
|
|
{
|
|
path: 'medicines',
|
|
element: <MedicineList />
|
|
},
|
|
{
|
|
path: 'medicines/:id',
|
|
element: <MedicineDetail />
|
|
},
|
|
{
|
|
path: 'medicines/add',
|
|
element: <AddMedicine />
|
|
},
|
|
{
|
|
path: 'medicines/edit/:id',
|
|
element: <AddMedicine />
|
|
},
|
|
{
|
|
path: 'quick-dispense',
|
|
element: <QuickDispense />
|
|
},
|
|
{
|
|
path: 'scanner',
|
|
element: <Scanner />
|
|
},
|
|
{
|
|
path: 'search',
|
|
element: <Search />
|
|
},
|
|
{
|
|
path: 'audit-log',
|
|
element: <AuditLog />
|
|
},
|
|
{
|
|
path: 'notifications',
|
|
element: <Notifications />
|
|
},
|
|
{
|
|
path: 'settings',
|
|
element: <Settings />
|
|
},
|
|
{
|
|
path: 'users',
|
|
element: <UserManagement />
|
|
}
|
|
]
|
|
}
|
|
]);
|
|
|
|
export default router;
|
|
```
|
|
|
|
### 3.2 页面路由说明
|
|
|
|
| 路由 | 页面 | 说明 |
|
|
|------|------|------|
|
|
| `/login` | Login | 登录页面 |
|
|
| `/` | Home | 首页,库存概览 |
|
|
| `/medicines` | MedicineList | 药品列表 |
|
|
| `/medicines/:id` | MedicineDetail | 药品详情 |
|
|
| `/medicines/add` | AddMedicine | 添加药品 |
|
|
| `/medicines/edit/:id` | AddMedicine | 编辑药品 |
|
|
| `/quick-dispense` | QuickDispense | 快速取药(大屏模式) |
|
|
| `/scanner` | Scanner | AI 识别 |
|
|
| `/search` | Search | 搜索页面 |
|
|
| `/audit-log` | AuditLog | 审计日志 |
|
|
| `/notifications` | Notifications | 通知中心 |
|
|
| `/settings` | Settings | 设置页面 |
|
|
| `/users` | UserManagement | 用户管理 |
|
|
|
|
## 4. 状态管理设计
|
|
|
|
### 4.1 认证状态 (authStore)
|
|
|
|
```typescript
|
|
// stores/authStore.ts
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
|
|
interface User {
|
|
id: number;
|
|
username: string;
|
|
role: 'admin' | 'user' | 'readonly';
|
|
displayName?: string;
|
|
}
|
|
|
|
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) => {
|
|
// 调用登录 API
|
|
const response = await authApi.login(username, password);
|
|
set({
|
|
user: response.user,
|
|
token: response.token,
|
|
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
|
|
})
|
|
}
|
|
)
|
|
);
|
|
```
|
|
|
|
### 4.2 药品状态 (medicineStore)
|
|
|
|
```typescript
|
|
// stores/medicineStore.ts
|
|
import { create } from 'zustand';
|
|
|
|
interface Medicine {
|
|
id: number;
|
|
name: string;
|
|
genericName?: string;
|
|
brandName?: string;
|
|
manufacturer?: string;
|
|
specification?: string;
|
|
categoryId?: number;
|
|
totalQuantity: number;
|
|
nearestExpiryDate?: string;
|
|
batchCount: number;
|
|
}
|
|
|
|
interface MedicineState {
|
|
medicines: Medicine[];
|
|
currentMedicine: Medicine | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
|
|
fetchMedicines: (params?: MedicineQueryParams) => Promise<void>;
|
|
fetchMedicine: (id: number) => Promise<void>;
|
|
addMedicine: (data: MedicineFormData) => Promise<void>;
|
|
updateMedicine: (id: number, data: MedicineFormData) => Promise<void>;
|
|
deleteMedicine: (id: number) => Promise<void>;
|
|
}
|
|
|
|
export const useMedicineStore = create<MedicineState>((set) => ({
|
|
medicines: [],
|
|
currentMedicine: null,
|
|
loading: false,
|
|
error: null,
|
|
|
|
fetchMedicines: async (params) => {
|
|
set({ loading: true, error: null });
|
|
try {
|
|
const response = await medicineApi.list(params);
|
|
set({ medicines: response.data, loading: false });
|
|
} catch (error) {
|
|
set({ error: error.message, loading: false });
|
|
}
|
|
},
|
|
|
|
fetchMedicine: async (id) => {
|
|
set({ loading: true, error: null });
|
|
try {
|
|
const response = await medicineApi.get(id);
|
|
set({ currentMedicine: response.data, loading: false });
|
|
} catch (error) {
|
|
set({ error: error.message, loading: false });
|
|
}
|
|
},
|
|
|
|
addMedicine: async (data) => {
|
|
set({ loading: true, error: null });
|
|
try {
|
|
await medicineApi.create(data);
|
|
set({ loading: false });
|
|
} catch (error) {
|
|
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) {
|
|
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) {
|
|
set({ error: error.message, loading: false });
|
|
throw error;
|
|
}
|
|
}
|
|
}));
|
|
```
|
|
|
|
## 5. API 调用层设计
|
|
|
|
### 5.1 Axios 实例配置
|
|
|
|
```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,
|
|
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;
|
|
```
|
|
|
|
### 5.2 药品 API
|
|
|
|
```typescript
|
|
// api/medicines.ts
|
|
import client from './client';
|
|
import {
|
|
Medicine,
|
|
MedicineListResponse,
|
|
MedicineFormData,
|
|
MedicineQueryParams
|
|
} from '../types/medicine';
|
|
|
|
export const medicineApi = {
|
|
// 获取药品列表
|
|
list: (params?: MedicineQueryParams): Promise<MedicineListResponse> => {
|
|
return client.get('/medicines', { params });
|
|
},
|
|
|
|
// 获取药品详情
|
|
get: (id: number): Promise<Medicine> => {
|
|
return client.get(`/medicines/${id}`);
|
|
},
|
|
|
|
// 创建药品
|
|
create: (data: MedicineFormData): Promise<Medicine> => {
|
|
return client.post('/medicines', data);
|
|
},
|
|
|
|
// 更新药品
|
|
update: (id: number, data: MedicineFormData): Promise<Medicine> => {
|
|
return client.put(`/medicines/${id}`, data);
|
|
},
|
|
|
|
// 删除药品
|
|
delete: (id: number): Promise<void> => {
|
|
return client.delete(`/medicines/${id}`);
|
|
},
|
|
|
|
// AI 识别药盒
|
|
recognize: (image: File): Promise<MedicineFormData> => {
|
|
const formData = new FormData();
|
|
formData.append('image', image);
|
|
return client.post('/medicines/recognize', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
},
|
|
|
|
// 识别日期
|
|
recognizeDates: (image: File): Promise<{ productionDate?: string; expiryDate?: string }> => {
|
|
const formData = new FormData();
|
|
formData.append('image', image);
|
|
return client.post('/medicines/recognize-dates', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
},
|
|
|
|
// 识别说明书
|
|
recognizeLeaflet: (image: File): Promise<LeafletData> => {
|
|
const formData = new FormData();
|
|
formData.append('image', image);
|
|
return client.post('/medicines/recognize-leaflet', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
}
|
|
};
|
|
```
|
|
|
|
### 5.3 批次 API
|
|
|
|
```typescript
|
|
// api/batches.ts
|
|
import client from './client';
|
|
import { Batch, BatchFormData } from '../types/batch';
|
|
|
|
export const batchApi = {
|
|
// 获取药品的所有批次
|
|
listByMedicine: (medicineId: number): Promise<Batch[]> => {
|
|
return client.get(`/medicines/${medicineId}/batches`);
|
|
},
|
|
|
|
// 获取批次详情
|
|
get: (id: number): Promise<Batch> => {
|
|
return client.get(`/batches/${id}`);
|
|
},
|
|
|
|
// 创建批次
|
|
create: (medicineId: number, data: BatchFormData): Promise<Batch> => {
|
|
return client.post(`/medicines/${medicineId}/batches`, data);
|
|
},
|
|
|
|
// 更新批次
|
|
update: (id: number, data: BatchFormData): Promise<Batch> => {
|
|
return client.put(`/batches/${id}`, data);
|
|
},
|
|
|
|
// 删除批次
|
|
delete: (id: number): Promise<void> => {
|
|
return client.delete(`/batches/${id}`);
|
|
},
|
|
|
|
// 取药(扣减库存)
|
|
dispense: (id: number, quantity: number): Promise<Batch> => {
|
|
return client.post(`/batches/${id}/dispense`, { quantity });
|
|
},
|
|
|
|
// 入库(增加库存)
|
|
addStock: (id: number, quantity: number): Promise<Batch> => {
|
|
return client.post(`/batches/${id}/add-stock`, { quantity });
|
|
}
|
|
};
|
|
```
|
|
|
|
## 6. 组件设计
|
|
|
|
### 6.1 药品卡片组件
|
|
|
|
```tsx
|
|
// components/MedicineCard/index.tsx
|
|
import React from 'react';
|
|
import { Card, Tag } from 'antd-mobile';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Medicine } from '../../types/medicine';
|
|
import './index.css';
|
|
|
|
interface MedicineCardProps {
|
|
medicine: Medicine;
|
|
}
|
|
|
|
const MedicineCard: React.FC<MedicineCardProps> = ({ medicine }) => {
|
|
const navigate = useNavigate();
|
|
|
|
const getExpiryStatus = () => {
|
|
if (!medicine.nearestExpiryDate) return null;
|
|
|
|
const expiryDate = new Date(medicine.nearestExpiryDate);
|
|
const today = new Date();
|
|
const daysUntilExpiry = Math.ceil(
|
|
(expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
|
);
|
|
|
|
if (daysUntilExpiry <= 0) return <Tag color="danger">已过期</Tag>;
|
|
if (daysUntilExpiry <= 7) return <Tag color="warning">即将过期</Tag>;
|
|
if (daysUntilExpiry <= 30) return <Tag color="primary">30天内</Tag>;
|
|
return null;
|
|
};
|
|
|
|
return (
|
|
<Card
|
|
className="medicine-card"
|
|
onClick={() => navigate(`/medicines/${medicine.id}`)}
|
|
>
|
|
<div className="medicine-card-header">
|
|
<h3 className="medicine-name">{medicine.name}</h3>
|
|
{getExpiryStatus()}
|
|
</div>
|
|
<div className="medicine-card-body">
|
|
<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}</span>
|
|
</div>
|
|
<div className="medicine-info">
|
|
<span className="label">批次:</span>
|
|
<span className="value">{medicine.batchCount}</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default MedicineCard;
|
|
```
|
|
|
|
### 6.2 数量选择器组件(大屏模式)
|
|
|
|
```tsx
|
|
// components/QuantitySelector/index.tsx
|
|
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; // 大屏模式
|
|
}
|
|
|
|
const QuantitySelector: React.FC<QuantitySelectorProps> = ({
|
|
value,
|
|
onChange,
|
|
min = 0,
|
|
max = 999,
|
|
step = 1,
|
|
large = 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={value <= min}
|
|
>
|
|
-
|
|
</Button>
|
|
<span className="quantity-value">{value}</span>
|
|
<Button
|
|
className="quantity-btn increment"
|
|
onClick={handleIncrement}
|
|
disabled={value >= max}
|
|
>
|
|
+
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default QuantitySelector;
|
|
```
|
|
|
|
```css
|
|
/* components/QuantitySelector/index.css */
|
|
.quantity-selector {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.quantity-selector.large {
|
|
gap: 32px;
|
|
}
|
|
|
|
.quantity-btn {
|
|
width: 48px;
|
|
height: 48px;
|
|
font-size: 24px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.quantity-selector.large .quantity-btn {
|
|
width: 80px;
|
|
height: 80px;
|
|
font-size: 40px;
|
|
}
|
|
|
|
.quantity-value {
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
min-width: 60px;
|
|
text-align: center;
|
|
}
|
|
|
|
.quantity-selector.large .quantity-value {
|
|
font-size: 40px;
|
|
min-width: 100px;
|
|
}
|
|
```
|
|
|
|
## 7. 页面组件设计
|
|
|
|
### 7.1 首页(库存概览)
|
|
|
|
```tsx
|
|
// pages/Home/index.tsx
|
|
import React, { useEffect } from 'react';
|
|
import { Grid, Card, Badge } from 'antd-mobile';
|
|
import {
|
|
AppOutline,
|
|
SearchOutline,
|
|
AddCircleOutline,
|
|
SetOutline
|
|
} from 'antd-mobile-icons';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useMedicineStore } from '../../stores/medicineStore';
|
|
import './index.css';
|
|
|
|
const Home: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const { medicines, fetchMedicines } = useMedicineStore();
|
|
|
|
useEffect(() => {
|
|
fetchMedicines();
|
|
}, []);
|
|
|
|
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: 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">
|
|
<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;
|
|
```
|
|
|
|
### 7.2 快速取药页面(大屏模式)
|
|
|
|
```tsx
|
|
// pages/QuickDispense/index.tsx
|
|
import React, { useState } from 'react';
|
|
import { Card, Button, Dialog, Toast } from 'antd-mobile';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useMedicineStore } from '../../stores/medicineStore';
|
|
import QuantitySelector from '../../components/QuantitySelector';
|
|
import './index.css';
|
|
|
|
const QuickDispense: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const { medicines, fetchMedicines } = useMedicineStore();
|
|
const [selectedMedicine, setSelectedMedicine] = useState<any>(null);
|
|
const [selectedBatch, setSelectedBatch] = useState<any>(null);
|
|
const [quantity, setQuantity] = useState(1);
|
|
|
|
const handleDispense = async () => {
|
|
if (!selectedMedicine || !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">
|
|
{selectedMedicine.batches?.map((batch: any) => (
|
|
<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>
|
|
))}
|
|
</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;
|
|
```
|
|
|
|
## 8. Hooks 设计
|
|
|
|
### 8.1 摄像头 Hook
|
|
|
|
```typescript
|
|
// hooks/useCamera.ts
|
|
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);
|
|
}
|
|
} catch (err) {
|
|
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
|
|
};
|
|
};
|
|
```
|
|
|
|
## 9. 样式设计
|
|
|
|
### 9.1 CSS 变量
|
|
|
|
```css
|
|
/* styles/variables.css */
|
|
: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;
|
|
|
|
/* 间距 */
|
|
--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;
|
|
|
|
/* 大屏模式 */
|
|
--large-btn-size: 80px;
|
|
--large-font-size: 24px;
|
|
}
|
|
```
|
|
|
|
### 9.2 全局样式
|
|
|
|
```css
|
|
/* styles/global.css */
|
|
@import './variables.css';
|
|
|
|
* {
|
|
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;
|
|
}
|
|
|
|
/* 大屏模式适配 */
|
|
@media (min-width: 768px) {
|
|
:root {
|
|
--font-size-md: 18px;
|
|
--font-size-lg: 22px;
|
|
--font-size-xl: 26px;
|
|
}
|
|
}
|
|
```
|
|
|
|
## 10. PWA 配置
|
|
|
|
```json
|
|
// public/manifest.json
|
|
{
|
|
"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"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// vite.config.ts
|
|
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: {
|
|
proxy: {
|
|
'/api': {
|
|
target: 'http://localhost:8000',
|
|
changeOrigin: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
```
|