32 KiB
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
}
}
}
});