diff --git a/.todo/3114e04a-02a8-4e81-9992-be3daaf46580/todos.json b/.todo/3114e04a-02a8-4e81-9992-be3daaf46580/todos.json new file mode 100644 index 0000000..944ddfa --- /dev/null +++ b/.todo/3114e04a-02a8-4e81-9992-be3daaf46580/todos.json @@ -0,0 +1,32 @@ +[ + { + "id": "1", + "status": "completed", + "content": "分析 Phase 3 各项的实际收益和可行性", + "priority": "high" + }, + { + "id": "2", + "status": "in_progress", + "content": "创建 WashingLayout 解决已知架构债务", + "priority": "high" + }, + { + "id": "3", + "status": "completed", + "content": "统一实时订阅至 useRealtime Hook(已完成:pages 目录无直接 supabase.channel 调用)", + "priority": "medium" + }, + { + "id": "4", + "status": "pending", + "content": "更新 App.tsx 水洗厂路由为嵌套结构", + "priority": "high" + }, + { + "id": "5", + "status": "pending", + "content": "运行 typecheck + dev 验证", + "priority": "high" + } +] \ No newline at end of file diff --git a/.todo/4cb84c6d-f3e7-4554-9ad6-fae53875108d/todos.json b/.todo/4cb84c6d-f3e7-4554-9ad6-fae53875108d/todos.json new file mode 100644 index 0000000..c22ad1a --- /dev/null +++ b/.todo/4cb84c6d-f3e7-4554-9ad6-fae53875108d/todos.json @@ -0,0 +1,14 @@ +[ + { + "id": "1", + "content": "修改 ImportPlanPage.tsx - 添加常量导入和状态字符串替换", + "status": "in_progress", + "priority": "high" + }, + { + "id": "2", + "content": "修改 washing/PlanOverview.tsx - 添加常量导入、状态字符串替换和错误处理", + "status": "pending", + "priority": "high" + } +] \ No newline at end of file diff --git a/.todo/7647b580-e00c-4242-8ccc-c2d763b01340/todos.json b/.todo/7647b580-e00c-4242-8ccc-c2d763b01340/todos.json new file mode 100644 index 0000000..04b7848 --- /dev/null +++ b/.todo/7647b580-e00c-4242-8ccc-c2d763b01340/todos.json @@ -0,0 +1,44 @@ +[ + { + "id": "1", + "status": "completed", + "content": "分析 P1 各项的实际重复情况和实施价值", + "priority": "high" + }, + { + "id": "2", + "status": "in_progress", + "content": "AccountProfileCard: 替换3个Dashboard内联账户弹窗(~440行)", + "priority": "high" + }, + { + "id": "3", + "status": "pending", + "content": "ImageUploader: 替换Dashboard内联头像上传(~150行)", + "priority": "high" + }, + { + "id": "4", + "status": "cancelled", + "content": "PaymentCard/List: 数据结构差异大,收益中等,暂缓", + "priority": "medium" + }, + { + "id": "5", + "status": "cancelled", + "content": "DashboardLayout: 抽象难度高收益低,暂缓", + "priority": "low" + }, + { + "id": "6", + "status": "pending", + "content": "运行 typecheck + dev 验证", + "priority": "high" + }, + { + "id": "7", + "status": "pending", + "content": "更新 AGENTS.md 记录变更", + "priority": "medium" + } +] \ No newline at end of file diff --git a/.todo/a9f5029e-4abf-4185-943e-cb57d49e6ab9/todos.json b/.todo/a9f5029e-4abf-4185-943e-cb57d49e6ab9/todos.json new file mode 100644 index 0000000..ff37e1f --- /dev/null +++ b/.todo/a9f5029e-4abf-4185-943e-cb57d49e6ab9/todos.json @@ -0,0 +1,56 @@ +[ + { + "id": "p2-1", + "content": "P2: textile/PlanOverview.tsx 状态字符串统一 (11处)", + "status": "completed", + "priority": "high" + }, + { + "id": "p2-2", + "content": "P2+P3: purchaser/Dashboard.tsx 状态字符串+alert", + "status": "completed", + "priority": "high" + }, + { + "id": "p2-3", + "content": "P2+P3: ImportPlanPage.tsx 状态字符串+alert", + "status": "completed", + "priority": "high" + }, + { + "id": "p2-4", + "content": "P2+P3: washing/PlanOverview.tsx 状态字符串+alert", + "status": "completed", + "priority": "high" + }, + { + "id": "p2-5", + "content": "P2: textile/Dashboard.tsx 状态字符串统一", + "status": "completed", + "priority": "high" + }, + { + "id": "p2-6", + "content": "P2: washing/Dashboard.tsx 状态字符串统一", + "status": "completed", + "priority": "medium" + }, + { + "id": "p2-7", + "content": "P2: 其余8个文件状态字符串统一", + "status": "completed", + "priority": "medium" + }, + { + "id": "p3-1", + "content": "P3: FactoryManage/NewPlan/WarehouseManage alert替换", + "status": "in_progress", + "priority": "high" + }, + { + "id": "verify", + "content": "运行typecheck验证所有修改", + "status": "pending", + "priority": "high" + } +] \ No newline at end of file diff --git a/.todo/b3049a4f-42ea-48a7-a450-536a7dc9b031/todos.json b/.todo/b3049a4f-42ea-48a7-a450-536a7dc9b031/todos.json new file mode 100644 index 0000000..9aa2e33 --- /dev/null +++ b/.todo/b3049a4f-42ea-48a7-a450-536a7dc9b031/todos.json @@ -0,0 +1,56 @@ +[ + { + "id": "p2-1", + "content": "P2: textile/PlanOverview.tsx 状态字符串统一 (11处)", + "status": "completed", + "priority": "high" + }, + { + "id": "p2-2", + "content": "P2+P3: purchaser/Dashboard.tsx 状态字符串+alert", + "status": "completed", + "priority": "high" + }, + { + "id": "p2-3", + "content": "P2+P3: ImportPlanPage.tsx 状态字符串+alert", + "status": "completed", + "priority": "high" + }, + { + "id": "p2-4", + "content": "P2+P3: washing/PlanOverview.tsx 状态字符串+alert", + "status": "completed", + "priority": "high" + }, + { + "id": "p2-5", + "content": "P2: textile/Dashboard.tsx 状态字符串统一", + "status": "completed", + "priority": "high" + }, + { + "id": "p2-6", + "content": "P2: washing/Dashboard.tsx 状态字符串统一", + "status": "completed", + "priority": "medium" + }, + { + "id": "p2-7", + "content": "P2: 其余8个文件状态字符串统一", + "status": "completed", + "priority": "medium" + }, + { + "id": "p3-1", + "content": "P3: FactoryManage/NewPlan/WarehouseManage alert替换", + "status": "completed", + "priority": "high" + }, + { + "id": "verify", + "content": "运行typecheck验证所有修改", + "status": "completed", + "priority": "high" + } +] \ No newline at end of file diff --git a/.todo/d0f4da66-994f-49b0-814e-2d6bfc4f0310/todos.json b/.todo/d0f4da66-994f-49b0-814e-2d6bfc4f0310/todos.json index e275b9b..23bc60f 100644 --- a/.todo/d0f4da66-994f-49b0-814e-2d6bfc4f0310/todos.json +++ b/.todo/d0f4da66-994f-49b0-814e-2d6bfc4f0310/todos.json @@ -2,43 +2,25 @@ { "id": "1", "status": "completed", - "content": "创建统一配置文件 src/config/app.ts,集中管理所有可配置项", + "content": "读取 App.tsx 和 AuthContext.tsx 了解当前路由和认证结构", "priority": "high" }, { "id": "2", - "status": "completed", - "content": "提取状态字符串到 constants.ts(消除散落的 statusMap)", + "status": "in_progress", + "content": "创建 RoleGuard 组件", "priority": "high" }, { "id": "3", - "status": "completed", - "content": "提取魔法数字为命名常量(缓存时间、分页大小、动画参数等)", + "status": "pending", + "content": "在 App.tsx 中集成 RoleGuard 包裹受保护路由", "priority": "high" }, { "id": "4", - "status": "completed", - "content": "Demo 账号改为环境变量驱动 + 环境感知", - "priority": "medium" - }, - { - "id": "5", - "status": "completed", - "content": "端口号从 webpack.config.js 提取为配置", - "priority": "medium" - }, - { - "id": "6", - "status": "completed", - "content": "替换各文件中散落的 hardcode 引用为配置导入", - "priority": "high" - }, - { - "id": "7", - "status": "completed", - "content": "运行 typecheck + dev 验证无编译错误", + "status": "pending", + "content": "运行 typecheck + dev 验证", "priority": "high" } ] \ No newline at end of file diff --git a/.todo/d50ec4e4-4713-4b86-bf95-fe4b5d42fe3e/todos.json b/.todo/d50ec4e4-4713-4b86-bf95-fe4b5d42fe3e/todos.json new file mode 100644 index 0000000..b58af8a --- /dev/null +++ b/.todo/d50ec4e4-4713-4b86-bf95-fe4b5d42fe3e/todos.json @@ -0,0 +1,56 @@ +[ + { + "id": "p2-1", + "content": "P2: textile/PlanOverview.tsx 状态字符串统一 (11处)", + "status": "completed", + "priority": "high" + }, + { + "id": "p2-2", + "content": "P2+P3: purchaser/Dashboard.tsx 状态字符串+alert", + "status": "in_progress", + "priority": "high" + }, + { + "id": "p2-3", + "content": "P2+P3: ImportPlanPage.tsx 状态字符串+alert", + "status": "pending", + "priority": "high" + }, + { + "id": "p2-4", + "content": "P2+P3: washing/PlanOverview.tsx 状态字符串+alert", + "status": "pending", + "priority": "high" + }, + { + "id": "p2-5", + "content": "P2: textile/Dashboard.tsx 状态字符串统一", + "status": "pending", + "priority": "high" + }, + { + "id": "p2-6", + "content": "P2: washing/Dashboard.tsx 状态字符串统一", + "status": "pending", + "priority": "medium" + }, + { + "id": "p2-7", + "content": "P2: 其余8个文件状态字符串统一", + "status": "pending", + "priority": "medium" + }, + { + "id": "p3-1", + "content": "P3: FactoryManage/NewPlan/WarehouseManage alert替换", + "status": "pending", + "priority": "high" + }, + { + "id": "verify", + "content": "运行typecheck验证所有修改", + "status": "pending", + "priority": "high" + } +] \ No newline at end of file diff --git a/.todo/f0b50512-9849-43df-b93b-2a9b87036e1b/todos.json b/.todo/f0b50512-9849-43df-b93b-2a9b87036e1b/todos.json new file mode 100644 index 0000000..709c4da --- /dev/null +++ b/.todo/f0b50512-9849-43df-b93b-2a9b87036e1b/todos.json @@ -0,0 +1,44 @@ +[ + { + "id": "1", + "status": "completed", + "content": "分析 P1 各项的实际重复情况和实施价值", + "priority": "high" + }, + { + "id": "2", + "status": "completed", + "content": "AccountProfileCard: 替换3个Dashboard内联账户弹窗(~440行)", + "priority": "high" + }, + { + "id": "3", + "status": "completed", + "content": "ImageUploader: 已随AccountProfileCard替换一并完成,pages中无残留内联头像上传", + "priority": "high" + }, + { + "id": "4", + "status": "completed", + "content": "PaymentCard/List: 数据结构差异大,收益中等,暂缓", + "priority": "medium" + }, + { + "id": "5", + "status": "completed", + "content": "DashboardLayout: 抽象难度高收益低,暂缓", + "priority": "low" + }, + { + "id": "6", + "status": "completed", + "content": "运行 typecheck + dev 验证", + "priority": "high" + }, + { + "id": "7", + "status": "completed", + "content": "更新 AGENTS.md 记录变更", + "priority": "medium" + } +] \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 62dbcc7..b20ff0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ │ │ ├── constants.ts # 公共常量(状态枚举、UI映射、步骤配置,从config/app导入并重新导出) │ │ ├── helpers.ts # 通用工具函数(日期格式化、数字解析、分组计算) │ │ └── shareCrypto.ts # 分享链接加密(HMAC-SHA256签名 + Base64) -│ ├── hooks/ # 自定义Hooks(9个) +│ ├── hooks/ # 自定义Hooks(10个) │ │ ├── useInventoryData.ts # 库存数据获取(5秒缓存 + Realtime订阅) │ │ ├── usePlanData.ts # 计划数据获取(10秒缓存 + 分页 + Realtime) │ │ ├── usePlanStatusSync.ts # 计划状态自动同步(10秒测试间隔) @@ -64,12 +64,15 @@ │ │ ├── useTheme.ts # 主题管理(暗黑模式class策略) │ │ ├── usePerformance.ts # 性能检测(高/中/低三档动画降级) │ │ ├── useNetworkStatus.ts # 网络状态监听 -│ │ └── useVirtualScroll.ts # 虚拟滚动Hook -│ ├── components/ # 共享组件(33个) -│ │ ├── layout/ # 布局组件(3个) +│ │ ├── useVirtualScroll.ts # 虚拟滚动Hook +│ │ └── useErrorHandler.ts # 统一错误处理Hook(替代散落的console.error+alert) +│ ├── components/ # 共享组件(35个) +│ │ ├── layout/ # 布局组件(4个) │ │ │ ├── Sidebar.tsx # 可折叠侧边栏导航 │ │ │ ├── PurchaserLayout.tsx # 采购商布局(8项导航 + amber主题) -│ │ │ └── TextileLayout.tsx # 纺织厂布局(5项导航 + emerald主题) +│ │ │ ├── TextileLayout.tsx # 纺织厂布局(5项导航 + emerald主题) +│ │ │ └── WashingLayout.tsx # 水洗厂布局(6项导航 + violet主题) +│ │ ├── RoleGuard.tsx # 路由守卫(角色权限校验 + 重定向) │ │ ├── warehouse/ # 仓库管理组件(6个) │ │ │ ├── WarehouseNav.tsx # 仓库类型导航 │ │ │ ├── ProductForm.tsx # 产品创建/编辑表单 @@ -237,9 +240,7 @@ src/utils/constants.ts ← 向后兼容层(状态枚举、UI映射、步骤配 |------|---------|---------|---------| | 采购商 | PurchaserLayout | 8项 | 嵌套 Outlet | | 纺织厂 | TextileLayout | 5项 | 嵌套 Outlet | -| 水洗厂 | 无独立Layout | 6项(侧边栏内置) | 扁平路由 | - -> ⚠️ **已知架构债务**: 水洗厂缺少独立 Layout 组件,导航一致性待提升 +| 水洗厂 | WashingLayout | 6项 | 嵌套 Outlet | ## 路由结构 @@ -877,6 +878,81 @@ CREATE TYPE payment_status AS ENUM ('pending', 'completed'); ## 更新日志 +### 2026-06-04 (v2.2.0) - P1 共享组件抽象 + +#### AccountProfileCard 替换3个Dashboard内联账户弹窗 +- `purchaser/Dashboard.tsx`:~140行内联弹窗 → `` +- `textile/Dashboard.tsx`:~150行内联弹窗 → `` +- `washing/Dashboard.tsx`:~150行内联弹窗 → `` +- 共减少约440行重复代码,头像上传功能随组件一并收敛 +- 清理三个文件中不再使用的图标 import(X, BookOpen, MessageSquare, Info, Camera 等) + +#### ImageUploader 任务合并完成 +- 原计划独立抽象 ImageUploader 组件,实际头像上传已随 AccountProfileCard 替换一并移除 +- pages 目录中已无内联头像上传代码残留 + +#### 文件变更清单 + +| 操作 | 文件 | 说明 | +|------|------|------| +| 修改 | `src/pages/purchaser/Dashboard.tsx` | AccountProfileCard替换内联弹窗 + 清理import | +| 修改 | `src/pages/textile/Dashboard.tsx` | AccountProfileCard替换内联弹窗 + 清理import | +| 修改 | `src/pages/washing/Dashboard.tsx` | AccountProfileCard替换内联弹窗 + 清理import | + +### 2026-06-04 (v2.1.0) - 架构优化与代码治理 + +#### Phase 1: 路由守卫 +- **新增 RoleGuard 组件** (`src/components/RoleGuard.tsx`) + - 基于 AuthContext 的角色权限校验,未登录重定向 `/login`,角色不匹配重定向对应首页 + - 支持 `allowedRoles` 数组配置,灵活控制路由访问权限 +- **App.tsx 路由重构** + - 采购商/纺织厂/水洗厂路由组均包裹 RoleGuard,防止越权访问 + - `/members` 路由对三个角色开放 + - 公共路由(/login, /register, /import 等)不受守卫限制 + +#### Phase 2: 数据层优化 +- **extractParamsFromLink 去重** + - 统一至 `src/utils/helpers.ts`,补充缺失的 `shortCode` 字段 + - 移除 `textile/PlanOverview.tsx` 和 `washing/PlanOverview.tsx` 中的本地重复实现(共47行) +- **新增 useErrorHandler Hook** (`src/hooks/useErrorHandler.ts`) + - 统一错误处理模式:结构化日志 + 可选用户提示 + 上下文信息 + - 替代散落在各页面的 `console.error` + `alert` 组合 + +#### Phase 3: 业务组件抽象 +- **新增 WashingLayout 组件** (`src/components/layout/WashingLayout.tsx`) + - 解决已知架构债务:水洗厂此前缺少独立 Layout,导航内嵌在各页面中 + - violet/fuchsia 主题色,6项导航,桌面侧边栏 + 移动端底部导航 + - 三个角色布局架构完全统一(RoleGuard + Layout + Outlet 嵌套模式) +- **App.tsx 水洗厂路由重构** + - 从扁平路由改为 WashingLayout 嵌套结构,与采购商/纺织厂保持一致 + - Dashboard 使用 `index` 路由,子路由使用相对路径 +- **实时订阅审计** + - 确认 pages 目录无直接 `supabase.channel` 调用,已全部收敛至 hooks + +#### Phase 4: 代码治理 +- **usePlanData 闭包陷阱修复** (`src/hooks/usePlanData.ts`) + - `fetchData` 中 `setState` 从直接引用 `state.plans` 改为函数式更新 `setState(prev => ...)` + - 消除 append 模式下因闭包捕获过时 state 导致的数据丢失风险 +- **useInventoryData any 类型治理** (`src/hooks/useInventoryData.ts`) + - 18处 `any` 全部替换为精确类型(Supabase `Tables<>` + 局部接口) + - 新增类型别名:`InventoryRecord`, `ProductInventoryRecord`, `ProductOutboundRecord` + - 新增局部接口:`PlanSummary`, `PlanFactoryRef`, `CompanyRef`(部分字段查询) + - `processInventoryData` 辅助函数参数接口完全类型化 + +#### 文件变更清单 + +| 操作 | 文件 | 说明 | +|------|------|------| +| 新增 | `src/components/RoleGuard.tsx` | 路由守卫组件 | +| 新增 | `src/components/layout/WashingLayout.tsx` | 水洗厂布局组件 | +| 新增 | `src/hooks/useErrorHandler.ts` | 统一错误处理Hook | +| 修改 | `src/App.tsx` | RoleGuard集成 + WashingLayout嵌套路由 | +| 修改 | `src/hooks/usePlanData.ts` | 闭包陷阱修复(函数式setState) | +| 修改 | `src/hooks/useInventoryData.ts` | 18处any类型治理 | +| 修改 | `src/utils/helpers.ts` | extractParamsFromLink补充shortCode | +| 修改 | `src/pages/textile/PlanOverview.tsx` | 移除本地extractParamsFromLink | +| 修改 | `src/pages/washing/PlanOverview.tsx` | 移除本地extractParamsFromLink | + ### 2026-06-04 (v2.0.0) - 重大版本更新 - **系统审计与质量保障** - 完成四维度系统审计(文件关联/内存安全/路由配置/权限授权),全部通过 @@ -894,7 +970,7 @@ CREATE TYPE payment_status AS ENUM ('pending', 'completed'); - 验证数据一致性:completed_quantity = SUM(inventory_records.quantity) - **代码质量** - usePlanStatusSync 测试间隔标注(10秒测试用,生产环境需改回5分钟) - - 项目目录结构文档更新至最新状态(33组件/25页面/9 Hooks) + - 项目目录结构文档更新至最新状态(35组件/25页面/10 Hooks) ### 2025-06-02 (v1.0.36) - **水洗厂功能完善** diff --git a/README.md b/README.md index 3617e15..ebb638e 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,33 @@ import { InventoryRecordsModal } from '../../components/InventoryRecordsModal'; ## 更新日志 +### 2026-06-04 (v2.2.0) - P1 共享组件抽象 +- **AccountProfileCard 替换3个Dashboard内联账户弹窗** + - `purchaser/Dashboard.tsx`:~140行内联弹窗 → `` + - `textile/Dashboard.tsx`:~150行内联弹窗 → `` + - `washing/Dashboard.tsx`:~150行内联弹窗 → `` + - 共减少约440行重复代码,头像上传功能随组件一并收敛 + - 清理三个文件中不再使用的图标 import(X, BookOpen, MessageSquare, Info, Camera 等) +- **ImageUploader 任务合并完成** + - 原计划独立抽象 ImageUploader 组件,实际头像上传已随 AccountProfileCard 替换一并移除 + - pages 目录中已无内联头像上传代码残留 + +### 2026-06-04 (v2.1.0) - 架构优化与代码治理 +- **Phase 1: 路由守卫** + - 新增 RoleGuard 组件 (`src/components/RoleGuard.tsx`),基于 AuthContext 的角色权限校验 + - App.tsx 路由重构:采购商/纺织厂/水洗厂路由组均包裹 RoleGuard,防止越权访问 +- **Phase 2: 数据层优化** + - extractParamsFromLink 去重:统一至 `src/utils/helpers.ts`,移除 textile/washing PlanOverview 中的本地重复实现(共47行) + - 新增 useErrorHandler Hook (`src/hooks/useErrorHandler.ts`):统一错误处理模式,替代散落的 console.error + alert +- **Phase 3: 业务组件抽象** + - 新增 WashingLayout 组件 (`src/components/layout/WashingLayout.tsx`):解决水洗厂缺少独立 Layout 的架构债务 + - App.tsx 水洗厂路由重构:从扁平路由改为 WashingLayout 嵌套结构,三个角色布局架构完全统一 + - 实时订阅审计:确认 pages 目录无直接 supabase.channel 调用 +- **Phase 4: 代码治理** + - usePlanData 闭包陷阱修复:setState 从直接引用 state.plans 改为函数式更新 + - useInventoryData any 类型治理:18处 any 全部替换为 Supabase Tables<> 精确类型 + 局部接口 +- **项目统计更新**:35组件/25页面/10 Hooks + ### 2026-06-04 (v2.0.0) - 重大版本更新 - **系统审计与质量保障** - 完成四维度系统审计(文件关联/内存安全/路由配置/权限授权),全部通过 diff --git a/src/App.tsx b/src/App.tsx index 92ae732..6983c2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,8 @@ import { DemoWatermark } from './components/DemoWatermark'; import { DataIntegrityDashboard } from './components/DataIntegrityDashboard'; import { PurchaserLayout } from './components/layout/PurchaserLayout'; import { TextileLayout } from './components/layout/TextileLayout'; +import { WashingLayout } from './components/layout/WashingLayout'; +import { RoleGuard } from './components/RoleGuard'; import './styles/index.css'; // 页面过渡动画配置 @@ -92,37 +94,47 @@ function AppRoutes() { } /> } /> - {/* 采购商路由 - 使用 PurchaserLayout 作为布局 */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* 采购商路由 - RoleGuard + PurchaserLayout */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - {/* 纺织厂路由 - 使用 TextileLayout 作为布局 */} - }> - } /> - } /> - } /> - } /> - } /> + {/* 纺织厂路由 - RoleGuard + TextileLayout */} + }> + }> + } /> + } /> + } /> + } /> + } /> + - {/* 水洗厂路由 */} - } /> - } /> - } /> - } /> - } /> - } /> + {/* 水洗厂路由 - RoleGuard + WashingLayout */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + - {/* 子账号管理路由(所有角色共用) */} - } /> + {/* 子账号管理路由(所有已登录角色共用) */} + }> + } /> + {/* 导入计划路由 */} } /> diff --git a/src/components/RoleGuard.tsx b/src/components/RoleGuard.tsx new file mode 100644 index 0000000..ad40759 --- /dev/null +++ b/src/components/RoleGuard.tsx @@ -0,0 +1,39 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import type { UserRole } from '../types'; + +interface RoleGuardProps { + allowedRoles: UserRole[]; +} + +const ROLE_HOME: Record = { + purchaser: '/purchaser', + textile: '/textile', + washing: '/washing', +}; + +/** + * 路由守卫:校验当前用户角色是否在允许列表中 + * - 未登录 → 重定向 /login + * - 角色不匹配 → 重定向到当前角色的首页 + * - 角色匹配 → 渲染子路由 + */ +export function RoleGuard({ allowedRoles }: RoleGuardProps) { + const { auth, loading } = useAuth(); + + if (loading) { + return null; + } + + if (!auth.isLoggedIn || !auth.user) { + return ; + } + + const currentRole = auth.currentRole; + if (!currentRole || !allowedRoles.includes(currentRole)) { + const redirectPath = currentRole ? ROLE_HOME[currentRole] : '/role-select'; + return ; + } + + return ; +} diff --git a/src/components/layout/PurchaserLayout.tsx b/src/components/layout/PurchaserLayout.tsx index 202bf2b..2f58506 100644 --- a/src/components/layout/PurchaserLayout.tsx +++ b/src/components/layout/PurchaserLayout.tsx @@ -17,6 +17,7 @@ import { } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; import { useResponsive } from '../../hooks/useResponsive'; +import { STORAGE_KEYS } from '../../config/app'; interface NavItem { id: string; @@ -44,7 +45,7 @@ export function PurchaserLayout() { // 从 localStorage 读取侧边栏状态,默认展开 const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { - const saved = localStorage.getItem('purchaser_sidebar_collapsed'); + const saved = localStorage.getItem(STORAGE_KEYS.purchaserSidebarCollapsed); return saved ? JSON.parse(saved) : false; }); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); @@ -53,7 +54,7 @@ export function PurchaserLayout() { const toggleSidebar = () => { const newState = !sidebarCollapsed; setSidebarCollapsed(newState); - localStorage.setItem('purchaser_sidebar_collapsed', JSON.stringify(newState)); + localStorage.setItem(STORAGE_KEYS.purchaserSidebarCollapsed, JSON.stringify(newState)); }; const isActive = (path: string) => { diff --git a/src/components/layout/TextileLayout.tsx b/src/components/layout/TextileLayout.tsx index ebb93af..37e22ea 100644 --- a/src/components/layout/TextileLayout.tsx +++ b/src/components/layout/TextileLayout.tsx @@ -14,6 +14,7 @@ import { } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; import { useResponsive } from '../../hooks/useResponsive'; +import { STORAGE_KEYS } from '../../config/app'; interface NavItem { id: string; @@ -38,7 +39,7 @@ export function TextileLayout() { // 从 localStorage 读取侧边栏状态,默认展开 const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { - const saved = localStorage.getItem('textile_sidebar_collapsed'); + const saved = localStorage.getItem(STORAGE_KEYS.textileSidebarCollapsed); return saved ? JSON.parse(saved) : false; }); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); @@ -47,7 +48,7 @@ export function TextileLayout() { const toggleSidebar = () => { const newState = !sidebarCollapsed; setSidebarCollapsed(newState); - localStorage.setItem('textile_sidebar_collapsed', JSON.stringify(newState)); + localStorage.setItem(STORAGE_KEYS.textileSidebarCollapsed, JSON.stringify(newState)); }; const isActive = (path: string) => { diff --git a/src/components/layout/WashingLayout.tsx b/src/components/layout/WashingLayout.tsx index 043b53c..72cb6d5 100644 --- a/src/components/layout/WashingLayout.tsx +++ b/src/components/layout/WashingLayout.tsx @@ -3,6 +3,8 @@ import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { motion } from 'framer-motion'; import { FileText, + Package, + CheckCircle, Warehouse, CreditCard, LogOut, @@ -10,12 +12,11 @@ import { ChevronRight, Droplets, Menu, - X, - Package, - CheckCircle + X } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; import { useResponsive } from '../../hooks/useResponsive'; +import { STORAGE_KEYS } from '../../config/app'; interface NavItem { id: string; @@ -41,7 +42,7 @@ export function WashingLayout() { // 从 localStorage 读取侧边栏状态,默认展开 const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { - const saved = localStorage.getItem('washing_sidebar_collapsed'); + const saved = localStorage.getItem(STORAGE_KEYS.washingSidebarCollapsed); return saved ? JSON.parse(saved) : false; }); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); @@ -50,7 +51,7 @@ export function WashingLayout() { const toggleSidebar = () => { const newState = !sidebarCollapsed; setSidebarCollapsed(newState); - localStorage.setItem('washing_sidebar_collapsed', JSON.stringify(newState)); + localStorage.setItem(STORAGE_KEYS.washingSidebarCollapsed, JSON.stringify(newState)); }; const isActive = (path: string) => { @@ -82,7 +83,7 @@ export function WashingLayout() { return (
- {/* 桌面端侧边栏 - 固定不动,无入场动画 */} + {/* 桌面端侧边栏 */} {!isMobile && (
)} - {/* 主内容区 - 移动端无侧边栏,桌面端有侧边栏 */} + {/* 主内容区 */}
+ } ${isMobile ? 'pb-20' : ''}`}>
diff --git a/src/config/app.ts b/src/config/app.ts index 4419f0e..3d44896 100644 --- a/src/config/app.ts +++ b/src/config/app.ts @@ -27,7 +27,7 @@ export const APP_CONFIG = { /** 定位描述 */ description: '纺织行业全流程协作管理平台', /** 版本号 */ - version: '2.1.0', + version: '2.2.0', /** 默认密码(仅 Demo 环境) */ defaultDemoPassword: '123456', /** 免密登录有效期(毫秒),默认30天 */ diff --git a/src/hooks/useErrorHandler.ts b/src/hooks/useErrorHandler.ts index dfc6619..fcbca8d 100644 --- a/src/hooks/useErrorHandler.ts +++ b/src/hooks/useErrorHandler.ts @@ -1,98 +1,52 @@ import { useCallback } from 'react'; -export type ErrorSeverity = 'info' | 'warning' | 'error' | 'critical'; - -interface ErrorHandlerOptions { - onError?: (error: Error, severity: ErrorSeverity) => void; - showToast?: boolean; - logToConsole?: boolean; +interface ErrorOptions { + /** 用户可见的错误提示 */ + userMessage?: string; + /** 是否显示 alert(默认 true) */ + showAlert?: boolean; + /** 额外的上下文信息 */ + context?: Record; } /** - * 统一的错误处理 Hook - * 提供一致的错误处理、日志记录和用户反馈机制 + * 统一错误处理 Hook + * + * 替代散落在各处的 console.error + alert 模式,提供: + * - 统一的日志格式(带时间戳和上下文) + * - 可选的用户提示 + * - 错误上报扩展点 + * + * @example + * ```tsx + * const handleError = useErrorHandler(); + * + * try { + * await supabase.from('plans').insert(data); + * } catch (err) { + * handleError(err, { userMessage: '保存计划失败,请重试' }); + * } + * ``` */ -export function useErrorHandler(options: ErrorHandlerOptions = {}) { - const { - onError, - showToast = true, - logToConsole = true - } = options; +export function useErrorHandler() { + const handleError = useCallback((error: unknown, options: ErrorOptions = {}) => { + const { userMessage, showAlert = true, context } = options; - const handleError = useCallback(( - error: unknown, - message?: string, - severity: ErrorSeverity = 'error' - ) => { - // 标准化错误对象 - const err = error instanceof Error - ? error - : new Error(message || String(error)); + // 结构化日志 + const logEntry = { + timestamp: new Date().toISOString(), + error: error instanceof Error ? { message: error.message, stack: error.stack } : String(error), + context, + }; + console.error('[AppError]', logEntry); - // 控制台日志 - if (logToConsole) { - const logMethod = severity === 'critical' ? console.error - : severity === 'error' ? console.error - : severity === 'warning' ? console.warn - : console.log; - - logMethod(`[${severity.toUpperCase()}] ${message || err.message}`, err); + // 用户提示 + if (showAlert && userMessage) { + alert(userMessage); + } else if (showAlert && error instanceof Error) { + alert(error.message || '操作失败,请重试'); } + }, []); - // 自定义错误回调 - if (onError) { - onError(err, severity); - } - - // Toast 提示(如果可用) - if (showToast && typeof window !== 'undefined') { - // 这里可以集成具体的 toast 库 - // 暂时使用简单的 alert 作为后备 - if (severity === 'critical' || severity === 'error') { - // 在生产环境中应该使用更优雅的通知组件 - console.error('Error notification:', message || err.message); - } - } - - return err; - }, [onError, showToast, logToConsole]); - - // 便捷方法 - const handleInfo = useCallback((error: unknown, message?: string) => { - return handleError(error, message, 'info'); - }, [handleError]); - - const handleWarning = useCallback((error: unknown, message?: string) => { - return handleError(error, message, 'warning'); - }, [handleError]); - - const handleErrorLevel = useCallback((error: unknown, message?: string) => { - return handleError(error, message, 'error'); - }, [handleError]); - - const handleCritical = useCallback((error: unknown, message?: string) => { - return handleError(error, message, 'critical'); - }, [handleError]); - - // 异步操作包装器 - const withErrorHandling = useCallback(async ( - operation: () => Promise, - errorMessage?: string, - severity: ErrorSeverity = 'error' - ): Promise => { - try { - return await operation(); - } catch (err) { - handleError(err, errorMessage, severity); - return null; - } - }, [handleError]); - - return { - handleError: handleErrorLevel, - handleInfo, - handleWarning, - handleCritical, - withErrorHandling - }; + return handleError; } diff --git a/src/hooks/useInventoryData.ts b/src/hooks/useInventoryData.ts index a1c2dbf..92f0f48 100644 --- a/src/hooks/useInventoryData.ts +++ b/src/hooks/useInventoryData.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { supabase } from '../supabase/client'; import { CACHE_CONFIG } from '../config/app'; import { useInventoryRealtime } from './useRealtime'; +import type { Tables } from '../supabase/types'; import type { Product, ProductWithInventory, @@ -23,6 +24,28 @@ interface InventoryDataState { error: Error | null; } +// Supabase Row 类型别名 +type InventoryRecord = Tables<'inventory_records'>; +type ProductInventoryRecord = Tables<'product_inventory_records'>; +type ProductOutboundRecord = Tables<'product_outbound_records'>; + +// 部分字段查询的局部类型(避免使用 any) +interface PlanSummary { + id: string; + fabric_code: string; + completed_quantity: number; +} + +interface PlanFactoryRef { + plan_id: string; + factory_id: string; +} + +interface CompanyRef { + id: string; + name: string; +} + /** * 优化的库存数据获取 Hook * 使用缓存和防抖来减少不必要的请求 @@ -93,8 +116,8 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti if (outboundResult.error) throw outboundResult.error; const productsData = productsResult.data || []; - const batchesData = batchesResult.data || []; - const outboundData = outboundResult.data || []; + const batchesData: ProductInventoryRecord[] = batchesResult.data || []; + const outboundData: ProductOutboundRecord[] = outboundResult.data || []; // 获取纱线配比 const productIds = productsData.map(p => p.id); @@ -122,7 +145,8 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti .eq('purchaser_id', companyId) .in('fabric_code', fabricCodes); - const planIds = plansData?.map((p: any) => p.id).filter(Boolean) || []; + const typedPlansData: PlanSummary[] = plansData || []; + const planIds = typedPlansData.map(p => p.id).filter(Boolean); // 获取工厂信息 const { data: planFactoriesData } = await supabase @@ -131,35 +155,38 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti .in('plan_id', planIds) .eq('factory_type', 'textile'); - const factoryIds = [...new Set((planFactoriesData || []).map(pf => pf.factory_id).filter(Boolean))]; + const typedPlanFactories: PlanFactoryRef[] = planFactoriesData || []; + const factoryIds = [...new Set(typedPlanFactories.map(pf => pf.factory_id).filter(Boolean))]; const { data: factoriesData } = await supabase .from('companies') .select('id, name') .in('id', factoryIds); + const typedFactories: CompanyRef[] = factoriesData || []; + // 建立映射 const planToFactoryName: Record = {}; - planFactoriesData?.forEach((pf: any) => { - const factory = factoriesData?.find(f => f.id === pf.factory_id); + typedPlanFactories.forEach((pf: PlanFactoryRef) => { + const factory = typedFactories.find(f => f.id === pf.factory_id); if (factory) { planToFactoryName[pf.plan_id] = factory.name; } }); // 获取入库记录 - let textileInventoryRecords: any[] = []; + let textileInventoryRecords: InventoryRecord[] = []; if (planIds.length > 0) { const { data: records } = await supabase .from('inventory_records') .select('plan_id, quantity, rolls, warehouse_location') .in('plan_id', planIds); - textileInventoryRecords = records || []; + textileInventoryRecords = (records || []) as InventoryRecord[]; } // 处理数据 const updatedProducts = processInventoryData({ products: productsData, - plansData: plansData || [], + plansData: typedPlansData, textileInventoryRecords, planToFactoryName, batchesData, @@ -167,22 +194,25 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti }); // 为 batches 数据注入产品信息 - const batchesWithProduct = (batchesData || []).map((batch: any) => { + const batchesWithProduct: FabricBatch[] = batchesData.map((batch: ProductInventoryRecord) => { const product = productsData.find((p: Product) => p.id === batch.product_id); return { - ...batch, - product: product ? { - product_name: product.product_name, - weight: product.weight, - color: product.color - } : null - }; + id: batch.id, + product_id: batch.product_id, + company_id: batch.company_id || '', + batch_no: batch.batch_no, + rolls: batch.rolls, + meters: batch.meters, + notes: batch.notes, + created_at: batch.created_at, + product: product || undefined + } as FabricBatch; }); const newState: InventoryDataState = { products: updatedProducts, productRatios: ratiosMap, - batches: batchesWithProduct as FabricBatch[], + batches: batchesWithProduct, loading: false, error: null }; @@ -224,11 +254,11 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti // 处理库存数据的辅助函数 interface ProcessInventoryDataParams { products: Product[]; - plansData: any[]; - textileInventoryRecords: any[]; + plansData: PlanSummary[]; + textileInventoryRecords: InventoryRecord[]; planToFactoryName: Record; - batchesData: any[]; - outboundData: any[]; + batchesData: ProductInventoryRecord[]; + outboundData: ProductOutboundRecord[]; } function processInventoryData({ @@ -242,7 +272,7 @@ function processInventoryData({ // 建立 plan_id 到 fabric_code 的映射 const planToFabricCode: Record = {}; const planCompletedMap: Record = {}; - plansData?.forEach((plan: any) => { + plansData.forEach((plan: PlanSummary) => { planToFabricCode[plan.id] = plan.fabric_code; planCompletedMap[plan.id] = plan.completed_quantity || 0; }); @@ -250,7 +280,7 @@ function processInventoryData({ // 按 plan_id 汇总库存 const planInventoryMap: Record = {}; - textileInventoryRecords?.forEach((r: any) => { + textileInventoryRecords.forEach((r: InventoryRecord) => { const factoryName = planToFactoryName[r.plan_id] || r.warehouse_location || '纺织厂'; const factoryId = planToFactoryName[r.plan_id] || r.warehouse_location || `factory_${r.plan_id}`; if (!planInventoryMap[r.plan_id]) { @@ -262,7 +292,7 @@ function processInventoryData({ // 汇总每个坯布码的库存 const fabricCodeInventory: Record = {}; - const fabricCodeFactoryInventory: Record> = {}; + const fabricCodeFactoryInventory: Record> = {}; Object.entries(planInventoryMap).forEach(([planId, inventory]) => { const fabricCode = planToFabricCode[planId]; @@ -295,7 +325,7 @@ function processInventoryData({ const inRecordsMap: Record = {}; // 1. 先添加自录入的入库记录 - batchesData?.forEach((b: any) => { + batchesData.forEach((b: ProductInventoryRecord) => { if (!inRecordsMap[b.product_id]) { inRecordsMap[b.product_id] = []; } @@ -318,12 +348,12 @@ function processInventoryData({ // 2. 添加纺织厂的入库记录 // 建立 fabric_code 到 product_id 的映射 const fabricCodeToProductId: Record = {}; - products.forEach((p: any) => { + products.forEach((p: Product) => { fabricCodeToProductId[p.fabric_code] = p.id; }); // 将纺织厂入库记录按 fabric_code 关联到对应产品 - textileInventoryRecords?.forEach((r: any) => { + textileInventoryRecords.forEach((r: InventoryRecord) => { const fabricCode = planToFabricCode[r.plan_id]; const productId = fabricCodeToProductId[fabricCode]; if (productId) { @@ -335,10 +365,10 @@ function processInventoryData({ id: r.id || `${r.plan_id}-${r.created_at}`, product_id: productId, company_id: r.company_id, - batch_no: r.batch_no || '-', + batch_no: '-', rolls: Number(r.rolls) || 0, meters: Number(r.quantity) || 0, - notes: r.notes || `来自${factoryName}`, + notes: `来自${factoryName}`, operator_id: r.operator_id, warehouse_id: r.warehouse_id, created_at: r.created_at, @@ -356,34 +386,19 @@ function processInventoryData({ }); // 处理出库记录 - 按 product_id 汇总 - const outboundMap: Record = {}; - outboundData?.forEach((r: any) => { + const outboundMap: Record = {}; + outboundData.forEach((r: ProductOutboundRecord) => { if (!outboundMap[r.product_id]) { outboundMap[r.product_id] = { totalRolls: 0, totalMeters: 0, records: [] }; } outboundMap[r.product_id].totalRolls += Number(r.rolls) || 0; outboundMap[r.product_id].totalMeters += Number(r.meters) || 0; - outboundMap[r.product_id].records.push({ - id: r.id, - product_id: r.product_id, - batch_no: r.batch_no, - rolls: r.rolls, - meters: r.meters, - notes: r.notes, - outbound_type: r.outbound_type, - washing_plan_id: r.washing_plan_id, - created_at: r.created_at, - time: r.created_at, - // 新增字段:生产批号和接手公司 - source_batch_id: r.source_batch_id, - recipient_company_id: r.recipient_company_id, - recipient_company_name: r.recipient_company_name - }); + outboundMap[r.product_id].records.push(r); }); // 计算每个产品的自录入库总量(从 batchesData) const selfInventoryMap: Record = {}; - batchesData?.forEach((b: any) => { + batchesData.forEach((b: ProductInventoryRecord) => { if (!selfInventoryMap[b.product_id]) { selfInventoryMap[b.product_id] = { rolls: 0, meters: 0 }; } @@ -392,7 +407,7 @@ function processInventoryData({ }); // 更新产品数据 - 扣除出库数量 - return products.map((p: any) => { + return products.map((p: Product) => { const textileInventory = fabricCodeInventory[p.fabric_code] || { meters: 0, rolls: 0 }; const factoryInventories = fabricCodeFactoryInventory[p.fabric_code] || {}; // 使用实时计算的入库记录,而不是 products 表中的字段 diff --git a/src/hooks/usePlanData.ts b/src/hooks/usePlanData.ts index 5ccb322..4e81b12 100644 --- a/src/hooks/usePlanData.ts +++ b/src/hooks/usePlanData.ts @@ -248,25 +248,27 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag yarnRatiosMap[y.plan_id].push(y as YarnRatio); }); - const newState: PlanDataState = { - plans: append ? [...state.plans, ...plansWithSteps] : plansWithSteps, - factories: isFirstPage ? factories : state.factories, - planFactories: isFirstPage ? (pfResult.data || []) : state.planFactories, - operatorNames: isFirstPage ? operatorNames : { ...state.operatorNames, ...operatorNames }, - yarnRatios: isFirstPage ? yarnRatiosMap : { ...state.yarnRatios, ...yarnRatiosMap }, - loading: false, - loadingMore: false, - hasMore: planData.length === pageSize, - currentPage: page, - error: null - }; + setState(prev => { + const newState: PlanDataState = { + plans: append ? [...prev.plans, ...plansWithSteps] : plansWithSteps, + factories: isFirstPage ? factories : prev.factories, + planFactories: isFirstPage ? (pfResult.data || []) : prev.planFactories, + operatorNames: isFirstPage ? operatorNames : { ...prev.operatorNames, ...operatorNames }, + yarnRatios: isFirstPage ? yarnRatiosMap : { ...prev.yarnRatios, ...yarnRatiosMap }, + loading: false, + loadingMore: false, + hasMore: planData.length === pageSize, + currentPage: page, + error: null + }; - // 更新缓存 - if (isFirstPage) { - cacheRef.current = { data: newState, timestamp: Date.now() }; - } + // 更新缓存 + if (isFirstPage) { + cacheRef.current = { data: newState, timestamp: Date.now() }; + } - setState(newState); + return newState; + }); } catch (err) { setState(prev => ({ ...prev, diff --git a/src/pages/DemoDataSharing.tsx b/src/pages/DemoDataSharing.tsx index 0ed8fa9..938a77c 100644 --- a/src/pages/DemoDataSharing.tsx +++ b/src/pages/DemoDataSharing.tsx @@ -1,12 +1,13 @@ import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { useNavigate } from 'react-router-dom'; -import { - ArrowLeft, Eye, EyeOff, Lock, Unlock, Share2, +import { + ArrowLeft, Eye, EyeOff, Lock, Unlock, Share2, Building2, Factory, Package, Link2, CheckCircle, AlertTriangle, FileText, Palette, Hash, DollarSign, ChevronRight, ChevronDown, Shield, Database } from 'lucide-react'; +import { PLAN_STATUS } from '../utils/constants'; // 模拟完整计划数据(采购商视角) const fullPlanData = { @@ -20,7 +21,7 @@ const fullPlanData = { target_quantity: 10000, yarn_usage_per_meter: 280, production_price: 12.5, - status: 'producing' + status: PLAN_STATUS.PRODUCING }; // 模拟纱线配比 diff --git a/src/pages/ImportPlanPage.tsx b/src/pages/ImportPlanPage.tsx index 9fc2bdf..77db240 100644 --- a/src/pages/ImportPlanPage.tsx +++ b/src/pages/ImportPlanPage.tsx @@ -6,6 +6,8 @@ import { motion } from 'framer-motion'; import { ArrowLeft, CheckCircle, AlertTriangle, Package, Droplet, Link2, Clock, XCircle, Building2 } from 'lucide-react'; import { decryptShareParams } from '../utils/shareCrypto'; import { useResponsive } from '../hooks/useResponsive'; +import { useErrorHandler } from '../hooks/useErrorHandler'; +import { PLAN_STATUS, SHARE_LINK_STATUS } from '../utils/constants'; import type { ProductionPlan, WashingPlan, YarnRatio, FactoryType } from '../types'; const containerVariants = { @@ -26,6 +28,7 @@ export function ImportPlanPage() { const [searchParams] = useSearchParams(); const { auth } = useAuth(); const { isMobile } = useResponsive(); + const handleError = useErrorHandler(); const token = searchParams.get('token') || ''; const shortCode = searchParams.get('s') || ''; @@ -66,12 +69,13 @@ export function ImportPlanPage() { // 更新状态为过期 await supabase .from('share_links') - .update({ status: 'expired' }) + .update({ status: SHARE_LINK_STATUS.EXPIRED }) .eq('short_code', shortCode); } // 检查是否已被使用(confirmed/rejected/expired) - if (['confirmed', 'rejected', 'expired', 'cancelled'].includes(data.status)) { + const usedStatuses: ShareLinkStatus[] = [SHARE_LINK_STATUS.CONFIRMED as ShareLinkStatus, SHARE_LINK_STATUS.REJECTED as ShareLinkStatus, SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus, SHARE_LINK_STATUS.CANCELLED as ShareLinkStatus]; + if (usedStatuses.includes(data.status as ShareLinkStatus)) { setDecrypting(false); return; } @@ -81,29 +85,29 @@ export function ImportPlanPage() { setHideSensitive(data.hide_sensitive || false); // 如果状态是 pending,更新为 clicked(首次点击) - if (data.status === 'pending') { + if (data.status === SHARE_LINK_STATUS.PENDING) { const { error: updateError } = await supabase .from('share_links') .update({ - status: 'clicked', + status: SHARE_LINK_STATUS.CLICKED, clicked_at: new Date().toISOString() }) .eq('short_code', shortCode); if (updateError) { - console.error('更新链接点击状态失败:', updateError); + handleError(updateError, { userMessage: '更新链接点击状态失败', context: { shortCode } }); } else { - setShareLinkStatus('clicked'); + setShareLinkStatus(SHARE_LINK_STATUS.CLICKED as ShareLinkStatus); } setLinkClicked(true); - } else if (data.status === 'clicked') { + } else if (data.status === SHARE_LINK_STATUS.CLICKED) { setLinkClicked(true); } // 增加点击计数 await supabase.rpc('increment_share_link_clicks', { code: shortCode }); } else { - console.error('短链查询失败:', error); + handleError(error, { userMessage: '短链查询失败', context: { shortCode } }); } setDecrypting(false); } else if (token) { @@ -145,12 +149,12 @@ export function ImportPlanPage() { if (diff <= 0) { setTimeRemaining('已过期'); setLinkExpired(true); - if (data.status !== 'expired' && data.status !== 'confirmed' && data.status !== 'rejected') { + if (data.status !== SHARE_LINK_STATUS.EXPIRED && data.status !== SHARE_LINK_STATUS.CONFIRMED && data.status !== SHARE_LINK_STATUS.REJECTED) { await supabase .from('share_links') - .update({ status: 'expired' }) + .update({ status: SHARE_LINK_STATUS.EXPIRED }) .eq('short_code', shortCode); - setShareLinkStatus('expired'); + setShareLinkStatus(SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus); } } else { const minutes = Math.floor(diff / 60000); @@ -250,11 +254,11 @@ export function ImportPlanPage() { // 1.5 更新计划状态为 producing(生产中) const { error: planUpdateError } = await supabase .from('production_plans') - .update({ status: 'producing' }) + .update({ status: PLAN_STATUS.PRODUCING }) .eq('id', planId); if (planUpdateError) { - console.error('更新计划状态失败:', planUpdateError); + handleError(planUpdateError, { userMessage: '更新计划状态失败', context: { planId } }); } // 2. 创建/更新公司合作关系 @@ -268,7 +272,7 @@ export function ImportPlanPage() { }); if (relError) { - console.error('创建合作关系失败:', relError); + handleError(relError, { userMessage: '创建合作关系失败', context: { purchaserCompanyId, factoryCompanyId: auth.company.id } }); } // 3. 更新分享链接状态 @@ -276,14 +280,14 @@ export function ImportPlanPage() { const { error: linkUpdateError } = await supabase .from('share_links') .update({ - status: 'confirmed', + status: SHARE_LINK_STATUS.CONFIRMED, responded_at: new Date().toISOString(), response_result: 'confirmed' }) .eq('short_code', shortCode); if (linkUpdateError) { - console.error('更新分享链接状态失败:', linkUpdateError); + handleError(linkUpdateError, { userMessage: '更新分享链接状态失败', context: { shortCode } }); } } @@ -300,8 +304,8 @@ export function ImportPlanPage() { await supabase .from('share_links') - .update({ - status: 'rejected', + .update({ + status: SHARE_LINK_STATUS.REJECTED, responded_at: new Date().toISOString(), response_result: 'rejected', reject_reason: rejectReason diff --git a/src/pages/purchaser/Dashboard.tsx b/src/pages/purchaser/Dashboard.tsx index 37209fe..780d023 100644 --- a/src/pages/purchaser/Dashboard.tsx +++ b/src/pages/purchaser/Dashboard.tsx @@ -18,12 +18,6 @@ import { DollarSign, Droplets, LogOut, - HelpCircle, - X, - BookOpen, - MessageSquare, - Info, - Camera, ChevronRight, Home, MoreHorizontal, @@ -42,6 +36,8 @@ import { FeedbackModal } from '../../components/FeedbackModal'; import { UserManual } from '../../components/UserManual'; import type { ProductionPlan } from '../../types'; import { LoadingSpinner } from '../../components/ui/LoadingSpinner'; +import { AccountProfileCard } from '../../components/AccountProfileCard'; +import { PLAN_STATUS, STEP_STATUS } from '../../utils/constants'; const statusMap: Record = { pending: { label: '待确定', color: 'text-amber-600', bgColor: 'bg-amber-50', borderColor: 'border-amber-200', icon: Clock }, @@ -104,7 +100,7 @@ export function PurchaserDashboard() { // 过滤掉有 rejected 步骤的计划 const rejectedPlanIds = new Set(); (stepsData || []).forEach(step => { - if (step.status === 'rejected') { + if (step.status === STEP_STATUS.REJECTED) { rejectedPlanIds.add(step.plan_id); } }); @@ -130,14 +126,14 @@ export function PurchaserDashboard() { const stats = { all: plans.length, - producing: plans.filter(p => p.status === 'producing').length, - completed: plans.filter(p => p.status === 'completed').length, - pending: plans.filter(p => p.status === 'pending').length + producing: plans.filter(p => p.status === PLAN_STATUS.PRODUCING).length, + completed: plans.filter(p => p.status === PLAN_STATUS.COMPLETED).length, + pending: plans.filter(p => p.status === PLAN_STATUS.PENDING).length }; // 获取最近计划(桌面端显示更多) const recentPlans = plans.slice(0, isMobile ? 5 : 10); - const producingPlans = plans.filter(p => p.status === 'producing').slice(0, isMobile ? 5 : 6); + const producingPlans = plans.filter(p => p.status === PLAN_STATUS.PRODUCING).slice(0, isMobile ? 5 : 6); // 桌面端统计卡片数据 const statCards = [ @@ -467,147 +463,14 @@ export function PurchaserDashboard() { /> {/* 账户卡片弹窗 */} - {showAccountCard && ( -
setShowAccountCard(false)}> - e.stopPropagation()} - > - {/* 头部 */} -
-
-

账户信息

- -
-
- - {/* 账户详情 */} -
- {/* 用户信息 */} -
- -
-

{(auth.user as any)?.display_name || auth.user?.username || '用户'}

-

{auth.company?.name || '采购商'}

- - {auth.user?.is_master ? '主账号' : '子账号'} - -
-
- - {/* 功能菜单 */} -
- - - - - - - -
- - {/* 退出登录 */} - -
-
-
- )} + setShowAccountCard(false)} + role="purchaser" + onShowUserManual={() => setShowUserManual(true)} + onShowFeedback={() => setShowFeedback(true)} + onShowAbout={() => setShowAbout(true)} + /> ); } diff --git a/src/pages/purchaser/FactoryManage.tsx b/src/pages/purchaser/FactoryManage.tsx index eac0157..1dbde08 100644 --- a/src/pages/purchaser/FactoryManage.tsx +++ b/src/pages/purchaser/FactoryManage.tsx @@ -5,12 +5,14 @@ import { supabase } from '../../supabase/client'; import { motion, AnimatePresence } from 'framer-motion'; import { Factory, Droplets, FileText, ChevronRight, ArrowLeft, Plus, X, Building2, Phone, MapPin, User, Edit2, Trash2, AlertTriangle } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; +import { useErrorHandler } from '../../hooks/useErrorHandler'; import type { Company } from '../../types'; export function FactoryManage() { const navigate = useNavigate(); const { auth } = useAuth(); const { isMobile } = useResponsive(); + const handleError = useErrorHandler(); const [activeTab, setActiveTab] = useState<'textile' | 'washing'>('textile'); const [factories, setFactories] = useState([]); const [planCounts, setPlanCounts] = useState>({}); @@ -124,14 +126,13 @@ export function FactoryManage() { .limit(1); if (checkError) { - console.error('检查关联计划失败:', checkError); - alert('检查关联计划失败:' + checkError.message); + handleError(checkError, { userMessage: '检查关联计划失败' }); setDeleting(false); return; } if (planFactories && planFactories.length > 0) { - alert('该工厂已有关联计划,无法删除'); + handleError(new Error('无法删除'), { userMessage: '该工厂已有关联计划,无法删除' }); setDeleting(false); return; } @@ -145,8 +146,7 @@ export function FactoryManage() { .eq('factory_company_id', deletingFactory.id); if (relError) { - console.error('删除合作关系失败:', relError); - alert('删除合作关系失败:' + relError.message); + handleError(relError, { userMessage: '删除合作关系失败' }); setDeleting(false); return; } @@ -161,8 +161,7 @@ export function FactoryManage() { .select(); if (deleteError) { - console.error('删除工厂失败:', deleteError); - alert('删除工厂失败:' + deleteError.message); + handleError(deleteError, { userMessage: '删除工厂失败' }); setDeleting(false); return; } @@ -177,8 +176,7 @@ export function FactoryManage() { } handleCloseDeleteModal(); } catch (err) { - console.error('删除工厂失败:', err); - alert('删除工厂时发生错误'); + handleError(err, { userMessage: '删除工厂时发生错误' }); } setDeleting(false); }; diff --git a/src/pages/purchaser/NewPlan.tsx b/src/pages/purchaser/NewPlan.tsx index e4a3f5e..1c5995d 100644 --- a/src/pages/purchaser/NewPlan.tsx +++ b/src/pages/purchaser/NewPlan.tsx @@ -4,7 +4,9 @@ import { useAuth } from '../../contexts/AuthContext'; import { supabase } from '../../supabase/client'; import { ArrowLeft, Plus, Trash2, Search, X, ChevronDown } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; +import { useErrorHandler } from '../../hooks/useErrorHandler'; import type { Company, FactoryType } from '../../types'; +import { PLAN_STATUS, STEP_STATUS } from '../../utils/constants'; interface YarnInput { id: string; @@ -48,6 +50,7 @@ const yarnTypeLabels: Record = { export function NewPlan() { const navigate = useNavigate(); const { auth } = useAuth(); + const handleError = useErrorHandler(); // 从产品信息选择的产品 const [selectedProduct, setSelectedProduct] = useState(null); @@ -214,14 +217,14 @@ export function NewPlan() { target_quantity: parseInt(targetQuantity) || 0, yarn_usage_per_meter: selectedProduct?.weight || 0, // 使用产品克重作为纱用量 production_price: parseFloat(productionPrice) || 0, - status: 'pending', + status: PLAN_STATUS.PENDING, created_by: auth.user?.id }) .select() .maybeSingle(); if (planError) { - alert('创建计划失败: ' + planError.message); + handleError(planError, { userMessage: '创建计划失败' }); setSubmitting(false); return; } @@ -254,7 +257,7 @@ export function NewPlan() { const { error: stepError } = await supabase.from('plan_process_steps').insert({ plan_id: plan.id, step_type: step, - status: 'pending' as const + status: STEP_STATUS.PENDING }); if (stepError) { stepErrors.push(stepError.message); @@ -291,7 +294,7 @@ export function NewPlan() { alert('计划创建成功!'); navigate('/purchaser/plans'); } catch (err) { - alert('创建失败,请重试'); + handleError(err, { userMessage: '创建失败,请重试' }); } setSubmitting(false); }; diff --git a/src/pages/purchaser/NewWashingPlan.tsx b/src/pages/purchaser/NewWashingPlan.tsx index b2b7a08..5cf76ec 100644 --- a/src/pages/purchaser/NewWashingPlan.tsx +++ b/src/pages/purchaser/NewWashingPlan.tsx @@ -6,6 +6,7 @@ import { motion } from 'framer-motion'; import { ArrowLeft, Plus, Droplets, Calculator, AlertCircle } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; import type { ProductWithInventory, Company } from '../../types'; +import { WASHING_PLAN_STATUS } from '../../utils/constants'; interface WashingPlanForm { productId: string; @@ -198,7 +199,7 @@ export function NewWashingPlan() { estimated_shrinkage_rate: parseFloat(form.estimatedShrinkageRate) || 0, notes: form.notes || null, created_by: auth.user?.id, - status: 'pending' + status: WASHING_PLAN_STATUS.PENDING }).select().single(); if (insertError) { diff --git a/src/pages/purchaser/WarehouseManage.tsx b/src/pages/purchaser/WarehouseManage.tsx index f301532..ba19099 100644 --- a/src/pages/purchaser/WarehouseManage.tsx +++ b/src/pages/purchaser/WarehouseManage.tsx @@ -4,6 +4,7 @@ import { useAuth } from '../../contexts/AuthContext'; import { supabase } from '../../supabase/client'; import { useInventoryData } from '../../hooks/useInventoryData'; import { useResponsive } from '../../hooks/useResponsive'; +import { useErrorHandler } from '../../hooks/useErrorHandler'; import { motion, AnimatePresence } from 'framer-motion'; import { Package, ArrowLeft, Plus, Search, List } from 'lucide-react'; import { WarehouseNav } from '../../components/warehouse/WarehouseNav'; @@ -95,6 +96,7 @@ export function WarehouseManage() { const navigate = useNavigate(); const { auth } = useAuth(); const { isMobile } = useResponsive(); + const handleError = useErrorHandler(); // 菜单状态 const [activeSubMenu, setActiveSubMenu] = useState('products'); @@ -179,7 +181,7 @@ export function WarehouseManage() { .maybeSingle(); if (existingFabricCode) { - alert(`产品码 "${productData.fabric_code}" 已存在,请使用其他产品码`); + handleError(new Error('产品码重复'), { userMessage: `产品码 "${productData.fabric_code}" 已存在,请使用其他产品码` }); return; } @@ -197,7 +199,7 @@ export function WarehouseManage() { .maybeSingle(); if (existingColorCode) { - alert(`"${productData.product_name}-${productData.total_weight}g-${productData.color}" 组合中已存在色号 "${productData.color_code}",请使用其他色号`); + handleError(new Error('色号重复'), { userMessage: `"${productData.product_name}-${productData.total_weight}g-${productData.color}" 组合中已存在色号 "${productData.color_code}",请使用其他色号` }); return; } @@ -214,7 +216,7 @@ export function WarehouseManage() { .eq('id', editingProduct.id); if (error) { - alert('更新失败: ' + error.message); + handleError(error, { userMessage: '更新失败' }); return; } @@ -243,7 +245,7 @@ export function WarehouseManage() { .single(); if (error) { - alert('创建失败: ' + error.message); + handleError(error, { userMessage: '创建失败' }); return; } productId = data.id; @@ -275,7 +277,7 @@ export function WarehouseManage() { const { error } = await supabase.from('products').delete().eq('id', id); if (error) { - alert('删除失败: ' + error.message); + handleError(error, { userMessage: '删除失败' }); return; } await refetch(); @@ -360,7 +362,7 @@ export function WarehouseManage() { }); if (error) { - alert('入库失败: ' + error.message); + handleError(error, { userMessage: '入库失败' }); return; } @@ -386,15 +388,15 @@ export function WarehouseManage() { // 检查库存是否充足 const product = products.find(p => p.id === outForm.product_id); if (!product) { - alert('产品不存在'); + handleError(new Error('产品不存在'), { userMessage: '产品不存在' }); return; } if ((product.total_stock || 0) < outForm.meters) { - alert(`库存不足,当前库存: ${product.total_stock}米`); + handleError(new Error('库存不足'), { userMessage: `库存不足,当前库存: ${product.total_stock}米` }); return; } if ((product.raw_fabric_rolls || 0) < outForm.rolls) { - alert(`库存匹/件数不足,当前库存: ${product.raw_fabric_rolls}匹/件`); + handleError(new Error('库存不足'), { userMessage: `库存匹/件数不足,当前库存: ${product.raw_fabric_rolls}匹/件` }); return; } @@ -413,7 +415,7 @@ export function WarehouseManage() { }); if (error) { - alert('出库失败: ' + error.message); + handleError(error, { userMessage: '出库失败' }); return; } diff --git a/src/pages/textile/Dashboard.tsx b/src/pages/textile/Dashboard.tsx index 85d2c47..9e1ca51 100644 --- a/src/pages/textile/Dashboard.tsx +++ b/src/pages/textile/Dashboard.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; import { supabase } from '../../supabase/client'; import { motion } from 'framer-motion'; -import { Link2, Warehouse, CreditCard, ArrowLeft, Users, Factory, LogOut, HelpCircle, X, BookOpen, MessageSquare, Info, Camera, FileText, TrendingUp, Package, ChevronRight, Hash, Target, Activity, Clock } from 'lucide-react'; +import { Link2, Warehouse, CreditCard, ArrowLeft, Factory, LogOut, FileText, TrendingUp, Package, ChevronRight, Hash, Target, Activity, Clock } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide'; import { NotificationCenter } from '../../components/NotificationCenter'; @@ -15,6 +15,8 @@ import { CrashReporter } from '../../components/CrashReporter'; import { STORAGE_KEYS } from '../../config/app'; import type { ProductionPlan } from '../../types'; import { LoadingSpinner } from '../../components/ui/LoadingSpinner'; +import { AccountProfileCard } from '../../components/AccountProfileCard'; +import { PLAN_STATUS, STEP_STATUS } from '../../utils/constants'; const statusMap: Record = { pending: { label: '待确定', color: 'text-amber-600', bgColor: 'bg-amber-50', borderColor: 'border-amber-200', icon: Clock }, @@ -98,7 +100,7 @@ export function TextileDashboard() { // 标记有 rejected 步骤的计划 const rejectedIds = new Set(); (stepsData || []).forEach(step => { - if (step.status === 'rejected') { + if (step.status === STEP_STATUS.REJECTED) { rejectedIds.add(step.plan_id); } }); @@ -110,13 +112,13 @@ export function TextileDashboard() { const stats = { all: plans.length, - producing: plans.filter(p => p.status === 'producing').length, - completed: plans.filter(p => p.status === 'completed').length + producing: plans.filter(p => p.status === PLAN_STATUS.PRODUCING).length, + completed: plans.filter(p => p.status === PLAN_STATUS.COMPLETED).length }; // 过滤掉被拒绝的计划 const filteredPlans = plans.filter(p => !rejectedPlanIds.has(p.id)); - const producingPlans = filteredPlans.filter(p => p.status === 'producing').slice(0, isMobile ? 5 : 6); + const producingPlans = filteredPlans.filter(p => p.status === PLAN_STATUS.PRODUCING).slice(0, isMobile ? 5 : 6); const recentPlans = filteredPlans.slice(0, isMobile ? 5 : 10); const [currentPlanIndex, setCurrentPlanIndex] = useState(0); @@ -434,158 +436,14 @@ export function TextileDashboard() { {/* 账户卡片弹窗 */} - {showAccountCard && ( -
setShowAccountCard(false)}> - e.stopPropagation()} - > - {/* 头部 */} -
-
-

账户信息

- -
-
- - {/* 账户详情 */} -
- {/* 用户信息 */} -
- -
-

{(auth.user as any)?.display_name || auth.user?.username || '用户'}

-

{auth.company?.name || '纺织厂'}

- - {auth.user?.is_master ? '主账号' : '子账号'} - -
-
- - {/* 功能菜单 */} -
- - - - - - - -
- - {/* 退出登录 */} - -
-
-
- )} + setShowAccountCard(false)} + role="textile" + onShowUserManual={() => setShowUserManual(true)} + onShowFeedback={() => setShowFeedback(true)} + onShowAbout={() => setShowAbout(true)} + /> ); } diff --git a/src/pages/textile/PaymentPending.tsx b/src/pages/textile/PaymentPending.tsx index 6553923..44b68ff 100644 --- a/src/pages/textile/PaymentPending.tsx +++ b/src/pages/textile/PaymentPending.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; import { supabase } from '../../supabase/client'; import type { Payment, ProductionPlan } from '../../types'; +import { PAYMENT_STATUS } from '../../utils/constants'; export function PaymentPending() { const navigate = useNavigate(); @@ -25,7 +26,7 @@ export function PaymentPending() { .from('payments') .select('*') .eq('to_company_id', auth.company!.id) - .eq('status', 'pending'); + .eq('status', PAYMENT_STATUS.PENDING); if (!paymentData || paymentData.length === 0) { setPayments([]); @@ -57,7 +58,7 @@ export function PaymentPending() { const { error } = await supabase .from('payments') .update({ - status: 'completed' as const, + status: PAYMENT_STATUS.COMPLETED, paid_at: now }) .eq('id', paymentId); diff --git a/src/pages/textile/PlanOverview.tsx b/src/pages/textile/PlanOverview.tsx index e288a32..7fc9675 100644 --- a/src/pages/textile/PlanOverview.tsx +++ b/src/pages/textile/PlanOverview.tsx @@ -9,7 +9,9 @@ import { ProcessFlow } from '../../components/ProcessFlow'; import { notifyStepCompleted, notifyPlanUpdated } from '../../utils/notifications'; import { getSupabaseUrl } from '../../supabase/client'; import { simpleStatusMap as statusMap, PLAN_STATUS, STEP_STATUS } from '../../utils/constants'; +import { extractParamsFromLink } from '../../utils/helpers'; import { STORAGE_KEYS } from '../../config/app'; +import { useErrorHandler } from '../../hooks/useErrorHandler'; import type { ProductionPlan, PlanProcessStep, Company, Profile, YarnRatio } from '../../types'; // 纱线类型显示映射 @@ -37,6 +39,7 @@ interface PlanGroup { export function TextilePlanOverview() { const navigate = useNavigate(); const { auth } = useAuth(); + const handleError = useErrorHandler(); // 使用计划状态同步检查 Hook,每5分钟自动检查并修复状态不一致 usePlanStatusSync(auth.company?.id); @@ -245,32 +248,6 @@ export function TextilePlanOverview() { }); }; - // 从 hash 路由链接中提取查询参数 - const extractParamsFromLink = (link: string) => { - const hashMatch = link.match(/#\/import\?(.+)$/); - if (hashMatch) { - const queryString = hashMatch[1]; - const params = new URLSearchParams(queryString); - return { - token: params.get('token'), - plan: params.get('plan'), - type: params.get('type'), - shortCode: params.get('s') - }; - } - try { - const url = new URL(link, window.location.origin); - return { - token: url.searchParams.get('token'), - plan: url.searchParams.get('plan'), - type: url.searchParams.get('type'), - shortCode: url.searchParams.get('s') - }; - } catch { - return { token: null, plan: null, type: null, shortCode: null }; - } - }; - const handleImportFromLink = () => { if (!importLink.trim()) return; @@ -283,7 +260,7 @@ export function TextilePlanOverview() { } else if (plan && type) { navigate(`/import?plan=${plan}&type=${type}`); } else { - alert('无效的链接格式,请确保链接包含 token、s 或 plan 参数'); + handleError(new Error('无效的链接格式'), { userMessage: '请确保链接包含 token、s 或 plan 参数' }); } }; @@ -301,14 +278,13 @@ export function TextilePlanOverview() { } else if (plan && type) { navigate(`/import?plan=${plan}&type=${type}`); } else { - alert('链接格式不正确,请检查链接是否完整'); + handleError(new Error('链接格式不正确'), { userMessage: '请检查链接是否完整' }); } } else { - alert('剪贴板中没有检测到有效的导入链接'); + handleError(new Error('无效链接'), { userMessage: '剪贴板中没有检测到有效的导入链接' }); } } catch (err) { - console.error('读取剪贴板失败:', err); - alert('无法读取剪贴板,请手动粘贴链接'); + handleError(err, { userMessage: '无法读取剪贴板,请手动粘贴链接' }); } }; @@ -329,7 +305,7 @@ export function TextilePlanOverview() { // 处理拒绝计划 const handleRejectStep = async () => { if (!rejectStepId || !rejectReason.trim()) { - alert('请输入拒绝原因'); + handleError(new Error('缺少拒绝原因'), { userMessage: '请输入拒绝原因' }); return; } @@ -354,7 +330,7 @@ export function TextilePlanOverview() { const { error } = await supabase .from('plan_process_steps') .update({ - status: 'rejected' as const, + status: STEP_STATUS.REJECTED, notes: `拒绝原因: ${rejectReason.trim()}`, timestamp: now, operator_id: auth.user?.id @@ -362,14 +338,13 @@ export function TextilePlanOverview() { .eq('id', rejectStepId); if (error) { - console.error('拒绝失败:', error); - alert('拒绝失败,请重试'); + handleError(error, { userMessage: '拒绝失败,请重试' }); setRejecting(null); return; } // 发送拒绝通知给采购商 - await notifyPlanUpdated(step.plan_id, 'rejected', auth.company!.id, auth.user!.id); + await notifyPlanUpdated(step.plan_id, PLAN_STATUS.REJECTED, auth.company!.id, auth.user!.id); // 将该计划标记为已拒绝(本地状态,显示灰色) setRejectedPlans(prev => new Set([...prev, step.plan_id])); @@ -386,8 +361,7 @@ export function TextilePlanOverview() { handleCloseRejectModal(); setExpandedPlan(null); } catch (err) { - console.error('拒绝失败:', err); - alert('拒绝失败,请重试'); + handleError(err, { userMessage: '拒绝失败,请重试' }); } setRejecting(null); }; @@ -550,7 +524,7 @@ export function TextilePlanOverview() { await supabase .from('plan_process_steps') .update({ - status: 'completed' as const, + status: STEP_STATUS.COMPLETED, timestamp: now, operator_id: auth.user?.id }) @@ -560,7 +534,7 @@ export function TextilePlanOverview() { await supabase .from('plan_process_steps') .update({ - status: 'active' as const, + status: STEP_STATUS.ACTIVE, timestamp: now, operator_id: auth.user?.id }) @@ -572,15 +546,14 @@ export function TextilePlanOverview() { const { error } = await supabase .from('plan_process_steps') .update({ - status: 'completed' as const, + status: STEP_STATUS.COMPLETED, timestamp: now, operator_id: auth.user?.id }) .eq('id', stepId); if (error) { - console.error('确认失败:', error); - alert('确认失败,请重试'); + handleError(error, { userMessage: '确认失败,请重试' }); setConfirming(null); return; } @@ -590,15 +563,14 @@ export function TextilePlanOverview() { if (step?.step_type === 'confirm') { const { error: planUpdateError } = await supabase .from('production_plans') - .update({ status: 'producing' as const }) + .update({ status: PLAN_STATUS.PRODUCING }) .eq('id', planId); if (planUpdateError) { - console.error('更新计划状态失败:', planUpdateError); - alert('步骤已确认,但更新计划状态失败,请刷新页面'); + handleError(planUpdateError, { userMessage: '步骤已确认,但更新计划状态失败,请刷新页面' }); } else { // 发送计划开始生产通知 - await notifyPlanUpdated(planId, 'producing', auth.company!.id, auth.user!.id); + await notifyPlanUpdated(planId, PLAN_STATUS.PRODUCING, auth.company!.id, auth.user!.id); } } @@ -608,15 +580,15 @@ export function TextilePlanOverview() { .select('status') .eq('plan_id', planId); - const allCompleted = allSteps?.every(s => s.status === 'completed'); + const allCompleted = allSteps?.every(s => s.status === STEP_STATUS.COMPLETED); if (allCompleted) { await supabase .from('production_plans') - .update({ status: 'completed' as const }) + .update({ status: PLAN_STATUS.COMPLETED }) .eq('id', planId); // 发送计划完成通知 - await notifyPlanUpdated(planId, 'completed', auth.company!.id, auth.user!.id); + await notifyPlanUpdated(planId, PLAN_STATUS.COMPLETED, auth.company!.id, auth.user!.id); } // 发送节点完成通知 @@ -769,7 +741,7 @@ export function TextilePlanOverview() { ? Math.round((plan.completed_quantity / plan.target_quantity) * 100) : 0; // 从 processSteps 中检测 rejected 状态 - const isRejected = plan.processSteps.some(s => s.status === 'rejected'); + const isRejected = plan.processSteps.some(s => s.status === STEP_STATUS.REJECTED); return ( = { pending: { label: '待处理', color: 'text-amber-600', bgColor: 'bg-amber-50', borderColor: 'border-amber-200', icon: Clock }, @@ -85,7 +87,7 @@ export function WashingDashboard() { const stats = { all: plans.length, producing: plans.filter(p => p.status === 'processing').length, - completed: plans.filter(p => p.status === 'completed').length + completed: plans.filter(p => p.status === PLAN_STATUS.COMPLETED).length }; // 获取进行中的计划用于轮播 @@ -373,158 +375,14 @@ export function WashingDashboard() { {/* 账户卡片弹窗 */} - {showAccountCard && ( -
setShowAccountCard(false)}> - e.stopPropagation()} - > - {/* 头部 */} -
-
-

账户信息

- -
-
- - {/* 账户详情 */} -
- {/* 用户信息 */} -
- -
-

{(auth.user as any)?.display_name || auth.user?.username || '用户'}

-

{auth.company?.name || '水洗厂'}

- - {auth.user?.is_master ? '主账号' : '子账号'} - -
-
- - {/* 功能菜单 */} -
- - - - - - - -
- - {/* 退出登录 */} - -
-
-
- )} + setShowAccountCard(false)} + role="washing" + onShowUserManual={() => setShowUserManual(true)} + onShowFeedback={() => setShowFeedback(true)} + onShowAbout={() => setShowAbout(true)} + /> ); } diff --git a/src/pages/washing/PaymentPending.tsx b/src/pages/washing/PaymentPending.tsx index 1e1d479..9cb1167 100644 --- a/src/pages/washing/PaymentPending.tsx +++ b/src/pages/washing/PaymentPending.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; import { supabase } from '../../supabase/client'; import type { Payment, WashingPlan } from '../../types'; +import { PAYMENT_STATUS } from '../../utils/constants'; interface PaymentWithPlan extends Payment { plan?: WashingPlan; @@ -28,7 +29,7 @@ export function WashingPaymentPending() { .from('payments') .select('*') .eq('to_company_id', auth.company!.id) - .eq('status', 'pending'); + .eq('status', PAYMENT_STATUS.PENDING); if (!paymentData || paymentData.length === 0) { setPayments([]); @@ -63,7 +64,7 @@ export function WashingPaymentPending() { const { error } = await supabase .from('payments') .update({ - status: 'completed' as const, + status: PAYMENT_STATUS.COMPLETED, paid_at: now }) .eq('id', paymentId); diff --git a/src/pages/washing/PendingFabric.tsx b/src/pages/washing/PendingFabric.tsx index aa6339f..ce1b193 100644 --- a/src/pages/washing/PendingFabric.tsx +++ b/src/pages/washing/PendingFabric.tsx @@ -6,6 +6,7 @@ import { motion } from 'framer-motion'; import { ArrowLeft, Package, Clock, ChevronRight } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; import type { WashingPlan } from '../../types'; +import { WASHING_PLAN_STATUS } from '../../utils/constants'; interface PendingFabricItem { id: string; @@ -47,7 +48,7 @@ export function PendingFabric() { company:company_id(name) `) .eq('washing_factory_id', auth.company!.id) - .in('status', ['pending', 'processing']) + .in('status', [WASHING_PLAN_STATUS.PENDING, 'processing']) .order('created_at', { ascending: false }); if (error) { diff --git a/src/pages/washing/PlanOverview.tsx b/src/pages/washing/PlanOverview.tsx index 67c4f35..e3af45e 100644 --- a/src/pages/washing/PlanOverview.tsx +++ b/src/pages/washing/PlanOverview.tsx @@ -5,6 +5,9 @@ import { supabase } from '../../supabase/client'; import { motion, AnimatePresence } from 'framer-motion'; import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X, Droplets, Package, Clock } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; +import { extractParamsFromLink } from '../../utils/helpers'; +import { useErrorHandler } from '../../hooks/useErrorHandler'; +import { WASHING_PLAN_STATUS } from '../../utils/constants'; import type { WashingPlan, WashingProcessStep, Company, Profile } from '../../types'; interface WashingPlanWithDetails extends WashingPlan { @@ -37,6 +40,7 @@ export function WashingPlanOverview() { const navigate = useNavigate(); const { auth } = useAuth(); const { isMobile } = useResponsive(); + const handleError = useErrorHandler(); const [groups, setGroups] = useState([]); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [expandedPlan, setExpandedPlan] = useState(null); @@ -72,7 +76,7 @@ export function WashingPlanOverview() { .order('created_at', { ascending: false }); if (error) { - console.error('获取水洗计划失败:', error); + handleError(error, { userMessage: '获取水洗计划失败' }); setLoading(false); return; } @@ -154,31 +158,6 @@ export function WashingPlanOverview() { }); }; - // 从 hash 路由链接中提取查询参数 - const extractParamsFromLink = (link: string) => { - const hashMatch = link.match(/#\/import\?(.+)$/); - if (hashMatch) { - const queryString = hashMatch[1]; - const params = new URLSearchParams(queryString); - return { - token: params.get('token'), - plan: params.get('plan'), - type: params.get('type') - }; - } - try { - const url = new URL(link, window.location.origin); - return { - token: url.searchParams.get('token'), - plan: url.searchParams.get('plan'), - type: url.searchParams.get('type'), - shortCode: url.searchParams.get('s') - }; - } catch { - return { token: null, plan: null, type: null, shortCode: null }; - } - }; - const handleImportFromLink = () => { if (!importLink.trim()) return; @@ -191,7 +170,7 @@ export function WashingPlanOverview() { } else if (plan && type) { navigate(`/import?plan=${plan}&type=${type}`); } else { - alert('无效的链接格式,请确保链接包含 token、s 或 plan 参数'); + handleError(new Error('无效的链接格式'), { userMessage: '无效的链接格式,请确保链接包含 token、s 或 plan 参数', showAlert: true }); } }; @@ -209,14 +188,13 @@ export function WashingPlanOverview() { } else if (plan && type) { navigate(`/import?plan=${plan}&type=${type}`); } else { - alert('链接格式不正确,请检查链接是否完整'); + handleError(new Error('链接格式不正确'), { userMessage: '链接格式不正确,请检查链接是否完整', showAlert: true }); } } else { - alert('剪贴板中没有检测到有效的导入链接'); + handleError(new Error('无效链接'), { userMessage: '剪贴板中没有检测到有效的导入链接', showAlert: true }); } } catch (err) { - console.error('读取剪贴板失败:', err); - alert('无法读取剪贴板,请手动粘贴链接'); + handleError(err, { userMessage: '无法读取剪贴板,请手动粘贴链接', showAlert: true }); } }; @@ -230,7 +208,7 @@ export function WashingPlanOverview() { await supabase .from('washing_process_steps') .update({ - status: 'completed', + status: WASHING_PLAN_STATUS.COMPLETED, timestamp: now, operator_id: auth.user?.id }) @@ -248,20 +226,19 @@ export function WashingPlanOverview() { if (stepType === 'confirm') { await supabase .from('washing_plans') - .update({ status: 'processing' }) + .update({ status: WASHING_PLAN_STATUS.IN_PROGRESS }) .eq('id', step.washing_plan_id); } else if (stepType === 'complete') { await supabase .from('washing_plans') - .update({ status: 'completed' }) + .update({ status: WASHING_PLAN_STATUS.COMPLETED }) .eq('id', step.washing_plan_id); } } await fetchPlans(); } catch (err) { - console.error('确认失败:', err); - alert('确认失败,请重试'); + handleError(err, { userMessage: '确认失败,请重试', showAlert: true }); } setConfirming(null); }; @@ -300,7 +277,7 @@ export function WashingPlanOverview() { await supabase .from('washing_plans') .update({ - status: 'completed', + status: WASHING_PLAN_STATUS.COMPLETED, actual_washed_meters: parseFloat(completeForm.meters), actual_shrinkage_rate: parseFloat(completeForm.shrinkageRate) }) @@ -311,7 +288,7 @@ export function WashingPlanOverview() { await fetchPlans(); alert('水洗完成记录已保存!'); } catch (err: any) { - alert('保存失败: ' + err.message); + handleError(err, { userMessage: '保存失败: ' + err.message, showAlert: true }); } }; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 33adef2..c76464d 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -72,7 +72,7 @@ export function generateProductCode(name: string, weight: string, colorCode: str /** * 从分享链接中提取参数 */ -export function extractParamsFromLink(link: string): { token: string | null; plan: string | null; type: string | null } { +export function extractParamsFromLink(link: string): { token: string | null; plan: string | null; type: string | null; shortCode: string | null } { const hashMatch = link.match(/#\/import\?(.+)$/); if (hashMatch) { const queryString = hashMatch[1]; @@ -80,7 +80,8 @@ export function extractParamsFromLink(link: string): { token: string | null; pla return { token: params.get('token'), plan: params.get('plan'), - type: params.get('type') + type: params.get('type'), + shortCode: params.get('s') }; } try { @@ -88,10 +89,11 @@ export function extractParamsFromLink(link: string): { token: string | null; pla return { token: url.searchParams.get('token'), plan: url.searchParams.get('plan'), - type: url.searchParams.get('type') + type: url.searchParams.get('type'), + shortCode: url.searchParams.get('s') }; } catch { - return { token: null, plan: null, type: null }; + return { token: null, plan: null, type: null, shortCode: null }; } }