sync: Add files via upload (A@f3f0916866583911d315136c8b16f45e6a96c236)

This commit is contained in:
github-actions[bot] 2026-06-04 15:56:19 +00:00
parent e604317c7f
commit d06690963c
35 changed files with 810 additions and 886 deletions

View 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"
}
]

View 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"
}
]

View 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"
}
]

View 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"
}
]

View 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"
}
]

View File

@ -2,43 +2,25 @@
{ {
"id": "1", "id": "1",
"status": "completed", "status": "completed",
"content": "创建统一配置文件 src/config/app.ts集中管理所有可配置项", "content": "读取 App.tsx 和 AuthContext.tsx 了解当前路由和认证结构",
"priority": "high" "priority": "high"
}, },
{ {
"id": "2", "id": "2",
"status": "completed", "status": "in_progress",
"content": "提取状态字符串到 constants.ts消除散落的 statusMap", "content": "创建 RoleGuard 组件",
"priority": "high" "priority": "high"
}, },
{ {
"id": "3", "id": "3",
"status": "completed", "status": "pending",
"content": "提取魔法数字为命名常量(缓存时间、分页大小、动画参数等)", "content": "在 App.tsx 中集成 RoleGuard 包裹受保护路由",
"priority": "high" "priority": "high"
}, },
{ {
"id": "4", "id": "4",
"status": "completed", "status": "pending",
"content": "Demo 账号改为环境变量驱动 + 环境感知", "content": "运行 typecheck + dev 验证",
"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 验证无编译错误",
"priority": "high" "priority": "high"
} }
] ]

View 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"
}
]

View 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"
}
]

View File

@ -55,7 +55,7 @@
│ │ ├── constants.ts # 公共常量状态枚举、UI映射、步骤配置从config/app导入并重新导出 │ │ ├── constants.ts # 公共常量状态枚举、UI映射、步骤配置从config/app导入并重新导出
│ │ ├── helpers.ts # 通用工具函数(日期格式化、数字解析、分组计算) │ │ ├── helpers.ts # 通用工具函数(日期格式化、数字解析、分组计算)
│ │ └── shareCrypto.ts # 分享链接加密HMAC-SHA256签名 + Base64 │ │ └── shareCrypto.ts # 分享链接加密HMAC-SHA256签名 + Base64
│ ├── hooks/ # 自定义Hooks9个) │ ├── hooks/ # 自定义Hooks10个)
│ │ ├── useInventoryData.ts # 库存数据获取5秒缓存 + Realtime订阅 │ │ ├── useInventoryData.ts # 库存数据获取5秒缓存 + Realtime订阅
│ │ ├── usePlanData.ts # 计划数据获取10秒缓存 + 分页 + Realtime │ │ ├── usePlanData.ts # 计划数据获取10秒缓存 + 分页 + Realtime
│ │ ├── usePlanStatusSync.ts # 计划状态自动同步10秒测试间隔 │ │ ├── usePlanStatusSync.ts # 计划状态自动同步10秒测试间隔
@ -64,12 +64,15 @@
│ │ ├── useTheme.ts # 主题管理暗黑模式class策略 │ │ ├── useTheme.ts # 主题管理暗黑模式class策略
│ │ ├── usePerformance.ts # 性能检测(高/中/低三档动画降级) │ │ ├── usePerformance.ts # 性能检测(高/中/低三档动画降级)
│ │ ├── useNetworkStatus.ts # 网络状态监听 │ │ ├── useNetworkStatus.ts # 网络状态监听
│ │ └── useVirtualScroll.ts # 虚拟滚动Hook │ │ ├── useVirtualScroll.ts # 虚拟滚动Hook
│ ├── components/ # 共享组件33个 │ │ └── useErrorHandler.ts # 统一错误处理Hook替代散落的console.error+alert
│ │ ├── layout/ # 布局组件3个 │ ├── components/ # 共享组件35个
│ │ ├── layout/ # 布局组件4个
│ │ │ ├── Sidebar.tsx # 可折叠侧边栏导航 │ │ │ ├── Sidebar.tsx # 可折叠侧边栏导航
│ │ │ ├── PurchaserLayout.tsx # 采购商布局8项导航 + amber主题 │ │ │ ├── PurchaserLayout.tsx # 采购商布局8项导航 + amber主题
│ │ │ └── TextileLayout.tsx # 纺织厂布局5项导航 + emerald主题 │ │ │ ├── TextileLayout.tsx # 纺织厂布局5项导航 + emerald主题
│ │ │ └── WashingLayout.tsx # 水洗厂布局6项导航 + violet主题
│ │ ├── RoleGuard.tsx # 路由守卫(角色权限校验 + 重定向)
│ │ ├── warehouse/ # 仓库管理组件6个 │ │ ├── warehouse/ # 仓库管理组件6个
│ │ │ ├── WarehouseNav.tsx # 仓库类型导航 │ │ │ ├── WarehouseNav.tsx # 仓库类型导航
│ │ │ ├── ProductForm.tsx # 产品创建/编辑表单 │ │ │ ├── ProductForm.tsx # 产品创建/编辑表单
@ -237,9 +240,7 @@ src/utils/constants.ts ← 向后兼容层状态枚举、UI映射、步骤配
|------|---------|---------|---------| |------|---------|---------|---------|
| 采购商 | PurchaserLayout | 8项 | 嵌套 Outlet | | 采购商 | PurchaserLayout | 8项 | 嵌套 Outlet |
| 纺织厂 | TextileLayout | 5项 | 嵌套 Outlet | | 纺织厂 | TextileLayout | 5项 | 嵌套 Outlet |
| 水洗厂 | 无独立Layout | 6项(侧边栏内置) | 扁平路由 | | 水洗厂 | WashingLayout | 6项 | 嵌套 Outlet |
> ⚠️ **已知架构债务**: 水洗厂缺少独立 Layout 组件,导航一致性待提升
## 路由结构 ## 路由结构
@ -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行重复代码头像上传功能随组件一并收敛
- 清理三个文件中不再使用的图标 importX, 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) - 重大版本更新 ### 2026-06-04 (v2.0.0) - 重大版本更新
- **系统审计与质量保障** - **系统审计与质量保障**
- 完成四维度系统审计(文件关联/内存安全/路由配置/权限授权),全部通过 - 完成四维度系统审计(文件关联/内存安全/路由配置/权限授权),全部通过
@ -894,7 +970,7 @@ CREATE TYPE payment_status AS ENUM ('pending', 'completed');
- 验证数据一致性completed_quantity = SUM(inventory_records.quantity) - 验证数据一致性completed_quantity = SUM(inventory_records.quantity)
- **代码质量** - **代码质量**
- usePlanStatusSync 测试间隔标注10秒测试用生产环境需改回5分钟 - usePlanStatusSync 测试间隔标注10秒测试用生产环境需改回5分钟
- 项目目录结构文档更新至最新状态33组件/25页面/9 Hooks - 项目目录结构文档更新至最新状态35组件/25页面/10 Hooks
### 2025-06-02 (v1.0.36) ### 2025-06-02 (v1.0.36)
- **水洗厂功能完善** - **水洗厂功能完善**

View File

@ -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行重复代码头像上传功能随组件一并收敛
- 清理三个文件中不再使用的图标 importX, 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) - 重大版本更新 ### 2026-06-04 (v2.0.0) - 重大版本更新
- **系统审计与质量保障** - **系统审计与质量保障**
- 完成四维度系统审计(文件关联/内存安全/路由配置/权限授权),全部通过 - 完成四维度系统审计(文件关联/内存安全/路由配置/权限授权),全部通过

View File

@ -34,6 +34,8 @@ import { DemoWatermark } from './components/DemoWatermark';
import { DataIntegrityDashboard } from './components/DataIntegrityDashboard'; import { DataIntegrityDashboard } from './components/DataIntegrityDashboard';
import { PurchaserLayout } from './components/layout/PurchaserLayout'; import { PurchaserLayout } from './components/layout/PurchaserLayout';
import { TextileLayout } from './components/layout/TextileLayout'; import { TextileLayout } from './components/layout/TextileLayout';
import { WashingLayout } from './components/layout/WashingLayout';
import { RoleGuard } from './components/RoleGuard';
import './styles/index.css'; import './styles/index.css';
// 页面过渡动画配置 // 页面过渡动画配置
@ -92,7 +94,8 @@ function AppRoutes() {
<Route path="/register" element={<AnimatedRoute pageName="注册"><RegisterPage /></AnimatedRoute>} /> <Route path="/register" element={<AnimatedRoute pageName="注册"><RegisterPage /></AnimatedRoute>} />
<Route path="/role-select" element={<AnimatedRoute pageName="角色选择"><RoleSelectPage /></AnimatedRoute>} /> <Route path="/role-select" element={<AnimatedRoute pageName="角色选择"><RoleSelectPage /></AnimatedRoute>} />
{/* 采购商路由 - 使用 PurchaserLayout 作为布局 */} {/* 采购商路由 - RoleGuard + PurchaserLayout */}
<Route element={<RoleGuard allowedRoles={['purchaser']} />}>
<Route path="/purchaser" element={<PurchaserLayout />}> <Route path="/purchaser" element={<PurchaserLayout />}>
<Route index element={<AnimatedRoute pageName="采购商首页"><PurchaserDashboard /></AnimatedRoute>} /> <Route index element={<AnimatedRoute pageName="采购商首页"><PurchaserDashboard /></AnimatedRoute>} />
<Route path="plans" element={<AnimatedRoute pageName="计划总览"><PlanOverview /></AnimatedRoute>} /> <Route path="plans" element={<AnimatedRoute pageName="计划总览"><PlanOverview /></AnimatedRoute>} />
@ -103,8 +106,10 @@ function AppRoutes() {
<Route path="washing-plans/new" element={<AnimatedRoute pageName="新建水洗计划"><NewWashingPlan /></AnimatedRoute>} /> <Route path="washing-plans/new" element={<AnimatedRoute pageName="新建水洗计划"><NewWashingPlan /></AnimatedRoute>} />
<Route path="finished-warehouse" element={<AnimatedRoute pageName="成品仓库"><PurchaserFinishedWarehouse /></AnimatedRoute>} /> <Route path="finished-warehouse" element={<AnimatedRoute pageName="成品仓库"><PurchaserFinishedWarehouse /></AnimatedRoute>} />
</Route> </Route>
</Route>
{/* 纺织厂路由 - 使用 TextileLayout 作为布局 */} {/* 纺织厂路由 - RoleGuard + TextileLayout */}
<Route element={<RoleGuard allowedRoles={['textile']} />}>
<Route path="/textile" element={<TextileLayout />}> <Route path="/textile" element={<TextileLayout />}>
<Route index element={<AnimatedRoute pageName="纺织厂首页"><TextileDashboard /></AnimatedRoute>} /> <Route index element={<AnimatedRoute pageName="纺织厂首页"><TextileDashboard /></AnimatedRoute>} />
<Route path="plans" element={<AnimatedRoute pageName="纺织厂计划"><TextilePlanOverview /></AnimatedRoute>} /> <Route path="plans" element={<AnimatedRoute pageName="纺织厂计划"><TextilePlanOverview /></AnimatedRoute>} />
@ -112,17 +117,24 @@ function AppRoutes() {
<Route path="yarn-warehouse" element={<AnimatedRoute pageName="纱线仓库"><YarnWarehouse /></AnimatedRoute>} /> <Route path="yarn-warehouse" element={<AnimatedRoute pageName="纱线仓库"><YarnWarehouse /></AnimatedRoute>} />
<Route path="payments" element={<AnimatedRoute pageName="待结款"><PaymentPending /></AnimatedRoute>} /> <Route path="payments" element={<AnimatedRoute pageName="待结款"><PaymentPending /></AnimatedRoute>} />
</Route> </Route>
</Route>
{/* 水洗厂路由 */} {/* 水洗厂路由 - RoleGuard + WashingLayout */}
<Route path="/washing" element={<AnimatedRoute pageName="水洗厂首页"><WashingDashboard /></AnimatedRoute>} /> <Route element={<RoleGuard allowedRoles={['washing']} />}>
<Route path="/washing/plans" element={<AnimatedRoute pageName="水洗计划管理"><WashingPlanOverview /></AnimatedRoute>} /> <Route path="/washing" element={<WashingLayout />}>
<Route path="/washing/pending" element={<AnimatedRoute pageName="待处理坯布"><PendingFabric /></AnimatedRoute>} /> <Route index element={<AnimatedRoute pageName="水洗厂首页"><WashingDashboard /></AnimatedRoute>} />
<Route path="/washing/completed" element={<AnimatedRoute pageName="已完成坯布"><CompletedFabric /></AnimatedRoute>} /> <Route path="plans" element={<AnimatedRoute pageName="水洗计划管理"><WashingPlanOverview /></AnimatedRoute>} />
<Route path="/washing/finished-warehouse" element={<AnimatedRoute pageName="成品仓库"><FinishedWarehouse /></AnimatedRoute>} /> <Route path="pending" element={<AnimatedRoute pageName="待处理坯布"><PendingFabric /></AnimatedRoute>} />
<Route path="/washing/payments" element={<AnimatedRoute pageName="待结款"><WashingPaymentPending /></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 element={<RoleGuard allowedRoles={['purchaser', 'textile', 'washing']} />}>
<Route path="/members" element={<AnimatedRoute pageName="子账号管理"><MemberManage /></AnimatedRoute>} /> <Route path="/members" element={<AnimatedRoute pageName="子账号管理"><MemberManage /></AnimatedRoute>} />
</Route>
{/* 导入计划路由 */} {/* 导入计划路由 */}
<Route path="/import" element={<AnimatedRoute pageName="导入计划"><ImportPlanPage /></AnimatedRoute>} /> <Route path="/import" element={<AnimatedRoute pageName="导入计划"><ImportPlanPage /></AnimatedRoute>} />

View 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 />;
}

View File

@ -17,6 +17,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { STORAGE_KEYS } from '../../config/app';
interface NavItem { interface NavItem {
id: string; id: string;
@ -44,7 +45,7 @@ export function PurchaserLayout() {
// 从 localStorage 读取侧边栏状态,默认展开 // 从 localStorage 读取侧边栏状态,默认展开
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const saved = localStorage.getItem('purchaser_sidebar_collapsed'); const saved = localStorage.getItem(STORAGE_KEYS.purchaserSidebarCollapsed);
return saved ? JSON.parse(saved) : false; return saved ? JSON.parse(saved) : false;
}); });
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@ -53,7 +54,7 @@ export function PurchaserLayout() {
const toggleSidebar = () => { const toggleSidebar = () => {
const newState = !sidebarCollapsed; const newState = !sidebarCollapsed;
setSidebarCollapsed(newState); setSidebarCollapsed(newState);
localStorage.setItem('purchaser_sidebar_collapsed', JSON.stringify(newState)); localStorage.setItem(STORAGE_KEYS.purchaserSidebarCollapsed, JSON.stringify(newState));
}; };
const isActive = (path: string) => { const isActive = (path: string) => {

View File

@ -14,6 +14,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { STORAGE_KEYS } from '../../config/app';
interface NavItem { interface NavItem {
id: string; id: string;
@ -38,7 +39,7 @@ export function TextileLayout() {
// 从 localStorage 读取侧边栏状态,默认展开 // 从 localStorage 读取侧边栏状态,默认展开
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const saved = localStorage.getItem('textile_sidebar_collapsed'); const saved = localStorage.getItem(STORAGE_KEYS.textileSidebarCollapsed);
return saved ? JSON.parse(saved) : false; return saved ? JSON.parse(saved) : false;
}); });
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@ -47,7 +48,7 @@ export function TextileLayout() {
const toggleSidebar = () => { const toggleSidebar = () => {
const newState = !sidebarCollapsed; const newState = !sidebarCollapsed;
setSidebarCollapsed(newState); setSidebarCollapsed(newState);
localStorage.setItem('textile_sidebar_collapsed', JSON.stringify(newState)); localStorage.setItem(STORAGE_KEYS.textileSidebarCollapsed, JSON.stringify(newState));
}; };
const isActive = (path: string) => { const isActive = (path: string) => {

View File

@ -3,6 +3,8 @@ import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
FileText, FileText,
Package,
CheckCircle,
Warehouse, Warehouse,
CreditCard, CreditCard,
LogOut, LogOut,
@ -10,12 +12,11 @@ import {
ChevronRight, ChevronRight,
Droplets, Droplets,
Menu, Menu,
X, X
Package,
CheckCircle
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { STORAGE_KEYS } from '../../config/app';
interface NavItem { interface NavItem {
id: string; id: string;
@ -41,7 +42,7 @@ export function WashingLayout() {
// 从 localStorage 读取侧边栏状态,默认展开 // 从 localStorage 读取侧边栏状态,默认展开
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const saved = localStorage.getItem('washing_sidebar_collapsed'); const saved = localStorage.getItem(STORAGE_KEYS.washingSidebarCollapsed);
return saved ? JSON.parse(saved) : false; return saved ? JSON.parse(saved) : false;
}); });
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@ -50,7 +51,7 @@ export function WashingLayout() {
const toggleSidebar = () => { const toggleSidebar = () => {
const newState = !sidebarCollapsed; const newState = !sidebarCollapsed;
setSidebarCollapsed(newState); setSidebarCollapsed(newState);
localStorage.setItem('washing_sidebar_collapsed', JSON.stringify(newState)); localStorage.setItem(STORAGE_KEYS.washingSidebarCollapsed, JSON.stringify(newState));
}; };
const isActive = (path: string) => { const isActive = (path: string) => {
@ -82,7 +83,7 @@ export function WashingLayout() {
return ( return (
<div className="min-h-screen bg-slate-50"> <div className="min-h-screen bg-slate-50">
{/* 桌面端侧边栏 - 固定不动,无入场动画 */} {/* 桌面端侧边栏 */}
{!isMobile && ( {!isMobile && (
<aside <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 ${ 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> </aside>
)} )}
{/* 移动端顶部导航栏 */} {/* 移动端底部导航 */}
{isMobile && ( {isMobile && (
<header className="bg-white/80 backdrop-blur-xl border-b border-slate-200/50 sticky top-0 z-40"> <div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 z-50 safe-area-bottom">
<div className="px-4 py-3 flex justify-between items-center"> <div className="flex items-center justify-around py-2">
<div className="flex items-center gap-2"> {navItems.slice(0, 5).map((item) => {
<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>
{/* 移动端菜单 */}
{mobileMenuOpen && (
<nav className="border-t border-slate-200 bg-white">
{navItems.map((item) => {
const active = isActive(item.path); const active = isActive(item.path);
const Icon = item.icon; const Icon = item.icon;
return ( return (
<button <button
key={item.id} key={item.id}
onClick={() => { onClick={() => navigate(item.path)}
navigate(item.path); className={`flex flex-col items-center gap-0.5 px-2 py-1 rounded-lg transition-colors ${
setMobileMenuOpen(false); active ? 'text-violet-600' : 'text-slate-400'
}}
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" /> <Icon className={`w-5 h-5 ${active ? 'stroke-[2.5px]' : ''}`} />
<span className="font-medium text-sm">{item.label}</span> <span className="text-[10px] font-medium">{item.label}</span>
</button> </button>
); );
})} })}
<button </div>
onClick={handleLogout} </div>
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>
)} )}
{/* 主内容区 - 移动端无侧边栏,桌面端有侧边栏 */} {/* 主内容区 */}
<main className={`${ <main className={`${
!isMobile && (sidebarCollapsed ? 'ml-20' : 'ml-64') !isMobile && (sidebarCollapsed ? 'ml-20' : 'ml-64')
}`}> } ${isMobile ? 'pb-20' : ''}`}>
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@ -27,7 +27,7 @@ export const APP_CONFIG = {
/** 定位描述 */ /** 定位描述 */
description: '纺织行业全流程协作管理平台', description: '纺织行业全流程协作管理平台',
/** 版本号 */ /** 版本号 */
version: '2.1.0', version: '2.2.0',
/** 默认密码(仅 Demo 环境) */ /** 默认密码(仅 Demo 环境) */
defaultDemoPassword: '123456', defaultDemoPassword: '123456',
/** 免密登录有效期毫秒默认30天 */ /** 免密登录有效期毫秒默认30天 */

View File

@ -1,98 +1,52 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
export type ErrorSeverity = 'info' | 'warning' | 'error' | 'critical'; interface ErrorOptions {
/** 用户可见的错误提示 */
interface ErrorHandlerOptions { userMessage?: string;
onError?: (error: Error, severity: ErrorSeverity) => void; /** 是否显示 alert默认 true */
showToast?: boolean; showAlert?: boolean;
logToConsole?: 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 = {}) { export function useErrorHandler() {
const { const handleError = useCallback((error: unknown, options: ErrorOptions = {}) => {
onError, const { userMessage, showAlert = true, context } = options;
showToast = true,
logToConsole = true
} = options;
const handleError = useCallback(( // 结构化日志
error: unknown, const logEntry = {
message?: string, timestamp: new Date().toISOString(),
severity: ErrorSeverity = 'error' error: error instanceof Error ? { message: error.message, stack: error.stack } : String(error),
) => { context,
// 标准化错误对象
const err = error instanceof Error
? error
: new Error(message || String(error));
// 控制台日志
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 (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
}; };
console.error('[AppError]', logEntry);
// 用户提示
if (showAlert && userMessage) {
alert(userMessage);
} else if (showAlert && error instanceof Error) {
alert(error.message || '操作失败,请重试');
}
}, []);
return handleError;
} }

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { supabase } from '../supabase/client'; import { supabase } from '../supabase/client';
import { CACHE_CONFIG } from '../config/app'; import { CACHE_CONFIG } from '../config/app';
import { useInventoryRealtime } from './useRealtime'; import { useInventoryRealtime } from './useRealtime';
import type { Tables } from '../supabase/types';
import type { import type {
Product, Product,
ProductWithInventory, ProductWithInventory,
@ -23,6 +24,28 @@ interface InventoryDataState {
error: Error | null; 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 * Hook
* 使 * 使
@ -93,8 +116,8 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
if (outboundResult.error) throw outboundResult.error; if (outboundResult.error) throw outboundResult.error;
const productsData = productsResult.data || []; const productsData = productsResult.data || [];
const batchesData = batchesResult.data || []; const batchesData: ProductInventoryRecord[] = batchesResult.data || [];
const outboundData = outboundResult.data || []; const outboundData: ProductOutboundRecord[] = outboundResult.data || [];
// 获取纱线配比 // 获取纱线配比
const productIds = productsData.map(p => p.id); const productIds = productsData.map(p => p.id);
@ -122,7 +145,8 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
.eq('purchaser_id', companyId) .eq('purchaser_id', companyId)
.in('fabric_code', fabricCodes); .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 const { data: planFactoriesData } = await supabase
@ -131,35 +155,38 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
.in('plan_id', planIds) .in('plan_id', planIds)
.eq('factory_type', 'textile'); .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 const { data: factoriesData } = await supabase
.from('companies') .from('companies')
.select('id, name') .select('id, name')
.in('id', factoryIds); .in('id', factoryIds);
const typedFactories: CompanyRef[] = factoriesData || [];
// 建立映射 // 建立映射
const planToFactoryName: Record<string, string> = {}; const planToFactoryName: Record<string, string> = {};
planFactoriesData?.forEach((pf: any) => { typedPlanFactories.forEach((pf: PlanFactoryRef) => {
const factory = factoriesData?.find(f => f.id === pf.factory_id); const factory = typedFactories.find(f => f.id === pf.factory_id);
if (factory) { if (factory) {
planToFactoryName[pf.plan_id] = factory.name; planToFactoryName[pf.plan_id] = factory.name;
} }
}); });
// 获取入库记录 // 获取入库记录
let textileInventoryRecords: any[] = []; let textileInventoryRecords: InventoryRecord[] = [];
if (planIds.length > 0) { if (planIds.length > 0) {
const { data: records } = await supabase const { data: records } = await supabase
.from('inventory_records') .from('inventory_records')
.select('plan_id, quantity, rolls, warehouse_location') .select('plan_id, quantity, rolls, warehouse_location')
.in('plan_id', planIds); .in('plan_id', planIds);
textileInventoryRecords = records || []; textileInventoryRecords = (records || []) as InventoryRecord[];
} }
// 处理数据 // 处理数据
const updatedProducts = processInventoryData({ const updatedProducts = processInventoryData({
products: productsData, products: productsData,
plansData: plansData || [], plansData: typedPlansData,
textileInventoryRecords, textileInventoryRecords,
planToFactoryName, planToFactoryName,
batchesData, batchesData,
@ -167,22 +194,25 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
}); });
// 为 batches 数据注入产品信息 // 为 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); const product = productsData.find((p: Product) => p.id === batch.product_id);
return { return {
...batch, id: batch.id,
product: product ? { product_id: batch.product_id,
product_name: product.product_name, company_id: batch.company_id || '',
weight: product.weight, batch_no: batch.batch_no,
color: product.color rolls: batch.rolls,
} : null meters: batch.meters,
}; notes: batch.notes,
created_at: batch.created_at,
product: product || undefined
} as FabricBatch;
}); });
const newState: InventoryDataState = { const newState: InventoryDataState = {
products: updatedProducts, products: updatedProducts,
productRatios: ratiosMap, productRatios: ratiosMap,
batches: batchesWithProduct as FabricBatch[], batches: batchesWithProduct,
loading: false, loading: false,
error: null error: null
}; };
@ -224,11 +254,11 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
// 处理库存数据的辅助函数 // 处理库存数据的辅助函数
interface ProcessInventoryDataParams { interface ProcessInventoryDataParams {
products: Product[]; products: Product[];
plansData: any[]; plansData: PlanSummary[];
textileInventoryRecords: any[]; textileInventoryRecords: InventoryRecord[];
planToFactoryName: Record<string, string>; planToFactoryName: Record<string, string>;
batchesData: any[]; batchesData: ProductInventoryRecord[];
outboundData: any[]; outboundData: ProductOutboundRecord[];
} }
function processInventoryData({ function processInventoryData({
@ -242,7 +272,7 @@ function processInventoryData({
// 建立 plan_id 到 fabric_code 的映射 // 建立 plan_id 到 fabric_code 的映射
const planToFabricCode: Record<string, string> = {}; const planToFabricCode: Record<string, string> = {};
const planCompletedMap: Record<string, number> = {}; const planCompletedMap: Record<string, number> = {};
plansData?.forEach((plan: any) => { plansData.forEach((plan: PlanSummary) => {
planToFabricCode[plan.id] = plan.fabric_code; planToFabricCode[plan.id] = plan.fabric_code;
planCompletedMap[plan.id] = plan.completed_quantity || 0; planCompletedMap[plan.id] = plan.completed_quantity || 0;
}); });
@ -250,7 +280,7 @@ function processInventoryData({
// 按 plan_id 汇总库存 // 按 plan_id 汇总库存
const planInventoryMap: Record<string, { meters: number; rolls: number; factory_id: string; factory_name: string }> = {}; 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 factoryName = planToFactoryName[r.plan_id] || r.warehouse_location || '纺织厂';
const factoryId = planToFactoryName[r.plan_id] || r.warehouse_location || `factory_${r.plan_id}`; const factoryId = planToFactoryName[r.plan_id] || r.warehouse_location || `factory_${r.plan_id}`;
if (!planInventoryMap[r.plan_id]) { if (!planInventoryMap[r.plan_id]) {
@ -262,7 +292,7 @@ function processInventoryData({
// 汇总每个坯布码的库存 // 汇总每个坯布码的库存
const fabricCodeInventory: Record<string, { meters: number; rolls: number }> = {}; 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]) => { Object.entries(planInventoryMap).forEach(([planId, inventory]) => {
const fabricCode = planToFabricCode[planId]; const fabricCode = planToFabricCode[planId];
@ -295,7 +325,7 @@ function processInventoryData({
const inRecordsMap: Record<string, ProductInRecord[]> = {}; const inRecordsMap: Record<string, ProductInRecord[]> = {};
// 1. 先添加自录入的入库记录 // 1. 先添加自录入的入库记录
batchesData?.forEach((b: any) => { batchesData.forEach((b: ProductInventoryRecord) => {
if (!inRecordsMap[b.product_id]) { if (!inRecordsMap[b.product_id]) {
inRecordsMap[b.product_id] = []; inRecordsMap[b.product_id] = [];
} }
@ -318,12 +348,12 @@ function processInventoryData({
// 2. 添加纺织厂的入库记录 // 2. 添加纺织厂的入库记录
// 建立 fabric_code 到 product_id 的映射 // 建立 fabric_code 到 product_id 的映射
const fabricCodeToProductId: Record<string, string> = {}; const fabricCodeToProductId: Record<string, string> = {};
products.forEach((p: any) => { products.forEach((p: Product) => {
fabricCodeToProductId[p.fabric_code] = p.id; fabricCodeToProductId[p.fabric_code] = p.id;
}); });
// 将纺织厂入库记录按 fabric_code 关联到对应产品 // 将纺织厂入库记录按 fabric_code 关联到对应产品
textileInventoryRecords?.forEach((r: any) => { textileInventoryRecords.forEach((r: InventoryRecord) => {
const fabricCode = planToFabricCode[r.plan_id]; const fabricCode = planToFabricCode[r.plan_id];
const productId = fabricCodeToProductId[fabricCode]; const productId = fabricCodeToProductId[fabricCode];
if (productId) { if (productId) {
@ -335,10 +365,10 @@ function processInventoryData({
id: r.id || `${r.plan_id}-${r.created_at}`, id: r.id || `${r.plan_id}-${r.created_at}`,
product_id: productId, product_id: productId,
company_id: r.company_id, company_id: r.company_id,
batch_no: r.batch_no || '-', batch_no: '-',
rolls: Number(r.rolls) || 0, rolls: Number(r.rolls) || 0,
meters: Number(r.quantity) || 0, meters: Number(r.quantity) || 0,
notes: r.notes || `来自${factoryName}`, notes: `来自${factoryName}`,
operator_id: r.operator_id, operator_id: r.operator_id,
warehouse_id: r.warehouse_id, warehouse_id: r.warehouse_id,
created_at: r.created_at, created_at: r.created_at,
@ -356,34 +386,19 @@ function processInventoryData({
}); });
// 处理出库记录 - 按 product_id 汇总 // 处理出库记录 - 按 product_id 汇总
const outboundMap: Record<string, { totalRolls: number; totalMeters: number; records: any[] }> = {}; const outboundMap: Record<string, { totalRolls: number; totalMeters: number; records: ProductOutboundRecord[] }> = {};
outboundData?.forEach((r: any) => { outboundData.forEach((r: ProductOutboundRecord) => {
if (!outboundMap[r.product_id]) { if (!outboundMap[r.product_id]) {
outboundMap[r.product_id] = { totalRolls: 0, totalMeters: 0, records: [] }; outboundMap[r.product_id] = { totalRolls: 0, totalMeters: 0, records: [] };
} }
outboundMap[r.product_id].totalRolls += Number(r.rolls) || 0; outboundMap[r.product_id].totalRolls += Number(r.rolls) || 0;
outboundMap[r.product_id].totalMeters += Number(r.meters) || 0; outboundMap[r.product_id].totalMeters += Number(r.meters) || 0;
outboundMap[r.product_id].records.push({ outboundMap[r.product_id].records.push(r);
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
});
}); });
// 计算每个产品的自录入库总量(从 batchesData // 计算每个产品的自录入库总量(从 batchesData
const selfInventoryMap: Record<string, { rolls: number; meters: number }> = {}; const selfInventoryMap: Record<string, { rolls: number; meters: number }> = {};
batchesData?.forEach((b: any) => { batchesData.forEach((b: ProductInventoryRecord) => {
if (!selfInventoryMap[b.product_id]) { if (!selfInventoryMap[b.product_id]) {
selfInventoryMap[b.product_id] = { rolls: 0, meters: 0 }; 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 textileInventory = fabricCodeInventory[p.fabric_code] || { meters: 0, rolls: 0 };
const factoryInventories = fabricCodeFactoryInventory[p.fabric_code] || {}; const factoryInventories = fabricCodeFactoryInventory[p.fabric_code] || {};
// 使用实时计算的入库记录,而不是 products 表中的字段 // 使用实时计算的入库记录,而不是 products 表中的字段

View File

@ -248,12 +248,13 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
yarnRatiosMap[y.plan_id].push(y as YarnRatio); yarnRatiosMap[y.plan_id].push(y as YarnRatio);
}); });
setState(prev => {
const newState: PlanDataState = { const newState: PlanDataState = {
plans: append ? [...state.plans, ...plansWithSteps] : plansWithSteps, plans: append ? [...prev.plans, ...plansWithSteps] : plansWithSteps,
factories: isFirstPage ? factories : state.factories, factories: isFirstPage ? factories : prev.factories,
planFactories: isFirstPage ? (pfResult.data || []) : state.planFactories, planFactories: isFirstPage ? (pfResult.data || []) : prev.planFactories,
operatorNames: isFirstPage ? operatorNames : { ...state.operatorNames, ...operatorNames }, operatorNames: isFirstPage ? operatorNames : { ...prev.operatorNames, ...operatorNames },
yarnRatios: isFirstPage ? yarnRatiosMap : { ...state.yarnRatios, ...yarnRatiosMap }, yarnRatios: isFirstPage ? yarnRatiosMap : { ...prev.yarnRatios, ...yarnRatiosMap },
loading: false, loading: false,
loadingMore: false, loadingMore: false,
hasMore: planData.length === pageSize, hasMore: planData.length === pageSize,
@ -266,7 +267,8 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
cacheRef.current = { data: newState, timestamp: Date.now() }; cacheRef.current = { data: newState, timestamp: Date.now() };
} }
setState(newState); return newState;
});
} catch (err) { } catch (err) {
setState(prev => ({ setState(prev => ({
...prev, ...prev,

View File

@ -7,6 +7,7 @@ import {
AlertTriangle, FileText, Palette, Hash, DollarSign, AlertTriangle, FileText, Palette, Hash, DollarSign,
ChevronRight, ChevronDown, Shield, Database ChevronRight, ChevronDown, Shield, Database
} from 'lucide-react'; } from 'lucide-react';
import { PLAN_STATUS } from '../utils/constants';
// 模拟完整计划数据(采购商视角) // 模拟完整计划数据(采购商视角)
const fullPlanData = { const fullPlanData = {
@ -20,7 +21,7 @@ const fullPlanData = {
target_quantity: 10000, target_quantity: 10000,
yarn_usage_per_meter: 280, yarn_usage_per_meter: 280,
production_price: 12.5, production_price: 12.5,
status: 'producing' status: PLAN_STATUS.PRODUCING
}; };
// 模拟纱线配比 // 模拟纱线配比

View File

@ -6,6 +6,8 @@ import { motion } from 'framer-motion';
import { ArrowLeft, CheckCircle, AlertTriangle, Package, Droplet, Link2, Clock, XCircle, Building2 } from 'lucide-react'; import { ArrowLeft, CheckCircle, AlertTriangle, Package, Droplet, Link2, Clock, XCircle, Building2 } from 'lucide-react';
import { decryptShareParams } from '../utils/shareCrypto'; import { decryptShareParams } from '../utils/shareCrypto';
import { useResponsive } from '../hooks/useResponsive'; 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'; import type { ProductionPlan, WashingPlan, YarnRatio, FactoryType } from '../types';
const containerVariants = { const containerVariants = {
@ -26,6 +28,7 @@ export function ImportPlanPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { auth } = useAuth(); const { auth } = useAuth();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const handleError = useErrorHandler();
const token = searchParams.get('token') || ''; const token = searchParams.get('token') || '';
const shortCode = searchParams.get('s') || ''; const shortCode = searchParams.get('s') || '';
@ -66,12 +69,13 @@ export function ImportPlanPage() {
// 更新状态为过期 // 更新状态为过期
await supabase await supabase
.from('share_links') .from('share_links')
.update({ status: 'expired' }) .update({ status: SHARE_LINK_STATUS.EXPIRED })
.eq('short_code', shortCode); .eq('short_code', shortCode);
} }
// 检查是否已被使用confirmed/rejected/expired // 检查是否已被使用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); setDecrypting(false);
return; return;
} }
@ -81,29 +85,29 @@ export function ImportPlanPage() {
setHideSensitive(data.hide_sensitive || false); setHideSensitive(data.hide_sensitive || false);
// 如果状态是 pending更新为 clicked首次点击 // 如果状态是 pending更新为 clicked首次点击
if (data.status === 'pending') { if (data.status === SHARE_LINK_STATUS.PENDING) {
const { error: updateError } = await supabase const { error: updateError } = await supabase
.from('share_links') .from('share_links')
.update({ .update({
status: 'clicked', status: SHARE_LINK_STATUS.CLICKED,
clicked_at: new Date().toISOString() clicked_at: new Date().toISOString()
}) })
.eq('short_code', shortCode); .eq('short_code', shortCode);
if (updateError) { if (updateError) {
console.error('更新链接点击状态失败:', updateError); handleError(updateError, { userMessage: '更新链接点击状态失败', context: { shortCode } });
} else { } else {
setShareLinkStatus('clicked'); setShareLinkStatus(SHARE_LINK_STATUS.CLICKED as ShareLinkStatus);
} }
setLinkClicked(true); setLinkClicked(true);
} else if (data.status === 'clicked') { } else if (data.status === SHARE_LINK_STATUS.CLICKED) {
setLinkClicked(true); setLinkClicked(true);
} }
// 增加点击计数 // 增加点击计数
await supabase.rpc('increment_share_link_clicks', { code: shortCode }); await supabase.rpc('increment_share_link_clicks', { code: shortCode });
} else { } else {
console.error('短链查询失败:', error); handleError(error, { userMessage: '短链查询失败', context: { shortCode } });
} }
setDecrypting(false); setDecrypting(false);
} else if (token) { } else if (token) {
@ -145,12 +149,12 @@ export function ImportPlanPage() {
if (diff <= 0) { if (diff <= 0) {
setTimeRemaining('已过期'); setTimeRemaining('已过期');
setLinkExpired(true); 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 await supabase
.from('share_links') .from('share_links')
.update({ status: 'expired' }) .update({ status: SHARE_LINK_STATUS.EXPIRED })
.eq('short_code', shortCode); .eq('short_code', shortCode);
setShareLinkStatus('expired'); setShareLinkStatus(SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus);
} }
} else { } else {
const minutes = Math.floor(diff / 60000); const minutes = Math.floor(diff / 60000);
@ -250,11 +254,11 @@ export function ImportPlanPage() {
// 1.5 更新计划状态为 producing生产中 // 1.5 更新计划状态为 producing生产中
const { error: planUpdateError } = await supabase const { error: planUpdateError } = await supabase
.from('production_plans') .from('production_plans')
.update({ status: 'producing' }) .update({ status: PLAN_STATUS.PRODUCING })
.eq('id', planId); .eq('id', planId);
if (planUpdateError) { if (planUpdateError) {
console.error('更新计划状态失败:', planUpdateError); handleError(planUpdateError, { userMessage: '更新计划状态失败', context: { planId } });
} }
// 2. 创建/更新公司合作关系 // 2. 创建/更新公司合作关系
@ -268,7 +272,7 @@ export function ImportPlanPage() {
}); });
if (relError) { if (relError) {
console.error('创建合作关系失败:', relError); handleError(relError, { userMessage: '创建合作关系失败', context: { purchaserCompanyId, factoryCompanyId: auth.company.id } });
} }
// 3. 更新分享链接状态 // 3. 更新分享链接状态
@ -276,14 +280,14 @@ export function ImportPlanPage() {
const { error: linkUpdateError } = await supabase const { error: linkUpdateError } = await supabase
.from('share_links') .from('share_links')
.update({ .update({
status: 'confirmed', status: SHARE_LINK_STATUS.CONFIRMED,
responded_at: new Date().toISOString(), responded_at: new Date().toISOString(),
response_result: 'confirmed' response_result: 'confirmed'
}) })
.eq('short_code', shortCode); .eq('short_code', shortCode);
if (linkUpdateError) { if (linkUpdateError) {
console.error('更新分享链接状态失败:', linkUpdateError); handleError(linkUpdateError, { userMessage: '更新分享链接状态失败', context: { shortCode } });
} }
} }
@ -301,7 +305,7 @@ export function ImportPlanPage() {
await supabase await supabase
.from('share_links') .from('share_links')
.update({ .update({
status: 'rejected', status: SHARE_LINK_STATUS.REJECTED,
responded_at: new Date().toISOString(), responded_at: new Date().toISOString(),
response_result: 'rejected', response_result: 'rejected',
reject_reason: rejectReason reject_reason: rejectReason

View File

@ -18,12 +18,6 @@ import {
DollarSign, DollarSign,
Droplets, Droplets,
LogOut, LogOut,
HelpCircle,
X,
BookOpen,
MessageSquare,
Info,
Camera,
ChevronRight, ChevronRight,
Home, Home,
MoreHorizontal, MoreHorizontal,
@ -42,6 +36,8 @@ import { FeedbackModal } from '../../components/FeedbackModal';
import { UserManual } from '../../components/UserManual'; import { UserManual } from '../../components/UserManual';
import type { ProductionPlan } from '../../types'; import type { ProductionPlan } from '../../types';
import { LoadingSpinner } from '../../components/ui/LoadingSpinner'; 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 }> = { 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 }, pending: { label: '待确定', color: 'text-amber-600', bgColor: 'bg-amber-50', borderColor: 'border-amber-200', icon: Clock },
@ -104,7 +100,7 @@ export function PurchaserDashboard() {
// 过滤掉有 rejected 步骤的计划 // 过滤掉有 rejected 步骤的计划
const rejectedPlanIds = new Set<string>(); const rejectedPlanIds = new Set<string>();
(stepsData || []).forEach(step => { (stepsData || []).forEach(step => {
if (step.status === 'rejected') { if (step.status === STEP_STATUS.REJECTED) {
rejectedPlanIds.add(step.plan_id); rejectedPlanIds.add(step.plan_id);
} }
}); });
@ -130,14 +126,14 @@ export function PurchaserDashboard() {
const stats = { const stats = {
all: plans.length, all: plans.length,
producing: plans.filter(p => p.status === 'producing').length, producing: plans.filter(p => p.status === PLAN_STATUS.PRODUCING).length,
completed: plans.filter(p => p.status === 'completed').length, completed: plans.filter(p => p.status === PLAN_STATUS.COMPLETED).length,
pending: plans.filter(p => p.status === 'pending').length pending: plans.filter(p => p.status === PLAN_STATUS.PENDING).length
}; };
// 获取最近计划(桌面端显示更多) // 获取最近计划(桌面端显示更多)
const recentPlans = plans.slice(0, isMobile ? 5 : 10); 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 = [ const statCards = [
@ -467,147 +463,14 @@ export function PurchaserDashboard() {
/> />
{/* 账户卡片弹窗 */} {/* 账户卡片弹窗 */}
{showAccountCard && ( <AccountProfileCard
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAccountCard(false)}> isOpen={showAccountCard}
<motion.div onClose={() => setShowAccountCard(false)}
initial={{ opacity: 0, scale: 0.95 }} role="purchaser"
animate={{ opacity: 1, scale: 1 }} onShowUserManual={() => setShowUserManual(true)}
exit={{ opacity: 0, scale: 0.95 }} onShowFeedback={() => setShowFeedback(true)}
className="bg-white rounded-2xl w-full max-w-sm shadow-2xl overflow-hidden" onShowAbout={() => setShowAbout(true)}
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>
)}
</div> </div>
); );
} }

View File

@ -5,12 +5,14 @@ import { supabase } from '../../supabase/client';
import { motion, AnimatePresence } from 'framer-motion'; 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 { Factory, Droplets, FileText, ChevronRight, ArrowLeft, Plus, X, Building2, Phone, MapPin, User, Edit2, Trash2, AlertTriangle } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { useErrorHandler } from '../../hooks/useErrorHandler';
import type { Company } from '../../types'; import type { Company } from '../../types';
export function FactoryManage() { export function FactoryManage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { auth } = useAuth(); const { auth } = useAuth();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const handleError = useErrorHandler();
const [activeTab, setActiveTab] = useState<'textile' | 'washing'>('textile'); const [activeTab, setActiveTab] = useState<'textile' | 'washing'>('textile');
const [factories, setFactories] = useState<Company[]>([]); const [factories, setFactories] = useState<Company[]>([]);
const [planCounts, setPlanCounts] = useState<Record<string, number>>({}); const [planCounts, setPlanCounts] = useState<Record<string, number>>({});
@ -124,14 +126,13 @@ export function FactoryManage() {
.limit(1); .limit(1);
if (checkError) { if (checkError) {
console.error('检查关联计划失败:', checkError); handleError(checkError, { userMessage: '检查关联计划失败' });
alert('检查关联计划失败:' + checkError.message);
setDeleting(false); setDeleting(false);
return; return;
} }
if (planFactories && planFactories.length > 0) { if (planFactories && planFactories.length > 0) {
alert('该工厂已有关联计划,无法删除'); handleError(new Error('无法删除'), { userMessage: '该工厂已有关联计划,无法删除' });
setDeleting(false); setDeleting(false);
return; return;
} }
@ -145,8 +146,7 @@ export function FactoryManage() {
.eq('factory_company_id', deletingFactory.id); .eq('factory_company_id', deletingFactory.id);
if (relError) { if (relError) {
console.error('删除合作关系失败:', relError); handleError(relError, { userMessage: '删除合作关系失败' });
alert('删除合作关系失败:' + relError.message);
setDeleting(false); setDeleting(false);
return; return;
} }
@ -161,8 +161,7 @@ export function FactoryManage() {
.select(); .select();
if (deleteError) { if (deleteError) {
console.error('删除工厂失败:', deleteError); handleError(deleteError, { userMessage: '删除工厂失败' });
alert('删除工厂失败:' + deleteError.message);
setDeleting(false); setDeleting(false);
return; return;
} }
@ -177,8 +176,7 @@ export function FactoryManage() {
} }
handleCloseDeleteModal(); handleCloseDeleteModal();
} catch (err) { } catch (err) {
console.error('删除工厂失败:', err); handleError(err, { userMessage: '删除工厂时发生错误' });
alert('删除工厂时发生错误');
} }
setDeleting(false); setDeleting(false);
}; };

View File

@ -4,7 +4,9 @@ import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { supabase } from '../../supabase/client';
import { ArrowLeft, Plus, Trash2, Search, X, ChevronDown } from 'lucide-react'; import { ArrowLeft, Plus, Trash2, Search, X, ChevronDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useErrorHandler } from '../../hooks/useErrorHandler';
import type { Company, FactoryType } from '../../types'; import type { Company, FactoryType } from '../../types';
import { PLAN_STATUS, STEP_STATUS } from '../../utils/constants';
interface YarnInput { interface YarnInput {
id: string; id: string;
@ -48,6 +50,7 @@ const yarnTypeLabels: Record<string, string> = {
export function NewPlan() { export function NewPlan() {
const navigate = useNavigate(); const navigate = useNavigate();
const { auth } = useAuth(); const { auth } = useAuth();
const handleError = useErrorHandler();
// 从产品信息选择的产品 // 从产品信息选择的产品
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
@ -214,14 +217,14 @@ export function NewPlan() {
target_quantity: parseInt(targetQuantity) || 0, target_quantity: parseInt(targetQuantity) || 0,
yarn_usage_per_meter: selectedProduct?.weight || 0, // 使用产品克重作为纱用量 yarn_usage_per_meter: selectedProduct?.weight || 0, // 使用产品克重作为纱用量
production_price: parseFloat(productionPrice) || 0, production_price: parseFloat(productionPrice) || 0,
status: 'pending', status: PLAN_STATUS.PENDING,
created_by: auth.user?.id created_by: auth.user?.id
}) })
.select() .select()
.maybeSingle(); .maybeSingle();
if (planError) { if (planError) {
alert('创建计划失败: ' + planError.message); handleError(planError, { userMessage: '创建计划失败' });
setSubmitting(false); setSubmitting(false);
return; return;
} }
@ -254,7 +257,7 @@ export function NewPlan() {
const { error: stepError } = await supabase.from('plan_process_steps').insert({ const { error: stepError } = await supabase.from('plan_process_steps').insert({
plan_id: plan.id, plan_id: plan.id,
step_type: step, step_type: step,
status: 'pending' as const status: STEP_STATUS.PENDING
}); });
if (stepError) { if (stepError) {
stepErrors.push(stepError.message); stepErrors.push(stepError.message);
@ -291,7 +294,7 @@ export function NewPlan() {
alert('计划创建成功!'); alert('计划创建成功!');
navigate('/purchaser/plans'); navigate('/purchaser/plans');
} catch (err) { } catch (err) {
alert('创建失败,请重试'); handleError(err, { userMessage: '创建失败,请重试' });
} }
setSubmitting(false); setSubmitting(false);
}; };

View File

@ -6,6 +6,7 @@ import { motion } from 'framer-motion';
import { ArrowLeft, Plus, Droplets, Calculator, AlertCircle } from 'lucide-react'; import { ArrowLeft, Plus, Droplets, Calculator, AlertCircle } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import type { ProductWithInventory, Company } from '../../types'; import type { ProductWithInventory, Company } from '../../types';
import { WASHING_PLAN_STATUS } from '../../utils/constants';
interface WashingPlanForm { interface WashingPlanForm {
productId: string; productId: string;
@ -198,7 +199,7 @@ export function NewWashingPlan() {
estimated_shrinkage_rate: parseFloat(form.estimatedShrinkageRate) || 0, estimated_shrinkage_rate: parseFloat(form.estimatedShrinkageRate) || 0,
notes: form.notes || null, notes: form.notes || null,
created_by: auth.user?.id, created_by: auth.user?.id,
status: 'pending' status: WASHING_PLAN_STATUS.PENDING
}).select().single(); }).select().single();
if (insertError) { if (insertError) {

View File

@ -4,6 +4,7 @@ import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { supabase } from '../../supabase/client';
import { useInventoryData } from '../../hooks/useInventoryData'; import { useInventoryData } from '../../hooks/useInventoryData';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { useErrorHandler } from '../../hooks/useErrorHandler';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Package, ArrowLeft, Plus, Search, List } from 'lucide-react'; import { Package, ArrowLeft, Plus, Search, List } from 'lucide-react';
import { WarehouseNav } from '../../components/warehouse/WarehouseNav'; import { WarehouseNav } from '../../components/warehouse/WarehouseNav';
@ -95,6 +96,7 @@ export function WarehouseManage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { auth } = useAuth(); const { auth } = useAuth();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const handleError = useErrorHandler();
// 菜单状态 // 菜单状态
const [activeSubMenu, setActiveSubMenu] = useState<SubMenuType>('products'); const [activeSubMenu, setActiveSubMenu] = useState<SubMenuType>('products');
@ -179,7 +181,7 @@ export function WarehouseManage() {
.maybeSingle(); .maybeSingle();
if (existingFabricCode) { if (existingFabricCode) {
alert(`产品码 "${productData.fabric_code}" 已存在,请使用其他产品码`); handleError(new Error('产品码重复'), { userMessage: `产品码 "${productData.fabric_code}" 已存在,请使用其他产品码` });
return; return;
} }
@ -197,7 +199,7 @@ export function WarehouseManage() {
.maybeSingle(); .maybeSingle();
if (existingColorCode) { 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; return;
} }
@ -214,7 +216,7 @@ export function WarehouseManage() {
.eq('id', editingProduct.id); .eq('id', editingProduct.id);
if (error) { if (error) {
alert('更新失败: ' + error.message); handleError(error, { userMessage: '更新失败' });
return; return;
} }
@ -243,7 +245,7 @@ export function WarehouseManage() {
.single(); .single();
if (error) { if (error) {
alert('创建失败: ' + error.message); handleError(error, { userMessage: '创建失败' });
return; return;
} }
productId = data.id; productId = data.id;
@ -275,7 +277,7 @@ export function WarehouseManage() {
const { error } = await supabase.from('products').delete().eq('id', id); const { error } = await supabase.from('products').delete().eq('id', id);
if (error) { if (error) {
alert('删除失败: ' + error.message); handleError(error, { userMessage: '删除失败' });
return; return;
} }
await refetch(); await refetch();
@ -360,7 +362,7 @@ export function WarehouseManage() {
}); });
if (error) { if (error) {
alert('入库失败: ' + error.message); handleError(error, { userMessage: '入库失败' });
return; return;
} }
@ -386,15 +388,15 @@ export function WarehouseManage() {
// 检查库存是否充足 // 检查库存是否充足
const product = products.find(p => p.id === outForm.product_id); const product = products.find(p => p.id === outForm.product_id);
if (!product) { if (!product) {
alert('产品不存在'); handleError(new Error('产品不存在'), { userMessage: '产品不存在' });
return; return;
} }
if ((product.total_stock || 0) < outForm.meters) { if ((product.total_stock || 0) < outForm.meters) {
alert(`库存不足,当前库存: ${product.total_stock}`); handleError(new Error('库存不足'), { userMessage: `库存不足,当前库存: ${product.total_stock}` });
return; return;
} }
if ((product.raw_fabric_rolls || 0) < outForm.rolls) { if ((product.raw_fabric_rolls || 0) < outForm.rolls) {
alert(`库存匹/件数不足,当前库存: ${product.raw_fabric_rolls}匹/件`); handleError(new Error('库存不足'), { userMessage: `库存匹/件数不足,当前库存: ${product.raw_fabric_rolls}匹/件` });
return; return;
} }
@ -413,7 +415,7 @@ export function WarehouseManage() {
}); });
if (error) { if (error) {
alert('出库失败: ' + error.message); handleError(error, { userMessage: '出库失败' });
return; return;
} }

View File

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { supabase } from '../../supabase/client';
import { motion } from 'framer-motion'; 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 { useResponsive } from '../../hooks/useResponsive';
import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide'; import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide';
import { NotificationCenter } from '../../components/NotificationCenter'; import { NotificationCenter } from '../../components/NotificationCenter';
@ -15,6 +15,8 @@ import { CrashReporter } from '../../components/CrashReporter';
import { STORAGE_KEYS } from '../../config/app'; import { STORAGE_KEYS } from '../../config/app';
import type { ProductionPlan } from '../../types'; import type { ProductionPlan } from '../../types';
import { LoadingSpinner } from '../../components/ui/LoadingSpinner'; 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 }> = { 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 }, pending: { label: '待确定', color: 'text-amber-600', bgColor: 'bg-amber-50', borderColor: 'border-amber-200', icon: Clock },
@ -98,7 +100,7 @@ export function TextileDashboard() {
// 标记有 rejected 步骤的计划 // 标记有 rejected 步骤的计划
const rejectedIds = new Set<string>(); const rejectedIds = new Set<string>();
(stepsData || []).forEach(step => { (stepsData || []).forEach(step => {
if (step.status === 'rejected') { if (step.status === STEP_STATUS.REJECTED) {
rejectedIds.add(step.plan_id); rejectedIds.add(step.plan_id);
} }
}); });
@ -110,13 +112,13 @@ export function TextileDashboard() {
const stats = { const stats = {
all: plans.length, all: plans.length,
producing: plans.filter(p => p.status === 'producing').length, producing: plans.filter(p => p.status === PLAN_STATUS.PRODUCING).length,
completed: plans.filter(p => p.status === 'completed').length completed: plans.filter(p => p.status === PLAN_STATUS.COMPLETED).length
}; };
// 过滤掉被拒绝的计划 // 过滤掉被拒绝的计划
const filteredPlans = plans.filter(p => !rejectedPlanIds.has(p.id)); 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 recentPlans = filteredPlans.slice(0, isMobile ? 5 : 10);
const [currentPlanIndex, setCurrentPlanIndex] = useState(0); const [currentPlanIndex, setCurrentPlanIndex] = useState(0);
@ -434,158 +436,14 @@ export function TextileDashboard() {
<CrashReporter /> <CrashReporter />
{/* 账户卡片弹窗 */} {/* 账户卡片弹窗 */}
{showAccountCard && ( <AccountProfileCard
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAccountCard(false)}> isOpen={showAccountCard}
<motion.div onClose={() => setShowAccountCard(false)}
initial={{ opacity: 0, scale: 0.95 }} role="textile"
animate={{ opacity: 1, scale: 1 }} onShowUserManual={() => setShowUserManual(true)}
exit={{ opacity: 0, scale: 0.95 }} onShowFeedback={() => setShowFeedback(true)}
className="bg-white rounded-2xl w-full max-w-sm shadow-2xl overflow-hidden" onShowAbout={() => setShowAbout(true)}
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>
)}
</div> </div>
); );
} }

View File

@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { supabase } from '../../supabase/client';
import type { Payment, ProductionPlan } from '../../types'; import type { Payment, ProductionPlan } from '../../types';
import { PAYMENT_STATUS } from '../../utils/constants';
export function PaymentPending() { export function PaymentPending() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -25,7 +26,7 @@ export function PaymentPending() {
.from('payments') .from('payments')
.select('*') .select('*')
.eq('to_company_id', auth.company!.id) .eq('to_company_id', auth.company!.id)
.eq('status', 'pending'); .eq('status', PAYMENT_STATUS.PENDING);
if (!paymentData || paymentData.length === 0) { if (!paymentData || paymentData.length === 0) {
setPayments([]); setPayments([]);
@ -57,7 +58,7 @@ export function PaymentPending() {
const { error } = await supabase const { error } = await supabase
.from('payments') .from('payments')
.update({ .update({
status: 'completed' as const, status: PAYMENT_STATUS.COMPLETED,
paid_at: now paid_at: now
}) })
.eq('id', paymentId); .eq('id', paymentId);

View File

@ -9,7 +9,9 @@ import { ProcessFlow } from '../../components/ProcessFlow';
import { notifyStepCompleted, notifyPlanUpdated } from '../../utils/notifications'; import { notifyStepCompleted, notifyPlanUpdated } from '../../utils/notifications';
import { getSupabaseUrl } from '../../supabase/client'; import { getSupabaseUrl } from '../../supabase/client';
import { simpleStatusMap as statusMap, PLAN_STATUS, STEP_STATUS } from '../../utils/constants'; import { simpleStatusMap as statusMap, PLAN_STATUS, STEP_STATUS } from '../../utils/constants';
import { extractParamsFromLink } from '../../utils/helpers';
import { STORAGE_KEYS } from '../../config/app'; import { STORAGE_KEYS } from '../../config/app';
import { useErrorHandler } from '../../hooks/useErrorHandler';
import type { ProductionPlan, PlanProcessStep, Company, Profile, YarnRatio } from '../../types'; import type { ProductionPlan, PlanProcessStep, Company, Profile, YarnRatio } from '../../types';
// 纱线类型显示映射 // 纱线类型显示映射
@ -37,6 +39,7 @@ interface PlanGroup {
export function TextilePlanOverview() { export function TextilePlanOverview() {
const navigate = useNavigate(); const navigate = useNavigate();
const { auth } = useAuth(); const { auth } = useAuth();
const handleError = useErrorHandler();
// 使用计划状态同步检查 Hook每5分钟自动检查并修复状态不一致 // 使用计划状态同步检查 Hook每5分钟自动检查并修复状态不一致
usePlanStatusSync(auth.company?.id); 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 = () => { const handleImportFromLink = () => {
if (!importLink.trim()) return; if (!importLink.trim()) return;
@ -283,7 +260,7 @@ export function TextilePlanOverview() {
} else if (plan && type) { } else if (plan && type) {
navigate(`/import?plan=${plan}&type=${type}`); navigate(`/import?plan=${plan}&type=${type}`);
} else { } else {
alert('无效的链接格式,请确保链接包含 token、s 或 plan 参数'); handleError(new Error('无效的链接格式'), { userMessage: '请确保链接包含 token、s 或 plan 参数' });
} }
}; };
@ -301,14 +278,13 @@ export function TextilePlanOverview() {
} else if (plan && type) { } else if (plan && type) {
navigate(`/import?plan=${plan}&type=${type}`); navigate(`/import?plan=${plan}&type=${type}`);
} else { } else {
alert('链接格式不正确,请检查链接是否完整'); handleError(new Error('链接格式不正确'), { userMessage: '请检查链接是否完整' });
} }
} else { } else {
alert('剪贴板中没有检测到有效的导入链接'); handleError(new Error('无效链接'), { userMessage: '剪贴板中没有检测到有效的导入链接' });
} }
} catch (err) { } catch (err) {
console.error('读取剪贴板失败:', err); handleError(err, { userMessage: '无法读取剪贴板,请手动粘贴链接' });
alert('无法读取剪贴板,请手动粘贴链接');
} }
}; };
@ -329,7 +305,7 @@ export function TextilePlanOverview() {
// 处理拒绝计划 // 处理拒绝计划
const handleRejectStep = async () => { const handleRejectStep = async () => {
if (!rejectStepId || !rejectReason.trim()) { if (!rejectStepId || !rejectReason.trim()) {
alert('请输入拒绝原因'); handleError(new Error('缺少拒绝原因'), { userMessage: '请输入拒绝原因' });
return; return;
} }
@ -354,7 +330,7 @@ export function TextilePlanOverview() {
const { error } = await supabase const { error } = await supabase
.from('plan_process_steps') .from('plan_process_steps')
.update({ .update({
status: 'rejected' as const, status: STEP_STATUS.REJECTED,
notes: `拒绝原因: ${rejectReason.trim()}`, notes: `拒绝原因: ${rejectReason.trim()}`,
timestamp: now, timestamp: now,
operator_id: auth.user?.id operator_id: auth.user?.id
@ -362,14 +338,13 @@ export function TextilePlanOverview() {
.eq('id', rejectStepId); .eq('id', rejectStepId);
if (error) { if (error) {
console.error('拒绝失败:', error); handleError(error, { userMessage: '拒绝失败,请重试' });
alert('拒绝失败,请重试');
setRejecting(null); setRejecting(null);
return; 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])); setRejectedPlans(prev => new Set([...prev, step.plan_id]));
@ -386,8 +361,7 @@ export function TextilePlanOverview() {
handleCloseRejectModal(); handleCloseRejectModal();
setExpandedPlan(null); setExpandedPlan(null);
} catch (err) { } catch (err) {
console.error('拒绝失败:', err); handleError(err, { userMessage: '拒绝失败,请重试' });
alert('拒绝失败,请重试');
} }
setRejecting(null); setRejecting(null);
}; };
@ -550,7 +524,7 @@ export function TextilePlanOverview() {
await supabase await supabase
.from('plan_process_steps') .from('plan_process_steps')
.update({ .update({
status: 'completed' as const, status: STEP_STATUS.COMPLETED,
timestamp: now, timestamp: now,
operator_id: auth.user?.id operator_id: auth.user?.id
}) })
@ -560,7 +534,7 @@ export function TextilePlanOverview() {
await supabase await supabase
.from('plan_process_steps') .from('plan_process_steps')
.update({ .update({
status: 'active' as const, status: STEP_STATUS.ACTIVE,
timestamp: now, timestamp: now,
operator_id: auth.user?.id operator_id: auth.user?.id
}) })
@ -572,15 +546,14 @@ export function TextilePlanOverview() {
const { error } = await supabase const { error } = await supabase
.from('plan_process_steps') .from('plan_process_steps')
.update({ .update({
status: 'completed' as const, status: STEP_STATUS.COMPLETED,
timestamp: now, timestamp: now,
operator_id: auth.user?.id operator_id: auth.user?.id
}) })
.eq('id', stepId); .eq('id', stepId);
if (error) { if (error) {
console.error('确认失败:', error); handleError(error, { userMessage: '确认失败,请重试' });
alert('确认失败,请重试');
setConfirming(null); setConfirming(null);
return; return;
} }
@ -590,15 +563,14 @@ export function TextilePlanOverview() {
if (step?.step_type === 'confirm') { if (step?.step_type === 'confirm') {
const { error: planUpdateError } = await supabase const { error: planUpdateError } = await supabase
.from('production_plans') .from('production_plans')
.update({ status: 'producing' as const }) .update({ status: PLAN_STATUS.PRODUCING })
.eq('id', planId); .eq('id', planId);
if (planUpdateError) { if (planUpdateError) {
console.error('更新计划状态失败:', planUpdateError); handleError(planUpdateError, { userMessage: '步骤已确认,但更新计划状态失败,请刷新页面' });
alert('步骤已确认,但更新计划状态失败,请刷新页面');
} else { } 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') .select('status')
.eq('plan_id', planId); .eq('plan_id', planId);
const allCompleted = allSteps?.every(s => s.status === 'completed'); const allCompleted = allSteps?.every(s => s.status === STEP_STATUS.COMPLETED);
if (allCompleted) { if (allCompleted) {
await supabase await supabase
.from('production_plans') .from('production_plans')
.update({ status: 'completed' as const }) .update({ status: PLAN_STATUS.COMPLETED })
.eq('id', planId); .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) ? Math.round((plan.completed_quantity / plan.target_quantity) * 100)
: 0; : 0;
// 从 processSteps 中检测 rejected 状态 // 从 processSteps 中检测 rejected 状态
const isRejected = plan.processSteps.some(s => s.status === 'rejected'); const isRejected = plan.processSteps.some(s => s.status === STEP_STATUS.REJECTED);
return ( return (
<motion.div <motion.div
key={plan.id} key={plan.id}
@ -944,7 +916,7 @@ export function TextilePlanOverview() {
showConfirmButton={true} showConfirmButton={true}
planTargetQuantity={plan.target_quantity} planTargetQuantity={plan.target_quantity}
planCompletedQuantity={plan.completed_quantity} planCompletedQuantity={plan.completed_quantity}
isPlanLocked={plan.status === 'completed'} isPlanLocked={plan.status === PLAN_STATUS.COMPLETED}
lastInventoryTime={plan.inventoryRecords[0]?.time} lastInventoryTime={plan.inventoryRecords[0]?.time}
inventoryRecords={plan.inventoryRecords} inventoryRecords={plan.inventoryRecords}
yarnRatios={plan.yarnRatios} yarnRatios={plan.yarnRatios}

View File

@ -6,6 +6,7 @@ import { motion } from 'framer-motion';
import { ArrowLeft, CheckCircle, Droplets, ChevronRight } from 'lucide-react'; import { ArrowLeft, CheckCircle, Droplets, ChevronRight } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import type { WashingPlan, WashingPlanCompletion } from '../../types'; import type { WashingPlan, WashingPlanCompletion } from '../../types';
import { WASHING_PLAN_STATUS } from '../../utils/constants';
interface CompletedFabricItem { interface CompletedFabricItem {
id: string; id: string;
@ -46,7 +47,7 @@ export function CompletedFabric() {
company:company_id(name) company:company_id(name)
`) `)
.eq('washing_factory_id', auth.company!.id) .eq('washing_factory_id', auth.company!.id)
.eq('status', 'completed') .eq('status', WASHING_PLAN_STATUS.COMPLETED)
.order('updated_at', { ascending: false }); .order('updated_at', { ascending: false });
if (error) { if (error) {

View File

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { supabase } from '../../supabase/client';
import { motion } from 'framer-motion'; 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 { useResponsive } from '../../hooks/useResponsive';
import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide'; import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide';
import { NotificationCenter } from '../../components/NotificationCenter'; import { NotificationCenter } from '../../components/NotificationCenter';
@ -14,6 +14,8 @@ import { UserManual } from '../../components/UserManual';
import { CrashReporter } from '../../components/CrashReporter'; import { CrashReporter } from '../../components/CrashReporter';
import type { WashingPlan } from '../../types'; import type { WashingPlan } from '../../types';
import { LoadingSpinner } from '../../components/ui/LoadingSpinner'; 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 }> = { 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 }, 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 = { const stats = {
all: plans.length, all: plans.length,
producing: plans.filter(p => p.status === 'processing').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 /> <CrashReporter />
{/* 账户卡片弹窗 */} {/* 账户卡片弹窗 */}
{showAccountCard && ( <AccountProfileCard
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAccountCard(false)}> isOpen={showAccountCard}
<motion.div onClose={() => setShowAccountCard(false)}
initial={{ opacity: 0, scale: 0.95 }} role="washing"
animate={{ opacity: 1, scale: 1 }} onShowUserManual={() => setShowUserManual(true)}
exit={{ opacity: 0, scale: 0.95 }} onShowFeedback={() => setShowFeedback(true)}
className="bg-white rounded-2xl w-full max-w-sm shadow-2xl overflow-hidden" onShowAbout={() => setShowAbout(true)}
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>
)}
</div> </div>
); );
} }

View File

@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { supabase } from '../../supabase/client';
import type { Payment, WashingPlan } from '../../types'; import type { Payment, WashingPlan } from '../../types';
import { PAYMENT_STATUS } from '../../utils/constants';
interface PaymentWithPlan extends Payment { interface PaymentWithPlan extends Payment {
plan?: WashingPlan; plan?: WashingPlan;
@ -28,7 +29,7 @@ export function WashingPaymentPending() {
.from('payments') .from('payments')
.select('*') .select('*')
.eq('to_company_id', auth.company!.id) .eq('to_company_id', auth.company!.id)
.eq('status', 'pending'); .eq('status', PAYMENT_STATUS.PENDING);
if (!paymentData || paymentData.length === 0) { if (!paymentData || paymentData.length === 0) {
setPayments([]); setPayments([]);
@ -63,7 +64,7 @@ export function WashingPaymentPending() {
const { error } = await supabase const { error } = await supabase
.from('payments') .from('payments')
.update({ .update({
status: 'completed' as const, status: PAYMENT_STATUS.COMPLETED,
paid_at: now paid_at: now
}) })
.eq('id', paymentId); .eq('id', paymentId);

View File

@ -6,6 +6,7 @@ import { motion } from 'framer-motion';
import { ArrowLeft, Package, Clock, ChevronRight } from 'lucide-react'; import { ArrowLeft, Package, Clock, ChevronRight } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import type { WashingPlan } from '../../types'; import type { WashingPlan } from '../../types';
import { WASHING_PLAN_STATUS } from '../../utils/constants';
interface PendingFabricItem { interface PendingFabricItem {
id: string; id: string;
@ -47,7 +48,7 @@ export function PendingFabric() {
company:company_id(name) company:company_id(name)
`) `)
.eq('washing_factory_id', auth.company!.id) .eq('washing_factory_id', auth.company!.id)
.in('status', ['pending', 'processing']) .in('status', [WASHING_PLAN_STATUS.PENDING, 'processing'])
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (error) { if (error) {

View File

@ -5,6 +5,9 @@ import { supabase } from '../../supabase/client';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X, Droplets, Package, Clock } from 'lucide-react'; import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X, Droplets, Package, Clock } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; 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'; import type { WashingPlan, WashingProcessStep, Company, Profile } from '../../types';
interface WashingPlanWithDetails extends WashingPlan { interface WashingPlanWithDetails extends WashingPlan {
@ -37,6 +40,7 @@ export function WashingPlanOverview() {
const navigate = useNavigate(); const navigate = useNavigate();
const { auth } = useAuth(); const { auth } = useAuth();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const handleError = useErrorHandler();
const [groups, setGroups] = useState<PlanGroup[]>([]); const [groups, setGroups] = useState<PlanGroup[]>([]);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [expandedPlan, setExpandedPlan] = useState<string | null>(null); const [expandedPlan, setExpandedPlan] = useState<string | null>(null);
@ -72,7 +76,7 @@ export function WashingPlanOverview() {
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (error) { if (error) {
console.error('获取水洗计划失败:', error); handleError(error, { userMessage: '获取水洗计划失败' });
setLoading(false); setLoading(false);
return; 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 = () => { const handleImportFromLink = () => {
if (!importLink.trim()) return; if (!importLink.trim()) return;
@ -191,7 +170,7 @@ export function WashingPlanOverview() {
} else if (plan && type) { } else if (plan && type) {
navigate(`/import?plan=${plan}&type=${type}`); navigate(`/import?plan=${plan}&type=${type}`);
} else { } 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) { } else if (plan && type) {
navigate(`/import?plan=${plan}&type=${type}`); navigate(`/import?plan=${plan}&type=${type}`);
} else { } else {
alert('链接格式不正确,请检查链接是否完整'); handleError(new Error('链接格式不正确'), { userMessage: '链接格式不正确,请检查链接是否完整', showAlert: true });
} }
} else { } else {
alert('剪贴板中没有检测到有效的导入链接'); handleError(new Error('无效链接'), { userMessage: '剪贴板中没有检测到有效的导入链接', showAlert: true });
} }
} catch (err) { } catch (err) {
console.error('读取剪贴板失败:', err); handleError(err, { userMessage: '无法读取剪贴板,请手动粘贴链接', showAlert: true });
alert('无法读取剪贴板,请手动粘贴链接');
} }
}; };
@ -230,7 +208,7 @@ export function WashingPlanOverview() {
await supabase await supabase
.from('washing_process_steps') .from('washing_process_steps')
.update({ .update({
status: 'completed', status: WASHING_PLAN_STATUS.COMPLETED,
timestamp: now, timestamp: now,
operator_id: auth.user?.id operator_id: auth.user?.id
}) })
@ -248,20 +226,19 @@ export function WashingPlanOverview() {
if (stepType === 'confirm') { if (stepType === 'confirm') {
await supabase await supabase
.from('washing_plans') .from('washing_plans')
.update({ status: 'processing' }) .update({ status: WASHING_PLAN_STATUS.IN_PROGRESS })
.eq('id', step.washing_plan_id); .eq('id', step.washing_plan_id);
} else if (stepType === 'complete') { } else if (stepType === 'complete') {
await supabase await supabase
.from('washing_plans') .from('washing_plans')
.update({ status: 'completed' }) .update({ status: WASHING_PLAN_STATUS.COMPLETED })
.eq('id', step.washing_plan_id); .eq('id', step.washing_plan_id);
} }
} }
await fetchPlans(); await fetchPlans();
} catch (err) { } catch (err) {
console.error('确认失败:', err); handleError(err, { userMessage: '确认失败,请重试', showAlert: true });
alert('确认失败,请重试');
} }
setConfirming(null); setConfirming(null);
}; };
@ -300,7 +277,7 @@ export function WashingPlanOverview() {
await supabase await supabase
.from('washing_plans') .from('washing_plans')
.update({ .update({
status: 'completed', status: WASHING_PLAN_STATUS.COMPLETED,
actual_washed_meters: parseFloat(completeForm.meters), actual_washed_meters: parseFloat(completeForm.meters),
actual_shrinkage_rate: parseFloat(completeForm.shrinkageRate) actual_shrinkage_rate: parseFloat(completeForm.shrinkageRate)
}) })
@ -311,7 +288,7 @@ export function WashingPlanOverview() {
await fetchPlans(); await fetchPlans();
alert('水洗完成记录已保存!'); alert('水洗完成记录已保存!');
} catch (err: any) { } catch (err: any) {
alert('保存失败: ' + err.message); handleError(err, { userMessage: '保存失败: ' + err.message, showAlert: true });
} }
}; };

View File

@ -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\?(.+)$/); const hashMatch = link.match(/#\/import\?(.+)$/);
if (hashMatch) { if (hashMatch) {
const queryString = hashMatch[1]; const queryString = hashMatch[1];
@ -80,7 +80,8 @@ export function extractParamsFromLink(link: string): { token: string | null; pla
return { return {
token: params.get('token'), token: params.get('token'),
plan: params.get('plan'), plan: params.get('plan'),
type: params.get('type') type: params.get('type'),
shortCode: params.get('s')
}; };
} }
try { try {
@ -88,10 +89,11 @@ export function extractParamsFromLink(link: string): { token: string | null; pla
return { return {
token: url.searchParams.get('token'), token: url.searchParams.get('token'),
plan: url.searchParams.get('plan'), plan: url.searchParams.get('plan'),
type: url.searchParams.get('type') type: url.searchParams.get('type'),
shortCode: url.searchParams.get('s')
}; };
} catch { } catch {
return { token: null, plan: null, type: null }; return { token: null, plan: null, type: null, shortCode: null };
} }
} }