Files
2026-06-15 14:50:15 +08:00

32 KiB

前端开发文档

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 核心依赖

{
  "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 使用

import { 
  Button, 
  Card, 
  Input, 
  Form, 
  Dialog, 
  Toast,
  NavBar,
  TabBar,
  PullToRefresh,
  InfiniteScroll
} from 'antd-mobile';

3. 路由设计

3.1 路由配置

// 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)

// 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)

// 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 实例配置

// 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

// 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

// 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 药品卡片组件

// 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 数量选择器组件(大屏模式)

// 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;
/* 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 首页(库存概览)

// 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 快速取药页面(大屏模式)

// 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

// 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 变量

/* 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 全局样式

/* 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 配置

// 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"
    }
  ]
}
// 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
      }
    }
  }
});