# 前端开发文档 ## 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: }, { path: '/', element: , children: [ { index: true, element: }, { path: 'medicines', element: }, { path: 'medicines/:id', element: }, { path: 'medicines/add', element: }, { path: 'medicines/edit/:id', element: }, { path: 'quick-dispense', element: }, { path: 'scanner', element: }, { path: 'search', element: }, { path: 'audit-log', element: }, { path: 'notifications', element: }, { path: 'settings', element: }, { path: 'users', element: } ] } ]); 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; logout: () => void; setUser: (user: User) => void; setToken: (token: string) => void; } export const useAuthStore = create()( 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; fetchMedicine: (id: number) => Promise; addMedicine: (data: MedicineFormData) => Promise; updateMedicine: (id: number, data: MedicineFormData) => Promise; deleteMedicine: (id: number) => Promise; } export const useMedicineStore = create((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 => { return client.get('/medicines', { params }); }, // 获取药品详情 get: (id: number): Promise => { return client.get(`/medicines/${id}`); }, // 创建药品 create: (data: MedicineFormData): Promise => { return client.post('/medicines', data); }, // 更新药品 update: (id: number, data: MedicineFormData): Promise => { return client.put(`/medicines/${id}`, data); }, // 删除药品 delete: (id: number): Promise => { return client.delete(`/medicines/${id}`); }, // AI 识别药盒 recognize: (image: File): Promise => { 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 => { 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 => { return client.get(`/medicines/${medicineId}/batches`); }, // 获取批次详情 get: (id: number): Promise => { return client.get(`/batches/${id}`); }, // 创建批次 create: (medicineId: number, data: BatchFormData): Promise => { return client.post(`/medicines/${medicineId}/batches`, data); }, // 更新批次 update: (id: number, data: BatchFormData): Promise => { return client.put(`/batches/${id}`, data); }, // 删除批次 delete: (id: number): Promise => { return client.delete(`/batches/${id}`); }, // 取药(扣减库存) dispense: (id: number, quantity: number): Promise => { return client.post(`/batches/${id}/dispense`, { quantity }); }, // 入库(增加库存) addStock: (id: number, quantity: number): Promise => { 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 = ({ 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 已过期; if (daysUntilExpiry <= 7) return 即将过期; if (daysUntilExpiry <= 30) return 30天内; return null; }; return ( navigate(`/medicines/${medicine.id}`)} >

{medicine.name}

{getExpiryStatus()}
规格: {medicine.specification || '-'}
库存: {medicine.totalQuantity}
批次: {medicine.batchCount}
); }; 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 = ({ 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 (
{value}
); }; 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: , title: '搜索', path: '/search' }, { icon: , title: '添加药品', path: '/medicines/add' }, { icon: , title: '快速取药', path: '/quick-dispense' }, { icon: , 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 (
{stats.totalMedicines}
药品种类
{stats.totalQuantity}
总库存
0 ? stats.expiringCount : null}>
{stats.expiringCount}
即将过期
{quickActions.map((action) => (
navigate(action.path)} >
{action.icon}
{action.title}
))}
); }; 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(null); const [selectedBatch, setSelectedBatch] = useState(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 (
{!selectedMedicine ? (

选择药品

{medicines.map((medicine) => ( setSelectedMedicine(medicine)} >
{medicine.name}
库存: {medicine.totalQuantity}
))}
) : !selectedBatch ? (

选择批次 - {selectedMedicine.name}

{selectedMedicine.batches?.map((batch: any) => ( setSelectedBatch(batch)} >
批次: {batch.batchNo || '默认'}
库存: {batch.quantity}
有效期: {batch.expiryDate}
))}
) : (

取药 - {selectedMedicine.name}

批次: {selectedBatch.batchNo || '默认'}

当前库存: {selectedBatch.quantity}

取药数量

)}
); }; 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; canvasRef: React.RefObject; isReady: boolean; error: string | null; start: () => Promise; stop: () => void; capture: () => Promise; } export const useCamera = (options: UseCameraOptions = {}): UseCameraReturn => { const { facingMode = 'environment', width = 1920, height = 1080 } = options; const videoRef = useRef(null); const canvasRef = useRef(null); const streamRef = useRef(null); const [isReady, setIsReady] = useState(false); const [error, setError] = useState(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 => { 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 } } } }); ```