feat: bootstrap auto virtual tryon admin frontend
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
.superpowers/
|
||||
.dev-stack/
|
||||
.env.local
|
||||
.next/
|
||||
node_modules/
|
||||
tsconfig.tsbuildinfo
|
||||
846
2026-03-27-frontend-admin-prd.md
Normal file
846
2026-03-27-frontend-admin-prd.md
Normal file
@@ -0,0 +1,846 @@
|
||||
# 虚拟试衣运营后台前端 PRD
|
||||
|
||||
## 1. 文档信息
|
||||
|
||||
- 文档类型:前端产品需求文档(PRD)
|
||||
- 文档目标:为一个新的前端项目提供可落地的信息架构、页面范围、接口映射、状态机与交付边界
|
||||
- 适用对象:前端开发、前端负责人、交互设计、项目协作方
|
||||
- 基准后端:当前仓库 `FastAPI + Temporal + SQLite + SQLAlchemy` 实现
|
||||
- 文档基线日期:2026-03-27
|
||||
|
||||
## 2. 文档原则
|
||||
|
||||
本 PRD 严格基于当前后端代码和已验证逻辑编写,不假设后端存在未实现的能力。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 已在当前后端中存在的 API 和状态,前端一期可以按真实联调设计
|
||||
- 当前后端不存在的资源库、列表、登录等能力,只能写成占位模块或二期依赖
|
||||
- 所有图像处理步骤当前仍为 mock,资产 URI 可能是 `mock://...`
|
||||
- 人工修订稿当前不是二进制上传,而是通过 `uploaded_uri` 登记一个离线修订结果
|
||||
|
||||
## 3. 当前后端真实能力摘要
|
||||
|
||||
### 3.1 已存在的业务流程
|
||||
|
||||
- 低端全自动流程:`auto_basic`
|
||||
- 中端半自动流程:`semi_pro`
|
||||
- 中端审核决策:`approve`、`rerun_scene`、`rerun_face`、`rerun_fusion`
|
||||
- 中端人工修订回流:
|
||||
- 注册离线修订稿
|
||||
- 查询单线版本链
|
||||
- 确认继续流水线
|
||||
|
||||
### 3.2 已存在的关键状态
|
||||
|
||||
- 客户层级:`low`、`mid`
|
||||
- 服务模式:`auto_basic`、`semi_pro`
|
||||
- 订单状态:`created`、`running`、`waiting_review`、`succeeded`、`failed`、`cancelled`
|
||||
- 审核任务状态:`pending`、`revision_uploaded`、`submitted`
|
||||
- 关键步骤:`prepare_model`、`tryon`、`scene`、`texture`、`face`、`fusion`、`qc`、`review`、`export`
|
||||
|
||||
### 3.3 已存在的 API
|
||||
|
||||
- `POST /api/v1/orders`
|
||||
- `GET /api/v1/orders/{order_id}`
|
||||
- `GET /api/v1/orders/{order_id}/assets`
|
||||
- `POST /api/v1/orders/{order_id}/revisions`
|
||||
- `GET /api/v1/orders/{order_id}/revisions`
|
||||
- `GET /api/v1/reviews/pending`
|
||||
- `POST /api/v1/reviews/{order_id}/submit`
|
||||
- `POST /api/v1/reviews/{order_id}/confirm-revision`
|
||||
- `GET /api/v1/workflows/{order_id}`
|
||||
|
||||
### 3.4 当前后端明确缺失的能力
|
||||
|
||||
- 订单列表接口
|
||||
- 订单搜索/分页/筛选接口
|
||||
- workflow 列表接口
|
||||
- 模特库接口
|
||||
- 场景库接口
|
||||
- 服装库接口
|
||||
- 登录、用户、角色、权限接口
|
||||
- 真实文件上传接口
|
||||
- 真实对象存储预览 URL
|
||||
|
||||
## 4. 产品目标
|
||||
|
||||
本前端项目的目标不是只做一个审核页,而是建立一个可扩展的运营后台前端壳子,首期重点覆盖以下两类核心用户:
|
||||
|
||||
- 提交任务的人:业务、运营、协作人员
|
||||
- 审核任务的人:审核员、质检、人工修订协作人员
|
||||
|
||||
前端一期目标:
|
||||
|
||||
- 提供一个完整的后台导航与路由结构
|
||||
- 让提单工作台能够基于现有后端真实创建订单
|
||||
- 让审核工作台完整联调当前后端审核链路
|
||||
- 让单订单详情与流程详情具备真实查看能力
|
||||
- 为资源库、登录权限等未来能力预留清晰的信息架构
|
||||
|
||||
非目标:
|
||||
|
||||
- 一期不实现登录与权限
|
||||
- 一期不实现真实资源库联调
|
||||
- 一期不实现真实文件上传
|
||||
- 一期不重构后端接口
|
||||
|
||||
## 5. 用户角色
|
||||
|
||||
### 5.1 提单员
|
||||
|
||||
职责:
|
||||
|
||||
- 创建订单
|
||||
- 选择客户层级与服务模式
|
||||
- 选择模特、场景、服装资源
|
||||
- 提交任务并查看订单结果
|
||||
|
||||
### 5.2 审核员
|
||||
|
||||
职责:
|
||||
|
||||
- 查看待审核订单
|
||||
- 决定 `approve` 或 `rerun_*`
|
||||
- 发起人工修订稿登记
|
||||
- 确认人工修订后的继续流水线
|
||||
|
||||
### 5.3 运营查看者
|
||||
|
||||
职责:
|
||||
|
||||
- 查看订单状态
|
||||
- 查看流程执行状态
|
||||
- 排查失败订单
|
||||
- 跳转到详情页和审核页
|
||||
|
||||
### 5.4 系统管理员
|
||||
|
||||
首版仅预留,不实现真实能力。
|
||||
|
||||
未来职责:
|
||||
|
||||
- 用户管理
|
||||
- 角色管理
|
||||
- 权限管理
|
||||
- 系统配置
|
||||
|
||||
## 6. 一期信息架构
|
||||
|
||||
### 6.1 一级导航
|
||||
|
||||
- `订单总览`
|
||||
- `提单工作台`
|
||||
- `审核工作台`
|
||||
- `流程追踪`
|
||||
- `资源库`
|
||||
- `系统设置`
|
||||
|
||||
### 6.2 页面树
|
||||
|
||||
- `/orders`
|
||||
- `/orders/:orderId`
|
||||
- `/submit-workbench`
|
||||
- `/reviews/workbench`
|
||||
- `/workflows`
|
||||
- `/workflows/:orderId`
|
||||
- `/libraries/models`
|
||||
- `/libraries/scenes`
|
||||
- `/libraries/garments`
|
||||
- `/settings`
|
||||
- `/login`
|
||||
|
||||
### 6.3 首页策略
|
||||
|
||||
- 默认首页为 `/orders`
|
||||
- 该页面作为后台默认入口
|
||||
- 审核工作台为高频业务页,但不是默认首页
|
||||
- 从订单总览可以跳转到订单详情和审核工作台
|
||||
|
||||
## 7. 模块说明
|
||||
|
||||
## 7.1 订单总览
|
||||
|
||||
### 页面目标
|
||||
|
||||
- 提供订单浏览和搜索入口
|
||||
- 承接系统默认首页
|
||||
- 支持跳转到订单详情
|
||||
- 支持跳转到审核工作台
|
||||
|
||||
### 页面组件
|
||||
|
||||
- 顶部筛选区
|
||||
- 订单搜索框
|
||||
- 订单列表表格
|
||||
- 状态标签
|
||||
- 快速动作列
|
||||
- 空状态与异常状态
|
||||
|
||||
### 用户路径
|
||||
|
||||
1. 进入首页
|
||||
2. 搜索订单或浏览列表
|
||||
3. 查看订单状态
|
||||
4. 跳转订单详情或审核工作台
|
||||
|
||||
### 当前后端支撑情况
|
||||
|
||||
`不足`
|
||||
|
||||
原因:
|
||||
|
||||
- 当前没有订单列表接口
|
||||
- 当前没有分页、筛选、搜索接口
|
||||
|
||||
### 一期实现策略
|
||||
|
||||
- 页面保留为正式模块
|
||||
- 首版可采用以下过渡方式之一:
|
||||
- 订单号直达查询
|
||||
- 最近访问订单缓存
|
||||
- 前端 mock 数据演示
|
||||
|
||||
### 后端依赖
|
||||
|
||||
- 新增订单列表接口
|
||||
- 新增订单筛选/搜索接口
|
||||
|
||||
## 7.2 提单工作台
|
||||
|
||||
### 页面目标
|
||||
|
||||
- 为业务或运营提供单独的任务提交工作台
|
||||
- 支持创建新订单
|
||||
- 为未来资源库接入预留完整交互结构
|
||||
|
||||
### 页面组件
|
||||
|
||||
- 客户层级选择
|
||||
- 服务模式选择
|
||||
- 模特选择区
|
||||
- 场景选择区
|
||||
- 服装选择区
|
||||
- pose 输入区
|
||||
- 参数预览卡
|
||||
- 提交按钮
|
||||
- 提交结果反馈
|
||||
|
||||
### 用户路径
|
||||
|
||||
1. 选择客户层级
|
||||
2. 选择服务模式
|
||||
3. 选择模特/场景/服装
|
||||
4. 检查参数摘要
|
||||
5. 提交订单
|
||||
6. 跳转到订单详情页
|
||||
|
||||
### 当前后端支撑情况
|
||||
|
||||
`部分支撑`
|
||||
|
||||
已存在能力:
|
||||
|
||||
- `POST /api/v1/orders`
|
||||
|
||||
缺口:
|
||||
|
||||
- 模特、场景、服装没有查询接口
|
||||
|
||||
### 一期实现策略
|
||||
|
||||
- 表单提交真实联调
|
||||
- 模特/场景/服装选择器采用占位方式实现:
|
||||
- mock 列表
|
||||
- 本地 JSON
|
||||
- 手动输入资源 ID
|
||||
|
||||
### 业务校验
|
||||
|
||||
前端必须内置当前后端的模式约束:
|
||||
|
||||
- `low` 只能配 `auto_basic`
|
||||
- `mid` 只能配 `semi_pro`
|
||||
|
||||
### 提交成功行为
|
||||
|
||||
- 展示后端返回的 `order_id`
|
||||
- 展示后端返回的 `workflow_id`
|
||||
- 自动跳转 `/orders/:orderId`
|
||||
|
||||
## 7.3 审核工作台
|
||||
|
||||
### 页面目标
|
||||
|
||||
- 集中处理所有待审核订单
|
||||
- 让审核员高效执行审核和人工修订回流
|
||||
|
||||
### 页面组件
|
||||
|
||||
- 待审核队列
|
||||
- 订单切换区
|
||||
- 当前资产主视图区
|
||||
- 版本链区
|
||||
- 审核动作区
|
||||
- 人工修订区
|
||||
- 流程摘要区
|
||||
- 提交结果反馈
|
||||
|
||||
### 核心动作
|
||||
|
||||
- `approve`
|
||||
- `rerun_scene`
|
||||
- `rerun_face`
|
||||
- `rerun_fusion`
|
||||
- `注册人工修订稿`
|
||||
- `确认继续流水线`
|
||||
|
||||
### 用户路径
|
||||
|
||||
1. 进入审核工作台
|
||||
2. 从待审核队列选择订单
|
||||
3. 查看订单详情、资产与版本链
|
||||
4. 执行审核决定或人工修订回流动作
|
||||
5. 页面刷新队列状态
|
||||
|
||||
### 当前后端支撑情况
|
||||
|
||||
`完整支撑`
|
||||
|
||||
### 已有接口
|
||||
|
||||
- `GET /api/v1/reviews/pending`
|
||||
- `GET /api/v1/orders/{order_id}`
|
||||
- `GET /api/v1/orders/{order_id}/assets`
|
||||
- `GET /api/v1/orders/{order_id}/revisions`
|
||||
- `GET /api/v1/workflows/{order_id}`
|
||||
- `POST /api/v1/reviews/{order_id}/submit`
|
||||
- `POST /api/v1/orders/{order_id}/revisions`
|
||||
- `POST /api/v1/reviews/{order_id}/confirm-revision`
|
||||
|
||||
### 关键规则
|
||||
|
||||
- `pending` 状态显示标准审核动作
|
||||
- `revision_uploaded` 状态必须突出“确认继续流水线”
|
||||
- signal 失败时订单仍应停留在队列中,前端不得乐观删除
|
||||
- 修订链在确认继续后仍可继续查看
|
||||
|
||||
## 7.4 订单详情
|
||||
|
||||
### 页面目标
|
||||
|
||||
- 提供订单全量上下文的稳定承载页
|
||||
- 成为订单总览、审核页、流程页的共享详情入口
|
||||
|
||||
### 页面组件
|
||||
|
||||
- 订单基础信息卡
|
||||
- 当前状态卡
|
||||
- 最终结果区
|
||||
- 资产列表
|
||||
- 修订版本链
|
||||
- workflow 摘要
|
||||
- 跳转动作区
|
||||
|
||||
### 用户路径
|
||||
|
||||
1. 从任意入口进入订单详情
|
||||
2. 查看订单当前状态
|
||||
3. 查看资产与版本链
|
||||
4. 查看流程摘要
|
||||
5. 需要时跳转审核或流程详情
|
||||
|
||||
### 当前后端支撑情况
|
||||
|
||||
`较完整`
|
||||
|
||||
### 已有接口
|
||||
|
||||
- `GET /api/v1/orders/{order_id}`
|
||||
- `GET /api/v1/orders/{order_id}/assets`
|
||||
- `GET /api/v1/orders/{order_id}/revisions`
|
||||
- `GET /api/v1/workflows/{order_id}`
|
||||
|
||||
### 页面规则
|
||||
|
||||
- 修订链接口 404 表示没有修订链,不应渲染为系统异常
|
||||
- 资产为空应显示业务空态
|
||||
- 最终资产可能为空,直到流程真正完成
|
||||
|
||||
## 7.5 流程追踪
|
||||
|
||||
### 页面目标
|
||||
|
||||
- 让运营或技术查看单订单 workflow 执行细节
|
||||
- 用于排查当前步骤、失败步骤和流程回流情况
|
||||
|
||||
### 页面组件
|
||||
|
||||
- 订单号查询输入
|
||||
- 流程状态卡
|
||||
- 步骤时间线
|
||||
- 错误信息区
|
||||
- 步骤详情展开区
|
||||
|
||||
### 页面拆分
|
||||
|
||||
- `/workflows`:首版做轻量查询入口
|
||||
- `/workflows/:orderId`:首版真实联调页面
|
||||
|
||||
### 当前后端支撑情况
|
||||
|
||||
`部分支撑`
|
||||
|
||||
已存在:
|
||||
|
||||
- `GET /api/v1/workflows/{order_id}`
|
||||
|
||||
缺口:
|
||||
|
||||
- 缺少 workflow 列表接口
|
||||
|
||||
### 一期实现策略
|
||||
|
||||
- 流程详情页真实联调
|
||||
- 流程首页做订单号查询入口,而非完整列表页
|
||||
|
||||
## 7.6 资源库中心
|
||||
|
||||
### 子模块
|
||||
|
||||
- 模特库
|
||||
- 场景库
|
||||
- 服装库
|
||||
|
||||
### 页面目标
|
||||
|
||||
- 提供未来资源域浏览入口
|
||||
- 为提单工作台资源选择器提供同一套信息结构
|
||||
|
||||
### 页面组件
|
||||
|
||||
- 列表或卡片视图
|
||||
- 资源搜索
|
||||
- 标签筛选
|
||||
- 资源详情抽屉
|
||||
- “选中并回填提单工作台”交互预留
|
||||
|
||||
### 当前后端支撑情况
|
||||
|
||||
`无`
|
||||
|
||||
### 一期实现策略
|
||||
|
||||
- 只做页面结构、路由与 mock 数据演示
|
||||
- 页面必须明确提示“当前为占位模块,待后端资源库接口补齐后联调”
|
||||
|
||||
### 未来后端需求
|
||||
|
||||
- 模特库列表接口
|
||||
- 模特详情接口
|
||||
- 场景库列表接口
|
||||
- 场景详情接口
|
||||
- 服装库列表接口
|
||||
- 服装详情接口
|
||||
|
||||
## 7.7 系统设置与登录预留
|
||||
|
||||
### 页面目标
|
||||
|
||||
- 为未来登录权限与后台治理能力预留路由和导航位置
|
||||
|
||||
### 当前后端支撑情况
|
||||
|
||||
`无`
|
||||
|
||||
### 一期实现策略
|
||||
|
||||
- 只保留页面壳子或占位页
|
||||
- 不纳入一期真实验收
|
||||
|
||||
## 8. 接口映射
|
||||
|
||||
## 8.1 创建订单
|
||||
|
||||
### 请求
|
||||
|
||||
`POST /api/v1/orders`
|
||||
|
||||
请求字段:
|
||||
|
||||
- `customer_level`
|
||||
- `service_mode`
|
||||
- `model_id`
|
||||
- `pose_id`
|
||||
- `garment_asset_id`
|
||||
- `scene_ref_asset_id`
|
||||
|
||||
### 响应
|
||||
|
||||
- `order_id`
|
||||
- `workflow_id`
|
||||
- `status`
|
||||
|
||||
### 前端用途
|
||||
|
||||
- 提单工作台提交订单
|
||||
|
||||
## 8.2 订单详情
|
||||
|
||||
### 请求
|
||||
|
||||
`GET /api/v1/orders/{order_id}`
|
||||
|
||||
### 关键响应字段
|
||||
|
||||
- `order_id`
|
||||
- `customer_level`
|
||||
- `service_mode`
|
||||
- `status`
|
||||
- `model_id`
|
||||
- `pose_id`
|
||||
- `garment_asset_id`
|
||||
- `scene_ref_asset_id`
|
||||
- `final_asset_id`
|
||||
- `current_revision_asset_id`
|
||||
- `current_revision_version`
|
||||
- `revision_count`
|
||||
- `review_task_status`
|
||||
- `workflow_id`
|
||||
- `current_step`
|
||||
- `final_asset`
|
||||
|
||||
### 前端用途
|
||||
|
||||
- 订单详情页
|
||||
- 审核工作台右侧上下文
|
||||
|
||||
## 8.3 订单资产
|
||||
|
||||
### 请求
|
||||
|
||||
`GET /api/v1/orders/{order_id}/assets`
|
||||
|
||||
### 关键响应字段
|
||||
|
||||
- `id`
|
||||
- `order_id`
|
||||
- `parent_asset_id`
|
||||
- `root_asset_id`
|
||||
- `version_no`
|
||||
- `is_current_version`
|
||||
- `asset_type`
|
||||
- `step_name`
|
||||
- `uri`
|
||||
- `metadata_json`
|
||||
|
||||
### 前端用途
|
||||
|
||||
- 订单详情资产区
|
||||
- 审核工作台主图与资产列表
|
||||
|
||||
## 8.4 待审核列表
|
||||
|
||||
### 请求
|
||||
|
||||
`GET /api/v1/reviews/pending`
|
||||
|
||||
### 关键响应字段
|
||||
|
||||
- `review_task_id`
|
||||
- `order_id`
|
||||
- `workflow_id`
|
||||
- `current_step`
|
||||
- `review_status`
|
||||
- `latest_revision_asset_id`
|
||||
- `revision_count`
|
||||
- `created_at`
|
||||
|
||||
### 前端用途
|
||||
|
||||
- 审核工作台左侧队列
|
||||
|
||||
## 8.5 审核提交
|
||||
|
||||
### 请求
|
||||
|
||||
`POST /api/v1/reviews/{order_id}/submit`
|
||||
|
||||
### 请求字段
|
||||
|
||||
- `decision`
|
||||
- `reviewer_id`
|
||||
- `selected_asset_id`
|
||||
- `comment`
|
||||
|
||||
### 支持的 decision
|
||||
|
||||
- `approve`
|
||||
- `rerun_scene`
|
||||
- `rerun_face`
|
||||
- `rerun_fusion`
|
||||
|
||||
### 前端用途
|
||||
|
||||
- 审核工作台动作区
|
||||
|
||||
## 8.6 注册人工修订稿
|
||||
|
||||
### 请求
|
||||
|
||||
`POST /api/v1/orders/{order_id}/revisions`
|
||||
|
||||
### 请求字段
|
||||
|
||||
- `parent_asset_id`
|
||||
- `uploaded_uri`
|
||||
- `reviewer_id`
|
||||
- `comment`
|
||||
|
||||
### 响应字段
|
||||
|
||||
- `order_id`
|
||||
- `asset_id`
|
||||
- `parent_asset_id`
|
||||
- `root_asset_id`
|
||||
- `version_no`
|
||||
- `review_task_status`
|
||||
|
||||
### 前端用途
|
||||
|
||||
- 审核工作台人工修订上传区
|
||||
|
||||
## 8.7 查询修订链
|
||||
|
||||
### 请求
|
||||
|
||||
`GET /api/v1/orders/{order_id}/revisions`
|
||||
|
||||
### 响应结构
|
||||
|
||||
- `order_id`
|
||||
- `items[]`
|
||||
|
||||
每个 `item` 包含:
|
||||
|
||||
- `asset_id`
|
||||
- `parent_asset_id`
|
||||
- `root_asset_id`
|
||||
- `version_no`
|
||||
- `is_current_version`
|
||||
- `asset_type`
|
||||
- `uri`
|
||||
- `created_at`
|
||||
|
||||
### 前端用途
|
||||
|
||||
- 审核工作台版本链
|
||||
- 订单详情版本链
|
||||
|
||||
## 8.8 确认继续流水线
|
||||
|
||||
### 请求
|
||||
|
||||
`POST /api/v1/reviews/{order_id}/confirm-revision`
|
||||
|
||||
### 请求字段
|
||||
|
||||
- `reviewer_id`
|
||||
- `comment`
|
||||
|
||||
### 响应字段
|
||||
|
||||
- `order_id`
|
||||
- `workflow_id`
|
||||
- `revision_asset_id`
|
||||
- `decision`
|
||||
- `status`
|
||||
|
||||
### 前端用途
|
||||
|
||||
- 审核工作台“确认继续流水线”动作
|
||||
|
||||
## 8.9 workflow 状态
|
||||
|
||||
### 请求
|
||||
|
||||
`GET /api/v1/workflows/{order_id}`
|
||||
|
||||
### 关键响应字段
|
||||
|
||||
- `order_id`
|
||||
- `workflow_id`
|
||||
- `workflow_type`
|
||||
- `workflow_status`
|
||||
- `current_step`
|
||||
- `latest_revision_asset_id`
|
||||
- `latest_revision_version`
|
||||
- `pending_manual_confirm`
|
||||
- `steps[]`
|
||||
|
||||
### 前端用途
|
||||
|
||||
- 审核工作台流程摘要
|
||||
- 订单详情流程摘要
|
||||
- 流程追踪详情页
|
||||
|
||||
## 9. 页面状态机
|
||||
|
||||
## 9.1 提单工作台
|
||||
|
||||
- `未填写`
|
||||
- `填写中`
|
||||
- `前端校验失败`
|
||||
- `提交中`
|
||||
- `提交成功`
|
||||
- `提交失败`
|
||||
|
||||
页面规则:
|
||||
|
||||
- 提交成功后必须跳转订单详情
|
||||
- 提交失败必须保留当前表单内容
|
||||
|
||||
## 9.2 审核工作台
|
||||
|
||||
- 队列状态:
|
||||
- `pending`
|
||||
- `revision_uploaded`
|
||||
- 订单状态:
|
||||
- `waiting_review`
|
||||
- `running`
|
||||
- `succeeded`
|
||||
- `failed`
|
||||
|
||||
页面规则:
|
||||
|
||||
- `pending` 显示标准审核动作
|
||||
- `revision_uploaded` 必须突出“确认继续流水线”
|
||||
- signal 失败时不得从前端移除队列项
|
||||
|
||||
## 9.3 订单详情
|
||||
|
||||
- `加载中`
|
||||
- `订单存在`
|
||||
- `资产为空`
|
||||
- `存在修订链`
|
||||
- `流程已完成`
|
||||
- `流程失败`
|
||||
|
||||
页面规则:
|
||||
|
||||
- 无修订链不是异常
|
||||
- 无最终图不是异常
|
||||
|
||||
## 9.4 流程追踪
|
||||
|
||||
- `待查询`
|
||||
- `查询中`
|
||||
- `查询成功`
|
||||
- `workflow 不存在`
|
||||
- `workflow 失败`
|
||||
|
||||
## 9.5 资源库占位模块
|
||||
|
||||
- `mock 列表态`
|
||||
- `空数据态`
|
||||
- `未接后端提示态`
|
||||
|
||||
## 10. 一期与二期边界
|
||||
|
||||
## 10.1 一期真实可交付
|
||||
|
||||
- 后台导航壳子
|
||||
- 提单工作台
|
||||
- 审核工作台
|
||||
- 订单详情页
|
||||
- 流程详情页 `/workflows/:orderId`
|
||||
- 登录与系统设置路由预留
|
||||
|
||||
## 10.2 一期占位交付
|
||||
|
||||
- 订单总览列表
|
||||
- 流程追踪首页列表
|
||||
- 模特库
|
||||
- 场景库
|
||||
- 服装库
|
||||
- 登录页真实鉴权
|
||||
- 权限控制
|
||||
|
||||
## 10.3 二期依赖后端补齐后再推进
|
||||
|
||||
- 订单列表/搜索/分页
|
||||
- workflow 列表
|
||||
- 模特库真实联调
|
||||
- 场景库真实联调
|
||||
- 服装库真实联调
|
||||
- 真实文件上传
|
||||
- 对象存储预览
|
||||
- 登录与权限系统
|
||||
|
||||
## 11. 前端技术建议
|
||||
|
||||
本节不是强制技术选型,而是为了让新项目起步更稳。
|
||||
|
||||
建议:
|
||||
|
||||
- 使用独立前端项目,不内嵌到当前后端仓库
|
||||
- 使用模块化路由结构
|
||||
- 为“真实联调模块”和“占位模块”明确分目录
|
||||
- 将 API 层、状态机、页面组件分层
|
||||
- 对 `review_status`、`workflow_status`、`current_step` 建立统一枚举映射
|
||||
- 对 mock 资源库数据和真实 API 数据使用统一视图模型,避免未来切换时重写页面
|
||||
|
||||
## 12. 验收标准
|
||||
|
||||
## 12.1 一期必须满足
|
||||
|
||||
- 能从提单工作台提交订单并跳转详情
|
||||
- 能在审核工作台查看待审核队列
|
||||
- 能执行 `approve`
|
||||
- 能执行 `rerun_scene / rerun_face / rerun_fusion`
|
||||
- 能登记人工修订稿
|
||||
- 能确认继续流水线
|
||||
- 能在订单详情页查看资产、修订链、workflow 摘要
|
||||
- 能在流程详情页查看步骤历史
|
||||
|
||||
## 12.2 一期允许不满足
|
||||
|
||||
- 首页订单列表真实联调
|
||||
- 资源库真实联调
|
||||
- 登录和权限
|
||||
- 真实图片上传与预览
|
||||
|
||||
## 13. 风险与依赖
|
||||
|
||||
### 13.1 主要风险
|
||||
|
||||
- 首页默认是订单总览,但后端暂时没有订单列表接口
|
||||
- 提单工作台依赖资源 ID,但资源库接口尚不存在
|
||||
- 资产 URI 仍可能是 mock 地址,前端无法保证真实图片预览
|
||||
- 登录权限未实现,首版只能按受信环境假设推进
|
||||
|
||||
### 13.2 协作依赖
|
||||
|
||||
前端一期推进过程中,需要后端后续补充以下能力,才能让后台从“可用”走向“完整”:
|
||||
|
||||
- 订单列表接口
|
||||
- workflow 列表接口
|
||||
- 资源库接口
|
||||
- 上传接口
|
||||
- 权限接口
|
||||
|
||||
## 14. 结论
|
||||
|
||||
这个前端项目现在就可以启动,但必须明确区分两类模块:
|
||||
|
||||
- 可按当前后端真实联调的模块
|
||||
- 只能先做壳子和占位的模块
|
||||
|
||||
当前最值得优先落地的是:
|
||||
|
||||
1. 提单工作台
|
||||
2. 审核工作台
|
||||
3. 订单详情
|
||||
4. 流程详情页
|
||||
|
||||
订单总览、流程首页、资源库中心与权限系统,则应在前端一期中保留结构,但以“占位模块”或“后端依赖模块”的方式写入项目范围。
|
||||
110
README.md
Normal file
110
README.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Auto Virtual Tryon Frontend
|
||||
|
||||
Standalone Next.js admin frontend for the virtual try-on workflow.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20+
|
||||
- npm 10+
|
||||
- Optional local backend at `/Volumes/DockCase/codes/auto-virtual-tryon`
|
||||
|
||||
## Local Setup
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Create `.env.local`:
|
||||
|
||||
```bash
|
||||
BACKEND_BASE_URL=http://127.0.0.1:8000/api/v1
|
||||
```
|
||||
|
||||
3. Start the frontend:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
## One-Click Local Stack
|
||||
|
||||
If the sibling backend exists at `/Volumes/DockCase/codes/auto-virtual-tryon`, you can start the full local integration stack from this repo:
|
||||
|
||||
```bash
|
||||
npm run stack:start
|
||||
```
|
||||
|
||||
This command starts:
|
||||
|
||||
- Temporal dev server on `127.0.0.1:7233`
|
||||
- FastAPI on `127.0.0.1:8000`
|
||||
- backend worker process
|
||||
- Next.js dev server on `127.0.0.1:3000`
|
||||
|
||||
The stack writes PID and log files under `.dev-stack/`.
|
||||
|
||||
Useful helpers:
|
||||
|
||||
- `npm run stack:status`
|
||||
- `npm run stack:logs`
|
||||
- `npm run stack:stop`
|
||||
|
||||
Optional log filters:
|
||||
|
||||
- `SERVICE=frontend npm run stack:logs`
|
||||
- `LINES=120 npm run stack:logs`
|
||||
|
||||
If `temporal` is not installed globally, the stack script also checks the SDK-downloaded CLI binary under your temp directory. If neither exists, install Temporal CLI first or run the backend tests once to warm that cache.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
- `npm run test`
|
||||
- `npm run lint`
|
||||
- `npm run typecheck`
|
||||
- `npm run typecheck:clean`
|
||||
- `npm run build`
|
||||
- `npm run verify`
|
||||
|
||||
`npm run verify` now clears `.next/dev` before type-checking, so stale Next dev validator output does not poison local verification.
|
||||
|
||||
## Route Scope
|
||||
|
||||
Real integration pages:
|
||||
|
||||
- `/submit-workbench`
|
||||
- `/reviews/workbench`
|
||||
- `/orders/[orderId]`
|
||||
- `/workflows/[orderId]`
|
||||
|
||||
Placeholder or transitional pages:
|
||||
|
||||
- `/orders`
|
||||
- `/workflows`
|
||||
- `/libraries/models`
|
||||
- `/libraries/scenes`
|
||||
- `/libraries/garments`
|
||||
- `/settings`
|
||||
- `/login`
|
||||
|
||||
## Backend Notes
|
||||
|
||||
The frontend assumes the current FastAPI backend exposes:
|
||||
|
||||
- `POST /api/v1/orders`
|
||||
- `GET /api/v1/orders/{orderId}`
|
||||
- `GET /api/v1/orders/{orderId}/assets`
|
||||
- `GET /api/v1/orders/{orderId}/revisions`
|
||||
- `GET /api/v1/reviews/pending`
|
||||
- `POST /api/v1/reviews/{orderId}/submit`
|
||||
- `POST /api/v1/reviews/{orderId}/confirm-revision`
|
||||
- `GET /api/v1/workflows/{orderId}`
|
||||
|
||||
## Manual Integration Notes
|
||||
|
||||
- Without the one-click stack, FastAPI can still be started manually with `uvicorn app.main:app --reload` in `/Volumes/DockCase/codes/auto-virtual-tryon`.
|
||||
- `GET /healthz` and `GET /api/v1/reviews/pending` work once the backend dependencies are installed and the local DB has been migrated with `alembic upgrade head`.
|
||||
- Order creation still depends on a Temporal server at `127.0.0.1:7233` plus the backend worker process. If those are not running, `POST /api/v1/orders` returns a Temporal connection error and the frontend surfaces that backend failure honestly.
|
||||
11
app/(dashboard)/layout.tsx
Normal file
11
app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { DashboardShell } from "@/components/layout/dashboard-shell";
|
||||
|
||||
type DashboardLayoutProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
return <DashboardShell>{children}</DashboardShell>;
|
||||
}
|
||||
5
app/(dashboard)/libraries/garments/page.tsx
Normal file
5
app/(dashboard)/libraries/garments/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LibraryPageScreen } from "@/features/libraries/library-page";
|
||||
|
||||
export default function GarmentsLibraryPage() {
|
||||
return <LibraryPageScreen libraryType="garments" />;
|
||||
}
|
||||
5
app/(dashboard)/libraries/models/page.tsx
Normal file
5
app/(dashboard)/libraries/models/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LibraryPageScreen } from "@/features/libraries/library-page";
|
||||
|
||||
export default function ModelsLibraryPage() {
|
||||
return <LibraryPageScreen libraryType="models" />;
|
||||
}
|
||||
5
app/(dashboard)/libraries/scenes/page.tsx
Normal file
5
app/(dashboard)/libraries/scenes/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LibraryPageScreen } from "@/features/libraries/library-page";
|
||||
|
||||
export default function ScenesLibraryPage() {
|
||||
return <LibraryPageScreen libraryType="scenes" />;
|
||||
}
|
||||
15
app/(dashboard)/orders/[orderId]/page.tsx
Normal file
15
app/(dashboard)/orders/[orderId]/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { OrderDetailScreen } from "@/features/orders/order-detail";
|
||||
|
||||
type OrderDetailPageProps = {
|
||||
params: Promise<{
|
||||
orderId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function OrderDetailPage({
|
||||
params,
|
||||
}: OrderDetailPageProps) {
|
||||
const { orderId } = await params;
|
||||
|
||||
return <OrderDetailScreen orderId={Number(orderId)} />;
|
||||
}
|
||||
5
app/(dashboard)/orders/page.tsx
Normal file
5
app/(dashboard)/orders/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { OrdersHomeScreen } from "@/features/orders/orders-home";
|
||||
|
||||
export default function OrdersPage() {
|
||||
return <OrdersHomeScreen />;
|
||||
}
|
||||
15
app/(dashboard)/reviews/workbench/[orderId]/page.tsx
Normal file
15
app/(dashboard)/reviews/workbench/[orderId]/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReviewWorkbenchDetailScreen } from "@/features/reviews/review-workbench-detail";
|
||||
|
||||
type ReviewWorkbenchDetailPageProps = {
|
||||
params: Promise<{
|
||||
orderId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function ReviewWorkbenchDetailPage({
|
||||
params,
|
||||
}: ReviewWorkbenchDetailPageProps) {
|
||||
const { orderId } = await params;
|
||||
|
||||
return <ReviewWorkbenchDetailScreen orderId={Number(orderId)} />;
|
||||
}
|
||||
5
app/(dashboard)/reviews/workbench/page.tsx
Normal file
5
app/(dashboard)/reviews/workbench/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ReviewWorkbenchListScreen } from "@/features/reviews/review-workbench-list";
|
||||
|
||||
export default function ReviewWorkbenchPage() {
|
||||
return <ReviewWorkbenchListScreen />;
|
||||
}
|
||||
5
app/(dashboard)/settings/page.tsx
Normal file
5
app/(dashboard)/settings/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SettingsPlaceholder } from "@/features/settings/settings-placeholder";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return <SettingsPlaceholder />;
|
||||
}
|
||||
5
app/(dashboard)/submit-workbench/page.tsx
Normal file
5
app/(dashboard)/submit-workbench/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubmitWorkbench } from "@/features/orders/submit-workbench";
|
||||
|
||||
export default function SubmitWorkbenchPage() {
|
||||
return <SubmitWorkbench />;
|
||||
}
|
||||
15
app/(dashboard)/workflows/[orderId]/page.tsx
Normal file
15
app/(dashboard)/workflows/[orderId]/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { WorkflowDetailScreen } from "@/features/workflows/workflow-detail";
|
||||
|
||||
type WorkflowDetailPageProps = {
|
||||
params: Promise<{
|
||||
orderId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function WorkflowDetailPage({
|
||||
params,
|
||||
}: WorkflowDetailPageProps) {
|
||||
const { orderId } = await params;
|
||||
|
||||
return <WorkflowDetailScreen orderId={Number(orderId)} />;
|
||||
}
|
||||
5
app/(dashboard)/workflows/page.tsx
Normal file
5
app/(dashboard)/workflows/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { WorkflowLookupScreen } from "@/features/workflows/workflow-lookup";
|
||||
|
||||
export default function WorkflowsPage() {
|
||||
return <WorkflowLookupScreen />;
|
||||
}
|
||||
63
app/api/dashboard/orders-overview/route.ts
Normal file
63
app/api/dashboard/orders-overview/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { adaptOrderSummary } from "@/lib/adapters/orders";
|
||||
import { backendRequest } from "@/lib/http/backend-client";
|
||||
import {
|
||||
jsonSuccess,
|
||||
parsePositiveIntegerParam,
|
||||
withErrorHandling,
|
||||
} from "@/lib/http/response";
|
||||
import type { OrderListResponseDto, OrderStatus } from "@/lib/types/backend";
|
||||
|
||||
const MESSAGE = "订单总览当前显示真实后端最近订单。";
|
||||
const DEFAULT_LIMIT = 6;
|
||||
const ALLOWED_STATUS = new Set<OrderStatus>([
|
||||
"created",
|
||||
"running",
|
||||
"waiting_review",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withErrorHandling(async () => {
|
||||
const url = new URL(request.url);
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const limitParam = url.searchParams.get("limit");
|
||||
const queryParam = url.searchParams.get("query")?.trim();
|
||||
const statusParam = url.searchParams.get("status")?.trim();
|
||||
const params = new URLSearchParams({
|
||||
page: String(
|
||||
pageParam ? parsePositiveIntegerParam(pageParam, "page") : 1,
|
||||
),
|
||||
limit: String(
|
||||
limitParam ? parsePositiveIntegerParam(limitParam, "limit") : DEFAULT_LIMIT,
|
||||
),
|
||||
});
|
||||
|
||||
if (statusParam && ALLOWED_STATUS.has(statusParam as OrderStatus)) {
|
||||
params.set("status", statusParam);
|
||||
}
|
||||
|
||||
if (queryParam) {
|
||||
params.set("query", queryParam);
|
||||
}
|
||||
|
||||
const response = await backendRequest<OrderListResponseDto>(
|
||||
`/orders?${params.toString()}`,
|
||||
);
|
||||
|
||||
return jsonSuccess(
|
||||
{
|
||||
page: response.data.page,
|
||||
limit: response.data.limit,
|
||||
total: response.data.total,
|
||||
totalPages: response.data.total_pages,
|
||||
items: response.data.items.map(adaptOrderSummary),
|
||||
},
|
||||
{
|
||||
mode: "proxy",
|
||||
message: MESSAGE,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
63
app/api/dashboard/workflow-lookup/route.ts
Normal file
63
app/api/dashboard/workflow-lookup/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { adaptWorkflowLookupItem } from "@/lib/adapters/workflows";
|
||||
import { backendRequest } from "@/lib/http/backend-client";
|
||||
import {
|
||||
jsonSuccess,
|
||||
parsePositiveIntegerParam,
|
||||
withErrorHandling,
|
||||
} from "@/lib/http/response";
|
||||
import type { OrderStatus, WorkflowListResponseDto } from "@/lib/types/backend";
|
||||
|
||||
const MESSAGE = "流程追踪首页当前显示真实后端最近流程。";
|
||||
const DEFAULT_LIMIT = 8;
|
||||
const ALLOWED_STATUS = new Set<OrderStatus>([
|
||||
"created",
|
||||
"running",
|
||||
"waiting_review",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withErrorHandling(async () => {
|
||||
const url = new URL(request.url);
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const limitParam = url.searchParams.get("limit");
|
||||
const queryParam = url.searchParams.get("query")?.trim();
|
||||
const statusParam = url.searchParams.get("status")?.trim();
|
||||
const params = new URLSearchParams({
|
||||
page: String(
|
||||
pageParam ? parsePositiveIntegerParam(pageParam, "page") : 1,
|
||||
),
|
||||
limit: String(
|
||||
limitParam ? parsePositiveIntegerParam(limitParam, "limit") : DEFAULT_LIMIT,
|
||||
),
|
||||
});
|
||||
|
||||
if (statusParam && ALLOWED_STATUS.has(statusParam as OrderStatus)) {
|
||||
params.set("status", statusParam);
|
||||
}
|
||||
|
||||
if (queryParam) {
|
||||
params.set("query", queryParam);
|
||||
}
|
||||
|
||||
const response = await backendRequest<WorkflowListResponseDto>(
|
||||
`/workflows?${params.toString()}`,
|
||||
);
|
||||
|
||||
return jsonSuccess(
|
||||
{
|
||||
page: response.data.page,
|
||||
limit: response.data.limit,
|
||||
total: response.data.total,
|
||||
totalPages: response.data.total_pages,
|
||||
items: response.data.items.map(adaptWorkflowLookupItem),
|
||||
},
|
||||
{
|
||||
mode: "proxy",
|
||||
message: MESSAGE,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
45
app/api/libraries/[libraryType]/route.ts
Normal file
45
app/api/libraries/[libraryType]/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
GARMENT_LIBRARY_FIXTURES,
|
||||
MODEL_LIBRARY_FIXTURES,
|
||||
SCENE_LIBRARY_FIXTURES,
|
||||
} from "@/lib/mock/libraries";
|
||||
import { RouteError, jsonSuccess, withErrorHandling } from "@/lib/http/response";
|
||||
import type { LibraryItemVM, LibraryType } from "@/lib/types/view-models";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
libraryType: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const LIBRARY_FIXTURE_MAP: Record<LibraryType, LibraryItemVM[]> = {
|
||||
models: MODEL_LIBRARY_FIXTURES,
|
||||
scenes: SCENE_LIBRARY_FIXTURES,
|
||||
garments: GARMENT_LIBRARY_FIXTURES,
|
||||
};
|
||||
|
||||
const MESSAGE = "资源库当前使用占位数据,真实后端接口尚未提供。";
|
||||
|
||||
function isLibraryType(value: string): value is LibraryType {
|
||||
return Object.hasOwn(LIBRARY_FIXTURE_MAP, value);
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
return withErrorHandling(async () => {
|
||||
const { libraryType } = await context.params;
|
||||
|
||||
if (!isLibraryType(libraryType)) {
|
||||
throw new RouteError(404, "NOT_FOUND", "不支持的资源库类型。");
|
||||
}
|
||||
|
||||
return jsonSuccess(
|
||||
{
|
||||
items: LIBRARY_FIXTURE_MAP[libraryType],
|
||||
},
|
||||
{
|
||||
mode: "placeholder",
|
||||
message: MESSAGE,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
36
app/api/orders/[orderId]/assets/route.ts
Normal file
36
app/api/orders/[orderId]/assets/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { adaptAsset } from "@/lib/adapters/orders";
|
||||
import { backendRequest } from "@/lib/http/backend-client";
|
||||
import {
|
||||
jsonSuccess,
|
||||
parsePositiveIntegerParam,
|
||||
withErrorHandling,
|
||||
} from "@/lib/http/response";
|
||||
import type { AssetDto } from "@/lib/types/backend";
|
||||
import { businessEmptyState, READY_STATE } from "@/lib/types/view-models";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
orderId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
return withErrorHandling(async () => {
|
||||
const { orderId: rawOrderId } = await context.params;
|
||||
const orderId = parsePositiveIntegerParam(rawOrderId, "orderId");
|
||||
const response = await backendRequest<AssetDto[]>(`/orders/${orderId}/assets`);
|
||||
const items = response.data.map(adaptAsset);
|
||||
|
||||
return jsonSuccess(
|
||||
{
|
||||
items,
|
||||
state: items.length
|
||||
? READY_STATE
|
||||
: businessEmptyState("暂无资产", "当前订单还没有生成可查看的资产列表。"),
|
||||
},
|
||||
{
|
||||
mode: "proxy",
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
54
app/api/orders/[orderId]/revisions/route.ts
Normal file
54
app/api/orders/[orderId]/revisions/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { adaptRevisionChain, adaptRevisionRegistration } from "@/lib/adapters/revisions";
|
||||
import { backendRequest } from "@/lib/http/backend-client";
|
||||
import {
|
||||
jsonSuccess,
|
||||
parseJsonBody,
|
||||
parsePositiveIntegerParam,
|
||||
withErrorHandling,
|
||||
} from "@/lib/http/response";
|
||||
import type {
|
||||
RegisterRevisionResponseDto,
|
||||
RevisionChainResponseDto,
|
||||
} from "@/lib/types/backend";
|
||||
import { parseRegisterRevisionPayload } from "@/lib/validation/revision";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
orderId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
return withErrorHandling(async () => {
|
||||
const { orderId: rawOrderId } = await context.params;
|
||||
const orderId = parsePositiveIntegerParam(rawOrderId, "orderId");
|
||||
const response = await backendRequest<RevisionChainResponseDto>(
|
||||
`/orders/${orderId}/revisions`,
|
||||
);
|
||||
|
||||
return jsonSuccess(adaptRevisionChain(response.data), {
|
||||
mode: "proxy",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
return withErrorHandling(async () => {
|
||||
const { orderId: rawOrderId } = await context.params;
|
||||
const orderId = parsePositiveIntegerParam(rawOrderId, "orderId");
|
||||
const rawPayload = await parseJsonBody(request);
|
||||
const payload = parseRegisterRevisionPayload(rawPayload);
|
||||
const response = await backendRequest<RegisterRevisionResponseDto>(
|
||||
`/orders/${orderId}/revisions`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
|
||||
return jsonSuccess(adaptRevisionRegistration(response.data), {
|
||||
status: response.status,
|
||||
mode: "proxy",
|
||||
});
|
||||
});
|
||||
}
|
||||
32
app/api/orders/[orderId]/route.ts
Normal file
32
app/api/orders/[orderId]/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { adaptOrderDetail } from "@/lib/adapters/orders";
|
||||
import { backendRequest } from "@/lib/http/backend-client";
|
||||
import {
|
||||
jsonSuccess,
|
||||
parsePositiveIntegerParam,
|
||||
withErrorHandling,
|
||||
} from "@/lib/http/response";
|
||||
import type { AssetDto, OrderDetailResponseDto } from "@/lib/types/backend";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
orderId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
return withErrorHandling(async () => {
|
||||
const { orderId: rawOrderId } = await context.params;
|
||||
const orderId = parsePositiveIntegerParam(rawOrderId, "orderId");
|
||||
const [orderResponse, assetsResponse] = await Promise.all([
|
||||
backendRequest<OrderDetailResponseDto>(`/orders/${orderId}`),
|
||||
backendRequest<AssetDto[]>(`/orders/${orderId}/assets`),
|
||||
]);
|
||||
|
||||
return jsonSuccess(
|
||||
adaptOrderDetail(orderResponse.data, assetsResponse.data),
|
||||
{
|
||||
mode: "proxy",
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
32
app/api/orders/route.ts
Normal file
32
app/api/orders/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { CreateOrderResponseDto } from "@/lib/types/backend";
|
||||
import { backendRequest } from "@/lib/http/backend-client";
|
||||
import {
|
||||
jsonSuccess,
|
||||
parseJsonBody,
|
||||
withErrorHandling,
|
||||
} from "@/lib/http/response";
|
||||
import { parseCreateOrderPayload } from "@/lib/validation/create-order";
|
||||
|
||||
function normalizeCreateOrderResponse(payload: CreateOrderResponseDto) {
|
||||
return {
|
||||
orderId: payload.order_id,
|
||||
workflowId: payload.workflow_id,
|
||||
status: payload.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return withErrorHandling(async () => {
|
||||
const rawPayload = await parseJsonBody(request);
|
||||
const payload = parseCreateOrderPayload(rawPayload);
|
||||
const response = await backendRequest<CreateOrderResponseDto>("/orders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return jsonSuccess(normalizeCreateOrderResponse(response.data), {
|
||||
status: response.status,
|
||||
mode: "proxy",
|
||||
});
|
||||
});
|
||||
}
|
||||
37
app/api/reviews/[orderId]/confirm-revision/route.ts
Normal file
37
app/api/reviews/[orderId]/confirm-revision/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { adaptReviewSubmission } from "@/lib/adapters/reviews";
|
||||
import { backendRequest } from "@/lib/http/backend-client";
|
||||
import {
|
||||
jsonSuccess,
|
||||
parseJsonBody,
|
||||
parsePositiveIntegerParam,
|
||||
withErrorHandling,
|
||||
} from "@/lib/http/response";
|
||||
import type { ConfirmRevisionResponseDto } from "@/lib/types/backend";
|
||||
import { parseConfirmRevisionPayload } from "@/lib/validation/revision";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
orderId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
return withErrorHandling(async () => {
|
||||
const { orderId: rawOrderId } = await context.params;
|
||||
const orderId = parsePositiveIntegerParam(rawOrderId, "orderId");
|
||||
const rawPayload = await parseJsonBody(request);
|
||||
const payload = parseConfirmRevisionPayload(rawPayload);
|
||||
const response = await backendRequest<ConfirmRevisionResponseDto>(
|
||||
`/reviews/${orderId}/confirm-revision`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
|
||||
return jsonSuccess(adaptReviewSubmission(response.data), {
|
||||
status: response.status,
|
||||
mode: "proxy",
|
||||
});
|
||||
});
|
||||
}
|
||||
36
app/api/reviews/[orderId]/submit/route.ts
Normal file
36
app/api/reviews/[orderId]/submit/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { adaptReviewSubmission } from "@/lib/adapters/reviews";
|
||||
import { backendRequest } from "@/lib/http/backend-client";
|
||||
import {
|
||||
jsonSuccess,
|
||||
parseJsonBody,
|
||||
parsePositiveIntegerParam,
|
||||
withErrorHandling,
|
||||
} from "@/lib/http/response";
|
||||
import type { SubmitReviewResponseDto } from "@/lib/types/backend";
|
||||
import { parseReviewActionPayload } from "@/lib/validation/review-action";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
orderId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
return withErrorHandling(async () => {
|
||||
const { orderId: rawOrderId } = await context.params;
|
||||
const orderId = parsePositiveIntegerParam(rawOrderId, "orderId");
|
||||
const rawPayload = await parseJsonBody(request);
|
||||
const payload = parseReviewActionPayload(rawPayload);
|
||||
const response = await backendRequest<SubmitReviewResponseDto>(
|
||||
`/reviews/${orderId}/submit`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
|
||||
return jsonSuccess(adaptReviewSubmission(response.data), {
|
||||
mode: "proxy",
|
||||
});
|
||||
});
|
||||
}
|
||||
50
app/api/reviews/pending/route.ts
Normal file
50
app/api/reviews/pending/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { adaptPendingReviews } from "@/lib/adapters/reviews";
|
||||
import { backendRequest } from "@/lib/http/backend-client";
|
||||
import { jsonSuccess, withErrorHandling } from "@/lib/http/response";
|
||||
import type {
|
||||
PendingReviewResponseDto,
|
||||
WorkflowStatusResponseDto,
|
||||
} from "@/lib/types/backend";
|
||||
import { adaptWorkflowDetail } from "@/lib/adapters/workflows";
|
||||
import type { ReviewQueueItemVM } from "@/lib/types/view-models";
|
||||
|
||||
async function enrichQueueItem(
|
||||
item: ReviewQueueItemVM,
|
||||
): Promise<ReviewQueueItemVM> {
|
||||
try {
|
||||
const workflowResponse = await backendRequest<WorkflowStatusResponseDto>(
|
||||
`/workflows/${item.orderId}`,
|
||||
);
|
||||
const workflow = adaptWorkflowDetail(workflowResponse.data);
|
||||
|
||||
return {
|
||||
...item,
|
||||
workflowType: workflow.workflowType,
|
||||
hasMockAssets: workflow.hasMockAssets,
|
||||
failureCount: workflow.failureCount,
|
||||
};
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withErrorHandling(async () => {
|
||||
void request;
|
||||
const response = await backendRequest<PendingReviewResponseDto[]>(
|
||||
"/reviews/pending",
|
||||
);
|
||||
const queue = adaptPendingReviews(response.data);
|
||||
const items = await Promise.all(queue.items.map(enrichQueueItem));
|
||||
|
||||
return jsonSuccess(
|
||||
{
|
||||
...queue,
|
||||
items,
|
||||
},
|
||||
{
|
||||
mode: "proxy",
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
28
app/api/workflows/[orderId]/route.ts
Normal file
28
app/api/workflows/[orderId]/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { adaptWorkflowDetail } from "@/lib/adapters/workflows";
|
||||
import { backendRequest } from "@/lib/http/backend-client";
|
||||
import {
|
||||
jsonSuccess,
|
||||
parsePositiveIntegerParam,
|
||||
withErrorHandling,
|
||||
} from "@/lib/http/response";
|
||||
import type { WorkflowStatusResponseDto } from "@/lib/types/backend";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
orderId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
return withErrorHandling(async () => {
|
||||
const { orderId: rawOrderId } = await context.params;
|
||||
const orderId = parsePositiveIntegerParam(rawOrderId, "orderId");
|
||||
const response = await backendRequest<WorkflowStatusResponseDto>(
|
||||
`/workflows/${orderId}`,
|
||||
);
|
||||
|
||||
return jsonSuccess(adaptWorkflowDetail(response.data), {
|
||||
mode: "proxy",
|
||||
});
|
||||
});
|
||||
}
|
||||
59
app/globals.css
Normal file
59
app/globals.css
Normal file
@@ -0,0 +1,59 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg-canvas: #f6f1e8;
|
||||
--bg-canvas-strong: #efe5d7;
|
||||
--bg-elevated: rgba(255, 250, 243, 0.86);
|
||||
--surface: #fffaf2;
|
||||
--surface-muted: #f3ece1;
|
||||
--surface-strong: #fffdf8;
|
||||
--shell: #1e2724;
|
||||
--shell-muted: #7d8a80;
|
||||
--shell-border: rgba(242, 237, 229, 0.12);
|
||||
--ink-strong: #23303a;
|
||||
--ink: #2f352f;
|
||||
--ink-muted: #6a645a;
|
||||
--ink-faint: #8f8576;
|
||||
--accent-primary: #6e7f52;
|
||||
--accent-primary-strong: #5d6b46;
|
||||
--accent-ink: #f8f5ef;
|
||||
--accent-ring: rgba(110, 127, 82, 0.3);
|
||||
--accent-soft: rgba(110, 127, 82, 0.14);
|
||||
--border-soft: rgba(99, 87, 71, 0.16);
|
||||
--border-strong: rgba(82, 71, 57, 0.24);
|
||||
--shadow-shell: 0 28px 80px rgba(47, 38, 28, 0.12);
|
||||
--shadow-card: 0 18px 40px rgba(62, 46, 27, 0.08);
|
||||
--font-sans:
|
||||
"Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
||||
sans-serif;
|
||||
--font-mono: "IBM Plex Mono", "SFMono-Regular", "SF Mono", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(229, 214, 190, 0.9), transparent 42%),
|
||||
linear-gradient(180deg, var(--bg-canvas) 0%, var(--bg-canvas-strong) 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
21
app/layout.tsx
Normal file
21
app/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Auto Virtual Tryon Admin",
|
||||
description: "Operations console for virtual try-on workflows.",
|
||||
};
|
||||
|
||||
type RootLayoutProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
app/login/page.tsx
Normal file
5
app/login/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LoginPlaceholder } from "@/features/auth/login-placeholder";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginPlaceholder />;
|
||||
}
|
||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/orders");
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
# Auto Virtual Tryon Admin Frontend Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a standalone Next.js admin console with a shared dashboard shell, a BFF data layer, four real-integration pages backed by `auto-virtual-tryon`, and honest placeholder modules for unsupported backend capabilities.
|
||||
|
||||
**Architecture:** Manually scaffold a Next.js App Router app in this existing non-empty directory instead of using `create-next-app`. Route all UI data access through a Next.js BFF layer in `app/api/*`, with adapters normalizing both proxied FastAPI responses and mock placeholder data into stable frontend view models. Keep operational pages focused: submit, review, order detail, and workflow detail do the real work; home, library, login, and settings pages preserve information architecture without pretending backend capabilities already exist.
|
||||
|
||||
**Tech Stack:** Next.js App Router, React, TypeScript, Tailwind CSS, Zod, Vitest, React Testing Library, jsdom, Lucide React
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Bootstrap The Next.js Workspace
|
||||
|
||||
**Files:**
|
||||
- Create: `package.json`
|
||||
- Create: `tsconfig.json`
|
||||
- Create: `next.config.ts`
|
||||
- Create: `next-env.d.ts`
|
||||
- Create: `postcss.config.mjs`
|
||||
- Create: `eslint.config.mjs`
|
||||
- Create: `vitest.config.ts`
|
||||
- Create: `vitest.setup.ts`
|
||||
- Create: `app/layout.tsx`
|
||||
- Create: `app/page.tsx`
|
||||
- Create: `app/globals.css`
|
||||
- Create: `app/(dashboard)/layout.tsx`
|
||||
- Create: `src/components/layout/dashboard-shell.tsx`
|
||||
- Create: `src/components/layout/nav-config.ts`
|
||||
- Test: `tests/ui/dashboard-shell.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the package and toolchain files for a manual scaffold**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "auto-virtual-tryon-frontend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Install runtime and test dependencies**
|
||||
|
||||
Run: `npm install next@latest react@latest react-dom@latest zod clsx tailwind-merge lucide-react && npm install -D typescript @types/node @types/react @types/react-dom eslint eslint-config-next postcss tailwindcss @tailwindcss/postcss vitest vite @vitejs/plugin-react vite-tsconfig-paths jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom`
|
||||
Expected: install completes with `added` packages and no missing peer dependency errors
|
||||
|
||||
- [ ] **Step 3: Write the first failing shell smoke test**
|
||||
|
||||
```tsx
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DashboardShell } from "@/components/layout/dashboard-shell";
|
||||
|
||||
test("renders the primary dashboard navigation", () => {
|
||||
render(<DashboardShell>content</DashboardShell>);
|
||||
|
||||
expect(screen.getByText("订单总览")).toBeInTheDocument();
|
||||
expect(screen.getByText("提单工作台")).toBeInTheDocument();
|
||||
expect(screen.getByText("审核工作台")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the shell smoke test to verify it fails**
|
||||
|
||||
Run: `npm run test -- tests/ui/dashboard-shell.test.tsx`
|
||||
Expected: FAIL with a module-not-found error for `@/components/layout/dashboard-shell` or a missing test environment configuration
|
||||
|
||||
- [ ] **Step 5: Add the minimal app shell and root redirect**
|
||||
|
||||
```tsx
|
||||
// app/page.tsx
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/orders");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Re-run the shell smoke test to verify it passes**
|
||||
|
||||
Run: `npm run test -- tests/ui/dashboard-shell.test.tsx`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Start the dev server for a compile smoke check**
|
||||
|
||||
Run: `npm run dev`
|
||||
Expected: Next.js starts and serves `http://localhost:3000`
|
||||
|
||||
- [ ] **Step 8: Commit the bootstrap if this directory is a git repo**
|
||||
|
||||
```bash
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git add package.json tsconfig.json next.config.ts next-env.d.ts postcss.config.mjs eslint.config.mjs vitest.config.ts vitest.setup.ts app src tests
|
||||
git commit -m "feat: bootstrap next admin frontend"
|
||||
fi
|
||||
```
|
||||
|
||||
### Task 2: Define Shared Types, Status Maps, And Adapters
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/types/backend.ts`
|
||||
- Create: `src/lib/types/view-models.ts`
|
||||
- Create: `src/lib/types/status.ts`
|
||||
- Create: `src/lib/adapters/orders.ts`
|
||||
- Create: `src/lib/adapters/reviews.ts`
|
||||
- Create: `src/lib/adapters/workflows.ts`
|
||||
- Create: `src/lib/mock/orders.ts`
|
||||
- Create: `src/lib/mock/libraries.ts`
|
||||
- Create: `src/lib/mock/workflows.ts`
|
||||
- Test: `tests/lib/adapters/orders.test.ts`
|
||||
- Test: `tests/lib/adapters/reviews.test.ts`
|
||||
- Test: `tests/lib/adapters/workflows.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing adapter tests for business-empty and mock states**
|
||||
|
||||
```ts
|
||||
import { adaptOrderDetail } from "@/lib/adapters/orders";
|
||||
|
||||
test("marks mock asset uris as mock previews", () => {
|
||||
const viewModel = adaptOrderDetail({
|
||||
order_id: 1,
|
||||
final_asset: { id: 10, uri: "mock://result-10", asset_type: "image", step_name: "export", metadata_json: null, created_at: "2026-03-27T00:00:00Z", order_id: 1, parent_asset_id: null, root_asset_id: null, version_no: 1, is_current_version: true },
|
||||
} as any);
|
||||
|
||||
expect(viewModel.finalAsset?.isMock).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the adapter tests to verify they fail**
|
||||
|
||||
Run: `npm run test -- tests/lib/adapters/orders.test.ts tests/lib/adapters/reviews.test.ts tests/lib/adapters/workflows.test.ts`
|
||||
Expected: FAIL with missing adapter module exports
|
||||
|
||||
- [ ] **Step 3: Implement backend DTO types, frontend view models, and adapter functions**
|
||||
|
||||
```ts
|
||||
export type AssetViewModel = {
|
||||
id: number;
|
||||
uri: string;
|
||||
label: string;
|
||||
isMock: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add shared status labels and badge variants**
|
||||
|
||||
```ts
|
||||
export const ORDER_STATUS_META = {
|
||||
created: { label: "已创建", tone: "neutral" },
|
||||
running: { label: "处理中", tone: "info" },
|
||||
waiting_review: { label: "待审核", tone: "warning" },
|
||||
succeeded: { label: "已完成", tone: "success" },
|
||||
failed: { label: "失败", tone: "danger" },
|
||||
} as const;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run the adapter tests to verify they pass**
|
||||
|
||||
Run: `npm run test -- tests/lib/adapters/orders.test.ts tests/lib/adapters/reviews.test.ts tests/lib/adapters/workflows.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the shared model layer if git is available**
|
||||
|
||||
```bash
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git add src/lib tests/lib
|
||||
git commit -m "feat: add frontend view models and adapters"
|
||||
fi
|
||||
```
|
||||
|
||||
### Task 3: Build The BFF Layer For Real And Placeholder Data
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/env.ts`
|
||||
- Create: `src/lib/http/backend-client.ts`
|
||||
- Create: `src/lib/http/response.ts`
|
||||
- Create: `src/lib/validation/create-order.ts`
|
||||
- Create: `src/lib/validation/review-action.ts`
|
||||
- Create: `app/api/orders/route.ts`
|
||||
- Create: `app/api/orders/[orderId]/route.ts`
|
||||
- Create: `app/api/orders/[orderId]/assets/route.ts`
|
||||
- Create: `app/api/reviews/pending/route.ts`
|
||||
- Create: `app/api/reviews/[orderId]/submit/route.ts`
|
||||
- Create: `app/api/workflows/[orderId]/route.ts`
|
||||
- Create: `app/api/dashboard/orders-overview/route.ts`
|
||||
- Create: `app/api/dashboard/workflow-lookup/route.ts`
|
||||
- Create: `app/api/libraries/[libraryType]/route.ts`
|
||||
- Test: `tests/app/api/orders-create.route.test.ts`
|
||||
- Test: `tests/app/api/reviews-pending.route.test.ts`
|
||||
- Test: `tests/app/api/libraries.route.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing BFF tests for one proxied endpoint and one mock endpoint**
|
||||
|
||||
```ts
|
||||
import { GET } from "@/app/api/libraries/[libraryType]/route";
|
||||
|
||||
test("returns mock library data for unsupported backend modules", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/libraries/models"), {
|
||||
params: Promise.resolve({ libraryType: "models" }),
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the BFF tests to verify they fail**
|
||||
|
||||
Run: `npm run test -- tests/app/api/orders-create.route.test.ts tests/app/api/reviews-pending.route.test.ts tests/app/api/libraries.route.test.ts`
|
||||
Expected: FAIL with missing route handler modules
|
||||
|
||||
- [ ] **Step 3: Implement the backend fetch helper and environment parsing**
|
||||
|
||||
```ts
|
||||
export function getBackendBaseUrl() {
|
||||
return process.env.BACKEND_BASE_URL ?? "http://127.0.0.1:8000/api/v1";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement the proxied route handlers and placeholder route handlers**
|
||||
|
||||
```ts
|
||||
return NextResponse.json({
|
||||
mode: "placeholder",
|
||||
items: MODEL_LIBRARY_FIXTURES,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Normalize transport, validation, and system errors in one helper**
|
||||
|
||||
```ts
|
||||
return NextResponse.json(
|
||||
{ error: "BACKEND_UNAVAILABLE", message: "后端暂时不可用,请稍后重试。" },
|
||||
{ status: 502 },
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Re-run the BFF tests to verify they pass**
|
||||
|
||||
Run: `npm run test -- tests/app/api/orders-create.route.test.ts tests/app/api/reviews-pending.route.test.ts tests/app/api/libraries.route.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit the BFF layer if git is available**
|
||||
|
||||
```bash
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git add app/api src/lib/http src/lib/env.ts src/lib/validation tests/app/api
|
||||
git commit -m "feat: add bff routes for proxy and placeholder data"
|
||||
fi
|
||||
```
|
||||
|
||||
### Task 4: Implement The Shared Dashboard UI System
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/ui/button.tsx`
|
||||
- Create: `src/components/ui/card.tsx`
|
||||
- Create: `src/components/ui/status-badge.tsx`
|
||||
- Create: `src/components/ui/empty-state.tsx`
|
||||
- Create: `src/components/ui/page-header.tsx`
|
||||
- Create: `src/components/ui/section-title.tsx`
|
||||
- Modify: `app/globals.css`
|
||||
- Modify: `src/components/layout/dashboard-shell.tsx`
|
||||
- Test: `tests/ui/status-badge.test.tsx`
|
||||
- Test: `tests/ui/dashboard-shell.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write a failing test for status badge labels and tones**
|
||||
|
||||
```tsx
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
|
||||
test("renders the waiting review label", () => {
|
||||
render(<StatusBadge status="waiting_review" />);
|
||||
expect(screen.getByText("待审核")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the UI primitive tests to verify they fail**
|
||||
|
||||
Run: `npm run test -- tests/ui/status-badge.test.tsx tests/ui/dashboard-shell.test.tsx`
|
||||
Expected: FAIL with missing `StatusBadge` export
|
||||
|
||||
- [ ] **Step 3: Implement the warm-console theme tokens and UI primitives**
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg-canvas: #f6f1e8;
|
||||
--bg-surface: #fffdf8;
|
||||
--ink-strong: #23303a;
|
||||
--accent-primary: #6e7f52;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the dashboard shell to use the approved navigation, type hierarchy, and side rail**
|
||||
|
||||
```tsx
|
||||
<aside className="bg-[var(--ink-strong)] text-stone-100">
|
||||
{/* primary navigation */}
|
||||
</aside>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run the UI primitive tests to verify they pass**
|
||||
|
||||
Run: `npm run test -- tests/ui/status-badge.test.tsx tests/ui/dashboard-shell.test.tsx`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the shared UI system if git is available**
|
||||
|
||||
```bash
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git add app/globals.css src/components tests/ui
|
||||
git commit -m "feat: add dashboard shell and shared ui primitives"
|
||||
fi
|
||||
```
|
||||
|
||||
### Task 5: Implement The Submit Workbench
|
||||
|
||||
**Files:**
|
||||
- Create: `src/features/orders/components/create-order-form.tsx`
|
||||
- Create: `src/features/orders/components/resource-picker-card.tsx`
|
||||
- Create: `src/features/orders/components/order-summary-card.tsx`
|
||||
- Create: `src/features/orders/submit-workbench.tsx`
|
||||
- Modify: `app/(dashboard)/submit-workbench/page.tsx`
|
||||
- Test: `tests/features/orders/submit-workbench.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write a failing test for the customer-level/service-mode constraint**
|
||||
|
||||
```tsx
|
||||
test("forces low customers to use auto_basic", async () => {
|
||||
render(<SubmitWorkbench />);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("客户层级"), "low");
|
||||
|
||||
expect(screen.getByDisplayValue("auto_basic")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the submit workbench test to verify it fails**
|
||||
|
||||
Run: `npm run test -- tests/features/orders/submit-workbench.test.tsx`
|
||||
Expected: FAIL with missing `SubmitWorkbench` component
|
||||
|
||||
- [ ] **Step 3: Implement the mock-backed selectors, validation, and summary card**
|
||||
|
||||
```ts
|
||||
if (customerLevel === "low") {
|
||||
form.service_mode = "auto_basic";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the submit action with loading, success, and inline error states**
|
||||
|
||||
```tsx
|
||||
const [isPending, startTransition] = useTransition();
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run the submit workbench test to verify it passes**
|
||||
|
||||
Run: `npm run test -- tests/features/orders/submit-workbench.test.tsx`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Manually verify the happy path in the browser against a running backend**
|
||||
|
||||
Run: `npm run dev`
|
||||
Expected: a created order redirects to `/orders/<id>` and shows the returned `workflow_id`
|
||||
|
||||
- [ ] **Step 7: Commit the submit workbench if git is available**
|
||||
|
||||
```bash
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git add app/(dashboard)/submit-workbench src/features/orders tests/features/orders
|
||||
git commit -m "feat: implement submit workbench"
|
||||
fi
|
||||
```
|
||||
|
||||
### Task 6: Implement The Review Workbench
|
||||
|
||||
**Files:**
|
||||
- Create: `src/features/reviews/components/review-queue.tsx`
|
||||
- Create: `src/features/reviews/components/review-image-panel.tsx`
|
||||
- Create: `src/features/reviews/components/review-action-panel.tsx`
|
||||
- Create: `src/features/reviews/components/review-workflow-summary.tsx`
|
||||
- Create: `src/features/reviews/review-workbench.tsx`
|
||||
- Modify: `app/(dashboard)/reviews/workbench/page.tsx`
|
||||
- Test: `tests/features/reviews/review-workbench.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write a failing test that `rerun_face` requires a comment**
|
||||
|
||||
```tsx
|
||||
test("requires a comment before rerun_face submission", async () => {
|
||||
render(<ReviewWorkbench />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "重跑 Face" }));
|
||||
|
||||
expect(screen.getByText("请填写审核备注")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the review workbench test to verify it fails**
|
||||
|
||||
Run: `npm run test -- tests/features/reviews/review-workbench.test.tsx`
|
||||
Expected: FAIL with missing `ReviewWorkbench` component
|
||||
|
||||
- [ ] **Step 3: Implement the queue, current-order selection, and image inspection layout**
|
||||
|
||||
```tsx
|
||||
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement approve and rerun actions with non-optimistic queue refresh**
|
||||
|
||||
```tsx
|
||||
await submitReview(payload);
|
||||
await refreshQueue();
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run the review workbench test to verify it passes**
|
||||
|
||||
Run: `npm run test -- tests/features/reviews/review-workbench.test.tsx`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Manually verify review actions against a running backend**
|
||||
|
||||
Run: `npm run dev`
|
||||
Expected: pending items remain visible until the refreshed backend response says otherwise
|
||||
|
||||
- [ ] **Step 7: Commit the review workbench if git is available**
|
||||
|
||||
```bash
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git add app/(dashboard)/reviews src/features/reviews tests/features/reviews
|
||||
git commit -m "feat: implement review workbench"
|
||||
fi
|
||||
```
|
||||
|
||||
### Task 7: Implement Order Detail And Workflow Detail
|
||||
|
||||
**Files:**
|
||||
- Create: `src/features/orders/components/order-detail-header.tsx`
|
||||
- Create: `src/features/orders/components/order-assets-panel.tsx`
|
||||
- Create: `src/features/orders/components/order-workflow-card.tsx`
|
||||
- Create: `src/features/orders/order-detail.tsx`
|
||||
- Create: `src/features/workflows/components/workflow-status-card.tsx`
|
||||
- Create: `src/features/workflows/components/workflow-timeline.tsx`
|
||||
- Create: `src/features/workflows/workflow-detail.tsx`
|
||||
- Modify: `app/(dashboard)/orders/[orderId]/page.tsx`
|
||||
- Modify: `app/(dashboard)/workflows/[orderId]/page.tsx`
|
||||
- Test: `tests/features/orders/order-detail.test.tsx`
|
||||
- Test: `tests/features/workflows/workflow-detail.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for a mock asset banner and an empty final-result state**
|
||||
|
||||
```tsx
|
||||
test("shows a mock asset banner when the final asset uses a mock uri", () => {
|
||||
render(<OrderDetail viewModel={mockOrderDetailVm} />);
|
||||
|
||||
expect(screen.getByText("当前资产来自 mock 流程")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the detail page tests to verify they fail**
|
||||
|
||||
Run: `npm run test -- tests/features/orders/order-detail.test.tsx tests/features/workflows/workflow-detail.test.tsx`
|
||||
Expected: FAIL with missing detail components
|
||||
|
||||
- [ ] **Step 3: Implement the order detail page with business-empty handling**
|
||||
|
||||
```tsx
|
||||
{!viewModel.finalAsset ? <EmptyState title="最终图暂未生成" /> : <FinalAssetCard asset={viewModel.finalAsset} />}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement the workflow detail page with step timeline and failure emphasis**
|
||||
|
||||
```tsx
|
||||
<ol>{viewModel.steps.map((step) => <WorkflowStepRow key={step.name} step={step} />)}</ol>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run the detail page tests to verify they pass**
|
||||
|
||||
Run: `npm run test -- tests/features/orders/order-detail.test.tsx tests/features/workflows/workflow-detail.test.tsx`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the detail pages if git is available**
|
||||
|
||||
```bash
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git add app/(dashboard)/orders app/(dashboard)/workflows src/features/orders src/features/workflows tests/features/orders tests/features/workflows
|
||||
git commit -m "feat: implement order and workflow detail pages"
|
||||
fi
|
||||
```
|
||||
|
||||
### Task 8: Implement Placeholder Home, Workflow Lookup, Libraries, Login, And Settings
|
||||
|
||||
**Files:**
|
||||
- Create: `src/features/orders/orders-home.tsx`
|
||||
- Create: `src/features/workflows/workflow-lookup.tsx`
|
||||
- Create: `src/features/libraries/library-page.tsx`
|
||||
- Create: `src/features/settings/settings-placeholder.tsx`
|
||||
- Create: `src/features/auth/login-placeholder.tsx`
|
||||
- Modify: `app/(dashboard)/orders/page.tsx`
|
||||
- Modify: `app/(dashboard)/workflows/page.tsx`
|
||||
- Create: `app/(dashboard)/libraries/models/page.tsx`
|
||||
- Create: `app/(dashboard)/libraries/scenes/page.tsx`
|
||||
- Create: `app/(dashboard)/libraries/garments/page.tsx`
|
||||
- Create: `app/(dashboard)/settings/page.tsx`
|
||||
- Create: `app/login/page.tsx`
|
||||
- Test: `tests/features/orders/orders-home.test.tsx`
|
||||
- Test: `tests/features/libraries/library-page.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for the honest placeholder messaging**
|
||||
|
||||
```tsx
|
||||
test("explains that the orders list depends on a future backend api", () => {
|
||||
render(<OrdersHome />);
|
||||
|
||||
expect(screen.getByText("当前未接入真实订单列表接口")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the placeholder page tests to verify they fail**
|
||||
|
||||
Run: `npm run test -- tests/features/orders/orders-home.test.tsx tests/features/libraries/library-page.test.tsx`
|
||||
Expected: FAIL with missing placeholder components
|
||||
|
||||
- [ ] **Step 3: Implement the `/orders` home page with direct lookup, recent visits, and transition copy**
|
||||
|
||||
```tsx
|
||||
<EmptyState title="当前未接入真实订单列表接口" description="可通过订单号直达详情,或从最近访问记录继续处理。" />
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement the workflow lookup, library placeholders, settings, and login placeholders**
|
||||
|
||||
```tsx
|
||||
<LibraryPage title="模特库" placeholderMode />
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run the placeholder page tests to verify they pass**
|
||||
|
||||
Run: `npm run test -- tests/features/orders/orders-home.test.tsx tests/features/libraries/library-page.test.tsx`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the placeholder pages if git is available**
|
||||
|
||||
```bash
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git add app/(dashboard)/orders app/(dashboard)/workflows app/(dashboard)/libraries app/(dashboard)/settings app/login src/features/orders src/features/workflows src/features/libraries src/features/settings src/features/auth tests/features
|
||||
git commit -m "feat: add placeholder dashboard modules"
|
||||
fi
|
||||
```
|
||||
|
||||
### Task 9: Verify The Full Build And Document Local Setup
|
||||
|
||||
**Files:**
|
||||
- Create: `README.md`
|
||||
- Modify: `package.json`
|
||||
- Modify: `docs/superpowers/specs/2026-03-27-auto-virtual-tryon-admin-frontend-design.md`
|
||||
|
||||
- [ ] **Step 1: Add a README with local run instructions and backend environment notes**
|
||||
|
||||
```md
|
||||
BACKEND_BASE_URL=http://127.0.0.1:8000/api/v1
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the full automated test suite**
|
||||
|
||||
Run: `npm run test`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Run lint and production build verification**
|
||||
|
||||
Run: `npm run lint && npm run build`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Perform a final manual flow check against the FastAPI backend**
|
||||
|
||||
Run: `npm run dev`
|
||||
Expected: submit, review, order detail, and workflow detail all work against `/Volumes/DockCase/codes/auto-virtual-tryon`
|
||||
|
||||
- [ ] **Step 5: Commit the verified frontend if git is available**
|
||||
|
||||
```bash
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git add README.md package.json docs/superpowers/specs/2026-03-27-auto-virtual-tryon-admin-frontend-design.md
|
||||
git commit -m "docs: add frontend runbook and verification notes"
|
||||
fi
|
||||
```
|
||||
@@ -0,0 +1,512 @@
|
||||
# Auto Virtual Tryon Admin Frontend Design
|
||||
|
||||
Date: 2026-03-27
|
||||
|
||||
## 1. Background
|
||||
|
||||
This project will be a standalone Next.js admin frontend for the virtual try-on pipeline described in the root PRD:
|
||||
|
||||
- PRD source: `2026-03-27-frontend-admin-prd.md`
|
||||
- Backend reference: adjacent project `/Volumes/DockCase/codes/auto-virtual-tryon`
|
||||
|
||||
The frontend is not being designed against an abstract future system. It must respect the current backend reality.
|
||||
|
||||
### 1.1 Current Backend Reality
|
||||
|
||||
The current backend project already exposes stable APIs for:
|
||||
|
||||
- `POST /api/v1/orders`
|
||||
- `GET /api/v1/orders/{order_id}`
|
||||
- `GET /api/v1/orders/{order_id}/assets`
|
||||
- `GET /api/v1/reviews/pending`
|
||||
- `POST /api/v1/reviews/{order_id}/submit`
|
||||
- `GET /api/v1/workflows/{order_id}`
|
||||
|
||||
The root PRD also describes manual revision flows and additional endpoints such as:
|
||||
|
||||
- `POST /api/v1/orders/{order_id}/revisions`
|
||||
- `GET /api/v1/orders/{order_id}/revisions`
|
||||
- `POST /api/v1/reviews/{order_id}/confirm-revision`
|
||||
|
||||
Those endpoints are not present in the currently implemented backend code at `/Volumes/DockCase/codes/auto-virtual-tryon/app`.
|
||||
|
||||
### 1.2 Design Consequence
|
||||
|
||||
The frontend must therefore distinguish between:
|
||||
|
||||
- real integration modules backed by implemented APIs
|
||||
- placeholder or mock-backed modules that preserve the intended information architecture
|
||||
|
||||
The design does not fake backend completeness. It preserves product structure while keeping technical truth explicit.
|
||||
|
||||
## 2. Product Goal
|
||||
|
||||
Build a complete admin console shell in Next.js that:
|
||||
|
||||
- feels like a real internal operations product rather than a demo
|
||||
- supports true end-to-end integration for the currently implemented backend workflows
|
||||
- preserves the full first-phase information architecture from the PRD
|
||||
- keeps future backend completion low-cost by isolating mocks and adapters away from page components
|
||||
|
||||
## 3. Approved Scope
|
||||
|
||||
### 3.1 Scope Baseline
|
||||
|
||||
The approved direction is:
|
||||
|
||||
- follow the PRD as the product baseline
|
||||
- prioritize real integration pages first
|
||||
- do not let the rest of the console feel unfinished or structurally hollow
|
||||
|
||||
### 3.2 Integration Strategy
|
||||
|
||||
The approved data strategy is:
|
||||
|
||||
- pages only request the Next.js app itself
|
||||
- Next.js provides a BFF layer under `app/api/*`
|
||||
- the BFF decides whether to proxy to the real FastAPI backend or return mock / placeholder data
|
||||
|
||||
This keeps page code unaware of whether a module is currently real or mocked.
|
||||
|
||||
## 4. Information Architecture
|
||||
|
||||
### 4.1 Primary Navigation
|
||||
|
||||
The first-level navigation is:
|
||||
|
||||
- `订单总览`
|
||||
- `提单工作台`
|
||||
- `审核工作台`
|
||||
- `流程追踪`
|
||||
- `资源库`
|
||||
- `系统设置`
|
||||
|
||||
### 4.2 Route Structure
|
||||
|
||||
The approved page tree is:
|
||||
|
||||
- `/orders`
|
||||
- `/orders/[orderId]`
|
||||
- `/submit-workbench`
|
||||
- `/reviews/workbench`
|
||||
- `/workflows`
|
||||
- `/workflows/[orderId]`
|
||||
- `/libraries/models`
|
||||
- `/libraries/scenes`
|
||||
- `/libraries/garments`
|
||||
- `/settings`
|
||||
- `/login`
|
||||
|
||||
### 4.3 Default Entry
|
||||
|
||||
The default landing page remains `/orders`.
|
||||
|
||||
This is intentionally retained even though the backend does not yet provide a real orders list API. The page will act as the official home of the console using a transitional strategy:
|
||||
|
||||
- direct order lookup
|
||||
- recent-visit history
|
||||
- mock or placeholder overview content
|
||||
- explicit messaging that full list/search depends on future backend support
|
||||
|
||||
## 5. Layout and Visual Direction
|
||||
|
||||
### 5.1 Approved Visual Direction
|
||||
|
||||
The approved visual direction is:
|
||||
|
||||
`A. Gallery-First Warm Console`
|
||||
|
||||
This means:
|
||||
|
||||
- warm neutral page background instead of pure white
|
||||
- deep ink navigation shell
|
||||
- restrained green accent for key CTA states
|
||||
- review surfaces designed around image inspection first
|
||||
|
||||
### 5.2 Visual Character
|
||||
|
||||
The UI should feel:
|
||||
|
||||
- professional
|
||||
- calm
|
||||
- high-density but readable
|
||||
- distinct from generic enterprise dashboards
|
||||
|
||||
It should not feel:
|
||||
|
||||
- cold and generic
|
||||
- decorative for decoration’s sake
|
||||
- overly editorial on operational pages
|
||||
|
||||
### 5.3 Typography and Color Rules
|
||||
|
||||
- Chinese UI text uses `Noto Sans SC`
|
||||
- IDs, order numbers, and technical labels use `IBM Plex Mono`
|
||||
- the primary accent is reserved for high-value actions such as submit and approve
|
||||
- state colors are managed separately from brand accent colors
|
||||
- the review workbench remains visually more restrained than marketing-style pages
|
||||
|
||||
## 6. Technical Architecture
|
||||
|
||||
### 6.1 Framework Direction
|
||||
|
||||
The frontend will be built as a standalone Next.js application using:
|
||||
|
||||
- Next.js App Router
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
|
||||
### 6.2 Route Grouping
|
||||
|
||||
The application will be organized as:
|
||||
|
||||
```text
|
||||
app/
|
||||
(dashboard)/
|
||||
orders/page.tsx
|
||||
orders/[orderId]/page.tsx
|
||||
submit-workbench/page.tsx
|
||||
reviews/workbench/page.tsx
|
||||
workflows/page.tsx
|
||||
workflows/[orderId]/page.tsx
|
||||
libraries/models/page.tsx
|
||||
libraries/scenes/page.tsx
|
||||
libraries/garments/page.tsx
|
||||
settings/page.tsx
|
||||
login/page.tsx
|
||||
api/*
|
||||
```
|
||||
|
||||
The dashboard route group owns the shared shell:
|
||||
|
||||
- side navigation
|
||||
- top bar
|
||||
- page content container
|
||||
- shared empty states
|
||||
- shared badges and status treatments
|
||||
|
||||
### 6.3 BFF Layer
|
||||
|
||||
The BFF layer under `app/api/*` is responsible for:
|
||||
|
||||
- request validation
|
||||
- backend proxying
|
||||
- response normalization
|
||||
- mock response generation
|
||||
- environment-based backend URL routing
|
||||
- mapping backend responses into stable frontend-facing contracts
|
||||
|
||||
Pages do not call FastAPI directly.
|
||||
|
||||
## 7. Data Model Strategy
|
||||
|
||||
### 7.1 Frontend View Models
|
||||
|
||||
The UI consumes stable frontend view models rather than raw backend payloads.
|
||||
|
||||
Approved shared view models:
|
||||
|
||||
- `OrderSummaryVM`
|
||||
- `OrderDetailVM`
|
||||
- `ReviewQueueItemVM`
|
||||
- `WorkflowDetailVM`
|
||||
- `LibraryItemVM`
|
||||
|
||||
### 7.2 Adapter Rules
|
||||
|
||||
All cross-source differences are handled in adapters:
|
||||
|
||||
- missing backend fields are converted into explicit fallback copy or null-safe UI states
|
||||
- business-empty responses are not treated as system failures
|
||||
- mock asset URIs such as `mock://...` are displayed honestly as mock assets
|
||||
- backend enum values are mapped once, not repeatedly in page components
|
||||
|
||||
### 7.3 File Decomposition
|
||||
|
||||
The frontend should be split along responsibility boundaries:
|
||||
|
||||
```text
|
||||
src/
|
||||
lib/
|
||||
api/
|
||||
adapters/
|
||||
mock/
|
||||
types/
|
||||
features/
|
||||
orders/
|
||||
reviews/
|
||||
workflows/
|
||||
libraries/
|
||||
components/
|
||||
layout/
|
||||
ui/
|
||||
```
|
||||
|
||||
The key rule is that mock-vs-real complexity must live in `lib`, not in page UI trees.
|
||||
|
||||
## 8. Real Modules vs Placeholder Modules
|
||||
|
||||
### 8.1 Real Integration Modules
|
||||
|
||||
The first implementation phase will perform true integration for:
|
||||
|
||||
- `提单工作台`
|
||||
- `审核工作台`
|
||||
- `订单详情`
|
||||
- `流程详情页 /workflows/[orderId]`
|
||||
|
||||
### 8.2 Placeholder or Transitional Modules
|
||||
|
||||
The first implementation phase will preserve full route structure for:
|
||||
|
||||
- `/orders`
|
||||
- `/workflows`
|
||||
- `/libraries/models`
|
||||
- `/libraries/scenes`
|
||||
- `/libraries/garments`
|
||||
- `/settings`
|
||||
- `/login`
|
||||
|
||||
These are not “missing pages”. They are real screens with:
|
||||
|
||||
- proper layout
|
||||
- proper states
|
||||
- mock or transitional data
|
||||
- explicit product messaging about current backend limitations
|
||||
|
||||
## 9. Core Page Responsibilities
|
||||
|
||||
### 9.1 `/submit-workbench`
|
||||
|
||||
Purpose:
|
||||
|
||||
- the only first-class order creation workspace
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- customer level selection
|
||||
- service mode selection
|
||||
- mock-backed resource selection for model, scene, garment
|
||||
- local validation of backend-supported combinations
|
||||
- request submission to create orders
|
||||
- success feedback and redirect to `/orders/[orderId]`
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- no review actions
|
||||
- no workflow timeline deep-dive
|
||||
- no list monitoring behavior
|
||||
|
||||
### 9.2 `/reviews/workbench`
|
||||
|
||||
Purpose:
|
||||
|
||||
- the highest-frequency manual review surface
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- display the pending review queue
|
||||
- switch between orders quickly without route churn
|
||||
- show the current candidate asset and key order context
|
||||
- perform `approve` and `rerun_*` review decisions
|
||||
- surface compact workflow summary and errors relevant to review
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- not the canonical archive for full order history
|
||||
- not the main workflow forensic page
|
||||
- not a fake implementation of unbuilt revision-chain APIs
|
||||
|
||||
Because the current backend lacks revision endpoints, first implementation should present those PRD concepts as clearly marked placeholders rather than simulate unsupported actions as if they were real.
|
||||
|
||||
### 9.3 `/orders/[orderId]`
|
||||
|
||||
Purpose:
|
||||
|
||||
- the canonical shared order detail page
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- order metadata
|
||||
- current status
|
||||
- asset list
|
||||
- final result area
|
||||
- workflow summary
|
||||
- navigation to review and workflow detail pages
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- no heavy review action UI
|
||||
- no full process forensics
|
||||
- no order creation
|
||||
|
||||
### 9.4 `/workflows/[orderId]`
|
||||
|
||||
Purpose:
|
||||
|
||||
- focused workflow inspection and troubleshooting
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- workflow status card
|
||||
- current step
|
||||
- step timeline
|
||||
- failure and anomaly details
|
||||
- direct order-id-based lookup
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- no primary image review experience
|
||||
- no review decision submission
|
||||
- no resource selection
|
||||
|
||||
## 10. UX Rules and State Handling
|
||||
|
||||
### 10.1 Submit Workbench Rules
|
||||
|
||||
- `low` must only allow `auto_basic`
|
||||
- `mid` must only allow `semi_pro`
|
||||
- submission failure must preserve form state
|
||||
- success must show `order_id` and `workflow_id`, then redirect to order detail
|
||||
|
||||
### 10.2 Review Workbench Rules
|
||||
|
||||
- queue items are never removed optimistically on action submission
|
||||
- `rerun_scene`, `rerun_face`, and `rerun_fusion` require comment input
|
||||
- action failures are shown inline in the action area
|
||||
- queue refresh should be explicit and predictable after successful actions
|
||||
|
||||
### 10.3 Order Detail Rules
|
||||
|
||||
- absence of final asset is a valid business state
|
||||
- absence of assets is a valid business empty state
|
||||
- unsupported revision-chain behavior is displayed as “not currently backed by backend” rather than as an error
|
||||
|
||||
### 10.4 Workflow Rules
|
||||
|
||||
- “not found” workflow responses must be distinguished from transport or server failures
|
||||
- workflow step rendering should emphasize current step, failed step, and rerun lineage where available
|
||||
|
||||
## 11. Error Model
|
||||
|
||||
The UI uses three error/state categories:
|
||||
|
||||
### 11.1 Business Empty State
|
||||
|
||||
Examples:
|
||||
|
||||
- no final image yet
|
||||
- no assets yet
|
||||
- no real orders list yet
|
||||
- no resource library backend yet
|
||||
|
||||
These render as valid explanatory UI states, not error banners.
|
||||
|
||||
### 11.2 Retryable Error
|
||||
|
||||
Examples:
|
||||
|
||||
- temporary network failure
|
||||
- backend timeout
|
||||
- proxy failure
|
||||
|
||||
These render with retry affordances.
|
||||
|
||||
### 11.3 System Error
|
||||
|
||||
Examples:
|
||||
|
||||
- unexpected response shape
|
||||
- unrecoverable server failure
|
||||
- application-level state contradiction
|
||||
|
||||
These render as hard failure states with diagnostic framing.
|
||||
|
||||
## 12. Mock and Placeholder Policy
|
||||
|
||||
Placeholder modules are first-class citizens in the shell, but they must remain honest.
|
||||
|
||||
Rules:
|
||||
|
||||
- never imply an unavailable backend capability is actually live
|
||||
- never silently replace missing backend data with fabricated “real-looking” data on core real-integration pages
|
||||
- keep mock data isolated to modules explicitly designated as transitional
|
||||
- label unavailable capability clearly where it matters to task completion
|
||||
|
||||
## 13. Testing Strategy
|
||||
|
||||
First-phase testing will prioritize correctness over exhaustive visual snapshot coverage.
|
||||
|
||||
### 13.1 Must-Test Areas
|
||||
|
||||
- BFF route handlers
|
||||
- adapter mapping logic
|
||||
- submit workbench validation rules
|
||||
- submit and review action request flows
|
||||
- key page state rendering for loading, empty, success, and failure
|
||||
|
||||
### 13.2 Lower Priority for Phase 1
|
||||
|
||||
- pixel-perfect visual snapshots
|
||||
- exhaustive placeholder-page snapshots
|
||||
- animation-heavy testing
|
||||
|
||||
The purpose of the first test layer is to protect data contracts and operational flows.
|
||||
|
||||
## 14. Delivery Boundary
|
||||
|
||||
### 14.1 First-Phase Hard Acceptance
|
||||
|
||||
The frontend must be able to:
|
||||
|
||||
- create an order from the submit workbench
|
||||
- review pending orders from the review workbench
|
||||
- execute `approve`
|
||||
- execute `rerun_scene`
|
||||
- execute `rerun_face`
|
||||
- execute `rerun_fusion`
|
||||
- inspect order detail data
|
||||
- inspect workflow detail data
|
||||
|
||||
### 14.2 First-Phase Structural Acceptance
|
||||
|
||||
The console must also provide:
|
||||
|
||||
- complete navigation shell
|
||||
- `/orders` as the official entry page
|
||||
- resource library placeholders
|
||||
- workflow lookup index page
|
||||
- settings and login route placeholders
|
||||
|
||||
## 15. Risks
|
||||
|
||||
- The root PRD is ahead of the currently implemented backend in revision-related capabilities.
|
||||
- The default home page is structurally important but cannot yet be backed by a real list API.
|
||||
- Mock asset URIs limit the realism of image preview experiences.
|
||||
- Without authentication in phase 1, the console assumes a trusted environment.
|
||||
|
||||
## 16. Implementation Direction
|
||||
|
||||
The implementation should proceed by:
|
||||
|
||||
1. scaffolding the Next.js app shell and BFF layer
|
||||
2. establishing shared types, adapters, and placeholder data contracts
|
||||
3. implementing the four real-integration modules
|
||||
4. filling in the transitional modules with honest placeholder behavior
|
||||
5. validating the accepted states and operational flows with tests
|
||||
|
||||
## 17. Local Setup Notes
|
||||
|
||||
Implementation assumes the frontend runs with:
|
||||
|
||||
- `BACKEND_BASE_URL=http://127.0.0.1:8000/api/v1`
|
||||
|
||||
Recommended local verification commands:
|
||||
|
||||
- `npm run test`
|
||||
- `npm run lint`
|
||||
- `npm run typecheck`
|
||||
- `npm run build`
|
||||
- `npm run verify`
|
||||
|
||||
The adjacent backend project must be started separately when manually checking the four real-integration pages.
|
||||
6
eslint.config.mjs
Normal file
6
eslint.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTypeScript from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = [...nextCoreWebVitals, ...nextTypeScript];
|
||||
|
||||
export default eslintConfig;
|
||||
1
mjs-modules.d.ts
vendored
Normal file
1
mjs-modules.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.mjs";
|
||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7484
package-lock.json
generated
Normal file
7484
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "auto-virtual-tryon-frontend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"stack:start": "bash scripts/dev-stack/start.sh",
|
||||
"stack:stop": "bash scripts/dev-stack/stop.sh",
|
||||
"stack:status": "bash scripts/dev-stack/status.sh",
|
||||
"stack:logs": "bash scripts/dev-stack/logs.sh",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:clean": "node -e \"require('node:fs').rmSync('.next/dev', { recursive: true, force: true })\" && npm run typecheck",
|
||||
"verify": "npm run test && npm run lint && npm run typecheck:clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^16.2.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"zod": "^4.3.6",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"lucide-react": "^1.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.3",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
8
scripts/dev-stack/logs.sh
Executable file
8
scripts/dev-stack/logs.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
node "${SCRIPT_DIR}/stack.mjs" logs
|
||||
37
scripts/dev-stack/stack.d.ts
vendored
Normal file
37
scripts/dev-stack/stack.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
export type StackServiceConfig = {
|
||||
key: string;
|
||||
cwd: string;
|
||||
port: number | null;
|
||||
command: string[];
|
||||
};
|
||||
|
||||
export type StackConfig = {
|
||||
frontendRoot: string;
|
||||
backendRoot: string;
|
||||
runtimeRoot: string;
|
||||
pidRoot: string;
|
||||
logRoot: string;
|
||||
temporalDatabaseFile: string;
|
||||
services: StackServiceConfig[];
|
||||
};
|
||||
|
||||
export function getDefaultTemporalCandidates(tempDirectory: string): string[];
|
||||
|
||||
export function selectServicesForLogs(
|
||||
services: StackServiceConfig[],
|
||||
serviceFilter: string | null,
|
||||
): StackServiceConfig[];
|
||||
|
||||
export function formatServiceLogs(entries: Array<{
|
||||
key: string;
|
||||
logFilePath: string;
|
||||
lines: string[];
|
||||
}>): string;
|
||||
|
||||
export function resolveTemporalCli(options: {
|
||||
pathLookupResult: string | null;
|
||||
candidatePaths: string[];
|
||||
existingPaths: Set<string>;
|
||||
}): string | null;
|
||||
|
||||
export function createStackConfig(frontendRoot: string, backendRoot?: string): StackConfig;
|
||||
494
scripts/dev-stack/stack.mjs
Normal file
494
scripts/dev-stack/stack.mjs
Normal file
@@ -0,0 +1,494 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import net from "node:net";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
|
||||
const STACK_DIR_NAME = ".dev-stack";
|
||||
const DEFAULT_TEMPORAL_CANDIDATES = [
|
||||
"temporal-sdk-python-1.24.0",
|
||||
"temporal-sdk-python-1.25.0",
|
||||
"temporal-sdk-python-1.26.0",
|
||||
"temporal-cli",
|
||||
];
|
||||
|
||||
export function getDefaultTemporalCandidates(tempDirectory) {
|
||||
return DEFAULT_TEMPORAL_CANDIDATES.map((name) => path.join(tempDirectory, name));
|
||||
}
|
||||
|
||||
export function resolveTemporalCli({
|
||||
pathLookupResult,
|
||||
candidatePaths,
|
||||
existingPaths,
|
||||
}) {
|
||||
if (pathLookupResult && existingPaths.has(pathLookupResult)) {
|
||||
return pathLookupResult;
|
||||
}
|
||||
|
||||
for (const candidatePath of candidatePaths) {
|
||||
if (existingPaths.has(candidatePath)) {
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createStackConfig(frontendRoot, backendRoot = path.join(path.dirname(frontendRoot), "auto-virtual-tryon")) {
|
||||
const runtimeRoot = path.join(frontendRoot, STACK_DIR_NAME);
|
||||
const pidRoot = path.join(runtimeRoot, "pids");
|
||||
const logRoot = path.join(runtimeRoot, "logs");
|
||||
const temporalDatabaseFile = path.join(runtimeRoot, "temporal-cli-dev.db");
|
||||
const backendPython = path.join(backendRoot, ".venv", "bin", "python");
|
||||
const frontendNext = path.join(frontendRoot, "node_modules", ".bin", "next");
|
||||
|
||||
return {
|
||||
frontendRoot,
|
||||
backendRoot,
|
||||
runtimeRoot,
|
||||
pidRoot,
|
||||
logRoot,
|
||||
temporalDatabaseFile,
|
||||
services: [
|
||||
{
|
||||
key: "temporal",
|
||||
cwd: runtimeRoot,
|
||||
port: 7233,
|
||||
command: [
|
||||
"__TEMPORAL_CLI__",
|
||||
"server",
|
||||
"start-dev",
|
||||
"--ip",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"7233",
|
||||
"--headless",
|
||||
"--db-filename",
|
||||
temporalDatabaseFile,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "backend-api",
|
||||
cwd: backendRoot,
|
||||
port: 8000,
|
||||
command: [
|
||||
backendPython,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"app.main:app",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"8000",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "backend-worker",
|
||||
cwd: backendRoot,
|
||||
port: null,
|
||||
command: [backendPython, "-m", "app.workers.runner"],
|
||||
},
|
||||
{
|
||||
key: "frontend",
|
||||
cwd: frontendRoot,
|
||||
port: 3000,
|
||||
command: [frontendNext, "dev", "--hostname", "127.0.0.1", "--port", "3000"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function selectServicesForLogs(services, serviceFilter) {
|
||||
if (!serviceFilter) {
|
||||
return services;
|
||||
}
|
||||
|
||||
const matched = services.find((service) => service.key === serviceFilter);
|
||||
if (!matched) {
|
||||
const supportedKeys = services.map((service) => service.key).join(", ");
|
||||
throw new Error(`Unknown service "${serviceFilter}". Expected one of: ${supportedKeys}`);
|
||||
}
|
||||
|
||||
return [matched];
|
||||
}
|
||||
|
||||
export function formatServiceLogs(entries) {
|
||||
return entries
|
||||
.flatMap((entry, index) => {
|
||||
const header = `--- ${entry.key} (${entry.logFilePath}) ---`;
|
||||
const body = entry.lines.length > 0 ? entry.lines : ["(log file is empty)"];
|
||||
return [...(index === 0 ? [] : [""]), header, ...body];
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function ensureDirectory(directoryPath) {
|
||||
fs.mkdirSync(directoryPath, { recursive: true });
|
||||
}
|
||||
|
||||
function getServicePidFile(config, serviceKey) {
|
||||
return path.join(config.pidRoot, `${serviceKey}.pid`);
|
||||
}
|
||||
|
||||
function getServiceLogFile(config, serviceKey) {
|
||||
return path.join(config.logRoot, `${serviceKey}.log`);
|
||||
}
|
||||
|
||||
function readLastLogLines(logFilePath, lineLimit) {
|
||||
if (!fs.existsSync(logFilePath)) {
|
||||
return ["(log file not found yet)"];
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(logFilePath, "utf8");
|
||||
if (!raw.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedLines = raw.replace(/\r\n/g, "\n").split("\n");
|
||||
if (normalizedLines.at(-1) === "") {
|
||||
normalizedLines.pop();
|
||||
}
|
||||
|
||||
return normalizedLines.slice(-lineLimit);
|
||||
}
|
||||
|
||||
function readPid(pidFilePath) {
|
||||
if (!fs.existsSync(pidFilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(pidFilePath, "utf8").trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(raw);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function isPidRunning(pid) {
|
||||
if (!pid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removePidFile(pidFilePath) {
|
||||
if (fs.existsSync(pidFilePath)) {
|
||||
fs.unlinkSync(pidFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
function pathExists(candidatePath) {
|
||||
try {
|
||||
fs.accessSync(candidatePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getTemporalCliPath() {
|
||||
const pathLookup = spawnSync("which", ["temporal"], { encoding: "utf8" });
|
||||
const fromPath = pathLookup.status === 0 ? pathLookup.stdout.trim() : null;
|
||||
const tempDirectory = process.env.TMPDIR || "/tmp";
|
||||
const candidatePaths = getDefaultTemporalCandidates(tempDirectory);
|
||||
const existingPaths = new Set(
|
||||
[fromPath, ...candidatePaths].filter(Boolean).filter((candidatePath) => pathExists(candidatePath)),
|
||||
);
|
||||
|
||||
return resolveTemporalCli({
|
||||
pathLookupResult: fromPath,
|
||||
candidatePaths,
|
||||
existingPaths,
|
||||
});
|
||||
}
|
||||
|
||||
function materializeServices(config, temporalCliPath) {
|
||||
return config.services.map((service) => ({
|
||||
...service,
|
||||
command: service.command.map((part) => (part === "__TEMPORAL_CLI__" ? temporalCliPath : part)),
|
||||
}));
|
||||
}
|
||||
|
||||
function waitForPort(port, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
const attempt = () => {
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port });
|
||||
socket.once("connect", () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", () => {
|
||||
socket.destroy();
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
reject(new Error(`Timed out waiting for port ${port}`));
|
||||
return;
|
||||
}
|
||||
setTimeout(attempt, 250);
|
||||
});
|
||||
};
|
||||
|
||||
attempt();
|
||||
});
|
||||
}
|
||||
|
||||
function waitForHttp(url, timeoutMs) {
|
||||
const client = url.startsWith("https://") ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
const attempt = () => {
|
||||
const request = client.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode && response.statusCode < 500) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
reject(new Error(`Timed out waiting for ${url}`));
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(attempt, 250);
|
||||
});
|
||||
|
||||
request.on("error", () => {
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
reject(new Error(`Timed out waiting for ${url}`));
|
||||
return;
|
||||
}
|
||||
setTimeout(attempt, 250);
|
||||
});
|
||||
};
|
||||
|
||||
attempt();
|
||||
});
|
||||
}
|
||||
|
||||
function isLocalPortBusy(port) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port });
|
||||
socket.once("connect", () => {
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopService(config, service) {
|
||||
const pidFilePath = getServicePidFile(config, service.key);
|
||||
const pid = readPid(pidFilePath);
|
||||
if (!pid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isPidRunning(pid)) {
|
||||
removePidFile(pidFilePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
process.kill(-pid, "SIGTERM");
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
if (!isPidRunning(pid)) {
|
||||
removePidFile(pidFilePath);
|
||||
return true;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
process.kill(-pid, "SIGKILL");
|
||||
removePidFile(pidFilePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function startService(config, service) {
|
||||
const pidFilePath = getServicePidFile(config, service.key);
|
||||
const logFilePath = getServiceLogFile(config, service.key);
|
||||
const existingPid = readPid(pidFilePath);
|
||||
|
||||
if (existingPid && isPidRunning(existingPid)) {
|
||||
return { key: service.key, pid: existingPid, reused: true };
|
||||
}
|
||||
|
||||
removePidFile(pidFilePath);
|
||||
|
||||
if (service.port && (await isLocalPortBusy(service.port))) {
|
||||
throw new Error(`Port ${service.port} is already in use. Stop the existing process before running stack:start.`);
|
||||
}
|
||||
|
||||
ensureDirectory(path.dirname(logFilePath));
|
||||
const logDescriptor = fs.openSync(logFilePath, "a");
|
||||
const child = spawn(service.command[0], service.command.slice(1), {
|
||||
cwd: service.cwd,
|
||||
detached: true,
|
||||
stdio: ["ignore", logDescriptor, logDescriptor],
|
||||
env: {
|
||||
...process.env,
|
||||
BACKEND_BASE_URL: process.env.BACKEND_BASE_URL || "http://127.0.0.1:8000/api/v1",
|
||||
},
|
||||
});
|
||||
|
||||
child.unref();
|
||||
fs.writeFileSync(pidFilePath, `${child.pid}\n`, "utf8");
|
||||
fs.closeSync(logDescriptor);
|
||||
|
||||
if (service.key === "temporal") {
|
||||
await waitForPort(7233, 15_000);
|
||||
} else if (service.key === "backend-api") {
|
||||
await waitForHttp("http://127.0.0.1:8000/healthz", 15_000);
|
||||
} else if (service.key === "frontend") {
|
||||
await waitForHttp("http://127.0.0.1:3000", 20_000);
|
||||
} else {
|
||||
await sleep(1_500);
|
||||
}
|
||||
|
||||
if (!isPidRunning(child.pid)) {
|
||||
throw new Error(`${service.key} exited early. Check ${logFilePath}`);
|
||||
}
|
||||
|
||||
return { key: service.key, pid: child.pid, reused: false };
|
||||
}
|
||||
|
||||
async function startStack() {
|
||||
const frontendRoot = process.cwd();
|
||||
const config = createStackConfig(frontendRoot);
|
||||
const temporalCliPath = getTemporalCliPath();
|
||||
|
||||
if (!temporalCliPath) {
|
||||
throw new Error(
|
||||
"Temporal CLI not found. Install `temporal` or ensure the SDK-downloaded CLI exists under TMPDIR.",
|
||||
);
|
||||
}
|
||||
|
||||
ensureDirectory(config.runtimeRoot);
|
||||
ensureDirectory(config.pidRoot);
|
||||
ensureDirectory(config.logRoot);
|
||||
|
||||
const services = materializeServices(config, temporalCliPath);
|
||||
const startedServices = [];
|
||||
|
||||
try {
|
||||
for (const service of services) {
|
||||
const result = await startService(config, service);
|
||||
startedServices.push(service);
|
||||
const marker = result.reused ? "reused" : "started";
|
||||
console.log(`${service.key}: ${marker} (pid ${result.pid})`);
|
||||
}
|
||||
|
||||
console.log(`frontend: http://127.0.0.1:3000`);
|
||||
console.log(`backend: http://127.0.0.1:8000`);
|
||||
console.log(`temporal: 127.0.0.1:7233`);
|
||||
} catch (error) {
|
||||
for (const service of startedServices.reverse()) {
|
||||
await stopService(config, service);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopStack() {
|
||||
const frontendRoot = process.cwd();
|
||||
const config = createStackConfig(frontendRoot);
|
||||
const temporalCliPath = getTemporalCliPath() || "__TEMPORAL_CLI__";
|
||||
const services = materializeServices(config, temporalCliPath);
|
||||
|
||||
for (const service of [...services].reverse()) {
|
||||
const stopped = await stopService(config, service);
|
||||
console.log(`${service.key}: ${stopped ? "stopped" : "not running"}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function printStatus() {
|
||||
const frontendRoot = process.cwd();
|
||||
const config = createStackConfig(frontendRoot);
|
||||
const temporalCliPath = getTemporalCliPath() || "__TEMPORAL_CLI__";
|
||||
const services = materializeServices(config, temporalCliPath);
|
||||
|
||||
for (const service of services) {
|
||||
const pidFilePath = getServicePidFile(config, service.key);
|
||||
const logFilePath = getServiceLogFile(config, service.key);
|
||||
const pid = readPid(pidFilePath);
|
||||
const running = pid ? isPidRunning(pid) : false;
|
||||
const portText = service.port ? ` port=${service.port}` : "";
|
||||
const logText = fs.existsSync(logFilePath) ? ` log=${logFilePath}` : "";
|
||||
console.log(`${service.key}: ${running ? "running" : "stopped"} pid=${pid ?? "-"}${portText}${logText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function printLogs() {
|
||||
const frontendRoot = process.cwd();
|
||||
const config = createStackConfig(frontendRoot);
|
||||
const temporalCliPath = getTemporalCliPath() || "__TEMPORAL_CLI__";
|
||||
const services = materializeServices(config, temporalCliPath);
|
||||
const serviceFilter = process.env.SERVICE || null;
|
||||
const lineLimit = Number(process.env.LINES || "80");
|
||||
const selectedServices = selectServicesForLogs(services, serviceFilter);
|
||||
|
||||
const entries = selectedServices.map((service) => {
|
||||
const logFilePath = getServiceLogFile(config, service.key);
|
||||
return {
|
||||
key: service.key,
|
||||
logFilePath,
|
||||
lines: readLastLogLines(logFilePath, Number.isFinite(lineLimit) && lineLimit > 0 ? lineLimit : 80),
|
||||
};
|
||||
});
|
||||
|
||||
console.log(formatServiceLogs(entries));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const command = process.argv[2];
|
||||
|
||||
if (command === "start") {
|
||||
await startStack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "stop") {
|
||||
await stopStack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "status") {
|
||||
await printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "logs") {
|
||||
await printLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Usage: node scripts/dev-stack/stack.mjs <start|stop|status|logs>");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
const currentFilePath = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
8
scripts/dev-stack/start.sh
Executable file
8
scripts/dev-stack/start.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
node "${SCRIPT_DIR}/stack.mjs" start
|
||||
8
scripts/dev-stack/status.sh
Executable file
8
scripts/dev-stack/status.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
node "${SCRIPT_DIR}/stack.mjs" status
|
||||
8
scripts/dev-stack/stop.sh
Executable file
8
scripts/dev-stack/stop.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
node "${SCRIPT_DIR}/stack.mjs" stop
|
||||
62
src/components/layout/dashboard-shell.tsx
Normal file
62
src/components/layout/dashboard-shell.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { primaryNavItems } from "@/components/layout/nav-config";
|
||||
|
||||
type DashboardShellProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function DashboardShell({ children }: DashboardShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-transparent px-4 py-5 text-[var(--ink)] md:h-screen md:overflow-hidden md:px-6 md:py-6">
|
||||
<div className="mx-auto grid max-w-7xl gap-4 rounded-[32px] border border-[var(--border-soft)] bg-[var(--bg-elevated)] p-3 shadow-[var(--shadow-shell)] backdrop-blur md:h-full md:grid-cols-[280px_minmax(0,1fr)] md:p-4">
|
||||
<aside
|
||||
aria-label="Dashboard rail"
|
||||
className="flex rounded-[28px] border border-[var(--shell-border)] bg-[var(--shell)] p-6 text-white md:h-full"
|
||||
>
|
||||
<div className="flex min-h-full w-full flex-col">
|
||||
<div>
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.3em] text-white/48">
|
||||
Auto Tryon Ops
|
||||
</p>
|
||||
<h1 className="mt-4 text-2xl font-semibold tracking-[-0.03em] text-white">
|
||||
运营控制台
|
||||
</h1>
|
||||
<p className="mt-3 max-w-[18rem] text-sm leading-6 text-white/66">
|
||||
保持订单、审核与流程追踪在同一套高密度暖色界面里完成。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="mt-8 space-y-1" aria-label="Primary Navigation">
|
||||
{primaryNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between rounded-[18px] border border-transparent px-3.5 py-3 text-sm text-white/80 transition hover:border-white/8 hover:bg-white/7 hover:text-white"
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto rounded-[24px] border border-white/8 bg-white/6 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-white/44">
|
||||
Shared shell
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-white/68">
|
||||
后续任务页面只负责业务模块,布局、节奏和基础视觉都从这里继承。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main
|
||||
aria-label="Dashboard content"
|
||||
className="overflow-hidden rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[inset_0_1px_0_rgba(255,255,255,0.32)] md:h-full md:min-h-0 md:overflow-y-auto"
|
||||
>
|
||||
<div className="px-5 py-6 md:px-8 md:py-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/components/layout/nav-config.ts
Normal file
13
src/components/layout/nav-config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type PrimaryNavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const primaryNavItems: PrimaryNavItem[] = [
|
||||
{ href: "/orders", label: "订单总览" },
|
||||
{ href: "/submit-workbench", label: "提单工作台" },
|
||||
{ href: "/reviews/workbench", label: "审核工作台" },
|
||||
{ href: "/workflows", label: "流程追踪" },
|
||||
{ href: "/libraries/models", label: "资源库" },
|
||||
{ href: "/settings", label: "系统设置" },
|
||||
];
|
||||
76
src/components/ui/button.tsx
Normal file
76
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type ButtonHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
leading?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
|
||||
const VARIANT_STYLES: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"border-transparent bg-[var(--accent-primary)] text-[var(--accent-ink)] shadow-[0_12px_30px_rgba(110,127,82,0.22)] hover:bg-[var(--accent-primary-strong)]",
|
||||
secondary:
|
||||
"border-[var(--border-strong)] bg-[var(--surface)] text-[var(--ink-strong)] hover:bg-[var(--surface-muted)]",
|
||||
ghost:
|
||||
"border-transparent bg-transparent text-[var(--ink-muted)] hover:bg-[var(--surface-muted)] hover:text-[var(--ink-strong)]",
|
||||
danger:
|
||||
"border-transparent bg-[#8c4a43] text-[#fff8f5] shadow-[0_12px_30px_rgba(140,74,67,0.16)] hover:bg-[#7a3d37]",
|
||||
};
|
||||
|
||||
const SIZE_STYLES: Record<ButtonSize, string> = {
|
||||
sm: "min-h-9 rounded-full px-3.5 text-sm",
|
||||
md: "min-h-11 rounded-full px-4 text-sm",
|
||||
lg: "min-h-12 rounded-full px-5 text-base",
|
||||
};
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
leading,
|
||||
size = "md",
|
||||
trailing,
|
||||
type = "button",
|
||||
variant = "primary",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
className={joinClasses(
|
||||
"inline-flex items-center justify-center gap-2 border font-medium tracking-[0.01em] transition duration-150",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
VARIANT_STYLES[variant],
|
||||
SIZE_STYLES[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{leading ? <span aria-hidden="true">{leading}</span> : null}
|
||||
{children}
|
||||
{trailing ? <span aria-hidden="true">{trailing}</span> : null}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
129
src/components/ui/card.tsx
Normal file
129
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type CardProps = HTMLAttributes<HTMLDivElement>;
|
||||
type CardSectionProps = HTMLAttributes<HTMLDivElement>;
|
||||
type CardTitleProps = HTMLAttributes<HTMLHeadingElement>;
|
||||
type CardDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={joinClasses(
|
||||
"rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[var(--shadow-card)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Card.displayName = "Card";
|
||||
|
||||
export const CardHeader = forwardRef<HTMLDivElement, CardSectionProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={joinClasses(
|
||||
"flex flex-col gap-2 border-b border-[var(--border-soft)] px-6 py-5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={joinClasses(
|
||||
"text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
export const CardDescription = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
CardDescriptionProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
className={joinClasses("text-sm leading-6 text-[var(--ink-muted)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
export const CardContent = forwardRef<HTMLDivElement, CardSectionProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={joinClasses("px-6 py-5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export const CardFooter = forwardRef<HTMLDivElement, CardSectionProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={joinClasses(
|
||||
"flex items-center justify-between gap-3 border-t border-[var(--border-soft)] px-6 py-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export function CardEyebrow({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLParagraphElement> & { children: ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={joinClasses(
|
||||
"font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-[var(--ink-faint)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
43
src/components/ui/empty-state.tsx
Normal file
43
src/components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type EmptyStateProps = {
|
||||
actions?: ReactNode;
|
||||
description: string;
|
||||
eyebrow?: string;
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
};
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
actions,
|
||||
description,
|
||||
eyebrow = "No content",
|
||||
icon,
|
||||
title,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-6 py-10 text-center shadow-[inset_0_1px_0_rgba(255,255,255,0.4)]">
|
||||
{icon ? (
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--surface)] text-[var(--ink-muted)] shadow-[var(--shadow-card)]">
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.26em] text-[var(--ink-faint)]">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h3 className="mt-3 text-xl font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm leading-7 text-[var(--ink-muted)]">
|
||||
{description}
|
||||
</p>
|
||||
{actions ? (
|
||||
<div className={joinClasses("mt-6 flex justify-center gap-3")}>{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/ui/page-header.tsx
Normal file
47
src/components/ui/page-header.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type PageHeaderProps = {
|
||||
actions?: ReactNode;
|
||||
description?: ReactNode;
|
||||
eyebrow?: string;
|
||||
meta?: ReactNode;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
export function PageHeader({
|
||||
actions,
|
||||
description,
|
||||
eyebrow = "Dashboard view",
|
||||
meta,
|
||||
title,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-5 border-b border-[var(--border-soft)] pb-6 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-3">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.26em] text-[var(--ink-faint)]">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<div className="max-w-3xl text-sm leading-7 text-[var(--ink-muted)]">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{actions || meta ? (
|
||||
<div className="flex flex-col gap-3 md:items-end">
|
||||
{meta ? (
|
||||
<div className="font-[var(--font-mono)] text-xs uppercase tracking-[0.18em] text-[var(--ink-faint)]">
|
||||
{meta}
|
||||
</div>
|
||||
) : null}
|
||||
{actions ? <div className="flex flex-wrap gap-3">{actions}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/ui/section-title.tsx
Normal file
38
src/components/ui/section-title.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type SectionTitleProps = {
|
||||
action?: ReactNode;
|
||||
description?: ReactNode;
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
export function SectionTitle({
|
||||
action,
|
||||
description,
|
||||
eyebrow,
|
||||
title,
|
||||
}: SectionTitleProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-2">
|
||||
{eyebrow ? (
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-[var(--ink-faint)]">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<div className="text-sm leading-6 text-[var(--ink-muted)]">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{action ? <div className="flex flex-wrap gap-3">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/ui/status-badge.tsx
Normal file
116
src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
import type {
|
||||
OrderStatus,
|
||||
ReviewDecision,
|
||||
StepStatus,
|
||||
WorkflowStepName,
|
||||
} from "@/lib/types/backend";
|
||||
import {
|
||||
ORDER_STATUS_META,
|
||||
REVIEW_DECISION_META,
|
||||
STEP_STATUS_META,
|
||||
WORKFLOW_STEP_META,
|
||||
type StatusMeta,
|
||||
type StatusTone,
|
||||
} from "@/lib/types/status";
|
||||
|
||||
type StatusBadgeVariant =
|
||||
| "order"
|
||||
| "reviewDecision"
|
||||
| "stepStatus"
|
||||
| "workflowStep";
|
||||
|
||||
type StatusBadgeBaseProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
type OrderStatusBadgeProps = StatusBadgeBaseProps & {
|
||||
status: OrderStatus;
|
||||
variant?: "order";
|
||||
};
|
||||
|
||||
type ReviewDecisionBadgeProps = StatusBadgeBaseProps & {
|
||||
status: ReviewDecision;
|
||||
variant: "reviewDecision";
|
||||
};
|
||||
|
||||
type StepStatusBadgeProps = StatusBadgeBaseProps & {
|
||||
status: StepStatus;
|
||||
variant: "stepStatus";
|
||||
};
|
||||
|
||||
type WorkflowStepBadgeProps = StatusBadgeBaseProps & {
|
||||
status: WorkflowStepName | null;
|
||||
variant: "workflowStep";
|
||||
};
|
||||
|
||||
export type StatusBadgeProps =
|
||||
| OrderStatusBadgeProps
|
||||
| ReviewDecisionBadgeProps
|
||||
| StepStatusBadgeProps
|
||||
| WorkflowStepBadgeProps;
|
||||
|
||||
const TONE_STYLES: Record<StatusTone, string> = {
|
||||
neutral:
|
||||
"border-[rgba(76,69,60,0.14)] bg-[rgba(110,98,84,0.08)] text-[var(--ink-muted)]",
|
||||
info: "border-[rgba(57,86,95,0.16)] bg-[rgba(57,86,95,0.1)] text-[#2e4d56]",
|
||||
warning:
|
||||
"border-[rgba(145,104,46,0.18)] bg-[rgba(202,164,97,0.14)] text-[#7a5323]",
|
||||
success:
|
||||
"border-[rgba(88,106,56,0.18)] bg-[rgba(110,127,82,0.14)] text-[#50633b]",
|
||||
danger:
|
||||
"border-[rgba(140,74,67,0.18)] bg-[rgba(140,74,67,0.12)] text-[#7f3f38]",
|
||||
};
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function getMetaFromRecord<T extends string>(
|
||||
record: Record<T, StatusMeta>,
|
||||
status: string,
|
||||
variant: StatusBadgeVariant,
|
||||
): StatusMeta {
|
||||
const meta = record[status as T];
|
||||
|
||||
if (!meta) {
|
||||
throw new Error(`Invalid status "${status}" for variant "${variant}".`);
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
function resolveStatusMeta(props: StatusBadgeProps): StatusMeta {
|
||||
switch (props.variant) {
|
||||
case "reviewDecision":
|
||||
return getMetaFromRecord(REVIEW_DECISION_META, props.status, props.variant);
|
||||
case "stepStatus":
|
||||
return getMetaFromRecord(STEP_STATUS_META, props.status, props.variant);
|
||||
case "workflowStep":
|
||||
if (props.status === null) {
|
||||
return { label: "未开始", tone: "neutral" };
|
||||
}
|
||||
|
||||
return getMetaFromRecord(WORKFLOW_STEP_META, props.status, props.variant);
|
||||
case "order":
|
||||
case undefined:
|
||||
return getMetaFromRecord(ORDER_STATUS_META, props.status, "order");
|
||||
}
|
||||
}
|
||||
|
||||
export function StatusBadge({ className, ...props }: StatusBadgeProps) {
|
||||
const meta = resolveStatusMeta(props);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-tone={meta.tone}
|
||||
className={joinClasses(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.16em]",
|
||||
TONE_STYLES[meta.tone],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
35
src/features/auth/login-placeholder.tsx
Normal file
35
src/features/auth/login-placeholder.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function LoginPlaceholder() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,rgba(208,190,152,0.28),transparent_42%),var(--bg-canvas)] px-6 py-12">
|
||||
<Card className="w-full max-w-xl">
|
||||
<CardHeader>
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Authentication pending</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>登录模块待接入</CardTitle>
|
||||
<CardDescription>
|
||||
当前管理台还没有接入真实认证系统,所以这里不渲染假的用户名密码表单,只说明后续接入边界。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm leading-7 text-[var(--ink-muted)]">
|
||||
<p>后续接入建议:</p>
|
||||
<p>1. 统一由 Next.js BFF 代理会话态。</p>
|
||||
<p>2. 把审核动作、设置页和资源库的权限控制收口到服务端。</p>
|
||||
<p>3. 再决定是否引入单点登录或更细的角色分层。</p>
|
||||
<Link
|
||||
href="/orders"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-[var(--border-strong)] bg-[var(--surface)] px-4 text-sm font-medium text-[var(--ink-strong)] transition hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
返回后台首页
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
194
src/features/libraries/library-page.tsx
Normal file
194
src/features/libraries/library-page.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import type { LibraryItemVM, LibraryType } from "@/lib/types/view-models";
|
||||
|
||||
type LibraryPageProps = {
|
||||
isLoading?: boolean;
|
||||
items: LibraryItemVM[];
|
||||
libraryType: LibraryType;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type LibraryEnvelope = {
|
||||
data?: {
|
||||
items?: LibraryItemVM[];
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const LIBRARY_META: Record<
|
||||
LibraryType,
|
||||
{ title: string; description: string; eyebrow: string }
|
||||
> = {
|
||||
models: {
|
||||
title: "模特库",
|
||||
description: "继续复用提单页所需的 mock 模特数据,同时保留正式资源库的结构和标签体系。",
|
||||
eyebrow: "Model library",
|
||||
},
|
||||
scenes: {
|
||||
title: "场景库",
|
||||
description: "场景库先用 mock 占位数据承接,等真实后台资源列表可用后再切换数据源。",
|
||||
eyebrow: "Scene library",
|
||||
},
|
||||
garments: {
|
||||
title: "服装库",
|
||||
description: "服装资源暂时继续走 mock 资产,但页面本身已经按正式资源库组织。",
|
||||
eyebrow: "Garment library",
|
||||
},
|
||||
};
|
||||
|
||||
const TITLE_MESSAGE = "当前资源库仍使用 mock 数据";
|
||||
const DEFAULT_MESSAGE = "当前只保留页面结构和 mock 资源项,真实资源查询能力将在后端支持后接入。";
|
||||
|
||||
export function LibraryPage({
|
||||
isLoading = false,
|
||||
items,
|
||||
libraryType,
|
||||
message = DEFAULT_MESSAGE,
|
||||
}: LibraryPageProps) {
|
||||
const meta = LIBRARY_META[libraryType];
|
||||
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
<PageHeader
|
||||
eyebrow={meta.eyebrow}
|
||||
title={meta.title}
|
||||
description={meta.description}
|
||||
meta="正式占位模块"
|
||||
/>
|
||||
|
||||
<div className="rounded-[28px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-6 py-5">
|
||||
<p className="text-lg font-semibold tracking-[-0.02em] text-[#7a5323]">
|
||||
{TITLE_MESSAGE}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-7 text-[#7a5323]">{message}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Library inventory</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{meta.title}占位清单</CardTitle>
|
||||
<CardDescription>
|
||||
每个资源条目都保留预览地址、说明和标签,后续只需要替换 BFF 数据源。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
|
||||
正在加载资源库数据…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && items.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-6 text-[var(--ink-muted)]">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<code className="block rounded-[18px] bg-[rgba(74,64,53,0.08)] px-3 py-3 text-xs leading-6 text-[var(--ink-muted)]">
|
||||
{item.previewUri}
|
||||
</code>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-[rgba(110,98,84,0.08)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !items.length ? (
|
||||
<EmptyState
|
||||
eyebrow="Library empty"
|
||||
title="暂无资源条目"
|
||||
description="当前库还没有占位数据,等后端或运营资源列表准备好后再补齐。"
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LibraryPageScreen({ libraryType }: { libraryType: LibraryType }) {
|
||||
const [items, setItems] = useState<LibraryItemVM[]>([]);
|
||||
const [message, setMessage] = useState(
|
||||
"当前资源库仍使用 mock 数据,真实资源查询能力将在后端支持后接入。",
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadLibrary() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/libraries/${libraryType}`);
|
||||
const payload = (await response.json()) as LibraryEnvelope;
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems(payload.data?.items ?? []);
|
||||
setMessage(
|
||||
payload.message ??
|
||||
"当前资源库仍使用 mock 数据,真实资源查询能力将在后端支持后接入。",
|
||||
);
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems([]);
|
||||
setMessage("资源库数据加载失败,请稍后重试。");
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadLibrary();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [libraryType]);
|
||||
|
||||
return (
|
||||
<LibraryPage
|
||||
isLoading={isLoading}
|
||||
items={items}
|
||||
libraryType={libraryType}
|
||||
message={message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
201
src/features/orders/components/create-order-form.tsx
Normal file
201
src/features/orders/components/create-order-form.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardEyebrow,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { OrderSummaryCard } from "@/features/orders/components/order-summary-card";
|
||||
import { ResourcePickerCard } from "@/features/orders/components/resource-picker-card";
|
||||
import {
|
||||
SERVICE_MODE_LABELS,
|
||||
type ModelPickerOption,
|
||||
type ResourcePickerOption,
|
||||
} from "@/features/orders/resource-picker-options";
|
||||
import type {
|
||||
CustomerLevel,
|
||||
ServiceMode,
|
||||
} from "@/lib/types/backend";
|
||||
|
||||
type SubmissionSuccess = {
|
||||
orderId: number;
|
||||
workflowId: string | null;
|
||||
};
|
||||
|
||||
export type CreateOrderFormValues = {
|
||||
customerLevel: CustomerLevel;
|
||||
garmentId: string;
|
||||
modelId: string;
|
||||
sceneId: string;
|
||||
serviceMode: ServiceMode;
|
||||
};
|
||||
|
||||
type CreateOrderFormProps = {
|
||||
allowedServiceMode: ServiceMode;
|
||||
garments: ResourcePickerOption[];
|
||||
isLoadingResources: boolean;
|
||||
isSubmitting: boolean;
|
||||
models: ModelPickerOption[];
|
||||
scenes: ResourcePickerOption[];
|
||||
submissionSuccess: SubmissionSuccess | null;
|
||||
submitError: string | null;
|
||||
value: CreateOrderFormValues;
|
||||
onCustomerLevelChange: (value: CustomerLevel) => void;
|
||||
onGarmentChange: (value: string) => void;
|
||||
onModelChange: (value: string) => void;
|
||||
onSceneChange: (value: string) => void;
|
||||
onServiceModeChange: (value: ServiceMode) => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function CreateOrderForm({
|
||||
allowedServiceMode,
|
||||
garments,
|
||||
isLoadingResources,
|
||||
isSubmitting,
|
||||
models,
|
||||
scenes,
|
||||
submissionSuccess,
|
||||
submitError,
|
||||
value,
|
||||
onCustomerLevelChange,
|
||||
onGarmentChange,
|
||||
onModelChange,
|
||||
onSceneChange,
|
||||
onServiceModeChange,
|
||||
onSubmit,
|
||||
}: CreateOrderFormProps) {
|
||||
const selectedModel = models.find((item) => item.id === value.modelId) ?? null;
|
||||
const selectedScene = scenes.find((item) => item.id === value.sceneId) ?? null;
|
||||
const selectedGarment =
|
||||
garments.find((item) => item.id === value.garmentId) ?? null;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.85fr)]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardEyebrow>Business inputs</CardEyebrow>
|
||||
<CardTitle>订单参数</CardTitle>
|
||||
<CardDescription>
|
||||
先确定客户层级,再由表单自动约束允许的服务模式。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||
<span className="font-medium">客户层级</span>
|
||||
<select
|
||||
aria-label="客户层级"
|
||||
className={joinClasses(
|
||||
"min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)]",
|
||||
"focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]",
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
value={value.customerLevel}
|
||||
onChange={(event) =>
|
||||
onCustomerLevelChange(event.target.value as CustomerLevel)
|
||||
}
|
||||
>
|
||||
<option value="low">低客单 low</option>
|
||||
<option value="mid">中客单 mid</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||
<span className="font-medium">服务模式</span>
|
||||
<select
|
||||
aria-label="服务模式"
|
||||
className={joinClasses(
|
||||
"min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)]",
|
||||
"focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]",
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
value={value.serviceMode}
|
||||
onChange={(event) =>
|
||||
onServiceModeChange(event.target.value as ServiceMode)
|
||||
}
|
||||
>
|
||||
<option
|
||||
disabled={allowedServiceMode !== "auto_basic"}
|
||||
value="auto_basic"
|
||||
>
|
||||
{SERVICE_MODE_LABELS.auto_basic} auto_basic
|
||||
</option>
|
||||
<option
|
||||
disabled={allowedServiceMode !== "semi_pro"}
|
||||
value="semi_pro"
|
||||
>
|
||||
{SERVICE_MODE_LABELS.semi_pro} semi_pro
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<ResourcePickerCard
|
||||
description="使用资源库占位数据挑选模特,提交时会映射到真实后端 ID 与 pose_id。"
|
||||
disabled={isSubmitting}
|
||||
isLoading={isLoadingResources}
|
||||
items={models}
|
||||
label="模特资源"
|
||||
title="模特"
|
||||
value={value.modelId}
|
||||
onChange={onModelChange}
|
||||
/>
|
||||
<ResourcePickerCard
|
||||
description="场景仍来自 mock 数据,但通过 BFF 路由加载,保持页面集成方式真实。"
|
||||
disabled={isSubmitting}
|
||||
isLoading={isLoadingResources}
|
||||
items={scenes}
|
||||
label="场景资源"
|
||||
title="场景"
|
||||
value={value.sceneId}
|
||||
onChange={onSceneChange}
|
||||
/>
|
||||
<ResourcePickerCard
|
||||
description="服装选择沿用当前资源库占位素材,为订单创建页提供稳定联调入口。"
|
||||
disabled={isSubmitting}
|
||||
isLoading={isLoadingResources}
|
||||
items={garments}
|
||||
label="服装资源"
|
||||
title="服装"
|
||||
value={value.garmentId}
|
||||
onChange={onGarmentChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button disabled={isLoadingResources || isSubmitting} type="submit">
|
||||
{isSubmitting ? "提交中..." : "提交订单"}
|
||||
</Button>
|
||||
<p className="text-sm text-[var(--ink-muted)]">
|
||||
提交只负责创建订单,不承载审核或流程追踪行为。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrderSummaryCard
|
||||
customerLevel={value.customerLevel}
|
||||
garment={selectedGarment}
|
||||
model={selectedModel}
|
||||
scene={selectedScene}
|
||||
serviceMode={value.serviceMode}
|
||||
submitError={submitError}
|
||||
submissionSuccess={submissionSuccess}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
109
src/features/orders/components/order-assets-panel.tsx
Normal file
109
src/features/orders/components/order-assets-panel.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import type { AssetViewModel, OrderDetailVM } from "@/lib/types/view-models";
|
||||
|
||||
type OrderAssetsPanelProps = {
|
||||
viewModel: OrderDetailVM;
|
||||
};
|
||||
|
||||
function renderAssetCard(asset: AssetViewModel) {
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">{asset.label}</p>
|
||||
<p className="text-xs text-[var(--ink-muted)]">{asset.stepLabel}</p>
|
||||
</div>
|
||||
{asset.isMock ? (
|
||||
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[#7a5323]">
|
||||
Mock
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<code className="mt-4 block rounded-[20px] bg-[rgba(74,64,53,0.08)] px-3 py-3 text-xs leading-6 text-[var(--ink-muted)]">
|
||||
{asset.uri}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
|
||||
const finalAssetTitle =
|
||||
viewModel.finalAssetState.kind === "business-empty"
|
||||
? viewModel.finalAssetState.title
|
||||
: "最终图暂未生成";
|
||||
const finalAssetDescription =
|
||||
viewModel.finalAssetState.kind === "business-empty"
|
||||
? viewModel.finalAssetState.description
|
||||
: "当前订单还没有可展示的最终结果。";
|
||||
const galleryEmptyTitle =
|
||||
viewModel.assetGalleryState.kind === "business-empty"
|
||||
? viewModel.assetGalleryState.title
|
||||
: "暂无资产";
|
||||
const galleryEmptyDescription =
|
||||
viewModel.assetGalleryState.kind === "business-empty"
|
||||
? viewModel.assetGalleryState.description
|
||||
: "当前订单还没有生成可查看的资产列表。";
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Asset gallery</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>结果图与过程资产</CardTitle>
|
||||
<CardDescription>
|
||||
订单详情页负责汇总最终图和过程产物,帮助运营复盘,不在这里做审核提交。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{viewModel.hasMockAssets ? (
|
||||
<div className="rounded-[24px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-5 py-4 text-sm text-[#7a5323]">
|
||||
当前资产来自 mock 流程
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">最终结果</p>
|
||||
<p className="text-sm text-[var(--ink-muted)]">
|
||||
以最终图为空态为准,不把后端未产出的内容伪装成失败。
|
||||
</p>
|
||||
</div>
|
||||
{viewModel.finalAsset ? (
|
||||
renderAssetCard(viewModel.finalAsset)
|
||||
) : (
|
||||
<EmptyState
|
||||
eyebrow="Final asset empty"
|
||||
title={finalAssetTitle}
|
||||
description={finalAssetDescription}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">过程资产</p>
|
||||
<p className="text-sm text-[var(--ink-muted)]">
|
||||
按步骤产物聚合展示,便于追查哪一环引入了 mock 或异常结果。
|
||||
</p>
|
||||
</div>
|
||||
{viewModel.assets.length ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">{viewModel.assets.map(renderAssetCard)}</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
eyebrow="Gallery empty"
|
||||
title={galleryEmptyTitle}
|
||||
description={galleryEmptyDescription}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
80
src/features/orders/components/order-detail-header.tsx
Normal file
80
src/features/orders/components/order-detail-header.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { OrderDetailVM } from "@/lib/types/view-models";
|
||||
|
||||
type OrderDetailHeaderProps = {
|
||||
viewModel: OrderDetailVM;
|
||||
};
|
||||
|
||||
function formatTimestamp(timestamp: string) {
|
||||
return timestamp.replace("T", " ").replace("Z", " UTC");
|
||||
}
|
||||
|
||||
function formatCustomerLevel(level: OrderDetailVM["customerLevel"]) {
|
||||
return level === "mid" ? "Mid 客户" : "Low 客户";
|
||||
}
|
||||
|
||||
function formatServiceMode(mode: OrderDetailVM["serviceMode"]) {
|
||||
return mode === "semi_pro" ? "Semi Pro" : "Auto Basic";
|
||||
}
|
||||
|
||||
export function OrderDetailHeader({ viewModel }: OrderDetailHeaderProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="Order detail"
|
||||
title={`订单 #${viewModel.orderId}`}
|
||||
description="订单详情页只读展示核心参数、结果图和流程入口,不承接审核动作。"
|
||||
meta={`更新于 ${formatTimestamp(viewModel.updatedAt)}`}
|
||||
actions={
|
||||
viewModel.workflowId ? (
|
||||
<Link
|
||||
href={`/workflows/${viewModel.orderId}`}
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-[var(--border-strong)] bg-[var(--surface)] px-4 text-sm font-medium text-[var(--ink-strong)] transition hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
查看流程详情
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Order status
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<StatusBadge status={viewModel.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Customer level
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
{formatCustomerLevel(viewModel.customerLevel)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Service mode
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
{formatServiceMode(viewModel.serviceMode)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Current step
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatusBadge variant="workflowStep" status={viewModel.currentStep} />
|
||||
<span className="text-sm text-[var(--ink-muted)]">{viewModel.currentStepLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/features/orders/components/order-summary-card.tsx
Normal file
124
src/features/orders/components/order-summary-card.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardEyebrow,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
CUSTOMER_LEVEL_LABELS,
|
||||
SERVICE_MODE_LABELS,
|
||||
type ModelPickerOption,
|
||||
type ResourcePickerOption,
|
||||
} from "@/features/orders/resource-picker-options";
|
||||
import type {
|
||||
CustomerLevel,
|
||||
ServiceMode,
|
||||
} from "@/lib/types/backend";
|
||||
|
||||
type SubmissionSuccess = {
|
||||
orderId: number;
|
||||
workflowId: string | null;
|
||||
};
|
||||
|
||||
type OrderSummaryCardProps = {
|
||||
customerLevel: CustomerLevel;
|
||||
model: ModelPickerOption | null;
|
||||
scene: ResourcePickerOption | null;
|
||||
garment: ResourcePickerOption | null;
|
||||
serviceMode: ServiceMode;
|
||||
submitError: string | null;
|
||||
submissionSuccess: SubmissionSuccess | null;
|
||||
};
|
||||
|
||||
type SummaryRowProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function SummaryRow({ label, value }: SummaryRowProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--border-soft)] py-3 last:border-b-0 last:pb-0">
|
||||
<dt className="text-sm text-[var(--ink-muted)]">{label}</dt>
|
||||
<dd className="text-right text-sm font-medium text-[var(--ink-strong)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrderSummaryCard({
|
||||
customerLevel,
|
||||
garment,
|
||||
model,
|
||||
scene,
|
||||
serviceMode,
|
||||
submitError,
|
||||
submissionSuccess,
|
||||
}: OrderSummaryCardProps) {
|
||||
return (
|
||||
<Card
|
||||
aria-label="提单摘要"
|
||||
role="region"
|
||||
className="sticky top-6 h-fit"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardEyebrow>Submission summary</CardEyebrow>
|
||||
<CardTitle>提单摘要</CardTitle>
|
||||
<CardDescription>
|
||||
这里显示将发送到 BFF 的业务组合与资源映射。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<dl>
|
||||
<SummaryRow
|
||||
label="客户层级"
|
||||
value={CUSTOMER_LEVEL_LABELS[customerLevel]}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="服务模式"
|
||||
value={`${SERVICE_MODE_LABELS[serviceMode]} ${serviceMode}`}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="模特资源"
|
||||
value={model ? model.name : "待选择"}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="场景资源"
|
||||
value={scene ? scene.name : "待选择"}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="服装资源"
|
||||
value={garment ? garment.name : "待选择"}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="提交映射"
|
||||
value={
|
||||
model && scene && garment
|
||||
? `model ${model.backendId} / pose ${model.poseId} / scene ${scene.backendId} / garment ${garment.backendId}`
|
||||
: "完成选择后显示"
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{submitError ? (
|
||||
<div className="rounded-[20px] border border-[#b88472] bg-[#f8ece5] px-4 py-3 text-sm text-[#7f4b3b]">
|
||||
{submitError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{submissionSuccess ? (
|
||||
<div className="space-y-2 rounded-[20px] border border-[var(--accent-ring)] bg-[var(--accent-soft)] px-4 py-4 text-sm text-[var(--accent-ink)]">
|
||||
<p className="font-semibold">
|
||||
订单 #{submissionSuccess.orderId} 已创建,正在跳转到详情页。
|
||||
</p>
|
||||
<p>
|
||||
工作流 ID {submissionSuccess.workflowId ?? "未返回"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
56
src/features/orders/components/order-workflow-card.tsx
Normal file
56
src/features/orders/components/order-workflow-card.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { OrderDetailVM } from "@/lib/types/view-models";
|
||||
|
||||
type OrderWorkflowCardProps = {
|
||||
viewModel: OrderDetailVM;
|
||||
};
|
||||
|
||||
export function OrderWorkflowCard({ viewModel }: OrderWorkflowCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Workflow linkage</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>流程摘要</CardTitle>
|
||||
<CardDescription>
|
||||
详情页只保留当前流程入口和关键状态,完整时间线单独放在流程详情页。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Workflow ID
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
{viewModel.workflowId ?? "暂未分配"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Current step
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatusBadge variant="workflowStep" status={viewModel.currentStep} />
|
||||
<span className="text-sm text-[var(--ink-muted)]">{viewModel.currentStepLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewModel.workflowId ? (
|
||||
<Link
|
||||
href={`/workflows/${viewModel.orderId}`}
|
||||
className="inline-flex min-h-11 w-full items-center justify-center rounded-full border border-[var(--border-strong)] bg-[var(--surface)] px-4 text-sm font-medium text-[var(--ink-strong)] transition hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
打开流程时间线
|
||||
</Link>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
96
src/features/orders/components/resource-picker-card.tsx
Normal file
96
src/features/orders/components/resource-picker-card.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardEyebrow,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { ResourcePickerOption } from "@/features/orders/resource-picker-options";
|
||||
|
||||
type ResourcePickerCardProps = {
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
items: ResourcePickerOption[];
|
||||
label: string;
|
||||
title: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function ResourcePickerCard({
|
||||
description,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
items,
|
||||
label,
|
||||
title,
|
||||
value,
|
||||
onChange,
|
||||
}: ResourcePickerCardProps) {
|
||||
const selectedItem = items.find((item) => item.id === value) ?? null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardEyebrow>Mock backed selector</CardEyebrow>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||
<span className="font-medium">{label}</span>
|
||||
<select
|
||||
aria-label={label}
|
||||
className={joinClasses(
|
||||
"min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)]",
|
||||
"focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]",
|
||||
)}
|
||||
disabled={disabled || isLoading}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{isLoading ? "正在加载占位资源..." : "请选择一个资源"}
|
||||
</option>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedItem ? (
|
||||
<div className="rounded-[20px] border border-[var(--border-soft)] bg-[var(--surface-muted)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
||||
{selectedItem.name}
|
||||
</p>
|
||||
<p className="text-sm leading-6 text-[var(--ink-muted)]">
|
||||
{selectedItem.description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-[var(--accent-soft)] px-3 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--accent-ink)]">
|
||||
{selectedItem.isMock ? "mock" : "live"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 font-[var(--font-mono)] text-xs text-[var(--ink-faint)]">
|
||||
{selectedItem.previewUri}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm leading-6 text-[var(--ink-muted)]">
|
||||
选择后会在摘要卡中同步显示资源名称与提交 ID。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
107
src/features/orders/order-detail.tsx
Normal file
107
src/features/orders/order-detail.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { OrderAssetsPanel } from "@/features/orders/components/order-assets-panel";
|
||||
import { OrderDetailHeader } from "@/features/orders/components/order-detail-header";
|
||||
import { OrderWorkflowCard } from "@/features/orders/components/order-workflow-card";
|
||||
import type { OrderDetailVM } from "@/lib/types/view-models";
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
data?: T;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type OrderDetailProps = {
|
||||
viewModel: OrderDetailVM;
|
||||
};
|
||||
|
||||
type OrderDetailScreenProps = {
|
||||
orderId: number;
|
||||
};
|
||||
|
||||
async function parseEnvelope<T>(response: Response): Promise<ApiEnvelope<T>> {
|
||||
return (await response.json()) as ApiEnvelope<T>;
|
||||
}
|
||||
|
||||
export function OrderDetail({ viewModel }: OrderDetailProps) {
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
<OrderDetailHeader viewModel={viewModel} />
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<OrderAssetsPanel viewModel={viewModel} />
|
||||
<OrderWorkflowCard viewModel={viewModel} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrderDetailScreen({ orderId }: OrderDetailScreenProps) {
|
||||
const [viewModel, setViewModel] = useState<OrderDetailVM | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadOrderDetail() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/orders/${orderId}`);
|
||||
const payload = await parseEnvelope<OrderDetailVM>(response);
|
||||
|
||||
if (!response.ok || !payload.data) {
|
||||
throw new Error(payload.message ?? "ORDER_DETAIL_LOAD_FAILED");
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setViewModel(payload.data);
|
||||
setError(null);
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setViewModel(null);
|
||||
setError("订单详情加载失败,请稍后重试。");
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadOrderDetail();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [orderId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-6 py-10 text-sm text-[var(--ink-muted)]">
|
||||
正在加载订单详情…
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !viewModel) {
|
||||
return (
|
||||
<EmptyState
|
||||
eyebrow="Order detail error"
|
||||
title="订单详情暂时不可用"
|
||||
description={error ?? "当前订单详情还无法展示,请稍后重试。"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrderDetail viewModel={viewModel} />;
|
||||
}
|
||||
377
src/features/orders/orders-home.tsx
Normal file
377
src/features/orders/orders-home.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { ORDER_STATUS_META } from "@/lib/types/status";
|
||||
import type { OrderStatus } from "@/lib/types/backend";
|
||||
import type { OrderSummaryVM } from "@/lib/types/view-models";
|
||||
|
||||
type FilterStatus = OrderStatus | "all";
|
||||
type PaginationData = {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type OrdersHomeProps = {
|
||||
currentPage?: number;
|
||||
isLoadingRecent?: boolean;
|
||||
message?: string;
|
||||
onOpenOrder?: (orderId: string) => void;
|
||||
onOpenWorkflow?: (orderId: string) => void;
|
||||
onPageChange?: (page: number) => void;
|
||||
onQuerySubmit?: (query: string) => void;
|
||||
onStatusChange?: (status: FilterStatus) => void;
|
||||
recentOrders: OrderSummaryVM[];
|
||||
selectedQuery?: string;
|
||||
selectedStatus?: FilterStatus;
|
||||
totalPages?: number;
|
||||
};
|
||||
|
||||
type OrdersOverviewEnvelope = {
|
||||
data?: {
|
||||
limit?: number;
|
||||
items?: OrderSummaryVM[];
|
||||
page?: number;
|
||||
total?: number;
|
||||
totalPages?: number;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const TITLE_MESSAGE = "最近订单已接入真实后端接口";
|
||||
const DEFAULT_MESSAGE = "当前首页展示真实最近订单,同时保留订单号直达入口,方便快速跳转详情和流程。";
|
||||
const DEFAULT_PAGINATION: PaginationData = {
|
||||
page: 1,
|
||||
limit: 6,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
};
|
||||
const ORDER_STATUS_FILTER_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: FilterStatus;
|
||||
}> = [
|
||||
{ value: "all", label: "全部状态" },
|
||||
...Object.entries(ORDER_STATUS_META).map(([value, meta]) => ({
|
||||
value: value as OrderStatus,
|
||||
label: meta.label,
|
||||
})),
|
||||
];
|
||||
|
||||
function formatTimestamp(timestamp: string) {
|
||||
return timestamp.replace("T", " ").replace("Z", " UTC");
|
||||
}
|
||||
|
||||
export function OrdersHome({
|
||||
currentPage = 1,
|
||||
isLoadingRecent = false,
|
||||
message = DEFAULT_MESSAGE,
|
||||
onOpenOrder,
|
||||
onOpenWorkflow,
|
||||
onPageChange,
|
||||
onQuerySubmit,
|
||||
onStatusChange,
|
||||
recentOrders,
|
||||
selectedQuery = "",
|
||||
selectedStatus = "all",
|
||||
totalPages = 0,
|
||||
}: OrdersHomeProps) {
|
||||
const [lookupValue, setLookupValue] = useState("");
|
||||
const [queryValue, setQueryValue] = useState(selectedQuery);
|
||||
const normalizedLookup = lookupValue.trim();
|
||||
const canLookup = /^\d+$/.test(normalizedLookup);
|
||||
const effectiveTotalPages = Math.max(totalPages, 1);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryValue(selectedQuery);
|
||||
}, [selectedQuery]);
|
||||
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
<PageHeader
|
||||
eyebrow="Orders home"
|
||||
title="订单总览"
|
||||
description="这里是后台首页入口,当前已经接入真实最近订单列表,并保留订单号直达入口方便快速处理。"
|
||||
meta="真实列表入口"
|
||||
/>
|
||||
|
||||
<div className="rounded-[28px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-6 py-5">
|
||||
<p className="text-lg font-semibold tracking-[-0.02em] text-[#7a5323]">
|
||||
{TITLE_MESSAGE}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-7 text-[#7a5323]">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Direct lookup</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>订单号直达</CardTitle>
|
||||
<CardDescription>
|
||||
保留订单号和流程号的直接入口,适合在列表之外快速跳转到指定订单。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-[var(--ink-strong)]">
|
||||
订单号
|
||||
</span>
|
||||
<input
|
||||
value={lookupValue}
|
||||
onChange={(event) => setLookupValue(event.target.value)}
|
||||
placeholder="输入订单号,例如 4201"
|
||||
className="min-h-12 w-full rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
disabled={!canLookup}
|
||||
onClick={() => onOpenOrder?.(normalizedLookup)}
|
||||
>
|
||||
打开订单详情
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!canLookup}
|
||||
onClick={() => onOpenWorkflow?.(normalizedLookup)}
|
||||
>
|
||||
打开流程详情
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Recent visits</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>最近访问</CardTitle>
|
||||
<CardDescription>
|
||||
这里已经接入真实后端最近订单列表,页面结构继续沿用首版设计。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 lg:min-w-[360px]">
|
||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||
<span className="font-medium">订单关键词搜索</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
aria-label="订单关键词搜索"
|
||||
value={queryValue}
|
||||
onChange={(event) => setQueryValue(event.target.value)}
|
||||
placeholder="搜索订单号或 workflow_id"
|
||||
className="min-h-12 flex-1 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)] focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
<Button onClick={() => onQuerySubmit?.(queryValue.trim())}>
|
||||
搜索订单
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||
<span className="font-medium">订单状态筛选</span>
|
||||
<select
|
||||
aria-label="订单状态筛选"
|
||||
className="min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
value={selectedStatus}
|
||||
onChange={(event) =>
|
||||
onStatusChange?.(event.target.value as FilterStatus)
|
||||
}
|
||||
>
|
||||
{ORDER_STATUS_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{isLoadingRecent ? (
|
||||
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
|
||||
正在加载最近访问记录…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoadingRecent && recentOrders.length ? (
|
||||
recentOrders.map((order) => (
|
||||
<div
|
||||
key={order.orderId}
|
||||
className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
||||
订单 #{order.orderId}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
工作流 {order.workflowId ?? "未关联"}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={order.status} />
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-[var(--ink-muted)]">
|
||||
<StatusBadge variant="workflowStep" status={order.currentStep} />
|
||||
<span>{order.currentStepLabel}</span>
|
||||
<span>{formatTimestamp(order.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : null}
|
||||
|
||||
{!isLoadingRecent && !recentOrders.length ? (
|
||||
<EmptyState
|
||||
eyebrow="No recent orders"
|
||||
title="暂无最近访问记录"
|
||||
description="当前筛选条件下还没有可展示的订单。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--border-soft)] pt-3">
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
第 {Math.min(currentPage, effectiveTotalPages)} / {effectiveTotalPages} 页
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => onPageChange?.(currentPage - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage >= effectiveTotalPages}
|
||||
onClick={() => onPageChange?.(currentPage + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrdersHomeScreen() {
|
||||
const router = useRouter();
|
||||
const [recentOrders, setRecentOrders] = useState<OrderSummaryVM[]>([]);
|
||||
const [message, setMessage] = useState(DEFAULT_MESSAGE);
|
||||
const [isLoadingRecent, setIsLoadingRecent] = useState(true);
|
||||
const [selectedQuery, setSelectedQuery] = useState("");
|
||||
const [selectedStatus, setSelectedStatus] = useState<FilterStatus>("all");
|
||||
const [pagination, setPagination] = useState<PaginationData>(DEFAULT_PAGINATION);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadRecentOrders() {
|
||||
setIsLoadingRecent(true);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(pagination.page),
|
||||
limit: String(pagination.limit),
|
||||
});
|
||||
|
||||
if (selectedStatus !== "all") {
|
||||
params.set("status", selectedStatus);
|
||||
}
|
||||
|
||||
if (selectedQuery.length > 0) {
|
||||
params.set("query", selectedQuery);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/dashboard/orders-overview?${params.toString()}`);
|
||||
const payload = (await response.json()) as OrdersOverviewEnvelope;
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRecentOrders(payload.data?.items ?? []);
|
||||
setPagination((current) => ({
|
||||
page: payload.data?.page ?? current.page,
|
||||
limit: payload.data?.limit ?? current.limit,
|
||||
total: payload.data?.total ?? current.total,
|
||||
totalPages: payload.data?.totalPages ?? current.totalPages,
|
||||
}));
|
||||
setMessage(payload.message ?? DEFAULT_MESSAGE);
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRecentOrders([]);
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
}));
|
||||
setMessage("最近访问记录加载失败,请稍后重试。");
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoadingRecent(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadRecentOrders();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [pagination.limit, pagination.page, selectedQuery, selectedStatus]);
|
||||
|
||||
return (
|
||||
<OrdersHome
|
||||
currentPage={pagination.page}
|
||||
isLoadingRecent={isLoadingRecent}
|
||||
message={message}
|
||||
onPageChange={(page) =>
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
page,
|
||||
}))
|
||||
}
|
||||
onQuerySubmit={(query) => {
|
||||
setSelectedQuery(query);
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
page: 1,
|
||||
}));
|
||||
}}
|
||||
recentOrders={recentOrders}
|
||||
onStatusChange={(status) => {
|
||||
setSelectedStatus(status);
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
page: 1,
|
||||
}));
|
||||
}}
|
||||
onOpenOrder={(orderId) => router.push(`/orders/${orderId}`)}
|
||||
onOpenWorkflow={(orderId) => router.push(`/workflows/${orderId}`)}
|
||||
selectedQuery={selectedQuery}
|
||||
selectedStatus={selectedStatus}
|
||||
totalPages={pagination.totalPages}
|
||||
/>
|
||||
);
|
||||
}
|
||||
104
src/features/orders/resource-picker-options.ts
Normal file
104
src/features/orders/resource-picker-options.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type {
|
||||
CustomerLevel,
|
||||
ServiceMode,
|
||||
} from "@/lib/types/backend";
|
||||
import type { LibraryItemVM } from "@/lib/types/view-models";
|
||||
|
||||
export type ResourcePickerOption = LibraryItemVM & {
|
||||
backendId: number;
|
||||
};
|
||||
|
||||
export type ModelPickerOption = ResourcePickerOption & {
|
||||
poseId: number;
|
||||
};
|
||||
|
||||
type ResourceBinding = {
|
||||
backendId: number;
|
||||
poseId?: number;
|
||||
};
|
||||
|
||||
const RESOURCE_BINDINGS: Record<string, ResourceBinding> = {
|
||||
"model-ava": {
|
||||
backendId: 101,
|
||||
poseId: 202,
|
||||
},
|
||||
"model-jian": {
|
||||
backendId: 102,
|
||||
poseId: 203,
|
||||
},
|
||||
"scene-loft": {
|
||||
backendId: 404,
|
||||
},
|
||||
"scene-garden": {
|
||||
backendId: 405,
|
||||
},
|
||||
"garment-coat-01": {
|
||||
backendId: 303,
|
||||
},
|
||||
"garment-dress-03": {
|
||||
backendId: 304,
|
||||
},
|
||||
};
|
||||
|
||||
export const CUSTOMER_LEVEL_LABELS: Record<CustomerLevel, string> = {
|
||||
low: "低客单",
|
||||
mid: "中客单",
|
||||
};
|
||||
|
||||
export const SERVICE_MODE_LABELS: Record<ServiceMode, string> = {
|
||||
auto_basic: "自动基础处理",
|
||||
semi_pro: "半人工专业处理",
|
||||
};
|
||||
|
||||
export const SERVICE_MODE_BY_CUSTOMER_LEVEL: Record<CustomerLevel, ServiceMode> =
|
||||
{
|
||||
low: "auto_basic",
|
||||
mid: "semi_pro",
|
||||
};
|
||||
|
||||
function getResourceBinding(resourceId: string) {
|
||||
return RESOURCE_BINDINGS[resourceId] ?? null;
|
||||
}
|
||||
|
||||
export function getServiceModeForCustomerLevel(
|
||||
customerLevel: CustomerLevel,
|
||||
): ServiceMode {
|
||||
return SERVICE_MODE_BY_CUSTOMER_LEVEL[customerLevel];
|
||||
}
|
||||
|
||||
export function mapModelOptions(items: LibraryItemVM[]): ModelPickerOption[] {
|
||||
return items.flatMap((item) => {
|
||||
const binding = getResourceBinding(item.id);
|
||||
|
||||
if (!binding?.poseId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...item,
|
||||
backendId: binding.backendId,
|
||||
poseId: binding.poseId,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function mapResourceOptions(
|
||||
items: LibraryItemVM[],
|
||||
): ResourcePickerOption[] {
|
||||
return items.flatMap((item) => {
|
||||
const binding = getResourceBinding(item.id);
|
||||
|
||||
if (!binding) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...item,
|
||||
backendId: binding.backendId,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
263
src/features/orders/submit-workbench.tsx
Normal file
263
src/features/orders/submit-workbench.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
startTransition,
|
||||
} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { CreateOrderForm, type CreateOrderFormValues } from "@/features/orders/components/create-order-form";
|
||||
import {
|
||||
getServiceModeForCustomerLevel,
|
||||
mapModelOptions,
|
||||
mapResourceOptions,
|
||||
type ModelPickerOption,
|
||||
type ResourcePickerOption,
|
||||
} from "@/features/orders/resource-picker-options";
|
||||
import type {
|
||||
CustomerLevel,
|
||||
ServiceMode,
|
||||
} from "@/lib/types/backend";
|
||||
import type { LibraryItemVM } from "@/lib/types/view-models";
|
||||
|
||||
type LibraryResponse = {
|
||||
data?: {
|
||||
items?: LibraryItemVM[];
|
||||
};
|
||||
};
|
||||
|
||||
type SubmissionSuccess = {
|
||||
orderId: number;
|
||||
workflowId: string | null;
|
||||
};
|
||||
|
||||
type CreateOrderResponse = {
|
||||
data?: {
|
||||
orderId?: number;
|
||||
workflowId?: string | null;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const INITIAL_FORM_VALUES: CreateOrderFormValues = {
|
||||
customerLevel: "mid",
|
||||
garmentId: "",
|
||||
modelId: "",
|
||||
sceneId: "",
|
||||
serviceMode: "semi_pro",
|
||||
};
|
||||
|
||||
async function fetchLibraryItems(libraryType: "models" | "scenes" | "garments") {
|
||||
const response = await fetch(`/api/libraries/${libraryType}`);
|
||||
const payload = (await response.json()) as LibraryResponse;
|
||||
|
||||
if (!response.ok || !payload.data?.items) {
|
||||
throw new Error("RESOURCE_LOAD_FAILED");
|
||||
}
|
||||
|
||||
return payload.data.items;
|
||||
}
|
||||
|
||||
export function SubmitWorkbench() {
|
||||
const router = useRouter();
|
||||
const [formValues, setFormValues] =
|
||||
useState<CreateOrderFormValues>(INITIAL_FORM_VALUES);
|
||||
const [models, setModels] = useState<ModelPickerOption[]>([]);
|
||||
const [scenes, setScenes] = useState<ResourcePickerOption[]>([]);
|
||||
const [garments, setGarments] = useState<ResourcePickerOption[]>([]);
|
||||
const [isLoadingResources, setIsLoadingResources] = useState(true);
|
||||
const [resourceError, setResourceError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [submissionSuccess, setSubmissionSuccess] =
|
||||
useState<SubmissionSuccess | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
async function loadResources() {
|
||||
setIsLoadingResources(true);
|
||||
setResourceError(null);
|
||||
|
||||
try {
|
||||
const [modelItems, sceneItems, garmentItems] = await Promise.all([
|
||||
fetchLibraryItems("models"),
|
||||
fetchLibraryItems("scenes"),
|
||||
fetchLibraryItems("garments"),
|
||||
]);
|
||||
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setModels(mapModelOptions(modelItems));
|
||||
setScenes(mapResourceOptions(sceneItems));
|
||||
setGarments(mapResourceOptions(garmentItems));
|
||||
} catch {
|
||||
if (isActive) {
|
||||
setResourceError("提单资源加载失败,请刷新页面后重试。");
|
||||
}
|
||||
} finally {
|
||||
if (isActive) {
|
||||
setIsLoadingResources(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadResources();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!submissionSuccess) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
startTransition(() => {
|
||||
router.push(`/orders/${submissionSuccess.orderId}`);
|
||||
});
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [router, submissionSuccess]);
|
||||
|
||||
const allowedServiceMode = getServiceModeForCustomerLevel(
|
||||
formValues.customerLevel,
|
||||
);
|
||||
|
||||
const handleCustomerLevelChange = (customerLevel: CustomerLevel) => {
|
||||
const serviceMode = getServiceModeForCustomerLevel(customerLevel);
|
||||
|
||||
setFormValues((current) => ({
|
||||
...current,
|
||||
customerLevel,
|
||||
serviceMode,
|
||||
}));
|
||||
setSubmitError(null);
|
||||
setSubmissionSuccess(null);
|
||||
};
|
||||
|
||||
const handleServiceModeChange = (serviceMode: ServiceMode) => {
|
||||
if (serviceMode !== allowedServiceMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFormValues((current) => ({
|
||||
...current,
|
||||
serviceMode,
|
||||
}));
|
||||
setSubmitError(null);
|
||||
};
|
||||
|
||||
const updateSelection = (
|
||||
field: "modelId" | "sceneId" | "garmentId",
|
||||
value: string,
|
||||
) => {
|
||||
setFormValues((current) => ({
|
||||
...current,
|
||||
[field]: value,
|
||||
}));
|
||||
setSubmitError(null);
|
||||
setSubmissionSuccess(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const selectedModel = models.find((item) => item.id === formValues.modelId);
|
||||
const selectedScene = scenes.find((item) => item.id === formValues.sceneId);
|
||||
const selectedGarment = garments.find(
|
||||
(item) => item.id === formValues.garmentId,
|
||||
);
|
||||
|
||||
if (!selectedModel || !selectedScene || !selectedGarment) {
|
||||
setSubmitError("请先完成模特、场景和服装资源选择。");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
setSubmissionSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/orders", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_level: formValues.customerLevel,
|
||||
service_mode: formValues.serviceMode,
|
||||
model_id: selectedModel.backendId,
|
||||
pose_id: selectedModel.poseId,
|
||||
garment_asset_id: selectedGarment.backendId,
|
||||
scene_ref_asset_id: selectedScene.backendId,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as CreateOrderResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
setSubmitError(payload.message ?? "提单失败,请稍后重试。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.data?.orderId) {
|
||||
setSubmitError("提单成功但未返回订单 ID。");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmissionSuccess({
|
||||
orderId: payload.data.orderId,
|
||||
workflowId: payload.data.workflowId ?? null,
|
||||
});
|
||||
} catch {
|
||||
setSubmitError("网络异常,请稍后重试。");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
<PageHeader
|
||||
eyebrow="Order creation workspace"
|
||||
title="提单工作台"
|
||||
description="围绕当前后端能力构建真实订单创建页。资源选择通过 BFF 拉取 mock 数据,提单动作则直接提交到 /api/orders。"
|
||||
meta="只负责创建订单"
|
||||
/>
|
||||
|
||||
{resourceError ? (
|
||||
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
||||
{resourceError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CreateOrderForm
|
||||
allowedServiceMode={allowedServiceMode}
|
||||
garments={garments}
|
||||
isLoadingResources={isLoadingResources}
|
||||
isSubmitting={isSubmitting}
|
||||
models={models}
|
||||
scenes={scenes}
|
||||
submissionSuccess={submissionSuccess}
|
||||
submitError={submitError}
|
||||
value={formValues}
|
||||
onCustomerLevelChange={handleCustomerLevelChange}
|
||||
onGarmentChange={(value) => updateSelection("garmentId", value)}
|
||||
onModelChange={(value) => updateSelection("modelId", value)}
|
||||
onSceneChange={(value) => updateSelection("sceneId", value)}
|
||||
onServiceModeChange={handleServiceModeChange}
|
||||
onSubmit={() => {
|
||||
void handleSubmit();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
115
src/features/reviews/components/review-action-panel.tsx
Normal file
115
src/features/reviews/components/review-action-panel.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { ReviewDecision } from "@/lib/types/backend";
|
||||
import type {
|
||||
AssetViewModel,
|
||||
OrderDetailVM,
|
||||
ReviewSubmissionVM,
|
||||
} from "@/lib/types/view-models";
|
||||
|
||||
type ReviewActionPanelProps = {
|
||||
isSubmitting: boolean;
|
||||
order: OrderDetailVM | null;
|
||||
selectedAsset: AssetViewModel | null;
|
||||
submissionError: string | null;
|
||||
submissionResult: ReviewSubmissionVM | null;
|
||||
onSubmit: (decision: ReviewDecision, comment: string) => void;
|
||||
};
|
||||
|
||||
type ReviewActionDefinition = {
|
||||
decision: ReviewDecision;
|
||||
label: string;
|
||||
variant: "primary" | "secondary" | "danger";
|
||||
};
|
||||
|
||||
const ACTIONS: ReviewActionDefinition[] = [
|
||||
{ decision: "approve", label: "审核通过", variant: "primary" },
|
||||
{ decision: "rerun_scene", label: "重跑 Scene", variant: "secondary" },
|
||||
{ decision: "rerun_face", label: "重跑 Face", variant: "secondary" },
|
||||
{ decision: "rerun_fusion", label: "重跑 Fusion", variant: "secondary" },
|
||||
{ decision: "reject", label: "驳回订单", variant: "danger" },
|
||||
];
|
||||
|
||||
export function ReviewActionPanel({
|
||||
isSubmitting,
|
||||
order,
|
||||
selectedAsset,
|
||||
submissionError,
|
||||
submissionResult,
|
||||
onSubmit,
|
||||
}: ReviewActionPanelProps) {
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="gap-4">
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Review action</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>审核动作面板</CardTitle>
|
||||
<CardDescription>
|
||||
审核页只负责做决策,不改详情数据。提交后等待后端结果,再刷新左侧待审核队列。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{order ? (
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4 text-sm text-[var(--ink-muted)]">
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
||||
当前订单 #{order.orderId}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
选中资产:{selectedAsset ? `${selectedAsset.label} (#${selectedAsset.id})` : "未选择"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-[var(--ink-strong)]">审核备注</span>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
rows={5}
|
||||
placeholder="重跑或驳回时填写原因,便于流程追踪和复盘。"
|
||||
className="min-h-[132px] w-full rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-3 text-sm leading-6 text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{submissionError ? (
|
||||
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
||||
{submissionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{submissionResult ? (
|
||||
<div className="rounded-[24px] border border-[rgba(88,106,56,0.18)] bg-[rgba(110,127,82,0.12)] px-5 py-4 text-sm text-[#50633b]">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span>已提交审核动作:</span>
|
||||
<StatusBadge variant="reviewDecision" status={submissionResult.decision} />
|
||||
<span>等待刷新后的队列结果。</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3">
|
||||
{ACTIONS.map((action) => (
|
||||
<Button
|
||||
key={action.decision}
|
||||
variant={action.variant}
|
||||
size="lg"
|
||||
disabled={!order || isSubmitting}
|
||||
onClick={() => onSubmit(action.decision, comment)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
181
src/features/reviews/components/review-image-panel.tsx
Normal file
181
src/features/reviews/components/review-image-panel.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { AssetViewModel, OrderDetailVM } from "@/lib/types/view-models";
|
||||
|
||||
type ReviewImagePanelProps = {
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
order: OrderDetailVM | null;
|
||||
selectedAssetId: number | null;
|
||||
onSelectAsset: (assetId: number) => void;
|
||||
};
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function collectAssets(order: OrderDetailVM): AssetViewModel[] {
|
||||
const uniqueAssets = new Map<number, AssetViewModel>();
|
||||
|
||||
if (order.finalAsset) {
|
||||
uniqueAssets.set(order.finalAsset.id, order.finalAsset);
|
||||
}
|
||||
|
||||
for (const asset of order.assets) {
|
||||
uniqueAssets.set(asset.id, asset);
|
||||
}
|
||||
|
||||
return Array.from(uniqueAssets.values());
|
||||
}
|
||||
|
||||
export function ReviewImagePanel({
|
||||
error,
|
||||
isLoading,
|
||||
order,
|
||||
selectedAssetId,
|
||||
onSelectAsset,
|
||||
}: ReviewImagePanelProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardContent className="px-6 py-8 text-sm text-[var(--ink-muted)]">
|
||||
正在加载订单详情与预览资产…
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardContent className="px-6 py-8">
|
||||
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardContent className="px-6 py-8">
|
||||
<EmptyState
|
||||
eyebrow="No active order"
|
||||
title="选择一个待审核订单"
|
||||
description="左侧队列选中订单后,这里会展示当前审核所需的结果图和过程资产。"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const assets = collectAssets(order);
|
||||
const selectedAsset =
|
||||
assets.find((asset) => asset.id === selectedAssetId) ??
|
||||
order.finalAsset ??
|
||||
assets[0] ??
|
||||
null;
|
||||
const emptyStateTitle =
|
||||
order.finalAssetState.kind === "business-empty"
|
||||
? order.finalAssetState.title
|
||||
: "暂无可用预览";
|
||||
const emptyStateDescription =
|
||||
order.finalAssetState.kind === "business-empty"
|
||||
? order.finalAssetState.description
|
||||
: "当前订单还没有能用于审核的结果图或过程资产。";
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="gap-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Review target</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>订单 #{order.orderId}</CardTitle>
|
||||
<CardDescription>
|
||||
当前服务模式为 {order.serviceMode},步骤停留在 {order.currentStepLabel}。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge status={order.status} />
|
||||
<StatusBadge variant="workflowStep" status={order.currentStep} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{selectedAsset ? (
|
||||
<div className="rounded-[28px] border border-[var(--border-soft)] bg-[linear-gradient(180deg,rgba(250,247,242,0.96),rgba(238,231,221,0.92))] p-5">
|
||||
<div className="flex min-h-[320px] items-center justify-center rounded-[22px] border border-dashed border-[var(--border-strong)] bg-[rgba(255,255,255,0.55)] p-6 text-center">
|
||||
<div className="space-y-3">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.22em] text-[var(--ink-faint)]">
|
||||
{selectedAsset.isMock ? "Mock preview asset" : "Preview asset"}
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
{selectedAsset.label}
|
||||
</h3>
|
||||
<p className="mx-auto max-w-lg text-sm leading-7 text-[var(--ink-muted)]">
|
||||
当前阶段优先保证审核流程可用,因此预览面板直接展示资产信息卡;真实图片 URI 可在后续 Task 7 的详情页复用。
|
||||
</p>
|
||||
<code className="inline-flex rounded-full bg-[rgba(74,64,53,0.08)] px-4 py-2 text-xs text-[var(--ink-muted)]">
|
||||
{selectedAsset.uri}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
eyebrow="Asset empty"
|
||||
title={emptyStateTitle}
|
||||
description={emptyStateDescription}
|
||||
/>
|
||||
)}
|
||||
|
||||
{order.hasMockAssets ? (
|
||||
<div className="rounded-[24px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-5 py-4 text-sm text-[#7a5323]">
|
||||
当前订单包含 mock 资产,审核结论仅用于前后台联调,不代表真实生产结果。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{assets.length ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{assets.map((asset) => {
|
||||
const isSelected = asset.id === selectedAsset?.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
onClick={() => onSelectAsset(asset.id)}
|
||||
className={joinClasses(
|
||||
"rounded-[24px] border px-4 py-4 text-left transition duration-150",
|
||||
isSelected
|
||||
? "border-[var(--accent-primary)] bg-[rgba(110,127,82,0.09)]"
|
||||
: "border-[var(--border-soft)] bg-[var(--surface-muted)] hover:bg-[var(--surface)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
||||
{asset.label}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--ink-muted)]">{asset.stepLabel}</p>
|
||||
</div>
|
||||
{asset.isMock ? (
|
||||
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[#7a5323]">
|
||||
Mock
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
112
src/features/reviews/components/review-queue.tsx
Normal file
112
src/features/reviews/components/review-queue.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardEyebrow, CardHeader } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SectionTitle } from "@/components/ui/section-title";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { ReviewQueueVM } from "@/lib/types/view-models";
|
||||
|
||||
type ReviewQueueProps = {
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
queue: ReviewQueueVM | null;
|
||||
};
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string) {
|
||||
return timestamp.replace("T", " ").replace("Z", " UTC");
|
||||
}
|
||||
|
||||
export function ReviewQueue({
|
||||
error,
|
||||
isLoading,
|
||||
queue,
|
||||
}: ReviewQueueProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<SectionTitle
|
||||
eyebrow="Pending queue"
|
||||
title="待审核队列"
|
||||
description="只展示当前后端真实返回的待审核订单,动作提交后再以非乐观方式刷新队列。"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
|
||||
正在加载待审核队列…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && error ? (
|
||||
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && queue?.state.kind === "business-empty" ? (
|
||||
<EmptyState
|
||||
eyebrow="Queue empty"
|
||||
title={queue.state.title}
|
||||
description={queue.state.description}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && queue?.items.length ? (
|
||||
<div className="space-y-3">
|
||||
{queue.items.map((item) => (
|
||||
<Link
|
||||
key={item.reviewTaskId}
|
||||
href={`/reviews/workbench/${item.orderId}`}
|
||||
className={joinClasses(
|
||||
"block w-full rounded-[24px] border px-4 py-4 text-left transition duration-150",
|
||||
"border-[var(--border-soft)] bg-[var(--surface-muted)] hover:border-[var(--border-strong)] hover:bg-[var(--surface)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Order #{item.orderId}</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
审核目标 #{item.orderId}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--ink-muted)]">
|
||||
工作流 {item.workflowId}
|
||||
{item.workflowType ? ` / ${item.workflowType}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-[var(--ink-muted)]">
|
||||
<StatusBadge variant="workflowStep" status={item.currentStep} />
|
||||
<span>{item.currentStepLabel}</span>
|
||||
<span>{formatTimestamp(item.createdAt)}</span>
|
||||
{item.hasMockAssets ? (
|
||||
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#7a5323]">
|
||||
Mock 资产
|
||||
</span>
|
||||
) : null}
|
||||
{item.failureCount > 0 ? (
|
||||
<span className="rounded-full bg-[rgba(140,74,67,0.12)] px-2.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#7f3f38]">
|
||||
失败 {item.failureCount}
|
||||
</span>
|
||||
) : null}
|
||||
{item.pendingManualConfirm ? (
|
||||
<span className="rounded-full bg-[rgba(88,106,56,0.12)] px-2.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#50633b]">
|
||||
修订待确认
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
144
src/features/reviews/components/review-revision-panel.tsx
Normal file
144
src/features/reviews/components/review-revision-panel.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardEyebrow,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type {
|
||||
AssetViewModel,
|
||||
OrderDetailVM,
|
||||
ReviewSubmissionVM,
|
||||
RevisionRegistrationVM,
|
||||
WorkflowDetailVM,
|
||||
} from "@/lib/types/view-models";
|
||||
|
||||
type ReviewRevisionPanelProps = {
|
||||
isSubmitting: boolean;
|
||||
order: OrderDetailVM | null;
|
||||
selectedAsset: AssetViewModel | null;
|
||||
workflow: WorkflowDetailVM | null;
|
||||
revisionError: string | null;
|
||||
revisionResult: RevisionRegistrationVM | null;
|
||||
confirmResult: ReviewSubmissionVM | null;
|
||||
onRegisterRevision: (payload: { uploadedUri: string; comment: string }) => void;
|
||||
onConfirmRevision: (comment: string) => void;
|
||||
};
|
||||
|
||||
export function ReviewRevisionPanel({
|
||||
isSubmitting,
|
||||
order,
|
||||
selectedAsset,
|
||||
workflow,
|
||||
revisionError,
|
||||
revisionResult,
|
||||
confirmResult,
|
||||
onRegisterRevision,
|
||||
onConfirmRevision,
|
||||
}: ReviewRevisionPanelProps) {
|
||||
const [uploadedUri, setUploadedUri] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
const pendingManualConfirm =
|
||||
order?.pendingManualConfirm || workflow?.pendingManualConfirm || false;
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="gap-4">
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Manual revision</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>人工修订稿</CardTitle>
|
||||
<CardDescription>
|
||||
当前后端支持登记离线修订稿,并在修订稿确认后复用既有 approve
|
||||
signal 继续流水线。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{selectedAsset ? (
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4 text-sm text-[var(--ink-muted)]">
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
||||
当前基准资产:{selectedAsset.label} (#{selectedAsset.id})
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{pendingManualConfirm
|
||||
? "已存在待确认修订稿,可直接确认继续流水线。"
|
||||
: "登记新的人工修订稿会把当前审核任务切到待确认状态。"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-[var(--ink-strong)]">
|
||||
修订稿 URI
|
||||
</span>
|
||||
<input
|
||||
value={uploadedUri}
|
||||
onChange={(event) => setUploadedUri(event.target.value)}
|
||||
placeholder="mock://manual-revision-v1"
|
||||
className="min-h-12 w-full rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-[var(--ink-strong)]">
|
||||
修订说明
|
||||
</span>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
rows={4}
|
||||
placeholder="说明这版离线修订解决了什么问题。"
|
||||
className="min-h-[112px] w-full rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-3 text-sm leading-6 text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{revisionError ? (
|
||||
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
||||
{revisionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{revisionResult ? (
|
||||
<div className="rounded-[24px] border border-[rgba(88,106,56,0.18)] bg-[rgba(110,127,82,0.12)] px-5 py-4 text-sm text-[#50633b]">
|
||||
已登记修订稿 v{revisionResult.versionNo},当前共有 {revisionResult.revisionCount} 个修订版本。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{confirmResult ? (
|
||||
<div className="rounded-[24px] border border-[rgba(88,106,56,0.18)] bg-[rgba(110,127,82,0.12)] px-5 py-4 text-sm text-[#50633b]">
|
||||
已确认修订稿并继续流水线,等待返回审核队列。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={!order || !selectedAsset || isSubmitting}
|
||||
onClick={() => onRegisterRevision({ uploadedUri, comment })}
|
||||
>
|
||||
登记人工修订稿
|
||||
</Button>
|
||||
{pendingManualConfirm ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
disabled={!order || isSubmitting}
|
||||
onClick={() => onConfirmRevision(comment)}
|
||||
>
|
||||
确认继续流水线
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
125
src/features/reviews/components/review-workflow-summary.tsx
Normal file
125
src/features/reviews/components/review-workflow-summary.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { WorkflowDetailVM } from "@/lib/types/view-models";
|
||||
|
||||
type ReviewWorkflowSummaryProps = {
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
workflow: WorkflowDetailVM | null;
|
||||
};
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function ReviewWorkflowSummary({
|
||||
error,
|
||||
isLoading,
|
||||
workflow,
|
||||
}: ReviewWorkflowSummaryProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Workflow summary</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>流程摘要</CardTitle>
|
||||
<CardDescription>
|
||||
审核工作台只保留当前步骤和关键异常,完整时间线留给独立流程详情页。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
|
||||
正在加载流程摘要…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && error ? (
|
||||
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && !workflow ? (
|
||||
<EmptyState
|
||||
eyebrow="No workflow"
|
||||
title="暂无流程上下文"
|
||||
description="选中待审核订单后,这里会给出当前流程卡点、失败次数和 mock 资产提示。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && workflow ? (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Current step
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatusBadge variant="workflowStep" status={workflow.currentStep} />
|
||||
<StatusBadge status={workflow.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Failure count
|
||||
</p>
|
||||
<p className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]">
|
||||
{workflow.failureCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workflow.hasMockAssets ? (
|
||||
<div className="rounded-[24px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-5 py-4 text-sm text-[#7a5323]">
|
||||
当前流程包含 mock 资产,适合联调审核动作,但不应被视为最终生产输出。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{workflow.steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={joinClasses(
|
||||
"rounded-[24px] border px-4 py-4",
|
||||
step.isCurrent
|
||||
? "border-[var(--accent-primary)] bg-[rgba(110,127,82,0.09)]"
|
||||
: "border-[var(--border-soft)] bg-[var(--surface-muted)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
||||
{step.label}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge variant="stepStatus" status={step.status} />
|
||||
{step.containsMockAssets ? (
|
||||
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[#7a5323]">
|
||||
Mock assets
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{step.isCurrent ? (
|
||||
<span className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--accent-primary-strong)]">
|
||||
当前步骤
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{step.errorMessage ? (
|
||||
<p className="mt-3 text-sm leading-6 text-[#7f3f38]">{step.errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
436
src/features/reviews/review-workbench-detail.tsx
Normal file
436
src/features/reviews/review-workbench-detail.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { ReviewActionPanel } from "@/features/reviews/components/review-action-panel";
|
||||
import { ReviewImagePanel } from "@/features/reviews/components/review-image-panel";
|
||||
import { ReviewRevisionPanel } from "@/features/reviews/components/review-revision-panel";
|
||||
import { ReviewWorkflowSummary } from "@/features/reviews/components/review-workflow-summary";
|
||||
import type { ReviewDecision } from "@/lib/types/backend";
|
||||
import type {
|
||||
AssetViewModel,
|
||||
OrderDetailVM,
|
||||
ReviewSubmissionVM,
|
||||
RevisionRegistrationVM,
|
||||
WorkflowDetailVM,
|
||||
} from "@/lib/types/view-models";
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
data?: T;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type ReviewWorkbenchDetailScreenProps = {
|
||||
orderId: number;
|
||||
};
|
||||
|
||||
const REVIEWER_ID = 1;
|
||||
|
||||
function isRerunDecision(decision: ReviewDecision) {
|
||||
return (
|
||||
decision === "rerun_scene" ||
|
||||
decision === "rerun_face" ||
|
||||
decision === "rerun_fusion"
|
||||
);
|
||||
}
|
||||
|
||||
function getPreferredAsset(order: OrderDetailVM) {
|
||||
return order.finalAsset ?? order.assets[0] ?? null;
|
||||
}
|
||||
|
||||
async function parseEnvelope<T>(response: Response): Promise<ApiEnvelope<T>> {
|
||||
return (await response.json()) as ApiEnvelope<T>;
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string) {
|
||||
return timestamp.replace("T", " ").replace("Z", " UTC");
|
||||
}
|
||||
|
||||
export function ReviewWorkbenchDetailScreen({
|
||||
orderId,
|
||||
}: ReviewWorkbenchDetailScreenProps) {
|
||||
const router = useRouter();
|
||||
const [orderDetail, setOrderDetail] = useState<OrderDetailVM | null>(null);
|
||||
const [workflowDetail, setWorkflowDetail] = useState<WorkflowDetailVM | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedAssetId, setSelectedAssetId] = useState<number | null>(null);
|
||||
const [contextError, setContextError] = useState<string | null>(null);
|
||||
const [submissionError, setSubmissionError] = useState<string | null>(null);
|
||||
const [submissionResult, setSubmissionResult] =
|
||||
useState<ReviewSubmissionVM | null>(null);
|
||||
const [revisionError, setRevisionError] = useState<string | null>(null);
|
||||
const [revisionResult, setRevisionResult] =
|
||||
useState<RevisionRegistrationVM | null>(null);
|
||||
const [isLoadingContext, setIsLoadingContext] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadReviewContext() {
|
||||
setIsLoadingContext(true);
|
||||
|
||||
try {
|
||||
const [orderResponse, workflowResponse] = await Promise.all([
|
||||
fetch(`/api/orders/${orderId}`),
|
||||
fetch(`/api/workflows/${orderId}`),
|
||||
]);
|
||||
const [orderPayload, workflowPayload] = await Promise.all([
|
||||
parseEnvelope<OrderDetailVM>(orderResponse),
|
||||
parseEnvelope<WorkflowDetailVM>(workflowResponse),
|
||||
]);
|
||||
const nextOrder = orderPayload.data;
|
||||
const nextWorkflow = workflowPayload.data;
|
||||
|
||||
if (
|
||||
!orderResponse.ok ||
|
||||
!workflowResponse.ok ||
|
||||
!nextOrder ||
|
||||
!nextWorkflow
|
||||
) {
|
||||
throw new Error("CONTEXT_LOAD_FAILED");
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredAsset = getPreferredAsset(nextOrder);
|
||||
|
||||
setOrderDetail(nextOrder);
|
||||
setWorkflowDetail(nextWorkflow);
|
||||
setContextError(null);
|
||||
setSelectedAssetId((current) => {
|
||||
if (
|
||||
current &&
|
||||
[
|
||||
...(nextOrder.finalAsset ? [nextOrder.finalAsset] : []),
|
||||
...nextOrder.assets,
|
||||
].some((asset) => asset.id === current)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return preferredAsset?.id ?? null;
|
||||
});
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderDetail(null);
|
||||
setWorkflowDetail(null);
|
||||
setSelectedAssetId(null);
|
||||
setContextError("订单详情或流程摘要加载失败,请稍后重试。");
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoadingContext(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadReviewContext();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [orderId]);
|
||||
|
||||
const selectedAsset: AssetViewModel | null =
|
||||
(orderDetail?.finalAsset?.id === selectedAssetId
|
||||
? orderDetail.finalAsset
|
||||
: orderDetail?.assets.find((asset) => asset.id === selectedAssetId)) ??
|
||||
orderDetail?.finalAsset ??
|
||||
orderDetail?.assets[0] ??
|
||||
null;
|
||||
|
||||
const handleSubmit = async (decision: ReviewDecision, comment: string) => {
|
||||
if (!orderDetail) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRerunDecision(decision) && !comment.trim()) {
|
||||
setSubmissionError("请填写审核备注");
|
||||
setSubmissionResult(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmissionError(null);
|
||||
setSubmissionResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/reviews/${orderDetail.orderId}/submit`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
decision,
|
||||
reviewer_id: REVIEWER_ID,
|
||||
selected_asset_id: selectedAsset?.id ?? null,
|
||||
comment: comment.trim() ? comment : null,
|
||||
}),
|
||||
});
|
||||
const payload = await parseEnvelope<ReviewSubmissionVM>(response);
|
||||
|
||||
if (!response.ok || !payload.data) {
|
||||
setSubmissionError(payload.message ?? "审核动作提交失败,请稍后重试。");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmissionResult(payload.data);
|
||||
router.push("/reviews/workbench");
|
||||
} catch {
|
||||
setSubmissionError("审核动作提交失败,请检查网络后重试。");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterRevision = async (payload: {
|
||||
uploadedUri: string;
|
||||
comment: string;
|
||||
}) => {
|
||||
if (!orderDetail || !selectedAsset) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.uploadedUri.trim()) {
|
||||
setRevisionError("请填写修订稿 URI");
|
||||
setRevisionResult(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setRevisionError(null);
|
||||
setRevisionResult(null);
|
||||
setSubmissionResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/orders/${orderDetail.orderId}/revisions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
parent_asset_id: selectedAsset.id,
|
||||
uploaded_uri: payload.uploadedUri.trim(),
|
||||
reviewer_id: REVIEWER_ID,
|
||||
comment: payload.comment.trim() ? payload.comment : null,
|
||||
}),
|
||||
});
|
||||
const revisionPayload = await parseEnvelope<RevisionRegistrationVM>(response);
|
||||
|
||||
if (!response.ok || !revisionPayload.data) {
|
||||
setRevisionError(revisionPayload.message ?? "人工修订稿登记失败,请稍后重试。");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRevision = revisionPayload.data;
|
||||
|
||||
setRevisionResult(nextRevision);
|
||||
setOrderDetail((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
currentRevisionAssetId: nextRevision.assetId,
|
||||
currentRevisionVersion: nextRevision.versionNo,
|
||||
latestRevisionAssetId: nextRevision.latestRevisionAssetId,
|
||||
latestRevisionVersion: nextRevision.versionNo,
|
||||
revisionCount: nextRevision.revisionCount,
|
||||
reviewTaskStatus: nextRevision.reviewTaskStatus,
|
||||
pendingManualConfirm: true,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
setWorkflowDetail((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
currentRevisionAssetId: nextRevision.assetId,
|
||||
currentRevisionVersion: nextRevision.versionNo,
|
||||
latestRevisionAssetId: nextRevision.latestRevisionAssetId,
|
||||
latestRevisionVersion: nextRevision.versionNo,
|
||||
revisionCount: nextRevision.revisionCount,
|
||||
reviewTaskStatus: nextRevision.reviewTaskStatus,
|
||||
pendingManualConfirm: true,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} catch {
|
||||
setRevisionError("人工修订稿登记失败,请检查网络后重试。");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmRevision = async (comment: string) => {
|
||||
if (!orderDetail) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setRevisionError(null);
|
||||
setSubmissionError(null);
|
||||
setSubmissionResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/reviews/${orderDetail.orderId}/confirm-revision`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reviewer_id: REVIEWER_ID,
|
||||
comment: comment.trim() ? comment : null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const payload = await parseEnvelope<ReviewSubmissionVM>(response);
|
||||
|
||||
if (!response.ok || !payload.data) {
|
||||
setRevisionError(payload.message ?? "确认修订失败,请稍后重试。");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmissionResult(payload.data);
|
||||
router.push("/reviews/workbench");
|
||||
} catch {
|
||||
setRevisionError("确认修订失败,请检查网络后重试。");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingContext) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-6 py-10 text-sm text-[var(--ink-muted)]">
|
||||
正在加载审核详情…
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (contextError || !orderDetail || !workflowDetail) {
|
||||
return (
|
||||
<EmptyState
|
||||
eyebrow="Review detail error"
|
||||
title="审核详情暂时不可用"
|
||||
description={contextError ?? "当前审核详情还无法展示,请稍后重试。"}
|
||||
actions={
|
||||
<Link
|
||||
href="/reviews/workbench"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-[var(--border-strong)] bg-[var(--surface)] px-4 text-sm font-medium text-[var(--ink-strong)] transition hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
返回审核列表
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
<PageHeader
|
||||
eyebrow="Review detail"
|
||||
title={`订单 #${orderDetail.orderId}`}
|
||||
description="审核详情页只处理单个订单,列表筛选和切单行为统一留在审核工作台首页。"
|
||||
meta={`更新于 ${formatTimestamp(orderDetail.updatedAt)}`}
|
||||
actions={
|
||||
<Link
|
||||
href="/reviews/workbench"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-[var(--border-strong)] bg-[var(--surface)] px-4 text-sm font-medium text-[var(--ink-strong)] transition hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
返回审核列表
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Order status
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<StatusBadge status={orderDetail.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Workflow
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
{orderDetail.workflowId ?? "暂未分配"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Current step
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatusBadge variant="workflowStep" status={orderDetail.currentStep} />
|
||||
<span className="text-sm text-[var(--ink-muted)]">
|
||||
{orderDetail.currentStepLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Workflow status
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<StatusBadge status={workflowDetail.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<ReviewImagePanel
|
||||
error={contextError}
|
||||
isLoading={isLoadingContext}
|
||||
order={orderDetail}
|
||||
selectedAssetId={selectedAssetId}
|
||||
onSelectAsset={setSelectedAssetId}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<ReviewRevisionPanel
|
||||
isSubmitting={isSubmitting}
|
||||
order={orderDetail}
|
||||
selectedAsset={selectedAsset}
|
||||
workflow={workflowDetail}
|
||||
revisionError={revisionError}
|
||||
revisionResult={revisionResult}
|
||||
confirmResult={submissionResult}
|
||||
onRegisterRevision={handleRegisterRevision}
|
||||
onConfirmRevision={handleConfirmRevision}
|
||||
/>
|
||||
<ReviewActionPanel
|
||||
key={`${orderDetail.orderId}-${submissionResult?.decision ?? "idle"}`}
|
||||
isSubmitting={isSubmitting}
|
||||
order={orderDetail}
|
||||
selectedAsset={selectedAsset}
|
||||
submissionError={submissionError}
|
||||
submissionResult={submissionResult}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<ReviewWorkflowSummary
|
||||
error={contextError}
|
||||
isLoading={isLoadingContext}
|
||||
workflow={workflowDetail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
80
src/features/reviews/review-workbench-list.tsx
Normal file
80
src/features/reviews/review-workbench-list.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { ReviewQueue } from "@/features/reviews/components/review-queue";
|
||||
import type { ReviewQueueVM } from "@/lib/types/view-models";
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
data?: T;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
async function parseEnvelope<T>(response: Response): Promise<ApiEnvelope<T>> {
|
||||
return (await response.json()) as ApiEnvelope<T>;
|
||||
}
|
||||
|
||||
export function ReviewWorkbenchListScreen() {
|
||||
const [queue, setQueue] = useState<ReviewQueueVM | null>(null);
|
||||
const [queueError, setQueueError] = useState<string | null>(null);
|
||||
const [isLoadingQueue, setIsLoadingQueue] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadQueue() {
|
||||
setIsLoadingQueue(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/reviews/pending");
|
||||
const payload = await parseEnvelope<ReviewQueueVM>(response);
|
||||
|
||||
if (!response.ok || !payload.data) {
|
||||
throw new Error(payload.message ?? "QUEUE_LOAD_FAILED");
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQueue(payload.data);
|
||||
setQueueError(null);
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQueue(null);
|
||||
setQueueError("待审核队列加载失败,请稍后重试。");
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoadingQueue(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadQueue();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
<PageHeader
|
||||
eyebrow="Human review queue"
|
||||
title="审核工作台"
|
||||
description="列表页只负责展示待审核队列,点击具体订单后再进入独立详情页处理资产、动作和流程摘要。"
|
||||
meta="先看列表,再进详情"
|
||||
/>
|
||||
|
||||
<ReviewQueue
|
||||
error={queueError}
|
||||
isLoading={isLoadingQueue}
|
||||
queue={queue}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
43
src/features/settings/settings-placeholder.tsx
Normal file
43
src/features/settings/settings-placeholder.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
|
||||
export function SettingsPlaceholder() {
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
<PageHeader
|
||||
eyebrow="Settings"
|
||||
title="系统设置"
|
||||
description="首版只保留设置的信息架构,不假装已经接入用户体系、角色权限或系统级配置接口。"
|
||||
meta="正式占位模块"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Environment notes</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>运行环境约束</CardTitle>
|
||||
<CardDescription>
|
||||
当前前端只依赖 `BACKEND_BASE_URL`,认证、审计日志和角色配置都还没有接入。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm leading-7 text-[var(--ink-muted)]">
|
||||
<p>1. Next.js 页面统一请求本站 `/api/*`。</p>
|
||||
<p>2. FastAPI 基地址通过环境变量控制。</p>
|
||||
<p>3. 登录与权限策略将在后续后端能力准备好后补齐。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<EmptyState
|
||||
eyebrow="Future settings"
|
||||
title="更多设置能力待接入"
|
||||
description="比如账号、审计、资源策略和自动化规则。目前先保留正式页面入口,避免后续重做导航和路由。"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
60
src/features/workflows/components/workflow-status-card.tsx
Normal file
60
src/features/workflows/components/workflow-status-card.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { WorkflowDetailVM } from "@/lib/types/view-models";
|
||||
|
||||
type WorkflowStatusCardProps = {
|
||||
viewModel: WorkflowDetailVM;
|
||||
};
|
||||
|
||||
export function WorkflowStatusCard({ viewModel }: WorkflowStatusCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Workflow status</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>流程概览</CardTitle>
|
||||
<CardDescription>
|
||||
这里强调当前节点、失败次数和工作流类型,帮助快速判断是否需要排查 Temporal 执行链路。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Workflow type
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
{viewModel.workflowType}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Current step
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatusBadge variant="workflowStep" status={viewModel.currentStep} />
|
||||
<span className="text-sm text-[var(--ink-muted)]">{viewModel.currentStepLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Workflow status
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<StatusBadge status={viewModel.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Failure focus
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
失败步骤 {viewModel.failureCount} 个
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
111
src/features/workflows/components/workflow-timeline.tsx
Normal file
111
src/features/workflows/components/workflow-timeline.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { WorkflowDetailVM } from "@/lib/types/view-models";
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type WorkflowTimelineProps = {
|
||||
viewModel: WorkflowDetailVM;
|
||||
};
|
||||
|
||||
export function WorkflowTimeline({ viewModel }: WorkflowTimelineProps) {
|
||||
const timelineTitle =
|
||||
viewModel.stepTimelineState.kind === "business-empty"
|
||||
? viewModel.stepTimelineState.title
|
||||
: "暂无流程记录";
|
||||
const timelineDescription =
|
||||
viewModel.stepTimelineState.kind === "business-empty"
|
||||
? viewModel.stepTimelineState.description
|
||||
: "当前工作流还没有可展示的步骤执行记录。";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Workflow timeline</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>步骤时间线</CardTitle>
|
||||
<CardDescription>
|
||||
每一步都保留状态、异常信息和 mock 资产提示,便于独立排查流程问题。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{viewModel.hasMockAssets ? (
|
||||
<div className="rounded-[24px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-5 py-4 text-sm text-[#7a5323]">
|
||||
当前流程包含 mock 资产
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{viewModel.steps.length ? (
|
||||
<ol className="space-y-3">
|
||||
{viewModel.steps.map((step) => (
|
||||
<li
|
||||
key={step.id}
|
||||
className={joinClasses(
|
||||
"rounded-[24px] border px-4 py-4",
|
||||
step.isFailed
|
||||
? "border-[rgba(140,74,67,0.2)] bg-[rgba(140,74,67,0.08)]"
|
||||
: step.isCurrent
|
||||
? "border-[var(--accent-primary)] bg-[rgba(110,127,82,0.09)]"
|
||||
: "border-[var(--border-soft)] bg-[var(--surface-muted)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
||||
{step.label}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge variant="stepStatus" status={step.status} />
|
||||
{step.containsMockAssets ? (
|
||||
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[#7a5323]">
|
||||
Mock assets
|
||||
</span>
|
||||
) : null}
|
||||
{step.isCurrent ? (
|
||||
<span className="rounded-full bg-[rgba(110,127,82,0.14)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--accent-primary-strong)]">
|
||||
当前步骤
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge variant="workflowStep" status={step.name} />
|
||||
</div>
|
||||
|
||||
{step.errorMessage ? (
|
||||
<p className="mt-4 rounded-[18px] bg-[rgba(255,255,255,0.5)] px-3 py-3 text-sm leading-6 text-[#7f3f38]">
|
||||
{step.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{step.mockAssetUris.length ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
{step.mockAssetUris.map((uri) => (
|
||||
<code
|
||||
key={uri}
|
||||
className="block rounded-[18px] bg-[rgba(74,64,53,0.08)] px-3 py-3 text-xs leading-6 text-[var(--ink-muted)]"
|
||||
>
|
||||
{uri}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<EmptyState
|
||||
eyebrow="Timeline empty"
|
||||
title={timelineTitle}
|
||||
description={timelineDescription}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
116
src/features/workflows/workflow-detail.tsx
Normal file
116
src/features/workflows/workflow-detail.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { WorkflowStatusCard } from "@/features/workflows/components/workflow-status-card";
|
||||
import { WorkflowTimeline } from "@/features/workflows/components/workflow-timeline";
|
||||
import type { WorkflowDetailVM } from "@/lib/types/view-models";
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
data?: T;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type WorkflowDetailProps = {
|
||||
viewModel: WorkflowDetailVM;
|
||||
};
|
||||
|
||||
type WorkflowDetailScreenProps = {
|
||||
orderId: number;
|
||||
};
|
||||
|
||||
function formatTimestamp(timestamp: string) {
|
||||
return timestamp.replace("T", " ").replace("Z", " UTC");
|
||||
}
|
||||
|
||||
async function parseEnvelope<T>(response: Response): Promise<ApiEnvelope<T>> {
|
||||
return (await response.json()) as ApiEnvelope<T>;
|
||||
}
|
||||
|
||||
export function WorkflowDetail({ viewModel }: WorkflowDetailProps) {
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
<PageHeader
|
||||
eyebrow="Workflow detail"
|
||||
title={`流程 ${viewModel.workflowId}`}
|
||||
description="流程详情页专门追踪执行链路、失败步骤和 mock 资产,不承接审核动作。"
|
||||
meta={`更新于 ${formatTimestamp(viewModel.updatedAt)}`}
|
||||
/>
|
||||
<WorkflowStatusCard viewModel={viewModel} />
|
||||
<WorkflowTimeline viewModel={viewModel} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowDetailScreen({
|
||||
orderId,
|
||||
}: WorkflowDetailScreenProps) {
|
||||
const [viewModel, setViewModel] = useState<WorkflowDetailVM | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadWorkflowDetail() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${orderId}`);
|
||||
const payload = await parseEnvelope<WorkflowDetailVM>(response);
|
||||
|
||||
if (!response.ok || !payload.data) {
|
||||
throw new Error(payload.message ?? "WORKFLOW_DETAIL_LOAD_FAILED");
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setViewModel(payload.data);
|
||||
setError(null);
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setViewModel(null);
|
||||
setError("流程详情加载失败,请稍后重试。");
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadWorkflowDetail();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [orderId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-6 py-10 text-sm text-[var(--ink-muted)]">
|
||||
正在加载流程详情…
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !viewModel) {
|
||||
return (
|
||||
<EmptyState
|
||||
eyebrow="Workflow detail error"
|
||||
title="流程详情暂时不可用"
|
||||
description={error ?? "当前流程详情还无法展示,请稍后重试。"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <WorkflowDetail viewModel={viewModel} />;
|
||||
}
|
||||
350
src/features/workflows/workflow-lookup.tsx
Normal file
350
src/features/workflows/workflow-lookup.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { ORDER_STATUS_META } from "@/lib/types/status";
|
||||
import type { OrderStatus } from "@/lib/types/backend";
|
||||
import type { WorkflowLookupItemVM } from "@/lib/types/view-models";
|
||||
|
||||
type FilterStatus = OrderStatus | "all";
|
||||
type PaginationData = {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type WorkflowLookupProps = {
|
||||
currentPage?: number;
|
||||
isLoading?: boolean;
|
||||
items: WorkflowLookupItemVM[];
|
||||
message?: string;
|
||||
onOpenWorkflow?: (orderId: string) => void;
|
||||
onPageChange?: (page: number) => void;
|
||||
onQuerySubmit?: (query: string) => void;
|
||||
onStatusChange?: (status: FilterStatus) => void;
|
||||
selectedQuery?: string;
|
||||
selectedStatus?: FilterStatus;
|
||||
totalPages?: number;
|
||||
};
|
||||
|
||||
type WorkflowLookupEnvelope = {
|
||||
data?: {
|
||||
items?: WorkflowLookupItemVM[];
|
||||
limit?: number;
|
||||
page?: number;
|
||||
total?: number;
|
||||
totalPages?: number;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MESSAGE = "流程追踪首页当前显示真实后端最近流程。";
|
||||
const DEFAULT_PAGINATION: PaginationData = {
|
||||
page: 1,
|
||||
limit: 8,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
};
|
||||
const WORKFLOW_STATUS_FILTER_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: FilterStatus;
|
||||
}> = [
|
||||
{ value: "all", label: "全部状态" },
|
||||
...Object.entries(ORDER_STATUS_META).map(([value, meta]) => ({
|
||||
value: value as OrderStatus,
|
||||
label: meta.label,
|
||||
})),
|
||||
];
|
||||
|
||||
export function WorkflowLookup({
|
||||
currentPage = 1,
|
||||
isLoading = false,
|
||||
items,
|
||||
message = DEFAULT_MESSAGE,
|
||||
onOpenWorkflow,
|
||||
onPageChange,
|
||||
onQuerySubmit,
|
||||
onStatusChange,
|
||||
selectedQuery = "",
|
||||
selectedStatus = "all",
|
||||
totalPages = 0,
|
||||
}: WorkflowLookupProps) {
|
||||
const [lookupValue, setLookupValue] = useState("");
|
||||
const [queryValue, setQueryValue] = useState(selectedQuery);
|
||||
const normalizedLookup = lookupValue.trim();
|
||||
const canLookup = /^\d+$/.test(normalizedLookup);
|
||||
const effectiveTotalPages = Math.max(totalPages, 1);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryValue(selectedQuery);
|
||||
}, [selectedQuery]);
|
||||
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
<PageHeader
|
||||
eyebrow="Workflow lookup"
|
||||
title="流程追踪"
|
||||
description="当前首页已经接入真实最近流程列表,并保留订单号直达能力,方便快速排查。"
|
||||
meta="真实列表入口"
|
||||
/>
|
||||
|
||||
<div className="rounded-[28px] border border-[rgba(57,86,95,0.16)] bg-[rgba(57,86,95,0.1)] px-6 py-5 text-sm leading-7 text-[#2e4d56]">
|
||||
{message}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Direct lookup</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>按订单号打开流程</CardTitle>
|
||||
<CardDescription>
|
||||
除了最近流程列表,也支持按订单号直接进入真实流程详情页。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<input
|
||||
value={lookupValue}
|
||||
onChange={(event) => setLookupValue(event.target.value)}
|
||||
placeholder="输入订单号,例如 4201"
|
||||
className="min-h-12 w-full rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!canLookup}
|
||||
onClick={() => onOpenWorkflow?.(normalizedLookup)}
|
||||
>
|
||||
打开流程详情
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Placeholder index</CardEyebrow>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>流程索引占位</CardTitle>
|
||||
<CardDescription>
|
||||
这里已经接入真实后端最近流程列表,继续沿用首版查询页结构。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 lg:min-w-[360px]">
|
||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||
<span className="font-medium">流程关键词搜索</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
aria-label="流程关键词搜索"
|
||||
value={queryValue}
|
||||
onChange={(event) => setQueryValue(event.target.value)}
|
||||
placeholder="搜索订单号或 workflow_id"
|
||||
className="min-h-12 flex-1 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)] focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
<Button onClick={() => onQuerySubmit?.(queryValue.trim())}>
|
||||
搜索流程
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||
<span className="font-medium">流程状态筛选</span>
|
||||
<select
|
||||
aria-label="流程状态筛选"
|
||||
className="min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
value={selectedStatus}
|
||||
onChange={(event) =>
|
||||
onStatusChange?.(event.target.value as FilterStatus)
|
||||
}
|
||||
>
|
||||
{WORKFLOW_STATUS_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
|
||||
正在加载流程索引…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && items.length ? (
|
||||
items.map((item) => (
|
||||
<div
|
||||
key={item.workflowId}
|
||||
className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
||||
订单 #{item.orderId}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
{item.workflowId} / {item.workflowType}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-[var(--ink-muted)]">
|
||||
<StatusBadge variant="workflowStep" status={item.currentStep} />
|
||||
<span>{item.currentStepLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : null}
|
||||
|
||||
{!isLoading && !items.length ? (
|
||||
<EmptyState
|
||||
eyebrow="Lookup empty"
|
||||
title="暂无流程索引"
|
||||
description="当前筛选条件下还没有可展示的流程记录。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--border-soft)] pt-3">
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
第 {Math.min(currentPage, effectiveTotalPages)} / {effectiveTotalPages} 页
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => onPageChange?.(currentPage - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage >= effectiveTotalPages}
|
||||
onClick={() => onPageChange?.(currentPage + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowLookupScreen() {
|
||||
const router = useRouter();
|
||||
const [items, setItems] = useState<WorkflowLookupItemVM[]>([]);
|
||||
const [message, setMessage] = useState(DEFAULT_MESSAGE);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedQuery, setSelectedQuery] = useState("");
|
||||
const [selectedStatus, setSelectedStatus] = useState<FilterStatus>("all");
|
||||
const [pagination, setPagination] = useState<PaginationData>(DEFAULT_PAGINATION);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadWorkflowIndex() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(pagination.page),
|
||||
limit: String(pagination.limit),
|
||||
});
|
||||
|
||||
if (selectedStatus !== "all") {
|
||||
params.set("status", selectedStatus);
|
||||
}
|
||||
|
||||
if (selectedQuery.length > 0) {
|
||||
params.set("query", selectedQuery);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/dashboard/workflow-lookup?${params.toString()}`);
|
||||
const payload = (await response.json()) as WorkflowLookupEnvelope;
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems(payload.data?.items ?? []);
|
||||
setPagination((current) => ({
|
||||
page: payload.data?.page ?? current.page,
|
||||
limit: payload.data?.limit ?? current.limit,
|
||||
total: payload.data?.total ?? current.total,
|
||||
totalPages: payload.data?.totalPages ?? current.totalPages,
|
||||
}));
|
||||
setMessage(payload.message ?? DEFAULT_MESSAGE);
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems([]);
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
}));
|
||||
setMessage("流程索引加载失败,请稍后重试。");
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadWorkflowIndex();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [pagination.limit, pagination.page, selectedQuery, selectedStatus]);
|
||||
|
||||
return (
|
||||
<WorkflowLookup
|
||||
currentPage={pagination.page}
|
||||
isLoading={isLoading}
|
||||
items={items}
|
||||
message={message}
|
||||
onPageChange={(page) =>
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
page,
|
||||
}))
|
||||
}
|
||||
onQuerySubmit={(query) => {
|
||||
setSelectedQuery(query);
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
page: 1,
|
||||
}));
|
||||
}}
|
||||
onStatusChange={(status) => {
|
||||
setSelectedStatus(status);
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
page: 1,
|
||||
}));
|
||||
}}
|
||||
onOpenWorkflow={(orderId) => router.push(`/workflows/${orderId}`)}
|
||||
selectedQuery={selectedQuery}
|
||||
selectedStatus={selectedStatus}
|
||||
totalPages={pagination.totalPages}
|
||||
/>
|
||||
);
|
||||
}
|
||||
121
src/lib/adapters/orders.ts
Normal file
121
src/lib/adapters/orders.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
AssetDto,
|
||||
AssetType,
|
||||
OrderDetailResponseDto,
|
||||
OrderListItemDto,
|
||||
WorkflowStepName,
|
||||
} from "@/lib/types/backend";
|
||||
import { getOrderStatusMeta, getWorkflowStepMeta } from "@/lib/types/status";
|
||||
import {
|
||||
businessEmptyState,
|
||||
READY_STATE,
|
||||
type AssetViewModel,
|
||||
type OrderDetailVM,
|
||||
type OrderSummaryVM,
|
||||
} from "@/lib/types/view-models";
|
||||
|
||||
const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
prepared_model: "模型准备图",
|
||||
tryon: "试穿图",
|
||||
scene: "场景图",
|
||||
texture: "纹理图",
|
||||
face: "面部图",
|
||||
fusion: "融合图",
|
||||
qc_candidate: "质检候选图",
|
||||
manual_revision: "人工修订稿",
|
||||
final: "最终图",
|
||||
};
|
||||
|
||||
function isMockUri(uri: string): boolean {
|
||||
return uri.startsWith("mock://");
|
||||
}
|
||||
|
||||
function getAssetLabel(assetType: AssetType, stepName: WorkflowStepName | null) {
|
||||
if (stepName) {
|
||||
return `${getWorkflowStepMeta(stepName).label}产物`;
|
||||
}
|
||||
|
||||
return ASSET_TYPE_LABELS[assetType];
|
||||
}
|
||||
|
||||
export function adaptAsset(asset: AssetDto): AssetViewModel {
|
||||
const stepMeta = getWorkflowStepMeta(asset.step_name);
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
orderId: asset.order_id,
|
||||
type: asset.asset_type,
|
||||
stepName: asset.step_name,
|
||||
parentAssetId: asset.parent_asset_id,
|
||||
rootAssetId: asset.root_asset_id,
|
||||
versionNo: asset.version_no,
|
||||
isCurrentVersion: asset.is_current_version,
|
||||
stepLabel: stepMeta.label,
|
||||
label: getAssetLabel(asset.asset_type, asset.step_name),
|
||||
uri: asset.uri,
|
||||
metadata: asset.metadata_json,
|
||||
createdAt: asset.created_at,
|
||||
isMock: isMockUri(asset.uri),
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptOrderSummary(
|
||||
order: Pick<
|
||||
OrderDetailResponseDto | OrderListItemDto,
|
||||
"order_id" | "workflow_id" | "status" | "current_step" | "updated_at"
|
||||
>,
|
||||
): OrderSummaryVM {
|
||||
return {
|
||||
orderId: order.order_id,
|
||||
workflowId: order.workflow_id,
|
||||
status: order.status,
|
||||
statusMeta: getOrderStatusMeta(order.status),
|
||||
currentStep: order.current_step,
|
||||
currentStepLabel: getWorkflowStepMeta(order.current_step).label,
|
||||
updatedAt: order.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptOrderDetail(
|
||||
order: OrderDetailResponseDto,
|
||||
assets: AssetDto[] = [],
|
||||
): OrderDetailVM {
|
||||
const finalAsset = order.final_asset ? adaptAsset(order.final_asset) : null;
|
||||
const assetItems = assets.map(adaptAsset);
|
||||
const hasMockAssets = [finalAsset, ...assetItems].some(
|
||||
(asset) => asset?.isMock,
|
||||
);
|
||||
|
||||
return {
|
||||
orderId: order.order_id,
|
||||
workflowId: order.workflow_id,
|
||||
customerLevel: order.customer_level,
|
||||
serviceMode: order.service_mode,
|
||||
status: order.status,
|
||||
statusMeta: getOrderStatusMeta(order.status),
|
||||
currentStep: order.current_step,
|
||||
currentStepLabel: getWorkflowStepMeta(order.current_step).label,
|
||||
modelId: order.model_id,
|
||||
poseId: order.pose_id,
|
||||
garmentAssetId: order.garment_asset_id,
|
||||
sceneRefAssetId: order.scene_ref_asset_id,
|
||||
currentRevisionAssetId: order.current_revision_asset_id,
|
||||
currentRevisionVersion: order.current_revision_version,
|
||||
latestRevisionAssetId: order.latest_revision_asset_id,
|
||||
latestRevisionVersion: order.latest_revision_version,
|
||||
revisionCount: order.revision_count,
|
||||
reviewTaskStatus: order.review_task_status,
|
||||
pendingManualConfirm: order.pending_manual_confirm,
|
||||
createdAt: order.created_at,
|
||||
updatedAt: order.updated_at,
|
||||
finalAsset,
|
||||
finalAssetState: finalAsset
|
||||
? READY_STATE
|
||||
: businessEmptyState("最终图暂未生成", "当前订单还没有可展示的最终结果。"),
|
||||
assets: assetItems,
|
||||
assetGalleryState: assetItems.length
|
||||
? READY_STATE
|
||||
: businessEmptyState("暂无资产", "当前订单还没有生成可查看的资产列表。"),
|
||||
hasMockAssets,
|
||||
};
|
||||
}
|
||||
69
src/lib/adapters/reviews.ts
Normal file
69
src/lib/adapters/reviews.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
PendingReviewResponseDto,
|
||||
SubmitReviewResponseDto,
|
||||
} from "@/lib/types/backend";
|
||||
import {
|
||||
getOrderStatusMeta,
|
||||
getReviewDecisionMeta,
|
||||
getWorkflowStepMeta,
|
||||
} from "@/lib/types/status";
|
||||
import {
|
||||
businessEmptyState,
|
||||
READY_STATE,
|
||||
type ReviewQueueItemVM,
|
||||
type ReviewQueueVM,
|
||||
type ReviewSubmissionVM,
|
||||
} from "@/lib/types/view-models";
|
||||
|
||||
function adaptPendingReviewItem(
|
||||
review: PendingReviewResponseDto,
|
||||
): ReviewQueueItemVM {
|
||||
return {
|
||||
reviewTaskId: review.review_task_id,
|
||||
orderId: review.order_id,
|
||||
workflowId: review.workflow_id,
|
||||
workflowType: null,
|
||||
currentStep: review.current_step,
|
||||
currentStepLabel: getWorkflowStepMeta(review.current_step).label,
|
||||
createdAt: review.created_at,
|
||||
status: "waiting_review",
|
||||
statusMeta: getOrderStatusMeta("waiting_review"),
|
||||
reviewTaskStatus: review.review_task_status,
|
||||
latestRevisionAssetId: review.latest_revision_asset_id,
|
||||
currentRevisionAssetId: review.current_revision_asset_id,
|
||||
latestRevisionVersion: review.latest_revision_version,
|
||||
revisionCount: review.revision_count,
|
||||
pendingManualConfirm: review.pending_manual_confirm,
|
||||
hasMockAssets: false,
|
||||
failureCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptPendingReviews(
|
||||
reviews: PendingReviewResponseDto[],
|
||||
): ReviewQueueVM {
|
||||
const items = reviews.map(adaptPendingReviewItem);
|
||||
|
||||
return {
|
||||
items,
|
||||
state: items.length
|
||||
? READY_STATE
|
||||
: businessEmptyState(
|
||||
"暂无待审核订单",
|
||||
"当前没有等待人工处理的审核任务。",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptReviewSubmission(
|
||||
submission: SubmitReviewResponseDto,
|
||||
): ReviewSubmissionVM {
|
||||
return {
|
||||
orderId: submission.order_id,
|
||||
workflowId: submission.workflow_id,
|
||||
revisionAssetId: submission.revision_asset_id,
|
||||
decision: submission.decision,
|
||||
decisionMeta: getReviewDecisionMeta(submission.decision),
|
||||
status: submission.status,
|
||||
};
|
||||
}
|
||||
44
src/lib/adapters/revisions.ts
Normal file
44
src/lib/adapters/revisions.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
RegisterRevisionResponseDto,
|
||||
RevisionChainResponseDto,
|
||||
} from "@/lib/types/backend";
|
||||
import type {
|
||||
RevisionChainVM,
|
||||
RevisionRegistrationVM,
|
||||
} from "@/lib/types/view-models";
|
||||
|
||||
export function adaptRevisionRegistration(
|
||||
payload: RegisterRevisionResponseDto,
|
||||
): RevisionRegistrationVM {
|
||||
return {
|
||||
orderId: payload.order_id,
|
||||
workflowId: payload.workflow_id,
|
||||
assetId: payload.asset_id,
|
||||
parentAssetId: payload.parent_asset_id,
|
||||
rootAssetId: payload.root_asset_id,
|
||||
versionNo: payload.version_no,
|
||||
reviewTaskStatus: payload.review_task_status,
|
||||
latestRevisionAssetId: payload.latest_revision_asset_id,
|
||||
revisionCount: payload.revision_count,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptRevisionChain(
|
||||
payload: RevisionChainResponseDto,
|
||||
): RevisionChainVM {
|
||||
return {
|
||||
orderId: payload.order_id,
|
||||
latestRevisionAssetId: payload.latest_revision_asset_id,
|
||||
revisionCount: payload.revision_count,
|
||||
items: payload.items.map((item) => ({
|
||||
assetId: item.asset_id,
|
||||
orderId: item.order_id,
|
||||
parentAssetId: item.parent_asset_id,
|
||||
rootAssetId: item.root_asset_id,
|
||||
versionNo: item.version_no,
|
||||
isCurrentVersion: item.is_current_version,
|
||||
uri: item.uri,
|
||||
createdAt: item.created_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
154
src/lib/adapters/workflows.ts
Normal file
154
src/lib/adapters/workflows.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
WorkflowListItemDto,
|
||||
WorkflowStatusResponseDto,
|
||||
} from "@/lib/types/backend";
|
||||
import {
|
||||
getOrderStatusMeta,
|
||||
getStepStatusMeta,
|
||||
getWorkflowStepMeta,
|
||||
} from "@/lib/types/status";
|
||||
import {
|
||||
businessEmptyState,
|
||||
READY_STATE,
|
||||
type WorkflowDetailVM,
|
||||
type WorkflowLookupItemVM,
|
||||
type WorkflowStepVM,
|
||||
} from "@/lib/types/view-models";
|
||||
|
||||
type WorkflowAssetUriField =
|
||||
| "asset_uri"
|
||||
| "candidate_uri"
|
||||
| "preview_uri"
|
||||
| "result_uri"
|
||||
| "source_uri";
|
||||
|
||||
const WORKFLOW_ASSET_URI_FIELDS = new Set<WorkflowAssetUriField>([
|
||||
"asset_uri",
|
||||
"candidate_uri",
|
||||
"preview_uri",
|
||||
"result_uri",
|
||||
"source_uri",
|
||||
]);
|
||||
|
||||
function collectKnownAssetUris(
|
||||
value: JsonValue | undefined,
|
||||
results: string[] = [],
|
||||
): string[] {
|
||||
if (!value || typeof value !== "object") {
|
||||
return results;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectKnownAssetUris(item, results);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
if (
|
||||
WORKFLOW_ASSET_URI_FIELDS.has(key as WorkflowAssetUriField) &&
|
||||
typeof nestedValue === "string" &&
|
||||
nestedValue.startsWith("mock://")
|
||||
) {
|
||||
results.push(nestedValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
collectKnownAssetUris(nestedValue, results);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function uniqueMockUris(...payloads: Array<JsonObject | null>): string[] {
|
||||
return [...new Set(payloads.flatMap((payload) => collectKnownAssetUris(payload)))];
|
||||
}
|
||||
|
||||
function adaptWorkflowStep(
|
||||
currentStep: WorkflowStatusResponseDto["current_step"],
|
||||
step: WorkflowStatusResponseDto["steps"][number],
|
||||
): WorkflowStepVM {
|
||||
const stepMeta = getWorkflowStepMeta(step.step_name);
|
||||
const mockAssetUris = uniqueMockUris(step.input_json, step.output_json);
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
workflowRunId: step.workflow_run_id,
|
||||
name: step.step_name,
|
||||
label: stepMeta.label,
|
||||
status: step.step_status,
|
||||
statusMeta: getStepStatusMeta(step.step_status),
|
||||
input: step.input_json,
|
||||
output: step.output_json,
|
||||
errorMessage: step.error_message,
|
||||
startedAt: step.started_at,
|
||||
endedAt: step.ended_at,
|
||||
containsMockAssets: mockAssetUris.length > 0,
|
||||
mockAssetUris,
|
||||
isCurrent: currentStep === step.step_name,
|
||||
isFailed: step.step_status === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptWorkflowLookupItem(
|
||||
workflow: Pick<
|
||||
WorkflowStatusResponseDto | WorkflowListItemDto,
|
||||
| "order_id"
|
||||
| "workflow_id"
|
||||
| "workflow_type"
|
||||
| "workflow_status"
|
||||
| "current_step"
|
||||
| "updated_at"
|
||||
>,
|
||||
): WorkflowLookupItemVM {
|
||||
return {
|
||||
orderId: workflow.order_id,
|
||||
workflowId: workflow.workflow_id,
|
||||
workflowType: workflow.workflow_type,
|
||||
status: workflow.workflow_status,
|
||||
statusMeta: getOrderStatusMeta(workflow.workflow_status),
|
||||
currentStep: workflow.current_step,
|
||||
currentStepLabel: getWorkflowStepMeta(workflow.current_step).label,
|
||||
updatedAt: workflow.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptWorkflowDetail(
|
||||
workflow: WorkflowStatusResponseDto,
|
||||
): WorkflowDetailVM {
|
||||
const steps = workflow.steps.map((step) =>
|
||||
adaptWorkflowStep(workflow.current_step, step),
|
||||
);
|
||||
|
||||
return {
|
||||
orderId: workflow.order_id,
|
||||
workflowId: workflow.workflow_id,
|
||||
workflowType: workflow.workflow_type,
|
||||
status: workflow.workflow_status,
|
||||
statusMeta: getOrderStatusMeta(workflow.workflow_status),
|
||||
currentStep: workflow.current_step,
|
||||
currentStepLabel: getWorkflowStepMeta(workflow.current_step).label,
|
||||
currentRevisionAssetId: workflow.current_revision_asset_id,
|
||||
currentRevisionVersion: workflow.current_revision_version,
|
||||
latestRevisionAssetId: workflow.latest_revision_asset_id,
|
||||
latestRevisionVersion: workflow.latest_revision_version,
|
||||
revisionCount: workflow.revision_count,
|
||||
reviewTaskStatus: workflow.review_task_status,
|
||||
pendingManualConfirm: workflow.pending_manual_confirm,
|
||||
createdAt: workflow.created_at,
|
||||
updatedAt: workflow.updated_at,
|
||||
steps,
|
||||
stepTimelineState: steps.length
|
||||
? READY_STATE
|
||||
: businessEmptyState(
|
||||
"暂无流程记录",
|
||||
"当前工作流还没有可展示的步骤执行记录。",
|
||||
),
|
||||
failureCount: steps.filter((step) => step.isFailed).length,
|
||||
hasMockAssets: steps.some((step) => step.containsMockAssets),
|
||||
};
|
||||
}
|
||||
5
src/lib/env.ts
Normal file
5
src/lib/env.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
const DEFAULT_BACKEND_BASE_URL = "http://127.0.0.1:8000/api/v1";
|
||||
|
||||
export function getBackendBaseUrl(): string {
|
||||
return process.env.BACKEND_BASE_URL ?? DEFAULT_BACKEND_BASE_URL;
|
||||
}
|
||||
113
src/lib/http/backend-client.ts
Normal file
113
src/lib/http/backend-client.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { getBackendBaseUrl } from "@/lib/env";
|
||||
import { RouteError, isObject } from "@/lib/http/response";
|
||||
|
||||
function buildBackendUrl(pathname: string): string {
|
||||
const baseUrl = getBackendBaseUrl().replace(/\/$/, "");
|
||||
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||
|
||||
return `${baseUrl}${normalizedPathname}`;
|
||||
}
|
||||
|
||||
function extractBackendMessage(payload: unknown): string | undefined {
|
||||
if (typeof payload === "string" && payload.length > 0) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (!isObject(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof payload.message === "string" && payload.message.length > 0) {
|
||||
return payload.message;
|
||||
}
|
||||
|
||||
if (typeof payload.detail === "string" && payload.detail.length > 0) {
|
||||
return payload.detail;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function parseBackendPayload(response: Response): Promise<unknown> {
|
||||
const rawText = await response.text();
|
||||
|
||||
if (rawText.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawText) as unknown;
|
||||
} catch {
|
||||
throw new RouteError(
|
||||
502,
|
||||
"BACKEND_ERROR",
|
||||
"后端返回了无法解析的响应。",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function backendRequest<T>(
|
||||
pathname: string,
|
||||
init?: RequestInit,
|
||||
): Promise<{ data: T; status: number }> {
|
||||
const url = buildBackendUrl(pathname);
|
||||
const headers = new Headers(init?.headers);
|
||||
|
||||
if (!headers.has("accept")) {
|
||||
headers.set("accept", "application/json");
|
||||
}
|
||||
|
||||
if (init?.body && !headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RouteError(
|
||||
502,
|
||||
"BACKEND_UNAVAILABLE",
|
||||
"后端暂时不可用,请稍后重试。",
|
||||
error instanceof Error ? error.message : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await parseBackendPayload(response);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new RouteError(
|
||||
404,
|
||||
"NOT_FOUND",
|
||||
extractBackendMessage(payload) ?? "请求的资源不存在。",
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 400 || response.status === 422) {
|
||||
throw new RouteError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
extractBackendMessage(payload) ?? "请求参数无效。",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
throw new RouteError(
|
||||
502,
|
||||
"BACKEND_ERROR",
|
||||
extractBackendMessage(payload) ?? "后端请求失败,请稍后重试。",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
data: payload as T,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
113
src/lib/http/response.ts
Normal file
113
src/lib/http/response.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export type ResponseMode = "proxy" | "placeholder";
|
||||
|
||||
export class RouteError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RouteError";
|
||||
}
|
||||
}
|
||||
|
||||
type SuccessOptions = {
|
||||
status?: number;
|
||||
mode?: ResponseMode;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type ErrorBody = {
|
||||
error: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export function jsonSuccess<T>(data: T, options: SuccessOptions = {}) {
|
||||
const body: {
|
||||
mode: ResponseMode;
|
||||
data: T;
|
||||
message?: string;
|
||||
} = {
|
||||
mode: options.mode ?? "proxy",
|
||||
data,
|
||||
};
|
||||
|
||||
if (options.message) {
|
||||
body.message = options.message;
|
||||
}
|
||||
|
||||
return NextResponse.json(body, {
|
||||
status: options.status ?? 200,
|
||||
});
|
||||
}
|
||||
|
||||
export function jsonError(
|
||||
status: number,
|
||||
error: string,
|
||||
message: string,
|
||||
details?: unknown,
|
||||
) {
|
||||
const body: ErrorBody = {
|
||||
error,
|
||||
message,
|
||||
};
|
||||
|
||||
if (details !== undefined) {
|
||||
body.details = details;
|
||||
}
|
||||
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
|
||||
export function handleRouteError(error: unknown) {
|
||||
if (error instanceof RouteError) {
|
||||
return jsonError(error.status, error.code, error.message, error.details);
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
return jsonError(500, "SYSTEM_ERROR", "系统内部错误,请稍后重试。");
|
||||
}
|
||||
|
||||
export async function withErrorHandling(
|
||||
handler: () => Promise<NextResponse>,
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
return await handler();
|
||||
} catch (error) {
|
||||
return handleRouteError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseJsonBody<T>(request: Request): Promise<T> {
|
||||
try {
|
||||
return (await request.json()) as T;
|
||||
} catch {
|
||||
throw new RouteError(400, "INVALID_JSON", "请求体必须是合法 JSON。");
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePositiveIntegerParam(
|
||||
value: string,
|
||||
fieldName: string,
|
||||
): number {
|
||||
const parsedValue = Number(value);
|
||||
|
||||
if (!Number.isInteger(parsedValue) || parsedValue < 1) {
|
||||
throw new RouteError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
`${fieldName} 必须是正整数。`,
|
||||
);
|
||||
}
|
||||
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
64
src/lib/mock/libraries.ts
Normal file
64
src/lib/mock/libraries.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { LibraryItemVM } from "@/lib/types/view-models";
|
||||
|
||||
export const MODEL_LIBRARY_FIXTURES: LibraryItemVM[] = [
|
||||
{
|
||||
id: "model-ava",
|
||||
libraryType: "models",
|
||||
name: "Ava / Studio",
|
||||
description: "中性棚拍模特占位数据,用于提交页联调。",
|
||||
previewUri: "mock://libraries/models/ava",
|
||||
tags: ["女装", "半身", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
{
|
||||
id: "model-jian",
|
||||
libraryType: "models",
|
||||
name: "Jian / Editorial",
|
||||
description: "男装模特占位数据,保留资源库信息架构。",
|
||||
previewUri: "mock://libraries/models/jian",
|
||||
tags: ["男装", "全身", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SCENE_LIBRARY_FIXTURES: LibraryItemVM[] = [
|
||||
{
|
||||
id: "scene-loft",
|
||||
libraryType: "scenes",
|
||||
name: "Loft Window",
|
||||
description: "暖调室内场景占位素材。",
|
||||
previewUri: "mock://libraries/scenes/loft-window",
|
||||
tags: ["室内", "暖光", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
{
|
||||
id: "scene-garden",
|
||||
libraryType: "scenes",
|
||||
name: "Garden Walk",
|
||||
description: "户外花园场景占位素材。",
|
||||
previewUri: "mock://libraries/scenes/garden-walk",
|
||||
tags: ["户外", "自然光", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const GARMENT_LIBRARY_FIXTURES: LibraryItemVM[] = [
|
||||
{
|
||||
id: "garment-coat-01",
|
||||
libraryType: "garments",
|
||||
name: "Structured Coat 01",
|
||||
description: "大衣品类占位素材,供提单页联调使用。",
|
||||
previewUri: "mock://libraries/garments/coat-01",
|
||||
tags: ["大衣", "秋冬", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
{
|
||||
id: "garment-dress-03",
|
||||
libraryType: "garments",
|
||||
name: "Silk Dress 03",
|
||||
description: "连衣裙品类占位素材,保持资源库路由完整。",
|
||||
previewUri: "mock://libraries/garments/dress-03",
|
||||
tags: ["连衣裙", "礼服", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
];
|
||||
85
src/lib/mock/orders.ts
Normal file
85
src/lib/mock/orders.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { AssetDto, OrderDetailResponseDto } from "@/lib/types/backend";
|
||||
|
||||
export const ORDER_DETAIL_DTO_FIXTURE: OrderDetailResponseDto = {
|
||||
order_id: 4201,
|
||||
customer_level: "mid",
|
||||
service_mode: "semi_pro",
|
||||
status: "waiting_review",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
final_asset_id: null,
|
||||
workflow_id: "wf-4201",
|
||||
current_step: "review",
|
||||
current_revision_asset_id: null,
|
||||
current_revision_version: null,
|
||||
latest_revision_asset_id: null,
|
||||
latest_revision_version: null,
|
||||
revision_count: 0,
|
||||
review_task_status: null,
|
||||
pending_manual_confirm: false,
|
||||
final_asset: null,
|
||||
created_at: "2026-03-27T09:00:00Z",
|
||||
updated_at: "2026-03-27T09:15:00Z",
|
||||
};
|
||||
|
||||
export const ORDER_ASSET_DTO_FIXTURES: AssetDto[] = [
|
||||
{
|
||||
id: 9001,
|
||||
order_id: 4201,
|
||||
asset_type: "fusion",
|
||||
step_name: "fusion",
|
||||
parent_asset_id: null,
|
||||
root_asset_id: null,
|
||||
version_no: 0,
|
||||
is_current_version: false,
|
||||
uri: "mock://fusion-4201",
|
||||
metadata_json: {
|
||||
note: "placeholder asset",
|
||||
},
|
||||
created_at: "2026-03-27T09:10:00Z",
|
||||
},
|
||||
{
|
||||
id: 9002,
|
||||
order_id: 4201,
|
||||
asset_type: "qc_candidate",
|
||||
step_name: "qc",
|
||||
parent_asset_id: null,
|
||||
root_asset_id: null,
|
||||
version_no: 0,
|
||||
is_current_version: false,
|
||||
uri: "mock://qc-4201",
|
||||
metadata_json: null,
|
||||
created_at: "2026-03-27T09:12:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
export const RECENT_ORDER_SUMMARY_DTO_FIXTURES: Array<
|
||||
Pick<
|
||||
OrderDetailResponseDto,
|
||||
"order_id" | "workflow_id" | "status" | "current_step" | "updated_at"
|
||||
>
|
||||
> = [
|
||||
{
|
||||
order_id: ORDER_DETAIL_DTO_FIXTURE.order_id,
|
||||
workflow_id: ORDER_DETAIL_DTO_FIXTURE.workflow_id,
|
||||
status: ORDER_DETAIL_DTO_FIXTURE.status,
|
||||
current_step: ORDER_DETAIL_DTO_FIXTURE.current_step,
|
||||
updated_at: ORDER_DETAIL_DTO_FIXTURE.updated_at,
|
||||
},
|
||||
{
|
||||
order_id: 4202,
|
||||
workflow_id: "wf-4202",
|
||||
status: "running",
|
||||
current_step: "fusion",
|
||||
updated_at: "2026-03-27T09:30:00Z",
|
||||
},
|
||||
{
|
||||
order_id: 4203,
|
||||
workflow_id: "wf-4203",
|
||||
status: "succeeded",
|
||||
current_step: "export",
|
||||
updated_at: "2026-03-27T08:58:00Z",
|
||||
},
|
||||
];
|
||||
75
src/lib/mock/workflows.ts
Normal file
75
src/lib/mock/workflows.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { WorkflowStatusResponseDto } from "@/lib/types/backend";
|
||||
|
||||
export const WORKFLOW_DETAIL_DTO_FIXTURE: WorkflowStatusResponseDto = {
|
||||
order_id: 4201,
|
||||
workflow_id: "wf-4201",
|
||||
workflow_type: "mid_end",
|
||||
workflow_status: "running",
|
||||
current_step: "review",
|
||||
current_revision_asset_id: null,
|
||||
current_revision_version: null,
|
||||
latest_revision_asset_id: null,
|
||||
latest_revision_version: null,
|
||||
revision_count: 0,
|
||||
review_task_status: null,
|
||||
pending_manual_confirm: false,
|
||||
steps: [
|
||||
{
|
||||
id: 1,
|
||||
workflow_run_id: 88,
|
||||
step_name: "prepare_model",
|
||||
step_status: "succeeded",
|
||||
input_json: null,
|
||||
output_json: {
|
||||
preview_uri: "mock://workflow/4201/prepare-model",
|
||||
},
|
||||
error_message: null,
|
||||
started_at: "2026-03-27T09:00:00Z",
|
||||
ended_at: "2026-03-27T09:01:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
workflow_run_id: 88,
|
||||
step_name: "review",
|
||||
step_status: "waiting",
|
||||
input_json: {
|
||||
candidate_uri: "mock://workflow/4201/review-candidate",
|
||||
},
|
||||
output_json: null,
|
||||
error_message: null,
|
||||
started_at: "2026-03-27T09:12:00Z",
|
||||
ended_at: null,
|
||||
},
|
||||
],
|
||||
created_at: "2026-03-27T09:00:00Z",
|
||||
updated_at: "2026-03-27T09:15:00Z",
|
||||
};
|
||||
|
||||
export const WORKFLOW_LOOKUP_DTO_FIXTURES: Array<
|
||||
Pick<
|
||||
WorkflowStatusResponseDto,
|
||||
| "order_id"
|
||||
| "workflow_id"
|
||||
| "workflow_type"
|
||||
| "workflow_status"
|
||||
| "current_step"
|
||||
| "updated_at"
|
||||
>
|
||||
> = [
|
||||
{
|
||||
order_id: WORKFLOW_DETAIL_DTO_FIXTURE.order_id,
|
||||
workflow_id: WORKFLOW_DETAIL_DTO_FIXTURE.workflow_id,
|
||||
workflow_type: WORKFLOW_DETAIL_DTO_FIXTURE.workflow_type,
|
||||
workflow_status: WORKFLOW_DETAIL_DTO_FIXTURE.workflow_status,
|
||||
current_step: WORKFLOW_DETAIL_DTO_FIXTURE.current_step,
|
||||
updated_at: WORKFLOW_DETAIL_DTO_FIXTURE.updated_at,
|
||||
},
|
||||
{
|
||||
order_id: 4202,
|
||||
workflow_id: "wf-4202",
|
||||
workflow_type: "mid_end",
|
||||
workflow_status: "failed",
|
||||
current_step: "fusion",
|
||||
updated_at: "2026-03-27T08:40:00Z",
|
||||
},
|
||||
];
|
||||
267
src/lib/types/backend.ts
Normal file
267
src/lib/types/backend.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
|
||||
export type JsonObject = {
|
||||
[key: string]: JsonValue;
|
||||
};
|
||||
|
||||
export type CustomerLevel = "low" | "mid";
|
||||
|
||||
export type ServiceMode = "auto_basic" | "semi_pro";
|
||||
|
||||
export type OrderStatus =
|
||||
| "created"
|
||||
| "running"
|
||||
| "waiting_review"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export type WorkflowStepName =
|
||||
| "prepare_model"
|
||||
| "tryon"
|
||||
| "scene"
|
||||
| "texture"
|
||||
| "face"
|
||||
| "fusion"
|
||||
| "qc"
|
||||
| "export"
|
||||
| "review";
|
||||
|
||||
export type ReviewDecision =
|
||||
| "approve"
|
||||
| "rerun_scene"
|
||||
| "rerun_face"
|
||||
| "rerun_fusion"
|
||||
| "reject";
|
||||
|
||||
export type ReviewTaskStatus =
|
||||
| "pending"
|
||||
| "revision_uploaded"
|
||||
| "submitted";
|
||||
|
||||
export type StepStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "succeeded"
|
||||
| "failed";
|
||||
|
||||
export type AssetType =
|
||||
| "prepared_model"
|
||||
| "tryon"
|
||||
| "scene"
|
||||
| "texture"
|
||||
| "face"
|
||||
| "fusion"
|
||||
| "qc_candidate"
|
||||
| "manual_revision"
|
||||
| "final";
|
||||
|
||||
export type CreateOrderRequestDto = {
|
||||
customer_level: CustomerLevel;
|
||||
service_mode: ServiceMode;
|
||||
model_id: number;
|
||||
pose_id: number;
|
||||
garment_asset_id: number;
|
||||
scene_ref_asset_id: number;
|
||||
};
|
||||
|
||||
export type CreateOrderResponseDto = {
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
status: OrderStatus;
|
||||
};
|
||||
|
||||
export type AssetDto = {
|
||||
id: number;
|
||||
order_id: number;
|
||||
asset_type: AssetType;
|
||||
step_name: WorkflowStepName | null;
|
||||
parent_asset_id: number | null;
|
||||
root_asset_id: number | null;
|
||||
version_no: number;
|
||||
is_current_version: boolean;
|
||||
uri: string;
|
||||
metadata_json: JsonObject | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type OrderDetailResponseDto = {
|
||||
order_id: number;
|
||||
customer_level: CustomerLevel;
|
||||
service_mode: ServiceMode;
|
||||
status: OrderStatus;
|
||||
model_id: number;
|
||||
pose_id: number;
|
||||
garment_asset_id: number;
|
||||
scene_ref_asset_id: number;
|
||||
final_asset_id: number | null;
|
||||
workflow_id: string | null;
|
||||
current_step: WorkflowStepName | null;
|
||||
current_revision_asset_id: number | null;
|
||||
current_revision_version: number | null;
|
||||
latest_revision_asset_id: number | null;
|
||||
latest_revision_version: number | null;
|
||||
revision_count: number;
|
||||
review_task_status: ReviewTaskStatus | null;
|
||||
pending_manual_confirm: boolean;
|
||||
final_asset: AssetDto | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type OrderListItemDto = {
|
||||
order_id: number;
|
||||
workflow_id: string | null;
|
||||
customer_level: CustomerLevel;
|
||||
service_mode: ServiceMode;
|
||||
status: OrderStatus;
|
||||
current_step: WorkflowStepName | null;
|
||||
updated_at: string;
|
||||
final_asset_id: number | null;
|
||||
review_task_status: ReviewTaskStatus | null;
|
||||
latest_revision_asset_id: number | null;
|
||||
latest_revision_version: number | null;
|
||||
revision_count: number;
|
||||
pending_manual_confirm: boolean;
|
||||
};
|
||||
|
||||
export type OrderListResponseDto = {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
items: OrderListItemDto[];
|
||||
};
|
||||
|
||||
export type PendingReviewResponseDto = {
|
||||
review_task_id: number;
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
current_step: WorkflowStepName | null;
|
||||
review_task_status: ReviewTaskStatus;
|
||||
latest_revision_asset_id: number | null;
|
||||
current_revision_asset_id: number | null;
|
||||
latest_revision_version: number | null;
|
||||
revision_count: number;
|
||||
pending_manual_confirm: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type SubmitReviewRequestDto = {
|
||||
decision: ReviewDecision;
|
||||
reviewer_id: number;
|
||||
selected_asset_id: number | null;
|
||||
comment: string | null;
|
||||
};
|
||||
|
||||
export type SubmitReviewResponseDto = {
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
revision_asset_id?: number;
|
||||
decision: ReviewDecision;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type RegisterRevisionRequestDto = {
|
||||
parent_asset_id: number;
|
||||
uploaded_uri: string;
|
||||
reviewer_id: number;
|
||||
comment: string | null;
|
||||
};
|
||||
|
||||
export type RegisterRevisionResponseDto = {
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
asset_id: number;
|
||||
parent_asset_id: number;
|
||||
root_asset_id: number;
|
||||
version_no: number;
|
||||
review_task_status: ReviewTaskStatus;
|
||||
latest_revision_asset_id: number;
|
||||
revision_count: number;
|
||||
};
|
||||
|
||||
export type RevisionChainItemDto = {
|
||||
asset_id: number;
|
||||
order_id: number;
|
||||
parent_asset_id: number | null;
|
||||
root_asset_id: number | null;
|
||||
version_no: number;
|
||||
is_current_version: boolean;
|
||||
uri: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type RevisionChainResponseDto = {
|
||||
order_id: number;
|
||||
latest_revision_asset_id: number | null;
|
||||
revision_count: number;
|
||||
items: RevisionChainItemDto[];
|
||||
};
|
||||
|
||||
export type ConfirmRevisionRequestDto = {
|
||||
reviewer_id: number;
|
||||
comment: string | null;
|
||||
};
|
||||
|
||||
export type ConfirmRevisionResponseDto = {
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
revision_asset_id: number;
|
||||
decision: ReviewDecision;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type WorkflowStepDto = {
|
||||
id: number;
|
||||
workflow_run_id: number;
|
||||
step_name: WorkflowStepName;
|
||||
step_status: StepStatus;
|
||||
input_json: JsonObject | null;
|
||||
output_json: JsonObject | null;
|
||||
error_message: string | null;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowStatusResponseDto = {
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
workflow_type: string;
|
||||
workflow_status: OrderStatus;
|
||||
current_step: WorkflowStepName | null;
|
||||
current_revision_asset_id: number | null;
|
||||
current_revision_version: number | null;
|
||||
latest_revision_asset_id: number | null;
|
||||
latest_revision_version: number | null;
|
||||
revision_count: number;
|
||||
review_task_status: ReviewTaskStatus | null;
|
||||
pending_manual_confirm: boolean;
|
||||
steps: WorkflowStepDto[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type WorkflowListItemDto = {
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
workflow_type: string;
|
||||
workflow_status: OrderStatus;
|
||||
current_step: WorkflowStepName | null;
|
||||
updated_at: string;
|
||||
failure_count: number;
|
||||
review_task_status: ReviewTaskStatus | null;
|
||||
latest_revision_asset_id: number | null;
|
||||
latest_revision_version: number | null;
|
||||
revision_count: number;
|
||||
pending_manual_confirm: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowListResponseDto = {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
items: WorkflowListItemDto[];
|
||||
};
|
||||
72
src/lib/types/status.ts
Normal file
72
src/lib/types/status.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
OrderStatus,
|
||||
ReviewDecision,
|
||||
StepStatus,
|
||||
WorkflowStepName,
|
||||
} from "@/lib/types/backend";
|
||||
|
||||
export type StatusTone = "neutral" | "info" | "warning" | "success" | "danger";
|
||||
|
||||
export type StatusMeta = {
|
||||
label: string;
|
||||
tone: StatusTone;
|
||||
};
|
||||
|
||||
export const ORDER_STATUS_META = {
|
||||
created: { label: "已创建", tone: "neutral" },
|
||||
running: { label: "处理中", tone: "info" },
|
||||
waiting_review: { label: "待审核", tone: "warning" },
|
||||
succeeded: { label: "已完成", tone: "success" },
|
||||
failed: { label: "失败", tone: "danger" },
|
||||
cancelled: { label: "已取消", tone: "neutral" },
|
||||
} as const satisfies Record<OrderStatus, StatusMeta>;
|
||||
|
||||
export const STEP_STATUS_META = {
|
||||
pending: { label: "待执行", tone: "neutral" },
|
||||
running: { label: "执行中", tone: "info" },
|
||||
waiting: { label: "等待人工处理", tone: "warning" },
|
||||
succeeded: { label: "成功", tone: "success" },
|
||||
failed: { label: "失败", tone: "danger" },
|
||||
} as const satisfies Record<StepStatus, StatusMeta>;
|
||||
|
||||
export const REVIEW_DECISION_META = {
|
||||
approve: { label: "通过", tone: "success" },
|
||||
rerun_scene: { label: "重跑场景", tone: "warning" },
|
||||
rerun_face: { label: "重跑面部", tone: "warning" },
|
||||
rerun_fusion: { label: "重跑融合", tone: "warning" },
|
||||
reject: { label: "驳回", tone: "danger" },
|
||||
} as const satisfies Record<ReviewDecision, StatusMeta>;
|
||||
|
||||
export const WORKFLOW_STEP_META = {
|
||||
prepare_model: { label: "模型准备", tone: "neutral" },
|
||||
tryon: { label: "试穿生成", tone: "neutral" },
|
||||
scene: { label: "场景处理", tone: "neutral" },
|
||||
texture: { label: "纹理修复", tone: "neutral" },
|
||||
face: { label: "面部修复", tone: "neutral" },
|
||||
fusion: { label: "融合", tone: "neutral" },
|
||||
qc: { label: "质检", tone: "neutral" },
|
||||
export: { label: "导出", tone: "neutral" },
|
||||
review: { label: "人工审核", tone: "warning" },
|
||||
} as const satisfies Record<WorkflowStepName, StatusMeta>;
|
||||
|
||||
export function getOrderStatusMeta(status: OrderStatus): StatusMeta {
|
||||
return ORDER_STATUS_META[status];
|
||||
}
|
||||
|
||||
export function getReviewDecisionMeta(decision: ReviewDecision): StatusMeta {
|
||||
return REVIEW_DECISION_META[decision];
|
||||
}
|
||||
|
||||
export function getStepStatusMeta(status: StepStatus): StatusMeta {
|
||||
return STEP_STATUS_META[status];
|
||||
}
|
||||
|
||||
export function getWorkflowStepMeta(
|
||||
stepName: WorkflowStepName | null,
|
||||
): StatusMeta {
|
||||
if (!stepName) {
|
||||
return { label: "未开始", tone: "neutral" };
|
||||
}
|
||||
|
||||
return WORKFLOW_STEP_META[stepName];
|
||||
}
|
||||
223
src/lib/types/view-models.ts
Normal file
223
src/lib/types/view-models.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type {
|
||||
AssetType,
|
||||
CustomerLevel,
|
||||
JsonObject,
|
||||
OrderStatus,
|
||||
ReviewDecision,
|
||||
ReviewTaskStatus,
|
||||
ServiceMode,
|
||||
StepStatus,
|
||||
WorkflowStepName,
|
||||
} from "@/lib/types/backend";
|
||||
import type { StatusMeta } from "@/lib/types/status";
|
||||
|
||||
export type ReadyState = {
|
||||
kind: "ready";
|
||||
};
|
||||
|
||||
export type BusinessEmptyState = {
|
||||
kind: "business-empty";
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type SectionState = ReadyState | BusinessEmptyState;
|
||||
|
||||
export type AssetViewModel = {
|
||||
id: number;
|
||||
orderId: number;
|
||||
type: AssetType;
|
||||
stepName: WorkflowStepName | null;
|
||||
parentAssetId: number | null;
|
||||
rootAssetId: number | null;
|
||||
versionNo: number;
|
||||
isCurrentVersion: boolean;
|
||||
stepLabel: string;
|
||||
label: string;
|
||||
uri: string;
|
||||
metadata: JsonObject | null;
|
||||
createdAt: string;
|
||||
isMock: boolean;
|
||||
};
|
||||
|
||||
export type OrderSummaryVM = {
|
||||
orderId: number;
|
||||
workflowId: string | null;
|
||||
status: OrderStatus;
|
||||
statusMeta: StatusMeta;
|
||||
currentStep: WorkflowStepName | null;
|
||||
currentStepLabel: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type OrderDetailVM = {
|
||||
orderId: number;
|
||||
workflowId: string | null;
|
||||
customerLevel: CustomerLevel;
|
||||
serviceMode: ServiceMode;
|
||||
status: OrderStatus;
|
||||
statusMeta: StatusMeta;
|
||||
currentStep: WorkflowStepName | null;
|
||||
currentStepLabel: string;
|
||||
modelId: number;
|
||||
poseId: number;
|
||||
garmentAssetId: number;
|
||||
sceneRefAssetId: number;
|
||||
currentRevisionAssetId: number | null;
|
||||
currentRevisionVersion: number | null;
|
||||
latestRevisionAssetId: number | null;
|
||||
latestRevisionVersion: number | null;
|
||||
revisionCount: number;
|
||||
reviewTaskStatus: ReviewTaskStatus | null;
|
||||
pendingManualConfirm: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
finalAsset: AssetViewModel | null;
|
||||
finalAssetState: SectionState;
|
||||
assets: AssetViewModel[];
|
||||
assetGalleryState: SectionState;
|
||||
hasMockAssets: boolean;
|
||||
};
|
||||
|
||||
export type ReviewQueueItemVM = {
|
||||
reviewTaskId: number;
|
||||
orderId: number;
|
||||
workflowId: string;
|
||||
workflowType: string | null;
|
||||
currentStep: WorkflowStepName | null;
|
||||
currentStepLabel: string;
|
||||
createdAt: string;
|
||||
status: "waiting_review";
|
||||
statusMeta: StatusMeta;
|
||||
reviewTaskStatus: ReviewTaskStatus;
|
||||
latestRevisionAssetId: number | null;
|
||||
currentRevisionAssetId: number | null;
|
||||
latestRevisionVersion: number | null;
|
||||
revisionCount: number;
|
||||
pendingManualConfirm: boolean;
|
||||
hasMockAssets: boolean;
|
||||
failureCount: number;
|
||||
};
|
||||
|
||||
export type ReviewQueueVM = {
|
||||
items: ReviewQueueItemVM[];
|
||||
state: SectionState;
|
||||
};
|
||||
|
||||
export type ReviewSubmissionVM = {
|
||||
orderId: number;
|
||||
workflowId: string;
|
||||
revisionAssetId?: number;
|
||||
decision: ReviewDecision;
|
||||
decisionMeta: StatusMeta;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type RevisionRegistrationVM = {
|
||||
orderId: number;
|
||||
workflowId: string;
|
||||
assetId: number;
|
||||
parentAssetId: number;
|
||||
rootAssetId: number;
|
||||
versionNo: number;
|
||||
reviewTaskStatus: ReviewTaskStatus;
|
||||
latestRevisionAssetId: number;
|
||||
revisionCount: number;
|
||||
};
|
||||
|
||||
export type RevisionChainItemVM = {
|
||||
assetId: number;
|
||||
orderId: number;
|
||||
parentAssetId: number | null;
|
||||
rootAssetId: number | null;
|
||||
versionNo: number;
|
||||
isCurrentVersion: boolean;
|
||||
uri: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type RevisionChainVM = {
|
||||
orderId: number;
|
||||
latestRevisionAssetId: number | null;
|
||||
revisionCount: number;
|
||||
items: RevisionChainItemVM[];
|
||||
};
|
||||
|
||||
export type WorkflowStepVM = {
|
||||
id: number;
|
||||
workflowRunId: number;
|
||||
name: WorkflowStepName;
|
||||
label: string;
|
||||
status: StepStatus;
|
||||
statusMeta: StatusMeta;
|
||||
input: JsonObject | null;
|
||||
output: JsonObject | null;
|
||||
errorMessage: string | null;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
containsMockAssets: boolean;
|
||||
mockAssetUris: string[];
|
||||
isCurrent: boolean;
|
||||
isFailed: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowLookupItemVM = {
|
||||
orderId: number;
|
||||
workflowId: string;
|
||||
workflowType: string;
|
||||
status: OrderStatus;
|
||||
statusMeta: StatusMeta;
|
||||
currentStep: WorkflowStepName | null;
|
||||
currentStepLabel: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type WorkflowDetailVM = {
|
||||
orderId: number;
|
||||
workflowId: string;
|
||||
workflowType: string;
|
||||
status: OrderStatus;
|
||||
statusMeta: StatusMeta;
|
||||
currentStep: WorkflowStepName | null;
|
||||
currentStepLabel: string;
|
||||
currentRevisionAssetId: number | null;
|
||||
currentRevisionVersion: number | null;
|
||||
latestRevisionAssetId: number | null;
|
||||
latestRevisionVersion: number | null;
|
||||
revisionCount: number;
|
||||
reviewTaskStatus: ReviewTaskStatus | null;
|
||||
pendingManualConfirm: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
steps: WorkflowStepVM[];
|
||||
stepTimelineState: SectionState;
|
||||
failureCount: number;
|
||||
hasMockAssets: boolean;
|
||||
};
|
||||
|
||||
export type LibraryType = "models" | "scenes" | "garments";
|
||||
|
||||
export type LibraryItemVM = {
|
||||
id: string;
|
||||
libraryType: LibraryType;
|
||||
name: string;
|
||||
description: string;
|
||||
previewUri: string;
|
||||
tags: string[];
|
||||
isMock: boolean;
|
||||
};
|
||||
|
||||
export const READY_STATE: ReadyState = {
|
||||
kind: "ready",
|
||||
};
|
||||
|
||||
export function businessEmptyState(
|
||||
title: string,
|
||||
description: string,
|
||||
): BusinessEmptyState {
|
||||
return {
|
||||
kind: "business-empty",
|
||||
title,
|
||||
description,
|
||||
};
|
||||
}
|
||||
45
src/lib/validation/create-order.ts
Normal file
45
src/lib/validation/create-order.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CreateOrderRequestDto } from "@/lib/types/backend";
|
||||
import { RouteError } from "@/lib/http/response";
|
||||
|
||||
export const createOrderSchema = z
|
||||
.object({
|
||||
customer_level: z.enum(["low", "mid"]),
|
||||
service_mode: z.enum(["auto_basic", "semi_pro"]),
|
||||
model_id: z.number().int().positive(),
|
||||
pose_id: z.number().int().positive(),
|
||||
garment_asset_id: z.number().int().positive(),
|
||||
scene_ref_asset_id: z.number().int().positive(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const validServiceMode =
|
||||
(value.customer_level === "low" &&
|
||||
value.service_mode === "auto_basic") ||
|
||||
(value.customer_level === "mid" && value.service_mode === "semi_pro");
|
||||
|
||||
if (!validServiceMode) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["service_mode"],
|
||||
message: "当前客户等级不支持该服务模式。",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function parseCreateOrderPayload(
|
||||
payload: unknown,
|
||||
): CreateOrderRequestDto {
|
||||
const result = createOrderSchema.safeParse(payload);
|
||||
|
||||
if (!result.success) {
|
||||
throw new RouteError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
"提单参数不合法。",
|
||||
result.error.flatten(),
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
61
src/lib/validation/review-action.ts
Normal file
61
src/lib/validation/review-action.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { SubmitReviewRequestDto } from "@/lib/types/backend";
|
||||
import { RouteError } from "@/lib/http/response";
|
||||
|
||||
const rerunDecisions = new Set(["rerun_scene", "rerun_face", "rerun_fusion"]);
|
||||
|
||||
export const reviewActionSchema = z
|
||||
.object({
|
||||
decision: z.enum([
|
||||
"approve",
|
||||
"rerun_scene",
|
||||
"rerun_face",
|
||||
"rerun_fusion",
|
||||
"reject",
|
||||
]),
|
||||
reviewer_id: z.number().int().positive(),
|
||||
selected_asset_id: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
comment: z
|
||||
.union([z.string(), z.null(), z.undefined()])
|
||||
.transform((value) => {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (rerunDecisions.has(value.decision) && !value.comment) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["comment"],
|
||||
message: "重跑类审核动作必须填写原因说明。",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function parseReviewActionPayload(
|
||||
payload: unknown,
|
||||
): SubmitReviewRequestDto {
|
||||
const result = reviewActionSchema.safeParse(payload);
|
||||
|
||||
if (!result.success) {
|
||||
throw new RouteError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
"审核动作参数不合法。",
|
||||
result.error.flatten(),
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
64
src/lib/validation/revision.ts
Normal file
64
src/lib/validation/revision.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
type ConfirmRevisionRequestDto,
|
||||
type RegisterRevisionRequestDto,
|
||||
} from "@/lib/types/backend";
|
||||
import { RouteError } from "@/lib/http/response";
|
||||
|
||||
const nullableTrimmedString = z
|
||||
.union([z.string(), z.null(), z.undefined()])
|
||||
.transform((value) => {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
});
|
||||
|
||||
const registerRevisionSchema = z.object({
|
||||
parent_asset_id: z.number().int().positive(),
|
||||
uploaded_uri: z.string().trim().min(1),
|
||||
reviewer_id: z.number().int().positive(),
|
||||
comment: nullableTrimmedString,
|
||||
});
|
||||
|
||||
const confirmRevisionSchema = z.object({
|
||||
reviewer_id: z.number().int().positive(),
|
||||
comment: nullableTrimmedString,
|
||||
});
|
||||
|
||||
export function parseRegisterRevisionPayload(
|
||||
payload: unknown,
|
||||
): RegisterRevisionRequestDto {
|
||||
const result = registerRevisionSchema.safeParse(payload);
|
||||
|
||||
if (!result.success) {
|
||||
throw new RouteError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
"人工修订登记参数不合法。",
|
||||
result.error.flatten(),
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export function parseConfirmRevisionPayload(
|
||||
payload: unknown,
|
||||
): ConfirmRevisionRequestDto {
|
||||
const result = confirmRevisionSchema.safeParse(payload);
|
||||
|
||||
if (!result.success) {
|
||||
throw new RouteError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
"确认修订参数不合法。",
|
||||
result.error.flatten(),
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
50
tests/app/api/libraries.route.test.ts
Normal file
50
tests/app/api/libraries.route.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { GET } from "../../../app/api/libraries/[libraryType]/route";
|
||||
|
||||
test("returns honest placeholder library data for unsupported backend modules", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/libraries/models"), {
|
||||
params: Promise.resolve({ libraryType: "models" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toMatchObject({
|
||||
mode: "placeholder",
|
||||
data: {
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
libraryType: "models",
|
||||
isMock: true,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
message: "资源库当前使用占位数据,真实后端接口尚未提供。",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects unsupported placeholder library types with a normalized error", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/libraries/unknown"), {
|
||||
params: Promise.resolve({ libraryType: "unknown" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload).toEqual({
|
||||
error: "NOT_FOUND",
|
||||
message: "不支持的资源库类型。",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects inherited object keys instead of treating them as valid library types", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/libraries/toString"), {
|
||||
params: Promise.resolve({ libraryType: "toString" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload).toEqual({
|
||||
error: "NOT_FOUND",
|
||||
message: "不支持的资源库类型。",
|
||||
});
|
||||
});
|
||||
154
tests/app/api/order-revisions.route.test.ts
Normal file
154
tests/app/api/order-revisions.route.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
GET,
|
||||
POST,
|
||||
} from "../../../app/api/orders/[orderId]/revisions/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("proxies manual revision registration and normalizes success data", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
asset_id: 501,
|
||||
parent_asset_id: 11,
|
||||
root_asset_id: 11,
|
||||
version_no: 1,
|
||||
review_task_status: "revision_uploaded",
|
||||
latest_revision_asset_id: 501,
|
||||
revision_count: 1,
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/orders/101/revisions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
parent_asset_id: 11,
|
||||
uploaded_uri: "mock://manual-revision-v1",
|
||||
reviewer_id: 88,
|
||||
comment: "人工修订第一版",
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ orderId: "101" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
assetId: 501,
|
||||
parentAssetId: 11,
|
||||
rootAssetId: 11,
|
||||
versionNo: 1,
|
||||
reviewTaskStatus: "revision_uploaded",
|
||||
latestRevisionAssetId: 501,
|
||||
revisionCount: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("proxies revision chain lookup and normalizes the item list", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
order_id: 101,
|
||||
latest_revision_asset_id: 502,
|
||||
revision_count: 2,
|
||||
items: [
|
||||
{
|
||||
asset_id: 501,
|
||||
order_id: 101,
|
||||
parent_asset_id: 11,
|
||||
root_asset_id: 11,
|
||||
version_no: 1,
|
||||
is_current_version: false,
|
||||
uri: "mock://manual-revision-v1",
|
||||
created_at: "2026-03-27T00:10:00Z",
|
||||
},
|
||||
{
|
||||
asset_id: 502,
|
||||
order_id: 101,
|
||||
parent_asset_id: 501,
|
||||
root_asset_id: 11,
|
||||
version_no: 2,
|
||||
is_current_version: true,
|
||||
uri: "mock://manual-revision-v2",
|
||||
created_at: "2026-03-27T00:20:00Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/orders/101/revisions"), {
|
||||
params: Promise.resolve({ orderId: "101" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 101,
|
||||
latestRevisionAssetId: 502,
|
||||
revisionCount: 2,
|
||||
items: [
|
||||
{
|
||||
assetId: 501,
|
||||
orderId: 101,
|
||||
parentAssetId: 11,
|
||||
rootAssetId: 11,
|
||||
versionNo: 1,
|
||||
isCurrentVersion: false,
|
||||
uri: "mock://manual-revision-v1",
|
||||
createdAt: "2026-03-27T00:10:00Z",
|
||||
},
|
||||
{
|
||||
assetId: 502,
|
||||
orderId: 101,
|
||||
parentAssetId: 501,
|
||||
rootAssetId: 11,
|
||||
versionNo: 2,
|
||||
isCurrentVersion: true,
|
||||
uri: "mock://manual-revision-v2",
|
||||
createdAt: "2026-03-27T00:20:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
163
tests/app/api/orders-create.route.test.ts
Normal file
163
tests/app/api/orders-create.route.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { POST } from "../../../app/api/orders/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("proxies order creation to the backend and returns normalized success data", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
order_id: 77,
|
||||
workflow_id: "wf-77",
|
||||
status: "created",
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/orders", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_level: "mid",
|
||||
service_mode: "semi_pro",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 77,
|
||||
workflowId: "wf-77",
|
||||
status: "created",
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://backend.test/api/v1/orders",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid order creation payloads before proxying", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/orders", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_level: "low",
|
||||
service_mode: "semi_pro",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload).toMatchObject({
|
||||
error: "VALIDATION_ERROR",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects malformed JSON before validation or proxying", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/orders", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{bad json",
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload).toEqual({
|
||||
error: "INVALID_JSON",
|
||||
message: "请求体必须是合法 JSON。",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("normalizes upstream validation errors from the backend", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
detail: "scene_ref_asset_id is invalid",
|
||||
}),
|
||||
{
|
||||
status: 422,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/orders", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_level: "mid",
|
||||
service_mode: "semi_pro",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload).toEqual({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "scene_ref_asset_id is invalid",
|
||||
details: {
|
||||
detail: "scene_ref_asset_id is invalid",
|
||||
},
|
||||
});
|
||||
});
|
||||
83
tests/app/api/orders-overview.route.test.ts
Normal file
83
tests/app/api/orders-overview.route.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { GET } from "../../../app/api/dashboard/orders-overview/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("proxies recent orders overview from the backend list api", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
page: 2,
|
||||
limit: 3,
|
||||
total: 5,
|
||||
total_pages: 2,
|
||||
items: [
|
||||
{
|
||||
order_id: 3,
|
||||
workflow_id: "order-3",
|
||||
customer_level: "mid",
|
||||
service_mode: "semi_pro",
|
||||
status: "waiting_review",
|
||||
current_step: "review",
|
||||
updated_at: "2026-03-27T14:00:03Z",
|
||||
final_asset_id: null,
|
||||
review_task_status: "revision_uploaded",
|
||||
latest_revision_asset_id: 8,
|
||||
latest_revision_version: 1,
|
||||
revision_count: 1,
|
||||
pending_manual_confirm: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://frontend.test/api/dashboard/orders-overview?page=2&limit=3&status=waiting_review&query=order-3"),
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
page: 2,
|
||||
limit: 3,
|
||||
total: 5,
|
||||
totalPages: 2,
|
||||
items: [
|
||||
{
|
||||
orderId: 3,
|
||||
workflowId: "order-3",
|
||||
status: "waiting_review",
|
||||
statusMeta: {
|
||||
label: "待审核",
|
||||
tone: "warning",
|
||||
},
|
||||
currentStep: "review",
|
||||
currentStepLabel: "人工审核",
|
||||
updatedAt: "2026-03-27T14:00:03Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
message: "订单总览当前显示真实后端最近订单。",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://backend.test/api/v1/orders?page=2&limit=3&status=waiting_review&query=order-3",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
64
tests/app/api/reviews-confirm-revision.route.test.ts
Normal file
64
tests/app/api/reviews-confirm-revision.route.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { POST } from "../../../app/api/reviews/[orderId]/confirm-revision/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("proxies revision confirmation and normalizes response data", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
revision_asset_id: 501,
|
||||
decision: "approve",
|
||||
status: "submitted",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/reviews/101/confirm-revision", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reviewer_id: 88,
|
||||
comment: "确认继续流水线",
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ orderId: "101" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
revisionAssetId: 501,
|
||||
decision: "approve",
|
||||
decisionMeta: {
|
||||
label: "通过",
|
||||
tone: "success",
|
||||
},
|
||||
status: "submitted",
|
||||
},
|
||||
});
|
||||
});
|
||||
160
tests/app/api/reviews-pending.route.test.ts
Normal file
160
tests/app/api/reviews-pending.route.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { GET } from "../../../app/api/reviews/pending/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("adapts an empty pending review list into a business-empty queue state", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/reviews/pending"));
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
items: [],
|
||||
state: {
|
||||
kind: "business-empty",
|
||||
title: "暂无待审核订单",
|
||||
description: "当前没有等待人工处理的审核任务。",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes upstream server errors for pending review proxying", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
detail: "database unavailable",
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/reviews/pending"));
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(payload).toEqual({
|
||||
error: "BACKEND_ERROR",
|
||||
message: "database unavailable",
|
||||
details: {
|
||||
detail: "database unavailable",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("enriches pending review items with workflow summary chips", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
|
||||
if (url === "http://backend.test/api/v1/reviews/pending") {
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
review_task_id: 301,
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
current_step: "review",
|
||||
created_at: "2026-03-27T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "http://backend.test/api/v1/workflows/101") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
workflow_type: "mid_end",
|
||||
workflow_status: "waiting_review",
|
||||
current_step: "review",
|
||||
steps: [
|
||||
{
|
||||
id: 1,
|
||||
workflow_run_id: 9001,
|
||||
step_name: "fusion",
|
||||
step_status: "failed",
|
||||
input_json: null,
|
||||
output_json: null,
|
||||
error_message: "fusion failed",
|
||||
started_at: "2026-03-27T00:07:00Z",
|
||||
ended_at: "2026-03-27T00:08:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
workflow_run_id: 9001,
|
||||
step_name: "review",
|
||||
step_status: "waiting",
|
||||
input_json: {
|
||||
preview_uri: "mock://fusion-preview",
|
||||
},
|
||||
output_json: null,
|
||||
error_message: null,
|
||||
started_at: "2026-03-27T00:09:00Z",
|
||||
ended_at: null,
|
||||
},
|
||||
],
|
||||
created_at: "2026-03-27T00:00:00Z",
|
||||
updated_at: "2026-03-27T00:10:00Z",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled fetch url: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/reviews/pending"));
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.data.items[0]).toMatchObject({
|
||||
orderId: 101,
|
||||
workflowType: "mid_end",
|
||||
hasMockAssets: true,
|
||||
failureCount: 1,
|
||||
});
|
||||
});
|
||||
144
tests/app/api/reviews-submit.route.test.ts
Normal file
144
tests/app/api/reviews-submit.route.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { POST } from "../../../app/api/reviews/[orderId]/submit/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("proxies review submission through a dynamic route and normalizes success data", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
decision: "approve",
|
||||
status: "queued",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/reviews/101/submit", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
decision: "approve",
|
||||
reviewer_id: 7,
|
||||
selected_asset_id: null,
|
||||
comment: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ orderId: "101" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
decision: "approve",
|
||||
decisionMeta: {
|
||||
label: "通过",
|
||||
tone: "success",
|
||||
},
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://backend.test/api/v1/reviews/101/submit",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid dynamic path params before proxying review submission", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/reviews/not-a-number/submit", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
decision: "approve",
|
||||
reviewer_id: 7,
|
||||
selected_asset_id: null,
|
||||
comment: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ orderId: "not-a-number" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload).toEqual({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "orderId 必须是正整数。",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("normalizes upstream not-found responses on review submission", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
detail: "review task not found",
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/reviews/999/submit", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
decision: "approve",
|
||||
reviewer_id: 7,
|
||||
selected_asset_id: null,
|
||||
comment: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ orderId: "999" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload).toEqual({
|
||||
error: "NOT_FOUND",
|
||||
message: "review task not found",
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user