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 && ( ); } 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()} - > - {/* 头部 */} - - - 账户信息 - setShowAccountCard(false)} className="p-2 hover:bg-white/20 rounded-lg transition-colors"> - - - - - - {/* 账户详情 */} - - {/* 用户信息 */} - - - {(auth.user as any)?.image_url ? ( - - ) : ( - - )} - - - - { - const file = e.target.files?.[0]; - if (!file) return; - const { data: { user } } = await supabase.auth.getUser(); - if (!user) return; - try { - const fileExt = file.name.split('.').pop(); - const fileName = `${user.id}-${Date.now()}.${fileExt}`; - const arrayBuffer = await file.arrayBuffer(); - const { error: uploadError } = await supabase.storage - .from('avatars') - .upload(fileName, arrayBuffer, { - contentType: file.type, - upsert: true - }); - if (uploadError) { - console.error('头像上传失败:', uploadError); - alert('头像上传失败: ' + uploadError.message); - return; - } - const { data: { publicUrl } } = supabase.storage - .from('avatars') - .getPublicUrl(fileName); - const { error: updateError } = await supabase - .from('profiles') - .update({ image_url: publicUrl } as any) - .eq('id', user.id); - if (updateError) { - console.error('头像更新失败:', updateError); - alert('头像更新失败: ' + updateError.message); - } else { - window.location.reload(); - } - } catch (err) { - console.error('头像上传出错:', err); - alert('头像上传出错,请重试'); - } - }} - /> - - - {(auth.user as any)?.display_name || auth.user?.username || '用户'} - {auth.company?.name || '纺织厂'} - - {auth.user?.is_master ? '主账号' : '子账号'} - - - - - {/* 功能菜单 */} - - { setShowAccountCard(false); navigate('/members'); }} - className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left" - > - - - - - 账号管理 - 管理团队成员和账户设置 - - - - { setShowAccountCard(false); setShowUserManual(true); }} - className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left" - > - - - - - 用户手册 - 查看详细使用指南 - - - - { setShowAccountCard(false); setShowFeedback(true); }} - className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left" - > - - - - - 用户反馈 - 提交问题或建议 - - - - { setShowAccountCard(false); setShowAbout(true); }} - className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left" - > - - - - - 关于 - 版本信息与版权 - - - - - {/* 退出登录 */} - { await logout(); navigate('/login'); }} - className="w-full py-3 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl transition-colors flex items-center justify-center gap-2 font-medium" - > - - 退出登录 - - - - - )} + 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()} - > - {/* 头部 */} - - - 账户信息 - setShowAccountCard(false)} className="p-2 hover:bg-white/20 rounded-lg transition-colors"> - - - - - - {/* 账户详情 */} - - {/* 用户信息 */} - - - {(auth.user as any)?.image_url ? ( - - ) : ( - - )} - - - - { - const file = e.target.files?.[0]; - if (!file) return; - const { data: { user } } = await supabase.auth.getUser(); - if (!user) return; - try { - const fileExt = file.name.split('.').pop(); - const fileName = `${user.id}-${Date.now()}.${fileExt}`; - const arrayBuffer = await file.arrayBuffer(); - const { error: uploadError } = await supabase.storage - .from('avatars') - .upload(fileName, arrayBuffer, { - contentType: file.type, - upsert: true - }); - if (uploadError) { - console.error('头像上传失败:', uploadError); - alert('头像上传失败: ' + uploadError.message); - return; - } - const { data: { publicUrl } } = supabase.storage - .from('avatars') - .getPublicUrl(fileName); - const { error: updateError } = await supabase - .from('profiles') - .update({ image_url: publicUrl } as any) - .eq('id', user.id); - if (updateError) { - console.error('头像更新失败:', updateError); - alert('头像更新失败: ' + updateError.message); - } else { - window.location.reload(); - } - } catch (err) { - console.error('头像上传出错:', err); - alert('头像上传出错,请重试'); - } - }} - /> - - - {(auth.user as any)?.display_name || auth.user?.username || '用户'} - {auth.company?.name || '水洗厂'} - - {auth.user?.is_master ? '主账号' : '子账号'} - - - - - {/* 功能菜单 */} - - { setShowAccountCard(false); navigate('/members'); }} - className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left" - > - - - - - 账号管理 - 管理团队成员和账户设置 - - - - { setShowAccountCard(false); setShowUserManual(true); }} - className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left" - > - - - - - 用户手册 - 查看详细使用指南 - - - - { setShowAccountCard(false); setShowFeedback(true); }} - className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left" - > - - - - - 用户反馈 - 提交问题或建议 - - - - { setShowAccountCard(false); setShowAbout(true); }} - className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left" - > - - - - - 关于 - 版本信息与版权 - - - - - {/* 退出登录 */} - { await logout(); navigate('/login'); }} - className="w-full py-3 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl transition-colors flex items-center justify-center gap-2 font-medium" - > - - 退出登录 - - - - - )} + 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 }; } }
{(auth.user as any)?.display_name || auth.user?.username || '用户'}
{auth.company?.name || '纺织厂'}
账号管理
管理团队成员和账户设置
用户手册
查看详细使用指南
用户反馈
提交问题或建议
关于
版本信息与版权
{auth.company?.name || '水洗厂'}