sync: Add files via upload (A@f3f0916866583911d315136c8b16f45e6a96c236)
This commit is contained in:
parent
e604317c7f
commit
d06690963c
32
.todo/3114e04a-02a8-4e81-9992-be3daaf46580/todos.json
Normal file
32
.todo/3114e04a-02a8-4e81-9992-be3daaf46580/todos.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
14
.todo/4cb84c6d-f3e7-4554-9ad6-fae53875108d/todos.json
Normal file
14
.todo/4cb84c6d-f3e7-4554-9ad6-fae53875108d/todos.json
Normal file
@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"content": "修改 ImportPlanPage.tsx - 添加常量导入和状态字符串替换",
|
||||
"status": "in_progress",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"content": "修改 washing/PlanOverview.tsx - 添加常量导入、状态字符串替换和错误处理",
|
||||
"status": "pending",
|
||||
"priority": "high"
|
||||
}
|
||||
]
|
||||
44
.todo/7647b580-e00c-4242-8ccc-c2d763b01340/todos.json
Normal file
44
.todo/7647b580-e00c-4242-8ccc-c2d763b01340/todos.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
56
.todo/a9f5029e-4abf-4185-943e-cb57d49e6ab9/todos.json
Normal file
56
.todo/a9f5029e-4abf-4185-943e-cb57d49e6ab9/todos.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
56
.todo/b3049a4f-42ea-48a7-a450-536a7dc9b031/todos.json
Normal file
56
.todo/b3049a4f-42ea-48a7-a450-536a7dc9b031/todos.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
56
.todo/d50ec4e4-4713-4b86-bf95-fe4b5d42fe3e/todos.json
Normal file
56
.todo/d50ec4e4-4713-4b86-bf95-fe4b5d42fe3e/todos.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
44
.todo/f0b50512-9849-43df-b93b-2a9b87036e1b/todos.json
Normal file
44
.todo/f0b50512-9849-43df-b93b-2a9b87036e1b/todos.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
94
AGENTS.md
94
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行内联弹窗 → `<AccountProfileCard role="purchaser" />`
|
||||
- `textile/Dashboard.tsx`:~150行内联弹窗 → `<AccountProfileCard role="textile" />`
|
||||
- `washing/Dashboard.tsx`:~150行内联弹窗 → `<AccountProfileCard role="washing" />`
|
||||
- 共减少约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)
|
||||
- **水洗厂功能完善**
|
||||
|
||||
27
README.md
27
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行内联弹窗 → `<AccountProfileCard role="purchaser" />`
|
||||
- `textile/Dashboard.tsx`:~150行内联弹窗 → `<AccountProfileCard role="textile" />`
|
||||
- `washing/Dashboard.tsx`:~150行内联弹窗 → `<AccountProfileCard role="washing" />`
|
||||
- 共减少约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) - 重大版本更新
|
||||
- **系统审计与质量保障**
|
||||
- 完成四维度系统审计(文件关联/内存安全/路由配置/权限授权),全部通过
|
||||
|
||||
64
src/App.tsx
64
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() {
|
||||
<Route path="/register" element={<AnimatedRoute pageName="注册"><RegisterPage /></AnimatedRoute>} />
|
||||
<Route path="/role-select" element={<AnimatedRoute pageName="角色选择"><RoleSelectPage /></AnimatedRoute>} />
|
||||
|
||||
{/* 采购商路由 - 使用 PurchaserLayout 作为布局 */}
|
||||
<Route path="/purchaser" element={<PurchaserLayout />}>
|
||||
<Route index element={<AnimatedRoute pageName="采购商首页"><PurchaserDashboard /></AnimatedRoute>} />
|
||||
<Route path="plans" element={<AnimatedRoute pageName="计划总览"><PlanOverview /></AnimatedRoute>} />
|
||||
<Route path="plans/new" element={<AnimatedRoute pageName="新建计划"><NewPlan /></AnimatedRoute>} />
|
||||
<Route path="warehouse" element={<AnimatedRoute pageName="生产仓库管理"><WarehouseManage /></AnimatedRoute>} />
|
||||
<Route path="factories" element={<AnimatedRoute pageName="工厂管理"><FactoryManage /></AnimatedRoute>} />
|
||||
<Route path="accounts-payable" element={<AnimatedRoute pageName="应付账款"><AccountsPayable /></AnimatedRoute>} />
|
||||
<Route path="washing-plans/new" element={<AnimatedRoute pageName="新建水洗计划"><NewWashingPlan /></AnimatedRoute>} />
|
||||
<Route path="finished-warehouse" element={<AnimatedRoute pageName="成品仓库"><PurchaserFinishedWarehouse /></AnimatedRoute>} />
|
||||
{/* 采购商路由 - RoleGuard + PurchaserLayout */}
|
||||
<Route element={<RoleGuard allowedRoles={['purchaser']} />}>
|
||||
<Route path="/purchaser" element={<PurchaserLayout />}>
|
||||
<Route index element={<AnimatedRoute pageName="采购商首页"><PurchaserDashboard /></AnimatedRoute>} />
|
||||
<Route path="plans" element={<AnimatedRoute pageName="计划总览"><PlanOverview /></AnimatedRoute>} />
|
||||
<Route path="plans/new" element={<AnimatedRoute pageName="新建计划"><NewPlan /></AnimatedRoute>} />
|
||||
<Route path="warehouse" element={<AnimatedRoute pageName="生产仓库管理"><WarehouseManage /></AnimatedRoute>} />
|
||||
<Route path="factories" element={<AnimatedRoute pageName="工厂管理"><FactoryManage /></AnimatedRoute>} />
|
||||
<Route path="accounts-payable" element={<AnimatedRoute pageName="应付账款"><AccountsPayable /></AnimatedRoute>} />
|
||||
<Route path="washing-plans/new" element={<AnimatedRoute pageName="新建水洗计划"><NewWashingPlan /></AnimatedRoute>} />
|
||||
<Route path="finished-warehouse" element={<AnimatedRoute pageName="成品仓库"><PurchaserFinishedWarehouse /></AnimatedRoute>} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 纺织厂路由 - 使用 TextileLayout 作为布局 */}
|
||||
<Route path="/textile" element={<TextileLayout />}>
|
||||
<Route index element={<AnimatedRoute pageName="纺织厂首页"><TextileDashboard /></AnimatedRoute>} />
|
||||
<Route path="plans" element={<AnimatedRoute pageName="纺织厂计划"><TextilePlanOverview /></AnimatedRoute>} />
|
||||
<Route path="fabric-warehouse" element={<AnimatedRoute pageName="坯布仓库"><FabricWarehouse /></AnimatedRoute>} />
|
||||
<Route path="yarn-warehouse" element={<AnimatedRoute pageName="纱线仓库"><YarnWarehouse /></AnimatedRoute>} />
|
||||
<Route path="payments" element={<AnimatedRoute pageName="待结款"><PaymentPending /></AnimatedRoute>} />
|
||||
{/* 纺织厂路由 - RoleGuard + TextileLayout */}
|
||||
<Route element={<RoleGuard allowedRoles={['textile']} />}>
|
||||
<Route path="/textile" element={<TextileLayout />}>
|
||||
<Route index element={<AnimatedRoute pageName="纺织厂首页"><TextileDashboard /></AnimatedRoute>} />
|
||||
<Route path="plans" element={<AnimatedRoute pageName="纺织厂计划"><TextilePlanOverview /></AnimatedRoute>} />
|
||||
<Route path="fabric-warehouse" element={<AnimatedRoute pageName="坯布仓库"><FabricWarehouse /></AnimatedRoute>} />
|
||||
<Route path="yarn-warehouse" element={<AnimatedRoute pageName="纱线仓库"><YarnWarehouse /></AnimatedRoute>} />
|
||||
<Route path="payments" element={<AnimatedRoute pageName="待结款"><PaymentPending /></AnimatedRoute>} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 水洗厂路由 */}
|
||||
<Route path="/washing" element={<AnimatedRoute pageName="水洗厂首页"><WashingDashboard /></AnimatedRoute>} />
|
||||
<Route path="/washing/plans" element={<AnimatedRoute pageName="水洗计划管理"><WashingPlanOverview /></AnimatedRoute>} />
|
||||
<Route path="/washing/pending" element={<AnimatedRoute pageName="待处理坯布"><PendingFabric /></AnimatedRoute>} />
|
||||
<Route path="/washing/completed" element={<AnimatedRoute pageName="已完成坯布"><CompletedFabric /></AnimatedRoute>} />
|
||||
<Route path="/washing/finished-warehouse" element={<AnimatedRoute pageName="成品仓库"><FinishedWarehouse /></AnimatedRoute>} />
|
||||
<Route path="/washing/payments" element={<AnimatedRoute pageName="待结款"><WashingPaymentPending /></AnimatedRoute>} />
|
||||
{/* 水洗厂路由 - RoleGuard + WashingLayout */}
|
||||
<Route element={<RoleGuard allowedRoles={['washing']} />}>
|
||||
<Route path="/washing" element={<WashingLayout />}>
|
||||
<Route index element={<AnimatedRoute pageName="水洗厂首页"><WashingDashboard /></AnimatedRoute>} />
|
||||
<Route path="plans" element={<AnimatedRoute pageName="水洗计划管理"><WashingPlanOverview /></AnimatedRoute>} />
|
||||
<Route path="pending" element={<AnimatedRoute pageName="待处理坯布"><PendingFabric /></AnimatedRoute>} />
|
||||
<Route path="completed" element={<AnimatedRoute pageName="已完成坯布"><CompletedFabric /></AnimatedRoute>} />
|
||||
<Route path="finished-warehouse" element={<AnimatedRoute pageName="成品仓库"><FinishedWarehouse /></AnimatedRoute>} />
|
||||
<Route path="payments" element={<AnimatedRoute pageName="待结款"><WashingPaymentPending /></AnimatedRoute>} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 子账号管理路由(所有角色共用) */}
|
||||
<Route path="/members" element={<AnimatedRoute pageName="子账号管理"><MemberManage /></AnimatedRoute>} />
|
||||
{/* 子账号管理路由(所有已登录角色共用) */}
|
||||
<Route element={<RoleGuard allowedRoles={['purchaser', 'textile', 'washing']} />}>
|
||||
<Route path="/members" element={<AnimatedRoute pageName="子账号管理"><MemberManage /></AnimatedRoute>} />
|
||||
</Route>
|
||||
|
||||
{/* 导入计划路由 */}
|
||||
<Route path="/import" element={<AnimatedRoute pageName="导入计划"><ImportPlanPage /></AnimatedRoute>} />
|
||||
|
||||
39
src/components/RoleGuard.tsx
Normal file
39
src/components/RoleGuard.tsx
Normal file
@ -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<string, string> = {
|
||||
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 <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const currentRole = auth.currentRole;
|
||||
if (!currentRole || !allowedRoles.includes(currentRole)) {
|
||||
const redirectPath = currentRole ? ROLE_HOME[currentRole] : '/role-select';
|
||||
return <Navigate to={redirectPath || '/role-select'} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* 桌面端侧边栏 - 固定不动,无入场动画 */}
|
||||
{/* 桌面端侧边栏 */}
|
||||
{!isMobile && (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 text-white z-50 flex flex-col ${
|
||||
@ -169,65 +170,34 @@ export function WashingLayout() {
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* 移动端顶部导航栏 */}
|
||||
{/* 移动端底部导航 */}
|
||||
{isMobile && (
|
||||
<header className="bg-white/80 backdrop-blur-xl border-b border-slate-200/50 sticky top-0 z-40">
|
||||
<div className="px-4 py-3 flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-400 to-fuchsia-400 flex items-center justify-center">
|
||||
<Droplets className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-slate-900">水洗厂</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||
>
|
||||
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 z-50 safe-area-bottom">
|
||||
<div className="flex items-center justify-around py-2">
|
||||
{navItems.slice(0, 5).map((item) => {
|
||||
const active = isActive(item.path);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => navigate(item.path)}
|
||||
className={`flex flex-col items-center gap-0.5 px-2 py-1 rounded-lg transition-colors ${
|
||||
active ? 'text-violet-600' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${active ? 'stroke-[2.5px]' : ''}`} />
|
||||
<span className="text-[10px] font-medium">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 移动端菜单 */}
|
||||
{mobileMenuOpen && (
|
||||
<nav className="border-t border-slate-200 bg-white">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.path);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 ${
|
||||
active
|
||||
? 'bg-violet-50 text-violet-600'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="font-medium text-sm">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="font-medium text-sm">退出登录</span>
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主内容区 - 移动端无侧边栏,桌面端有侧边栏 */}
|
||||
{/* 主内容区 */}
|
||||
<main className={`${
|
||||
!isMobile && (sidebarCollapsed ? 'ml-20' : 'ml-64')
|
||||
}`}>
|
||||
} ${isMobile ? 'pb-20' : ''}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -27,7 +27,7 @@ export const APP_CONFIG = {
|
||||
/** 定位描述 */
|
||||
description: '纺织行业全流程协作管理平台',
|
||||
/** 版本号 */
|
||||
version: '2.1.0',
|
||||
version: '2.2.0',
|
||||
/** 默认密码(仅 Demo 环境) */
|
||||
defaultDemoPassword: '123456',
|
||||
/** 免密登录有效期(毫秒),默认30天 */
|
||||
|
||||
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的错误处理 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 <T>(
|
||||
operation: () => Promise<T>,
|
||||
errorMessage?: string,
|
||||
severity: ErrorSeverity = 'error'
|
||||
): Promise<T | null> => {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err) {
|
||||
handleError(err, errorMessage, severity);
|
||||
return null;
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
return {
|
||||
handleError: handleErrorLevel,
|
||||
handleInfo,
|
||||
handleWarning,
|
||||
handleCritical,
|
||||
withErrorHandling
|
||||
};
|
||||
return handleError;
|
||||
}
|
||||
|
||||
@ -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<string, string> = {};
|
||||
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<string, string>;
|
||||
batchesData: any[];
|
||||
outboundData: any[];
|
||||
batchesData: ProductInventoryRecord[];
|
||||
outboundData: ProductOutboundRecord[];
|
||||
}
|
||||
|
||||
function processInventoryData({
|
||||
@ -242,7 +272,7 @@ function processInventoryData({
|
||||
// 建立 plan_id 到 fabric_code 的映射
|
||||
const planToFabricCode: Record<string, string> = {};
|
||||
const planCompletedMap: Record<string, number> = {};
|
||||
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<string, { meters: number; rolls: number; factory_id: string; factory_name: string }> = {};
|
||||
|
||||
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<string, { meters: number; rolls: number }> = {};
|
||||
const fabricCodeFactoryInventory: Record<string, Record<string, any>> = {};
|
||||
const fabricCodeFactoryInventory: Record<string, Record<string, { factory_id: string; factory_name: string; rolls: number; meters: number; records: ProductInRecord[] }>> = {};
|
||||
|
||||
Object.entries(planInventoryMap).forEach(([planId, inventory]) => {
|
||||
const fabricCode = planToFabricCode[planId];
|
||||
@ -295,7 +325,7 @@ function processInventoryData({
|
||||
const inRecordsMap: Record<string, ProductInRecord[]> = {};
|
||||
|
||||
// 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<string, string> = {};
|
||||
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<string, { totalRolls: number; totalMeters: number; records: any[] }> = {};
|
||||
outboundData?.forEach((r: any) => {
|
||||
const outboundMap: Record<string, { totalRolls: number; totalMeters: number; records: ProductOutboundRecord[] }> = {};
|
||||
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<string, { rolls: number; meters: number }> = {};
|
||||
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 表中的字段
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
// 模拟纱线配比
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<string, { label: string; color: string; bgColor: string; icon: any; borderColor: string }> = {
|
||||
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<string>();
|
||||
(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 && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAccountCard(false)}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="bg-white rounded-2xl w-full max-w-sm shadow-2xl overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="bg-gradient-to-r from-amber-500 to-orange-500 p-6 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">账户信息</h2>
|
||||
<button onClick={() => setShowAccountCard(false)} className="p-2 hover:bg-white/20 rounded-lg transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账户详情 */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 用户信息 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-16 h-16 bg-gradient-to-br from-amber-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg cursor-pointer hover:opacity-90 transition-opacity overflow-hidden relative group">
|
||||
{(auth.user as any)?.image_url ? (
|
||||
<img src={(auth.user as any).image_url} alt="头像" className="w-full h-full object-cover relative z-10" />
|
||||
) : (
|
||||
<Building2 className="w-8 h-8 text-white relative z-10" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||||
<Camera className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
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) {
|
||||
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) {
|
||||
alert('头像更新失败: ' + updateError.message);
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('头像上传出错,请重试');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 text-lg truncate max-w-[200px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
|
||||
<p className="text-sm text-slate-500">{auth.company?.name || '采购商'}</p>
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full mt-1">
|
||||
{auth.user?.is_master ? '主账号' : '子账号'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能菜单 */}
|
||||
<div className="space-y-2 pt-4 border-t border-slate-200">
|
||||
<button
|
||||
onClick={() => { setShowAccountCard(false); navigate('/members'); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">账号管理</p>
|
||||
<p className="text-xs text-slate-500">管理团队成员和账户设置</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setShowAccountCard(false); setShowUserManual(true); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||
<BookOpen className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">用户手册</p>
|
||||
<p className="text-xs text-slate-500">查看详细使用指南</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setShowAccountCard(false); setShowFeedback(true); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">用户反馈</p>
|
||||
<p className="text-xs text-slate-500">提交问题或建议</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setShowAccountCard(false); setShowAbout(true); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
|
||||
<Info className="w-5 h-5 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">关于</p>
|
||||
<p className="text-xs text-slate-500">版本信息与版权</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 退出登录 */}
|
||||
<button
|
||||
onClick={async () => { 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"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
<AccountProfileCard
|
||||
isOpen={showAccountCard}
|
||||
onClose={() => setShowAccountCard(false)}
|
||||
role="purchaser"
|
||||
onShowUserManual={() => setShowUserManual(true)}
|
||||
onShowFeedback={() => setShowFeedback(true)}
|
||||
onShowAbout={() => setShowAbout(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<Company[]>([]);
|
||||
const [planCounts, setPlanCounts] = useState<Record<string, number>>({});
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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<string, string> = {
|
||||
export function NewPlan() {
|
||||
const navigate = useNavigate();
|
||||
const { auth } = useAuth();
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
// 从产品信息选择的产品
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(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);
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<SubMenuType>('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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, { label: string; color: string; bgColor: string; icon: any; borderColor: string }> = {
|
||||
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<string>();
|
||||
(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() {
|
||||
<CrashReporter />
|
||||
|
||||
{/* 账户卡片弹窗 */}
|
||||
{showAccountCard && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAccountCard(false)}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="bg-white rounded-2xl w-full max-w-sm shadow-2xl overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="bg-gradient-to-r from-emerald-500 to-green-600 p-6 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">账户信息</h2>
|
||||
<button onClick={() => setShowAccountCard(false)} className="p-2 hover:bg-white/20 rounded-lg transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账户详情 */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 用户信息 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-16 h-16 bg-gradient-to-br from-emerald-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg cursor-pointer hover:opacity-90 transition-opacity overflow-hidden relative group">
|
||||
{(auth.user as any)?.image_url ? (
|
||||
<img src={(auth.user as any).image_url} alt="头像" className="w-full h-full object-cover relative z-10" />
|
||||
) : (
|
||||
<Factory className="w-8 h-8 text-white relative z-10" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||||
<Camera className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
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('头像上传出错,请重试');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 text-lg truncate max-w-[200px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
|
||||
<p className="text-sm text-slate-500">{auth.company?.name || '纺织厂'}</p>
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-full mt-1">
|
||||
{auth.user?.is_master ? '主账号' : '子账号'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能菜单 */}
|
||||
<div className="space-y-2 pt-4 border-t border-slate-200">
|
||||
<button
|
||||
onClick={() => { setShowAccountCard(false); navigate('/members'); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">账号管理</p>
|
||||
<p className="text-xs text-slate-500">管理团队成员和账户设置</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setShowAccountCard(false); setShowUserManual(true); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||
<BookOpen className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">用户手册</p>
|
||||
<p className="text-xs text-slate-500">查看详细使用指南</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setShowAccountCard(false); setShowFeedback(true); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">用户反馈</p>
|
||||
<p className="text-xs text-slate-500">提交问题或建议</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setShowAccountCard(false); setShowAbout(true); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
|
||||
<Info className="w-5 h-5 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">关于</p>
|
||||
<p className="text-xs text-slate-500">版本信息与版权</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 退出登录 */}
|
||||
<button
|
||||
onClick={async () => { 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"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
<AccountProfileCard
|
||||
isOpen={showAccountCard}
|
||||
onClose={() => setShowAccountCard(false)}
|
||||
role="textile"
|
||||
onShowUserManual={() => setShowUserManual(true)}
|
||||
onShowFeedback={() => setShowFeedback(true)}
|
||||
onShowAbout={() => setShowAbout(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 (
|
||||
<motion.div
|
||||
key={plan.id}
|
||||
@ -944,7 +916,7 @@ export function TextilePlanOverview() {
|
||||
showConfirmButton={true}
|
||||
planTargetQuantity={plan.target_quantity}
|
||||
planCompletedQuantity={plan.completed_quantity}
|
||||
isPlanLocked={plan.status === 'completed'}
|
||||
isPlanLocked={plan.status === PLAN_STATUS.COMPLETED}
|
||||
lastInventoryTime={plan.inventoryRecords[0]?.time}
|
||||
inventoryRecords={plan.inventoryRecords}
|
||||
yarnRatios={plan.yarnRatios}
|
||||
|
||||
@ -6,6 +6,7 @@ import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, CheckCircle, Droplets, ChevronRight } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import type { WashingPlan, WashingPlanCompletion } from '../../types';
|
||||
import { WASHING_PLAN_STATUS } from '../../utils/constants';
|
||||
|
||||
interface CompletedFabricItem {
|
||||
id: string;
|
||||
@ -46,7 +47,7 @@ export function CompletedFabric() {
|
||||
company:company_id(name)
|
||||
`)
|
||||
.eq('washing_factory_id', auth.company!.id)
|
||||
.eq('status', 'completed')
|
||||
.eq('status', WASHING_PLAN_STATUS.COMPLETED)
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
|
||||
@ -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 { Import, Warehouse, CreditCard, ArrowLeft, Users, Droplets, Package, CheckCircle, Clock, LogOut, HelpCircle, X, BookOpen, MessageSquare, Info, Camera, FileText, Plus, TrendingUp, Building2, ChevronRight, Hash, Target, Activity } from 'lucide-react';
|
||||
import { Import, Warehouse, CreditCard, ArrowLeft, Users, Droplets, Package, CheckCircle, Clock, LogOut, FileText, Plus, TrendingUp, Building2, ChevronRight, Hash, Target, Activity } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide';
|
||||
import { NotificationCenter } from '../../components/NotificationCenter';
|
||||
@ -14,6 +14,8 @@ import { UserManual } from '../../components/UserManual';
|
||||
import { CrashReporter } from '../../components/CrashReporter';
|
||||
import type { WashingPlan } from '../../types';
|
||||
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
|
||||
import { AccountProfileCard } from '../../components/AccountProfileCard';
|
||||
import { PLAN_STATUS } from '../../utils/constants';
|
||||
|
||||
const statusMap: Record<string, { label: string; color: string; bgColor: string; icon: any; borderColor: string }> = {
|
||||
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() {
|
||||
<CrashReporter />
|
||||
|
||||
{/* 账户卡片弹窗 */}
|
||||
{showAccountCard && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAccountCard(false)}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="bg-white rounded-2xl w-full max-w-sm shadow-2xl overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="bg-gradient-to-r from-violet-400 to-fuchsia-400 p-6 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">账户信息</h2>
|
||||
<button onClick={() => setShowAccountCard(false)} className="p-2 hover:bg-white/20 rounded-lg transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账户详情 */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 用户信息 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-16 h-16 bg-gradient-to-br from-violet-400 to-fuchsia-400 rounded-xl flex items-center justify-center shadow-lg cursor-pointer hover:opacity-90 transition-opacity overflow-hidden relative group">
|
||||
{(auth.user as any)?.image_url ? (
|
||||
<img src={(auth.user as any).image_url} alt="头像" className="w-full h-full object-cover relative z-10" />
|
||||
) : (
|
||||
<Droplets className="w-8 h-8 text-white relative z-10" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||||
<Camera className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
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('头像上传出错,请重试');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 text-lg truncate max-w-[200px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
|
||||
<p className="text-sm text-slate-500">{auth.company?.name || '水洗厂'}</p>
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-violet-100 text-violet-600 px-2 py-0.5 rounded-full mt-1">
|
||||
{auth.user?.is_master ? '主账号' : '子账号'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能菜单 */}
|
||||
<div className="space-y-2 pt-4 border-t border-slate-200">
|
||||
<button
|
||||
onClick={() => { setShowAccountCard(false); navigate('/members'); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">账号管理</p>
|
||||
<p className="text-xs text-slate-500">管理团队成员和账户设置</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setShowAccountCard(false); setShowUserManual(true); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
|
||||
<BookOpen className="w-5 h-5 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">用户手册</p>
|
||||
<p className="text-xs text-slate-500">查看详细使用指南</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setShowAccountCard(false); setShowFeedback(true); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">用户反馈</p>
|
||||
<p className="text-xs text-slate-500">提交问题或建议</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setShowAccountCard(false); setShowAbout(true); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
|
||||
<Info className="w-5 h-5 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">关于</p>
|
||||
<p className="text-xs text-slate-500">版本信息与版权</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 退出登录 */}
|
||||
<button
|
||||
onClick={async () => { 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"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
<AccountProfileCard
|
||||
isOpen={showAccountCard}
|
||||
onClose={() => setShowAccountCard(false)}
|
||||
role="washing"
|
||||
onShowUserManual={() => setShowUserManual(true)}
|
||||
onShowFeedback={() => setShowFeedback(true)}
|
||||
onShowAbout={() => setShowAbout(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<PlanGroup[]>([]);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [expandedPlan, setExpandedPlan] = useState<string | null>(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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user