feat: bootstrap auto virtual tryon admin frontend

This commit is contained in:
afei A
2026-03-27 23:38:50 +08:00
commit 98c6b741d6
119 changed files with 19046 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.DS_Store
.superpowers/
.dev-stack/
.env.local
.next/
node_modules/
tsconfig.tsbuildinfo

View 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
View 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.

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

View File

@@ -0,0 +1,5 @@
import { LibraryPageScreen } from "@/features/libraries/library-page";
export default function GarmentsLibraryPage() {
return <LibraryPageScreen libraryType="garments" />;
}

View File

@@ -0,0 +1,5 @@
import { LibraryPageScreen } from "@/features/libraries/library-page";
export default function ModelsLibraryPage() {
return <LibraryPageScreen libraryType="models" />;
}

View File

@@ -0,0 +1,5 @@
import { LibraryPageScreen } from "@/features/libraries/library-page";
export default function ScenesLibraryPage() {
return <LibraryPageScreen libraryType="scenes" />;
}

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

View File

@@ -0,0 +1,5 @@
import { OrdersHomeScreen } from "@/features/orders/orders-home";
export default function OrdersPage() {
return <OrdersHomeScreen />;
}

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

View File

@@ -0,0 +1,5 @@
import { ReviewWorkbenchListScreen } from "@/features/reviews/review-workbench-list";
export default function ReviewWorkbenchPage() {
return <ReviewWorkbenchListScreen />;
}

View File

@@ -0,0 +1,5 @@
import { SettingsPlaceholder } from "@/features/settings/settings-placeholder";
export default function SettingsPage() {
return <SettingsPlaceholder />;
}

View File

@@ -0,0 +1,5 @@
import { SubmitWorkbench } from "@/features/orders/submit-workbench";
export default function SubmitWorkbenchPage() {
return <SubmitWorkbench />;
}

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

View File

@@ -0,0 +1,5 @@
import { WorkflowLookupScreen } from "@/features/workflows/workflow-lookup";
export default function WorkflowsPage() {
return <WorkflowLookupScreen />;
}

View 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,
},
);
});
}

View 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,
},
);
});
}

View 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,
},
);
});
}

View 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",
},
);
});
}

View 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",
});
});
}

View 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
View 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",
});
});
}

View 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",
});
});
}

View 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",
});
});
}

View 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",
},
);
});
}

View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
import { LoginPlaceholder } from "@/features/auth/login-placeholder";
export default function LoginPage() {
return <LoginPlaceholder />;
}

5
app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/orders");
}

View File

@@ -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
```

View File

@@ -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 decorations 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
View 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
View File

@@ -0,0 +1 @@
declare module "*.mjs";

6
next-env.d.ts vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

8
scripts/dev-stack/logs.sh Executable file
View 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
View 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
View 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
View 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
View 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
View 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

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

View 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: "系统设置" },
];

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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,
},
];
});
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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,
})),
};
}

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

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

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

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

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

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

View 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: "不支持的资源库类型。",
});
});

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

View 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",
},
});
});

View 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),
);
});

View 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",
},
});
});

View 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,
});
});

View 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