瀏覽代碼

Initial commit: Work Statistics System

iaun 3 月之前
當前提交
01fe38ab05
共有 75 個文件被更改,包括 13122 次插入0 次删除
  1. 56 0
      .gitignore
  2. 300 0
      .kiro/specs/mobile-and-import/design.md
  3. 96 0
      .kiro/specs/mobile-and-import/requirements.md
  4. 113 0
      .kiro/specs/mobile-and-import/tasks.md
  5. 646 0
      .kiro/specs/system-enhancements/design.md
  6. 90 0
      .kiro/specs/system-enhancements/requirements.md
  7. 193 0
      .kiro/specs/system-enhancements/tasks.md
  8. 479 0
      .kiro/specs/work-statistics-system/design.md
  9. 120 0
      .kiro/specs/work-statistics-system/requirements.md
  10. 177 0
      .kiro/specs/work-statistics-system/tasks.md
  11. 115 0
      README.md
  12. 13 0
      backend/.env.example
  13. 56 0
      backend/app/__init__.py
  14. 47 0
      backend/app/config.py
  15. 7 0
      backend/app/models/__init__.py
  16. 44 0
      backend/app/models/admin.py
  17. 39 0
      backend/app/models/item.py
  18. 36 0
      backend/app/models/person.py
  19. 64 0
      backend/app/models/work_record.py
  20. 35 0
      backend/app/routes/__init__.py
  21. 226 0
      backend/app/routes/admin.py
  22. 120 0
      backend/app/routes/auth.py
  23. 131 0
      backend/app/routes/export.py
  24. 167 0
      backend/app/routes/import_routes.py
  25. 197 0
      backend/app/routes/item.py
  26. 192 0
      backend/app/routes/person.py
  27. 385 0
      backend/app/routes/work_record.py
  28. 9 0
      backend/app/services/__init__.py
  29. 299 0
      backend/app/services/admin_service.py
  30. 53 0
      backend/app/services/auth_service.py
  31. 330 0
      backend/app/services/export_service.py
  32. 226 0
      backend/app/services/import_service.py
  33. 118 0
      backend/app/services/item_service.py
  34. 107 0
      backend/app/services/person_service.py
  35. 322 0
      backend/app/services/work_record_service.py
  36. 4 0
      backend/app/utils/__init__.py
  37. 57 0
      backend/app/utils/auth_decorator.py
  38. 45 0
      backend/app/utils/validators.py
  39. 30 0
      backend/init_db.py
  40. 25 0
      backend/requirements.txt
  41. 9 0
      backend/run.py
  42. 1 0
      backend/tests/__init__.py
  43. 69 0
      backend/tests/conftest.py
  44. 240 0
      backend/tests/test_admin.py
  45. 19 0
      backend/tests/test_app.py
  46. 121 0
      backend/tests/test_auth.py
  47. 267 0
      backend/tests/test_export.py
  48. 229 0
      backend/tests/test_item.py
  49. 125 0
      backend/tests/test_person.py
  50. 68 0
      backend/tests/test_work_record.py
  51. 10 0
      backend/wsgi.py
  52. 13 0
      frontend/index.html
  53. 3104 0
      frontend/package-lock.json
  54. 25 0
      frontend/package.json
  55. 41 0
      frontend/src/App.jsx
  56. 129 0
      frontend/src/components/AdminForm.jsx
  57. 196 0
      frontend/src/components/AdminList.jsx
  58. 403 0
      frontend/src/components/Dashboard.jsx
  59. 199 0
      frontend/src/components/Export.jsx
  60. 266 0
      frontend/src/components/Import.jsx
  61. 127 0
      frontend/src/components/ItemForm.jsx
  62. 180 0
      frontend/src/components/ItemList.jsx
  63. 225 0
      frontend/src/components/Layout.jsx
  64. 94 0
      frontend/src/components/Login.jsx
  65. 99 0
      frontend/src/components/PersonForm.jsx
  66. 173 0
      frontend/src/components/PersonList.jsx
  67. 31 0
      frontend/src/components/ProtectedRoute.jsx
  68. 178 0
      frontend/src/components/WorkRecordForm.jsx
  69. 271 0
      frontend/src/components/WorkRecordList.jsx
  70. 80 0
      frontend/src/contexts/AuthContext.jsx
  71. 150 0
      frontend/src/index.css
  72. 22 0
      frontend/src/index.jsx
  73. 130 0
      frontend/src/services/api.js
  74. 35 0
      frontend/src/utils/timeUtils.js
  75. 24 0
      frontend/vite.config.js

+ 56 - 0
.gitignore

@@ -0,0 +1,56 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+venv/
+ENV/
+.venv/
+*.egg-info/
+dist/
+build/
+.pytest_cache/
+.coverage
+htmlcov/
+
+# Backend
+backend/venv/
+backend/*.db
+backend/.env
+backend/instance/
+
+# Node.js
+node_modules/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Frontend build
+frontend/dist/
+frontend/node_modules/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+logs/
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# Temporary files
+tmp/
+temp/
+*.tmp

+ 300 - 0
.kiro/specs/mobile-and-import/design.md

@@ -0,0 +1,300 @@
+# Design Document: Mobile Adaptation and Import Feature
+
+## Overview
+
+本设计文档描述工作统计系统的手机适配和数据导入功能的技术实现方案。手机适配将通过CSS媒体查询和Ant Design的响应式组件实现;导入功能将在后端使用openpyxl解析XLSX文件,前端使用Ant Design的Upload组件。
+
+## Architecture
+
+### 系统架构图
+
+```mermaid
+graph TB
+    subgraph Frontend
+        A[index.html] --> B[Viewport Meta]
+        C[Layout.jsx] --> D[Responsive Sider]
+        E[Components] --> F[Mobile CSS]
+        G[Import.jsx] --> H[Upload Component]
+    end
+    
+    subgraph Backend
+        I[import_routes.py] --> J[ImportService]
+        J --> K[Template Generator]
+        J --> L[XLSX Parser]
+        L --> M[Data Validator]
+        M --> N[WorkRecord Creator]
+    end
+    
+    H -->|POST /api/import/upload| I
+    H -->|GET /api/import/template| I
+```
+
+### 技术栈
+
+- **前端**: React + Ant Design (响应式组件)
+- **后端**: Flask + openpyxl (XLSX处理)
+- **样式**: CSS Media Queries + Ant Design Grid
+
+## Components and Interfaces
+
+### 1. Viewport Configuration (index.html)
+
+修改viewport meta标签以禁止用户缩放:
+
+```html
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
+```
+
+### 2. Responsive Layout Component (Layout.jsx)
+
+增强现有Layout组件以支持移动端:
+
+```jsx
+// 移动端检测
+const [isMobile, setIsMobile] = useState(window.innerWidth < 768)
+
+// 移动端使用Drawer替代Sider
+{isMobile ? (
+  <Drawer placement="left" onClose={toggleMenu} open={menuVisible}>
+    <Menu items={menuItems} onClick={handleMenuClick} />
+  </Drawer>
+) : (
+  <Sider collapsible collapsed={collapsed}>
+    <Menu items={menuItems} onClick={handleMenuClick} />
+  </Sider>
+)}
+```
+
+### 3. Import Service (Backend)
+
+新增 `backend/app/services/import_service.py`:
+
+```python
+class ImportService:
+    REQUIRED_COLUMNS = ['人员姓名', '物品名称', '工作日期', '数量']
+    MAX_FILE_SIZE = 5 * 1024 * 1024  # 5MB
+    
+    @staticmethod
+    def generate_template() -> BytesIO:
+        """生成导入模板XLSX文件"""
+        pass
+    
+    @staticmethod
+    def parse_and_validate(file_content: bytes) -> Tuple[List[dict], List[str]]:
+        """解析并验证XLSX文件内容
+        Returns: (valid_records, errors)
+        """
+        pass
+    
+    @staticmethod
+    def import_records(records: List[dict]) -> int:
+        """批量创建工作记录
+        Returns: 成功导入的记录数
+        """
+        pass
+```
+
+### 4. Import Routes (Backend)
+
+新增 `backend/app/routes/import_routes.py`:
+
+```python
+@import_bp.route('/template', methods=['GET'])
+def download_template():
+    """下载导入模板"""
+    pass
+
+@import_bp.route('/upload', methods=['POST'])
+def upload_import():
+    """上传并导入XLSX文件"""
+    pass
+```
+
+### 5. Import Component (Frontend)
+
+新增 `frontend/src/components/Import.jsx`:
+
+```jsx
+function Import() {
+  // 模板下载
+  const handleDownloadTemplate = async () => { ... }
+  
+  // 文件上传
+  const handleUpload = async (file) => { ... }
+  
+  return (
+    <Card>
+      <Button onClick={handleDownloadTemplate}>下载导入模板</Button>
+      <Upload beforeUpload={handleUpload} accept=".xlsx">
+        <Button>选择文件上传</Button>
+      </Upload>
+      {errors && <Alert type="error" message={errors} />}
+      {success && <Alert type="success" message={success} />}
+    </Card>
+  )
+}
+```
+
+### 6. API Extensions
+
+扩展 `frontend/src/services/api.js`:
+
+```javascript
+export const importApi = {
+  downloadTemplate: () => axios.get('/api/import/template', { responseType: 'blob' }),
+  upload: (file) => {
+    const formData = new FormData()
+    formData.append('file', file)
+    return api.post('/api/import/upload', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })
+  }
+}
+```
+
+## Data Models
+
+### Import Template Structure
+
+| 列名 | 字段 | 类型 | 说明 |
+|------|------|------|------|
+| 人员姓名 | person_name | string | 必须匹配系统中已存在的人员 |
+| 物品名称 | item_name | string | 必须匹配系统中已存在的物品 |
+| 工作日期 | work_date | date | 格式: YYYY-MM-DD |
+| 数量 | quantity | number | 正整数 |
+
+### Import Response
+
+```typescript
+// 成功响应
+interface ImportSuccessResponse {
+  success: true
+  count: number  // 导入记录数
+}
+
+// 失败响应
+interface ImportErrorResponse {
+  success: false
+  errors: string[]  // 错误信息列表
+}
+```
+
+## Correctness Properties
+
+*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
+
+### Property 1: Template Column Completeness
+
+*For any* generated import template, it SHALL contain all required columns (人员姓名, 物品名称, 工作日期, 数量) in the header row.
+
+**Validates: Requirements 3.2**
+
+### Property 2: XLSX Format Validation
+
+*For any* uploaded file that is not a valid XLSX format, the Import_Service SHALL reject it with a format error.
+
+**Validates: Requirements 4.2**
+
+### Property 3: Required Column Validation
+
+*For any* uploaded XLSX file missing one or more required columns, the Import_Service SHALL reject it with a column missing error.
+
+**Validates: Requirements 4.3**
+
+### Property 4: Row Validation Error Format
+
+*For any* row with invalid data (non-existent person, non-existent item, invalid date format, non-positive quantity, or empty required field), the Import_Service SHALL return an error message containing the row number and specific error reason.
+
+**Validates: Requirements 4.4, 4.5, 5.1, 5.2, 5.3, 5.4, 5.5**
+
+### Property 5: Successful Import Record Count
+
+*For any* valid import file with N rows of data, the Import_Service SHALL create exactly N work records and return count=N.
+
+**Validates: Requirements 4.6, 4.7**
+
+### Property 6: Atomic Import Operation
+
+*For any* import file containing at least one invalid row, the Import_Service SHALL NOT create any work records (all-or-nothing).
+
+**Validates: Requirements 4.8**
+
+### Property 7: File Size Validation
+
+*For any* uploaded file exceeding 5MB, the System SHALL reject the upload with a file size error.
+
+**Validates: Requirements 4.11**
+
+## Error Handling
+
+### Backend Error Handling
+
+| 错误类型 | HTTP状态码 | 错误消息格式 |
+|---------|-----------|-------------|
+| 文件格式错误 | 400 | "文件格式错误,请上传XLSX文件" |
+| 文件过大 | 400 | "文件大小超过限制(最大5MB)" |
+| 缺少必需列 | 400 | "缺少必需列: xxx" |
+| 人员不存在 | 400 | "第X行: 人员 'xxx' 不存在" |
+| 物品不存在 | 400 | "第X行: 物品 'xxx' 不存在" |
+| 日期格式错误 | 400 | "第X行: 日期格式错误,应为 YYYY-MM-DD" |
+| 数量无效 | 400 | "第X行: 数量必须为正数" |
+| 字段为空 | 400 | "第X行: 'xxx' 不能为空" |
+
+### Frontend Error Display
+
+- 所有验证错误显示在可滚动列表中
+- 第一个错误高亮显示
+- 提供"重新上传"按钮
+
+## Testing Strategy
+
+### Unit Tests
+
+1. **Template Generation Tests**
+   - 验证模板包含所有必需列
+   - 验证模板包含示例数据
+   - 验证日期格式正确
+
+2. **Validation Tests**
+   - 测试各种无效输入的错误消息
+   - 测试边界条件(空文件、单行、多行)
+
+3. **Import Tests**
+   - 测试成功导入场景
+   - 测试部分失败场景(原子性)
+
+### Property-Based Tests
+
+使用 `hypothesis` 库进行属性测试:
+
+1. **Property 1**: 生成模板列完整性
+2. **Property 4**: 验证错误消息格式
+3. **Property 5**: 成功导入记录计数
+4. **Property 6**: 原子性操作验证
+
+### Test Configuration
+
+- Property tests: 最少100次迭代
+- 使用 pytest + hypothesis
+- 测试标签格式: `**Feature: mobile-and-import, Property N: {property_text}**`
+
+## Mobile Responsive Breakpoints
+
+```css
+/* 移动端 */
+@media (max-width: 767px) {
+  /* 单列布局 */
+  /* 抽屉式导航 */
+  /* 卡片式表格 */
+}
+
+/* 平板 */
+@media (min-width: 768px) and (max-width: 991px) {
+  /* 两列布局 */
+}
+
+/* 桌面 */
+@media (min-width: 992px) {
+  /* 多列布局 */
+}
+```

+ 96 - 0
.kiro/specs/mobile-and-import/requirements.md

@@ -0,0 +1,96 @@
+# Requirements Document
+
+## Introduction
+
+本文档描述工作统计系统的两个新增功能需求:手机端适配(包含禁止用户缩放)和数据导入功能(支持XLSX格式模板)。这些功能将提升系统的移动端用户体验,并支持批量数据导入以提高工作效率。
+
+## Glossary
+
+- **System**: 工作统计系统的整体应用
+- **Frontend**: 前端应用,基于React和Ant Design构建
+- **Mobile_View**: 移动端视图,针对手机屏幕优化的界面
+- **Viewport**: 浏览器视口,控制页面显示区域和缩放行为
+- **Import_Service**: 导入服务,负责解析和处理导入的数据文件
+- **Import_Template**: 导入模板,预定义格式的XLSX文件
+- **Work_Record**: 工作记录实体
+- **XLSX**: Excel文件格式,用于数据导入导出
+
+## Requirements
+
+### Requirement 1: Mobile Responsive Design
+
+**User Story:** As a mobile user, I want to use the system on my phone with a properly adapted interface, so that I can manage work records conveniently on mobile devices.
+
+#### Acceptance Criteria
+
+1. THE Frontend SHALL adapt layout responsively for mobile screens (width < 768px)
+2. THE Frontend SHALL use single-column layout for forms and lists on mobile devices
+3. THE Frontend SHALL adjust navigation menu to mobile-friendly format (collapsible menu or bottom navigation)
+4. THE Frontend SHALL ensure all buttons and interactive elements have minimum touch target size of 44x44 pixels
+5. THE Frontend SHALL optimize table displays for mobile by using card-based layouts or horizontal scrolling
+6. THE Frontend SHALL ensure all text remains readable without horizontal scrolling on mobile devices
+7. WHEN displaying forms on mobile, THE Frontend SHALL stack form fields vertically
+8. WHEN displaying the dashboard on mobile, THE Frontend SHALL reorganize statistics cards into single-column layout
+
+### Requirement 2: Disable User Zoom
+
+**User Story:** As a system administrator, I want to prevent users from zooming the page on mobile devices, so that the interface maintains consistent appearance and usability.
+
+#### Acceptance Criteria
+
+1. THE Frontend SHALL set viewport meta tag to disable user scaling
+2. THE Viewport SHALL include `user-scalable=no` attribute
+3. THE Viewport SHALL set `maximum-scale=1.0` to prevent zoom
+4. THE Viewport SHALL set `minimum-scale=1.0` to prevent zoom out
+5. THE Frontend SHALL maintain initial-scale at 1.0 for proper rendering
+
+### Requirement 3: Data Import Template Download
+
+**User Story:** As a manager, I want to download an import template, so that I can prepare data in the correct format for batch import.
+
+#### Acceptance Criteria
+
+1. THE System SHALL provide a downloadable XLSX import template
+2. THE Import_Template SHALL contain columns: person_name, item_name, work_date, quantity
+3. THE Import_Template SHALL include a header row with column names in Chinese (人员姓名, 物品名称, 工作日期, 数量)
+4. THE Import_Template SHALL include example data rows demonstrating correct format
+5. THE Import_Template SHALL specify date format as YYYY-MM-DD in the example
+6. WHEN a user clicks the download template button, THE System SHALL download the template file named "import_template.xlsx"
+7. THE Frontend SHALL display the template download button prominently in the import section
+
+### Requirement 4: Work Record Import
+
+**User Story:** As a manager, I want to import work records from an XLSX file, so that I can batch create multiple records efficiently.
+
+#### Acceptance Criteria
+
+1. WHEN a user uploads an XLSX file, THE Import_Service SHALL parse the file content
+2. THE Import_Service SHALL validate that uploaded file is in XLSX format
+3. THE Import_Service SHALL validate that all required columns exist in the uploaded file
+4. THE Import_Service SHALL validate each row's data:
+   - person_name must match an existing person in the system
+   - item_name must match an existing item in the system
+   - work_date must be a valid date in YYYY-MM-DD format
+   - quantity must be a positive number
+5. IF any row contains invalid data, THEN THE Import_Service SHALL return detailed error messages indicating row number and error reason
+6. IF all rows are valid, THEN THE Import_Service SHALL create work records for each row
+7. WHEN import succeeds, THE System SHALL return the count of successfully imported records
+8. WHEN import fails validation, THE System SHALL NOT create any records (atomic operation)
+9. THE Frontend SHALL display a file upload component for selecting XLSX files
+10. THE Frontend SHALL show import progress and results (success count or error details)
+11. THE Frontend SHALL limit file size to 5MB maximum
+12. IF file size exceeds limit, THEN THE System SHALL reject the upload with appropriate error message
+
+### Requirement 5: Import Error Handling
+
+**User Story:** As a manager, I want clear error messages when import fails, so that I can correct the data and retry.
+
+#### Acceptance Criteria
+
+1. WHEN person_name does not match any existing person, THE Import_Service SHALL report "第X行: 人员 'xxx' 不存在"
+2. WHEN item_name does not match any existing item, THE Import_Service SHALL report "第X行: 物品 'xxx' 不存在"
+3. WHEN work_date format is invalid, THE Import_Service SHALL report "第X行: 日期格式错误,应为 YYYY-MM-DD"
+4. WHEN quantity is not a positive number, THE Import_Service SHALL report "第X行: 数量必须为正数"
+5. WHEN required field is empty, THE Import_Service SHALL report "第X行: 'xxx' 不能为空"
+6. THE Frontend SHALL display all validation errors in a scrollable list
+7. THE Frontend SHALL highlight the first error for user attention

+ 113 - 0
.kiro/specs/mobile-and-import/tasks.md

@@ -0,0 +1,113 @@
+# Implementation Plan: Mobile Adaptation and Import Feature
+
+## Overview
+
+本实现计划将手机适配和导入功能分解为可执行的编码任务。首先实现手机适配(viewport和响应式布局),然后实现后端导入服务,最后实现前端导入界面。
+
+## Tasks
+
+- [x] 1. Configure viewport to disable user zoom
+  - [x] 1.1 Update index.html viewport meta tag
+    - Add `user-scalable=no, maximum-scale=1.0, minimum-scale=1.0` to viewport
+    - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
+
+- [x] 2. Implement mobile responsive layout
+  - [x] 2.1 Add mobile detection and responsive styles to Layout.jsx
+    - Add window resize listener for mobile detection (< 768px)
+    - Replace Sider with Drawer component on mobile
+    - Add hamburger menu button in Header for mobile
+    - _Requirements: 1.1, 1.3_
+  - [x] 2.2 Add responsive CSS styles to index.css
+    - Add media queries for mobile breakpoints
+    - Style adjustments for touch targets (min 44x44px)
+    - _Requirements: 1.4, 1.6_
+  - [x] 2.3 Update Dashboard.jsx for mobile layout
+    - Use responsive Col spans for statistics cards
+    - Stack cards vertically on mobile
+    - _Requirements: 1.8_
+  - [x] 2.4 Update form components for mobile
+    - Adjust form layouts to stack vertically on mobile
+    - Update WorkRecordForm.jsx, PersonForm.jsx, ItemForm.jsx
+    - _Requirements: 1.2, 1.7_
+  - [x] 2.5 Update list/table components for mobile
+    - Add horizontal scroll or card-based layout for tables
+    - Update WorkRecordList.jsx, PersonList.jsx, ItemList.jsx
+    - _Requirements: 1.5_
+
+- [x] 3. Checkpoint - Verify mobile adaptation
+  - Ensure mobile layout works correctly, ask the user if questions arise.
+
+- [x] 4. Implement backend import service
+  - [x] 4.1 Create ImportService class in backend/app/services/import_service.py
+    - Implement `generate_template()` method to create XLSX template
+    - Template must include headers: 人员姓名, 物品名称, 工作日期, 数量
+    - Include example data rows
+    - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
+  - [ ]* 4.2 Write property test for template column completeness
+    - **Property 1: Template Column Completeness**
+    - **Validates: Requirements 3.2**
+  - [x] 4.3 Implement `parse_and_validate()` method
+    - Parse XLSX file content using openpyxl
+    - Validate file format is XLSX
+    - Validate required columns exist
+    - Validate each row's data (person, item, date, quantity)
+    - Return list of valid records and list of errors
+    - _Requirements: 4.1, 4.2, 4.3, 4.4_
+  - [ ]* 4.4 Write property test for validation error format
+    - **Property 4: Row Validation Error Format**
+    - **Validates: Requirements 4.4, 4.5, 5.1, 5.2, 5.3, 5.4, 5.5**
+  - [x] 4.5 Implement `import_records()` method
+    - Create WorkRecord entries for each valid record
+    - Implement atomic operation (rollback on any failure)
+    - Return count of imported records
+    - _Requirements: 4.6, 4.7, 4.8_
+  - [ ]* 4.6 Write property test for successful import count
+    - **Property 5: Successful Import Record Count**
+    - **Validates: Requirements 4.6, 4.7**
+  - [ ]* 4.7 Write property test for atomic import operation
+    - **Property 6: Atomic Import Operation**
+    - **Validates: Requirements 4.8**
+
+- [x] 5. Implement backend import routes
+  - [x] 5.1 Create import routes in backend/app/routes/import_routes.py
+    - GET /api/import/template - download template
+    - POST /api/import/upload - upload and import file
+    - Add file size validation (5MB limit)
+    - _Requirements: 3.6, 4.9, 4.11, 4.12_
+  - [x] 5.2 Register import blueprint in backend/app/__init__.py
+    - Import and register import_bp
+    - _Requirements: 4.1_
+  - [ ]* 5.3 Write unit tests for import routes
+    - Test template download endpoint
+    - Test upload endpoint with valid/invalid files
+    - Test file size limit
+    - _Requirements: 3.6, 4.11, 4.12_
+
+- [x] 6. Checkpoint - Verify backend import functionality
+  - Ensure all backend tests pass, ask the user if questions arise.
+
+- [x] 7. Implement frontend import component
+  - [x] 7.1 Add import API methods to frontend/src/services/api.js
+    - Add `importApi.downloadTemplate()` method
+    - Add `importApi.upload(file)` method
+    - _Requirements: 3.6, 4.9_
+  - [x] 7.2 Create Import.jsx component
+    - Add template download button
+    - Add file upload component with .xlsx filter
+    - Display upload progress and results
+    - Display validation errors in scrollable list
+    - _Requirements: 3.7, 4.9, 4.10, 5.6, 5.7_
+  - [x] 7.3 Add Import route and navigation
+    - Add route in App.jsx
+    - Add menu item in Layout.jsx
+    - _Requirements: 3.7_
+
+- [x] 8. Final checkpoint - Integration testing
+  - Ensure all features work together, ask the user if questions arise.
+
+## Notes
+
+- Tasks marked with `*` are optional property-based tests
+- Mobile adaptation tasks (1-2) should be completed before import tasks (4-7)
+- Backend import service (4) must be completed before frontend import component (7)
+- Property tests use pytest + hypothesis library

+ 646 - 0
.kiro/specs/system-enhancements/design.md

@@ -0,0 +1,646 @@
+# Design Document: System Enhancements
+
+## Overview
+
+本设计文档描述工作统计系统的增强功能实现方案,包括:基于JWT的用户认证、管理员管理、工作记录按月筛选、前端北京时间显示、以及仪表盘月报功能。这些功能将在现有Flask后端和React前端基础上进行扩展。
+
+## Architecture
+
+```mermaid
+graph TB
+    subgraph Frontend
+        React[React SPA]
+        AuthContext[Auth Context]
+        TimeUtils[Time Utils]
+    end
+    
+    subgraph Backend
+        API[Flask API Server]
+        AuthMiddleware[JWT Auth Middleware]
+        Services[Business Services]
+        Models[SQLAlchemy Models]
+    end
+    
+    subgraph Database
+        PG[(PostgreSQL/SQLite)]
+    end
+    
+    React -->|JWT in Header| API
+    React --> AuthContext
+    React --> TimeUtils
+    API --> AuthMiddleware
+    AuthMiddleware --> Services
+    Services --> Models
+    Models --> PG
+```
+
+### Technology Stack Additions
+
+- **Authentication**: PyJWT (JWT token generation/validation)
+- **Password Hashing**: bcrypt (via Flask-Bcrypt)
+- **Frontend State**: React Context API (for auth state)
+- **Date/Time**: dayjs (for timezone conversion and formatting)
+
+## Components and Interfaces
+
+### Backend Structure (New/Modified Files)
+
+```
+backend/
+├── app/
+│   ├── models/
+│   │   └── admin.py              # NEW: Admin model
+│   ├── routes/
+│   │   ├── auth.py               # NEW: Auth routes (login)
+│   │   └── admin.py              # NEW: Admin management routes
+│   ├── services/
+│   │   ├── auth_service.py       # NEW: JWT generation/validation
+│   │   └── admin_service.py      # NEW: Admin CRUD operations
+│   ├── utils/
+│   │   └── auth_decorator.py     # NEW: @require_auth decorator
+│   └── config.py                 # MODIFIED: Add JWT settings
+```
+
+### Frontend Structure (New/Modified Files)
+
+```
+frontend/
+├── src/
+│   ├── components/
+│   │   ├── Login.jsx             # NEW: Login page
+│   │   ├── AdminList.jsx         # NEW: Admin management page
+│   │   ├── AdminForm.jsx         # NEW: Admin add/edit form
+│   │   ├── WorkRecordList.jsx    # MODIFIED: Add month filter
+│   │   └── Dashboard.jsx         # MODIFIED: Add monthly report
+│   ├── contexts/
+│   │   └── AuthContext.jsx       # NEW: Auth state management
+│   ├── utils/
+│   │   └── timeUtils.js          # NEW: Beijing time conversion
+│   ├── services/
+│   │   └── api.js                # MODIFIED: Add JWT header
+│   └── App.jsx                   # MODIFIED: Add auth routing
+```
+
+### API Endpoints
+
+#### Auth API (New)
+
+| Method | Endpoint | Description | Auth Required |
+|--------|----------|-------------|---------------|
+| POST | `/api/auth/login` | Login with username/password | No |
+| POST | `/api/auth/logout` | Logout (optional server-side) | Yes |
+| GET | `/api/auth/me` | Get current admin info | Yes |
+
+#### Admin API (New)
+
+| Method | Endpoint | Description | Auth Required |
+|--------|----------|-------------|---------------|
+| GET | `/api/admins` | List all admins | Yes |
+| GET | `/api/admins/<id>` | Get admin by ID | Yes |
+| POST | `/api/admins/create` | Create new admin | Yes |
+| POST | `/api/admins/update` | Update admin | Yes |
+| POST | `/api/admins/delete` | Delete admin | Yes |
+
+#### Work Record API (Modified)
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/work-records?year=&month=&person_id=` | List with month filter |
+| GET | `/api/work-records/monthly-summary?year=&month=` | Monthly summary for dashboard |
+
+### Response Format
+
+Login success response:
+```json
+{
+  "success": true,
+  "data": {
+    "token": "eyJhbGciOiJIUzI1NiIs...",
+    "admin": {
+      "id": 1,
+      "username": "admin"
+    }
+  }
+}
+```
+
+Login error response:
+```json
+{
+  "success": false,
+  "error": "Invalid username or password",
+  "code": "AUTH_ERROR"
+}
+```
+
+Unauthorized response (HTTP 401):
+```json
+{
+  "success": false,
+  "error": "Authentication required",
+  "code": "UNAUTHORIZED"
+}
+```
+
+## Data Models
+
+### Admin Model (New)
+
+```python
+class Admin:
+    id: int (primary key, auto-increment)
+    username: str (required, unique, non-empty)
+    password_hash: str (required, bcrypt hashed)
+    created_at: datetime
+    updated_at: datetime
+```
+
+### Entity Relationship Diagram (Updated)
+
+```mermaid
+erDiagram
+    Admin {
+        int id PK
+        string username UK
+        string password_hash
+        datetime created_at
+        datetime updated_at
+    }
+    
+    Person ||--o{ WorkRecord : has
+    Item ||--o{ WorkRecord : has
+    
+    Person {
+        int id PK
+        string name
+        datetime created_at
+        datetime updated_at
+    }
+    
+    Item {
+        int id PK
+        string name
+        decimal unit_price
+        datetime created_at
+        datetime updated_at
+    }
+    
+    WorkRecord {
+        int id PK
+        int person_id FK
+        int item_id FK
+        date work_date
+        int quantity
+        datetime created_at
+        datetime updated_at
+    }
+```
+
+## JWT Authentication Design
+
+### JWT Token Structure
+
+```json
+{
+  "header": {
+    "alg": "HS256",
+    "typ": "JWT"
+  },
+  "payload": {
+    "admin_id": 1,
+    "username": "admin",
+    "exp": 1735689600,
+    "iat": 1735084800
+  }
+}
+```
+
+### JWT Configuration
+
+```python
+# config.py
+JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'dev-secret-key')
+JWT_EXPIRATION_DAYS = 7
+JWT_ALGORITHM = 'HS256'
+```
+
+### Auth Decorator
+
+```python
+# utils/auth_decorator.py
+from functools import wraps
+from flask import request, jsonify
+import jwt
+
+def require_auth(f):
+    @wraps(f)
+    def decorated(*args, **kwargs):
+        token = request.headers.get('Authorization', '').replace('Bearer ', '')
+        if not token:
+            return jsonify({
+                'success': False,
+                'error': 'Authentication required',
+                'code': 'UNAUTHORIZED'
+            }), 401
+        
+        try:
+            payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
+            request.current_admin = payload
+        except jwt.ExpiredSignatureError:
+            return jsonify({
+                'success': False,
+                'error': 'Token expired',
+                'code': 'TOKEN_EXPIRED'
+            }), 401
+        except jwt.InvalidTokenError:
+            return jsonify({
+                'success': False,
+                'error': 'Invalid token',
+                'code': 'INVALID_TOKEN'
+            }), 401
+        
+        return f(*args, **kwargs)
+    return decorated
+```
+
+## Frontend Auth Design
+
+### Auth Context
+
+```jsx
+// contexts/AuthContext.jsx
+const AuthContext = createContext();
+
+export function AuthProvider({ children }) {
+  const [token, setToken] = useState(localStorage.getItem('token'));
+  const [admin, setAdmin] = useState(null);
+
+  const login = async (username, password) => {
+    const response = await api.post('/auth/login', { username, password });
+    if (response.success) {
+      localStorage.setItem('token', response.data.token);
+      setToken(response.data.token);
+      setAdmin(response.data.admin);
+    }
+    return response;
+  };
+
+  const logout = () => {
+    localStorage.removeItem('token');
+    setToken(null);
+    setAdmin(null);
+  };
+
+  return (
+    <AuthContext.Provider value={{ token, admin, login, logout, isAuthenticated: !!token }}>
+      {children}
+    </AuthContext.Provider>
+  );
+}
+```
+
+### Protected Route
+
+```jsx
+// components/ProtectedRoute.jsx
+function ProtectedRoute({ children }) {
+  const { isAuthenticated } = useAuth();
+  
+  if (!isAuthenticated) {
+    return <Navigate to="/login" />;
+  }
+  
+  return children;
+}
+```
+
+## Beijing Time Display Design
+
+### Time Utility Functions
+
+```javascript
+// utils/timeUtils.js
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc';
+import timezone from 'dayjs/plugin/timezone';
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+const BEIJING_TIMEZONE = 'Asia/Shanghai';
+const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
+const DATE_FORMAT = 'YYYY-MM-DD';
+
+// Convert UTC datetime to Beijing time string
+export function toBeijingDateTime(utcDatetime) {
+  if (!utcDatetime) return '';
+  return dayjs.utc(utcDatetime).tz(BEIJING_TIMEZONE).format(DATETIME_FORMAT);
+}
+
+// Convert UTC date to Beijing date string
+export function toBeijingDate(utcDate) {
+  if (!utcDate) return '';
+  return dayjs.utc(utcDate).tz(BEIJING_TIMEZONE).format(DATE_FORMAT);
+}
+```
+
+### Usage in Components
+
+```jsx
+// Example usage in table columns
+const columns = [
+  {
+    title: '创建时间',
+    dataIndex: 'created_at',
+    render: (text) => toBeijingDateTime(text)
+  },
+  {
+    title: '工作日期',
+    dataIndex: 'work_date',
+    render: (text) => toBeijingDate(text)
+  }
+];
+```
+
+## Monthly Filter Design
+
+### Work Record Filter API
+
+```
+GET /api/work-records?year=2024&month=12&person_id=1
+```
+
+Query parameters:
+- `year` (optional): Filter by year (e.g., 2024)
+- `month` (optional): Filter by month (1-12)
+- `person_id` (optional): Filter by person
+
+### Backend Filter Logic
+
+```python
+# services/work_record_service.py
+def get_work_records(year=None, month=None, person_id=None):
+    query = WorkRecord.query
+    
+    if year and month:
+        start_date = date(year, month, 1)
+        if month == 12:
+            end_date = date(year + 1, 1, 1)
+        else:
+            end_date = date(year, month + 1, 1)
+        query = query.filter(WorkRecord.work_date >= start_date)
+        query = query.filter(WorkRecord.work_date < end_date)
+    
+    if person_id:
+        query = query.filter(WorkRecord.person_id == person_id)
+    
+    return query.all()
+```
+
+### Frontend Month Picker
+
+```jsx
+// WorkRecordList.jsx
+<DatePicker.MonthPicker
+  placeholder="选择月份"
+  onChange={(date) => {
+    if (date) {
+      setYear(date.year());
+      setMonth(date.month() + 1);
+    } else {
+      setYear(null);
+      setMonth(null);
+    }
+  }}
+/>
+```
+
+## Dashboard Monthly Report Design
+
+### Monthly Summary API
+
+```
+GET /api/work-records/monthly-summary?year=2024&month=12
+```
+
+Response:
+```json
+{
+  "success": true,
+  "data": {
+    "year": 2024,
+    "month": 12,
+    "total_records": 150,
+    "total_earnings": 25000.00,
+    "top_performers": [
+      {"person_id": 1, "person_name": "张三", "earnings": 8000.00},
+      {"person_id": 2, "person_name": "李四", "earnings": 7500.00}
+    ],
+    "item_breakdown": [
+      {"item_id": 1, "item_name": "物品A", "quantity": 100, "earnings": 10000.00},
+      {"item_id": 2, "item_name": "物品B", "quantity": 80, "earnings": 8000.00}
+    ]
+  }
+}
+```
+
+### Dashboard Monthly Report Component
+
+```jsx
+// Dashboard.jsx - Monthly Report Section
+<Card title="月度报告">
+  <DatePicker.MonthPicker 
+    value={selectedMonth}
+    onChange={setSelectedMonth}
+  />
+  <Row gutter={16}>
+    <Col span={6}>
+      <Statistic title="本月记录数" value={monthlyData.total_records} />
+    </Col>
+    <Col span={6}>
+      <Statistic title="本月总收入" value={monthlyData.total_earnings} prefix="¥" />
+    </Col>
+  </Row>
+  <Table 
+    title="业绩排名"
+    dataSource={monthlyData.top_performers}
+    columns={[
+      { title: '人员', dataIndex: 'person_name' },
+      { title: '收入', dataIndex: 'earnings' }
+    ]}
+  />
+</Card>
+```
+
+
+
+## Correctness Properties
+
+*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
+
+### Property 1: JWT Authentication Round-Trip
+
+*For any* valid admin credentials (username, password), logging in should return a JWT token that:
+- Contains the correct admin_id and username in the payload
+- Has an expiration time exactly 7 days from issuance
+- Can be decoded and verified with the secret key
+
+**Validates: Requirements 1.2, 1.8, 1.10**
+
+### Property 2: Invalid Credentials Rejection
+
+*For any* invalid credentials (non-existent username OR wrong password), the login attempt should be rejected with an authentication error and no token should be returned.
+
+**Validates: Requirements 1.3**
+
+### Property 3: Protected Endpoint Authentication
+
+*For any* protected API endpoint and any request without a valid JWT token (missing, expired, or malformed), the system should return HTTP 401 Unauthorized.
+
+**Validates: Requirements 1.5, 1.6, 1.7**
+
+### Property 4: Admin CRUD Round-Trip
+
+*For any* valid admin data (unique username, password >= 6 chars), creating an admin, then retrieving it, should return the same username. Updating the admin and retrieving again should return the updated values. Deleting the admin (when not the last one) should make it no longer retrievable. The admin list should never expose password_hash.
+
+**Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5**
+
+### Property 5: Admin Username Uniqueness
+
+*For any* existing admin username, attempting to create another admin with the same username should be rejected with a validation error.
+
+**Validates: Requirements 2.6**
+
+### Property 6: Admin Password Validation
+
+*For any* password with length less than 6 characters, attempting to create or update an admin should be rejected with a validation error.
+
+**Validates: Requirements 2.7**
+
+### Property 7: Password Secure Hashing
+
+*For any* admin password, the stored password_hash should not equal the plaintext password, and bcrypt.checkpw should return True when verifying the original password against the hash.
+
+**Validates: Requirements 2.9**
+
+### Property 8: Work Record Month Filter Consistency
+
+*For any* set of work records and a month filter (year, month), the filtered results should contain only records where work_date falls within that month. When combined with person_id filter, results should match both criteria.
+
+**Validates: Requirements 3.1, 3.2, 3.5**
+
+### Property 9: DateTime Beijing Time Formatting
+
+*For any* UTC datetime value, converting to Beijing time should add 8 hours, and the formatted string should match:
+- For datetime: `yyyy-MM-dd HH:mm:ss` pattern
+- For date only: `yyyy-MM-dd` pattern
+
+**Validates: Requirements 4.1, 4.2, 4.4**
+
+### Property 10: Monthly Summary Consistency
+
+*For any* month with work records:
+- total_records should equal the count of records in that month
+- total_earnings should equal the sum of all record total_prices
+- top_performers should be correctly ranked by earnings (descending)
+- item_breakdown totals should sum to total_earnings
+
+**Validates: Requirements 5.2, 5.3, 5.4, 5.5**
+
+## Error Handling
+
+### Authentication Errors (HTTP 401)
+
+- Missing Authorization header
+- Invalid JWT token format
+- Expired JWT token
+- Invalid JWT signature
+- Invalid credentials (wrong username/password)
+
+### Validation Errors (HTTP 400)
+
+- Empty or whitespace-only username
+- Password less than 6 characters
+- Duplicate username
+- Invalid year/month parameters
+
+### Business Logic Errors (HTTP 400)
+
+- Attempting to delete the last admin account
+
+### Not Found Errors (HTTP 404)
+
+- Admin not found by ID
+
+### Error Response Format
+
+```python
+{
+    "success": False,
+    "error": "Human-readable error message",
+    "code": "ERROR_CODE",  # AUTH_ERROR, VALIDATION_ERROR, NOT_FOUND, etc.
+    "details": {}  # Optional additional details
+}
+```
+
+## Testing Strategy
+
+### Testing Framework
+
+- **Unit Tests**: pytest
+- **Property-Based Tests**: hypothesis (Python PBT library)
+- **Frontend Tests**: Jest + React Testing Library
+
+### Unit Tests
+
+Unit tests focus on specific examples and edge cases:
+
+- Test login with valid/invalid credentials
+- Test JWT token generation and validation
+- Test admin CRUD operations
+- Test month filter with various date ranges
+- Test Beijing time conversion edge cases (midnight, year boundaries)
+- Test monthly summary calculations
+
+### Property-Based Tests
+
+Property-based tests verify universal properties across many generated inputs:
+
+- **Minimum 100 iterations** per property test
+- Each test references its design document property
+- Tag format: **Feature: system-enhancements, Property N: [property description]**
+
+### Test Configuration
+
+```python
+# conftest.py additions
+import pytest
+from hypothesis import settings
+
+# Configure hypothesis for minimum 100 examples
+settings.register_profile("ci", max_examples=100)
+settings.load_profile("ci")
+
+@pytest.fixture
+def auth_token(client, test_admin):
+    """Get a valid JWT token for testing protected endpoints."""
+    response = client.post('/api/auth/login', json={
+        'username': test_admin.username,
+        'password': 'testpassword'
+    })
+    return response.json['data']['token']
+
+@pytest.fixture
+def auth_headers(auth_token):
+    """Get headers with JWT token for authenticated requests."""
+    return {'Authorization': f'Bearer {auth_token}'}
+```
+
+### Test Coverage Goals
+
+- JWT token generation and validation
+- All admin CRUD operations
+- Authentication decorator on all protected endpoints
+- Month filter logic with edge cases (month boundaries, leap years)
+- Beijing time conversion accuracy
+- Monthly summary aggregation correctness

+ 90 - 0
.kiro/specs/system-enhancements/requirements.md

@@ -0,0 +1,90 @@
+# Requirements Document
+
+## Introduction
+
+本文档描述工作统计系统的增强功能需求,包括:用户认证与管理员管理、工作记录按月份筛选、前端时间格式化(北京时间)、以及仪表盘月报功能。这些功能将在现有工作统计系统基础上进行扩展。
+
+## Glossary
+
+- **System**: 工作统计系统的整体应用
+- **Auth_Service**: 认证服务,负责用户登录验证和会话管理
+- **Admin**: 管理员用户,拥有系统操作权限
+- **Session**: 用户登录会话,包含认证令牌
+- **Work_Record**: 工作记录实体
+- **Dashboard**: 仪表盘页面,展示统计数据
+- **Monthly_Report**: 月报,按月汇总的统计数据
+- **Beijing_Time**: 北京时间,UTC+8时区
+
+## Requirements
+
+### Requirement 1: User Authentication
+
+**User Story:** As a system administrator, I want to require login before any operation, so that only authorized users can access the system.
+
+#### Acceptance Criteria
+
+1. WHEN a user accesses any page without authentication, THE System SHALL redirect to the login page
+2. WHEN a user submits valid credentials (username, password) via login form, THE Auth_Service SHALL generate a JWT token and return it to the client
+3. WHEN a user submits invalid credentials, THE Auth_Service SHALL reject the login and display an error message
+4. WHEN a user clicks logout, THE Frontend SHALL remove the stored JWT token and redirect to the login page
+5. WHEN a JWT token expires or is invalid, THE System SHALL redirect to the login page
+6. THE System SHALL protect all API endpoints except login endpoint with JWT authentication check
+7. IF an unauthenticated request is made to a protected API, THEN THE System SHALL return HTTP 401 Unauthorized
+8. THE Auth_Service SHALL set JWT token expiration to 7 days by default
+9. THE Frontend SHALL store the JWT token in localStorage and include it in the Authorization header for all API requests
+10. THE Auth_Service SHALL include admin username and ID in the JWT payload
+
+### Requirement 2: Admin Management
+
+**User Story:** As a system administrator, I want to manage multiple admin accounts, so that multiple people can administer the system.
+
+#### Acceptance Criteria
+
+1. THE System SHALL support multiple admin accounts
+2. WHEN an admin submits new admin data (username, password) via POST request, THE System SHALL create a new admin account
+3. WHEN an admin requests the admin list via GET request, THE System SHALL return all admin accounts (without passwords)
+4. WHEN an admin submits an update for another admin via POST request, THE System SHALL update the admin's information
+5. WHEN an admin submits a delete request for another admin via POST request, THE System SHALL remove the admin account
+6. IF the username already exists, THEN THE System SHALL reject the creation and return a validation error
+7. IF the password is less than 6 characters, THEN THE System SHALL reject the request and return a validation error
+8. THE System SHALL NOT allow deleting the last remaining admin account
+9. THE System SHALL store passwords using secure hashing (bcrypt or similar)
+
+### Requirement 3: Work Record Monthly Filter
+
+**User Story:** As a manager, I want to filter work records by month, so that I can view records for a specific month easily.
+
+#### Acceptance Criteria
+
+1. WHEN a manager selects a year and month in the work record list, THE System SHALL display only work records from that month
+2. WHEN a manager requests work records via GET request with year and month parameters, THE API_Server SHALL return only records where work_date falls within that month
+3. THE Frontend SHALL provide a month picker component for selecting the filter month
+4. WHEN no month filter is selected, THE System SHALL display all work records (default behavior)
+5. THE System SHALL combine month filter with existing person filter if both are applied
+
+### Requirement 4: Beijing Time Display
+
+**User Story:** As a manager, I want all times displayed in Beijing time format, so that I can easily understand when events occurred.
+
+#### Acceptance Criteria
+
+1. THE Frontend SHALL display all datetime values in Beijing time (UTC+8)
+2. THE Frontend SHALL format all datetime values as **yyyy-MM-dd HH:mm:ss**
+3. WHEN displaying created_at or updated_at timestamps, THE Frontend SHALL convert from UTC to Beijing time
+4. WHEN displaying work_date, THE Frontend SHALL format as **yyyy-MM-dd**
+5. THE System SHALL handle timezone conversion consistently across all components
+
+### Requirement 5: Dashboard Monthly Report
+
+**User Story:** As a manager, I want to see a monthly report on the dashboard, so that I can quickly review the current month's performance.
+
+#### Acceptance Criteria
+
+1. THE Dashboard SHALL display a monthly report section showing current month's statistics
+2. THE Monthly_Report SHALL show total work records count for the current month
+3. THE Monthly_Report SHALL show total earnings for the current month
+4. THE Monthly_Report SHALL show top performers (persons with highest earnings) for the current month
+5. THE Monthly_Report SHALL show earnings breakdown by item for the current month
+6. WHEN the month changes, THE Dashboard SHALL automatically update to show the new current month's data
+7. THE Dashboard SHALL allow selecting a different month to view historical monthly reports
+

+ 193 - 0
.kiro/specs/system-enhancements/tasks.md

@@ -0,0 +1,193 @@
+# Implementation Plan: System Enhancements
+
+## Overview
+
+本实现计划将在现有工作统计系统基础上添加:JWT认证、管理员管理、工作记录月份筛选、北京时间显示、仪表盘月报功能。实现顺序为:后端认证 → 管理员管理 → 月份筛选 → 月报API → 前端认证 → 前端功能增强。
+
+## Tasks
+
+- [x] 1. 后端认证基础设施
+  - [x] 1.1 添加认证相关依赖到requirements.txt
+    - 添加 PyJWT、Flask-Bcrypt 依赖
+    - _Requirements: 1.2, 2.9_
+  - [x] 1.2 创建Admin模型
+    - 在 backend/app/models/admin.py 创建Admin模型
+    - 包含 id、username、password_hash、created_at、updated_at 字段
+    - username 设置为唯一索引
+    - _Requirements: 2.1_
+  - [x] 1.3 更新配置文件添加JWT设置
+    - 在 backend/app/config.py 添加 JWT_SECRET_KEY、JWT_EXPIRATION_DAYS、JWT_ALGORITHM
+    - _Requirements: 1.8_
+  - [x] 1.4 创建认证服务
+    - 在 backend/app/services/auth_service.py 实现 JWT 生成和验证
+    - 实现 generate_token(admin) 和 verify_token(token) 方法
+    - _Requirements: 1.2, 1.8, 1.10_
+  - [x] 1.5 创建认证装饰器
+    - 在 backend/app/utils/auth_decorator.py 创建 @require_auth 装饰器
+    - 验证 Authorization header 中的 JWT token
+    - 返回 401 状态码对于无效/过期/缺失的 token
+    - _Requirements: 1.5, 1.6, 1.7_
+
+- [x] 2. 后端认证API
+  - [x] 2.1 创建认证路由
+    - 在 backend/app/routes/auth.py 创建登录端点 POST /api/auth/login
+    - 实现 GET /api/auth/me 获取当前管理员信息
+    - _Requirements: 1.2, 1.3_
+  - [ ]* 2.2 编写认证属性测试
+    - **Property 1: JWT Authentication Round-Trip**
+    - **Property 2: Invalid Credentials Rejection**
+    - **Property 3: Protected Endpoint Authentication**
+    - **Validates: Requirements 1.2, 1.3, 1.5, 1.6, 1.8, 1.10**
+
+- [x] 3. 后端管理员管理
+  - [x] 3.1 创建管理员服务
+    - 在 backend/app/services/admin_service.py 实现管理员 CRUD
+    - 实现密码哈希存储(bcrypt)
+    - 实现用户名唯一性检查
+    - 实现密码长度验证(>=6字符)
+    - 实现最后一个管理员删除保护
+    - _Requirements: 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9_
+  - [x] 3.2 创建管理员路由
+    - 在 backend/app/routes/admin.py 创建管理员 CRUD 端点
+    - 所有端点使用 @require_auth 装饰器保护
+    - 列表接口不返回 password_hash
+    - _Requirements: 2.2, 2.3, 2.4, 2.5_
+  - [ ]* 3.3 编写管理员属性测试
+    - **Property 4: Admin CRUD Round-Trip**
+    - **Property 5: Admin Username Uniqueness**
+    - **Property 6: Admin Password Validation**
+    - **Property 7: Password Secure Hashing**
+    - **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.9**
+
+- [x] 4. 保护现有API端点
+  - [x] 4.1 为所有现有路由添加认证装饰器
+    - 修改 person.py、item.py、work_record.py、export.py 路由
+    - 为所有端点添加 @require_auth 装饰器
+    - _Requirements: 1.6_
+
+- [x] 5. Checkpoint - 后端认证功能验证
+  - 确保所有测试通过,如有问题请询问用户
+
+- [x] 6. 后端月份筛选和月报
+  - [x] 6.1 扩展工作记录服务支持月份筛选
+    - 修改 backend/app/services/work_record_service.py
+    - 添加 year 和 month 参数支持
+    - 实现月份范围过滤逻辑
+    - _Requirements: 3.1, 3.2, 3.5_
+  - [x] 6.2 扩展工作记录路由支持月份筛选
+    - 修改 GET /api/work-records 支持 year 和 month 查询参数
+    - _Requirements: 3.2_
+  - [x] 6.3 创建月度汇总API
+    - 在 work_record_service.py 添加 get_monthly_summary 方法
+    - 在 work_record.py 路由添加 GET /api/work-records/monthly-summary
+    - 返回 total_records、total_earnings、top_performers、item_breakdown
+    - _Requirements: 5.2, 5.3, 5.4, 5.5_
+  - [ ]* 6.4 编写月份筛选和月报属性测试
+    - **Property 8: Work Record Month Filter Consistency**
+    - **Property 10: Monthly Summary Consistency**
+    - **Validates: Requirements 3.1, 3.2, 3.5, 5.2, 5.3, 5.4, 5.5**
+
+- [x] 7. Checkpoint - 后端功能完成验证
+  - 确保所有后端测试通过,如有问题请询问用户
+
+- [x] 8. 前端认证基础设施
+  - [x] 8.1 安装前端依赖
+    - 添加 dayjs 依赖用于时间处理
+    - _Requirements: 4.1_
+  - [x] 8.2 创建时间工具函数
+    - 在 frontend/src/utils/timeUtils.js 创建北京时间转换函数
+    - 实现 toBeijingDateTime 和 toBeijingDate 函数
+    - _Requirements: 4.1, 4.2, 4.4_
+  - [x] 8.3 创建认证上下文
+    - 在 frontend/src/contexts/AuthContext.jsx 创建 AuthProvider
+    - 实现 login、logout、isAuthenticated 状态管理
+    - 从 localStorage 读取/存储 token
+    - _Requirements: 1.2, 1.4, 1.9_
+  - [x] 8.4 修改API服务添加JWT头
+    - 修改 frontend/src/services/api.js
+    - 添加 axios 拦截器自动附加 Authorization header
+    - 处理 401 响应自动跳转登录页
+    - _Requirements: 1.5, 1.9_
+
+- [x] 9. 前端登录页面
+  - [x] 9.1 创建登录页面组件
+    - 在 frontend/src/components/Login.jsx 创建登录表单
+    - 使用 Ant Design Form 组件
+    - 显示登录错误信息
+    - _Requirements: 1.2, 1.3_
+  - [x] 9.2 创建受保护路由组件
+    - 在 frontend/src/components/ProtectedRoute.jsx 创建路由守卫
+    - 未认证时重定向到登录页
+    - _Requirements: 1.1_
+  - [x] 9.3 更新App.jsx添加认证路由
+    - 修改 frontend/src/App.jsx
+    - 添加 AuthProvider 包装
+    - 添加 /login 路由
+    - 使用 ProtectedRoute 保护其他路由
+    - _Requirements: 1.1_
+
+- [x] 10. 前端管理员管理页面
+  - [x] 10.1 创建管理员列表组件
+    - 在 frontend/src/components/AdminList.jsx 创建管理员列表
+    - 使用 Ant Design Table 组件
+    - 支持新增、编辑、删除操作
+    - _Requirements: 2.2, 2.3, 2.4, 2.5_
+  - [x] 10.2 创建管理员表单组件
+    - 在 frontend/src/components/AdminForm.jsx 创建管理员表单
+    - 使用 Modal + Form 组件
+    - 显示验证错误信息
+    - _Requirements: 2.2, 2.4, 2.6, 2.7_
+  - [x] 10.3 添加管理员管理路由和导航
+    - 修改 App.jsx 添加 /admins 路由
+    - 修改 Layout.jsx 添加管理员管理菜单项
+    - _Requirements: 2.2_
+
+- [x] 11. 前端时间格式化更新
+  - [x] 11.1 更新所有组件使用北京时间格式
+    - 修改 PersonList.jsx、ItemList.jsx、WorkRecordList.jsx
+    - 使用 toBeijingDateTime 格式化 created_at、updated_at
+    - 使用 toBeijingDate 格式化 work_date
+    - _Requirements: 4.1, 4.2, 4.3, 4.4_
+  - [ ]* 11.2 编写时间格式化单元测试
+    - **Property 9: DateTime Beijing Time Formatting**
+    - **Validates: Requirements 4.1, 4.2, 4.4**
+
+- [x] 12. 前端工作记录月份筛选
+  - [x] 12.1 更新工作记录列表添加月份筛选
+    - 修改 frontend/src/components/WorkRecordList.jsx
+    - 添加 MonthPicker 组件
+    - 调用 API 时传递 year 和 month 参数
+    - _Requirements: 3.1, 3.3, 3.4, 3.5_
+
+- [x] 13. 前端仪表盘月报
+  - [x] 13.1 更新仪表盘添加月报功能
+    - 修改 frontend/src/components/Dashboard.jsx
+    - 添加月份选择器
+    - 显示月度统计:总记录数、总收入
+    - 显示业绩排名表格
+    - 显示物品收入明细
+    - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_
+
+- [x] 14. 前端登出功能
+  - [x] 14.1 添加登出按钮和功能
+    - 修改 Layout.jsx 添加登出按钮
+    - 点击登出清除 token 并跳转登录页
+    - _Requirements: 1.4_
+
+- [x] 15. 创建默认管理员
+  - [x] 15.1 添加数据库初始化脚本
+    - 创建默认管理员账户(如 admin/admin123)
+    - 在应用启动时检查并创建
+    - _Requirements: 2.1_
+
+- [x] 16. Final Checkpoint - 完整功能验证
+  - 确保所有测试通过
+  - 验证前后端集成正常
+  - 如有问题请询问用户
+
+## Notes
+
+- Tasks marked with `*` are optional and can be skipped for faster MVP
+- 实现顺序:后端认证 → 管理员管理 → 月份筛选 → 前端认证 → 前端功能
+- 每个阶段完成后进行 checkpoint 验证
+- 属性测试验证核心业务逻辑的正确性

+ 479 - 0
.kiro/specs/work-statistics-system/design.md

@@ -0,0 +1,479 @@
+# Design Document: Work Statistics System
+
+## Overview
+
+工作统计系统采用前后端分离架构,后端使用Python Flask框架提供RESTful API,前端使用React构建单页应用,数据存储使用PostgreSQL(测试环境使用SQLite)。系统支持人员管理、物品管理、工作记录管理,以及Excel报表导出功能。
+
+## Architecture
+
+```mermaid
+graph TB
+    subgraph Frontend
+        React[React SPA]
+    end
+    
+    subgraph Backend
+        API[Flask API Server]
+        Services[Business Services]
+        Models[SQLAlchemy Models]
+        Export[Excel Export Service]
+    end
+    
+    subgraph Database
+        PG[(PostgreSQL/SQLite)]
+    end
+    
+    React -->|HTTP GET/POST| API
+    API --> Services
+    Services --> Models
+    Services --> Export
+    Models --> PG
+```
+
+### Technology Stack
+
+- **Backend**: Python 3.11+, Flask, SQLAlchemy, openpyxl, Flask-RESTX (Swagger文档)
+- **Frontend**: React 18, Axios, React Router, Ant Design (简洁美观的UI组件库)
+- **Database**: PostgreSQL (production), SQLite (testing)
+- **Testing**: pytest, hypothesis (property-based testing)
+- **API Documentation**: Swagger UI (通过 Flask-RESTX 自动生成)
+
+## Components and Interfaces
+
+### Backend Structure
+
+```
+backend/
+├── app/
+│   ├── __init__.py          # Flask app factory
+│   ├── config.py             # Configuration
+│   ├── models/
+│   │   ├── __init__.py
+│   │   ├── person.py         # Person model
+│   │   ├── item.py           # Item model
+│   │   └── work_record.py    # WorkRecord model
+│   ├── routes/
+│   │   ├── __init__.py
+│   │   ├── person.py         # Person API routes
+│   │   ├── item.py           # Item API routes
+│   │   ├── work_record.py    # WorkRecord API routes
+│   │   └── export.py         # Export API routes
+│   ├── services/
+│   │   ├── __init__.py
+│   │   ├── person_service.py
+│   │   ├── item_service.py
+│   │   ├── work_record_service.py
+│   │   └── export_service.py
+│   └── utils/
+│       ├── __init__.py
+│       └── validators.py
+├── tests/
+│   ├── __init__.py
+│   ├── conftest.py
+│   ├── test_person.py
+│   ├── test_item.py
+│   ├── test_work_record.py
+│   └── test_export.py
+├── requirements.txt
+└── run.py
+```
+
+### API Documentation (Swagger)
+
+系统使用 **Flask-RESTX** 自动生成 Swagger API 文档:
+
+- **访问地址**: `http://localhost:5000/api/docs`
+- **功能**: 
+  - 自动生成所有API接口文档
+  - 支持在线测试API
+  - 显示请求/响应模型
+  - 支持导出 OpenAPI 3.0 规范
+
+```python
+# Flask-RESTX 配置示例
+from flask_restx import Api, Resource, fields
+
+api = Api(
+    title='Work Statistics API',
+    version='1.0',
+    description='工作统计系统 API 文档',
+    doc='/api/docs'
+)
+
+# 定义数据模型
+person_model = api.model('Person', {
+    'id': fields.Integer(description='人员ID'),
+    'name': fields.String(required=True, description='人员姓名')
+})
+```
+
+### Frontend Structure
+
+```
+frontend/
+├── src/
+│   ├── components/
+│   │   ├── Layout.jsx          # 主布局(侧边栏+内容区)
+│   │   ├── PersonList.jsx      # 人员列表(Table组件)
+│   │   ├── PersonForm.jsx      # 人员表单(Modal+Form)
+│   │   ├── ItemList.jsx        # 物品列表
+│   │   ├── ItemForm.jsx        # 物品表单
+│   │   ├── WorkRecordList.jsx  # 工作记录列表(带筛选)
+│   │   ├── WorkRecordForm.jsx  # 工作记录表单
+│   │   └── Dashboard.jsx       # 仪表盘(统计卡片+图表)
+│   ├── services/
+│   │   └── api.js              # API调用封装
+│   ├── App.jsx
+│   └── index.jsx
+├── package.json
+└── vite.config.js
+```
+
+### UI Design Principles
+
+使用 **Ant Design** 组件库,遵循以下设计原则:
+
+1. **简洁清晰** - 使用 Ant Design 的 Table、Form、Modal 等组件,保持界面整洁
+2. **响应式布局** - 使用 Layout 组件实现侧边栏导航 + 内容区布局
+3. **一致性** - 统一的按钮样式、表单验证、消息提示
+4. **易用性** - 表格支持排序、筛选;表单有清晰的验证提示
+
+### UI Components
+
+| 页面 | 主要组件 | 功能 |
+|------|----------|------|
+| 人员管理 | Table + Modal + Form | 列表展示、新增/编辑弹窗 |
+| 物品管理 | Table + Modal + Form | 列表展示、新增/编辑弹窗 |
+| 工作记录 | Table + DatePicker + Select | 列表、日期筛选、人员筛选 |
+| 仪表盘 | Card + Statistic | 今日统计、快捷操作 |
+| 导出 | Button + DatePicker | 选择月份/年份导出Excel |
+
+### API Endpoints
+
+#### Person API
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/persons` | List all persons |
+| GET | `/api/persons/<id>` | Get person by ID |
+| POST | `/api/persons/create` | Create new person |
+| POST | `/api/persons/update` | Update person |
+| POST | `/api/persons/delete` | Delete person |
+
+#### Item API
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/items` | List all items |
+| GET | `/api/items/<id>` | Get item by ID |
+| POST | `/api/items/create` | Create new item |
+| POST | `/api/items/update` | Update item |
+| POST | `/api/items/delete` | Delete item |
+
+#### Work Record API
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/work-records` | List work records (with filters) |
+| GET | `/api/work-records/<id>` | Get work record by ID |
+| POST | `/api/work-records/create` | Create new work record |
+| POST | `/api/work-records/update` | Update work record |
+| POST | `/api/work-records/delete` | Delete work record |
+| GET | `/api/work-records/daily-summary` | Get daily summary |
+
+#### Export API
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/export/monthly?year=&month=` | Export monthly Excel |
+| GET | `/api/export/yearly?year=` | Export yearly Excel |
+
+### Response Format
+
+All API responses follow this structure:
+
+```json
+{
+  "success": true,
+  "data": { ... },
+  "message": "Operation successful"
+}
+```
+
+Error response:
+
+```json
+{
+  "success": false,
+  "error": "Error message",
+  "code": "VALIDATION_ERROR"
+}
+```
+
+## Data Models
+
+### Person Model
+
+```python
+class Person:
+    id: int (primary key, auto-increment)
+    name: str (required, non-empty)
+    created_at: datetime
+    updated_at: datetime
+```
+
+### Item Model
+
+```python
+class Item:
+    id: int (primary key, auto-increment)
+    name: str (required, non-empty)
+    unit_price: float (required, positive, supports decimal values like 10.50)
+    created_at: datetime
+    updated_at: datetime
+```
+
+**Note**: `unit_price` 使用浮点数类型,支持小数点(如 10.50、25.75 等)。在数据库中使用 `NUMERIC(10, 2)` 或 `FLOAT` 类型存储以保证精度。
+
+### WorkRecord Model
+
+```python
+class WorkRecord:
+    id: int (primary key, auto-increment)
+    person_id: int (foreign key -> Person.id)
+    item_id: int (foreign key -> Item.id)
+    work_date: date (required)
+    quantity: int (required, positive)
+    created_at: datetime
+    updated_at: datetime
+    
+    # Computed property
+    @property
+    def total_price(self) -> float:
+        return self.item.unit_price * self.quantity  # Returns float, e.g., 10.50 * 5 = 52.50
+```
+
+**Note**: `total_price` 是计算属性,返回浮点数,等于 `unit_price * quantity`。
+
+### Entity Relationship Diagram
+
+```mermaid
+erDiagram
+    Person ||--o{ WorkRecord : has
+    Item ||--o{ WorkRecord : has
+    
+    Person {
+        int id PK
+        string name
+        datetime created_at
+        datetime updated_at
+    }
+    
+    Item {
+        int id PK
+        string name
+        decimal unit_price
+        datetime created_at
+        datetime updated_at
+    }
+    
+    WorkRecord {
+        int id PK
+        int person_id FK
+        int item_id FK
+        date work_date
+        int quantity
+        datetime created_at
+        datetime updated_at
+    }
+```
+
+## Excel Export Format
+
+### Detail Sheet (明细表)
+
+| 人员 | 日期 | 物品 | 单价 | 数量 | 总价 |
+|------|------|------|------|------|------|
+| 张三 | 2024-01-01 | 物品A | 10.50 | 5 | 52.50 |
+| 张三 | 2024-01-01 | 物品B | 20.75 | 3 | 62.25 |
+
+### Monthly Summary Sheet (月度汇总)
+
+| 人员 | 总金额 |
+|------|--------|
+| 张三 | 1500.00 |
+| 李四 | 2000.00 |
+| **合计** | **3500.00** |
+
+### Yearly Summary Sheet (年度汇总 - 按月)
+
+| 人员 | 1月 | 2月 | ... | 12月 | 年度合计 |
+|------|-----|-----|-----|------|----------|
+| 张三 | 1500 | 1800 | ... | 2000 | 20000 |
+| 李四 | 2000 | 2200 | ... | 2500 | 25000 |
+| **合计** | 3500 | 4000 | ... | 4500 | 45000 |
+
+
+
+## Correctness Properties
+
+*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
+
+### Property 1: Person CRUD Round-Trip
+
+*For any* valid person name, creating a person, then retrieving it by ID, should return the same name. Updating the person's name and retrieving again should return the new name. Deleting the person should make it no longer retrievable.
+
+**Validates: Requirements 1.1, 1.2, 1.3, 1.4**
+
+### Property 2: Person Name Validation
+
+*For any* string composed entirely of whitespace (including empty string), attempting to create or update a person with that name should be rejected with a validation error.
+
+**Validates: Requirements 1.5**
+
+### Property 3: Item CRUD Round-Trip
+
+*For any* valid item (non-empty name, positive unit_price), creating an item, then retrieving it by ID, should return the same name and unit_price. Updating the item and retrieving again should return the updated values. Deleting the item should make it no longer retrievable.
+
+**Validates: Requirements 2.1, 2.2, 2.3, 2.4**
+
+### Property 4: Item Validation
+
+*For any* item with empty name or non-positive unit_price, attempting to create or update should be rejected with a validation error.
+
+**Validates: Requirements 2.5**
+
+### Property 5: Work Record Total Price Calculation
+
+*For any* work record with a valid person, item, and positive quantity, the total_price should equal item.unit_price * quantity.
+
+**Validates: Requirements 3.1, 3.3**
+
+### Property 6: Work Record Filter Consistency
+
+*For any* set of work records and a filter (person_id or date_range), the filtered results should contain only records matching the filter criteria.
+
+**Validates: Requirements 3.2**
+
+### Property 7: Work Record Quantity Validation
+
+*For any* quantity that is zero or negative, attempting to create or update a work record should be rejected with a validation error.
+
+**Validates: Requirements 3.5**
+
+### Property 8: Work Record Reference Validation
+
+*For any* non-existent person_id or item_id, attempting to create a work record should be rejected with a reference error.
+
+**Validates: Requirements 3.6**
+
+### Property 9: Daily Summary Consistency
+
+*For any* date with work records, the daily summary total for each person should equal the sum of their individual work record total_prices for that date.
+
+**Validates: Requirements 4.1, 4.2**
+
+### Property 10: Monthly Export Completeness
+
+*For any* year and month with work records, the exported Excel should contain exactly all work records for that month, and the summary sheet totals should equal the sum of detail records.
+
+**Validates: Requirements 5.1, 5.3**
+
+### Property 11: Yearly Export Completeness
+
+*For any* year with work records, the exported Excel should contain exactly all work records for that year, and the monthly breakdown totals should sum to the yearly total.
+
+**Validates: Requirements 6.1, 6.3**
+
+### Property 12: API Response Consistency
+
+*For any* API request, the response should contain either {success: true, data: ...} or {success: false, error: ...} structure.
+
+**Validates: Requirements 8.3**
+
+### Property 13: Referential Integrity
+
+*For any* person or item with associated work records, deleting that person or item should either fail with an error or cascade delete the associated work records (based on configuration).
+
+**Validates: Requirements 7.4**
+
+## Error Handling
+
+### Validation Errors (HTTP 400)
+
+- Empty or whitespace-only person name
+- Empty item name
+- Non-positive item unit_price
+- Non-positive work record quantity
+- Invalid date format
+- Missing required fields
+
+### Reference Errors (HTTP 404)
+
+- Person not found by ID
+- Item not found by ID
+- Work record not found by ID
+- Non-existent person_id or item_id in work record
+
+### Server Errors (HTTP 500)
+
+- Database connection failure
+- Excel generation failure
+- Unexpected exceptions
+
+### Error Response Format
+
+```python
+{
+    "success": False,
+    "error": "Human-readable error message",
+    "code": "ERROR_CODE",  # e.g., VALIDATION_ERROR, NOT_FOUND, REFERENCE_ERROR
+    "details": {}  # Optional additional details
+}
+```
+
+## Testing Strategy
+
+### Testing Framework
+
+- **Unit Tests**: pytest
+- **Property-Based Tests**: hypothesis (Python PBT library)
+- **Frontend Tests**: Jest + React Testing Library
+
+### Unit Tests
+
+Unit tests focus on specific examples and edge cases:
+
+- Test each API endpoint with valid inputs
+- Test validation error cases (empty names, invalid prices)
+- Test reference error cases (non-existent IDs)
+- Test Excel export format and content
+
+### Property-Based Tests
+
+Property-based tests verify universal properties across many generated inputs:
+
+- **Minimum 100 iterations** per property test
+- Each test references its design document property
+- Tag format: **Feature: work-statistics-system, Property N: [property description]**
+
+### Test Configuration
+
+```python
+# conftest.py
+import pytest
+from hypothesis import settings
+
+# Configure hypothesis for minimum 100 examples
+settings.register_profile("ci", max_examples=100)
+settings.load_profile("ci")
+```
+
+### Test Coverage Goals
+
+- All CRUD operations for Person, Item, WorkRecord
+- All validation rules
+- Total price calculation
+- Filter functionality
+- Daily summary aggregation
+- Excel export content and format

+ 120 - 0
.kiro/specs/work-statistics-system/requirements.md

@@ -0,0 +1,120 @@
+# Requirements Document
+
+## Introduction
+
+工作统计系统是一个用于记录和管理人员工作产出的应用。系统支持管理人员信息、物品信息,记录每日工作量,并提供按月/年导出Excel报表的功能。后端使用Python,前端使用React,数据存储使用PostgreSQL(测试使用SQLite)。
+
+## Glossary
+
+- **System**: 工作统计系统的整体应用
+- **Person**: 被记录工作的人员实体
+- **Item**: 物品实体,包含名称和单价
+- **Work_Record**: 人员工作记录,记录某人在某天做了某物品多少件
+- **API_Server**: 后端RESTful API服务
+- **Frontend**: React前端应用
+- **Export_Service**: Excel导出服务
+
+## Requirements
+
+### Requirement 1: Person Management
+
+**User Story:** As a manager, I want to manage person information, so that I can track who is doing the work.
+
+#### Acceptance Criteria
+
+1. WHEN a manager submits a person name via POST request, THE System SHALL create a new person record and return the created person data
+2. WHEN a manager requests the person list via GET request, THE System SHALL return all person records with their IDs and names
+3. WHEN a manager submits an update for a person via POST request, THE System SHALL update the person's name and return the updated data
+4. WHEN a manager submits a delete request for a person via POST request, THE System SHALL remove the person record from the system
+5. IF a person name is empty or whitespace only, THEN THE System SHALL reject the request and return a validation error
+
+### Requirement 2: Item Management
+
+**User Story:** As a manager, I want to manage item information with prices, so that I can calculate work value.
+
+#### Acceptance Criteria
+
+1. WHEN a manager submits item data (name, unit_price) via POST request, THE System SHALL create a new item record and return the created item data
+2. WHEN a manager requests the item list via GET request, THE System SHALL return all item records with their IDs, names, and unit prices
+3. WHEN a manager submits an update for an item via POST request, THE System SHALL update the item's name and/or unit_price and return the updated data
+4. WHEN a manager submits a delete request for an item via POST request, THE System SHALL remove the item record from the system
+5. IF an item name is empty or unit_price is not a positive number, THEN THE System SHALL reject the request and return a validation error
+
+### Requirement 3: Work Record Management
+
+**User Story:** As a manager, I want to record daily work output for each person, so that I can track productivity.
+
+#### Acceptance Criteria
+
+1. WHEN a manager submits work record data (person_id, item_id, date, quantity) via POST request, THE System SHALL create a new work record and return the created record with calculated total_price
+2. WHEN a manager requests work records via GET request with optional filters (person_id, date_range), THE System SHALL return matching work records with person name, item name, unit_price, quantity, and total_price
+3. WHEN a manager submits an update for a work record via POST request, THE System SHALL update the record and recalculate total_price
+4. WHEN a manager submits a delete request for a work record via POST request, THE System SHALL remove the work record from the system
+5. IF the quantity is not a positive integer, THEN THE System SHALL reject the request and return a validation error
+6. IF the referenced person_id or item_id does not exist, THEN THE System SHALL reject the request and return a reference error
+
+### Requirement 4: Daily Summary
+
+**User Story:** As a manager, I want to view daily summaries, so that I can see each person's daily output.
+
+#### Acceptance Criteria
+
+1. WHEN a manager requests daily summary via GET request with a date, THE System SHALL return aggregated work data grouped by person showing total items and total value for that day
+2. WHEN a manager requests daily summary for a person via GET request, THE System SHALL return that person's work records for the specified date with item breakdown
+
+### Requirement 5: Excel Export - Monthly
+
+**User Story:** As a manager, I want to export monthly reports to Excel, so that I can share and archive work statistics.
+
+#### Acceptance Criteria
+
+1. WHEN a manager requests monthly export via GET request with year and month parameters, THE System SHALL generate an Excel file containing all work records for that month
+2. THE Export_Service SHALL format the Excel with columns: Person, Date, Item, Unit_Price, Quantity, Total_Price
+3. THE Export_Service SHALL include a summary sheet showing each person's monthly total earnings
+4. THE Export_Service SHALL return the Excel file as a downloadable attachment
+
+### Requirement 6: Excel Export - Yearly
+
+**User Story:** As a manager, I want to export yearly reports to Excel, so that I can review annual performance.
+
+#### Acceptance Criteria
+
+1. WHEN a manager requests yearly export via GET request with year parameter, THE System SHALL generate an Excel file containing all work records for that year
+2. THE Export_Service SHALL format the Excel with columns: Person, Date, Item, Unit_Price, Quantity, Total_Price
+3. THE Export_Service SHALL include a summary sheet showing each person's yearly total earnings broken down by month
+4. THE Export_Service SHALL return the Excel file as a downloadable attachment
+
+### Requirement 7: Data Persistence
+
+**User Story:** As a system administrator, I want data to be persisted reliably, so that work records are not lost.
+
+#### Acceptance Criteria
+
+1. THE System SHALL store all data in PostgreSQL for production environment
+2. THE System SHALL support SQLite for testing environment
+3. WHEN data is saved, THE System SHALL persist it immediately to the database
+4. THE System SHALL maintain referential integrity between Person, Item, and Work_Record entities
+
+### Requirement 8: API Design
+
+**User Story:** As a developer, I want RESTful APIs using only GET and POST methods, so that the system is easy to integrate.
+
+#### Acceptance Criteria
+
+1. THE API_Server SHALL use GET method for all read operations (list, retrieve, export)
+2. THE API_Server SHALL use POST method for all write operations (create, update, delete)
+3. THE API_Server SHALL return JSON responses with consistent structure for all endpoints
+4. THE API_Server SHALL return appropriate HTTP status codes (200 for success, 400 for validation errors, 404 for not found, 500 for server errors)
+
+### Requirement 9: Frontend Interface
+
+**User Story:** As a manager, I want a user-friendly web interface, so that I can easily manage work statistics.
+
+#### Acceptance Criteria
+
+1. THE Frontend SHALL provide pages for Person management (list, add, edit, delete)
+2. THE Frontend SHALL provide pages for Item management (list, add, edit, delete)
+3. THE Frontend SHALL provide pages for Work Record management (list, add, edit, delete, filter by person/date)
+4. THE Frontend SHALL provide a dashboard showing daily summaries
+5. THE Frontend SHALL provide export buttons for monthly and yearly Excel reports
+6. THE Frontend SHALL display validation errors returned from the API

+ 177 - 0
.kiro/specs/work-statistics-system/tasks.md

@@ -0,0 +1,177 @@
+# Implementation Plan: Work Statistics System
+
+## Overview
+
+本实现计划将工作统计系统分解为可执行的编码任务,按照后端基础设施 → 数据模型 → API接口 → 前端界面的顺序逐步实现。使用Python Flask作为后端,React + Ant Design作为前端,SQLite用于测试。
+
+## Tasks
+
+- [x] 1. Set up backend project structure
+  - Create Flask application factory with configuration
+  - Set up SQLAlchemy with SQLite for testing
+  - Configure Flask-RESTX for Swagger documentation
+  - Create requirements.txt with dependencies
+  - _Requirements: 7.2, 8.1, 8.2_
+
+- [x] 2. Implement Person module
+  - [x] 2.1 Create Person model
+    - Define Person SQLAlchemy model with id, name, created_at, updated_at
+    - _Requirements: 1.1, 1.2_
+  - [x] 2.2 Implement Person service
+    - Create PersonService with create, get_all, get_by_id, update, delete methods
+    - Add name validation (reject empty/whitespace)
+    - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
+  - [x] 2.3 Create Person API routes
+    - GET /api/persons - list all
+    - GET /api/persons/<id> - get by id
+    - POST /api/persons/create - create
+    - POST /api/persons/update - update
+    - POST /api/persons/delete - delete
+    - Add Swagger documentation
+    - _Requirements: 1.1, 1.2, 1.3, 1.4, 8.1, 8.2_
+  - [ ]* 2.4 Write property tests for Person CRUD
+    - **Property 1: Person CRUD Round-Trip**
+    - **Property 2: Person Name Validation**
+    - **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5**
+
+- [x] 3. Implement Item module
+  - [x] 3.1 Create Item model
+    - Define Item SQLAlchemy model with id, name, unit_price (float), created_at, updated_at
+    - _Requirements: 2.1, 2.2_
+  - [x] 3.2 Implement Item service
+    - Create ItemService with create, get_all, get_by_id, update, delete methods
+    - Add validation (reject empty name, non-positive price)
+    - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
+  - [x] 3.3 Create Item API routes
+    - GET /api/items - list all
+    - GET /api/items/<id> - get by id
+    - POST /api/items/create - create
+    - POST /api/items/update - update
+    - POST /api/items/delete - delete
+    - Add Swagger documentation
+    - _Requirements: 2.1, 2.2, 2.3, 2.4, 8.1, 8.2_
+  - [ ]* 3.4 Write property tests for Item CRUD
+    - **Property 3: Item CRUD Round-Trip**
+    - **Property 4: Item Validation**
+    - **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5**
+
+- [x] 4. Checkpoint - Backend Person and Item modules
+  - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 5. Implement WorkRecord module
+  - [x] 5.1 Create WorkRecord model
+    - Define WorkRecord SQLAlchemy model with id, person_id, item_id, work_date, quantity, created_at, updated_at
+    - Add total_price computed property
+    - Set up foreign key relationships
+    - _Requirements: 3.1, 3.2_
+  - [x] 5.2 Implement WorkRecord service
+    - Create WorkRecordService with create, get_all, get_by_id, update, delete methods
+    - Add filtering by person_id and date_range
+    - Add validation (positive quantity, valid references)
+    - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_
+  - [x] 5.3 Create WorkRecord API routes
+    - GET /api/work-records - list with filters
+    - GET /api/work-records/<id> - get by id
+    - POST /api/work-records/create - create
+    - POST /api/work-records/update - update
+    - POST /api/work-records/delete - delete
+    - GET /api/work-records/daily-summary - daily summary
+    - Add Swagger documentation
+    - _Requirements: 3.1, 3.2, 3.3, 3.4, 4.1, 4.2, 8.1, 8.2_
+  - [ ]* 5.4 Write property tests for WorkRecord
+    - **Property 5: Work Record Total Price Calculation**
+    - **Property 6: Work Record Filter Consistency**
+    - **Property 7: Work Record Quantity Validation**
+    - **Property 8: Work Record Reference Validation**
+    - **Property 9: Daily Summary Consistency**
+    - **Validates: Requirements 3.1, 3.2, 3.3, 3.5, 3.6, 4.1, 4.2**
+
+- [x] 6. Implement Excel Export module
+  - [x] 6.1 Implement Export service
+    - Create ExportService with export_monthly and export_yearly methods
+    - Generate Excel with detail sheet (Person, Date, Item, Unit_Price, Quantity, Total_Price)
+    - Generate summary sheet with totals
+    - _Requirements: 5.1, 5.2, 5.3, 6.1, 6.2, 6.3_
+  - [x] 6.2 Create Export API routes
+    - GET /api/export/monthly?year=&month= - monthly export
+    - GET /api/export/yearly?year= - yearly export
+    - Return Excel as downloadable attachment
+    - Add Swagger documentation
+    - _Requirements: 5.1, 5.4, 6.1, 6.4, 8.1_
+  - [ ]* 6.3 Write property tests for Export
+    - **Property 10: Monthly Export Completeness**
+    - **Property 11: Yearly Export Completeness**
+    - **Validates: Requirements 5.1, 5.3, 6.1, 6.3**
+
+- [x] 7. Checkpoint - Backend complete
+  - Ensure all backend tests pass, ask the user if questions arise.
+
+- [x] 8. Set up frontend project structure
+  - Initialize React project with Vite
+  - Install Ant Design, Axios, React Router
+  - Create basic Layout component with sidebar navigation
+  - Set up API service module
+  - _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_
+
+- [x] 9. Implement Person management UI
+  - [x] 9.1 Create PersonList component
+    - Display persons in Ant Design Table
+    - Add edit and delete buttons
+    - _Requirements: 9.1_
+  - [x] 9.2 Create PersonForm component
+    - Modal form for add/edit person
+    - Form validation for name
+    - _Requirements: 9.1, 9.6_
+
+- [x] 10. Implement Item management UI
+  - [x] 10.1 Create ItemList component
+    - Display items in Ant Design Table with name and unit_price
+    - Add edit and delete buttons
+    - _Requirements: 9.2_
+  - [x] 10.2 Create ItemForm component
+    - Modal form for add/edit item
+    - Form validation for name and unit_price (positive number with decimals)
+    - _Requirements: 9.2, 9.6_
+
+- [x] 11. Implement WorkRecord management UI
+  - [x] 11.1 Create WorkRecordList component
+    - Display work records in Ant Design Table
+    - Add DatePicker for date filter
+    - Add Select for person filter
+    - Show calculated total_price
+    - _Requirements: 9.3_
+  - [x] 11.2 Create WorkRecordForm component
+    - Modal form for add/edit work record
+    - Select components for person and item
+    - DatePicker for work_date
+    - InputNumber for quantity
+    - _Requirements: 9.3, 9.6_
+
+- [x] 12. Implement Dashboard and Export UI
+  - [x] 12.1 Create Dashboard component
+    - Display daily summary statistics using Card and Statistic components
+    - Quick links to add work record
+    - _Requirements: 9.4_
+  - [x] 12.2 Create Export component
+    - DatePicker for month/year selection
+    - Export buttons for monthly and yearly reports
+    - _Requirements: 9.5_
+
+- [x] 13. Wire frontend components together
+  - Set up React Router with all pages
+  - Connect all components to API
+  - Add loading states and error handling
+  - _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6_
+
+- [x] 14. Final checkpoint - Full system integration
+  - Ensure all tests pass, ask the user if questions arise.
+  - Verify Swagger documentation is accessible
+  - Test end-to-end workflow
+
+## Notes
+
+- Tasks marked with `*` are optional property-based tests and can be skipped for faster MVP
+- Each task references specific requirements for traceability
+- Checkpoints ensure incremental validation
+- Backend uses SQLite for testing, PostgreSQL configuration is ready for production
+- Frontend uses Ant Design for consistent, professional UI

+ 115 - 0
README.md

@@ -0,0 +1,115 @@
+# 工作统计系统
+
+一个用于管理人员工作记录和统计的全栈应用,支持工作记录管理、数据导入导出、月度报表等功能。
+
+## 功能特性
+
+- 人员管理:添加、编辑、删除人员信息
+- 物品管理:管理物品及单价
+- 工作记录:记录每日工作数据,支持按人员、日期筛选
+- 数据导入:通过 Excel 模板批量导入工作记录
+- 数据导出:导出工作记录为 Excel 文件
+- 仪表盘:日统计、月度报告、业绩排名
+- 管理员管理:多管理员支持
+- 移动端适配:响应式设计,支持手机访问
+
+## 技术栈
+
+**后端**
+- Python 3.10+
+- Flask + Flask-RESTX
+- SQLAlchemy (SQLite/PostgreSQL)
+- JWT 认证
+
+**前端**
+- React 18
+- Ant Design 5
+- Vite
+
+## 快速开始
+
+### 后端
+
+```bash
+cd backend
+python -m venv venv
+source venv/bin/activate  # Windows: venv\Scripts\activate
+pip install -r requirements.txt
+python run.py
+```
+
+后端运行在 http://localhost:5000
+
+默认管理员账号:`admin` / `admin123`
+
+### 前端
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+前端运行在 http://localhost:5173
+
+## 生产部署
+
+### 环境变量
+
+```bash
+cp backend/.env.example backend/.env
+# 编辑 .env 设置以下变量:
+# - SECRET_KEY
+# - JWT_SECRET_KEY
+# - DATABASE_URL (PostgreSQL)
+```
+
+### 初始化数据库
+
+```bash
+cd backend
+source venv/bin/activate
+export $(cat .env | xargs)
+python init_db.py
+```
+
+### 运行
+
+```bash
+# 后端 (Gunicorn)
+gunicorn -w 4 -b 0.0.0.0:5000 wsgi:app
+
+# 前端构建
+cd frontend
+npm run build
+# 将 dist/ 目录部署到 Web 服务器
+```
+
+## API 文档
+
+启动后端后访问 http://localhost:5000/api/docs 查看 Swagger 文档。
+
+## 目录结构
+
+```
+├── backend/
+│   ├── app/
+│   │   ├── models/      # 数据模型
+│   │   ├── routes/      # API 路由
+│   │   ├── services/    # 业务逻辑
+│   │   └── utils/       # 工具函数
+│   ├── tests/           # 测试文件
+│   ├── requirements.txt
+│   └── run.py
+├── frontend/
+│   ├── src/
+│   │   ├── components/  # React 组件
+│   │   ├── contexts/    # Context
+│   │   └── services/    # API 服务
+│   └── package.json
+└── README.md
+```
+
+## License
+
+MIT

+ 13 - 0
backend/.env.example

@@ -0,0 +1,13 @@
+# Flask Configuration
+FLASK_CONFIG=production
+
+# Security Keys (MUST change in production!)
+SECRET_KEY=your-super-secret-key-change-this
+JWT_SECRET_KEY=your-jwt-secret-key-change-this
+
+# PostgreSQL Database URL
+# Format: postgresql://username:password@host:port/database
+DATABASE_URL=postgresql://postgres:password@localhost:5432/work_statistics
+
+# Optional: CORS origins (comma-separated)
+# CORS_ORIGINS=https://yourdomain.com

+ 56 - 0
backend/app/__init__.py

@@ -0,0 +1,56 @@
+"""Flask application factory for Work Statistics System."""
+from flask import Flask
+from flask_sqlalchemy import SQLAlchemy
+from flask_cors import CORS
+
+db = SQLAlchemy()
+
+
+def create_app(config_name='default'):
+    """Create and configure the Flask application.
+    
+    Args:
+        config_name: Configuration name ('default', 'testing', 'production')
+    
+    Returns:
+        Configured Flask application instance
+    """
+    app = Flask(__name__)
+    
+    # Load configuration
+    from app.config import config
+    app.config.from_object(config[config_name])
+    
+    # Initialize extensions
+    db.init_app(app)
+    CORS(app)
+    
+    # Register API routes
+    from app.routes import register_routes
+    register_routes(app)
+    
+    # Create database tables and initialize default admin
+    with app.app_context():
+        db.create_all()
+        _init_default_admin()
+    
+    return app
+
+
+def _init_default_admin():
+    """Initialize default admin account if none exists.
+    
+    This ensures there is always at least one admin account
+    available for login when the application starts.
+    """
+    from flask import current_app
+    from app.services.admin_service import AdminService
+    
+    # Skip default admin creation in testing mode
+    if current_app.config.get('TESTING', False):
+        return
+    
+    created, message = AdminService.create_default_admin()
+    if created:
+        print(f"[Init] {message}")
+    # Silently skip if admins already exist

+ 47 - 0
backend/app/config.py

@@ -0,0 +1,47 @@
+"""Configuration settings for the Flask application."""
+import os
+
+basedir = os.path.abspath(os.path.dirname(__file__))
+
+
+class Config:
+    """Base configuration."""
+    SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key')
+    SQLALCHEMY_TRACK_MODIFICATIONS = False
+    
+    # API settings
+    RESTX_MASK_SWAGGER = False
+    RESTX_ERROR_404_HELP = False
+    
+    # JWT settings
+    JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-dev-secret-key')
+    JWT_EXPIRATION_DAYS = 7
+    JWT_ALGORITHM = 'HS256'
+
+
+class DevelopmentConfig(Config):
+    """Development configuration."""
+    DEBUG = True
+    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
+        'sqlite:///' + os.path.join(basedir, '..', 'dev.db')
+
+
+class TestingConfig(Config):
+    """Testing configuration with SQLite."""
+    TESTING = True
+    SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
+
+
+class ProductionConfig(Config):
+    """Production configuration with PostgreSQL."""
+    DEBUG = False
+    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
+        'postgresql://localhost/work_statistics'
+
+
+config = {
+    'default': DevelopmentConfig,
+    'development': DevelopmentConfig,
+    'testing': TestingConfig,
+    'production': ProductionConfig
+}

+ 7 - 0
backend/app/models/__init__.py

@@ -0,0 +1,7 @@
+"""Database models for Work Statistics System."""
+from app.models.person import Person
+from app.models.item import Item
+from app.models.work_record import WorkRecord
+from app.models.admin import Admin
+
+__all__ = ['Person', 'Item', 'WorkRecord', 'Admin']

+ 44 - 0
backend/app/models/admin.py

@@ -0,0 +1,44 @@
+"""Admin model for Work Statistics System."""
+from datetime import datetime, timezone
+from app import db
+
+
+class Admin(db.Model):
+    """Admin model representing an administrator in the system.
+    
+    Attributes:
+        id: Primary key, auto-incremented
+        username: Admin's username (required, unique, non-empty)
+        password_hash: Bcrypt hashed password (required)
+        created_at: Timestamp when the record was created
+        updated_at: Timestamp when the record was last updated
+    """
+    __tablename__ = 'admins'
+    
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    username = db.Column(db.String(100), nullable=False, unique=True, index=True)
+    password_hash = db.Column(db.String(255), nullable=False)
+    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
+    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
+    
+    def to_dict(self, include_password_hash=False):
+        """Convert model to dictionary for JSON serialization.
+        
+        Args:
+            include_password_hash: Whether to include password_hash in output (default False)
+        
+        Returns:
+            Dictionary representation of the admin
+        """
+        result = {
+            'id': self.id,
+            'username': self.username,
+            'created_at': self.created_at.isoformat() if self.created_at else None,
+            'updated_at': self.updated_at.isoformat() if self.updated_at else None
+        }
+        if include_password_hash:
+            result['password_hash'] = self.password_hash
+        return result
+    
+    def __repr__(self):
+        return f'<Admin {self.id}: {self.username}>'

+ 39 - 0
backend/app/models/item.py

@@ -0,0 +1,39 @@
+"""Item model for Work Statistics System."""
+from datetime import datetime, timezone
+from app import db
+
+
+class Item(db.Model):
+    """Item model representing a work item with unit price.
+    
+    Attributes:
+        id: Primary key, auto-incremented
+        name: Item's name (required, non-empty)
+        unit_price: Price per unit (required, positive float)
+        created_at: Timestamp when the record was created
+        updated_at: Timestamp when the record was last updated
+    """
+    __tablename__ = 'items'
+    
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    name = db.Column(db.String(100), nullable=False, index=True)
+    unit_price = db.Column(db.Float, nullable=False)
+    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
+    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
+    
+    def to_dict(self):
+        """Convert model to dictionary for JSON serialization.
+        
+        Returns:
+            Dictionary representation of the item
+        """
+        return {
+            'id': self.id,
+            'name': self.name,
+            'unit_price': self.unit_price,
+            'created_at': self.created_at.isoformat() if self.created_at else None,
+            'updated_at': self.updated_at.isoformat() if self.updated_at else None
+        }
+    
+    def __repr__(self):
+        return f'<Item {self.id}: {self.name} @ {self.unit_price}>'

+ 36 - 0
backend/app/models/person.py

@@ -0,0 +1,36 @@
+"""Person model for Work Statistics System."""
+from datetime import datetime, timezone
+from app import db
+
+
+class Person(db.Model):
+    """Person model representing a worker in the system.
+    
+    Attributes:
+        id: Primary key, auto-incremented
+        name: Person's name (required, non-empty)
+        created_at: Timestamp when the record was created
+        updated_at: Timestamp when the record was last updated
+    """
+    __tablename__ = 'persons'
+    
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    name = db.Column(db.String(100), nullable=False, index=True)
+    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
+    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
+    
+    def to_dict(self):
+        """Convert model to dictionary for JSON serialization.
+        
+        Returns:
+            Dictionary representation of the person
+        """
+        return {
+            'id': self.id,
+            'name': self.name,
+            'created_at': self.created_at.isoformat() if self.created_at else None,
+            'updated_at': self.updated_at.isoformat() if self.updated_at else None
+        }
+    
+    def __repr__(self):
+        return f'<Person {self.id}: {self.name}>'

+ 64 - 0
backend/app/models/work_record.py

@@ -0,0 +1,64 @@
+"""WorkRecord model for Work Statistics System."""
+from datetime import datetime, timezone, date
+from app import db
+
+
+class WorkRecord(db.Model):
+    """WorkRecord model representing a work entry for a person on a specific date.
+    
+    Attributes:
+        id: Primary key, auto-incremented
+        person_id: Foreign key to Person
+        item_id: Foreign key to Item
+        work_date: Date of the work
+        quantity: Number of items produced (positive integer)
+        created_at: Timestamp when the record was created
+        updated_at: Timestamp when the record was last updated
+    """
+    __tablename__ = 'work_records'
+    
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    person_id = db.Column(db.Integer, db.ForeignKey('persons.id'), nullable=False)
+    item_id = db.Column(db.Integer, db.ForeignKey('items.id'), nullable=False)
+    work_date = db.Column(db.Date, nullable=False)
+    quantity = db.Column(db.Integer, nullable=False)
+    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
+    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
+    
+    # Relationships
+    person = db.relationship('Person', backref=db.backref('work_records', lazy='dynamic'))
+    item = db.relationship('Item', backref=db.backref('work_records', lazy='dynamic'))
+    
+    @property
+    def total_price(self):
+        """Calculate total price as unit_price * quantity.
+        
+        Returns:
+            Float representing the total price
+        """
+        if self.item:
+            return self.item.unit_price * self.quantity
+        return 0.0
+    
+    def to_dict(self):
+        """Convert model to dictionary for JSON serialization.
+        
+        Returns:
+            Dictionary representation of the work record
+        """
+        return {
+            'id': self.id,
+            'person_id': self.person_id,
+            'person_name': self.person.name if self.person else None,
+            'item_id': self.item_id,
+            'item_name': self.item.name if self.item else None,
+            'unit_price': self.item.unit_price if self.item else None,
+            'work_date': self.work_date.isoformat() if self.work_date else None,
+            'quantity': self.quantity,
+            'total_price': self.total_price,
+            'created_at': self.created_at.isoformat() if self.created_at else None,
+            'updated_at': self.updated_at.isoformat() if self.updated_at else None
+        }
+    
+    def __repr__(self):
+        return f'<WorkRecord {self.id}: Person {self.person_id}, Item {self.item_id}, Qty {self.quantity}>'

+ 35 - 0
backend/app/routes/__init__.py

@@ -0,0 +1,35 @@
+"""API routes registration with Flask-RESTX for Swagger documentation."""
+from flask_restx import Api
+
+
+def register_routes(app):
+    """Register all API routes with Swagger documentation.
+    
+    Args:
+        app: Flask application instance
+    """
+    api = Api(
+        app,
+        title='Work Statistics API',
+        version='1.0',
+        description='工作统计系统 API 文档 - 管理人员、物品和工作记录',
+        doc='/api/docs',
+        prefix=''
+    )
+    
+    # Import and register namespaces
+    from app.routes.auth import auth_ns
+    from app.routes.admin import admin_ns
+    from app.routes.person import person_ns
+    from app.routes.item import item_ns
+    from app.routes.work_record import work_record_ns
+    from app.routes.export import export_ns
+    from app.routes.import_routes import import_ns
+    
+    api.add_namespace(auth_ns, path='/api/auth')
+    api.add_namespace(admin_ns, path='/api/admins')
+    api.add_namespace(person_ns, path='/api/persons')
+    api.add_namespace(item_ns, path='/api/items')
+    api.add_namespace(work_record_ns, path='/api/work-records')
+    api.add_namespace(export_ns, path='/api/export')
+    api.add_namespace(import_ns, path='/api/import')

+ 226 - 0
backend/app/routes/admin.py

@@ -0,0 +1,226 @@
+"""Admin management API routes."""
+from flask_restx import Namespace, Resource, fields
+from flask import request
+from app.services.admin_service import AdminService
+from app.utils.auth_decorator import require_auth
+
+admin_ns = Namespace('admins', description='管理员管理接口')
+
+# API models for Swagger documentation
+admin_input = admin_ns.model('AdminInput', {
+    'username': fields.String(required=True, description='用户名'),
+    'password': fields.String(required=True, description='密码(至少6个字符)')
+})
+
+admin_update_input = admin_ns.model('AdminUpdateInput', {
+    'id': fields.Integer(required=True, description='管理员ID'),
+    'username': fields.String(description='新用户名(可选)'),
+    'password': fields.String(description='新密码(可选,至少6个字符)')
+})
+
+admin_delete_input = admin_ns.model('AdminDeleteInput', {
+    'id': fields.Integer(required=True, description='管理员ID')
+})
+
+admin_info = admin_ns.model('AdminInfo', {
+    'id': fields.Integer(description='管理员ID'),
+    'username': fields.String(description='用户名'),
+    'created_at': fields.String(description='创建时间'),
+    'updated_at': fields.String(description='更新时间')
+})
+
+admin_response = admin_ns.model('AdminResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'data': fields.Nested(admin_info, description='管理员信息')
+})
+
+admin_list_response = admin_ns.model('AdminListResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'data': fields.List(fields.Nested(admin_info), description='管理员列表')
+})
+
+delete_response = admin_ns.model('DeleteResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'message': fields.String(description='操作结果消息')
+})
+
+error_response = admin_ns.model('AdminErrorResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'error': fields.String(description='错误信息'),
+    'code': fields.String(description='错误代码')
+})
+
+
+@admin_ns.route('')
+class AdminList(Resource):
+    """Resource for listing admins."""
+    
+    @admin_ns.doc('list_admins')
+    @admin_ns.response(200, 'Success', admin_list_response)
+    @admin_ns.response(401, 'Unauthorized', error_response)
+    @require_auth
+    def get(self):
+        """获取所有管理员列表(不包含密码)"""
+        admins = AdminService.get_all()
+        return {
+            'success': True,
+            'data': admins
+        }, 200
+
+
+@admin_ns.route('/<int:admin_id>')
+class AdminDetail(Resource):
+    """Resource for getting a single admin."""
+    
+    @admin_ns.doc('get_admin')
+    @admin_ns.response(200, 'Success', admin_response)
+    @admin_ns.response(401, 'Unauthorized', error_response)
+    @admin_ns.response(404, 'Not found', error_response)
+    @require_auth
+    def get(self, admin_id):
+        """根据ID获取管理员信息"""
+        admin, error = AdminService.get_by_id(admin_id)
+        
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'NOT_FOUND'
+            }, 404
+        
+        return {
+            'success': True,
+            'data': admin
+        }, 200
+
+
+@admin_ns.route('/create')
+class AdminCreate(Resource):
+    """Resource for creating admins."""
+    
+    @admin_ns.doc('create_admin')
+    @admin_ns.expect(admin_input)
+    @admin_ns.response(201, 'Created', admin_response)
+    @admin_ns.response(400, 'Validation error', error_response)
+    @admin_ns.response(401, 'Unauthorized', error_response)
+    @require_auth
+    def post(self):
+        """创建新管理员"""
+        data = admin_ns.payload or {}
+        username = data.get('username', '')
+        password = data.get('password', '')
+        
+        admin, error = AdminService.create(username, password)
+        
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        return {
+            'success': True,
+            'data': admin
+        }, 201
+
+
+@admin_ns.route('/update')
+class AdminUpdate(Resource):
+    """Resource for updating admins."""
+    
+    @admin_ns.doc('update_admin')
+    @admin_ns.expect(admin_update_input)
+    @admin_ns.response(200, 'Success', admin_response)
+    @admin_ns.response(400, 'Validation error', error_response)
+    @admin_ns.response(401, 'Unauthorized', error_response)
+    @admin_ns.response(403, 'Forbidden', error_response)
+    @admin_ns.response(404, 'Not found', error_response)
+    @require_auth
+    def post(self):
+        """更新管理员信息"""
+        data = admin_ns.payload or {}
+        admin_id = data.get('id')
+        username = data.get('username')
+        password = data.get('password')
+        
+        if not admin_id:
+            return {
+                'success': False,
+                'error': 'Admin ID is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        # Protect primary admin (ID=1) from being modified by others
+        current_admin_id = request.current_admin.get('admin_id')
+        if admin_id == 1 and current_admin_id != 1:
+            return {
+                'success': False,
+                'error': '无权修改主管理员信息',
+                'code': 'FORBIDDEN'
+            }, 403
+        
+        admin, error = AdminService.update(admin_id, username=username, password=password)
+        
+        if error:
+            if 'not found' in error.lower() or '未找到' in error:
+                return {
+                    'success': False,
+                    'error': error,
+                    'code': 'NOT_FOUND'
+                }, 404
+            return {
+                'success': False,
+                'error': error,
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        return {
+            'success': True,
+            'data': admin
+        }, 200
+
+
+@admin_ns.route('/delete')
+class AdminDelete(Resource):
+    """Resource for deleting admins."""
+    
+    @admin_ns.doc('delete_admin')
+    @admin_ns.expect(admin_delete_input)
+    @admin_ns.response(200, 'Success', delete_response)
+    @admin_ns.response(400, 'Validation error', error_response)
+    @admin_ns.response(401, 'Unauthorized', error_response)
+    @admin_ns.response(404, 'Not found', error_response)
+    @require_auth
+    def post(self):
+        """删除管理员"""
+        data = admin_ns.payload or {}
+        admin_id = data.get('id')
+        
+        if not admin_id:
+            return {
+                'success': False,
+                'error': 'Admin ID is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        current_admin_id = request.current_admin.get('admin_id')
+        success, error = AdminService.delete(admin_id, current_admin_id=current_admin_id)
+        
+        if error:
+            if 'not found' in error.lower() or '未找到' in error:
+                return {
+                    'success': False,
+                    'error': error,
+                    'code': 'NOT_FOUND'
+                }, 404
+            return {
+                'success': False,
+                'error': error,
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        return {
+            'success': True,
+            'message': 'Admin deleted successfully'
+        }, 200

+ 120 - 0
backend/app/routes/auth.py

@@ -0,0 +1,120 @@
+"""Authentication API routes."""
+from flask_restx import Namespace, Resource, fields
+from flask import request
+from flask_bcrypt import Bcrypt
+from app.models.admin import Admin
+from app.services.auth_service import AuthService
+from app.utils.auth_decorator import require_auth
+
+auth_ns = Namespace('auth', description='认证接口')
+
+# Initialize bcrypt
+bcrypt = Bcrypt()
+
+# API models for Swagger documentation
+login_input = auth_ns.model('LoginInput', {
+    'username': fields.String(required=True, description='用户名'),
+    'password': fields.String(required=True, description='密码')
+})
+
+admin_info = auth_ns.model('AdminInfo', {
+    'id': fields.Integer(description='管理员ID'),
+    'username': fields.String(description='用户名')
+})
+
+login_data = auth_ns.model('LoginData', {
+    'token': fields.String(description='JWT令牌'),
+    'admin': fields.Nested(admin_info, description='管理员信息')
+})
+
+login_response = auth_ns.model('LoginResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'data': fields.Nested(login_data, description='登录数据')
+})
+
+me_response = auth_ns.model('MeResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'data': fields.Nested(admin_info, description='当前管理员信息')
+})
+
+error_response = auth_ns.model('AuthErrorResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'error': fields.String(description='错误信息'),
+    'code': fields.String(description='错误代码')
+})
+
+
+@auth_ns.route('/login')
+class Login(Resource):
+    """Resource for user login."""
+    
+    @auth_ns.doc('login')
+    @auth_ns.expect(login_input)
+    @auth_ns.response(200, 'Success', login_response)
+    @auth_ns.response(401, 'Authentication failed', error_response)
+    def post(self):
+        """用户登录,返回JWT令牌"""
+        data = auth_ns.payload or {}
+        username = data.get('username', '').strip()
+        password = data.get('password', '')
+        
+        # Validate input
+        if not username or not password:
+            return {
+                'success': False,
+                'error': 'Username and password are required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        # Find admin by username
+        admin = Admin.query.filter_by(username=username).first()
+        
+        if not admin:
+            return {
+                'success': False,
+                'error': 'Invalid username or password',
+                'code': 'AUTH_ERROR'
+            }, 401
+        
+        # Verify password
+        if not bcrypt.check_password_hash(admin.password_hash, password):
+            return {
+                'success': False,
+                'error': 'Invalid username or password',
+                'code': 'AUTH_ERROR'
+            }, 401
+        
+        # Generate JWT token
+        token = AuthService.generate_token(admin)
+        
+        return {
+            'success': True,
+            'data': {
+                'token': token,
+                'admin': {
+                    'id': admin.id,
+                    'username': admin.username
+                }
+            }
+        }, 200
+
+
+@auth_ns.route('/me')
+class Me(Resource):
+    """Resource for getting current admin info."""
+    
+    @auth_ns.doc('get_current_admin')
+    @auth_ns.response(200, 'Success', me_response)
+    @auth_ns.response(401, 'Unauthorized', error_response)
+    @require_auth
+    def get(self):
+        """获取当前登录管理员信息"""
+        current_admin = request.current_admin
+        
+        return {
+            'success': True,
+            'data': {
+                'id': current_admin.get('admin_id'),
+                'username': current_admin.get('username')
+            }
+        }, 200

+ 131 - 0
backend/app/routes/export.py

@@ -0,0 +1,131 @@
+"""Export API routes for Excel report generation."""
+from flask import request, send_file
+from flask_restx import Namespace, Resource, fields
+from app.services.export_service import ExportService
+from app.utils.auth_decorator import require_auth
+
+export_ns = Namespace('export', description='Excel导出接口')
+
+# Response models for Swagger documentation
+error_response = export_ns.model('ExportErrorResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'error': fields.String(description='错误信息'),
+    'code': fields.String(description='错误代码')
+})
+
+
+@export_ns.route('/monthly')
+class MonthlyExport(Resource):
+    """Resource for monthly Excel export."""
+    
+    @export_ns.doc('export_monthly')
+    @export_ns.param('year', '年份 (例如: 2024)', required=True, type=int)
+    @export_ns.param('month', '月份 (1-12)', required=True, type=int)
+    @export_ns.response(200, 'Excel文件下载')
+    @export_ns.response(400, '参数错误', error_response)
+    @export_ns.response(500, '服务器错误', error_response)
+    @export_ns.produces(['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])
+    @require_auth
+    def get(self):
+        """导出月度工作报表Excel
+        
+        生成包含以下内容的Excel文件:
+        - 明细表: 人员、日期、物品、单价、数量、总价
+        - 月度汇总: 每人总金额及合计
+        """
+        # Get and validate parameters
+        year = request.args.get('year', type=int)
+        month = request.args.get('month', type=int)
+        
+        if year is None:
+            return {
+                'success': False,
+                'error': 'Year parameter is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        if month is None:
+            return {
+                'success': False,
+                'error': 'Month parameter is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        try:
+            excel_file, error = ExportService.export_monthly(year, month)
+            
+            if error:
+                return {
+                    'success': False,
+                    'error': error,
+                    'code': 'VALIDATION_ERROR'
+                }, 400
+            
+            filename = f'work_report_{year}_{month:02d}.xlsx'
+            
+            return send_file(
+                excel_file,
+                mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                as_attachment=True,
+                download_name=filename
+            )
+        except Exception as e:
+            return {
+                'success': False,
+                'error': f'Failed to generate Excel: {str(e)}',
+                'code': 'SERVER_ERROR'
+            }, 500
+
+
+@export_ns.route('/yearly')
+class YearlyExport(Resource):
+    """Resource for yearly Excel export."""
+    
+    @export_ns.doc('export_yearly')
+    @export_ns.param('year', '年份 (例如: 2024)', required=True, type=int)
+    @export_ns.response(200, 'Excel文件下载')
+    @export_ns.response(400, '参数错误', error_response)
+    @export_ns.response(500, '服务器错误', error_response)
+    @export_ns.produces(['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])
+    @require_auth
+    def get(self):
+        """导出年度工作报表Excel
+        
+        生成包含以下内容的Excel文件:
+        - 明细表: 人员、日期、物品、单价、数量、总价
+        - 年度汇总: 每人按月统计及年度合计
+        """
+        # Get and validate parameters
+        year = request.args.get('year', type=int)
+        
+        if year is None:
+            return {
+                'success': False,
+                'error': 'Year parameter is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        try:
+            excel_file, error = ExportService.export_yearly(year)
+            
+            if error:
+                return {
+                    'success': False,
+                    'error': error,
+                    'code': 'VALIDATION_ERROR'
+                }, 400
+            
+            filename = f'work_report_{year}.xlsx'
+            
+            return send_file(
+                excel_file,
+                mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                as_attachment=True,
+                download_name=filename
+            )
+        except Exception as e:
+            return {
+                'success': False,
+                'error': f'Failed to generate Excel: {str(e)}',
+                'code': 'SERVER_ERROR'
+            }, 500

+ 167 - 0
backend/app/routes/import_routes.py

@@ -0,0 +1,167 @@
+"""Import API routes for XLSX file import operations."""
+from flask import request, send_file
+from flask_restx import Namespace, Resource, fields
+from werkzeug.datastructures import FileStorage
+from app.services.import_service import ImportService
+from app.utils.auth_decorator import require_auth
+
+import_ns = Namespace('import', description='数据导入接口')
+
+# File upload parser
+upload_parser = import_ns.parser()
+upload_parser.add_argument(
+    'file',
+    location='files',
+    type=FileStorage,
+    required=True,
+    help='XLSX文件'
+)
+
+# Response models for Swagger documentation
+error_response = import_ns.model('ImportErrorResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'error': fields.String(description='错误信息'),
+    'errors': fields.List(fields.String, description='错误信息列表'),
+    'code': fields.String(description='错误代码')
+})
+
+success_response = import_ns.model('ImportSuccessResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'count': fields.Integer(description='成功导入的记录数')
+})
+
+
+@import_ns.route('/template')
+class ImportTemplate(Resource):
+    """Resource for downloading import template."""
+    
+    @import_ns.doc('download_template')
+    @import_ns.response(200, 'XLSX模板文件下载')
+    @import_ns.response(500, '服务器错误', error_response)
+    @import_ns.produces(['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])
+    @require_auth
+    def get(self):
+        """下载导入模板
+        
+        下载包含以下列的XLSX模板文件:
+        - 人员姓名
+        - 物品名称
+        - 工作日期 (格式: YYYY-MM-DD)
+        - 数量
+        
+        Requirements: 3.6
+        """
+        try:
+            template_file = ImportService.generate_template()
+            
+            return send_file(
+                template_file,
+                mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                as_attachment=True,
+                download_name='import_template.xlsx'
+            )
+        except Exception as e:
+            return {
+                'success': False,
+                'error': f'生成模板失败: {str(e)}',
+                'code': 'SERVER_ERROR'
+            }, 500
+
+
+@import_ns.route('/upload')
+class ImportUpload(Resource):
+    """Resource for uploading and importing XLSX files."""
+    
+    @import_ns.doc('upload_import')
+    @import_ns.expect(upload_parser)
+    @import_ns.response(200, '导入成功', success_response)
+    @import_ns.response(400, '验证错误', error_response)
+    @import_ns.response(500, '服务器错误', error_response)
+    @require_auth
+    def post(self):
+        """上传并导入XLSX文件
+        
+        上传XLSX文件并批量创建工作记录。
+        文件大小限制: 5MB
+        
+        验证规则:
+        - 文件必须是XLSX格式
+        - 必须包含所有必需列
+        - 人员姓名必须匹配系统中已存在的人员
+        - 物品名称必须匹配系统中已存在的物品
+        - 工作日期格式必须为 YYYY-MM-DD
+        - 数量必须为正数
+        
+        Requirements: 4.9, 4.11, 4.12
+        """
+        # Check if file is present
+        if 'file' not in request.files:
+            return {
+                'success': False,
+                'error': '请选择要上传的文件',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        file = request.files['file']
+        
+        # Check if file is selected
+        if file.filename == '':
+            return {
+                'success': False,
+                'error': '请选择要上传的文件',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        # Check file extension
+        if not file.filename.lower().endswith('.xlsx'):
+            return {
+                'success': False,
+                'error': '文件格式错误,请上传XLSX文件',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        # Read file content and check size
+        file_content = file.read()
+        
+        # Validate file size (5MB limit)
+        if len(file_content) > ImportService.MAX_FILE_SIZE:
+            return {
+                'success': False,
+                'error': '文件大小超过限制(最大5MB)',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        try:
+            # Parse and validate file content
+            valid_records, errors = ImportService.parse_and_validate(file_content)
+            
+            # If there are validation errors, return them (atomic operation - no records created)
+            if errors:
+                return {
+                    'success': False,
+                    'errors': errors,
+                    'code': 'VALIDATION_ERROR'
+                }, 400
+            
+            # If no valid records found
+            if not valid_records:
+                return {
+                    'success': False,
+                    'error': '文件中没有有效的数据行',
+                    'code': 'VALIDATION_ERROR'
+                }, 400
+            
+            # Import the validated records
+            count = ImportService.import_records(valid_records)
+            
+            return {
+                'success': True,
+                'count': count
+            }, 200
+            
+        except Exception as e:
+            return {
+                'success': False,
+                'error': f'导入失败: {str(e)}',
+                'code': 'SERVER_ERROR'
+            }, 500

+ 197 - 0
backend/app/routes/item.py

@@ -0,0 +1,197 @@
+"""Item API routes."""
+from flask_restx import Namespace, Resource, fields
+from app.services.item_service import ItemService
+from app.utils.auth_decorator import require_auth
+
+item_ns = Namespace('items', description='物品管理接口')
+
+# API models for Swagger documentation
+item_model = item_ns.model('Item', {
+    'id': fields.Integer(readonly=True, description='物品ID'),
+    'name': fields.String(required=True, description='物品名称'),
+    'unit_price': fields.Float(required=True, description='单价'),
+    'created_at': fields.String(readonly=True, description='创建时间'),
+    'updated_at': fields.String(readonly=True, description='更新时间')
+})
+
+item_input = item_ns.model('ItemInput', {
+    'name': fields.String(required=True, description='物品名称'),
+    'unit_price': fields.Float(required=True, description='单价')
+})
+
+item_update = item_ns.model('ItemUpdate', {
+    'id': fields.Integer(required=True, description='物品ID'),
+    'name': fields.String(description='物品名称'),
+    'unit_price': fields.Float(description='单价')
+})
+
+item_delete = item_ns.model('ItemDelete', {
+    'id': fields.Integer(required=True, description='物品ID')
+})
+
+# Response models
+success_response = item_ns.model('SuccessResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'data': fields.Raw(description='返回数据'),
+    'message': fields.String(description='消息')
+})
+
+error_response = item_ns.model('ErrorResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'error': fields.String(description='错误信息'),
+    'code': fields.String(description='错误代码')
+})
+
+
+@item_ns.route('')
+class ItemList(Resource):
+    """Resource for listing all items."""
+    
+    @item_ns.doc('list_items')
+    @item_ns.response(200, 'Success', success_response)
+    @require_auth
+    def get(self):
+        """获取所有物品列表"""
+        items = ItemService.get_all()
+        return {
+            'success': True,
+            'data': items,
+            'message': 'Items retrieved successfully'
+        }, 200
+
+
+@item_ns.route('/<int:id>')
+@item_ns.param('id', '物品ID')
+class ItemDetail(Resource):
+    """Resource for getting a single item."""
+    
+    @item_ns.doc('get_item')
+    @item_ns.response(200, 'Success', success_response)
+    @item_ns.response(404, 'Item not found', error_response)
+    @require_auth
+    def get(self, id):
+        """根据ID获取物品信息"""
+        item, error = ItemService.get_by_id(id)
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'NOT_FOUND'
+            }, 404
+        
+        return {
+            'success': True,
+            'data': item,
+            'message': 'Item retrieved successfully'
+        }, 200
+
+
+@item_ns.route('/create')
+class ItemCreate(Resource):
+    """Resource for creating an item."""
+    
+    @item_ns.doc('create_item')
+    @item_ns.expect(item_input)
+    @item_ns.response(200, 'Success', success_response)
+    @item_ns.response(400, 'Validation error', error_response)
+    @require_auth
+    def post(self):
+        """创建新物品"""
+        data = item_ns.payload
+        name = data.get('name', '')
+        unit_price = data.get('unit_price')
+        
+        item, error = ItemService.create(name, unit_price)
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        return {
+            'success': True,
+            'data': item,
+            'message': 'Item created successfully'
+        }, 200
+
+
+@item_ns.route('/update')
+class ItemUpdate(Resource):
+    """Resource for updating an item."""
+    
+    @item_ns.doc('update_item')
+    @item_ns.expect(item_update)
+    @item_ns.response(200, 'Success', success_response)
+    @item_ns.response(400, 'Validation error', error_response)
+    @item_ns.response(404, 'Item not found', error_response)
+    @require_auth
+    def post(self):
+        """更新物品信息"""
+        data = item_ns.payload
+        item_id = data.get('id')
+        name = data.get('name')
+        unit_price = data.get('unit_price')
+        
+        if not item_id:
+            return {
+                'success': False,
+                'error': 'Item ID is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        item, error = ItemService.update(item_id, name, unit_price)
+        if error:
+            if 'not found' in error.lower():
+                return {
+                    'success': False,
+                    'error': error,
+                    'code': 'NOT_FOUND'
+                }, 404
+            return {
+                'success': False,
+                'error': error,
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        return {
+            'success': True,
+            'data': item,
+            'message': 'Item updated successfully'
+        }, 200
+
+
+@item_ns.route('/delete')
+class ItemDelete(Resource):
+    """Resource for deleting an item."""
+    
+    @item_ns.doc('delete_item')
+    @item_ns.expect(item_delete)
+    @item_ns.response(200, 'Success', success_response)
+    @item_ns.response(404, 'Item not found', error_response)
+    @require_auth
+    def post(self):
+        """删除物品"""
+        data = item_ns.payload
+        item_id = data.get('id')
+        
+        if not item_id:
+            return {
+                'success': False,
+                'error': 'Item ID is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        success, error = ItemService.delete(item_id)
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'NOT_FOUND'
+            }, 404
+        
+        return {
+            'success': True,
+            'data': None,
+            'message': 'Item deleted successfully'
+        }, 200

+ 192 - 0
backend/app/routes/person.py

@@ -0,0 +1,192 @@
+"""Person API routes."""
+from flask_restx import Namespace, Resource, fields
+from app.services.person_service import PersonService
+from app.utils.auth_decorator import require_auth
+
+person_ns = Namespace('persons', description='人员管理接口')
+
+# API models for Swagger documentation
+person_model = person_ns.model('Person', {
+    'id': fields.Integer(readonly=True, description='人员ID'),
+    'name': fields.String(required=True, description='人员姓名'),
+    'created_at': fields.String(readonly=True, description='创建时间'),
+    'updated_at': fields.String(readonly=True, description='更新时间')
+})
+
+person_input = person_ns.model('PersonInput', {
+    'name': fields.String(required=True, description='人员姓名')
+})
+
+person_update = person_ns.model('PersonUpdate', {
+    'id': fields.Integer(required=True, description='人员ID'),
+    'name': fields.String(required=True, description='人员姓名')
+})
+
+person_delete = person_ns.model('PersonDelete', {
+    'id': fields.Integer(required=True, description='人员ID')
+})
+
+# Response models
+success_response = person_ns.model('SuccessResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'data': fields.Raw(description='返回数据'),
+    'message': fields.String(description='消息')
+})
+
+error_response = person_ns.model('ErrorResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'error': fields.String(description='错误信息'),
+    'code': fields.String(description='错误代码')
+})
+
+
+@person_ns.route('')
+class PersonList(Resource):
+    """Resource for listing all persons."""
+    
+    @person_ns.doc('list_persons')
+    @person_ns.response(200, 'Success', success_response)
+    @require_auth
+    def get(self):
+        """获取所有人员列表"""
+        persons = PersonService.get_all()
+        return {
+            'success': True,
+            'data': persons,
+            'message': 'Persons retrieved successfully'
+        }, 200
+
+
+@person_ns.route('/<int:id>')
+@person_ns.param('id', '人员ID')
+class PersonDetail(Resource):
+    """Resource for getting a single person."""
+    
+    @person_ns.doc('get_person')
+    @person_ns.response(200, 'Success', success_response)
+    @person_ns.response(404, 'Person not found', error_response)
+    @require_auth
+    def get(self, id):
+        """根据ID获取人员信息"""
+        person, error = PersonService.get_by_id(id)
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'NOT_FOUND'
+            }, 404
+        
+        return {
+            'success': True,
+            'data': person,
+            'message': 'Person retrieved successfully'
+        }, 200
+
+
+@person_ns.route('/create')
+class PersonCreate(Resource):
+    """Resource for creating a person."""
+    
+    @person_ns.doc('create_person')
+    @person_ns.expect(person_input)
+    @person_ns.response(200, 'Success', success_response)
+    @person_ns.response(400, 'Validation error', error_response)
+    @require_auth
+    def post(self):
+        """创建新人员"""
+        data = person_ns.payload
+        name = data.get('name', '')
+        
+        person, error = PersonService.create(name)
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        return {
+            'success': True,
+            'data': person,
+            'message': 'Person created successfully'
+        }, 200
+
+
+@person_ns.route('/update')
+class PersonUpdate(Resource):
+    """Resource for updating a person."""
+    
+    @person_ns.doc('update_person')
+    @person_ns.expect(person_update)
+    @person_ns.response(200, 'Success', success_response)
+    @person_ns.response(400, 'Validation error', error_response)
+    @person_ns.response(404, 'Person not found', error_response)
+    @require_auth
+    def post(self):
+        """更新人员信息"""
+        data = person_ns.payload
+        person_id = data.get('id')
+        name = data.get('name', '')
+        
+        if not person_id:
+            return {
+                'success': False,
+                'error': 'Person ID is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        person, error = PersonService.update(person_id, name)
+        if error:
+            if 'not found' in error.lower():
+                return {
+                    'success': False,
+                    'error': error,
+                    'code': 'NOT_FOUND'
+                }, 404
+            return {
+                'success': False,
+                'error': error,
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        return {
+            'success': True,
+            'data': person,
+            'message': 'Person updated successfully'
+        }, 200
+
+
+@person_ns.route('/delete')
+class PersonDelete(Resource):
+    """Resource for deleting a person."""
+    
+    @person_ns.doc('delete_person')
+    @person_ns.expect(person_delete)
+    @person_ns.response(200, 'Success', success_response)
+    @person_ns.response(404, 'Person not found', error_response)
+    @require_auth
+    def post(self):
+        """删除人员"""
+        data = person_ns.payload
+        person_id = data.get('id')
+        
+        if not person_id:
+            return {
+                'success': False,
+                'error': 'Person ID is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        success, error = PersonService.delete(person_id)
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'NOT_FOUND'
+            }, 404
+        
+        return {
+            'success': True,
+            'data': None,
+            'message': 'Person deleted successfully'
+        }, 200

+ 385 - 0
backend/app/routes/work_record.py

@@ -0,0 +1,385 @@
+"""Work Record API routes."""
+from flask import request
+from flask_restx import Namespace, Resource, fields
+from app.services.work_record_service import WorkRecordService
+from app.utils.auth_decorator import require_auth
+
+work_record_ns = Namespace('work-records', description='工作记录管理接口')
+
+# API models for Swagger documentation
+work_record_model = work_record_ns.model('WorkRecord', {
+    'id': fields.Integer(readonly=True, description='记录ID'),
+    'person_id': fields.Integer(required=True, description='人员ID'),
+    'person_name': fields.String(readonly=True, description='人员姓名'),
+    'item_id': fields.Integer(required=True, description='物品ID'),
+    'item_name': fields.String(readonly=True, description='物品名称'),
+    'unit_price': fields.Float(readonly=True, description='单价'),
+    'work_date': fields.String(required=True, description='工作日期 (YYYY-MM-DD)'),
+    'quantity': fields.Integer(required=True, description='数量'),
+    'total_price': fields.Float(readonly=True, description='总价'),
+    'created_at': fields.String(readonly=True, description='创建时间'),
+    'updated_at': fields.String(readonly=True, description='更新时间')
+})
+
+work_record_input = work_record_ns.model('WorkRecordInput', {
+    'person_id': fields.Integer(required=True, description='人员ID'),
+    'item_id': fields.Integer(required=True, description='物品ID'),
+    'work_date': fields.String(required=True, description='工作日期 (YYYY-MM-DD)'),
+    'quantity': fields.Integer(required=True, description='数量')
+})
+
+work_record_update = work_record_ns.model('WorkRecordUpdate', {
+    'id': fields.Integer(required=True, description='记录ID'),
+    'person_id': fields.Integer(description='人员ID'),
+    'item_id': fields.Integer(description='物品ID'),
+    'work_date': fields.String(description='工作日期 (YYYY-MM-DD)'),
+    'quantity': fields.Integer(description='数量')
+})
+
+work_record_delete = work_record_ns.model('WorkRecordDelete', {
+    'id': fields.Integer(required=True, description='记录ID')
+})
+
+# Response models
+success_response = work_record_ns.model('SuccessResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'data': fields.Raw(description='返回数据'),
+    'message': fields.String(description='消息')
+})
+
+error_response = work_record_ns.model('ErrorResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'error': fields.String(description='错误信息'),
+    'code': fields.String(description='错误代码')
+})
+
+# Daily summary models
+daily_summary_item = work_record_ns.model('DailySummaryItem', {
+    'item_name': fields.String(description='物品名称'),
+    'unit_price': fields.Float(description='单价'),
+    'quantity': fields.Integer(description='数量'),
+    'total_price': fields.Float(description='总价')
+})
+
+daily_summary_person = work_record_ns.model('DailySummaryPerson', {
+    'person_id': fields.Integer(description='人员ID'),
+    'person_name': fields.String(description='人员姓名'),
+    'total_items': fields.Integer(description='总数量'),
+    'total_value': fields.Float(description='总金额'),
+    'items': fields.List(fields.Nested(daily_summary_item), description='物品明细')
+})
+
+daily_summary_response = work_record_ns.model('DailySummaryResponse', {
+    'date': fields.String(description='日期'),
+    'summary': fields.List(fields.Nested(daily_summary_person), description='人员汇总'),
+    'grand_total_items': fields.Integer(description='总数量'),
+    'grand_total_value': fields.Float(description='总金额')
+})
+
+# Monthly summary models
+monthly_top_performer = work_record_ns.model('MonthlyTopPerformer', {
+    'person_id': fields.Integer(description='人员ID'),
+    'person_name': fields.String(description='人员姓名'),
+    'earnings': fields.Float(description='收入')
+})
+
+monthly_item_breakdown = work_record_ns.model('MonthlyItemBreakdown', {
+    'item_id': fields.Integer(description='物品ID'),
+    'item_name': fields.String(description='物品名称'),
+    'quantity': fields.Integer(description='数量'),
+    'earnings': fields.Float(description='收入')
+})
+
+monthly_summary_response = work_record_ns.model('MonthlySummaryResponse', {
+    'year': fields.Integer(description='年份'),
+    'month': fields.Integer(description='月份'),
+    'total_records': fields.Integer(description='总记录数'),
+    'total_earnings': fields.Float(description='总收入'),
+    'top_performers': fields.List(fields.Nested(monthly_top_performer), description='业绩排名'),
+    'item_breakdown': fields.List(fields.Nested(monthly_item_breakdown), description='物品收入明细')
+})
+
+
+@work_record_ns.route('')
+class WorkRecordList(Resource):
+    """Resource for listing work records with filters."""
+    
+    @work_record_ns.doc('list_work_records')
+    @work_record_ns.param('person_id', '按人员ID筛选', type=int)
+    @work_record_ns.param('date', '按具体日期筛选 (YYYY-MM-DD)', type=str)
+    @work_record_ns.param('year', '按年份筛选 (如 2024)', type=int)
+    @work_record_ns.param('month', '按月份筛选 (1-12)', type=int)
+    @work_record_ns.param('start_date', '开始日期 (YYYY-MM-DD)', type=str)
+    @work_record_ns.param('end_date', '结束日期 (YYYY-MM-DD)', type=str)
+    @work_record_ns.response(200, 'Success', success_response)
+    @require_auth
+    def get(self):
+        """获取工作记录列表(支持筛选)"""
+        person_id = request.args.get('person_id', type=int)
+        date = request.args.get('date')
+        year = request.args.get('year', type=int)
+        month = request.args.get('month', type=int)
+        start_date = request.args.get('start_date')
+        end_date = request.args.get('end_date')
+        
+        # 如果指定了具体日期,使用 start_date 和 end_date 来筛选同一天
+        if date:
+            start_date = date
+            end_date = date
+        
+        work_records = WorkRecordService.get_all(
+            person_id=person_id,
+            start_date=start_date,
+            end_date=end_date,
+            year=year,
+            month=month
+        )
+        return {
+            'success': True,
+            'data': work_records,
+            'message': 'Work records retrieved successfully'
+        }, 200
+
+
+@work_record_ns.route('/<int:id>')
+@work_record_ns.param('id', '记录ID')
+class WorkRecordDetail(Resource):
+    """Resource for getting a single work record."""
+    
+    @work_record_ns.doc('get_work_record')
+    @work_record_ns.response(200, 'Success', success_response)
+    @work_record_ns.response(404, 'Work record not found', error_response)
+    @require_auth
+    def get(self, id):
+        """根据ID获取工作记录"""
+        work_record, error = WorkRecordService.get_by_id(id)
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'NOT_FOUND'
+            }, 404
+        
+        return {
+            'success': True,
+            'data': work_record,
+            'message': 'Work record retrieved successfully'
+        }, 200
+
+
+@work_record_ns.route('/create')
+class WorkRecordCreate(Resource):
+    """Resource for creating a work record."""
+    
+    @work_record_ns.doc('create_work_record')
+    @work_record_ns.expect(work_record_input)
+    @work_record_ns.response(200, 'Success', success_response)
+    @work_record_ns.response(400, 'Validation error', error_response)
+    @work_record_ns.response(404, 'Reference not found', error_response)
+    @require_auth
+    def post(self):
+        """创建新工作记录"""
+        data = work_record_ns.payload
+        person_id = data.get('person_id')
+        item_id = data.get('item_id')
+        work_date = data.get('work_date')
+        quantity = data.get('quantity')
+        
+        work_record, error = WorkRecordService.create(
+            person_id=person_id,
+            item_id=item_id,
+            work_date=work_date,
+            quantity=quantity
+        )
+        
+        if error:
+            if 'not found' in error.lower():
+                return {
+                    'success': False,
+                    'error': error,
+                    'code': 'REFERENCE_ERROR'
+                }, 404
+            return {
+                'success': False,
+                'error': error,
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        return {
+            'success': True,
+            'data': work_record,
+            'message': 'Work record created successfully'
+        }, 200
+
+
+@work_record_ns.route('/update')
+class WorkRecordUpdate(Resource):
+    """Resource for updating a work record."""
+    
+    @work_record_ns.doc('update_work_record')
+    @work_record_ns.expect(work_record_update)
+    @work_record_ns.response(200, 'Success', success_response)
+    @work_record_ns.response(400, 'Validation error', error_response)
+    @work_record_ns.response(404, 'Not found', error_response)
+    @require_auth
+    def post(self):
+        """更新工作记录"""
+        data = work_record_ns.payload
+        work_record_id = data.get('id')
+        
+        if not work_record_id:
+            return {
+                'success': False,
+                'error': 'Work record ID is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        work_record, error = WorkRecordService.update(
+            work_record_id=work_record_id,
+            person_id=data.get('person_id'),
+            item_id=data.get('item_id'),
+            work_date=data.get('work_date'),
+            quantity=data.get('quantity')
+        )
+        
+        if error:
+            if 'not found' in error.lower():
+                return {
+                    'success': False,
+                    'error': error,
+                    'code': 'NOT_FOUND'
+                }, 404
+            return {
+                'success': False,
+                'error': error,
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        return {
+            'success': True,
+            'data': work_record,
+            'message': 'Work record updated successfully'
+        }, 200
+
+
+@work_record_ns.route('/delete')
+class WorkRecordDelete(Resource):
+    """Resource for deleting a work record."""
+    
+    @work_record_ns.doc('delete_work_record')
+    @work_record_ns.expect(work_record_delete)
+    @work_record_ns.response(200, 'Success', success_response)
+    @work_record_ns.response(404, 'Work record not found', error_response)
+    @require_auth
+    def post(self):
+        """删除工作记录"""
+        data = work_record_ns.payload
+        work_record_id = data.get('id')
+        
+        if not work_record_id:
+            return {
+                'success': False,
+                'error': 'Work record ID is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        success, error = WorkRecordService.delete(work_record_id)
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'NOT_FOUND'
+            }, 404
+        
+        return {
+            'success': True,
+            'data': None,
+            'message': 'Work record deleted successfully'
+        }, 200
+
+
+@work_record_ns.route('/daily-summary')
+class WorkRecordDailySummary(Resource):
+    """Resource for getting daily summary."""
+    
+    @work_record_ns.doc('get_daily_summary')
+    @work_record_ns.param('date', '日期 (YYYY-MM-DD)', required=True, type=str)
+    @work_record_ns.param('person_id', '按人员ID筛选', type=int)
+    @work_record_ns.response(200, 'Success')
+    @work_record_ns.response(400, 'Validation error', error_response)
+    @require_auth
+    def get(self):
+        """获取每日工作汇总"""
+        work_date = request.args.get('date')
+        person_id = request.args.get('person_id', type=int)
+        
+        if not work_date:
+            return {
+                'success': False,
+                'error': 'Date parameter is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        try:
+            summary = WorkRecordService.get_daily_summary(
+                work_date=work_date,
+                person_id=person_id
+            )
+            return {
+                'success': True,
+                'data': summary,
+                'message': 'Daily summary retrieved successfully'
+            }, 200
+        except ValueError as e:
+            return {
+                'success': False,
+                'error': str(e),
+                'code': 'VALIDATION_ERROR'
+            }, 400
+
+
+@work_record_ns.route('/monthly-summary')
+class WorkRecordMonthlySummary(Resource):
+    """Resource for getting monthly summary."""
+    
+    @work_record_ns.doc('get_monthly_summary')
+    @work_record_ns.param('year', '年份 (如 2024)', required=True, type=int)
+    @work_record_ns.param('month', '月份 (1-12)', required=True, type=int)
+    @work_record_ns.response(200, 'Success')
+    @work_record_ns.response(400, 'Validation error', error_response)
+    @require_auth
+    def get(self):
+        """获取月度工作汇总"""
+        year = request.args.get('year', type=int)
+        month = request.args.get('month', type=int)
+        
+        if not year or not month:
+            return {
+                'success': False,
+                'error': 'Year and month parameters are required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        if month < 1 or month > 12:
+            return {
+                'success': False,
+                'error': 'Month must be between 1 and 12',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        try:
+            summary = WorkRecordService.get_monthly_summary(
+                year=year,
+                month=month
+            )
+            return {
+                'success': True,
+                'data': summary,
+                'message': 'Monthly summary retrieved successfully'
+            }, 200
+        except ValueError as e:
+            return {
+                'success': False,
+                'error': str(e),
+                'code': 'VALIDATION_ERROR'
+            }, 400

+ 9 - 0
backend/app/services/__init__.py

@@ -0,0 +1,9 @@
+"""Business services for Work Statistics System."""
+from app.services.person_service import PersonService
+from app.services.item_service import ItemService
+from app.services.work_record_service import WorkRecordService
+from app.services.export_service import ExportService
+from app.services.auth_service import AuthService
+from app.services.import_service import ImportService
+
+__all__ = ['PersonService', 'ItemService', 'WorkRecordService', 'ExportService', 'AuthService', 'ImportService']

+ 299 - 0
backend/app/services/admin_service.py

@@ -0,0 +1,299 @@
+"""Admin service for business logic operations."""
+from flask_bcrypt import Bcrypt
+from app import db
+from app.models.admin import Admin
+
+bcrypt = Bcrypt()
+
+
+class AdminService:
+    """Service class for Admin CRUD operations."""
+    
+    MIN_PASSWORD_LENGTH = 6
+    
+    @staticmethod
+    def _hash_password(password):
+        """Hash a password using bcrypt.
+        
+        Args:
+            password: Plain text password
+            
+        Returns:
+            Hashed password string
+        """
+        return bcrypt.generate_password_hash(password).decode('utf-8')
+    
+    @staticmethod
+    def _check_password(password, password_hash):
+        """Verify a password against its hash.
+        
+        Args:
+            password: Plain text password
+            password_hash: Bcrypt hashed password
+            
+        Returns:
+            True if password matches, False otherwise
+        """
+        return bcrypt.check_password_hash(password_hash, password)
+    
+    @staticmethod
+    def _validate_username(username):
+        """Validate username is not empty or whitespace only.
+        
+        Args:
+            username: Username to validate
+            
+        Returns:
+            Tuple of (is_valid, error_message)
+        """
+        if not username or not username.strip():
+            return False, "用户名不能为空"
+        return True, None
+    
+    @staticmethod
+    def _validate_password(password):
+        """Validate password meets minimum length requirement.
+        
+        Args:
+            password: Password to validate
+            
+        Returns:
+            Tuple of (is_valid, error_message)
+        """
+        if not password or len(password) < AdminService.MIN_PASSWORD_LENGTH:
+            return False, f"密码长度至少为 {AdminService.MIN_PASSWORD_LENGTH} 个字符"
+        return True, None
+    
+    @staticmethod
+    def _check_username_unique(username, exclude_id=None):
+        """Check if username is unique.
+        
+        Args:
+            username: Username to check
+            exclude_id: Admin ID to exclude from check (for updates)
+            
+        Returns:
+            Tuple of (is_unique, error_message)
+        """
+        query = Admin.query.filter(Admin.username == username.strip())
+        if exclude_id:
+            query = query.filter(Admin.id != exclude_id)
+        
+        existing = query.first()
+        if existing:
+            return False, "用户名已存在"
+        return True, None
+    
+    @staticmethod
+    def create(username, password):
+        """Create a new admin.
+        
+        Args:
+            username: Admin's username
+            password: Admin's password (plain text)
+            
+        Returns:
+            Tuple of (admin_dict, error_message)
+            On success: (admin_dict, None)
+            On failure: (None, error_message)
+        """
+        # Validate username
+        is_valid, error = AdminService._validate_username(username)
+        if not is_valid:
+            return None, error
+        
+        # Validate password
+        is_valid, error = AdminService._validate_password(password)
+        if not is_valid:
+            return None, error
+        
+        # Check username uniqueness
+        is_unique, error = AdminService._check_username_unique(username)
+        if not is_unique:
+            return None, error
+        
+        # Create admin with hashed password
+        password_hash = AdminService._hash_password(password)
+        admin = Admin(
+            username=username.strip(),
+            password_hash=password_hash
+        )
+        db.session.add(admin)
+        db.session.commit()
+        
+        return admin.to_dict(), None
+    
+    @staticmethod
+    def get_all():
+        """Get all admins (without password_hash).
+        
+        Returns:
+            List of admin dictionaries
+        """
+        admins = Admin.query.order_by(Admin.id).all()
+        return [a.to_dict(include_password_hash=False) for a in admins]
+    
+    @staticmethod
+    def get_by_id(admin_id):
+        """Get an admin by ID.
+        
+        Args:
+            admin_id: Admin's ID
+            
+        Returns:
+            Tuple of (admin_dict, error_message)
+            On success: (admin_dict, None)
+            On failure: (None, error_message)
+        """
+        admin = db.session.get(Admin, admin_id)
+        if not admin:
+            return None, f"未找到ID为 {admin_id} 的管理员"
+        
+        return admin.to_dict(include_password_hash=False), None
+    
+    @staticmethod
+    def get_by_username(username):
+        """Get an admin by username.
+        
+        Args:
+            username: Admin's username
+            
+        Returns:
+            Admin model instance or None
+        """
+        if not username:
+            return None
+        return Admin.query.filter(Admin.username == username.strip()).first()
+    
+    @staticmethod
+    def verify_credentials(username, password):
+        """Verify admin credentials.
+        
+        Args:
+            username: Admin's username
+            password: Admin's password (plain text)
+            
+        Returns:
+            Admin model instance if valid, None otherwise
+        """
+        admin = AdminService.get_by_username(username)
+        if not admin:
+            return None
+        
+        if AdminService._check_password(password, admin.password_hash):
+            return admin
+        return None
+    
+    @staticmethod
+    def update(admin_id, username=None, password=None):
+        """Update an admin's information.
+        
+        Args:
+            admin_id: Admin's ID
+            username: New username (optional)
+            password: New password (optional)
+            
+        Returns:
+            Tuple of (admin_dict, error_message)
+            On success: (admin_dict, None)
+            On failure: (None, error_message)
+        """
+        admin = db.session.get(Admin, admin_id)
+        if not admin:
+            return None, f"未找到ID为 {admin_id} 的管理员"
+        
+        # Update username if provided
+        if username is not None:
+            is_valid, error = AdminService._validate_username(username)
+            if not is_valid:
+                return None, error
+            
+            is_unique, error = AdminService._check_username_unique(username, exclude_id=admin_id)
+            if not is_unique:
+                return None, error
+            
+            admin.username = username.strip()
+        
+        # Update password if provided
+        if password is not None:
+            is_valid, error = AdminService._validate_password(password)
+            if not is_valid:
+                return None, error
+            
+            admin.password_hash = AdminService._hash_password(password)
+        
+        db.session.commit()
+        
+        return admin.to_dict(include_password_hash=False), None
+    
+    @staticmethod
+    def delete(admin_id, current_admin_id=None):
+        """Delete an admin.
+        
+        Args:
+            admin_id: Admin's ID
+            current_admin_id: Current logged-in admin's ID (optional)
+            
+        Returns:
+            Tuple of (success, error_message)
+            On success: (True, None)
+            On failure: (False, error_message)
+        """
+        # Protect the primary admin (ID=1)
+        if admin_id == 1:
+            return False, "无法删除主管理员账户"
+        
+        # Prevent self-deletion
+        if current_admin_id and admin_id == current_admin_id:
+            return False, "不能删除自己的账户"
+        
+        admin = db.session.get(Admin, admin_id)
+        if not admin:
+            return False, f"未找到ID为 {admin_id} 的管理员"
+        
+        # Check if this is the last admin
+        admin_count = Admin.query.count()
+        if admin_count <= 1:
+            return False, "无法删除最后一个管理员账户"
+        
+        db.session.delete(admin)
+        db.session.commit()
+        
+        return True, None
+    
+    @staticmethod
+    def get_count():
+        """Get the total number of admins.
+        
+        Returns:
+            Integer count of admins
+        """
+        return Admin.query.count()
+    
+    @staticmethod
+    def create_default_admin(username='admin', password='admin123'):
+        """Create a default admin account if no admins exist.
+        
+        This method is called at application startup to ensure there is
+        at least one admin account available for login.
+        
+        Args:
+            username: Default admin username (default: 'admin')
+            password: Default admin password (default: 'admin123')
+            
+        Returns:
+            Tuple of (created, message)
+            - (True, message) if admin was created
+            - (False, message) if admin already exists or creation failed
+        """
+        # Check if any admins exist
+        admin_count = Admin.query.count()
+        if admin_count > 0:
+            return False, "Admin accounts already exist, skipping default admin creation"
+        
+        # Create the default admin
+        admin_dict, error = AdminService.create(username, password)
+        if error:
+            return False, f"Failed to create default admin: {error}"
+        
+        return True, f"Default admin '{username}' created successfully"

+ 53 - 0
backend/app/services/auth_service.py

@@ -0,0 +1,53 @@
+"""Authentication service for JWT token generation and validation."""
+import jwt
+from datetime import datetime, timezone, timedelta
+from flask import current_app
+
+
+class AuthService:
+    """Service for handling JWT authentication."""
+    
+    @staticmethod
+    def generate_token(admin):
+        """Generate a JWT token for an admin.
+        
+        Args:
+            admin: Admin model instance
+            
+        Returns:
+            JWT token string
+        """
+        expiration_days = current_app.config.get('JWT_EXPIRATION_DAYS', 7)
+        secret_key = current_app.config.get('JWT_SECRET_KEY')
+        algorithm = current_app.config.get('JWT_ALGORITHM', 'HS256')
+        
+        now = datetime.now(timezone.utc)
+        payload = {
+            'admin_id': admin.id,
+            'username': admin.username,
+            'iat': now,
+            'exp': now + timedelta(days=expiration_days)
+        }
+        
+        token = jwt.encode(payload, secret_key, algorithm=algorithm)
+        return token
+    
+    @staticmethod
+    def verify_token(token):
+        """Verify and decode a JWT token.
+        
+        Args:
+            token: JWT token string
+            
+        Returns:
+            Decoded payload dict if valid, None if invalid
+            
+        Raises:
+            jwt.ExpiredSignatureError: If token has expired
+            jwt.InvalidTokenError: If token is invalid
+        """
+        secret_key = current_app.config.get('JWT_SECRET_KEY')
+        algorithm = current_app.config.get('JWT_ALGORITHM', 'HS256')
+        
+        payload = jwt.decode(token, secret_key, algorithms=[algorithm])
+        return payload

+ 330 - 0
backend/app/services/export_service.py

@@ -0,0 +1,330 @@
+"""Export service for generating Excel reports."""
+from datetime import date
+from io import BytesIO
+from calendar import monthrange
+from openpyxl import Workbook
+from openpyxl.styles import Font, Alignment, Border, Side
+from openpyxl.utils import get_column_letter
+from app import db
+from app.models.work_record import WorkRecord
+from app.models.person import Person
+
+
+class ExportService:
+    """Service class for Excel export operations."""
+    
+    # Column headers for detail sheet
+    DETAIL_HEADERS = ['人员', '日期', '物品', '单价', '数量', '总价']
+    
+    @staticmethod
+    def _apply_header_style(cell):
+        """Apply header style to a cell."""
+        cell.font = Font(bold=True)
+        cell.alignment = Alignment(horizontal='center')
+        thin_border = Border(
+            left=Side(style='thin'),
+            right=Side(style='thin'),
+            top=Side(style='thin'),
+            bottom=Side(style='thin')
+        )
+        cell.border = thin_border
+    
+    @staticmethod
+    def _apply_cell_style(cell, is_number=False):
+        """Apply standard cell style."""
+        thin_border = Border(
+            left=Side(style='thin'),
+            right=Side(style='thin'),
+            top=Side(style='thin'),
+            bottom=Side(style='thin')
+        )
+        cell.border = thin_border
+        if is_number:
+            cell.alignment = Alignment(horizontal='right')
+    
+    @staticmethod
+    def _auto_adjust_column_width(worksheet):
+        """Auto-adjust column widths based on content."""
+        for column_cells in worksheet.columns:
+            max_length = 0
+            column = column_cells[0].column_letter
+            for cell in column_cells:
+                try:
+                    if cell.value:
+                        cell_length = len(str(cell.value))
+                        if cell_length > max_length:
+                            max_length = cell_length
+                except:
+                    pass
+            adjusted_width = min(max_length + 2, 50)
+            worksheet.column_dimensions[column].width = max(adjusted_width, 10)
+
+    @staticmethod
+    def _get_work_records_for_period(start_date, end_date):
+        """Get work records for a date range.
+        
+        Args:
+            start_date: Start date (inclusive)
+            end_date: End date (inclusive)
+            
+        Returns:
+            List of WorkRecord objects ordered by person name, date
+        """
+        return WorkRecord.query.filter(
+            WorkRecord.work_date >= start_date,
+            WorkRecord.work_date <= end_date
+        ).join(Person).order_by(
+            Person.name,
+            WorkRecord.work_date
+        ).all()
+    
+    @staticmethod
+    def _create_detail_sheet(workbook, records, sheet_name='明细表'):
+        """Create detail sheet with work records.
+        
+        Args:
+            workbook: openpyxl Workbook
+            records: List of WorkRecord objects
+            sheet_name: Name for the sheet
+            
+        Returns:
+            The created worksheet
+        """
+        ws = workbook.active
+        ws.title = sheet_name
+        
+        # Write headers
+        for col, header in enumerate(ExportService.DETAIL_HEADERS, 1):
+            cell = ws.cell(row=1, column=col, value=header)
+            ExportService._apply_header_style(cell)
+        
+        # Write data rows
+        for row_idx, record in enumerate(records, 2):
+            data = [
+                record.person.name,
+                record.work_date.strftime('%Y-%m-%d'),
+                record.item.name,
+                record.item.unit_price,
+                record.quantity,
+                record.total_price
+            ]
+            for col_idx, value in enumerate(data, 1):
+                cell = ws.cell(row=row_idx, column=col_idx, value=value)
+                is_number = col_idx in [4, 5, 6]  # unit_price, quantity, total_price
+                ExportService._apply_cell_style(cell, is_number)
+        
+        ExportService._auto_adjust_column_width(ws)
+        return ws
+    
+    @staticmethod
+    def _create_monthly_summary_sheet(workbook, records):
+        """Create monthly summary sheet grouped by person.
+        
+        Args:
+            workbook: openpyxl Workbook
+            records: List of WorkRecord objects
+            
+        Returns:
+            The created worksheet
+        """
+        ws = workbook.create_sheet(title='月度汇总')
+        
+        # Write headers
+        headers = ['人员', '总金额']
+        for col, header in enumerate(headers, 1):
+            cell = ws.cell(row=1, column=col, value=header)
+            ExportService._apply_header_style(cell)
+        
+        # Calculate totals by person
+        person_totals = {}
+        for record in records:
+            person_name = record.person.name
+            if person_name not in person_totals:
+                person_totals[person_name] = 0.0
+            person_totals[person_name] += record.total_price
+        
+        # Write data rows
+        row_idx = 2
+        grand_total = 0.0
+        for person_name in sorted(person_totals.keys()):
+            total = person_totals[person_name]
+            grand_total += total
+            
+            cell = ws.cell(row=row_idx, column=1, value=person_name)
+            ExportService._apply_cell_style(cell)
+            
+            cell = ws.cell(row=row_idx, column=2, value=round(total, 2))
+            ExportService._apply_cell_style(cell, is_number=True)
+            
+            row_idx += 1
+        
+        # Write grand total row
+        cell = ws.cell(row=row_idx, column=1, value='合计')
+        cell.font = Font(bold=True)
+        ExportService._apply_cell_style(cell)
+        
+        cell = ws.cell(row=row_idx, column=2, value=round(grand_total, 2))
+        cell.font = Font(bold=True)
+        ExportService._apply_cell_style(cell, is_number=True)
+        
+        ExportService._auto_adjust_column_width(ws)
+        return ws
+
+    @staticmethod
+    def _create_yearly_summary_sheet(workbook, records, year):
+        """Create yearly summary sheet with monthly breakdown.
+        
+        Args:
+            workbook: openpyxl Workbook
+            records: List of WorkRecord objects
+            year: The year being exported
+            
+        Returns:
+            The created worksheet
+        """
+        ws = workbook.create_sheet(title='年度汇总')
+        
+        # Write headers: 人员, 1月, 2月, ..., 12月, 年度合计
+        headers = ['人员'] + [f'{m}月' for m in range(1, 13)] + ['年度合计']
+        for col, header in enumerate(headers, 1):
+            cell = ws.cell(row=1, column=col, value=header)
+            ExportService._apply_header_style(cell)
+        
+        # Calculate totals by person and month
+        person_monthly_totals = {}
+        for record in records:
+            person_name = record.person.name
+            month = record.work_date.month
+            
+            if person_name not in person_monthly_totals:
+                person_monthly_totals[person_name] = {m: 0.0 for m in range(1, 13)}
+            
+            person_monthly_totals[person_name][month] += record.total_price
+        
+        # Write data rows
+        row_idx = 2
+        monthly_grand_totals = {m: 0.0 for m in range(1, 13)}
+        
+        for person_name in sorted(person_monthly_totals.keys()):
+            monthly_totals = person_monthly_totals[person_name]
+            yearly_total = sum(monthly_totals.values())
+            
+            # Person name
+            cell = ws.cell(row=row_idx, column=1, value=person_name)
+            ExportService._apply_cell_style(cell)
+            
+            # Monthly values
+            for month in range(1, 13):
+                value = monthly_totals[month]
+                monthly_grand_totals[month] += value
+                cell = ws.cell(row=row_idx, column=month + 1, value=round(value, 2))
+                ExportService._apply_cell_style(cell, is_number=True)
+            
+            # Yearly total
+            cell = ws.cell(row=row_idx, column=14, value=round(yearly_total, 2))
+            ExportService._apply_cell_style(cell, is_number=True)
+            
+            row_idx += 1
+        
+        # Write grand total row
+        cell = ws.cell(row=row_idx, column=1, value='合计')
+        cell.font = Font(bold=True)
+        ExportService._apply_cell_style(cell)
+        
+        grand_total = 0.0
+        for month in range(1, 13):
+            value = monthly_grand_totals[month]
+            grand_total += value
+            cell = ws.cell(row=row_idx, column=month + 1, value=round(value, 2))
+            cell.font = Font(bold=True)
+            ExportService._apply_cell_style(cell, is_number=True)
+        
+        cell = ws.cell(row=row_idx, column=14, value=round(grand_total, 2))
+        cell.font = Font(bold=True)
+        ExportService._apply_cell_style(cell, is_number=True)
+        
+        ExportService._auto_adjust_column_width(ws)
+        return ws
+
+    @staticmethod
+    def export_monthly(year, month):
+        """Export monthly work records to Excel.
+        
+        Args:
+            year: Year (e.g., 2024)
+            month: Month (1-12)
+            
+        Returns:
+            Tuple of (BytesIO containing Excel file, error_message)
+            On success: (excel_bytes, None)
+            On failure: (None, error_message)
+        """
+        # Validate inputs
+        if not isinstance(year, int) or year < 1900 or year > 9999:
+            return None, "年份无效,必须在 1900 到 9999 之间"
+        
+        if not isinstance(month, int) or month < 1 or month > 12:
+            return None, "月份无效,必须在 1 到 12 之间"
+        
+        # Calculate date range
+        start_date = date(year, month, 1)
+        _, last_day = monthrange(year, month)
+        end_date = date(year, month, last_day)
+        
+        # Get work records
+        records = ExportService._get_work_records_for_period(start_date, end_date)
+        
+        # Create workbook
+        workbook = Workbook()
+        
+        # Create detail sheet
+        ExportService._create_detail_sheet(workbook, records, f'{year}年{month}月明细')
+        
+        # Create summary sheet
+        ExportService._create_monthly_summary_sheet(workbook, records)
+        
+        # Save to BytesIO
+        output = BytesIO()
+        workbook.save(output)
+        output.seek(0)
+        
+        return output, None
+    
+    @staticmethod
+    def export_yearly(year):
+        """Export yearly work records to Excel.
+        
+        Args:
+            year: Year (e.g., 2024)
+            
+        Returns:
+            Tuple of (BytesIO containing Excel file, error_message)
+            On success: (excel_bytes, None)
+            On failure: (None, error_message)
+        """
+        # Validate inputs
+        if not isinstance(year, int) or year < 1900 or year > 9999:
+            return None, "年份无效,必须在 1900 到 9999 之间"
+        
+        # Calculate date range
+        start_date = date(year, 1, 1)
+        end_date = date(year, 12, 31)
+        
+        # Get work records
+        records = ExportService._get_work_records_for_period(start_date, end_date)
+        
+        # Create workbook
+        workbook = Workbook()
+        
+        # Create detail sheet
+        ExportService._create_detail_sheet(workbook, records, f'{year}年明细')
+        
+        # Create yearly summary sheet with monthly breakdown
+        ExportService._create_yearly_summary_sheet(workbook, records, year)
+        
+        # Save to BytesIO
+        output = BytesIO()
+        workbook.save(output)
+        output.seek(0)
+        
+        return output, None

+ 226 - 0
backend/app/services/import_service.py

@@ -0,0 +1,226 @@
+"""Import service for handling XLSX file import operations."""
+from io import BytesIO
+from datetime import datetime
+from typing import Tuple, List, Dict, Any
+
+from openpyxl import Workbook, load_workbook
+from openpyxl.utils.exceptions import InvalidFileException
+
+from app import db
+from app.models.work_record import WorkRecord
+from app.models.person import Person
+from app.models.item import Item
+
+
+class ImportService:
+    """Service class for importing work records from XLSX files."""
+    
+    REQUIRED_COLUMNS = ['人员姓名', '物品名称', '工作日期', '数量']
+    MAX_FILE_SIZE = 5 * 1024 * 1024  # 5MB
+    
+    @staticmethod
+    def generate_template() -> BytesIO:
+        """Generate an XLSX import template file.
+        
+        Creates a template with required column headers and example data rows
+        demonstrating the correct format.
+        
+        Returns:
+            BytesIO: In-memory XLSX file content
+            
+        Requirements: 3.1, 3.2, 3.3, 3.4, 3.5
+        """
+        wb = Workbook()
+        ws = wb.active
+        ws.title = "导入模板"
+        
+        # Add header row with Chinese column names
+        headers = ImportService.REQUIRED_COLUMNS
+        for col_idx, header in enumerate(headers, start=1):
+            ws.cell(row=1, column=col_idx, value=header)
+        
+        # Adjust column widths for better readability
+        ws.column_dimensions['A'].width = 15
+        ws.column_dimensions['B'].width = 15
+        ws.column_dimensions['C'].width = 15
+        ws.column_dimensions['D'].width = 10
+        
+        # Save to BytesIO
+        output = BytesIO()
+        wb.save(output)
+        output.seek(0)
+        
+        return output
+
+
+    @staticmethod
+    def parse_and_validate(file_content: bytes) -> Tuple[List[Dict[str, Any]], List[str]]:
+        """Parse and validate XLSX file content.
+        
+        Parses the uploaded XLSX file, validates the format and required columns,
+        then validates each row's data against existing persons and items.
+        
+        Args:
+            file_content: Raw bytes of the uploaded XLSX file
+            
+        Returns:
+            Tuple of (valid_records, errors):
+            - valid_records: List of dicts with person_id, item_id, work_date, quantity
+            - errors: List of error messages for invalid rows
+            
+        Requirements: 4.1, 4.2, 4.3, 4.4
+        """
+        errors = []
+        valid_records = []
+        
+        # Try to load the workbook
+        try:
+            file_stream = BytesIO(file_content)
+            wb = load_workbook(file_stream, read_only=True, data_only=True)
+        except InvalidFileException:
+            return [], ["文件格式错误,请上传XLSX文件"]
+        except Exception:
+            return [], ["文件格式错误,请上传XLSX文件"]
+        
+        ws = wb.active
+        
+        # Get header row and validate required columns
+        header_row = [cell.value for cell in ws[1]]
+        
+        # Check for missing required columns
+        missing_columns = []
+        column_indices = {}
+        for col_name in ImportService.REQUIRED_COLUMNS:
+            if col_name not in header_row:
+                missing_columns.append(col_name)
+            else:
+                column_indices[col_name] = header_row.index(col_name)
+        
+        if missing_columns:
+            return [], [f"缺少必需列: {', '.join(missing_columns)}"]
+        
+        # Cache persons and items for lookup
+        persons = {p.name: p.id for p in Person.query.all()}
+        items = {i.name: i.id for i in Item.query.all()}
+        
+        # Validate each data row (starting from row 2)
+        for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
+            # Skip empty rows
+            if all(cell is None or (isinstance(cell, str) and cell.strip() == '') for cell in row):
+                continue
+            
+            row_errors = []
+            record = {}
+            
+            # Get values from row
+            person_name = row[column_indices['人员姓名']]
+            item_name = row[column_indices['物品名称']]
+            work_date = row[column_indices['工作日期']]
+            quantity = row[column_indices['数量']]
+            
+            # Validate person_name
+            if person_name is None or (isinstance(person_name, str) and person_name.strip() == ''):
+                row_errors.append(f"第{row_idx}行: '人员姓名' 不能为空")
+            else:
+                person_name_str = str(person_name).strip()
+                if person_name_str not in persons:
+                    row_errors.append(f"第{row_idx}行: 人员 '{person_name_str}' 不存在")
+                else:
+                    record['person_id'] = persons[person_name_str]
+            
+            # Validate item_name
+            if item_name is None or (isinstance(item_name, str) and item_name.strip() == ''):
+                row_errors.append(f"第{row_idx}行: '物品名称' 不能为空")
+            else:
+                item_name_str = str(item_name).strip()
+                if item_name_str not in items:
+                    row_errors.append(f"第{row_idx}行: 物品 '{item_name_str}' 不存在")
+                else:
+                    record['item_id'] = items[item_name_str]
+            
+            # Validate work_date
+            if work_date is None or (isinstance(work_date, str) and work_date.strip() == ''):
+                row_errors.append(f"第{row_idx}行: '工作日期' 不能为空")
+            else:
+                parsed_date = None
+                if isinstance(work_date, datetime):
+                    parsed_date = work_date.date()
+                elif hasattr(work_date, 'date'):
+                    # Handle date objects
+                    parsed_date = work_date
+                else:
+                    # Try to parse string date
+                    try:
+                        parsed_date = datetime.strptime(str(work_date).strip(), '%Y-%m-%d').date()
+                    except ValueError:
+                        row_errors.append(f"第{row_idx}行: 日期格式错误,应为 YYYY-MM-DD")
+                
+                if parsed_date:
+                    record['work_date'] = parsed_date
+            
+            # Validate quantity
+            if quantity is None or (isinstance(quantity, str) and quantity.strip() == ''):
+                row_errors.append(f"第{row_idx}行: '数量' 不能为空")
+            else:
+                try:
+                    qty_value = int(float(quantity))
+                    if qty_value <= 0:
+                        row_errors.append(f"第{row_idx}行: 数量必须为正数")
+                    else:
+                        record['quantity'] = qty_value
+                except (ValueError, TypeError):
+                    row_errors.append(f"第{row_idx}行: 数量必须为正数")
+            
+            if row_errors:
+                errors.extend(row_errors)
+            elif len(record) == 4:  # All fields validated successfully
+                valid_records.append(record)
+        
+        wb.close()
+        
+        return valid_records, errors
+
+
+    @staticmethod
+    def import_records(records: List[Dict[str, Any]]) -> int:
+        """Import validated records as WorkRecord entries.
+        
+        Creates WorkRecord entries for each validated record. Uses atomic
+        operation - if any record fails to create, all changes are rolled back.
+        
+        Args:
+            records: List of validated record dicts with person_id, item_id,
+                    work_date, and quantity
+                    
+        Returns:
+            int: Count of successfully imported records
+            
+        Raises:
+            Exception: If any record fails to create (triggers rollback)
+            
+        Requirements: 4.6, 4.7, 4.8
+        """
+        if not records:
+            return 0
+        
+        try:
+            created_records = []
+            for record in records:
+                work_record = WorkRecord(
+                    person_id=record['person_id'],
+                    item_id=record['item_id'],
+                    work_date=record['work_date'],
+                    quantity=record['quantity']
+                )
+                db.session.add(work_record)
+                created_records.append(work_record)
+            
+            # Commit all records atomically
+            db.session.commit()
+            
+            return len(created_records)
+            
+        except Exception as e:
+            # Rollback on any failure
+            db.session.rollback()
+            raise e

+ 118 - 0
backend/app/services/item_service.py

@@ -0,0 +1,118 @@
+"""Item service for business logic operations."""
+from app import db
+from app.models.item import Item
+from app.utils.validators import is_valid_name, is_positive_number
+
+
+class ItemService:
+    """Service class for Item CRUD operations."""
+    
+    @staticmethod
+    def create(name, unit_price):
+        """Create a new item.
+        
+        Args:
+            name: Item's name
+            unit_price: Price per unit
+            
+        Returns:
+            Tuple of (item_dict, error_message)
+            On success: (item_dict, None)
+            On failure: (None, error_message)
+        """
+        if not is_valid_name(name):
+            return None, "物品名称不能为空"
+        
+        if not is_positive_number(unit_price):
+            return None, "单价必须为正数"
+        
+        item = Item(name=name.strip(), unit_price=float(unit_price))
+        db.session.add(item)
+        db.session.commit()
+        
+        return item.to_dict(), None
+    
+    @staticmethod
+    def get_all():
+        """Get all items.
+        
+        Returns:
+            List of item dictionaries
+        """
+        items = Item.query.order_by(Item.id).all()
+        return [i.to_dict() for i in items]
+    
+    @staticmethod
+    def get_by_id(item_id):
+        """Get an item by ID.
+        
+        Args:
+            item_id: Item's ID
+            
+        Returns:
+            Tuple of (item_dict, error_message)
+            On success: (item_dict, None)
+            On failure: (None, error_message)
+        """
+        item = db.session.get(Item, item_id)
+        if not item:
+            return None, f"未找到ID为 {item_id} 的物品"
+        
+        return item.to_dict(), None
+    
+    @staticmethod
+    def update(item_id, name=None, unit_price=None):
+        """Update an item's name and/or unit_price.
+        
+        Args:
+            item_id: Item's ID
+            name: New name (optional)
+            unit_price: New unit price (optional)
+            
+        Returns:
+            Tuple of (item_dict, error_message)
+            On success: (item_dict, None)
+            On failure: (None, error_message)
+        """
+        item = db.session.get(Item, item_id)
+        if not item:
+            return None, f"未找到ID为 {item_id} 的物品"
+        
+        if name is not None:
+            if not is_valid_name(name):
+                return None, "物品名称不能为空"
+            item.name = name.strip()
+        
+        if unit_price is not None:
+            if not is_positive_number(unit_price):
+                return None, "单价必须为正数"
+            item.unit_price = float(unit_price)
+        
+        db.session.commit()
+        
+        return item.to_dict(), None
+    
+    @staticmethod
+    def delete(item_id):
+        """Delete an item.
+        
+        Args:
+            item_id: Item's ID
+            
+        Returns:
+            Tuple of (success, error_message)
+            On success: (True, None)
+            On failure: (False, error_message)
+        """
+        item = db.session.get(Item, item_id)
+        if not item:
+            return False, f"未找到ID为 {item_id} 的物品"
+        
+        # Check if item has associated work records
+        if item.work_records.count() > 0:
+            return False, "无法删除已有工作记录的物品"
+        
+        db.session.delete(item)
+        db.session.commit()
+        
+        return True, None

+ 107 - 0
backend/app/services/person_service.py

@@ -0,0 +1,107 @@
+"""Person service for business logic operations."""
+from app import db
+from app.models.person import Person
+from app.utils.validators import is_valid_name
+
+
+class PersonService:
+    """Service class for Person CRUD operations."""
+    
+    @staticmethod
+    def create(name):
+        """Create a new person.
+        
+        Args:
+            name: Person's name
+            
+        Returns:
+            Tuple of (person_dict, error_message)
+            On success: (person_dict, None)
+            On failure: (None, error_message)
+        """
+        if not is_valid_name(name):
+            return None, "人员名称不能为空"
+        
+        person = Person(name=name.strip())
+        db.session.add(person)
+        db.session.commit()
+        
+        return person.to_dict(), None
+    
+    @staticmethod
+    def get_all():
+        """Get all persons.
+        
+        Returns:
+            List of person dictionaries
+        """
+        persons = Person.query.order_by(Person.id).all()
+        return [p.to_dict() for p in persons]
+    
+    @staticmethod
+    def get_by_id(person_id):
+        """Get a person by ID.
+        
+        Args:
+            person_id: Person's ID
+            
+        Returns:
+            Tuple of (person_dict, error_message)
+            On success: (person_dict, None)
+            On failure: (None, error_message)
+        """
+        person = db.session.get(Person, person_id)
+        if not person:
+            return None, f"未找到ID为 {person_id} 的人员"
+        
+        return person.to_dict(), None
+    
+    @staticmethod
+    def update(person_id, name):
+        """Update a person's name.
+        
+        Args:
+            person_id: Person's ID
+            name: New name
+            
+        Returns:
+            Tuple of (person_dict, error_message)
+            On success: (person_dict, None)
+            On failure: (None, error_message)
+        """
+        if not is_valid_name(name):
+            return None, "人员名称不能为空"
+        
+        person = db.session.get(Person, person_id)
+        if not person:
+            return None, f"未找到ID为 {person_id} 的人员"
+        
+        person.name = name.strip()
+        db.session.commit()
+        
+        return person.to_dict(), None
+    
+    @staticmethod
+    def delete(person_id):
+        """Delete a person.
+        
+        Args:
+            person_id: Person's ID
+            
+        Returns:
+            Tuple of (success, error_message)
+            On success: (True, None)
+            On failure: (False, error_message)
+        """
+        person = db.session.get(Person, person_id)
+        if not person:
+            return False, f"未找到ID为 {person_id} 的人员"
+        
+        # Check if person has associated work records
+        if person.work_records.count() > 0:
+            return False, "无法删除已有工作记录的人员"
+        
+        db.session.delete(person)
+        db.session.commit()
+        
+        return True, None

+ 322 - 0
backend/app/services/work_record_service.py

@@ -0,0 +1,322 @@
+"""WorkRecord service for business logic operations."""
+from datetime import datetime, date
+from sqlalchemy import func
+from app import db
+from app.models.work_record import WorkRecord
+from app.models.person import Person
+from app.models.item import Item
+
+
+class WorkRecordService:
+    """Service class for WorkRecord CRUD operations."""
+    
+    @staticmethod
+    def create(person_id, item_id, work_date, quantity):
+        """Create a new work record.
+        
+        Args:
+            person_id: ID of the person
+            item_id: ID of the item
+            work_date: Date of the work (date object or ISO string)
+            quantity: Number of items produced
+            
+        Returns:
+            Tuple of (work_record_dict, error_message)
+            On success: (work_record_dict, None)
+            On failure: (None, error_message)
+        """
+        # Validate quantity
+        if not isinstance(quantity, int) or quantity <= 0:
+            return None, "数量必须为正整数"
+        
+        # Validate person exists
+        person = db.session.get(Person, person_id)
+        if not person:
+            return None, f"未找到ID为 {person_id} 的人员"
+        
+        # Validate item exists
+        item = db.session.get(Item, item_id)
+        if not item:
+            return None, f"未找到ID为 {item_id} 的物品"
+        
+        # Parse work_date if string
+        if isinstance(work_date, str):
+            try:
+                work_date = datetime.fromisoformat(work_date).date()
+            except ValueError:
+                return None, "日期格式无效,请使用 YYYY-MM-DD 格式"
+        
+        work_record = WorkRecord(
+            person_id=person_id,
+            item_id=item_id,
+            work_date=work_date,
+            quantity=quantity
+        )
+        db.session.add(work_record)
+        db.session.commit()
+        
+        return work_record.to_dict(), None
+    
+    @staticmethod
+    def get_all(person_id=None, start_date=None, end_date=None, year=None, month=None):
+        """Get all work records with optional filters.
+        
+        All filters are applied as intersection (AND logic).
+        
+        Args:
+            person_id: Filter by person ID (optional)
+            start_date: Filter by start date (optional)
+            end_date: Filter by end date (optional)
+            year: Filter by year (optional, used with month)
+            month: Filter by month 1-12 (optional, used with year)
+            
+        Returns:
+            List of work record dictionaries
+        """
+        query = WorkRecord.query
+        
+        if person_id is not None:
+            query = query.filter(WorkRecord.person_id == person_id)
+        
+        # Apply month filter if both year and month are provided
+        if year is not None and month is not None:
+            month_start = date(year, month, 1)
+            if month == 12:
+                month_end = date(year + 1, 1, 1)
+            else:
+                month_end = date(year, month + 1, 1)
+            query = query.filter(WorkRecord.work_date >= month_start)
+            query = query.filter(WorkRecord.work_date < month_end)
+        
+        # Apply date range filter (intersection with month filter if both provided)
+        if start_date is not None:
+            if isinstance(start_date, str):
+                start_date = datetime.fromisoformat(start_date).date()
+            query = query.filter(WorkRecord.work_date >= start_date)
+        
+        if end_date is not None:
+            if isinstance(end_date, str):
+                end_date = datetime.fromisoformat(end_date).date()
+            query = query.filter(WorkRecord.work_date <= end_date)
+        
+        work_records = query.order_by(WorkRecord.work_date.desc(), WorkRecord.id).all()
+        return [wr.to_dict() for wr in work_records]
+    
+    @staticmethod
+    def get_by_id(work_record_id):
+        """Get a work record by ID.
+        
+        Args:
+            work_record_id: Work record's ID
+            
+        Returns:
+            Tuple of (work_record_dict, error_message)
+            On success: (work_record_dict, None)
+            On failure: (None, error_message)
+        """
+        work_record = db.session.get(WorkRecord, work_record_id)
+        if not work_record:
+            return None, f"未找到ID为 {work_record_id} 的工作记录"
+        
+        return work_record.to_dict(), None
+    
+    @staticmethod
+    def update(work_record_id, person_id=None, item_id=None, work_date=None, quantity=None):
+        """Update a work record.
+        
+        Args:
+            work_record_id: Work record's ID
+            person_id: New person ID (optional)
+            item_id: New item ID (optional)
+            work_date: New work date (optional)
+            quantity: New quantity (optional)
+            
+        Returns:
+            Tuple of (work_record_dict, error_message)
+            On success: (work_record_dict, None)
+            On failure: (None, error_message)
+        """
+        work_record = db.session.get(WorkRecord, work_record_id)
+        if not work_record:
+            return None, f"未找到ID为 {work_record_id} 的工作记录"
+        
+        if person_id is not None:
+            person = db.session.get(Person, person_id)
+            if not person:
+                return None, f"未找到ID为 {person_id} 的人员"
+            work_record.person_id = person_id
+        
+        if item_id is not None:
+            item = db.session.get(Item, item_id)
+            if not item:
+                return None, f"未找到ID为 {item_id} 的物品"
+            work_record.item_id = item_id
+        
+        if work_date is not None:
+            if isinstance(work_date, str):
+                try:
+                    work_date = datetime.fromisoformat(work_date).date()
+                except ValueError:
+                    return None, "日期格式无效,请使用 YYYY-MM-DD 格式"
+            work_record.work_date = work_date
+        
+        if quantity is not None:
+            if not isinstance(quantity, int) or quantity <= 0:
+                return None, "数量必须为正整数"
+            work_record.quantity = quantity
+        
+        db.session.commit()
+        
+        return work_record.to_dict(), None
+    
+    @staticmethod
+    def delete(work_record_id):
+        """Delete a work record.
+        
+        Args:
+            work_record_id: Work record's ID
+            
+        Returns:
+            Tuple of (success, error_message)
+            On success: (True, None)
+            On failure: (False, error_message)
+        """
+        work_record = db.session.get(WorkRecord, work_record_id)
+        if not work_record:
+            return False, f"未找到ID为 {work_record_id} 的工作记录"
+        
+        db.session.delete(work_record)
+        db.session.commit()
+        
+        return True, None
+    
+    @staticmethod
+    def get_daily_summary(work_date, person_id=None):
+        """Get daily summary of work records.
+        
+        Args:
+            work_date: Date to get summary for
+            person_id: Filter by person ID (optional)
+            
+        Returns:
+            Dictionary with daily summary data
+        """
+        if isinstance(work_date, str):
+            work_date = datetime.fromisoformat(work_date).date()
+        
+        query = WorkRecord.query.filter(WorkRecord.work_date == work_date)
+        
+        if person_id is not None:
+            query = query.filter(WorkRecord.person_id == person_id)
+        
+        work_records = query.all()
+        
+        # Group by person
+        summary_by_person = {}
+        for wr in work_records:
+            person_name = wr.person.name
+            if person_name not in summary_by_person:
+                summary_by_person[person_name] = {
+                    'person_id': wr.person_id,
+                    'person_name': person_name,
+                    'total_items': 0,
+                    'total_value': 0.0,
+                    'items': []
+                }
+            
+            summary_by_person[person_name]['total_items'] += wr.quantity
+            summary_by_person[person_name]['total_value'] += wr.total_price
+            summary_by_person[person_name]['items'].append({
+                'item_name': wr.item.name,
+                'unit_price': wr.item.unit_price,
+                'quantity': wr.quantity,
+                'total_price': wr.total_price
+            })
+        
+        return {
+            'date': work_date.isoformat(),
+            'summary': list(summary_by_person.values()),
+            'grand_total_items': sum(p['total_items'] for p in summary_by_person.values()),
+            'grand_total_value': sum(p['total_value'] for p in summary_by_person.values())
+        }
+
+    @staticmethod
+    def get_monthly_summary(year, month):
+        """Get monthly summary of work records.
+        
+        Args:
+            year: Year to get summary for
+            month: Month to get summary for (1-12)
+            
+        Returns:
+            Dictionary with monthly summary data including:
+            - total_records: Total number of work records
+            - total_earnings: Total earnings for the month
+            - top_performers: List of persons ranked by earnings (descending)
+            - item_breakdown: List of items with quantity and earnings
+        """
+        # Calculate month date range
+        month_start = date(year, month, 1)
+        if month == 12:
+            month_end = date(year + 1, 1, 1)
+        else:
+            month_end = date(year, month + 1, 1)
+        
+        # Get all work records for the month
+        work_records = WorkRecord.query.filter(
+            WorkRecord.work_date >= month_start,
+            WorkRecord.work_date < month_end
+        ).all()
+        
+        # Calculate totals
+        total_records = len(work_records)
+        total_earnings = sum(wr.total_price for wr in work_records)
+        
+        # Group by person for top performers
+        person_earnings = {}
+        for wr in work_records:
+            person_id = wr.person_id
+            if person_id not in person_earnings:
+                person_earnings[person_id] = {
+                    'person_id': person_id,
+                    'person_name': wr.person.name,
+                    'earnings': 0.0
+                }
+            person_earnings[person_id]['earnings'] += wr.total_price
+        
+        # Sort by earnings descending
+        top_performers = sorted(
+            person_earnings.values(),
+            key=lambda x: x['earnings'],
+            reverse=True
+        )
+        
+        # Group by item for breakdown
+        item_breakdown_dict = {}
+        for wr in work_records:
+            item_id = wr.item_id
+            if item_id not in item_breakdown_dict:
+                item_breakdown_dict[item_id] = {
+                    'item_id': item_id,
+                    'item_name': wr.item.name,
+                    'quantity': 0,
+                    'earnings': 0.0
+                }
+            item_breakdown_dict[item_id]['quantity'] += wr.quantity
+            item_breakdown_dict[item_id]['earnings'] += wr.total_price
+        
+        # Sort by earnings descending
+        item_breakdown = sorted(
+            item_breakdown_dict.values(),
+            key=lambda x: x['earnings'],
+            reverse=True
+        )
+        
+        return {
+            'year': year,
+            'month': month,
+            'total_records': total_records,
+            'total_earnings': total_earnings,
+            'top_performers': top_performers,
+            'item_breakdown': item_breakdown
+        }

+ 4 - 0
backend/app/utils/__init__.py

@@ -0,0 +1,4 @@
+"""Utility functions for Work Statistics System."""
+from app.utils.auth_decorator import require_auth
+
+__all__ = ['require_auth']

+ 57 - 0
backend/app/utils/auth_decorator.py

@@ -0,0 +1,57 @@
+"""Authentication decorator for protecting API endpoints."""
+from functools import wraps
+from flask import request
+import jwt
+from app.services.auth_service import AuthService
+
+
+def require_auth(f):
+    """Decorator to require JWT authentication for an endpoint.
+    
+    Validates the Authorization header contains a valid JWT token.
+    Sets request.current_admin with the decoded token payload if valid.
+    
+    Returns:
+        401 Unauthorized if token is missing, expired, or invalid
+    """
+    @wraps(f)
+    def decorated(*args, **kwargs):
+        auth_header = request.headers.get('Authorization', '')
+        
+        # Check for Bearer token
+        if not auth_header:
+            return {
+                'success': False,
+                'error': 'Authentication required',
+                'code': 'UNAUTHORIZED'
+            }, 401
+        
+        # Extract token from "Bearer <token>" format
+        parts = auth_header.split()
+        if len(parts) != 2 or parts[0].lower() != 'bearer':
+            return {
+                'success': False,
+                'error': 'Invalid authorization header format',
+                'code': 'INVALID_TOKEN'
+            }, 401
+        
+        token = parts[1]
+        
+        try:
+            payload = AuthService.verify_token(token)
+            request.current_admin = payload
+        except jwt.ExpiredSignatureError:
+            return {
+                'success': False,
+                'error': 'Token expired',
+                'code': 'TOKEN_EXPIRED'
+            }, 401
+        except jwt.InvalidTokenError:
+            return {
+                'success': False,
+                'error': 'Invalid token',
+                'code': 'INVALID_TOKEN'
+            }, 401
+        
+        return f(*args, **kwargs)
+    return decorated

+ 45 - 0
backend/app/utils/validators.py

@@ -0,0 +1,45 @@
+"""Validation utilities for the Work Statistics System."""
+
+
+def is_valid_name(name):
+    """Check if a name is valid (non-empty and not whitespace only).
+    
+    Args:
+        name: The name string to validate
+        
+    Returns:
+        True if valid, False otherwise
+    """
+    if name is None:
+        return False
+    return bool(name.strip())
+
+
+def is_positive_number(value):
+    """Check if a value is a positive number.
+    
+    Args:
+        value: The value to check
+        
+    Returns:
+        True if positive number, False otherwise
+    """
+    try:
+        return float(value) > 0
+    except (TypeError, ValueError):
+        return False
+
+
+def is_positive_integer(value):
+    """Check if a value is a positive integer.
+    
+    Args:
+        value: The value to check
+        
+    Returns:
+        True if positive integer, False otherwise
+    """
+    try:
+        return isinstance(value, int) and value > 0
+    except (TypeError, ValueError):
+        return False

+ 30 - 0
backend/init_db.py

@@ -0,0 +1,30 @@
+"""Initialize database and create tables."""
+import os
+from app import create_app, db
+from app.services.admin_service import AdminService
+
+def init_database():
+    """Initialize database with tables and default admin."""
+    config_name = os.environ.get('FLASK_CONFIG', 'production')
+    app = create_app(config_name)
+    
+    with app.app_context():
+        # Create all tables
+        db.create_all()
+        print("Database tables created successfully.")
+        
+        # Create default admin if not exists
+        created, message = AdminService.create_default_admin()
+        print(message)
+        
+        # Create indexes for name columns (if not exists)
+        try:
+            db.session.execute(db.text('CREATE INDEX IF NOT EXISTS ix_persons_name ON persons(name)'))
+            db.session.execute(db.text('CREATE INDEX IF NOT EXISTS ix_items_name ON items(name)'))
+            db.session.commit()
+            print("Indexes created successfully.")
+        except Exception as e:
+            print(f"Index creation skipped: {e}")
+
+if __name__ == '__main__':
+    init_database()

+ 25 - 0
backend/requirements.txt

@@ -0,0 +1,25 @@
+# Flask and extensions
+Flask==3.0.0
+Flask-SQLAlchemy==3.1.1
+Flask-RESTX==1.3.0
+Flask-CORS==4.0.0
+Flask-Bcrypt==1.0.1
+
+# Authentication
+PyJWT==2.8.0
+
+# Database
+SQLAlchemy>=2.0.36
+
+# Excel export
+openpyxl==3.1.2
+
+# Testing
+pytest==7.4.3
+hypothesis==6.92.1
+
+# Production server
+gunicorn==21.2.0
+
+# PostgreSQL driver
+psycopg2-binary==2.9.9

+ 9 - 0
backend/run.py

@@ -0,0 +1,9 @@
+"""Entry point for running the Flask application."""
+import os
+from app import create_app
+
+config_name = os.environ.get('FLASK_CONFIG', 'default')
+app = create_app(config_name)
+
+if __name__ == '__main__':
+    app.run(host='0.0.0.0', port=5000, debug=True)

+ 1 - 0
backend/tests/__init__.py

@@ -0,0 +1 @@
+"""Test package for Work Statistics System."""

+ 69 - 0
backend/tests/conftest.py

@@ -0,0 +1,69 @@
+"""Pytest configuration and fixtures for testing."""
+import pytest
+from hypothesis import settings
+
+from app import create_app, db
+from app.models.admin import Admin
+from app.services.auth_service import AuthService
+from flask_bcrypt import Bcrypt
+
+bcrypt = Bcrypt()
+
+# Configure hypothesis for minimum 100 examples
+settings.register_profile("ci", max_examples=100)
+settings.load_profile("ci")
+
+
+@pytest.fixture
+def app():
+    """Create application for testing."""
+    app = create_app('testing')
+    yield app
+
+
+@pytest.fixture
+def client(app):
+    """Create test client."""
+    return app.test_client()
+
+
+@pytest.fixture
+def db_session(app):
+    """Create database session for testing."""
+    with app.app_context():
+        db.create_all()
+        yield db
+        db.drop_all()
+
+
+@pytest.fixture
+def test_admin(db_session):
+    """Create a test admin for authentication tests."""
+    password_hash = bcrypt.generate_password_hash('testpassword').decode('utf-8')
+    admin = Admin(
+        username='testadmin',
+        password_hash=password_hash
+    )
+    db.session.add(admin)
+    db.session.commit()
+    
+    # Return a dict with admin info
+    return {
+        'id': admin.id,
+        'username': admin.username,
+        'password': 'testpassword'
+    }
+
+
+@pytest.fixture
+def auth_token(test_admin):
+    """Get a valid JWT token for testing protected endpoints."""
+    admin = Admin.query.filter_by(username=test_admin['username']).first()
+    token = AuthService.generate_token(admin)
+    return token
+
+
+@pytest.fixture
+def auth_headers(auth_token):
+    """Get headers with JWT token for authenticated requests."""
+    return {'Authorization': f'Bearer {auth_token}'}

+ 240 - 0
backend/tests/test_admin.py

@@ -0,0 +1,240 @@
+"""Test admin management API routes."""
+import pytest
+from flask_bcrypt import Bcrypt
+from app import db
+from app.models.admin import Admin
+
+
+@pytest.fixture
+def bcrypt_instance(app):
+    """Create bcrypt instance for testing."""
+    bcrypt = Bcrypt(app)
+    return bcrypt
+
+
+@pytest.fixture
+def test_admin(app, db_session, bcrypt_instance):
+    """Create a test admin for authentication tests."""
+    with app.app_context():
+        password_hash = bcrypt_instance.generate_password_hash('testpassword123').decode('utf-8')
+        admin = Admin(
+            username='testadmin',
+            password_hash=password_hash
+        )
+        db.session.add(admin)
+        db.session.commit()
+        yield admin
+
+
+@pytest.fixture
+def auth_token(client, test_admin):
+    """Get a valid JWT token for testing protected endpoints."""
+    response = client.post('/api/auth/login', json={
+        'username': 'testadmin',
+        'password': 'testpassword123'
+    })
+    return response.get_json()['data']['token']
+
+
+@pytest.fixture
+def auth_headers(auth_token):
+    """Get headers with JWT token for authenticated requests."""
+    return {'Authorization': f'Bearer {auth_token}'}
+
+
+def test_list_admins(client, test_admin, auth_headers):
+    """Test listing all admins."""
+    response = client.get('/api/admins', headers=auth_headers)
+    
+    assert response.status_code == 200
+    data = response.get_json()
+    assert data['success'] is True
+    assert len(data['data']) == 1
+    assert data['data'][0]['username'] == 'testadmin'
+    # Ensure password_hash is not exposed
+    assert 'password_hash' not in data['data'][0]
+
+
+def test_list_admins_unauthorized(client, test_admin):
+    """Test listing admins without auth returns 401."""
+    response = client.get('/api/admins')
+    
+    assert response.status_code == 401
+
+
+def test_get_admin_by_id(client, test_admin, auth_headers):
+    """Test getting admin by ID."""
+    response = client.get(f'/api/admins/{test_admin.id}', headers=auth_headers)
+    
+    assert response.status_code == 200
+    data = response.get_json()
+    assert data['success'] is True
+    assert data['data']['username'] == 'testadmin'
+    assert 'password_hash' not in data['data']
+
+
+def test_get_admin_not_found(client, test_admin, auth_headers):
+    """Test getting non-existent admin returns 404."""
+    response = client.get('/api/admins/9999', headers=auth_headers)
+    
+    assert response.status_code == 404
+    data = response.get_json()
+    assert data['success'] is False
+    assert data['code'] == 'NOT_FOUND'
+
+
+def test_create_admin(client, test_admin, auth_headers):
+    """Test creating a new admin."""
+    response = client.post('/api/admins/create', 
+        headers=auth_headers,
+        json={
+            'username': 'newadmin',
+            'password': 'newpassword123'
+        }
+    )
+    
+    assert response.status_code == 201
+    data = response.get_json()
+    assert data['success'] is True
+    assert data['data']['username'] == 'newadmin'
+    assert 'password_hash' not in data['data']
+
+
+def test_create_admin_duplicate_username(client, test_admin, auth_headers):
+    """Test creating admin with duplicate username returns 400."""
+    response = client.post('/api/admins/create',
+        headers=auth_headers,
+        json={
+            'username': 'testadmin',  # Already exists
+            'password': 'somepassword123'
+        }
+    )
+    
+    assert response.status_code == 400
+    data = response.get_json()
+    assert data['success'] is False
+    assert '已存在' in data['error']
+
+
+def test_create_admin_short_password(client, test_admin, auth_headers):
+    """Test creating admin with short password returns 400."""
+    response = client.post('/api/admins/create',
+        headers=auth_headers,
+        json={
+            'username': 'newadmin',
+            'password': '12345'  # Less than 6 characters
+        }
+    )
+    
+    assert response.status_code == 400
+    data = response.get_json()
+    assert data['success'] is False
+    assert '至少' in data['error'] or '6' in data['error']
+
+
+def test_create_admin_empty_username(client, test_admin, auth_headers):
+    """Test creating admin with empty username returns 400."""
+    response = client.post('/api/admins/create',
+        headers=auth_headers,
+        json={
+            'username': '',
+            'password': 'validpassword123'
+        }
+    )
+    
+    assert response.status_code == 400
+    data = response.get_json()
+    assert data['success'] is False
+
+
+def test_update_admin(client, test_admin, auth_headers):
+    """Test updating admin information."""
+    response = client.post('/api/admins/update',
+        headers=auth_headers,
+        json={
+            'id': test_admin.id,
+            'username': 'updatedadmin'
+        }
+    )
+    
+    assert response.status_code == 200
+    data = response.get_json()
+    assert data['success'] is True
+    assert data['data']['username'] == 'updatedadmin'
+
+
+def test_update_admin_password(client, test_admin, auth_headers):
+    """Test updating admin password."""
+    response = client.post('/api/admins/update',
+        headers=auth_headers,
+        json={
+            'id': test_admin.id,
+            'password': 'newpassword456'
+        }
+    )
+    
+    assert response.status_code == 200
+    
+    # Verify new password works
+    login_response = client.post('/api/auth/login', json={
+        'username': 'testadmin',
+        'password': 'newpassword456'
+    })
+    assert login_response.status_code == 200
+
+
+def test_update_admin_not_found(client, test_admin, auth_headers):
+    """Test updating non-existent admin returns 404."""
+    response = client.post('/api/admins/update',
+        headers=auth_headers,
+        json={
+            'id': 9999,
+            'username': 'newname'
+        }
+    )
+    
+    assert response.status_code == 404
+
+
+def test_delete_admin(client, app, db_session, bcrypt_instance, auth_headers):
+    """Test deleting an admin (when there are multiple)."""
+    # Create a second admin first
+    with app.app_context():
+        password_hash = bcrypt_instance.generate_password_hash('password123').decode('utf-8')
+        admin2 = Admin(username='admin2', password_hash=password_hash)
+        db.session.add(admin2)
+        db.session.commit()
+        admin2_id = admin2.id
+    
+    # Delete the second admin
+    response = client.post('/api/admins/delete',
+        headers=auth_headers,
+        json={'id': admin2_id}
+    )
+    
+    assert response.status_code == 200
+    data = response.get_json()
+    assert data['success'] is True
+
+
+def test_delete_last_admin(client, test_admin, auth_headers):
+    """Test deleting the last admin returns 400."""
+    response = client.post('/api/admins/delete',
+        headers=auth_headers,
+        json={'id': test_admin.id}
+    )
+    
+    assert response.status_code == 400
+    data = response.get_json()
+    assert data['success'] is False
+    assert '最后' in data['error'] or '管理员' in data['error']
+
+
+def test_delete_admin_not_found(client, test_admin, auth_headers):
+    """Test deleting non-existent admin returns 400."""
+    response = client.post('/api/admins/delete',
+        headers=auth_headers,
+        json={'id': 9999}
+    )
+    
+    assert response.status_code == 400

+ 19 - 0
backend/tests/test_app.py

@@ -0,0 +1,19 @@
+"""Test Flask application setup."""
+import pytest
+
+
+def test_app_creation(app):
+    """Test that the app is created successfully."""
+    assert app is not None
+    assert app.config['TESTING'] is True
+
+
+def test_app_has_swagger_docs(client):
+    """Test that Swagger documentation is accessible."""
+    response = client.get('/api/docs')
+    assert response.status_code == 200
+
+
+def test_database_setup(db_session):
+    """Test that database is set up correctly."""
+    assert db_session is not None

+ 121 - 0
backend/tests/test_auth.py

@@ -0,0 +1,121 @@
+"""Test authentication API routes."""
+import pytest
+from flask_bcrypt import Bcrypt
+from app import db
+from app.models.admin import Admin
+
+
+@pytest.fixture
+def bcrypt_instance(app):
+    """Create bcrypt instance for testing."""
+    bcrypt = Bcrypt(app)
+    return bcrypt
+
+
+@pytest.fixture
+def test_admin(app, db_session, bcrypt_instance):
+    """Create a test admin for authentication tests."""
+    with app.app_context():
+        password_hash = bcrypt_instance.generate_password_hash('testpassword123').decode('utf-8')
+        admin = Admin(
+            username='testadmin',
+            password_hash=password_hash
+        )
+        db.session.add(admin)
+        db.session.commit()
+        yield admin
+
+
+def test_login_success(client, test_admin):
+    """Test successful login returns JWT token."""
+    response = client.post('/api/auth/login', json={
+        'username': 'testadmin',
+        'password': 'testpassword123'
+    })
+    
+    assert response.status_code == 200
+    data = response.get_json()
+    assert data['success'] is True
+    assert 'token' in data['data']
+    assert data['data']['admin']['username'] == 'testadmin'
+
+
+def test_login_invalid_username(client, test_admin):
+    """Test login with invalid username returns 401."""
+    response = client.post('/api/auth/login', json={
+        'username': 'wronguser',
+        'password': 'testpassword123'
+    })
+    
+    assert response.status_code == 401
+    data = response.get_json()
+    assert data['success'] is False
+    assert data['code'] == 'AUTH_ERROR'
+
+
+def test_login_invalid_password(client, test_admin):
+    """Test login with invalid password returns 401."""
+    response = client.post('/api/auth/login', json={
+        'username': 'testadmin',
+        'password': 'wrongpassword'
+    })
+    
+    assert response.status_code == 401
+    data = response.get_json()
+    assert data['success'] is False
+    assert data['code'] == 'AUTH_ERROR'
+
+
+def test_login_missing_credentials(client, test_admin):
+    """Test login with missing credentials returns 400."""
+    response = client.post('/api/auth/login', json={
+        'username': '',
+        'password': ''
+    })
+    
+    assert response.status_code == 400
+    data = response.get_json()
+    assert data['success'] is False
+    assert data['code'] == 'VALIDATION_ERROR'
+
+
+def test_me_with_valid_token(client, test_admin):
+    """Test /me endpoint with valid token returns admin info."""
+    # First login to get token
+    login_response = client.post('/api/auth/login', json={
+        'username': 'testadmin',
+        'password': 'testpassword123'
+    })
+    token = login_response.get_json()['data']['token']
+    
+    # Then call /me with token
+    response = client.get('/api/auth/me', headers={
+        'Authorization': f'Bearer {token}'
+    })
+    
+    assert response.status_code == 200
+    data = response.get_json()
+    assert data['success'] is True
+    assert data['data']['username'] == 'testadmin'
+
+
+def test_me_without_token(client, test_admin):
+    """Test /me endpoint without token returns 401."""
+    response = client.get('/api/auth/me')
+    
+    assert response.status_code == 401
+    data = response.get_json()
+    assert data['success'] is False
+    assert data['code'] == 'UNAUTHORIZED'
+
+
+def test_me_with_invalid_token(client, test_admin):
+    """Test /me endpoint with invalid token returns 401."""
+    response = client.get('/api/auth/me', headers={
+        'Authorization': 'Bearer invalid_token_here'
+    })
+    
+    assert response.status_code == 401
+    data = response.get_json()
+    assert data['success'] is False
+    assert data['code'] == 'INVALID_TOKEN'

+ 267 - 0
backend/tests/test_export.py

@@ -0,0 +1,267 @@
+"""Tests for Export API endpoints."""
+import pytest
+from datetime import date
+from io import BytesIO
+from openpyxl import load_workbook
+from flask_bcrypt import Bcrypt
+from app import create_app, db
+from app.models.person import Person
+from app.models.item import Item
+from app.models.work_record import WorkRecord
+from app.models.admin import Admin
+from app.services.auth_service import AuthService
+
+bcrypt = Bcrypt()
+
+
+@pytest.fixture
+def app():
+    """Create application for testing."""
+    app = create_app('testing')
+    with app.app_context():
+        db.create_all()
+        yield app
+        db.session.remove()
+        db.drop_all()
+
+
+@pytest.fixture
+def client(app):
+    """Create test client."""
+    return app.test_client()
+
+
+@pytest.fixture
+def auth_headers(app):
+    """Create auth headers for testing."""
+    with app.app_context():
+        password_hash = bcrypt.generate_password_hash('testpassword').decode('utf-8')
+        admin = Admin(username='exporttestadmin', password_hash=password_hash)
+        db.session.add(admin)
+        db.session.commit()
+        token = AuthService.generate_token(admin)
+        return {'Authorization': f'Bearer {token}'}
+
+
+@pytest.fixture
+def sample_data(app, auth_headers):
+    """Create sample data for export tests."""
+    with app.app_context():
+        # Create persons
+        person1 = Person(name='张三')
+        person2 = Person(name='李四')
+        db.session.add_all([person1, person2])
+        db.session.commit()
+        
+        # Create items
+        item1 = Item(name='物品A', unit_price=10.50)
+        item2 = Item(name='物品B', unit_price=20.75)
+        db.session.add_all([item1, item2])
+        db.session.commit()
+        
+        # Create work records for January 2024
+        records = [
+            WorkRecord(person_id=person1.id, item_id=item1.id, work_date=date(2024, 1, 5), quantity=5),
+            WorkRecord(person_id=person1.id, item_id=item2.id, work_date=date(2024, 1, 10), quantity=3),
+            WorkRecord(person_id=person2.id, item_id=item1.id, work_date=date(2024, 1, 15), quantity=8),
+            # February 2024
+            WorkRecord(person_id=person1.id, item_id=item1.id, work_date=date(2024, 2, 5), quantity=4),
+            WorkRecord(person_id=person2.id, item_id=item2.id, work_date=date(2024, 2, 10), quantity=6),
+        ]
+        db.session.add_all(records)
+        db.session.commit()
+        
+        return {
+            'person1_id': person1.id,
+            'person2_id': person2.id,
+            'item1_id': item1.id,
+            'item2_id': item2.id
+        }
+
+
+class TestExportAPI:
+    """Test cases for Export API endpoints."""
+    
+    def test_monthly_export_success(self, client, sample_data, auth_headers):
+        """Test successful monthly export."""
+        response = client.get('/api/export/monthly?year=2024&month=1', headers=auth_headers)
+        
+        assert response.status_code == 200
+        assert response.content_type == 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+        
+        # Verify Excel content
+        wb = load_workbook(BytesIO(response.data))
+        assert len(wb.sheetnames) == 2
+        assert '2024年1月明细' in wb.sheetnames
+        assert '月度汇总' in wb.sheetnames
+
+    def test_monthly_export_detail_sheet(self, client, sample_data, auth_headers):
+        """Test monthly export detail sheet content."""
+        response = client.get('/api/export/monthly?year=2024&month=1', headers=auth_headers)
+        
+        wb = load_workbook(BytesIO(response.data))
+        detail_sheet = wb['2024年1月明细']
+        
+        # Check headers
+        headers = [cell.value for cell in detail_sheet[1]]
+        assert headers == ['人员', '日期', '物品', '单价', '数量', '总价']
+        
+        # Check data rows (3 records in January)
+        data_rows = list(detail_sheet.iter_rows(min_row=2, values_only=True))
+        assert len(data_rows) == 3
+    
+    def test_monthly_export_summary_sheet(self, client, sample_data, auth_headers):
+        """Test monthly export summary sheet content."""
+        response = client.get('/api/export/monthly?year=2024&month=1', headers=auth_headers)
+        
+        wb = load_workbook(BytesIO(response.data))
+        summary_sheet = wb['月度汇总']
+        
+        # Check headers
+        headers = [cell.value for cell in summary_sheet[1]]
+        assert headers == ['人员', '总金额']
+        
+        # Check that summary has person rows plus total row
+        data_rows = list(summary_sheet.iter_rows(min_row=2, values_only=True))
+        assert len(data_rows) >= 2  # At least 2 persons
+        
+        # Last row should be total
+        last_row = data_rows[-1]
+        assert last_row[0] == '合计'
+    
+    def test_monthly_export_missing_year(self, client, sample_data, auth_headers):
+        """Test monthly export with missing year parameter."""
+        response = client.get('/api/export/monthly?month=1', headers=auth_headers)
+        
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+        assert 'Year' in data['error']
+    
+    def test_monthly_export_missing_month(self, client, sample_data, auth_headers):
+        """Test monthly export with missing month parameter."""
+        response = client.get('/api/export/monthly?year=2024', headers=auth_headers)
+        
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+        assert 'Month' in data['error']
+    
+    def test_monthly_export_invalid_month(self, client, sample_data, auth_headers):
+        """Test monthly export with invalid month."""
+        response = client.get('/api/export/monthly?year=2024&month=13', headers=auth_headers)
+        
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+    
+    def test_monthly_export_empty_month(self, client, sample_data, auth_headers):
+        """Test monthly export for month with no records."""
+        response = client.get('/api/export/monthly?year=2024&month=12', headers=auth_headers)
+        
+        assert response.status_code == 200
+        
+        wb = load_workbook(BytesIO(response.data))
+        detail_sheet = wb.active
+        
+        # Should have headers but no data rows
+        data_rows = list(detail_sheet.iter_rows(min_row=2, values_only=True))
+        assert len(data_rows) == 0
+
+
+    def test_yearly_export_success(self, client, sample_data, auth_headers):
+        """Test successful yearly export."""
+        response = client.get('/api/export/yearly?year=2024', headers=auth_headers)
+        
+        assert response.status_code == 200
+        assert response.content_type == 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+        
+        # Verify Excel content
+        wb = load_workbook(BytesIO(response.data))
+        assert len(wb.sheetnames) == 2
+        assert '2024年明细' in wb.sheetnames
+        assert '年度汇总' in wb.sheetnames
+    
+    def test_yearly_export_detail_sheet(self, client, sample_data, auth_headers):
+        """Test yearly export detail sheet content."""
+        response = client.get('/api/export/yearly?year=2024', headers=auth_headers)
+        
+        wb = load_workbook(BytesIO(response.data))
+        detail_sheet = wb['2024年明细']
+        
+        # Check headers
+        headers = [cell.value for cell in detail_sheet[1]]
+        assert headers == ['人员', '日期', '物品', '单价', '数量', '总价']
+        
+        # Check data rows (5 records total in 2024)
+        data_rows = list(detail_sheet.iter_rows(min_row=2, values_only=True))
+        assert len(data_rows) == 5
+    
+    def test_yearly_export_summary_sheet(self, client, sample_data, auth_headers):
+        """Test yearly export summary sheet with monthly breakdown."""
+        response = client.get('/api/export/yearly?year=2024', headers=auth_headers)
+        
+        wb = load_workbook(BytesIO(response.data))
+        summary_sheet = wb['年度汇总']
+        
+        # Check headers: 人员, 1月-12月, 年度合计
+        headers = [cell.value for cell in summary_sheet[1]]
+        assert headers[0] == '人员'
+        assert headers[1] == '1月'
+        assert headers[12] == '12月'
+        assert headers[13] == '年度合计'
+        
+        # Check that summary has person rows plus total row
+        data_rows = list(summary_sheet.iter_rows(min_row=2, values_only=True))
+        assert len(data_rows) >= 2  # At least 2 persons
+        
+        # Last row should be total
+        last_row = data_rows[-1]
+        assert last_row[0] == '合计'
+    
+    def test_yearly_export_missing_year(self, client, sample_data, auth_headers):
+        """Test yearly export with missing year parameter."""
+        response = client.get('/api/export/yearly', headers=auth_headers)
+        
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+        assert 'Year' in data['error']
+    
+    def test_yearly_export_empty_year(self, client, sample_data, auth_headers):
+        """Test yearly export for year with no records."""
+        response = client.get('/api/export/yearly?year=2020', headers=auth_headers)
+        
+        assert response.status_code == 200
+        
+        wb = load_workbook(BytesIO(response.data))
+        detail_sheet = wb.active
+        
+        # Should have headers but no data rows
+        data_rows = list(detail_sheet.iter_rows(min_row=2, values_only=True))
+        assert len(data_rows) == 0
+    
+    def test_export_total_price_calculation(self, client, sample_data, auth_headers):
+        """Test that total_price is correctly calculated in export."""
+        response = client.get('/api/export/monthly?year=2024&month=1', headers=auth_headers)
+        
+        wb = load_workbook(BytesIO(response.data))
+        detail_sheet = wb['2024年1月明细']
+        
+        # Check each row's total_price = unit_price * quantity
+        for row in detail_sheet.iter_rows(min_row=2, values_only=True):
+            if row[0] is not None:  # Skip empty rows
+                unit_price = row[3]
+                quantity = row[4]
+                total_price = row[5]
+                assert abs(total_price - (unit_price * quantity)) < 0.01
+    
+    def test_unauthorized_access(self, client, app):
+        """Test that export endpoints require authentication."""
+        with app.app_context():
+            db.create_all()
+        response = client.get('/api/export/monthly?year=2024&month=1')
+        assert response.status_code == 401
+        data = response.get_json()
+        assert data['success'] is False
+        assert data['code'] == 'UNAUTHORIZED'

+ 229 - 0
backend/tests/test_item.py

@@ -0,0 +1,229 @@
+"""Tests for Item API endpoints."""
+import pytest
+
+
+class TestItemAPI:
+    """Test cases for Item CRUD operations."""
+    
+    def test_create_item_success(self, client, db_session, auth_headers):
+        """Test creating an item with valid data."""
+        response = client.post('/api/items/create', json={
+            'name': 'Test Item',
+            'unit_price': 10.50
+        }, headers=auth_headers)
+        
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        assert data['data']['name'] == 'Test Item'
+        assert data['data']['unit_price'] == 10.50
+        assert 'id' in data['data']
+    
+    def test_create_item_empty_name(self, client, db_session, auth_headers):
+        """Test creating an item with empty name fails."""
+        response = client.post('/api/items/create', json={
+            'name': '',
+            'unit_price': 10.50
+        }, headers=auth_headers)
+        
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+        assert '不能为空' in data['error'] or '空' in data['error']
+    
+    def test_create_item_whitespace_name(self, client, db_session, auth_headers):
+        """Test creating an item with whitespace-only name fails."""
+        response = client.post('/api/items/create', json={
+            'name': '   ',
+            'unit_price': 10.50
+        }, headers=auth_headers)
+        
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+    
+    def test_create_item_zero_price(self, client, db_session, auth_headers):
+        """Test creating an item with zero price fails."""
+        response = client.post('/api/items/create', json={
+            'name': 'Test Item',
+            'unit_price': 0
+        }, headers=auth_headers)
+        
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+        assert '正数' in data['error'] or '正' in data['error']
+    
+    def test_create_item_negative_price(self, client, db_session, auth_headers):
+        """Test creating an item with negative price fails."""
+        response = client.post('/api/items/create', json={
+            'name': 'Test Item',
+            'unit_price': -5.00
+        }, headers=auth_headers)
+        
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+    
+    def test_get_all_items(self, client, db_session, auth_headers):
+        """Test getting all items."""
+        # Create some items first
+        client.post('/api/items/create', json={'name': 'Item A', 'unit_price': 10.00}, headers=auth_headers)
+        client.post('/api/items/create', json={'name': 'Item B', 'unit_price': 20.50}, headers=auth_headers)
+        
+        response = client.get('/api/items', headers=auth_headers)
+        
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        assert len(data['data']) >= 2
+    
+    def test_get_item_by_id(self, client, db_session, auth_headers):
+        """Test getting an item by ID."""
+        # Create an item first
+        create_response = client.post('/api/items/create', json={
+            'name': 'Test Item',
+            'unit_price': 15.75
+        }, headers=auth_headers)
+        item_id = create_response.get_json()['data']['id']
+        
+        response = client.get(f'/api/items/{item_id}', headers=auth_headers)
+        
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        assert data['data']['name'] == 'Test Item'
+        assert data['data']['unit_price'] == 15.75
+    
+    def test_get_item_not_found(self, client, db_session, auth_headers):
+        """Test getting a non-existent item."""
+        response = client.get('/api/items/99999', headers=auth_headers)
+        
+        assert response.status_code == 404
+        data = response.get_json()
+        assert data['success'] is False
+        assert data['code'] == 'NOT_FOUND'
+    
+    def test_update_item_success(self, client, db_session, auth_headers):
+        """Test updating an item."""
+        # Create an item first
+        create_response = client.post('/api/items/create', json={
+            'name': 'Original Item',
+            'unit_price': 10.00
+        }, headers=auth_headers)
+        item_id = create_response.get_json()['data']['id']
+        
+        response = client.post('/api/items/update', json={
+            'id': item_id,
+            'name': 'Updated Item',
+            'unit_price': 25.50
+        }, headers=auth_headers)
+        
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        assert data['data']['name'] == 'Updated Item'
+        assert data['data']['unit_price'] == 25.50
+    
+    def test_update_item_partial(self, client, db_session, auth_headers):
+        """Test updating only the name of an item."""
+        # Create an item first
+        create_response = client.post('/api/items/create', json={
+            'name': 'Original Item',
+            'unit_price': 10.00
+        }, headers=auth_headers)
+        item_id = create_response.get_json()['data']['id']
+        
+        response = client.post('/api/items/update', json={
+            'id': item_id,
+            'name': 'Updated Name Only'
+        }, headers=auth_headers)
+        
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        assert data['data']['name'] == 'Updated Name Only'
+        assert data['data']['unit_price'] == 10.00  # Price unchanged
+    
+    def test_update_item_not_found(self, client, db_session, auth_headers):
+        """Test updating a non-existent item."""
+        response = client.post('/api/items/update', json={
+            'id': 99999,
+            'name': 'Updated Item'
+        }, headers=auth_headers)
+        
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+    
+    def test_update_item_empty_name(self, client, db_session, auth_headers):
+        """Test updating an item with empty name fails."""
+        # Create an item first
+        create_response = client.post('/api/items/create', json={
+            'name': 'Original Item',
+            'unit_price': 10.00
+        }, headers=auth_headers)
+        item_id = create_response.get_json()['data']['id']
+        
+        response = client.post('/api/items/update', json={
+            'id': item_id,
+            'name': ''
+        }, headers=auth_headers)
+        
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+    
+    def test_update_item_invalid_price(self, client, db_session, auth_headers):
+        """Test updating an item with invalid price fails."""
+        # Create an item first
+        create_response = client.post('/api/items/create', json={
+            'name': 'Original Item',
+            'unit_price': 10.00
+        }, headers=auth_headers)
+        item_id = create_response.get_json()['data']['id']
+        
+        response = client.post('/api/items/update', json={
+            'id': item_id,
+            'unit_price': -5.00
+        }, headers=auth_headers)
+        
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+    
+    def test_delete_item_success(self, client, db_session, auth_headers):
+        """Test deleting an item."""
+        # Create an item first
+        create_response = client.post('/api/items/create', json={
+            'name': 'Item to Delete',
+            'unit_price': 10.00
+        }, headers=auth_headers)
+        item_id = create_response.get_json()['data']['id']
+        
+        response = client.post('/api/items/delete', json={'id': item_id}, headers=auth_headers)
+        
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        
+        # Verify item is deleted
+        get_response = client.get(f'/api/items/{item_id}', headers=auth_headers)
+        assert get_response.status_code == 404
+    
+    def test_delete_item_not_found(self, client, db_session, auth_headers):
+        """Test deleting a non-existent item."""
+        response = client.post('/api/items/delete', json={'id': 99999}, headers=auth_headers)
+        
+        assert response.status_code == 404
+        data = response.get_json()
+        assert data['success'] is False
+        assert data['code'] == 'NOT_FOUND'
+    
+    def test_unauthorized_access(self, client, db_session):
+        """Test that endpoints require authentication."""
+        response = client.get('/api/items')
+        assert response.status_code == 401
+        data = response.get_json()
+        assert data['success'] is False
+        assert data['code'] == 'UNAUTHORIZED'

+ 125 - 0
backend/tests/test_person.py

@@ -0,0 +1,125 @@
+"""Tests for Person module."""
+import pytest
+from app.services.person_service import PersonService
+
+
+class TestPersonAPI:
+    """Test Person API endpoints."""
+    
+    def test_create_person_success(self, client, db_session, auth_headers):
+        """Test creating a person with valid name."""
+        response = client.post('/api/persons/create', json={'name': 'Test Person'}, headers=auth_headers)
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        assert data['data']['name'] == 'Test Person'
+        assert 'id' in data['data']
+    
+    def test_create_person_empty_name(self, client, db_session, auth_headers):
+        """Test creating a person with empty name fails."""
+        response = client.post('/api/persons/create', json={'name': ''}, headers=auth_headers)
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+        assert data['code'] == 'VALIDATION_ERROR'
+    
+    def test_create_person_whitespace_name(self, client, db_session, auth_headers):
+        """Test creating a person with whitespace-only name fails."""
+        response = client.post('/api/persons/create', json={'name': '   '}, headers=auth_headers)
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+        assert data['code'] == 'VALIDATION_ERROR'
+    
+    def test_get_all_persons(self, client, db_session, auth_headers):
+        """Test getting all persons."""
+        # Create some persons first
+        client.post('/api/persons/create', json={'name': 'Person 1'}, headers=auth_headers)
+        client.post('/api/persons/create', json={'name': 'Person 2'}, headers=auth_headers)
+        
+        response = client.get('/api/persons', headers=auth_headers)
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        assert len(data['data']) == 2
+    
+    def test_get_person_by_id(self, client, db_session, auth_headers):
+        """Test getting a person by ID."""
+        # Create a person first
+        create_response = client.post('/api/persons/create', json={'name': 'Test Person'}, headers=auth_headers)
+        person_id = create_response.get_json()['data']['id']
+        
+        response = client.get(f'/api/persons/{person_id}', headers=auth_headers)
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        assert data['data']['name'] == 'Test Person'
+    
+    def test_get_person_not_found(self, client, db_session, auth_headers):
+        """Test getting a non-existent person."""
+        response = client.get('/api/persons/9999', headers=auth_headers)
+        assert response.status_code == 404
+        data = response.get_json()
+        assert data['success'] is False
+        assert data['code'] == 'NOT_FOUND'
+    
+    def test_update_person_success(self, client, db_session, auth_headers):
+        """Test updating a person's name."""
+        # Create a person first
+        create_response = client.post('/api/persons/create', json={'name': 'Original Name'}, headers=auth_headers)
+        person_id = create_response.get_json()['data']['id']
+        
+        response = client.post('/api/persons/update', json={'id': person_id, 'name': 'Updated Name'}, headers=auth_headers)
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        assert data['data']['name'] == 'Updated Name'
+    
+    def test_update_person_not_found(self, client, db_session, auth_headers):
+        """Test updating a non-existent person."""
+        response = client.post('/api/persons/update', json={'id': 9999, 'name': 'New Name'}, headers=auth_headers)
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+    
+    def test_update_person_empty_name(self, client, db_session, auth_headers):
+        """Test updating a person with empty name fails."""
+        # Create a person first
+        create_response = client.post('/api/persons/create', json={'name': 'Original Name'}, headers=auth_headers)
+        person_id = create_response.get_json()['data']['id']
+        
+        response = client.post('/api/persons/update', json={'id': person_id, 'name': ''}, headers=auth_headers)
+        assert response.status_code == 400
+        data = response.get_json()
+        assert data['success'] is False
+        assert data['code'] == 'VALIDATION_ERROR'
+    
+    def test_delete_person_success(self, client, db_session, auth_headers):
+        """Test deleting a person."""
+        # Create a person first
+        create_response = client.post('/api/persons/create', json={'name': 'To Delete'}, headers=auth_headers)
+        person_id = create_response.get_json()['data']['id']
+        
+        response = client.post('/api/persons/delete', json={'id': person_id}, headers=auth_headers)
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        
+        # Verify person is deleted
+        get_response = client.get(f'/api/persons/{person_id}', headers=auth_headers)
+        assert get_response.status_code == 404
+    
+    def test_delete_person_not_found(self, client, db_session, auth_headers):
+        """Test deleting a non-existent person."""
+        response = client.post('/api/persons/delete', json={'id': 9999}, headers=auth_headers)
+        assert response.status_code == 404
+        data = response.get_json()
+        assert data['success'] is False
+    
+    def test_unauthorized_access(self, client, db_session):
+        """Test that endpoints require authentication."""
+        response = client.get('/api/persons')
+        assert response.status_code == 401
+        data = response.get_json()
+        assert data['success'] is False
+        assert data['code'] == 'UNAUTHORIZED'

+ 68 - 0
backend/tests/test_work_record.py

@@ -0,0 +1,68 @@
+"""Tests for WorkRecord API endpoints."""
+import pytest
+from datetime import date
+
+
+class TestWorkRecordAPI:
+    """Test cases for WorkRecord API."""
+    
+    def test_create_work_record_success(self, client, db_session, auth_headers):
+        """Test creating a work record with valid data."""
+        person_resp = client.post('/api/persons/create', json={'name': 'Test Worker'}, headers=auth_headers)
+        person_id = person_resp.get_json()['data']['id']
+        
+        item_resp = client.post('/api/items/create', json={'name': 'Test Item', 'unit_price': 10.50}, headers=auth_headers)
+        item_id = item_resp.get_json()['data']['id']
+        
+        response = client.post('/api/work-records/create', json={
+            'person_id': person_id,
+            'item_id': item_id,
+            'work_date': '2024-01-15',
+            'quantity': 5
+        }, headers=auth_headers)
+        
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        assert data['data']['person_id'] == person_id
+        assert data['data']['item_id'] == item_id
+        assert data['data']['quantity'] == 5
+        assert data['data']['total_price'] == 52.50
+    
+    def test_get_all_work_records(self, client, db_session, auth_headers):
+        """Test getting all work records."""
+        person_resp = client.post('/api/persons/create', json={'name': 'Test Worker'}, headers=auth_headers)
+        person_id = person_resp.get_json()['data']['id']
+        
+        item_resp = client.post('/api/items/create', json={'name': 'Test Item', 'unit_price': 10.0}, headers=auth_headers)
+        item_id = item_resp.get_json()['data']['id']
+        
+        client.post('/api/work-records/create', json={
+            'person_id': person_id,
+            'item_id': item_id,
+            'work_date': '2024-01-15',
+            'quantity': 5
+        }, headers=auth_headers)
+        
+        response = client.get('/api/work-records', headers=auth_headers)
+        
+        assert response.status_code == 200
+        data = response.get_json()
+        assert data['success'] is True
+        assert len(data['data']) >= 1
+    
+    def test_get_work_record_not_found(self, client, db_session, auth_headers):
+        """Test getting a non-existent work record."""
+        response = client.get('/api/work-records/99999', headers=auth_headers)
+        
+        assert response.status_code == 404
+        data = response.get_json()
+        assert data['success'] is False
+    
+    def test_unauthorized_access(self, client, db_session):
+        """Test that endpoints require authentication."""
+        response = client.get('/api/work-records')
+        assert response.status_code == 401
+        data = response.get_json()
+        assert data['success'] is False
+        assert data['code'] == 'UNAUTHORIZED'

+ 10 - 0
backend/wsgi.py

@@ -0,0 +1,10 @@
+"""WSGI entry point for production deployment."""
+import os
+from app import create_app
+
+# Use production config
+os.environ.setdefault('FLASK_CONFIG', 'production')
+app = create_app('production')
+
+if __name__ == '__main__':
+    app.run()

+ 13 - 0
frontend/index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
+    <title>工作统计系统</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/index.jsx"></script>
+  </body>
+</html>

+ 3104 - 0
frontend/package-lock.json

@@ -0,0 +1,3104 @@
+{
+  "name": "work-statistics-frontend",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "work-statistics-frontend",
+      "version": "0.0.0",
+      "dependencies": {
+        "@ant-design/icons": "^5.5.1",
+        "antd": "^5.22.0",
+        "axios": "^1.7.7",
+        "dayjs": "^1.11.19",
+        "react": "^18.3.1",
+        "react-dom": "^18.3.1",
+        "react-router-dom": "^6.28.0"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-react": "^4.3.4",
+        "vite": "^6.0.3"
+      }
+    },
+    "node_modules/@ant-design/colors": {
+      "version": "7.2.1",
+      "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz",
+      "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/fast-color": "^2.0.6"
+      }
+    },
+    "node_modules/@ant-design/cssinjs": {
+      "version": "1.24.0",
+      "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz",
+      "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "@emotion/hash": "^0.8.0",
+        "@emotion/unitless": "^0.7.5",
+        "classnames": "^2.3.1",
+        "csstype": "^3.1.3",
+        "rc-util": "^5.35.0",
+        "stylis": "^4.3.4"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/@ant-design/cssinjs-utils": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz",
+      "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/cssinjs": "^1.21.0",
+        "@babel/runtime": "^7.23.2",
+        "rc-util": "^5.38.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@ant-design/fast-color": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz",
+      "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.7"
+      },
+      "engines": {
+        "node": ">=8.x"
+      }
+    },
+    "node_modules/@ant-design/icons": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
+      "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/colors": "^7.0.0",
+        "@ant-design/icons-svg": "^4.4.0",
+        "@babel/runtime": "^7.24.8",
+        "classnames": "^2.2.6",
+        "rc-util": "^5.31.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/@ant-design/icons-svg": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
+      "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
+      "license": "MIT"
+    },
+    "node_modules/@ant-design/react-slick": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz",
+      "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.4",
+        "classnames": "^2.2.5",
+        "json2mq": "^0.2.0",
+        "resize-observer-polyfill": "^1.5.1",
+        "throttle-debounce": "^5.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+      "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.27.1",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
+      "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
+      "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.27.1",
+        "@babel/generator": "^7.28.5",
+        "@babel/helper-compilation-targets": "^7.27.2",
+        "@babel/helper-module-transforms": "^7.28.3",
+        "@babel/helpers": "^7.28.4",
+        "@babel/parser": "^7.28.5",
+        "@babel/template": "^7.27.2",
+        "@babel/traverse": "^7.28.5",
+        "@babel/types": "^7.28.5",
+        "@jridgewell/remapping": "^2.3.5",
+        "convert-source-map": "^2.0.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.3",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
+      "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.5",
+        "@babel/types": "^7.28.5",
+        "@jridgewell/gen-mapping": "^0.3.12",
+        "@jridgewell/trace-mapping": "^0.3.28",
+        "jsesc": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.27.2",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
+      "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.27.2",
+        "@babel/helper-validator-option": "^7.27.1",
+        "browserslist": "^4.24.0",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-globals": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
+      "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.27.1",
+        "@babel/types": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.28.3",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
+      "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.27.1",
+        "@babel/traverse": "^7.28.3"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
+      "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
+      "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/template": "^7.27.2",
+        "@babel/types": "^7.28.4"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
+      "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.5"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-self": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+      "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-source": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+      "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+      "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.27.2",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
+      "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.27.1",
+        "@babel/parser": "^7.27.2",
+        "@babel/types": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
+      "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.27.1",
+        "@babel/generator": "^7.28.5",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/parser": "^7.28.5",
+        "@babel/template": "^7.27.2",
+        "@babel/types": "^7.28.5",
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
+      "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@emotion/hash": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
+      "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/unitless": {
+      "version": "0.7.5",
+      "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
+      "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
+      "license": "MIT"
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+      "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+      "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+      "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+      "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+      "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+      "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+      "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+      "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+      "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+      "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+      "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+      "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+      "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+      "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+      "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+      "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+      "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+      "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+      "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+      "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+      "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@rc-component/async-validator": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz",
+      "integrity": "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.4"
+      },
+      "engines": {
+        "node": ">=14.x"
+      }
+    },
+    "node_modules/@rc-component/color-picker": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz",
+      "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/fast-color": "^2.0.6",
+        "@babel/runtime": "^7.23.6",
+        "classnames": "^2.2.6",
+        "rc-util": "^5.38.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/context": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz",
+      "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "rc-util": "^5.27.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/mini-decimal": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz",
+      "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      }
+    },
+    "node_modules/@rc-component/mutate-observer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz",
+      "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.24.4"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/portal": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz",
+      "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.24.4"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/qrcode": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz",
+      "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.7"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/tour": {
+      "version": "1.15.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz",
+      "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0",
+        "@rc-component/portal": "^1.0.0-9",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.24.4"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/trigger": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.0.tgz",
+      "integrity": "sha512-iwaxZyzOuK0D7lS+0AQEtW52zUWxoGqTGkke3dRyb8pYiShmRpCjB/8TzPI4R6YySCH7Vm9BZj/31VPiiQTLBg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.23.2",
+        "@rc-component/portal": "^1.1.0",
+        "classnames": "^2.3.2",
+        "rc-motion": "^2.0.0",
+        "rc-resize-observer": "^1.3.1",
+        "rc-util": "^5.44.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@remix-run/router": {
+      "version": "1.23.1",
+      "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
+      "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-beta.27",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+      "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
+      "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
+      "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
+      "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
+      "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
+      "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
+      "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
+      "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
+      "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
+      "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
+      "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
+      "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
+      "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
+      "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
+      "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
+      "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
+      "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
+      "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
+      "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
+      "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
+      "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
+      "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
+      "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/babel__core": {
+      "version": "7.20.5",
+      "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+      "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "@types/babel__generator": "*",
+        "@types/babel__template": "*",
+        "@types/babel__traverse": "*"
+      }
+    },
+    "node_modules/@types/babel__generator": {
+      "version": "7.27.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+      "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__template": {
+      "version": "7.4.4",
+      "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+      "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__traverse": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+      "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.2"
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+      "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.28.0",
+        "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+        "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+        "@rolldown/pluginutils": "1.0.0-beta.27",
+        "@types/babel__core": "^7.20.5",
+        "react-refresh": "^0.17.0"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+      }
+    },
+    "node_modules/antd": {
+      "version": "5.29.3",
+      "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
+      "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/colors": "^7.2.1",
+        "@ant-design/cssinjs": "^1.23.0",
+        "@ant-design/cssinjs-utils": "^1.1.3",
+        "@ant-design/fast-color": "^2.0.6",
+        "@ant-design/icons": "^5.6.1",
+        "@ant-design/react-slick": "~1.1.2",
+        "@babel/runtime": "^7.26.0",
+        "@rc-component/color-picker": "~2.0.1",
+        "@rc-component/mutate-observer": "^1.1.0",
+        "@rc-component/qrcode": "~1.1.0",
+        "@rc-component/tour": "~1.15.1",
+        "@rc-component/trigger": "^2.3.0",
+        "classnames": "^2.5.1",
+        "copy-to-clipboard": "^3.3.3",
+        "dayjs": "^1.11.11",
+        "rc-cascader": "~3.34.0",
+        "rc-checkbox": "~3.5.0",
+        "rc-collapse": "~3.9.0",
+        "rc-dialog": "~9.6.0",
+        "rc-drawer": "~7.3.0",
+        "rc-dropdown": "~4.2.1",
+        "rc-field-form": "~2.7.1",
+        "rc-image": "~7.12.0",
+        "rc-input": "~1.8.0",
+        "rc-input-number": "~9.5.0",
+        "rc-mentions": "~2.20.0",
+        "rc-menu": "~9.16.1",
+        "rc-motion": "^2.9.5",
+        "rc-notification": "~5.6.4",
+        "rc-pagination": "~5.1.0",
+        "rc-picker": "~4.11.3",
+        "rc-progress": "~4.0.0",
+        "rc-rate": "~2.13.1",
+        "rc-resize-observer": "^1.4.3",
+        "rc-segmented": "~2.7.0",
+        "rc-select": "~14.16.8",
+        "rc-slider": "~11.1.9",
+        "rc-steps": "~6.0.1",
+        "rc-switch": "~4.1.0",
+        "rc-table": "~7.54.0",
+        "rc-tabs": "~15.7.0",
+        "rc-textarea": "~1.10.2",
+        "rc-tooltip": "~6.4.0",
+        "rc-tree": "~5.13.1",
+        "rc-tree-select": "~5.27.0",
+        "rc-upload": "~4.11.0",
+        "rc-util": "^5.44.4",
+        "scroll-into-view-if-needed": "^3.1.0",
+        "throttle-debounce": "^5.0.2"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/ant-design"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.13.2",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+      "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.4",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.9.11",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
+      "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.js"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.1",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+      "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "baseline-browser-mapping": "^2.9.0",
+        "caniuse-lite": "^1.0.30001759",
+        "electron-to-chromium": "^1.5.263",
+        "node-releases": "^2.0.27",
+        "update-browserslist-db": "^1.2.0"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001761",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz",
+      "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/classnames": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
+      "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
+      "license": "MIT"
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/compute-scroll-into-view": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
+      "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
+      "license": "MIT"
+    },
+    "node_modules/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/copy-to-clipboard": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
+      "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
+      "license": "MIT",
+      "dependencies": {
+        "toggle-selection": "^1.0.6"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.19",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
+      "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.267",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
+      "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+      "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.25.12",
+        "@esbuild/android-arm": "0.25.12",
+        "@esbuild/android-arm64": "0.25.12",
+        "@esbuild/android-x64": "0.25.12",
+        "@esbuild/darwin-arm64": "0.25.12",
+        "@esbuild/darwin-x64": "0.25.12",
+        "@esbuild/freebsd-arm64": "0.25.12",
+        "@esbuild/freebsd-x64": "0.25.12",
+        "@esbuild/linux-arm": "0.25.12",
+        "@esbuild/linux-arm64": "0.25.12",
+        "@esbuild/linux-ia32": "0.25.12",
+        "@esbuild/linux-loong64": "0.25.12",
+        "@esbuild/linux-mips64el": "0.25.12",
+        "@esbuild/linux-ppc64": "0.25.12",
+        "@esbuild/linux-riscv64": "0.25.12",
+        "@esbuild/linux-s390x": "0.25.12",
+        "@esbuild/linux-x64": "0.25.12",
+        "@esbuild/netbsd-arm64": "0.25.12",
+        "@esbuild/netbsd-x64": "0.25.12",
+        "@esbuild/openbsd-arm64": "0.25.12",
+        "@esbuild/openbsd-x64": "0.25.12",
+        "@esbuild/openharmony-arm64": "0.25.12",
+        "@esbuild/sunos-x64": "0.25.12",
+        "@esbuild/win32-arm64": "0.25.12",
+        "@esbuild/win32-ia32": "0.25.12",
+        "@esbuild/win32-x64": "0.25.12"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.11",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+      "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "license": "MIT"
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/json2mq": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
+      "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==",
+      "license": "MIT",
+      "dependencies": {
+        "string-convert": "^0.2.0"
+      }
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.27",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+      "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.6",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+      "license": "MIT"
+    },
+    "node_modules/rc-cascader": {
+      "version": "3.34.0",
+      "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz",
+      "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.25.7",
+        "classnames": "^2.3.1",
+        "rc-select": "~14.16.2",
+        "rc-tree": "~5.13.0",
+        "rc-util": "^5.43.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-checkbox": {
+      "version": "3.5.0",
+      "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz",
+      "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.25.2"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-collapse": {
+      "version": "3.9.0",
+      "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz",
+      "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "2.x",
+        "rc-motion": "^2.3.4",
+        "rc-util": "^5.27.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-dialog": {
+      "version": "9.6.0",
+      "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz",
+      "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/portal": "^1.0.0-8",
+        "classnames": "^2.2.6",
+        "rc-motion": "^2.3.0",
+        "rc-util": "^5.21.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-drawer": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz",
+      "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.23.9",
+        "@rc-component/portal": "^1.1.1",
+        "classnames": "^2.2.6",
+        "rc-motion": "^2.6.1",
+        "rc-util": "^5.38.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-dropdown": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz",
+      "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.2.6",
+        "rc-util": "^5.44.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.11.0",
+        "react-dom": ">=16.11.0"
+      }
+    },
+    "node_modules/rc-field-form": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz",
+      "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0",
+        "@rc-component/async-validator": "^5.0.3",
+        "rc-util": "^5.32.2"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-image": {
+      "version": "7.12.0",
+      "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz",
+      "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.2",
+        "@rc-component/portal": "^1.0.2",
+        "classnames": "^2.2.6",
+        "rc-dialog": "~9.6.0",
+        "rc-motion": "^2.6.2",
+        "rc-util": "^5.34.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-input": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz",
+      "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "classnames": "^2.2.1",
+        "rc-util": "^5.18.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/rc-input-number": {
+      "version": "9.5.0",
+      "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz",
+      "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/mini-decimal": "^1.0.1",
+        "classnames": "^2.2.5",
+        "rc-input": "~1.8.0",
+        "rc-util": "^5.40.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-mentions": {
+      "version": "2.20.0",
+      "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz",
+      "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.22.5",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.2.6",
+        "rc-input": "~1.8.0",
+        "rc-menu": "~9.16.0",
+        "rc-textarea": "~1.10.0",
+        "rc-util": "^5.34.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-menu": {
+      "version": "9.16.1",
+      "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz",
+      "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "2.x",
+        "rc-motion": "^2.4.3",
+        "rc-overflow": "^1.3.1",
+        "rc-util": "^5.27.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-motion": {
+      "version": "2.9.5",
+      "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz",
+      "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "classnames": "^2.2.1",
+        "rc-util": "^5.44.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-notification": {
+      "version": "5.6.4",
+      "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz",
+      "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "2.x",
+        "rc-motion": "^2.9.0",
+        "rc-util": "^5.20.1"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-overflow": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz",
+      "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "classnames": "^2.2.1",
+        "rc-resize-observer": "^1.0.0",
+        "rc-util": "^5.37.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-pagination": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz",
+      "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.38.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-picker": {
+      "version": "4.11.3",
+      "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz",
+      "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.7",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.2.1",
+        "rc-overflow": "^1.3.2",
+        "rc-resize-observer": "^1.4.0",
+        "rc-util": "^5.43.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "date-fns": ">= 2.x",
+        "dayjs": ">= 1.x",
+        "luxon": ">= 3.x",
+        "moment": ">= 2.x",
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      },
+      "peerDependenciesMeta": {
+        "date-fns": {
+          "optional": true
+        },
+        "dayjs": {
+          "optional": true
+        },
+        "luxon": {
+          "optional": true
+        },
+        "moment": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/rc-progress": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz",
+      "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.6",
+        "rc-util": "^5.16.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-rate": {
+      "version": "2.13.1",
+      "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz",
+      "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.5",
+        "rc-util": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-resize-observer": {
+      "version": "1.4.3",
+      "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz",
+      "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.20.7",
+        "classnames": "^2.2.1",
+        "rc-util": "^5.44.1",
+        "resize-observer-polyfill": "^1.5.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-segmented": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz",
+      "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "classnames": "^2.2.1",
+        "rc-motion": "^2.4.4",
+        "rc-util": "^5.17.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/rc-select": {
+      "version": "14.16.8",
+      "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz",
+      "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/trigger": "^2.1.1",
+        "classnames": "2.x",
+        "rc-motion": "^2.0.1",
+        "rc-overflow": "^1.3.1",
+        "rc-util": "^5.16.1",
+        "rc-virtual-list": "^3.5.2"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-dom": "*"
+      }
+    },
+    "node_modules/rc-slider": {
+      "version": "11.1.9",
+      "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz",
+      "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.5",
+        "rc-util": "^5.36.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-steps": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz",
+      "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.16.7",
+        "classnames": "^2.2.3",
+        "rc-util": "^5.16.1"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-switch": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz",
+      "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.21.0",
+        "classnames": "^2.2.1",
+        "rc-util": "^5.30.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-table": {
+      "version": "7.54.0",
+      "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz",
+      "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/context": "^1.4.0",
+        "classnames": "^2.2.5",
+        "rc-resize-observer": "^1.1.0",
+        "rc-util": "^5.44.3",
+        "rc-virtual-list": "^3.14.2"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-tabs": {
+      "version": "15.7.0",
+      "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz",
+      "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.2",
+        "classnames": "2.x",
+        "rc-dropdown": "~4.2.0",
+        "rc-menu": "~9.16.0",
+        "rc-motion": "^2.6.2",
+        "rc-resize-observer": "^1.0.0",
+        "rc-util": "^5.34.1"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-textarea": {
+      "version": "1.10.2",
+      "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz",
+      "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.1",
+        "rc-input": "~1.8.0",
+        "rc-resize-observer": "^1.0.0",
+        "rc-util": "^5.27.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-tooltip": {
+      "version": "6.4.0",
+      "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz",
+      "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.2",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.3.1",
+        "rc-util": "^5.44.3"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-tree": {
+      "version": "5.13.1",
+      "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz",
+      "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "2.x",
+        "rc-motion": "^2.0.1",
+        "rc-util": "^5.16.1",
+        "rc-virtual-list": "^3.5.1"
+      },
+      "engines": {
+        "node": ">=10.x"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-dom": "*"
+      }
+    },
+    "node_modules/rc-tree-select": {
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz",
+      "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.25.7",
+        "classnames": "2.x",
+        "rc-select": "~14.16.2",
+        "rc-tree": "~5.13.0",
+        "rc-util": "^5.43.0"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-dom": "*"
+      }
+    },
+    "node_modules/rc-upload": {
+      "version": "4.11.0",
+      "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz",
+      "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "classnames": "^2.2.5",
+        "rc-util": "^5.2.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-util": {
+      "version": "5.44.4",
+      "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz",
+      "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "react-is": "^18.2.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-virtual-list": {
+      "version": "3.19.2",
+      "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz",
+      "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.20.0",
+        "classnames": "^2.2.6",
+        "rc-resize-observer": "^1.0.0",
+        "rc-util": "^5.36.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/react": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+      "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+      "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "scheduler": "^0.23.2"
+      },
+      "peerDependencies": {
+        "react": "^18.3.1"
+      }
+    },
+    "node_modules/react-is": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+      "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+      "license": "MIT"
+    },
+    "node_modules/react-refresh": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+      "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-router": {
+      "version": "6.30.2",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz",
+      "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==",
+      "license": "MIT",
+      "dependencies": {
+        "@remix-run/router": "1.23.1"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8"
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "6.30.2",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz",
+      "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@remix-run/router": "1.23.1",
+        "react-router": "6.30.2"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8",
+        "react-dom": ">=16.8"
+      }
+    },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
+      "license": "MIT"
+    },
+    "node_modules/rollup": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
+      "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.54.0",
+        "@rollup/rollup-android-arm64": "4.54.0",
+        "@rollup/rollup-darwin-arm64": "4.54.0",
+        "@rollup/rollup-darwin-x64": "4.54.0",
+        "@rollup/rollup-freebsd-arm64": "4.54.0",
+        "@rollup/rollup-freebsd-x64": "4.54.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.54.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.54.0",
+        "@rollup/rollup-linux-arm64-musl": "4.54.0",
+        "@rollup/rollup-linux-loong64-gnu": "4.54.0",
+        "@rollup/rollup-linux-ppc64-gnu": "4.54.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.54.0",
+        "@rollup/rollup-linux-riscv64-musl": "4.54.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.54.0",
+        "@rollup/rollup-linux-x64-gnu": "4.54.0",
+        "@rollup/rollup-linux-x64-musl": "4.54.0",
+        "@rollup/rollup-openharmony-arm64": "4.54.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.54.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.54.0",
+        "@rollup/rollup-win32-x64-gnu": "4.54.0",
+        "@rollup/rollup-win32-x64-msvc": "4.54.0",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.23.2",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+      "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      }
+    },
+    "node_modules/scroll-into-view-if-needed": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
+      "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
+      "license": "MIT",
+      "dependencies": {
+        "compute-scroll-into-view": "^3.0.2"
+      }
+    },
+    "node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/string-convert": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
+      "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==",
+      "license": "MIT"
+    },
+    "node_modules/stylis": {
+      "version": "4.3.6",
+      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
+      "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
+      "license": "MIT"
+    },
+    "node_modules/throttle-debounce": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
+      "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.22"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.15",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/toggle-selection": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
+      "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
+      "license": "MIT"
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/vite": {
+      "version": "6.4.1",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+      "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "esbuild": "^0.25.0",
+        "fdir": "^6.4.4",
+        "picomatch": "^4.0.2",
+        "postcss": "^8.5.3",
+        "rollup": "^4.34.9",
+        "tinyglobby": "^0.2.13"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "jiti": ">=1.21.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true,
+      "license": "ISC"
+    }
+  }
+}

+ 25 - 0
frontend/package.json

@@ -0,0 +1,25 @@
+{
+  "name": "work-statistics-frontend",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "lint": "eslint .",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@ant-design/icons": "^5.5.1",
+    "antd": "^5.22.0",
+    "axios": "^1.7.7",
+    "dayjs": "^1.11.19",
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1",
+    "react-router-dom": "^6.28.0"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-react": "^4.3.4",
+    "vite": "^6.0.3"
+  }
+}

+ 41 - 0
frontend/src/App.jsx

@@ -0,0 +1,41 @@
+import { Routes, Route, Navigate } from 'react-router-dom'
+import { AuthProvider } from './contexts/AuthContext'
+import Layout from './components/Layout'
+import Login from './components/Login'
+import ProtectedRoute from './components/ProtectedRoute'
+import PersonList from './components/PersonList'
+import ItemList from './components/ItemList'
+import WorkRecordList from './components/WorkRecordList'
+import Dashboard from './components/Dashboard'
+import Export from './components/Export'
+import Import from './components/Import'
+import AdminList from './components/AdminList'
+
+function App() {
+  return (
+    <AuthProvider>
+      <Routes>
+        <Route path="/login" element={<Login />} />
+        <Route
+          path="/"
+          element={
+            <ProtectedRoute>
+              <Layout />
+            </ProtectedRoute>
+          }
+        >
+          <Route index element={<Navigate to="/dashboard" replace />} />
+          <Route path="dashboard" element={<Dashboard />} />
+          <Route path="persons" element={<PersonList />} />
+          <Route path="items" element={<ItemList />} />
+          <Route path="work-records" element={<WorkRecordList />} />
+          <Route path="export" element={<Export />} />
+          <Route path="import" element={<Import />} />
+          <Route path="admins" element={<AdminList />} />
+        </Route>
+      </Routes>
+    </AuthProvider>
+  )
+}
+
+export default App

+ 129 - 0
frontend/src/components/AdminForm.jsx

@@ -0,0 +1,129 @@
+import { useEffect, useState } from 'react'
+import { Modal, Form, Input, message, Alert, Spin } from 'antd'
+import { adminApi } from '../services/api'
+
+function AdminForm({ visible, admin, onSuccess, onCancel }) {
+  const [form] = Form.useForm()
+  const [submitting, setSubmitting] = useState(false)
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState(null)
+  const isEdit = !!admin
+
+  useEffect(() => {
+    if (visible) {
+      setLoading(true)
+      setError(null)
+      if (admin) {
+        form.setFieldsValue({ username: admin.username, password: '' })
+      } else {
+        form.resetFields()
+      }
+      setLoading(false)
+    }
+  }, [visible, admin, form])
+
+  const handleOk = async () => {
+    try {
+      setError(null)
+      const values = await form.validateFields()
+      setSubmitting(true)
+      
+      if (isEdit) {
+        const updateData = { id: admin.id, username: values.username }
+        // Only include password if it was changed
+        if (values.password) {
+          updateData.password = values.password
+        }
+        await adminApi.update(updateData)
+        message.success('更新成功')
+      } else {
+        await adminApi.create(values)
+        message.success('创建成功')
+      }
+      
+      onSuccess()
+    } catch (error) {
+      if (error.errorFields) {
+        return
+      }
+      const errorMsg = error.message || '操作失败'
+      setError(errorMsg)
+      message.error(errorMsg)
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  const validateUsername = (_, value) => {
+    if (!value || !value.trim()) {
+      return Promise.reject(new Error('请输入用户名'))
+    }
+    return Promise.resolve()
+  }
+
+  const validatePassword = (_, value) => {
+    // For edit mode, password is optional (only validate if provided)
+    if (isEdit && !value) {
+      return Promise.resolve()
+    }
+    if (!value) {
+      return Promise.reject(new Error('请输入密码'))
+    }
+    if (value.length < 6) {
+      return Promise.reject(new Error('密码长度不能少于6个字符'))
+    }
+    return Promise.resolve()
+  }
+
+  return (
+    <Modal
+      title={isEdit ? '编辑管理员' : '新增管理员'}
+      open={visible}
+      onOk={handleOk}
+      onCancel={onCancel}
+      confirmLoading={submitting}
+      okButtonProps={{ disabled: loading }}
+      cancelButtonProps={{ disabled: submitting }}
+      destroyOnClose
+      okText="确定"
+      cancelText="取消"
+    >
+      <Spin spinning={loading}>
+        {error && (
+          <Alert
+            message={error}
+            type="error"
+            showIcon
+            style={{ marginBottom: 16 }}
+          />
+        )}
+        <Form
+          form={form}
+          layout="vertical"
+          autoComplete="off"
+        >
+          <Form.Item
+            name="username"
+            label="用户名"
+            rules={[{ validator: validateUsername }]}
+          >
+            <Input placeholder="请输入用户名" maxLength={50} disabled={loading} />
+          </Form.Item>
+          <Form.Item
+            name="password"
+            label={isEdit ? '密码(留空则不修改)' : '密码'}
+            rules={[{ validator: validatePassword }]}
+          >
+            <Input.Password 
+              placeholder={isEdit ? '留空则不修改密码' : '请输入密码(至少6个字符)'} 
+              maxLength={100}
+              disabled={loading}
+            />
+          </Form.Item>
+        </Form>
+      </Spin>
+    </Modal>
+  )
+}
+
+export default AdminForm

+ 196 - 0
frontend/src/components/AdminList.jsx

@@ -0,0 +1,196 @@
+import { useState, useEffect } from 'react'
+import { Table, Button, Space, message, Popconfirm, Alert } from 'antd'
+import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
+import { adminApi } from '../services/api'
+import AdminForm from './AdminForm'
+import { toBeijingDateTime } from '../utils/timeUtils'
+import { useAuth } from '../contexts/AuthContext'
+
+const MOBILE_BREAKPOINT = 768
+
+function AdminList() {
+  const [admins, setAdmins] = useState([])
+  const [loading, setLoading] = useState(false)
+  const [formVisible, setFormVisible] = useState(false)
+  const [editingAdmin, setEditingAdmin] = useState(null)
+  const [error, setError] = useState(null)
+  const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT)
+  const { admin: currentAdmin } = useAuth()
+
+  // Mobile detection
+  useEffect(() => {
+    const handleResize = () => {
+      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+    }
+    window.addEventListener('resize', handleResize)
+    return () => window.removeEventListener('resize', handleResize)
+  }, [])
+
+  const fetchAdmins = async () => {
+    setLoading(true)
+    setError(null)
+    try {
+      const response = await adminApi.getAll()
+      setAdmins(response.data || [])
+    } catch (error) {
+      const errorMsg = error.message || '获取管理员列表失败'
+      setError(errorMsg)
+      message.error(errorMsg)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  useEffect(() => {
+    fetchAdmins()
+  }, [])
+
+  const handleAdd = () => {
+    setEditingAdmin(null)
+    setFormVisible(true)
+  }
+
+  const handleEdit = (record) => {
+    setEditingAdmin(record)
+    setFormVisible(true)
+  }
+
+  const handleDelete = async (id) => {
+    try {
+      await adminApi.delete(id)
+      message.success('删除成功')
+      fetchAdmins()
+    } catch (error) {
+      message.error(error.message || '删除失败')
+    }
+  }
+
+  const handleFormSuccess = () => {
+    setFormVisible(false)
+    setEditingAdmin(null)
+    fetchAdmins()
+  }
+
+  const handleFormCancel = () => {
+    setFormVisible(false)
+    setEditingAdmin(null)
+  }
+
+
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      key: 'id',
+      width: isMobile ? undefined : 80
+    },
+    {
+      title: '用户名',
+      dataIndex: 'username',
+      key: 'username'
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'created_at',
+      key: 'created_at',
+      width: 180,
+      render: (text) => toBeijingDateTime(text),
+      responsive: ['md']
+    },
+    {
+      title: '更新时间',
+      dataIndex: 'updated_at',
+      key: 'updated_at',
+      width: 180,
+      render: (text) => toBeijingDateTime(text),
+      responsive: ['md']
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: isMobile ? undefined : 150,
+      render: (_, record) => {
+        const isSelf = currentAdmin && currentAdmin.id === record.id
+        return (
+          <Space size="small" wrap={isMobile}>
+            <Button
+              type="link"
+              icon={<EditOutlined />}
+              onClick={() => handleEdit(record)}
+              size={isMobile ? 'small' : 'middle'}
+              style={isMobile ? { padding: '0 4px' } : undefined}
+            >
+              {!isMobile && '编辑'}
+            </Button>
+            <Popconfirm
+              title="确认删除"
+              description="确定要删除这个管理员吗?"
+              onConfirm={() => handleDelete(record.id)}
+              okText="确定"
+              cancelText="取消"
+              disabled={isSelf}
+            >
+              <Button 
+                type="link" 
+                danger 
+                icon={<DeleteOutlined />}
+                disabled={isSelf}
+                title={isSelf ? '不能删除自己' : ''}
+                size={isMobile ? 'small' : 'middle'}
+                style={isMobile ? { padding: '0 4px' } : undefined}
+              >
+                {!isMobile && '删除'}
+              </Button>
+            </Popconfirm>
+          </Space>
+        )
+      }
+    }
+  ]
+
+  return (
+    <div>
+      {error && (
+        <Alert
+          message="加载错误"
+          description={error}
+          type="error"
+          showIcon
+          closable
+          style={{ marginBottom: 16 }}
+          onClose={() => setError(null)}
+        />
+      )}
+      <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
+        <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
+          新增管理员
+        </Button>
+        <Button icon={<ReloadOutlined />} onClick={fetchAdmins} loading={loading}>
+          刷新
+        </Button>
+      </div>
+      <Table
+        columns={columns}
+        dataSource={admins}
+        rowKey="id"
+        loading={loading}
+        tableLayout="auto"
+        size={isMobile ? 'small' : 'middle'}
+        pagination={{
+          showSizeChanger: !isMobile,
+          showQuickJumper: !isMobile,
+          showTotal: (total) => `共 ${total} 条`,
+          size: isMobile ? 'small' : 'default'
+        }}
+      />
+      <AdminForm
+        visible={formVisible}
+        admin={editingAdmin}
+        onSuccess={handleFormSuccess}
+        onCancel={handleFormCancel}
+      />
+    </div>
+  )
+}
+
+export default AdminList

+ 403 - 0
frontend/src/components/Dashboard.jsx

@@ -0,0 +1,403 @@
+import { useState, useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { Card, Row, Col, Statistic, Button, DatePicker, Table, Empty, Spin, message, Alert } from 'antd'
+import {
+  UserOutlined,
+  DollarOutlined,
+  FileAddOutlined,
+  AppstoreOutlined,
+  ReloadOutlined,
+  TrophyOutlined,
+  ShoppingOutlined
+} from '@ant-design/icons'
+import dayjs from 'dayjs'
+import { workRecordApi, personApi, itemApi } from '../services/api'
+
+const MOBILE_BREAKPOINT = 768
+
+function Dashboard() {
+  const navigate = useNavigate()
+  const [loading, setLoading] = useState(false)
+  const [countsLoading, setCountsLoading] = useState(false)
+  const [monthlyLoading, setMonthlyLoading] = useState(false)
+  const [selectedDate, setSelectedDate] = useState(dayjs())
+  const [selectedMonth, setSelectedMonth] = useState(dayjs())
+  const [summary, setSummary] = useState(null)
+  const [monthlySummary, setMonthlySummary] = useState(null)
+  const [personCount, setPersonCount] = useState(0)
+  const [itemCount, setItemCount] = useState(0)
+  const [error, setError] = useState(null)
+  const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT)
+
+  // Mobile detection
+  useEffect(() => {
+    const handleResize = () => {
+      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+    }
+    window.addEventListener('resize', handleResize)
+    return () => window.removeEventListener('resize', handleResize)
+  }, [])
+
+  // Fetch daily summary
+  const fetchDailySummary = async (date) => {
+    setLoading(true)
+    setError(null)
+    try {
+      const dateStr = date.format('YYYY-MM-DD')
+      const response = await workRecordApi.getDailySummary({ date: dateStr })
+      setSummary(response.data)
+    } catch (error) {
+      setError('获取日统计失败: ' + error.message)
+      message.error('获取日统计失败: ' + error.message)
+      setSummary(null)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  // Fetch monthly summary
+  const fetchMonthlySummary = async (date) => {
+    setMonthlyLoading(true)
+    try {
+      const year = date.year()
+      const month = date.month() + 1
+      const response = await workRecordApi.getMonthlySummary({ year, month })
+      setMonthlySummary(response.data)
+    } catch (error) {
+      message.error('获取月度统计失败: ' + error.message)
+      setMonthlySummary(null)
+    } finally {
+      setMonthlyLoading(false)
+    }
+  }
+
+  // Fetch counts for persons and items
+  const fetchCounts = async () => {
+    setCountsLoading(true)
+    try {
+      const [personsRes, itemsRes] = await Promise.all([
+        personApi.getAll(),
+        itemApi.getAll()
+      ])
+      setPersonCount(personsRes.data?.length || 0)
+      setItemCount(itemsRes.data?.length || 0)
+    } catch (error) {
+      console.error('Failed to fetch counts:', error)
+      message.error('获取统计数据失败')
+    } finally {
+      setCountsLoading(false)
+    }
+  }
+
+  useEffect(() => {
+    fetchDailySummary(selectedDate)
+    fetchMonthlySummary(selectedMonth)
+    fetchCounts()
+  }, [])
+
+  const handleRefresh = () => {
+    fetchDailySummary(selectedDate)
+    fetchMonthlySummary(selectedMonth)
+    fetchCounts()
+  }
+
+  const handleDateChange = (date) => {
+    if (date) {
+      setSelectedDate(date)
+      fetchDailySummary(date)
+    }
+  }
+
+  const handleMonthChange = (date) => {
+    if (date) {
+      setSelectedMonth(date)
+      fetchMonthlySummary(date)
+    }
+  }
+
+  const handleAddWorkRecord = () => {
+    navigate('/work-records')
+  }
+
+  // Table columns for person summary (daily)
+  const columns = [
+    {
+      title: '人员',
+      dataIndex: 'person_name',
+      key: 'person_name'
+    },
+    {
+      title: '总件数',
+      dataIndex: 'total_items',
+      key: 'total_items',
+      align: 'right'
+    },
+    {
+      title: '总金额',
+      dataIndex: 'total_value',
+      key: 'total_value',
+      align: 'right',
+      render: (value) => `¥${value.toFixed(2)}`
+    }
+  ]
+
+  // Table columns for top performers (monthly)
+  const topPerformersColumns = [
+    {
+      title: '#',
+      key: 'rank',
+      render: (_, __, index) => index + 1
+    },
+    {
+      title: '人员',
+      dataIndex: 'person_name',
+      key: 'person_name'
+    },
+    {
+      title: '收入',
+      dataIndex: 'earnings',
+      key: 'earnings',
+      align: 'right',
+      render: (value) => `¥${value.toFixed(2)}`
+    }
+  ]
+
+  // Table columns for item breakdown (monthly)
+  const itemBreakdownColumns = [
+    {
+      title: '物品',
+      dataIndex: 'item_name',
+      key: 'item_name'
+    },
+    {
+      title: '数量',
+      dataIndex: 'quantity',
+      key: 'quantity',
+      align: 'right'
+    },
+    {
+      title: '收入',
+      dataIndex: 'earnings',
+      key: 'earnings',
+      align: 'right',
+      render: (value) => `¥${value.toFixed(2)}`
+    }
+  ]
+
+  return (
+    <div>
+      {error && (
+        <Alert
+          message="加载错误"
+          description={error}
+          type="error"
+          showIcon
+          closable
+          style={{ marginBottom: 16 }}
+          onClose={() => setError(null)}
+        />
+      )}
+
+      {/* Monthly Report Section */}
+      <Card 
+        title={
+          <Row justify="space-between" align="middle" gutter={[8, 8]}>
+            <Col xs={24} sm="auto">月度报告</Col>
+            <Col xs={24} sm="auto" style={{ paddingBottom: 8 }}>
+              <DatePicker.MonthPicker
+                value={selectedMonth}
+                onChange={handleMonthChange}
+                allowClear={false}
+                placeholder="选择月份"
+                format="YYYY-MM"
+                style={{ width: isMobile ? '100%' : 'auto' }}
+              />
+            </Col>
+          </Row>
+        }
+        style={{ marginBottom: isMobile ? 12 : 24 }}
+      >
+        <Spin spinning={monthlyLoading}>
+          <Row gutter={[16, 16]} style={{ marginBottom: isMobile ? 12 : 24 }}>
+            <Col xs={24} sm={12}>
+              <Card>
+                <Statistic
+                  title="本月记录数"
+                  value={monthlySummary?.total_records || 0}
+                  prefix={<AppstoreOutlined />}
+                />
+              </Card>
+            </Col>
+            <Col xs={24} sm={12}>
+              <Card>
+                <Statistic
+                  title="本月总收入"
+                  value={monthlySummary?.total_earnings || 0}
+                  precision={2}
+                  prefix={<DollarOutlined />}
+                  suffix="元"
+                />
+              </Card>
+            </Col>
+          </Row>
+
+          <Row gutter={[16, 16]}>
+            <Col xs={24} md={12}>
+              <Card 
+                title={
+                  <span>
+                    <TrophyOutlined style={{ marginRight: 8 }} />
+                    业绩排名
+                  </span>
+                }
+                size="small"
+              >
+                {monthlySummary?.top_performers?.length > 0 ? (
+                  <Table
+                    columns={topPerformersColumns}
+                    dataSource={monthlySummary.top_performers}
+                    rowKey="person_id"
+                    pagination={false}
+                    size="small"
+                    tableLayout="auto"
+                  />
+                ) : (
+                  <Empty description="暂无数据" />
+                )}
+              </Card>
+            </Col>
+            <Col xs={24} md={12}>
+              <Card 
+                title={
+                  <span>
+                    <ShoppingOutlined style={{ marginRight: 8 }} />
+                    物品收入明细
+                  </span>
+                }
+                size="small"
+              >
+                {monthlySummary?.item_breakdown?.length > 0 ? (
+                  <Table
+                    columns={itemBreakdownColumns}
+                    dataSource={monthlySummary.item_breakdown}
+                    rowKey="item_id"
+                    pagination={false}
+                    size="small"
+                    tableLayout="auto"
+                  />
+                ) : (
+                  <Empty description="暂无数据" />
+                )}
+              </Card>
+            </Col>
+          </Row>
+        </Spin>
+      </Card>
+
+      <div style={{ marginBottom: isMobile ? 12 : 24, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
+        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
+          <DatePicker
+            value={selectedDate}
+            onChange={handleDateChange}
+            allowClear={false}
+            style={{ width: isMobile ? '100%' : 200 }}
+          />
+          <Button
+            icon={<ReloadOutlined />}
+            onClick={handleRefresh}
+            loading={loading || countsLoading}
+          >
+            刷新
+          </Button>
+        </div>
+        <Button
+          type="primary"
+          icon={<FileAddOutlined />}
+          onClick={handleAddWorkRecord}
+        >
+          添加工作记录
+        </Button>
+      </div>
+
+      <Row gutter={[16, 16]} style={{ marginBottom: isMobile ? 12 : 24 }}>
+        <Col xs={12} sm={12} md={6}>
+          <Card>
+            <Statistic
+              title="今日总件数"
+              value={summary?.grand_total_items || 0}
+              prefix={<AppstoreOutlined />}
+              loading={loading}
+            />
+          </Card>
+        </Col>
+        <Col xs={12} sm={12} md={6}>
+          <Card>
+            <Statistic
+              title="今日总金额"
+              value={summary?.grand_total_value || 0}
+              precision={2}
+              prefix={<DollarOutlined />}
+              suffix="元"
+              loading={loading}
+            />
+          </Card>
+        </Col>
+        <Col xs={12} sm={12} md={6}>
+          <Card>
+            <Statistic
+              title="参与人数"
+              value={summary?.summary?.length || 0}
+              prefix={<UserOutlined />}
+              loading={loading}
+            />
+          </Card>
+        </Col>
+        <Col xs={12} sm={12} md={6}>
+          <Card>
+            <Statistic
+              title="系统人员/物品"
+              value={personCount}
+              suffix={`/ ${itemCount}`}
+              prefix={<UserOutlined />}
+              loading={countsLoading}
+            />
+          </Card>
+        </Col>
+      </Row>
+
+      <Card title={`${selectedDate.format('YYYY-MM-DD')} 工作统计详情`}>
+        <Spin spinning={loading}>
+          {summary?.summary?.length > 0 ? (
+            <Table
+              columns={columns}
+              dataSource={summary.summary}
+              rowKey="person_id"
+              pagination={false}
+              tableLayout="auto"
+              size={isMobile ? 'small' : 'middle'}
+              summary={() => (
+                <Table.Summary fixed>
+                  <Table.Summary.Row>
+                    <Table.Summary.Cell index={0}>
+                      <strong>合计</strong>
+                    </Table.Summary.Cell>
+                    <Table.Summary.Cell index={1} align="right">
+                      <strong>{summary.grand_total_items}</strong>
+                    </Table.Summary.Cell>
+                    <Table.Summary.Cell index={2} align="right">
+                      <strong>¥{summary.grand_total_value.toFixed(2)}</strong>
+                    </Table.Summary.Cell>
+                  </Table.Summary.Row>
+                </Table.Summary>
+              )}
+            />
+          ) : (
+            <Empty description="当日暂无工作记录" />
+          )}
+        </Spin>
+      </Card>
+    </div>
+  )
+}
+
+export default Dashboard

+ 199 - 0
frontend/src/components/Export.jsx

@@ -0,0 +1,199 @@
+import { useState } from 'react'
+import { Card, Row, Col, DatePicker, Button, Space, message, Divider, Alert } from 'antd'
+import { DownloadOutlined, FileExcelOutlined } from '@ant-design/icons'
+import dayjs from 'dayjs'
+import { exportApi } from '../services/api'
+
+function Export() {
+  const [monthValue, setMonthValue] = useState(dayjs())
+  const [yearValue, setYearValue] = useState(dayjs())
+  const [monthlyLoading, setMonthlyLoading] = useState(false)
+  const [yearlyLoading, setYearlyLoading] = useState(false)
+  const [error, setError] = useState(null)
+
+  // Download file helper
+  const downloadFile = (blob, filename) => {
+    const url = window.URL.createObjectURL(blob)
+    const link = document.createElement('a')
+    link.href = url
+    link.download = filename
+    document.body.appendChild(link)
+    link.click()
+    document.body.removeChild(link)
+    window.URL.revokeObjectURL(url)
+  }
+
+  // Generate export timestamp for filename (YYMMDDHHmm format)
+  const getExportTimestamp = () => {
+    const now = dayjs()
+    return now.format('YYMMDDHHmm')
+  }
+
+  // Handle monthly export
+  const handleMonthlyExport = async () => {
+    if (!monthValue) {
+      message.warning('请选择月份')
+      return
+    }
+
+    setMonthlyLoading(true)
+    setError(null)
+    try {
+      const year = monthValue.year()
+      const month = monthValue.month() + 1
+      const response = await exportApi.monthly(year, month)
+      
+      const timestamp = getExportTimestamp()
+      const filename = `work_report_${year}-${String(month).padStart(2, '0')}_${timestamp}.xlsx`
+      downloadFile(response.data, filename)
+      message.success('月度报表导出成功')
+    } catch (error) {
+      const errorMsg = '导出失败: ' + (error.message || '未知错误')
+      setError(errorMsg)
+      message.error(errorMsg)
+    } finally {
+      setMonthlyLoading(false)
+    }
+  }
+
+  // Handle yearly export
+  const handleYearlyExport = async () => {
+    if (!yearValue) {
+      message.warning('请选择年份')
+      return
+    }
+
+    setYearlyLoading(true)
+    setError(null)
+    try {
+      const year = yearValue.year()
+      const response = await exportApi.yearly(year)
+      
+      const timestamp = getExportTimestamp()
+      const filename = `work_report_${year}_${timestamp}.xlsx`
+      downloadFile(response.data, filename)
+      message.success('年度报表导出成功')
+    } catch (error) {
+      const errorMsg = '导出失败: ' + (error.message || '未知错误')
+      setError(errorMsg)
+      message.error(errorMsg)
+    } finally {
+      setYearlyLoading(false)
+    }
+  }
+
+  return (
+    <div>
+      {error && (
+        <Alert
+          message="导出错误"
+          description={error}
+          type="error"
+          showIcon
+          closable
+          style={{ marginBottom: 16 }}
+          onClose={() => setError(null)}
+        />
+      )}
+      <Row gutter={[24, 24]}>
+        <Col xs={24} md={12}>
+          <Card
+            title={
+              <Space>
+                <FileExcelOutlined style={{ color: '#52c41a' }} />
+                月度报表导出
+              </Space>
+            }
+          >
+            <p style={{ color: '#666', marginBottom: 16 }}>
+              导出指定月份的工作记录明细和汇总统计
+            </p>
+            <Space direction="vertical" size="middle" style={{ width: '100%' }}>
+              <div>
+                <span style={{ marginRight: 8 }}>选择月份:</span>
+                <DatePicker
+                  picker="month"
+                  value={monthValue}
+                  onChange={setMonthValue}
+                  allowClear={false}
+                  style={{ width: 200 }}
+                  placeholder="选择月份"
+                  format="YYYY-MM"
+                />
+              </div>
+              <Button
+                type="primary"
+                icon={<DownloadOutlined />}
+                onClick={handleMonthlyExport}
+                loading={monthlyLoading}
+                block
+              >
+                导出月度报表
+              </Button>
+            </Space>
+          </Card>
+        </Col>
+
+        <Col xs={24} md={12}>
+          <Card
+            title={
+              <Space>
+                <FileExcelOutlined style={{ color: '#1890ff' }} />
+                年度报表导出
+              </Space>
+            }
+          >
+            <p style={{ color: '#666', marginBottom: 16 }}>
+              导出指定年份的工作记录明细和按月汇总统计
+            </p>
+            <Space direction="vertical" size="middle" style={{ width: '100%' }}>
+              <div>
+                <span style={{ marginRight: 8 }}>选择年份:</span>
+                <DatePicker
+                  picker="year"
+                  value={yearValue}
+                  onChange={setYearValue}
+                  allowClear={false}
+                  style={{ width: 200 }}
+                  placeholder="选择年份"
+                />
+              </div>
+              <Button
+                type="primary"
+                icon={<DownloadOutlined />}
+                onClick={handleYearlyExport}
+                loading={yearlyLoading}
+                block
+              >
+                导出年度报表
+              </Button>
+            </Space>
+          </Card>
+        </Col>
+      </Row>
+
+      <Divider />
+
+      <Card title="报表说明" size="small">
+        <Row gutter={[16, 16]}>
+          <Col xs={24} md={12}>
+            <h4>月度报表包含:</h4>
+            <ul style={{ paddingLeft: 20, color: '#666' }}>
+              <li>明细表: 人员、日期、物品、单价、数量、总价</li>
+              <li>月度汇总: 每人总金额及合计</li>
+            </ul>
+          </Col>
+          <Col xs={24} md={12}>
+            <h4>年度报表包含:</h4>
+            <ul style={{ paddingLeft: 20, color: '#666' }}>
+              <li>明细表: 人员、日期、物品、单价、数量、总价</li>
+              <li>年度汇总: 每人按月统计及年度合计</li>
+            </ul>
+          </Col>
+        </Row>
+      </Card>
+    </div>
+  )
+}
+
+export default Export

+ 266 - 0
frontend/src/components/Import.jsx

@@ -0,0 +1,266 @@
+import { useState } from 'react'
+import { Card, Row, Col, Button, Space, message, Alert, Upload, List, Progress, Divider } from 'antd'
+import { DownloadOutlined, UploadOutlined, FileExcelOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
+import { importApi } from '../services/api'
+
+function Import() {
+  const [templateLoading, setTemplateLoading] = useState(false)
+  const [uploadLoading, setUploadLoading] = useState(false)
+  const [uploadProgress, setUploadProgress] = useState(0)
+  const [errors, setErrors] = useState([])
+  const [successCount, setSuccessCount] = useState(null)
+  const [uploadStatus, setUploadStatus] = useState(null) // 'success' | 'error' | null
+
+  // Download file helper
+  const downloadFile = (blob, filename) => {
+    const url = window.URL.createObjectURL(blob)
+    const link = document.createElement('a')
+    link.href = url
+    link.download = filename
+    document.body.appendChild(link)
+    link.click()
+    document.body.removeChild(link)
+    window.URL.revokeObjectURL(url)
+  }
+
+  // Handle template download
+  const handleDownloadTemplate = async () => {
+    setTemplateLoading(true)
+    try {
+      const response = await importApi.downloadTemplate()
+      downloadFile(response.data, 'import_template.xlsx')
+      message.success('模板下载成功')
+    } catch (error) {
+      message.error('模板下载失败: ' + (error.message || '未知错误'))
+    } finally {
+      setTemplateLoading(false)
+    }
+  }
+
+  // Handle file upload
+  const handleUpload = async (file) => {
+    // Reset state
+    setErrors([])
+    setSuccessCount(null)
+    setUploadStatus(null)
+    setUploadProgress(0)
+    setUploadLoading(true)
+
+    // Validate file type
+    const isXlsx = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
+                   file.name.endsWith('.xlsx')
+    if (!isXlsx) {
+      message.error('请上传XLSX格式的文件')
+      setUploadLoading(false)
+      return false
+    }
+
+    // Validate file size (5MB limit)
+    const maxSize = 5 * 1024 * 1024
+    if (file.size > maxSize) {
+      message.error('文件大小超过限制(最大5MB)')
+      setUploadLoading(false)
+      return false
+    }
+
+    // Simulate progress for better UX
+    const progressInterval = setInterval(() => {
+      setUploadProgress(prev => {
+        if (prev >= 90) {
+          clearInterval(progressInterval)
+          return prev
+        }
+        return prev + 10
+      })
+    }, 100)
+
+    try {
+      const response = await importApi.upload(file)
+      clearInterval(progressInterval)
+      setUploadProgress(100)
+
+      if (response.data.success) {
+        setSuccessCount(response.data.count)
+        setUploadStatus('success')
+        message.success(`成功导入 ${response.data.count} 条记录`)
+      } else {
+        setErrors(response.data.errors || ['导入失败'])
+        setUploadStatus('error')
+      }
+    } catch (error) {
+      clearInterval(progressInterval)
+      setUploadProgress(0)
+      
+      // Parse error response
+      let errorMessages = []
+      if (error.response?.data?.errors) {
+        errorMessages = error.response.data.errors
+      } else if (error.response?.data?.error) {
+        errorMessages = [error.response.data.error]
+      } else {
+        errorMessages = [error.message || '上传失败']
+      }
+      
+      setErrors(errorMessages)
+      setUploadStatus('error')
+    } finally {
+      setUploadLoading(false)
+    }
+
+    // Prevent default upload behavior
+    return false
+  }
+
+  // Reset upload state
+  const handleReset = () => {
+    setErrors([])
+    setSuccessCount(null)
+    setUploadStatus(null)
+    setUploadProgress(0)
+  }
+
+  return (
+    <div>
+      <Row gutter={[24, 24]}>
+        {/* Template Download Card */}
+        <Col xs={24} md={12}>
+          <Card
+            title={
+              <Space>
+                <FileExcelOutlined style={{ color: '#52c41a' }} />
+                下载导入模板
+              </Space>
+            }
+          >
+            <p style={{ color: '#666', marginBottom: 16 }}>
+              下载标准导入模板,按照模板格式填写数据后上传
+            </p>
+            <Button
+              type="primary"
+              icon={<DownloadOutlined />}
+              onClick={handleDownloadTemplate}
+              loading={templateLoading}
+              block
+            >
+              下载导入模板
+            </Button>
+          </Card>
+        </Col>
+
+        {/* File Upload Card */}
+        <Col xs={24} md={12}>
+          <Card
+            title={
+              <Space>
+                <UploadOutlined style={{ color: '#1890ff' }} />
+                上传数据文件
+              </Space>
+            }
+          >
+            <p style={{ color: '#666', marginBottom: 16 }}>
+              选择填写好的XLSX文件进行批量导入
+            </p>
+            <Upload
+              accept=".xlsx"
+              beforeUpload={handleUpload}
+              showUploadList={false}
+              disabled={uploadLoading}
+            >
+              <Button
+                type="primary"
+                icon={<UploadOutlined />}
+                loading={uploadLoading}
+                block
+              >
+                选择文件上传
+              </Button>
+            </Upload>
+            
+            {/* Upload Progress */}
+            {uploadLoading && (
+              <Progress 
+                percent={uploadProgress} 
+                status="active" 
+                style={{ marginTop: 16 }}
+              />
+            )}
+          </Card>
+        </Col>
+      </Row>
+
+      {/* Upload Results */}
+      {uploadStatus && (
+        <>
+          <Divider />
+          {uploadStatus === 'success' && (
+            <Alert
+              message="导入成功"
+              description={`成功导入 ${successCount} 条工作记录`}
+              type="success"
+              showIcon
+              icon={<CheckCircleOutlined />}
+              action={
+                <Button size="small" onClick={handleReset}>
+                  继续导入
+                </Button>
+              }
+            />
+          )}
+          
+          {uploadStatus === 'error' && errors.length > 0 && (
+            <Card
+              title={
+                <Space>
+                  <CloseCircleOutlined style={{ color: '#ff4d4f' }} />
+                  <span style={{ color: '#ff4d4f' }}>导入失败</span>
+                </Space>
+              }
+
+            >
+              <div style={{ maxHeight: 200, overflowY: 'auto' }}>
+                <List
+                  size="small"
+                  dataSource={errors}
+                  renderItem={(item) => (
+                    <List.Item style={{ color: '#ff4d4f' }}>
+                      <CloseCircleOutlined style={{ marginRight: 8 }} />
+                      {item}
+                    </List.Item>
+                  )}
+                />
+              </div>
+            </Card>
+          )}
+        </>
+      )}
+
+      <Divider />
+
+      {/* Instructions Card */}
+      <Card title="导入说明" size="small">
+        <Row gutter={[16, 16]}>
+          <Col xs={24} md={12}>
+            <h4>模板格式:</h4>
+            <ul style={{ paddingLeft: 20, color: '#666' }}>
+              <li>人员姓名: 必须是系统中已存在的人员</li>
+              <li>物品名称: 必须是系统中已存在的物品</li>
+              <li>工作日期: 格式为 YYYY-MM-DD</li>
+              <li>数量: 必须为正数</li>
+            </ul>
+          </Col>
+          <Col xs={24} md={12}>
+            <h4>注意事项:</h4>
+            <ul style={{ paddingLeft: 20, color: '#666' }}>
+              <li>文件大小不超过5MB</li>
+              <li>仅支持XLSX格式文件</li>
+              <li>如有任何数据错误,整个导入将被取消</li>
+              <li>请确保所有人员和物品已在系统中创建</li>
+            </ul>
+          </Col>
+        </Row>
+      </Card>
+    </div>
+  )
+}
+
+export default Import

+ 127 - 0
frontend/src/components/ItemForm.jsx

@@ -0,0 +1,127 @@
+import { useEffect, useState } from 'react'
+import { Modal, Form, Input, InputNumber, message, Spin } from 'antd'
+import { itemApi } from '../services/api'
+
+const MOBILE_BREAKPOINT = 768
+
+function ItemForm({ visible, item, onSuccess, onCancel }) {
+  const [form] = Form.useForm()
+  const [submitting, setSubmitting] = useState(false)
+  const [loading, setLoading] = useState(false)
+  const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT)
+  const isEdit = !!item
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+    }
+    window.addEventListener('resize', handleResize)
+    return () => window.removeEventListener('resize', handleResize)
+  }, [])
+
+  useEffect(() => {
+    if (visible) {
+      setLoading(true)
+      if (item) {
+        form.setFieldsValue({
+          name: item.name,
+          unit_price: item.unit_price
+        })
+      } else {
+        form.resetFields()
+      }
+      setLoading(false)
+    }
+  }, [visible, item, form])
+
+  const handleOk = async () => {
+    try {
+      const values = await form.validateFields()
+      setSubmitting(true)
+      
+      if (isEdit) {
+        await itemApi.update({ id: item.id, ...values })
+        message.success('更新成功')
+      } else {
+        await itemApi.create(values)
+        message.success('创建成功')
+      }
+      
+      onSuccess()
+    } catch (error) {
+      if (error.errorFields) {
+        return
+      }
+      message.error(error.message || '操作失败')
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  const validateName = (_, value) => {
+    if (!value || !value.trim()) {
+      return Promise.reject(new Error('请输入物品名称'))
+    }
+    return Promise.resolve()
+  }
+
+  const validateUnitPrice = (_, value) => {
+    if (value === null || value === undefined || value === '') {
+      return Promise.reject(new Error('请输入单价'))
+    }
+    if (typeof value !== 'number' || value <= 0) {
+      return Promise.reject(new Error('单价必须是正数'))
+    }
+    return Promise.resolve()
+  }
+
+  return (
+    <Modal
+      title={isEdit ? '编辑物品' : '新增物品'}
+      open={visible}
+      onOk={handleOk}
+      onCancel={onCancel}
+      confirmLoading={submitting}
+      okButtonProps={{ disabled: loading }}
+      cancelButtonProps={{ disabled: submitting }}
+      destroyOnClose
+      okText="确定"
+      cancelText="取消"
+      width={isMobile ? '100%' : 520}
+      style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 32px)', margin: '0 auto' } : undefined}
+    >
+      <Spin spinning={loading}>
+        <Form
+          form={form}
+          layout="vertical"
+          autoComplete="off"
+        >
+          <Form.Item
+            name="name"
+            label="物品名称"
+            rules={[{ validator: validateName }]}
+          >
+            <Input placeholder="请输入物品名称" maxLength={50} disabled={loading} />
+          </Form.Item>
+          <Form.Item
+            name="unit_price"
+            label="单价"
+            rules={[{ validator: validateUnitPrice }]}
+          >
+            <InputNumber
+              placeholder="请输入单价"
+              min={0.01}
+              step={0.01}
+              precision={2}
+              style={{ width: '100%' }}
+              prefix="¥"
+              disabled={loading}
+            />
+          </Form.Item>
+        </Form>
+      </Spin>
+    </Modal>
+  )
+}
+
+export default ItemForm

+ 180 - 0
frontend/src/components/ItemList.jsx

@@ -0,0 +1,180 @@
+import { useState, useEffect } from 'react'
+import { Table, Button, Space, message, Popconfirm, Alert } from 'antd'
+import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
+import { itemApi } from '../services/api'
+import ItemForm from './ItemForm'
+import { toBeijingDateTime } from '../utils/timeUtils'
+
+const MOBILE_BREAKPOINT = 768
+
+function ItemList() {
+  const [items, setItems] = useState([])
+  const [loading, setLoading] = useState(false)
+  const [formVisible, setFormVisible] = useState(false)
+  const [editingItem, setEditingItem] = useState(null)
+  const [error, setError] = useState(null)
+  const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT)
+
+  // Mobile detection
+  useEffect(() => {
+    const handleResize = () => {
+      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+    }
+    window.addEventListener('resize', handleResize)
+    return () => window.removeEventListener('resize', handleResize)
+  }, [])
+
+  const fetchItems = async () => {
+    setLoading(true)
+    setError(null)
+    try {
+      const response = await itemApi.getAll()
+      setItems(response.data || [])
+    } catch (error) {
+      const errorMsg = error.message || '获取物品列表失败'
+      setError(errorMsg)
+      message.error(errorMsg)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  useEffect(() => {
+    fetchItems()
+  }, [])
+
+  const handleAdd = () => {
+    setEditingItem(null)
+    setFormVisible(true)
+  }
+
+  const handleEdit = (record) => {
+    setEditingItem(record)
+    setFormVisible(true)
+  }
+
+  const handleDelete = async (id) => {
+    try {
+      await itemApi.delete(id)
+      message.success('删除成功')
+      fetchItems()
+    } catch (error) {
+      message.error(error.message || '删除失败')
+    }
+  }
+
+  const handleFormSuccess = () => {
+    setFormVisible(false)
+    setEditingItem(null)
+    fetchItems()
+  }
+
+  const handleFormCancel = () => {
+    setFormVisible(false)
+    setEditingItem(null)
+  }
+
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      key: 'id',
+      width: isMobile ? undefined : 80
+    },
+    {
+      title: '物品名称',
+      dataIndex: 'name',
+      key: 'name'
+    },
+    {
+      title: '单价',
+      dataIndex: 'unit_price',
+      key: 'unit_price',
+      width: isMobile ? undefined : 120,
+      render: (value) => `¥${Number(value).toFixed(2)}`
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'created_at',
+      key: 'created_at',
+      width: 180,
+      render: (text) => toBeijingDateTime(text),
+      responsive: ['md']
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: isMobile ? undefined : 150,
+      render: (_, record) => (
+        <Space size="small" wrap={isMobile}>
+          <Button
+            type="link"
+            icon={<EditOutlined />}
+            onClick={() => handleEdit(record)}
+            size={isMobile ? 'small' : 'middle'}
+            style={isMobile ? { padding: '0 4px' } : undefined}
+          >
+            {!isMobile && '编辑'}
+          </Button>
+          <Popconfirm
+            title="确认删除"
+            description="确定要删除这个物品吗?"
+            onConfirm={() => handleDelete(record.id)}
+            okText="确定"
+            cancelText="取消"
+          >
+            <Button type="link" danger icon={<DeleteOutlined />} size={isMobile ? 'small' : 'middle'} style={isMobile ? { padding: '0 4px' } : undefined}>
+              {!isMobile && '删除'}
+            </Button>
+          </Popconfirm>
+        </Space>
+      )
+    }
+  ]
+
+  return (
+    <div>
+      {error && (
+        <Alert
+          message="加载错误"
+          description={error}
+          type="error"
+          showIcon
+          closable
+          style={{ marginBottom: 16 }}
+          onClose={() => setError(null)}
+        />
+      )}
+      <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
+        <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
+          新增物品
+        </Button>
+        <Button icon={<ReloadOutlined />} onClick={fetchItems} loading={loading}>
+          刷新
+        </Button>
+      </div>
+      <Table
+        columns={columns}
+        dataSource={items}
+        rowKey="id"
+        loading={loading}
+        tableLayout="auto"
+        size={isMobile ? 'small' : 'middle'}
+        pagination={{
+          showSizeChanger: !isMobile,
+          showQuickJumper: !isMobile,
+          showTotal: (total) => `共 ${total} 条`,
+          size: isMobile ? 'small' : 'default'
+        }}
+      />
+      <ItemForm
+        visible={formVisible}
+        item={editingItem}
+        onSuccess={handleFormSuccess}
+        onCancel={handleFormCancel}
+      />
+    </div>
+  )
+}
+
+export default ItemList

+ 225 - 0
frontend/src/components/Layout.jsx

@@ -0,0 +1,225 @@
+import { useState, useEffect } from 'react'
+import { Outlet, useNavigate, useLocation } from 'react-router-dom'
+import { Layout as AntLayout, Menu, Button, Drawer, Popconfirm } from 'antd'
+import {
+  DashboardOutlined,
+  UserOutlined,
+  AppstoreOutlined,
+  FileTextOutlined,
+  ExportOutlined,
+  ImportOutlined,
+  MenuFoldOutlined,
+  MenuUnfoldOutlined,
+  TeamOutlined,
+  LogoutOutlined,
+  MenuOutlined
+} from '@ant-design/icons'
+import { useAuth } from '../contexts/AuthContext'
+
+const { Header, Sider, Content } = AntLayout
+
+const MOBILE_BREAKPOINT = 768
+
+const menuItems = [
+  {
+    key: '/dashboard',
+    icon: <DashboardOutlined />,
+    label: '仪表盘'
+  },
+  {
+    key: '/persons',
+    icon: <UserOutlined />,
+    label: '人员管理'
+  },
+  {
+    key: '/items',
+    icon: <AppstoreOutlined />,
+    label: '物品管理'
+  },
+  {
+    key: '/work-records',
+    icon: <FileTextOutlined />,
+    label: '工作记录'
+  },
+  {
+    key: '/export',
+    icon: <ExportOutlined />,
+    label: '导出报表'
+  },
+  {
+    key: '/import',
+    icon: <ImportOutlined />,
+    label: '导入数据'
+  },
+  {
+    key: '/admins',
+    icon: <TeamOutlined />,
+    label: '管理员管理'
+  }
+]
+
+function Layout() {
+  const [collapsed, setCollapsed] = useState(false)
+  const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT)
+  const [drawerVisible, setDrawerVisible] = useState(false)
+  const navigate = useNavigate()
+  const location = useLocation()
+  const { logout, admin } = useAuth()
+
+  // Mobile detection with resize listener
+  useEffect(() => {
+    const handleResize = () => {
+      const mobile = window.innerWidth < MOBILE_BREAKPOINT
+      setIsMobile(mobile)
+      if (!mobile) {
+        setDrawerVisible(false)
+      }
+    }
+
+    window.addEventListener('resize', handleResize)
+    return () => window.removeEventListener('resize', handleResize)
+  }, [])
+
+  const handleMenuClick = ({ key }) => {
+    navigate(key)
+    if (isMobile) {
+      setDrawerVisible(false)
+    }
+  }
+
+  const handleLogout = () => {
+    logout()
+    navigate('/login')
+  }
+
+  const toggleDrawer = () => {
+    setDrawerVisible(!drawerVisible)
+  }
+
+  const menuContent = (
+    <Menu
+      theme="dark"
+      mode="inline"
+      selectedKeys={[location.pathname]}
+      items={menuItems}
+      onClick={handleMenuClick}
+    />
+  )
+
+  const logoContent = (
+    <div style={{
+      height: 32,
+      margin: 16,
+      background: 'rgba(255, 255, 255, 0.2)',
+      borderRadius: 6,
+      display: 'flex',
+      alignItems: 'center',
+      justifyContent: 'center',
+      color: '#fff',
+      fontWeight: 'bold',
+      fontSize: collapsed && !isMobile ? 14 : 16
+    }}>
+      {collapsed && !isMobile ? '统计' : '工作统计系统'}
+    </div>
+  )
+
+  return (
+    <AntLayout style={{ minHeight: '100vh' }}>
+      {/* Mobile Drawer */}
+      {isMobile ? (
+        <Drawer
+          placement="left"
+          onClose={() => setDrawerVisible(false)}
+          open={drawerVisible}
+          width={250}
+          bodyStyle={{ padding: 0, background: '#001529' }}
+          headerStyle={{ display: 'none' }}
+        >
+          {logoContent}
+          {menuContent}
+        </Drawer>
+      ) : (
+        <Sider 
+          trigger={null} 
+          collapsible 
+          collapsed={collapsed}
+          style={{
+            overflow: 'auto',
+            height: '100vh',
+            position: 'fixed',
+            left: 0,
+            top: 0,
+            bottom: 0
+          }}
+        >
+          {logoContent}
+          {menuContent}
+        </Sider>
+      )}
+      <AntLayout style={{ marginLeft: isMobile ? 0 : (collapsed ? 80 : 200), transition: 'margin-left 0.2s' }}>
+        <Header style={{
+          padding: '0 16px',
+          background: '#fff',
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'space-between',
+          position: 'sticky',
+          top: 0,
+          zIndex: 1
+        }}>
+          {isMobile ? (
+            <Button
+              type="text"
+              icon={<MenuOutlined />}
+              onClick={toggleDrawer}
+              style={{ fontSize: 18, minWidth: 44, minHeight: 44 }}
+              className="mobile-menu-btn"
+            />
+          ) : (
+            collapsed ? (
+              <MenuUnfoldOutlined
+                style={{ fontSize: 18, cursor: 'pointer' }}
+                onClick={() => setCollapsed(false)}
+              />
+            ) : (
+              <MenuFoldOutlined
+                style={{ fontSize: 18, cursor: 'pointer' }}
+                onClick={() => setCollapsed(true)}
+              />
+            )
+          )}
+          <div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? 8 : 16 }}>
+            {admin && !isMobile && <span style={{ color: '#666' }}>欢迎, {admin.username}</span>}
+            <Popconfirm
+              title="确认登出"
+              description="确定要退出登录吗?"
+              onConfirm={handleLogout}
+              okText="确定"
+              cancelText="取消"
+            >
+              <Button 
+                type="text" 
+                icon={<LogoutOutlined />}
+                style={{ minWidth: 44, minHeight: 44 }}
+                className="mobile-touch-target"
+              >
+                {!isMobile && '登出'}
+              </Button>
+            </Popconfirm>
+          </div>
+        </Header>
+        <Content style={{
+          margin: isMobile ? 12 : 24,
+          padding: isMobile ? 12 : 24,
+          background: '#fff',
+          borderRadius: 8,
+          minHeight: 280
+        }}>
+          <Outlet />
+        </Content>
+      </AntLayout>
+    </AntLayout>
+  )
+}
+
+export default Layout

+ 94 - 0
frontend/src/components/Login.jsx

@@ -0,0 +1,94 @@
+import { useState } from 'react'
+import { Form, Input, Button, Card, Alert, Typography } from 'antd'
+import { UserOutlined, LockOutlined } from '@ant-design/icons'
+import { useNavigate } from 'react-router-dom'
+import { useAuth } from '../contexts/AuthContext'
+
+const { Title } = Typography
+
+function Login() {
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState('')
+  const { login } = useAuth()
+  const navigate = useNavigate()
+
+  const onFinish = async (values) => {
+    setLoading(true)
+    setError('')
+    
+    const result = await login(values.username, values.password)
+    
+    if (result.success) {
+      navigate('/dashboard')
+    } else {
+      setError(result.error || '登录失败,请检查用户名和密码')
+    }
+    
+    setLoading(false)
+  }
+
+  return (
+    <div style={{
+      display: 'flex',
+      justifyContent: 'center',
+      alignItems: 'center',
+      minHeight: '100vh',
+      background: '#f0f2f5'
+    }}>
+      <Card style={{ width: 400, boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}>
+        <Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
+          工作统计系统
+        </Title>
+        
+        {error && (
+          <Alert
+            message={error}
+            type="error"
+            showIcon
+            style={{ marginBottom: 16 }}
+          />
+        )}
+        
+        <Form
+          name="login"
+          onFinish={onFinish}
+          autoComplete="off"
+          size="large"
+        >
+          <Form.Item
+            name="username"
+            rules={[{ required: true, message: '请输入用户名' }]}
+          >
+            <Input
+              prefix={<UserOutlined />}
+              placeholder="用户名"
+            />
+          </Form.Item>
+
+          <Form.Item
+            name="password"
+            rules={[{ required: true, message: '请输入密码' }]}
+          >
+            <Input.Password
+              prefix={<LockOutlined />}
+              placeholder="密码"
+            />
+          </Form.Item>
+
+          <Form.Item>
+            <Button
+              type="primary"
+              htmlType="submit"
+              loading={loading}
+              block
+            >
+              登录
+            </Button>
+          </Form.Item>
+        </Form>
+      </Card>
+    </div>
+  )
+}
+
+export default Login

+ 99 - 0
frontend/src/components/PersonForm.jsx

@@ -0,0 +1,99 @@
+import { useEffect, useState } from 'react'
+import { Modal, Form, Input, message, Spin } from 'antd'
+import { personApi } from '../services/api'
+
+const MOBILE_BREAKPOINT = 768
+
+function PersonForm({ visible, person, onSuccess, onCancel }) {
+  const [form] = Form.useForm()
+  const [submitting, setSubmitting] = useState(false)
+  const [loading, setLoading] = useState(false)
+  const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT)
+  const isEdit = !!person
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+    }
+    window.addEventListener('resize', handleResize)
+    return () => window.removeEventListener('resize', handleResize)
+  }, [])
+
+  useEffect(() => {
+    if (visible) {
+      setLoading(true)
+      if (person) {
+        form.setFieldsValue({ name: person.name })
+      } else {
+        form.resetFields()
+      }
+      setLoading(false)
+    }
+  }, [visible, person, form])
+
+  const handleOk = async () => {
+    try {
+      const values = await form.validateFields()
+      setSubmitting(true)
+      
+      if (isEdit) {
+        await personApi.update({ id: person.id, ...values })
+        message.success('更新成功')
+      } else {
+        await personApi.create(values)
+        message.success('创建成功')
+      }
+      
+      onSuccess()
+    } catch (error) {
+      if (error.errorFields) {
+        return
+      }
+      message.error(error.message || '操作失败')
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  const validateName = (_, value) => {
+    if (!value || !value.trim()) {
+      return Promise.reject(new Error('请输入人员姓名'))
+    }
+    return Promise.resolve()
+  }
+
+  return (
+    <Modal
+      title={isEdit ? '编辑人员' : '新增人员'}
+      open={visible}
+      onOk={handleOk}
+      onCancel={onCancel}
+      confirmLoading={submitting}
+      okButtonProps={{ disabled: loading }}
+      cancelButtonProps={{ disabled: submitting }}
+      destroyOnClose
+      okText="确定"
+      cancelText="取消"
+      width={isMobile ? '100%' : 520}
+      style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 32px)', margin: '0 auto' } : undefined}
+    >
+      <Spin spinning={loading}>
+        <Form
+          form={form}
+          layout="vertical"
+          autoComplete="off"
+        >
+          <Form.Item
+            name="name"
+            label="姓名"
+            rules={[{ validator: validateName }]}
+          >
+            <Input placeholder="请输入人员姓名" maxLength={50} disabled={loading} />
+          </Form.Item>
+        </Form>
+      </Spin>
+    </Modal>
+  )
+}
+
+export default PersonForm

+ 173 - 0
frontend/src/components/PersonList.jsx

@@ -0,0 +1,173 @@
+import { useState, useEffect } from 'react'
+import { Table, Button, Space, message, Popconfirm, Alert } from 'antd'
+import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
+import { personApi } from '../services/api'
+import PersonForm from './PersonForm'
+import { toBeijingDateTime } from '../utils/timeUtils'
+
+const MOBILE_BREAKPOINT = 768
+
+function PersonList() {
+  const [persons, setPersons] = useState([])
+  const [loading, setLoading] = useState(false)
+  const [formVisible, setFormVisible] = useState(false)
+  const [editingPerson, setEditingPerson] = useState(null)
+  const [error, setError] = useState(null)
+  const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT)
+
+  // Mobile detection
+  useEffect(() => {
+    const handleResize = () => {
+      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+    }
+    window.addEventListener('resize', handleResize)
+    return () => window.removeEventListener('resize', handleResize)
+  }, [])
+
+  const fetchPersons = async () => {
+    setLoading(true)
+    setError(null)
+    try {
+      const response = await personApi.getAll()
+      setPersons(response.data || [])
+    } catch (error) {
+      const errorMsg = error.message || '获取人员列表失败'
+      setError(errorMsg)
+      message.error(errorMsg)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  useEffect(() => {
+    fetchPersons()
+  }, [])
+
+  const handleAdd = () => {
+    setEditingPerson(null)
+    setFormVisible(true)
+  }
+
+  const handleEdit = (record) => {
+    setEditingPerson(record)
+    setFormVisible(true)
+  }
+
+  const handleDelete = async (id) => {
+    try {
+      await personApi.delete(id)
+      message.success('删除成功')
+      fetchPersons()
+    } catch (error) {
+      message.error(error.message || '删除失败')
+    }
+  }
+
+  const handleFormSuccess = () => {
+    setFormVisible(false)
+    setEditingPerson(null)
+    fetchPersons()
+  }
+
+  const handleFormCancel = () => {
+    setFormVisible(false)
+    setEditingPerson(null)
+  }
+
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      key: 'id',
+      width: isMobile ? undefined : 80
+    },
+    {
+      title: '姓名',
+      dataIndex: 'name',
+      key: 'name'
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'created_at',
+      key: 'created_at',
+      width: 180,
+      render: (text) => toBeijingDateTime(text),
+      responsive: ['md']
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: isMobile ? undefined : 150,
+      render: (_, record) => (
+        <Space size="small" wrap={isMobile}>
+          <Button
+            type="link"
+            icon={<EditOutlined />}
+            onClick={() => handleEdit(record)}
+            size={isMobile ? 'small' : 'middle'}
+            style={isMobile ? { padding: '0 4px' } : undefined}
+          >
+            {!isMobile && '编辑'}
+          </Button>
+          <Popconfirm
+            title="确认删除"
+            description="确定要删除这个人员吗?"
+            onConfirm={() => handleDelete(record.id)}
+            okText="确定"
+            cancelText="取消"
+          >
+            <Button type="link" danger icon={<DeleteOutlined />} size={isMobile ? 'small' : 'middle'} style={isMobile ? { padding: '0 4px' } : undefined}>
+              {!isMobile && '删除'}
+            </Button>
+          </Popconfirm>
+        </Space>
+      )
+    }
+  ]
+
+  return (
+    <div>
+      {error && (
+        <Alert
+          message="加载错误"
+          description={error}
+          type="error"
+          showIcon
+          closable
+          style={{ marginBottom: 16 }}
+          onClose={() => setError(null)}
+        />
+      )}
+      <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
+        <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
+          新增人员
+        </Button>
+        <Button icon={<ReloadOutlined />} onClick={fetchPersons} loading={loading}>
+          刷新
+        </Button>
+      </div>
+      <Table
+        columns={columns}
+        dataSource={persons}
+        rowKey="id"
+        loading={loading}
+        tableLayout="auto"
+        size={isMobile ? 'small' : 'middle'}
+        pagination={{
+          showSizeChanger: !isMobile,
+          showQuickJumper: !isMobile,
+          showTotal: (total) => `共 ${total} 条`,
+          size: isMobile ? 'small' : 'default'
+        }}
+      />
+      <PersonForm
+        visible={formVisible}
+        person={editingPerson}
+        onSuccess={handleFormSuccess}
+        onCancel={handleFormCancel}
+      />
+    </div>
+  )
+}
+
+export default PersonList

+ 31 - 0
frontend/src/components/ProtectedRoute.jsx

@@ -0,0 +1,31 @@
+import { Navigate, useLocation } from 'react-router-dom'
+import { Spin } from 'antd'
+import { useAuth } from '../contexts/AuthContext'
+
+function ProtectedRoute({ children }) {
+  const { isAuthenticated, loading } = useAuth()
+  const location = useLocation()
+
+  // Show loading spinner while checking auth status
+  if (loading) {
+    return (
+      <div style={{
+        display: 'flex',
+        justifyContent: 'center',
+        alignItems: 'center',
+        minHeight: '100vh'
+      }}>
+        <Spin size="large" tip="加载中..." />
+      </div>
+    )
+  }
+
+  // Redirect to login if not authenticated
+  if (!isAuthenticated) {
+    return <Navigate to="/login" state={{ from: location }} replace />
+  }
+
+  return children
+}
+
+export default ProtectedRoute

+ 178 - 0
frontend/src/components/WorkRecordForm.jsx

@@ -0,0 +1,178 @@
+import { useEffect, useState } from 'react'
+import { Modal, Form, Select, DatePicker, InputNumber, message, Spin } from 'antd'
+import { workRecordApi, personApi, itemApi } from '../services/api'
+import dayjs from 'dayjs'
+
+const MOBILE_BREAKPOINT = 768
+
+function WorkRecordForm({ visible, record, onSuccess, onCancel }) {
+  const [form] = Form.useForm()
+  const [persons, setPersons] = useState([])
+  const [items, setItems] = useState([])
+  const [loading, setLoading] = useState(false)
+  const [submitting, setSubmitting] = useState(false)
+  const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT)
+  const isEdit = !!record
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+    }
+    window.addEventListener('resize', handleResize)
+    return () => window.removeEventListener('resize', handleResize)
+  }, [])
+
+  useEffect(() => {
+    if (visible) {
+      loadFormData()
+    }
+  }, [visible, record, form])
+
+  const loadFormData = async () => {
+    setLoading(true)
+    try {
+      await Promise.all([fetchPersons(), fetchItems()])
+      if (record) {
+        form.setFieldsValue({
+          person_id: record.person_id,
+          item_id: record.item_id,
+          work_date: record.work_date ? dayjs(record.work_date) : null,
+          quantity: record.quantity
+        })
+      } else {
+        form.resetFields()
+        form.setFieldsValue({
+          work_date: dayjs()
+        })
+      }
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const fetchPersons = async () => {
+    try {
+      const response = await personApi.getAll()
+      setPersons(response.data || [])
+    } catch (error) {
+      message.error(error.message || '获取人员列表失败')
+    }
+  }
+
+  const fetchItems = async () => {
+    try {
+      const response = await itemApi.getAll()
+      setItems(response.data || [])
+    } catch (error) {
+      message.error(error.message || '获取物品列表失败')
+    }
+  }
+
+  const handleOk = async () => {
+    try {
+      const values = await form.validateFields()
+      setSubmitting(true)
+      
+      const data = {
+        person_id: values.person_id,
+        item_id: values.item_id,
+        work_date: values.work_date.format('YYYY-MM-DD'),
+        quantity: values.quantity
+      }
+      
+      if (isEdit) {
+        await workRecordApi.update({ id: record.id, ...data })
+        message.success('更新成功')
+      } else {
+        await workRecordApi.create(data)
+        message.success('创建成功')
+      }
+      
+      onSuccess()
+    } catch (error) {
+      if (error.errorFields) {
+        return
+      }
+      message.error(error.message || '操作失败')
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  return (
+    <Modal
+      title={isEdit ? '编辑工作记录' : '新增工作记录'}
+      open={visible}
+      onOk={handleOk}
+      onCancel={onCancel}
+      confirmLoading={submitting}
+      okButtonProps={{ disabled: loading }}
+      cancelButtonProps={{ disabled: submitting }}
+      destroyOnClose
+      okText="确定"
+      cancelText="取消"
+      width={isMobile ? '100%' : 520}
+      style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 32px)', margin: '0 auto' } : undefined}
+    >
+      <Spin spinning={loading}>
+        <Form
+          form={form}
+          layout="vertical"
+          autoComplete="off"
+        >
+          <Form.Item
+            name="person_id"
+            label="人员"
+            rules={[{ required: true, message: '请选择人员' }]}
+          >
+            <Select
+              placeholder="请选择人员"
+              showSearch
+              optionFilterProp="label"
+              options={persons.map(p => ({ label: p.name, value: p.id }))}
+              disabled={loading}
+            />
+          </Form.Item>
+          <Form.Item
+            name="item_id"
+            label="物品"
+            rules={[{ required: true, message: '请选择物品' }]}
+          >
+            <Select
+              placeholder="请选择物品"
+              showSearch
+              optionFilterProp="label"
+              options={items.map(i => ({ label: `${i.name} (¥${Number(i.unit_price).toFixed(2)})`, value: i.id }))}
+              disabled={loading}
+            />
+          </Form.Item>
+          <Form.Item
+            name="work_date"
+            label="工作日期"
+            rules={[{ required: true, message: '请选择工作日期' }]}
+          >
+            <DatePicker style={{ width: '100%' }} disabled={loading} />
+          </Form.Item>
+          <Form.Item
+            name="quantity"
+            label="数量"
+            rules={[
+              { required: true, message: '请输入数量' },
+              { type: 'number', min: 1, message: '数量必须是正整数' }
+            ]}
+          >
+            <InputNumber
+              placeholder="请输入数量"
+              min={1}
+              precision={0}
+              style={{ width: '100%' }}
+              disabled={loading}
+            />
+          </Form.Item>
+        </Form>
+      </Spin>
+    </Modal>
+  )
+}
+
+export default WorkRecordForm

+ 271 - 0
frontend/src/components/WorkRecordList.jsx

@@ -0,0 +1,271 @@
+import { useState, useEffect } from 'react'
+import { Table, Button, Space, message, Popconfirm, DatePicker, Select, Row, Col, Alert } from 'antd'
+import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
+import { workRecordApi, personApi } from '../services/api'
+import WorkRecordForm from './WorkRecordForm'
+import { toBeijingDate } from '../utils/timeUtils'
+
+const MOBILE_BREAKPOINT = 768
+
+function WorkRecordList() {
+  const [workRecords, setWorkRecords] = useState([])
+  const [persons, setPersons] = useState([])
+  const [loading, setLoading] = useState(false)
+  const [formVisible, setFormVisible] = useState(false)
+  const [editingRecord, setEditingRecord] = useState(null)
+  const [error, setError] = useState(null)
+  const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT)
+  
+  // Filter states
+  const [selectedPersonId, setSelectedPersonId] = useState(null)
+  const [selectedDate, setSelectedDate] = useState(null)
+  const [selectedMonth, setSelectedMonth] = useState(null)
+
+  // Mobile detection
+  useEffect(() => {
+    const handleResize = () => {
+      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+    }
+    window.addEventListener('resize', handleResize)
+    return () => window.removeEventListener('resize', handleResize)
+  }, [])
+
+  const fetchPersons = async () => {
+    try {
+      const response = await personApi.getAll()
+      setPersons(response.data || [])
+    } catch (error) {
+      message.error(error.message || '获取人员列表失败')
+    }
+  }
+
+  const fetchWorkRecords = async () => {
+    setLoading(true)
+    setError(null)
+    try {
+      const params = {}
+      if (selectedPersonId) {
+        params.person_id = selectedPersonId
+      }
+      if (selectedDate) {
+        params.date = selectedDate.format('YYYY-MM-DD')
+      }
+      if (selectedMonth) {
+        params.year = selectedMonth.year()
+        params.month = selectedMonth.month() + 1
+      }
+      const response = await workRecordApi.getAll(params)
+      setWorkRecords(response.data || [])
+    } catch (error) {
+      const errorMsg = error.message || '获取工作记录失败'
+      setError(errorMsg)
+      message.error(errorMsg)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  useEffect(() => {
+    fetchPersons()
+  }, [])
+
+  useEffect(() => {
+    fetchWorkRecords()
+  }, [selectedPersonId, selectedDate, selectedMonth])
+
+  const handleAdd = () => {
+    setEditingRecord(null)
+    setFormVisible(true)
+  }
+
+  const handleEdit = (record) => {
+    setEditingRecord(record)
+    setFormVisible(true)
+  }
+
+  const handleDelete = async (id) => {
+    try {
+      await workRecordApi.delete(id)
+      message.success('删除成功')
+      fetchWorkRecords()
+    } catch (error) {
+      message.error(error.message || '删除失败')
+    }
+  }
+
+  const handleFormSuccess = () => {
+    setFormVisible(false)
+    setEditingRecord(null)
+    fetchWorkRecords()
+  }
+
+  const handleFormCancel = () => {
+    setFormVisible(false)
+    setEditingRecord(null)
+  }
+
+  const handleReset = () => {
+    setSelectedPersonId(null)
+    setSelectedDate(null)
+    setSelectedMonth(null)
+  }
+
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      key: 'id',
+      width: isMobile ? undefined : 60,
+      fixed: isMobile ? undefined : 'left'
+    },
+    {
+      title: '人员',
+      dataIndex: 'person_name',
+      key: 'person_name',
+      width: isMobile ? undefined : 100
+    },
+    {
+      title: '日期',
+      dataIndex: 'work_date',
+      key: 'work_date',
+      width: isMobile ? undefined : 120,
+      render: (text) => toBeijingDate(text)
+    },
+    {
+      title: '物品',
+      dataIndex: 'item_name',
+      key: 'item_name',
+      width: isMobile ? undefined : 120
+    },
+    {
+      title: '单价',
+      dataIndex: 'unit_price',
+      key: 'unit_price',
+      width: isMobile ? undefined : 100,
+      render: (value) => `¥${Number(value).toFixed(2)}`,
+      responsive: ['sm']
+    },
+    {
+      title: '数量',
+      dataIndex: 'quantity',
+      key: 'quantity',
+      width: isMobile ? undefined : 80
+    },
+    {
+      title: '总价',
+      dataIndex: 'total_price',
+      key: 'total_price',
+      width: isMobile ? undefined : 100,
+      render: (value) => `¥${Number(value).toFixed(2)}`
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: isMobile ? undefined : 150,
+      fixed: isMobile ? undefined : 'right',
+      render: (_, record) => (
+        <Space size="small" wrap={isMobile}>
+          <Button
+            type="link"
+            icon={<EditOutlined />}
+            onClick={() => handleEdit(record)}
+            size={isMobile ? 'small' : 'middle'}
+            style={isMobile ? { padding: '0 4px' } : undefined}
+          >
+            {!isMobile && '编辑'}
+          </Button>
+          <Popconfirm
+            title="确认删除"
+            description="确定要删除这条工作记录吗?"
+            onConfirm={() => handleDelete(record.id)}
+            okText="确定"
+            cancelText="取消"
+          >
+            <Button type="link" danger icon={<DeleteOutlined />} size={isMobile ? 'small' : 'middle'} style={isMobile ? { padding: '0 4px' } : undefined}>
+              {!isMobile && '删除'}
+            </Button>
+          </Popconfirm>
+        </Space>
+      )
+    }
+  ]
+
+
+  return (
+    <div>
+      {error && (
+        <Alert
+          message="加载错误"
+          description={error}
+          type="error"
+          showIcon
+          closable
+          style={{ marginBottom: 16 }}
+          onClose={() => setError(null)}
+        />
+      )}
+      <Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
+        <Col xs={12} sm={6} md={4}>
+          <Select
+            placeholder="选择人员"
+            allowClear
+            style={{ width: '100%' }}
+            value={selectedPersonId}
+            onChange={setSelectedPersonId}
+            options={persons.map(p => ({ label: p.name, value: p.id }))}
+          />
+        </Col>
+        <Col xs={12} sm={6} md={4}>
+          <DatePicker.MonthPicker
+            placeholder="选择月份"
+            value={selectedMonth}
+            onChange={setSelectedMonth}
+            format="YYYY-MM"
+            style={{ width: '100%' }}
+          />
+        </Col>
+        <Col xs={12} sm={6} md={4}>
+          <DatePicker
+            placeholder="选择日期"
+            value={selectedDate}
+            onChange={setSelectedDate}
+            style={{ width: '100%' }}
+          />
+        </Col>
+        <Col xs={12} sm={6} md={4}>
+          <Button icon={<ReloadOutlined />} onClick={handleReset} style={{ width: isMobile ? '100%' : 'auto' }}>
+            重置
+          </Button>
+        </Col>
+        <Col xs={24} sm={24} md={8} style={{ textAlign: isMobile ? 'left' : 'right' }}>
+          <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd} style={{ width: isMobile ? '100%' : 'auto' }}>
+            新增记录
+          </Button>
+        </Col>
+      </Row>
+      <Table
+        columns={columns}
+        dataSource={workRecords}
+        rowKey="id"
+        loading={loading}
+        tableLayout="auto"
+        scroll={isMobile ? undefined : { x: 800 }}
+        size={isMobile ? 'small' : 'middle'}
+        pagination={{
+          showSizeChanger: !isMobile,
+          showQuickJumper: !isMobile,
+          showTotal: (total) => `共 ${total} 条`,
+          size: isMobile ? 'small' : 'default'
+        }}
+      />
+      <WorkRecordForm
+        visible={formVisible}
+        record={editingRecord}
+        onSuccess={handleFormSuccess}
+        onCancel={handleFormCancel}
+      />
+    </div>
+  )
+}
+
+export default WorkRecordList

+ 80 - 0
frontend/src/contexts/AuthContext.jsx

@@ -0,0 +1,80 @@
+import { createContext, useContext, useState, useEffect } from 'react'
+import api from '../services/api'
+
+const AuthContext = createContext(null)
+
+export function AuthProvider({ children }) {
+  const [token, setToken] = useState(localStorage.getItem('token'))
+  const [admin, setAdmin] = useState(null)
+  const [loading, setLoading] = useState(true)
+
+  // Load admin info on mount if token exists
+  useEffect(() => {
+    const loadAdmin = async () => {
+      if (token) {
+        try {
+          const response = await api.get('/auth/me')
+          if (response.success) {
+            setAdmin(response.data)
+          } else {
+            // Token invalid, clear it
+            localStorage.removeItem('token')
+            setToken(null)
+          }
+        } catch (error) {
+          // Token invalid or expired
+          localStorage.removeItem('token')
+          setToken(null)
+        }
+      }
+      setLoading(false)
+    }
+    loadAdmin()
+  }, [token])
+
+  const login = async (username, password) => {
+    try {
+      const response = await api.post('/auth/login', { username, password })
+      if (response.success) {
+        localStorage.setItem('token', response.data.token)
+        setToken(response.data.token)
+        setAdmin(response.data.admin)
+        return { success: true }
+      }
+      return { success: false, error: response.error || '登录失败' }
+    } catch (error) {
+      return { success: false, error: error.message || '登录失败' }
+    }
+  }
+
+  const logout = () => {
+    localStorage.removeItem('token')
+    setToken(null)
+    setAdmin(null)
+  }
+
+  const value = {
+    token,
+    admin,
+    login,
+    logout,
+    isAuthenticated: !!token,
+    loading
+  }
+
+  return (
+    <AuthContext.Provider value={value}>
+      {children}
+    </AuthContext.Provider>
+  )
+}
+
+export function useAuth() {
+  const context = useContext(AuthContext)
+  if (!context) {
+    throw new Error('useAuth must be used within an AuthProvider')
+  }
+  return context
+}
+
+export default AuthContext

+ 150 - 0
frontend/src/index.css

@@ -0,0 +1,150 @@
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
+    'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+    'Noto Color Emoji';
+}
+
+#root {
+  height: 100vh;
+}
+
+/* Mobile responsive styles */
+@media (max-width: 767px) {
+  /* Ensure minimum touch target size of 44x44px */
+  .ant-btn {
+    min-height: 44px;
+    min-width: 44px;
+  }
+
+  .ant-btn-sm {
+    min-height: 36px;
+    min-width: 36px;
+  }
+
+  /* Touch-friendly form controls */
+  .ant-input,
+  .ant-select-selector,
+  .ant-picker,
+  .ant-input-number {
+    min-height: 44px !important;
+  }
+
+  .ant-select-selection-item,
+  .ant-select-selection-placeholder {
+    line-height: 42px !important;
+  }
+
+  /* Mobile menu button */
+  .mobile-menu-btn {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  /* Mobile touch targets */
+  .mobile-touch-target {
+    min-width: 44px;
+    min-height: 44px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  /* Table horizontal scroll on mobile */
+  .ant-table-wrapper {
+    overflow-x: auto;
+    -webkit-overflow-scrolling: touch;
+  }
+
+  /* Table column auto width */
+  .ant-table {
+    table-layout: auto !important;
+  }
+
+  .ant-table-cell {
+    white-space: nowrap;
+    padding: 8px 8px !important;
+  }
+
+  /* Card adjustments for mobile */
+  .ant-card {
+    margin-bottom: 12px;
+  }
+
+  .ant-card-body {
+    padding: 12px;
+  }
+
+  /* Form layout adjustments */
+  .ant-form-item {
+    margin-bottom: 16px;
+  }
+
+  /* Modal adjustments for mobile */
+  .ant-modal {
+    max-width: calc(100vw - 32px);
+    margin: 16px auto;
+  }
+
+  .ant-modal-body {
+    padding: 16px;
+  }
+
+  /* Row/Col adjustments */
+  .ant-row {
+    margin-left: 0 !important;
+    margin-right: 0 !important;
+  }
+
+  /* DatePicker adjustments */
+  .ant-picker {
+    width: 100% !important;
+  }
+
+  /* Select adjustments */
+  .ant-select {
+    width: 100% !important;
+  }
+
+  /* Statistic card text size */
+  .ant-statistic-title {
+    font-size: 12px;
+  }
+
+  .ant-statistic-content {
+    font-size: 20px;
+  }
+
+  /* Alert adjustments */
+  .ant-alert {
+    padding: 8px 12px;
+  }
+
+  /* Space adjustments */
+  .ant-space {
+    flex-wrap: wrap;
+  }
+
+  /* Popconfirm adjustments */
+  .ant-popconfirm {
+    max-width: calc(100vw - 32px);
+  }
+}
+
+/* Tablet responsive styles */
+@media (min-width: 768px) and (max-width: 991px) {
+  .ant-card-body {
+    padding: 16px;
+  }
+}
+
+/* Desktop styles */
+@media (min-width: 992px) {
+  /* Default styles apply */
+}

+ 22 - 0
frontend/src/index.jsx

@@ -0,0 +1,22 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import { ConfigProvider } from 'antd'
+import zhCN from 'antd/locale/zh_CN'
+import dayjs from 'dayjs'
+import 'dayjs/locale/zh-cn'
+import App from './App'
+import './index.css'
+
+// 设置 dayjs 使用中文
+dayjs.locale('zh-cn')
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+  <React.StrictMode>
+    <ConfigProvider locale={zhCN}>
+      <BrowserRouter>
+        <App />
+      </BrowserRouter>
+    </ConfigProvider>
+  </React.StrictMode>,
+)

+ 130 - 0
frontend/src/services/api.js

@@ -0,0 +1,130 @@
+import axios from 'axios'
+
+const api = axios.create({
+  baseURL: '/api',
+  timeout: 10000,
+  headers: {
+    'Content-Type': 'application/json'
+  }
+})
+
+// Request interceptor to add JWT token
+api.interceptors.request.use(
+  (config) => {
+    const token = localStorage.getItem('token')
+    if (token) {
+      config.headers.Authorization = `Bearer ${token}`
+    }
+    return config
+  },
+  (error) => {
+    return Promise.reject(error)
+  }
+)
+
+// Response interceptor for error handling
+api.interceptors.response.use(
+  (response) => {
+    return response.data
+  },
+  (error) => {
+    // Handle 401 Unauthorized - redirect to login
+    if (error.response?.status === 401) {
+      localStorage.removeItem('token')
+      // Only redirect if not already on login page
+      if (window.location.pathname !== '/login') {
+        window.location.href = '/login'
+      }
+    }
+    const message = error.response?.data?.error || error.message || '请求失败'
+    return Promise.reject(new Error(message))
+  }
+)
+
+// Auth API
+export const authApi = {
+  login: (data) => api.post('/auth/login', data),
+  me: () => api.get('/auth/me')
+}
+
+// Admin API
+export const adminApi = {
+  getAll: () => api.get('/admins'),
+  getById: (id) => api.get(`/admins/${id}`),
+  create: (data) => api.post('/admins/create', data),
+  update: (data) => api.post('/admins/update', data),
+  delete: (id) => api.post('/admins/delete', { id })
+}
+
+// Person API
+export const personApi = {
+  getAll: () => api.get('/persons'),
+  getById: (id) => api.get(`/persons/${id}`),
+  create: (data) => api.post('/persons/create', data),
+  update: (data) => api.post('/persons/update', data),
+  delete: (id) => api.post('/persons/delete', { id })
+}
+
+// Item API
+export const itemApi = {
+  getAll: () => api.get('/items'),
+  getById: (id) => api.get(`/items/${id}`),
+  create: (data) => api.post('/items/create', data),
+  update: (data) => api.post('/items/update', data),
+  delete: (id) => api.post('/items/delete', { id })
+}
+
+// Work Record API
+export const workRecordApi = {
+  getAll: (params) => api.get('/work-records', { params }),
+  getById: (id) => api.get(`/work-records/${id}`),
+  create: (data) => api.post('/work-records/create', data),
+  update: (data) => api.post('/work-records/update', data),
+  delete: (id) => api.post('/work-records/delete', { id }),
+  getDailySummary: (params) => api.get('/work-records/daily-summary', { params }),
+  getMonthlySummary: (params) => api.get('/work-records/monthly-summary', { params })
+}
+
+// Export API - uses axios directly to handle blob responses
+export const exportApi = {
+  monthly: (year, month) => {
+    const token = localStorage.getItem('token')
+    return axios.get('/api/export/monthly', {
+      params: { year, month },
+      responseType: 'blob',
+      headers: token ? { Authorization: `Bearer ${token}` } : {}
+    })
+  },
+  yearly: (year) => {
+    const token = localStorage.getItem('token')
+    return axios.get('/api/export/yearly', {
+      params: { year },
+      responseType: 'blob',
+      headers: token ? { Authorization: `Bearer ${token}` } : {}
+    })
+  }
+}
+
+// Import API - uses axios directly to handle blob/multipart responses
+export const importApi = {
+  downloadTemplate: () => {
+    const token = localStorage.getItem('token')
+    return axios.get('/api/import/template', {
+      responseType: 'blob',
+      headers: token ? { Authorization: `Bearer ${token}` } : {}
+    })
+  },
+  upload: (file) => {
+    const token = localStorage.getItem('token')
+    const formData = new FormData()
+    formData.append('file', file)
+    return axios.post('/api/import/upload', formData, {
+      headers: {
+        'Content-Type': 'multipart/form-data',
+        ...(token ? { Authorization: `Bearer ${token}` } : {})
+      }
+    })
+  }
+}
+
+export default api

+ 35 - 0
frontend/src/utils/timeUtils.js

@@ -0,0 +1,35 @@
+import dayjs from 'dayjs'
+import utc from 'dayjs/plugin/utc'
+import timezone from 'dayjs/plugin/timezone'
+
+dayjs.extend(utc)
+dayjs.extend(timezone)
+
+const BEIJING_TIMEZONE = 'Asia/Shanghai'
+const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
+const DATE_FORMAT = 'YYYY-MM-DD'
+
+/**
+ * Convert UTC datetime to Beijing time string
+ * @param {string|Date} utcDatetime - UTC datetime value
+ * @returns {string} Formatted Beijing time string (yyyy-MM-dd HH:mm:ss)
+ */
+export function toBeijingDateTime(utcDatetime) {
+  if (!utcDatetime) return ''
+  return dayjs.utc(utcDatetime).tz(BEIJING_TIMEZONE).format(DATETIME_FORMAT)
+}
+
+/**
+ * Convert UTC date to Beijing date string
+ * @param {string|Date} utcDate - UTC date value
+ * @returns {string} Formatted Beijing date string (yyyy-MM-dd)
+ */
+export function toBeijingDate(utcDate) {
+  if (!utcDate) return ''
+  return dayjs.utc(utcDate).tz(BEIJING_TIMEZONE).format(DATE_FORMAT)
+}
+
+export default {
+  toBeijingDateTime,
+  toBeijingDate
+}

+ 24 - 0
frontend/vite.config.js

@@ -0,0 +1,24 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [react()],
+  server: {
+    port: 3000,
+    proxy: {
+      '/api': {
+        target: 'http://localhost:5000',
+        changeOrigin: true
+      },
+      '/swaggerui': {
+        target: 'http://localhost:5000',
+        changeOrigin: true
+      },
+      '/swagger.json': {
+        target: 'http://localhost:5000',
+        changeOrigin: true
+      }
+    }
+  }
+})