feat(prototype): redesign procurement task workbench

This commit is contained in:
QiuSW
2026-08-03 15:00:49 +08:00
parent cad00877d3
commit 9c3c2b2cab
13 changed files with 741 additions and 283 deletions
+2 -2
View File
@@ -119,7 +119,7 @@ cmbuyer 是一个自动化采购系统:**网页端**负责建单与人工决
只做:
- **手工填链接**建单、任务查询
- **手工填链接**建单、任务查询、勾选待开始任务后批量开始第一趟试选
- 桌面端**定时轮询**领取(试选与下单两类)
- 第一趟试选:精确勾选规格、读单价、截图、退出释放手机
- 人工确认「机器选对了吗」并签发授权(**锁定单价**)
@@ -135,7 +135,7 @@ cmbuyer 是一个自动化采购系统:**网页端**负责建单与人工决
- **图片搜索**(B 路径)与多候选对照台
- **Excel 导入**、**ERP 建单**(均被待确认字段契约阻塞)
- 订单自动回读核对
- 批量勾选编排
- 已开始任务的批量排序、暂停 / 继续和运行中人工接管
- 绕过验证码、风控、人脸、短信校验
- 拼多多以外的平台
- 多设备并行、审批链、退款、财务对账
+12 -6
View File
@@ -31,7 +31,7 @@ MVP 只做**任务自带商品链接**的情形,分两趟执行:第一趟试
| ID | 功能 | 用户能做什么 | 优先级 | 关联用户故事 |
| --- | --- | --- | --- | --- |
| F-001 | 手工建单 | 管理员填写任务名称、拼多多链接、颜色分类、尺码、数量、价格上限,得到一条任务 | P0 | US-001 |
| F-001 | 手工建单 | 管理员在任务列表内填写任务名称、拼多多链接、颜色分类、尺码、数量、价格上限,保存一条待开始任务 | P0 | US-001 |
| F-004 | 任务查询 | 管理员按关键词、状态、时间范围找到目标任务 | P0 | US-002 |
| F-005 | 桌面端定时领取 | 执行员启动会话后,桌面端定时轮询领取待试选和已授权两类任务 | P0 | US-003 |
| F-006 | 第一趟试选 | 系统打开商品、按维度精确勾选颜色分类和尺码、读单价、截图后退出 | P0 | US-003 |
@@ -42,6 +42,7 @@ MVP 只做**任务自带商品链接**的情形,分两趟执行:第一趟试
| F-011 | 结果与失败分类回传 | 管理员看到成功、失败、已取消、待人工,以及可区分的失败原因和证据 | P0 | US-002、US-008 |
| F-013 | 登录与身份隔离 | 管理员用账号登录网页端;桌面端用设备凭据接入,两者权限不互通 | P0 | US-007 |
| F-017 | 下单 dry-run、提交围栏与调和 | 真机先只读演练到订单确认页;真实点击前由服务端原子冻结授权;点击后只调和结果、绝不重试 | P0 | US-005、US-008 |
| F-018 | 批量开始试选 | 管理员勾选一条或多条待开始任务,统一放入第一趟试选队列 | P0 | US-010 |
> F-002、F-003、F-012 已移出 MVP,编号保留不重用,见下表。
@@ -51,7 +52,7 @@ MVP 只做**任务自带商品链接**的情形,分两趟执行:第一趟试
| --- | --- | --- |
| F-002 Excel 批量建单 | 上传固定表头表格批量生成任务 | V2,被表头契约待确认阻塞 |
| F-003 从 ERP 货运明细建单 | 同步顺运宝货运单后复核生成任务 | V2,被字段映射待确认阻塞 |
| F-012 批量顺序编排与人工接管 | 勾选多条按顺序跑、暂停继续 | V2;MVP 用定时轮询代替 |
| F-012 批量顺序编排与人工接管 | 对已开始任务排序、暂停继续、运行中接管 | V2;MVP 仅支持批量开始试选,实际执行仍由定时轮询领取 |
| F-014 图片搜索路径(B 路径) | 任务只有参考图时搜图产出 goods_id 候选 | V2 |
| F-015 候选对照台 | 多个候选并排对照挑选 | V2,随 F-014 |
| F-016 订单自动核对回读 | 付款后只读读取订单页做五项唯一匹配并回写 | V2;MVP 用截图 + 人眼核对 |
@@ -77,6 +78,7 @@ MVP 只做**任务自带商品链接**的情形,分两趟执行:第一趟试
| F-008、F-009、F-010、F-017 | US-005 | P0 |
| F-013 | US-007 | P0 |
| F-011 | US-008 | P0 |
| F-018 | US-010 | P0 |
US-006(付款前核对)在 MVP 降级为:系统展示订单截图与授权信息,人在拼多多自行核对
后付款并手工标记完成。自动回读核对是 F-016(V2)。US-009(ERP 建单)随 F-003 移出 MVP。
@@ -85,9 +87,12 @@ US-006(付款前核对)在 MVP 降级为:系统展示订单截图与授权
### 建单
- **F-001**:填入任务名称、合法拼多多链接、颜色分类、尺码、数量、价格上限后提交,返回任务编号,
任务出现在列表且状态为待领取。链接格式非法或无法解析出 `goods_id` 时明确报错并保留
已填内容。
- **F-001**:在任务列表点击创建,填入任务名称、合法拼多多链接、颜色分类、尺码、数量、
价格上限后保存,返回任务编号;弹窗关闭,任务按创建时间倒序出现在第一行且状态为待开始。
链接格式非法或无法解析出 `goods_id` 时明确报错并保留已填内容。
- **F-018**:列表只允许勾选待开始任务;全选只覆盖当前筛选结果中可开始的任务。点击
「开始试选」后,被选任务原子转为待领取,并明确提示这只是进入第一趟试选队列,
**不签发下单授权、不创建订单、不付款**。任一任务状态已变化时整批不提交,刷新后重选。
### 第一趟:试选
@@ -151,11 +156,12 @@ US-006(付款前核对)在 MVP 降级为:系统展示订单截图与授权
| --- | --- |
| 第一版平台 | 网页端(管理)+ Windows 桌面端(执行),驱动一台 Android 手机 |
| 是否需要账号 | 是。管理员账号 + 桌面端设备凭据,两套身份分离 |
| 第一版范围 | 手工建单 → 定时领取 → **第一趟试选** → 人工确认 → dry-run / 提交围栏 → **第二趟下单** → 待付款 |
| 第一版范围 | 手工建单 → 批量开始试选 → 定时领取 → **第一趟试选** → 人工确认 → dry-run / 提交围栏 → **第二趟下单** → 待付款 |
| 任务来源 | **仅手工填链接。** Excel 与 ERP 移出 MVP |
| 找货方式 | **仅按链接。** 图片搜索移出 MVP |
| 采购平台 | 仅拼多多 |
| 领取方式 | 定时轮询,只在执行员启动的会话内运行 |
| 任务启动 | 创建后为待开始;管理员在列表勾选一条或多条并统一开始第一趟试选 |
| 设备连接 | ADB over USB 或 WiFi 均支持;同一台手机不得同时以两种方式在线 |
| 付款收口 | 系统展示订单截图,人核对后付款并手工标记完成 |
| 暂不支持 | 自动付款、图搜、Excel、ERP、多设备并行、多平台、退款、审批链、AI 辅助 |
+16 -4
View File
@@ -12,7 +12,7 @@
v
┌─────────────────────────────────────────┐
│ web 端(Go 单二进制) │
│ · 建单与任务生命周期 │
│ · 建单、批量开始试选与任务生命周期 │
│ · 候选确认与下单授权(唯一决策权威) │
│ · 证据存储与审计 │
│ · 管理 Web(服务端渲染) │
@@ -87,8 +87,14 @@
这是本项目最核心的结构决策。**MVP 只做 A 路径(任务自带商品链接),分两趟跑完。**
```text
┌──────────── web 端开始第一趟 ────────────┐
│ 新任务先保存为 DRAFT │
│ 管理员在任务表格勾选一条或多条 │
│ 原子转为 PENDING,只进入试选队列 │
└────────────────────┬─────────────────────┘
v
┌──────────────── 第一趟:试选 ────────────────┐
│ desk 端轮询领取待执行任务 │
│ desk 端轮询领取 PENDING 任务 │
│ 1. open_product(url) │
│ 2. 打开规格面板 │
│ 3. 按维度精确勾选颜色分类、尺码 │
@@ -301,10 +307,11 @@ MVP 的授权没有「选择理由 / 拒绝理由」——那是从多个候选
### 5.2 状态机
任务状态(web 端权威)。两趟执行对应两次 `CLAIMED → RUNNING`:
任务状态(web 端权威)。创建与开始试选分离;两趟执行对应两次 `CLAIMED → RUNNING`:
```text
PENDING / PENDING_RETRIAL ─claim→ CLAIMED ─start→ RUNNING(TRIAL)
DRAFT ─start trial→ PENDING ─┐
PENDING_RETRIAL ─────────────┴─claim→ CLAIMED ─start→ RUNNING(TRIAL)
↑ │ ├→ NEEDS_MANUAL
└────────release──────┘ └→ WAITING_CONFIRMATION
├→ CANCELED
@@ -320,6 +327,7 @@ PENDING / PENDING_RETRIAL ─claim→ CLAIMED ─start→ RUNNING(TRIAL)
| 状态 | 含义 |
| --- | --- |
| `DRAFT` | 已保存、等待管理员开始试选;设备不可领取,也不存在下单授权 |
| `RUNNING(TRIAL)` | 第一趟试选中:正在勾选规格、读价、截图 |
| `WAITING_CONFIRMATION` | 试选已回传,**等人确认机器选对了没** |
| `PENDING_RETRIAL` | 旧授权已过期或在围栏前被放弃,必须重新跑第一趟取得新价格 |
@@ -333,6 +341,10 @@ PENDING / PENDING_RETRIAL ─claim→ CLAIMED ─start→ RUNNING(TRIAL)
授权状态:`PENDING_DELIVERY → DELIVERED → ACKNOWLEDGED → EXECUTING → FENCED → CONSUMED`。
人退回或重新确认时旧授权转 `SUPERSEDED`;超时转 `EXPIRED`。
`DRAFT → PENDING` 只能由管理端“开始试选”动作触发。批量开始在一个事务中校验全部任务仍为
`DRAFT` 且版本一致后统一流转;任一冲突时整批不变,避免用户误以为选中的任务都已开始。
这个动作只开放第一趟领取资格,不创建 `order_authorizations` 或 `order_submissions`。
### 5.3 授权超时(MVP 必做,不得推后)
> **前序项目的教训**:曾出现 `EXECUTING` 授权永不推进,导致确认表单被永久隐藏、任务
+4 -4
View File
@@ -25,7 +25,7 @@
| T-002 | 初始化 desk 端 Python 骨架 | - | 虚拟环境、`requirements.txt`、`unittest` 可运行;`python -m compileall` 通过;日志与产物目录策略明确且不记录敏感信息 |
| T-003 | 建立 `init.ps1` 统一入口 | T-001, T-002 | 一条命令完成两端安装与基础验证并打印启动命令;未配置时主动失败而不是静默跳过 |
| T-004 | 建立核心数据模型与状态机 | T-001 | `tasks`、`order_authorizations`、`order_submissions` 表与 `04-architecture.md` 一致;状态流转有单元测试;金额用十进制字符串 |
| T-005 | 网页端 MVP 交互原型 | - | `docs/design/web-*.html` 单文件假数据原型覆盖登录、建单、工作台、详情;键盘、窄屏、空态 / 错误 / 加载、围栏后调和状态可演示;经人工确认前保持 `DOING` |
| T-005 | 网页端 MVP 交互原型 | - | `docs/design/web-*.html` 单文件假数据原型覆盖登录、表格工作台、创建弹窗、批量开始试选、路由化详情抽屉与完整详情;键盘、窄屏、空态 / 错误 / 加载、围栏后调和状态可演示;经人工确认前保持 `DOING` |
| T-006 | 桌面端 MVP 交互原型 | - | `docs/design/desk-*.html` 单文件假数据原型覆盖采购执行、设备与参数;明确 dry-run / 真实下单、App 版本失配、围栏后不可重试;经人工确认前保持 `DOING` |
### Phase 1 · 真机取证(最高风险,必须先做)
@@ -51,9 +51,9 @@
| ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- |
| T-201 | 管理员登录与会话 | T-004, T-005 | 登录建立会话;CSRF 生效;未登录跳转并只接受站内返回路径 |
| T-202 | 手工建单(F-001) | T-201 | 任务名称、链接、颜色分类、尺码、数量、价格上限校验;无法解析 `goods_id` 时明确报错并保留输入 |
| T-203 | 任务工作台与查询(F-004) | T-202 | 三泳道渲染;关键词 / 泳道 / 时间范围筛选;计数不随泳道变化;空状态可清除筛选 |
| T-204 | 任务详情页(F-011) | T-203 | 按状态呈现唯一主区块与主动作;执行证据可查看 |
| T-202 | 手工建单(F-001) | T-201 | 列表内创建弹窗与 `/tasks/new` 复用表单;任务名称、链接、颜色分类、尺码、数量、价格上限校验;成功创建 `DRAFT` 并倒序显示第一行 |
| T-203 | 任务表格、查询与批量开始试选(F-004、F-018) | T-202 | 传统表格无操作列;标题关键词筛选;只选 `DRAFT`;批量 `DRAFT → PENDING` 全有或全无;空状态可清除筛选 |
| T-204 | 路由化任务详情(F-011) | T-203 | 从列表进入显示保留现场的右侧抽屉,直达显示完整页;按状态呈现唯一主区块与主动作;执行证据可查看 |
| T-205 | 试选结果接收与确认页(F-007) | T-204, T-103 | 接收 `spec_trials` 与规格面板截图;确认页展示需求 vs 所选、单价、合计、截图;**轻量版,非对照台** |
| T-206 | 人工确认与授权签发(F-008) | T-205 | 确认即签发一次性授权并**锁定试选单价**;`expected_task_version` 冲突返回 409;金额上限服务端校验;支持「退回,不买」 |
| T-207 | 授权超时与围栏前放弃(F-010) | T-206 | 围栏前超时 / 放弃后任务转 `PENDING_RETRIAL`,必须重新试选;围栏后禁止超时释放或放弃,改走人工核查;每个状态都有安全下一步 |
+31 -6
View File
@@ -29,6 +29,7 @@ US 编号一经引用不再重用。需求变化时先改[需求](02-requirement
| US-006 | 付款前核对订单再花钱 | P0 | 采购管理员 | 对着系统给的几项组织人工核对,不一致就不付 | F-009 收口 | IX-006 | 已定 |
| US-007 | 管理身份和设备身份互不越权 | P0 | 采购管理员 | 设备被盗用也不能建单或改授权 | F-013 | IX-001 | 已定 |
| US-008 | 任务出问题时知道该怎么办 | P0 | 采购执行员 | 看到具体原因和下一步,而不是一个「失败」 | F-011 | IX-008 | 已定 |
| US-010 | 把选中的任务交给机器试选 | P0 | 采购管理员 | 一次选中多条待开始任务,让桌面端逐条领取第一趟试选 | F-018 | IX-012 | 已定 |
> US-009(从 ERP 货运明细建单)随 F-003 移出 MVP,编号保留不重用。
@@ -51,8 +52,8 @@ US 编号一经引用不再重用。需求变化时先改[需求](02-requirement
**验收场景**
1. 假如我有商品链接和规格,当我填完表单提交,那么得到任务编号,任务出现在工作台的
「机器在跑」泳道且状态为待领取。
1. 假如我有商品链接和规格,当我在任务列表的创建弹窗填完并保存,那么得到任务编号,
弹窗关闭,任务按创建时间倒序出现在表格第一行且状态为待开始。
2. 假如链接无法解析出商品标识,当我提交,那么系统明确报错并保留我已填的内容。
3. 假如我没填价格上限,当我提交,那么系统拒绝——**价格上限是资金边界,不能留空**。
@@ -69,13 +70,13 @@ US 编号一经引用不再重用。需求变化时先改[需求](02-requirement
**范围**
- 包含:按「谁持球」分组、关键词与时间范围查询、任务详情的结果与证据。
- 包含:传统任务表格、关键词与时间范围查询、任务详情抽屉和完整详情页中的结果与证据。
- 不包含:自定义视图、导出报表、跨任务统计。
**验收场景**
1. 假如有任务停在等待人工确认,当我打开工作台,那么它出现在「等你决定」并且第一条
已经展开,我不用再点一次才看到内容。
1. 假如有任务停在等待人工确认,当我打开工作台,那么表格状态与采购结果能让我一眼识别,
双击该行即可在不丢失列表现场的情况下查看详情。
2. 假如我按关键词筛选后没有匹配,当结果为空,那么显示空状态并提供清除筛选,而不是
一片空白或报错。
3. 假如任务失败了,当我打开详情,那么我看到可区分的失败原因和当时的截图,而不只是
@@ -95,7 +96,7 @@ US 编号一经引用不再重用。需求变化时先改[需求](02-requirement
**范围**
- 包含:定时轮询领取、第一趟试选、第二趟下单、连续失败自动停。
- 不包含:多台手机并行、无人值守整夜运行、批量勾选编排(V2)。
- 不包含:多台手机并行、无人值守整夜运行、已开始任务的排序、暂停 / 继续和运行中接管(V2)。
**验收场景**
@@ -225,6 +226,30 @@ US 编号一经引用不再重用。需求变化时先改[需求](02-requirement
5. 假如点击提交订单后无法判断是否已创建,那么系统转人工并提示「订单可能已创建、
支付未完成」,进入同一提交记录的调和,**不自动重试、不释放围栏**。
### US-010 把选中的任务交给机器试选
- 优先级:P0 | 关联功能:F-018 | 关联交互:IX-012
- 角色:采购管理员
- 前置条件:已登录;任务表格中至少有一条待开始任务。
**用户故事**
作为采购管理员,我想要勾选一条或多条待开始任务统一开始试选,从而在集中建单后一次交给
桌面端逐条领取,而不必逐行点击操作按钮。
**范围**
- 包含:当前筛选结果中的可开始任务全选、清除选择、批量进入第一趟试选队列。
- 不包含:指定执行顺序、暂停 / 继续、运行中接管、多设备调度(F-012,V2)。
**验收场景**
1. 假如我勾选了多条待开始任务,当我点击「开始试选」,那么这些任务一起转为待领取,
页面明确说明不会创建订单或付款。
2. 假如任务不是待开始,那么该行不能被本动作勾选,并能看见不可开始的原因。
3. 假如其中一条任务在提交前已被别人启动,当版本冲突发生,那么整批不产生部分成功,
页面刷新状态并让我重新选择。
## 四、交付前检查
- [ ] 每个 P0 功能至少关联一个 US 编号。
+9 -5
View File
@@ -19,15 +19,16 @@ IX 编号一经引用不再重用。交互清单不能扩大需求范围。
| ID | 关联 US | 页面 / 组件 | 触发 | 用户目标 | 预期结果 | 优先级 | 状态 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| IX-001 | US-007 | web `/login` | 提交表单 | 进入管理后台 | 建立会话并进入原目标页或 `/tasks` | P0 | 已定 |
| IX-002 | US-001 | web `/tasks/new` | 提交表单 | 建单 | 解析出 `goods_id` 并返回任务编号 | P0 | 已定 |
| IX-003 | US-002 | web `/tasks` 查询条与泳道 | 输入 / 点选 | 找到要处理的任务 | 实时筛选、计数更新、空状态可清除 | P0 | 已定 |
| IX-004 | US-002 | web `/tasks/{id}` | 进入页面 | 了解任务当前处境 | 按状态呈现唯一主区块与主动作 | P0 | 已定 |
| IX-002 | US-001 | web `/tasks` 创建弹窗;`/tasks/new` 直达兜底 | 提交表单 | 建单 | 解析出 `goods_id`,关闭弹窗并把待开始任务插入表格第一行 | P0 | 已定 |
| IX-003 | US-002 | web `/tasks` 查询条与任务表格 | 输入后筛选 | 找到要处理的任务 | 默认显示标题关键词,可展开状态 / 时间条件;创建时间倒序;空状态可清除 | P0 | 已定 |
| IX-004 | US-002 | web `/tasks/{id}` 路由化详情抽屉 / 完整页 | 双击行、任务编号链接或直接进入 | 了解任务当前处境 | 从列表进入时右侧打开且保留现场;直达时显示完整页;按状态呈现唯一主区块与主动作 | P0 | 已定 |
| IX-005 | US-004、US-005 | web 试选确认卡 | 查看后点确认 / 退回 | 确认机器选对了并授权 | 签发一次性授权并锁定单价,明确不付款 | P0 | 已定 |
| IX-006 | US-006 | web 待付款核对卡 | 查看后手工标记 | 核对后付款 | 展示订单截图与授权信息;人付款后标记完成 | P0 | 已定 |
| IX-007 | US-003 | desk 设备与参数页 | 填 serial → 连接检查 | 让设备就绪 | 连接成功并确认拼多多已安装 | P0 | 已定 |
| IX-008 | US-003、US-008 | desk 采购执行页 | 开始 / 停止轮询 | 让手机自动跑并处理异常 | 待人工时显著提示缺什么;连续失败自动停 | P0 | 已定 |
| IX-010 | US-005 | web 围栏前放弃授权入口 | 点击放弃 | 解开尚未提交的授权 | 授权作废,任务转待重新试选 | P0 | 已定 |
| IX-011 | US-005、US-008 | web / desk 提交围栏与调和状态 | 真实点击前申请围栏;点击后回报 | 防止断网或重复操作产生第二笔订单 | 围栏前失败不点击;围栏后不明确只核查、不重试 | P0 | 已定 |
| IX-012 | US-010 | web `/tasks` 表格选择与上下文批量栏 | 勾选后点「开始试选」 | 批量交给桌面端试选 | `DRAFT → PENDING` 原子提交;仅开始第一趟,不授权、不下单、不付款 | P0 | 已定 |
> IX-009(ERP 建单)随 F-003 移出 MVP,编号保留不重用。
@@ -39,7 +40,7 @@ IX 编号一经引用不再重用。交互清单不能扩大需求范围。
- 关联用户故事:US-004、US-005
- 关联需求 / 验收:F-007、F-008;[需求](02-requirements.md)第五节「决策与资金」
- 页面 / 组件:web `/tasks/{id}` 的 `SpecTrialCard` + `AuthorizePanel`
- 页面 / 组件:web `/tasks/{id}` 路由化详情抽屉 / 完整页的 `SpecTrialCard` + `AuthorizePanel`
- 目标角色:采购管理员
- 前置条件:已登录;任务状态为 `WAITING_CONFIRMATION`;已收到试选结果与规格面板截图
- 触发方式:查看后点击「确认下单(不付款)」或「退回,不买」
@@ -63,7 +64,7 @@ IX 编号一经引用不再重用。交互清单不能扩大需求范围。
| 规格不一致(✗) | **确认按钮禁用**并说明哪一项不符;只能「退回,不买」或转人工 |
| 金额超上限(✗) | **确认按钮禁用**并显示超出多少;不提供「仍然确认」入口 |
| 加载 / 提交中 | 按钮禁用并显示进行中,防重复提交 |
| 成功 | 跳回工作台,该任务进入「机器在跑」等待第二趟 |
| 成功 | 关闭详情抽屉或返回工作台,该任务显示为已授权并等待桌面端领取第二趟 |
| 退回 | 二次确认后任务终止为 `CANCELED`,说明不会再自动执行 |
| 服务或网络错误 | 保留页面状态,说明原因并允许重试 |
| 权限不足 | 设备凭据调用此接口一律 403 并记录;页面不暴露任务内容 |
@@ -177,6 +178,9 @@ IX 编号一经引用不再重用。交互清单不能扩大需求范围。
- 键盘可达、焦点可见、`prefers-reduced-motion` 生效。
- 状态不能只靠颜色表达。
- 金额一律显示两位小数并标注币种。
- 双击只能作为效率快捷方式;任务编号链接和聚焦行后按 Enter 必须提供等价详情入口。
- 表格行内的复选框、商品外链和任务编号链接不得误触发行双击;关闭详情抽屉后焦点回到原行。
- 表格批量栏只在存在选择时出现;全选只覆盖当前筛选结果中允许当前动作的行。
## 五、交付前检查
+24 -1
View File
@@ -53,7 +53,8 @@
| `POST` | `/login` | 建立管理会话 |
| `POST` | `/logout` | 销毁会话 |
| `GET` | `/tasks` | 任务列表,支持 `q`、`status`、`days`、`cursor` |
| `POST` | `/tasks` | 手工建单(F-001) |
| `POST` | `/tasks` | 手工建单,初始状态为 `DRAFT`(F-001) |
| `POST` | `/tasks/start-trials` | 批量把 `DRAFT` 原子转为 `PENDING`,开始第一趟试选(F-018) |
| `GET` | `/tasks/{id}` | 任务详情 |
| `POST` | `/tasks/{id}/cancel` | 取消任务 |
| `POST` | `/tasks/{id}/order-authorizations` | 确认试选结果并签发授权(F-008) |
@@ -64,6 +65,28 @@
> Excel 导入(`/tasks/import`)与 ERP 货运(`/freight*`)已移出 MVP,见
> [需求](02-requirements.md)第三节后续迭代表。
### `POST /tasks/start-trials`
管理页面以带 CSRF 的表单提交结构化任务版本列表:
```json
{
"start_key": "<幂等键>",
"tasks": [
{ "id": "018f...", "expected_task_version": 1 },
{ "id": "0190...", "expected_task_version": 3 }
]
}
```
- 只接受当前状态为 `DRAFT` 的任务;该动作含义是允许 desk 端领取第一趟试选,**不签发下单
授权、不建立提交围栏、不创建订单、不付款**。
- 服务端在一个事务内校验全部任务存在、属于当前管理范围、状态仍为 `DRAFT` 且版本匹配,
然后统一转 `PENDING` 并递增版本。任一项失败返回 `409 conflict`,整批不产生部分成功。
- `tasks` 为空或包含重复 id 返回 `400 invalid_argument`;重复 `start_key` 返回第一次的结果,
不重复推进版本。
- SSR 成功后 `303` 返回原任务列表查询地址;冲突时保留筛选条件,刷新表格并要求重新选择。
### `POST /tasks/{id}/order-authorizations`
```json
+11 -9
View File
@@ -13,7 +13,7 @@
- 日期:2026-08-03
- 阶段:**Phase 0 · 地基(先确认原型,尚未开始生产编码)**
- MVP 形态:手工填链接建单 → 定时轮询 → **第一趟试选** → 人工确认 → **第二趟下单** → 待付款
- MVP 形态:手工填链接建单 → 批量开始试选 → 定时轮询 → **第一趟试选** → 人工确认 → **第二趟下单** → 待付款
- 技术栈:已定。web 端 Go 1.23+ / gin / SQLite;desk 端 Python 3.11+ / uiautomator2 / PySide6。
详见 [`03-tech-stack.md`](03-tech-stack.md)
- 生产代码:**无**。仓库目前只有文档
@@ -22,8 +22,8 @@
- 标准启动路径:`./init.ps1`(Windows)/ `./init.sh`。已存在但会因两端目录未初始化而以
退出码 3 结束并列出待办任务——这是预期行为
- 标准验证路径:目前只有 `python scripts/validate_agent_context.py` 可跑通
- 当前 blocker:T-005 / T-006 原型已实现并通过自动检查,正在等待人工确认;确认前不开始
对应生产 UI。
- 当前 blocker:T-005 第二轮表格工作台修订和 T-006 均已通过自动检查;两者经人工确认前
不开始对应生产 UI。
## 当前目录要点
@@ -42,10 +42,11 @@
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
- 已完成:无。
- 正在进行:T-005(网页端 MVP 交互原型)、T-006(桌面端 MVP 交互原型)。两个任务的
HTML 与自动验证均已完成,因 `needs_human_review: true` 保持 `DOING`。
- **下一步:人工浏览 6 个原型,确认布局、信息密度、唯一主动作和安全文案。** 明确确认后
才能把 T-005 / T-006 改为 `DONE`。
- 正在进行:T-005(网页端 MVP 交互原型)、T-006(桌面端 MVP 交互原型)。T-005 第二轮
已将三泳道改为传统表格,并增加创建弹窗、`DRAFT` 批量开始试选和路由化详情抽屉;两项
自动验证均已完成。两个任务都因 `needs_human_review: true` 保持 `DOING`。
- **下一步:人工浏览 6 个原型,重点确认 Web 表格字段、批量开始试选、创建弹窗、详情抽屉
与安全文案,以及桌面端执行状态。** 明确确认后才能把 T-005 / T-006 改为 `DONE`。
- 原型使用假数据、不调用真实接口、不驱动真机;T-001 / T-002 骨架可在确认后并行,
Phase 2 生产页面还必须等待相关真机结论。
@@ -88,8 +89,9 @@ T-001 / T-002 完成后,本节替换为真实命令:
实现围栏前超时 / 放弃,在 T-208 实现围栏后调和;围栏后不得释放或重试。
4. **规格面板单价位置未取证**:闸门一依赖它,T-103 必须一并取证。若读不可靠,
确认页设计要改。
5. **MVP 已收窄**:只做手工填链接 + 两趟执行。Excel、ERP、图搜、订单自动核对、
AI 辅助全部推到 V2(见 `06-tasks.md` 的 T-501~T-508)。
5. **MVP 已收窄**:只做手工填链接、批量开始第一趟试选和两趟执行。Excel、ERP、图搜、
批量顺序编排 / 暂停接管、订单自动核对、AI 辅助全部推到 V2(见 `06-tasks.md` 的
T-501~T-508)。
6. **App 版本必须 fail closed**:运行时拼多多版本与本项目已取证版本不一致就停止领取,
不允许用旧判据继续跑。
+2
View File
@@ -17,6 +17,8 @@
- CSS / JS 全部内联,零构建依赖,双击即可在浏览器打开。
- 低保真优先:结构和控件齐全即可,不追求视觉完成度。
- 使用语义化标签(`button`、`form`、`table`、`dialog`、`nav`),标签本身就是控件清单。
- 列表内详情可以使用路由驱动的抽屉 / 模态层,但必须保留可复制的详情 URL、浏览器返回和
完整页直达兜底;关闭后恢复触发行焦点与列表现场。
- 数据一律用假数据,页面顶部放固定横幅标注:`PROTOTYPE - 仅供枚举交互,非实现依据`。
- 不加载外部字体、CDN、图片或生产地址;浏览器网络面板除当前本地文件外应为零请求。
- 需要演示空态、加载、错误等状态时,可用少量内联 JS 做状态切换按钮,对应交互清单状态表的行。
+3 -2
View File
@@ -202,7 +202,8 @@
<section>
<h2>创建后发生什么</h2>
<ol class="steps">
<li>任务进入「机器在跑」。</li>
<li>任务先保存为「待开始」。</li>
<li>在任务表格勾选后统一点击「开始试选」。</li>
<li>第一趟只试选、读价、截图,然后退出商品。</li>
<li>你看过证据后再决定是否授权。</li>
</ol>
@@ -309,7 +310,7 @@
setReady();
form.hidden = true;
status.className = 'status success show';
status.innerHTML = '<h2>任务已创建</h2><p>任务编号 <strong>T-20260803-019</strong>,已进入「机器在跑」等待领取。系统尚未进行任何拼多多操作。</p><a class="button button-primary" href="web-task-workbench.html">查看任务工作台</a>';
status.innerHTML = '<h2>任务已创建</h2><p>任务编号 <strong>T-20260803-019</strong>,当前为「待开始」。请回到任务表格勾选后点击“开始试选”;系统尚未进行任何拼多多操作。</p><a class="button button-primary" href="web-task-workbench.html">查看任务工作台</a>';
status.focus?.();
}
+544 -221
View File
@@ -4,113 +4,179 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<title>任务工作台 · cmbuyer 原型</title>
<title>采购任务 · cmbuyer 原型</title>
<style>
:root {
--bg: #f4f7fb; --surface: #fff; --surface-subtle: #f8fafc; --text: #172033; --muted: #526079;
--border: #cfd8e6; --primary: #155eef; --primary-hover: #0b4ed1; --primary-soft: #eaf1ff;
--danger: #b42318; --danger-soft: #fef3f2; --success: #067647; --success-soft: #ecfdf3;
--warning: #8a4b0f; --warning-soft: #fff7e8; --focus: #ffbf47; --shadow: 0 5px 16px rgba(23,32,51,.07);
font-family: "Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif;
--bg:#f4f7fb; --surface:#fff; --subtle:#f8fafc; --text:#172033; --muted:#526079;
--border:#cfd8e6; --border-strong:#91a0b4; --primary:#155eef; --primary-hover:#0b4ed1;
--primary-soft:#eaf1ff; --danger:#b42318; --danger-soft:#fef3f2; --success:#067647;
--success-soft:#ecfdf3; --warning:#8a4b0f; --warning-soft:#fff7e8; --focus:#ffbf47;
--shadow:0 7px 24px rgba(23,32,51,.08);
font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif;
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--bg); }
body { margin: 0; min-height: 100dvh; color: var(--text); background: var(--bg); font-size: 16px; line-height: 1.5; }
a { color: var(--primary); text-underline-offset: 3px; }
button, input, select { font: inherit; }
button, input, select, .button { min-height: 44px; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: .6; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
.skip-link { position: fixed; z-index: 1000; top: 8px; left: 8px; padding: 10px 14px; color: #fff; background: var(--text); transform: translateY(-160%); }
.skip-link:focus { transform: translateY(0); }
.prototype-banner { position: sticky; z-index: 50; top: 0; padding: 7px 16px; color: #fff; background: #29364d; text-align: center; font-size: .78rem; font-weight: 700; letter-spacing: .04em; }
.app-header { position: sticky; z-index: 40; top: 33px; min-height: 64px; display: flex; align-items: center; gap: 24px; padding: 0 max(16px, calc((100vw - 1400px) / 2)); border-bottom: 1px solid var(--border); background: rgba(255,255,255,.98); }
.brand { display: flex; align-items: center; gap: 10px; color: var(--text); text-decoration: none; font-weight: 750; white-space: nowrap; }
.brand-mark { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; color: #fff; background: var(--primary); font-size: .8rem; }
nav.primary { display: flex; align-items: stretch; align-self: stretch; gap: 4px; }
nav.primary a { display: flex; align-items: center; padding: 0 12px; color: #3d4b63; text-decoration: none; font-weight: 650; border-bottom: 3px solid transparent; }
nav.primary a[aria-current="page"] { color: var(--primary); border-bottom-color: var(--primary); }
.header-account { margin-left: auto; color: var(--muted); font-size: .88rem; white-space: nowrap; }
main { width: min(100% - 32px, 1400px); margin: 0 auto; padding: 26px 0 64px; }
.page-head { display: flex; justify-content: space-between; align-items: end; gap: 20px; margin-bottom: 18px; }
h1 { margin: 0; font-size: clamp(1.6rem, 3vw, 2rem); }
.subtitle { margin: 5px 0 0; color: var(--muted); }
.button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 9px 16px; border: 1px solid transparent; border-radius: 8px; text-decoration: none; font-weight: 700; }
.button-primary { color: #fff; background: var(--primary); }
.button-primary:hover { background: var(--primary-hover); }
.button-secondary { color: var(--text); border-color: var(--border); background: var(--surface); }
.button-secondary:hover { background: var(--surface-subtle); }
.query-panel { display: grid; grid-template-columns: minmax(220px, 1fr) 190px 190px auto; gap: 12px; align-items: end; padding: 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); box-shadow: var(--shadow); }
label { display: block; margin-bottom: 5px; color: #34425b; font-size: .84rem; font-weight: 700; }
input, select { width: 100%; padding: 9px 11px; border: 1px solid #91a0b4; border-radius: 8px; color: var(--text); background: #fff; }
.query-hint { margin: 8px 0 0; color: var(--muted); font-size: .8rem; }
.summary-line { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin: 16px 2px 10px; color: var(--muted); font-size: .86rem; }
.demo-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }
.demo-controls strong { margin-right: 2px; }
.demo-controls button { min-height: 34px; padding: 5px 9px; border: 1px solid var(--border); border-radius: 7px; color: #33415b; background: var(--surface-subtle); font-size: .78rem; }
.lanes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; align-items: start; }
.lane { min-width: 0; border: 1px solid var(--border); border-radius: 12px; background: #edf2f8; overflow: clip; }
.lane[hidden] { display: none; }
.lane-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 14px 12px; border-bottom: 1px solid var(--border); background: var(--surface-subtle); }
.lane-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
.lane-title h2 { margin: 0; font-size: 1rem; }
.lane-mark { width: 10px; height: 10px; flex: 0 0 auto; border-radius: 50%; background: var(--primary); }
.lane[data-lane="machine"] .lane-mark { background: #526079; }
.lane[data-lane="done"] .lane-mark { background: var(--success); }
.count { display: inline-grid; min-width: 28px; height: 24px; place-items: center; padding: 0 7px; border-radius: 999px; color: #34425b; background: #e2e8f0; font-size: .78rem; font-weight: 750; font-variant-numeric: tabular-nums; }
.lane-description { margin: 0; padding: 0 14px 10px; color: var(--muted); background: var(--surface-subtle); font-size: .79rem; }
.task-list { display: grid; gap: 10px; padding: 10px; }
.task-card { min-width: 0; border: 1px solid #c6d0de; border-radius: 9px; background: var(--surface); box-shadow: 0 2px 5px rgba(23,32,51,.04); overflow: hidden; }
.task-card[hidden] { display: none; }
.card-top { display: flex; align-items: start; justify-content: space-between; gap: 8px; padding: 12px; }
.task-id { color: var(--muted); font-size: .76rem; font-variant-numeric: tabular-nums; }
.task-card h3 { margin: 3px 0 0; font-size: .96rem; line-height: 1.35; overflow-wrap: anywhere; }
.badge { flex: 0 0 auto; padding: 4px 7px; border: 1px solid #b9cffc; border-radius: 999px; color: #163c8c; background: var(--primary-soft); font-size: .72rem; font-weight: 700; }
.badge.warning { color: var(--warning); border-color: #f0cd86; background: var(--warning-soft); }
.badge.success { color: var(--success); border-color: #a6e3c4; background: var(--success-soft); }
.badge.danger { color: var(--danger); border-color: #f3b7b2; background: var(--danger-soft); }
.task-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px; margin: 0; padding: 0 12px 12px; }
.task-meta div { min-width: 0; }
.task-meta dt { color: var(--muted); font-size: .72rem; }
.task-meta dd { margin: 1px 0 0; font-size: .84rem; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
details { border-top: 1px solid var(--border); }
summary { min-height: 44px; padding: 11px 12px; cursor: pointer; color: var(--primary); font-size: .84rem; font-weight: 700; }
.decision-preview { padding: 0 12px 12px; }
.decision-preview p { margin: 0 0 8px; color: #34425b; font-size: .84rem; }
.decision-preview .button { width: 100%; min-height: 40px; padding: 7px 10px; font-size: .84rem; }
.card-footer { display: flex; justify-content: space-between; gap: 8px; align-items: center; padding: 9px 12px; border-top: 1px solid #e5eaf1; color: var(--muted); font-size: .75rem; }
.card-footer a { font-weight: 700; }
.panel-state { display: none; min-height: 320px; place-items: center; padding: 36px 20px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); text-align: center; }
.panel-state.show { display: grid; }
.panel-state h2 { margin: 0 0 6px; font-size: 1.1rem; }
.panel-state p { max-width: 520px; margin: 0 0 14px; color: var(--muted); }
.panel-state.error { border-color: #f3b7b2; background: var(--danger-soft); }
.skeletons { width: min(100%, 700px); display: grid; gap: 12px; }
.skeleton { height: 86px; border-radius: 10px; background: linear-gradient(90deg, #e9edf3 25%, #f5f7fa 37%, #e9edf3 63%); background-size: 400% 100%; animation: shimmer 1.4s ease infinite; }
@keyframes shimmer { to { background-position: -100% 0; } }
@media (max-width: 1100px) { .lanes { grid-template-columns: 1fr; } .lane { max-width: none; } }
@media (max-width: 900px) { .query-panel { grid-template-columns: 1fr 1fr; } .query-panel .keyword { grid-column: 1 / -1; } }
@media (max-width: 767px) {
.app-header { top: 33px; padding-inline: 16px; gap: 10px; }
.header-account { display: none; }
nav.primary a { padding-inline: 8px; }
main { width: min(100% - 24px, 1400px); padding-top: 20px; }
.page-head { align-items: start; flex-direction: column; }
.page-head .button { width: 100%; }
.query-panel { grid-template-columns: 1fr; }
.query-panel .keyword { grid-column: auto; }
.query-panel .button { width: 100%; }
.summary-line { align-items: start; flex-direction: column; }
.task-meta { grid-template-columns: 1fr; }
* { box-sizing:border-box; }
html { min-width:320px; background:var(--bg); }
body { margin:0; min-height:100dvh; color:var(--text); background:var(--bg); font-size:16px; line-height:1.5; }
body:has(dialog[open]) { overflow:hidden; }
a { color:var(--primary); text-underline-offset:3px; }
button,input,select { font:inherit; }
button,.button,input,select { min-height:44px; }
button { cursor:pointer; }
button:disabled { cursor:not-allowed; opacity:.56; }
:focus-visible { outline:3px solid var(--focus); outline-offset:2px; }
.skip-link { position:fixed; z-index:1000; top:8px; left:8px; padding:10px 14px; color:#fff; background:var(--text); transform:translateY(-160%); }
.skip-link:focus { transform:translateY(0); }
.prototype-banner { position:sticky; z-index:50; top:0; padding:7px 16px; color:#fff; background:#29364d; text-align:center; font-size:.78rem; font-weight:700; letter-spacing:.04em; }
.app-header { position:sticky; z-index:40; top:33px; min-height:64px; display:flex; align-items:center; gap:24px; padding:0 max(16px,calc((100vw - 1480px)/2)); border-bottom:1px solid var(--border); background:rgba(255,255,255,.98); }
.brand { display:flex; align-items:center; gap:10px; color:var(--text); text-decoration:none; font-weight:750; white-space:nowrap; }
.brand-mark { display:grid; place-items:center; width:32px; height:32px; border-radius:8px; color:#fff; background:var(--primary); font-size:.8rem; }
nav.primary { display:flex; align-items:stretch; align-self:stretch; gap:4px; }
nav.primary a { display:flex; align-items:center; padding:0 12px; color:#3d4b63; text-decoration:none; font-weight:650; border-bottom:3px solid transparent; }
nav.primary a[aria-current="page"] { color:var(--primary); border-bottom-color:var(--primary); }
.header-account { margin-left:auto; color:var(--muted); font-size:.88rem; white-space:nowrap; }
main { width:min(100% - 32px,1480px); margin:0 auto; padding:24px 0 64px; }
.page-head { display:flex; justify-content:space-between; align-items:end; gap:20px; margin-bottom:17px; }
h1 { margin:0; font-size:clamp(1.55rem,3vw,2rem); }
.subtitle { margin:5px 0 0; color:var(--muted); }
.safety-note { max-width:560px; margin:0; color:#29466f; font-size:.84rem; text-align:right; }
.safety-note strong { color:#163c8c; }
.workbench { border:1px solid var(--border); border-radius:12px; background:var(--surface); box-shadow:var(--shadow); overflow:hidden; }
.toolbar { border-bottom:1px solid var(--border); background:var(--surface); }
.toolbar-row { min-height:68px; display:flex; align-items:center; justify-content:space-between; gap:12px; padding:11px 14px; }
.toolbar-row + .toolbar-row { border-top:1px solid #e6ebf2; background:var(--subtle); }
.toolbar-group { display:flex; flex-wrap:wrap; align-items:center; gap:9px; }
.toolbar-hint { margin:0; color:var(--muted); font-size:.8rem; }
.button { display:inline-flex; align-items:center; justify-content:center; gap:7px; padding:8px 14px; border:1px solid transparent; border-radius:8px; text-decoration:none; font-weight:700; white-space:nowrap; }
.button svg { width:17px; height:17px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
.button-primary { color:#fff; background:var(--primary); }
.button-primary:hover:not(:disabled) { background:var(--primary-hover); }
.button-secondary { color:var(--text); border-color:var(--border); background:var(--surface); }
.button-secondary:hover:not(:disabled) { background:#eef2f7; }
.button-quiet { color:#34425b; border-color:transparent; background:transparent; }
.button-quiet:hover:not(:disabled) { background:#e9eef5; }
.filter-form { width:100%; display:flex; align-items:end; justify-content:space-between; gap:12px; }
.filter-field { width:min(100%,520px); }
.filter-field label,.field label { display:block; margin-bottom:5px; color:#34425b; font-size:.82rem; font-weight:700; }
input,select { width:100%; padding:8px 11px; border:1px solid var(--border-strong); border-radius:8px; color:var(--text); background:#fff; }
input[aria-invalid="true"] { border-color:var(--danger); box-shadow:0 0 0 1px var(--danger); }
.filter-actions { display:flex; align-items:end; gap:9px; }
.advanced-filters { position:relative; }
.advanced-filters > summary { min-height:44px; display:flex; align-items:center; padding:8px 12px; border:1px solid var(--border); border-radius:8px; color:#34425b; background:#fff; cursor:pointer; font-weight:700; white-space:nowrap; }
.advanced-filters[open] > summary { color:#163c8c; border-color:#8aacf2; background:var(--primary-soft); }
.filter-popover { position:absolute; z-index:12; top:51px; right:0; width:310px; display:grid; gap:11px; padding:13px; border:1px solid var(--border); border-radius:9px; background:#fff; box-shadow:var(--shadow); }
.filter-popover label { display:block; margin-bottom:4px; color:#34425b; font-size:.78rem; font-weight:700; }
.selection-mode { width:100%; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.selection-mode[hidden],.filter-form[hidden] { display:none; }
.selection-copy { min-width:0; }
.selection-copy strong { display:block; color:#163c8c; }
.selection-copy span { color:var(--muted); font-size:.8rem; }
.table-summary { min-height:45px; display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px; padding:8px 14px; color:var(--muted); font-size:.82rem; border-bottom:1px solid #e5eaf1; }
.prototype-controls { position:relative; }
.prototype-controls summary { cursor:pointer; color:#34425b; font-weight:700; }
.demo-menu { position:absolute; z-index:10; right:0; top:30px; width:220px; display:grid; gap:6px; padding:9px; border:1px solid var(--border); border-radius:9px; background:#fff; box-shadow:var(--shadow); }
.demo-menu button { min-height:38px; padding:6px 9px; border:1px solid var(--border); border-radius:7px; color:#34425b; background:var(--subtle); text-align:left; }
.table-scroll { width:100%; overflow:auto; scrollbar-gutter:stable; }
table { width:100%; min-width:1180px; border-collapse:separate; border-spacing:0; table-layout:fixed; }
th,td { padding:11px 10px; border-bottom:1px solid #e3e8ef; text-align:left; vertical-align:middle; overflow-wrap:anywhere; }
th { position:sticky; top:0; z-index:4; color:#3b4960; background:#f1f5f9; font-size:.78rem; font-weight:750; white-space:nowrap; }
th:first-child,td:first-child { position:sticky; left:0; z-index:3; width:52px; text-align:center; background:inherit; }
th:nth-child(2),td:nth-child(2) { position:sticky; left:52px; z-index:3; width:270px; background:inherit; box-shadow:1px 0 0 #dce3ec; }
th:first-child,th:nth-child(2) { z-index:6; background:#f1f5f9; }
th:nth-child(3) { width:105px; } th:nth-child(4) { width:90px; } th:nth-child(5) { width:108px; }
th:nth-child(6) { width:72px; } th:nth-child(7) { width:260px; } th:nth-child(8) { width:130px; } th:nth-child(9) { width:166px; }
tbody tr { background:#fff; }
tbody tr:nth-child(even) { background:#fbfcfe; }
tbody tr:hover { background:#edf4ff; }
tbody tr:focus { position:relative; z-index:2; background:#eaf1ff; }
tbody tr[data-selected="true"] { background:#eaf1ff; }
tbody tr[data-new="true"] { animation:new-row 1.1s ease-out; }
td { font-size:.87rem; }
.row-check,.head-check { width:18px; height:18px; margin:0; accent-color:var(--primary); }
.product-link { display:inline-flex; align-items:start; gap:5px; color:#173e91; font-weight:700; line-height:1.35; }
.product-link svg { width:14px; height:14px; flex:0 0 auto; margin-top:2px; fill:none; stroke:currentColor; stroke-width:2; }
.task-link { display:block; width:max-content; max-width:100%; margin-top:4px; color:var(--muted); font-size:.75rem; font-variant-numeric:tabular-nums; }
.money,.quantity,.created { font-variant-numeric:tabular-nums; }
.money,.quantity { text-align:right; }
.result { color:#34425b; font-variant-numeric:tabular-nums; }
.result.empty { color:#7b8799; }
.status { display:inline-flex; align-items:center; gap:6px; padding:4px 8px; border:1px solid #b9cffc; border-radius:999px; color:#163c8c; background:var(--primary-soft); font-size:.74rem; font-weight:750; white-space:nowrap; }
.status::before { content:""; width:7px; height:7px; border-radius:50%; background:currentColor; }
.status.warning { color:var(--warning); border-color:#e8c983; background:var(--warning-soft); }
.status.success { color:var(--success); border-color:#a6e3c4; background:var(--success-soft); }
.status.danger { color:var(--danger); border-color:#f3b7b2; background:var(--danger-soft); }
.status.neutral { color:#526079; border-color:#cfd8e6; background:#f1f5f9; }
.table-state { min-height:320px; display:none; place-items:center; padding:36px 20px; text-align:center; background:#fff; }
.table-state.show { display:grid; }
.table-state h2 { margin:0 0 6px; font-size:1.08rem; }
.table-state p { max-width:520px; margin:0 0 14px; color:var(--muted); }
.table-state.error { color:var(--danger); background:var(--danger-soft); }
.skeletons { width:min(100%,850px); display:grid; gap:10px; }
.skeleton { height:45px; border-radius:7px; background:linear-gradient(90deg,#e9edf3 25%,#f7f9fb 37%,#e9edf3 63%); background-size:400% 100%; animation:shimmer 1.4s ease infinite; }
.toast { position:fixed; z-index:120; right:18px; bottom:18px; width:min(420px,calc(100% - 36px)); padding:12px 14px; border:1px solid #a6e3c4; border-radius:9px; color:var(--success); background:var(--success-soft); box-shadow:var(--shadow); }
.toast[hidden] { display:none; }
dialog { color:var(--text); }
dialog::backdrop { background:rgba(15,23,42,.58); }
.create-dialog { width:min(720px,calc(100% - 28px)); max-height:calc(100dvh - 28px); padding:0; border:0; border-radius:13px; box-shadow:0 24px 80px rgba(0,0,0,.3); }
.dialog-head { display:flex; align-items:start; justify-content:space-between; gap:14px; padding:17px 20px 13px; border-bottom:1px solid var(--border); }
.dialog-head h2 { margin:0; font-size:1.15rem; }
.dialog-head p { margin:4px 0 0; color:var(--muted); font-size:.84rem; }
.icon-button { width:44px; height:44px; display:grid; flex:0 0 auto; place-items:center; padding:0; border:1px solid var(--border); border-radius:8px; color:#34425b; background:#fff; }
.icon-button svg { width:19px; height:19px; fill:none; stroke:currentColor; stroke-width:2; }
.create-form { padding:18px 20px 20px; }
.form-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:13px 15px; }
.field.full { grid-column:1/-1; }
.field-hint,.field-error { min-height:1.2em; margin:4px 0 0; font-size:.76rem; }
.field-hint { color:var(--muted); }
.field-error { color:var(--danger); font-weight:650; }
.error-summary { display:none; margin:0 0 14px; padding:10px 12px; border:1px solid #f3b7b2; border-radius:8px; color:var(--danger); background:var(--danger-soft); }
.error-summary.show { display:block; }
.dialog-actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:9px; margin-top:18px; padding-top:15px; border-top:1px solid var(--border); }
.detail-drawer { width:min(860px,94vw); height:100dvh; max-width:none; max-height:none; margin:0 0 0 auto; padding:0; border:0; border-radius:0; box-shadow:-18px 0 55px rgba(0,0,0,.22); overflow:hidden; }
.detail-drawer[open] { display:grid; grid-template-rows:auto minmax(0,1fr) auto; }
.drawer-head { display:flex; align-items:start; justify-content:space-between; gap:16px; padding:18px 20px 14px; border-bottom:1px solid var(--border); background:#fff; }
.drawer-head h2 { margin:3px 0 0; font-size:1.28rem; line-height:1.3; }
.drawer-eyebrow { margin:0; color:var(--muted); font-size:.76rem; font-variant-numeric:tabular-nums; }
.drawer-body { min-height:0; overflow-y:auto; padding:18px 20px 28px; background:var(--bg); }
.drawer-safety { margin:0 0 14px; padding:11px 13px; border:1px solid #b9cffc; border-radius:9px; color:#29466f; background:var(--primary-soft); }
.drawer-panel { margin-bottom:13px; padding:15px; border:1px solid var(--border); border-radius:10px; background:#fff; }
.drawer-panel h3 { margin:0 0 10px; font-size:1rem; }
.detail-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:9px; margin:0; }
.detail-grid div { min-width:0; padding:10px 11px; border:1px solid #e1e7ef; border-radius:8px; background:var(--subtle); }
.detail-grid dt { color:var(--muted); font-size:.75rem; }
.detail-grid dd { margin:2px 0 0; font-weight:650; overflow-wrap:anywhere; }
.evidence-placeholder { min-height:160px; display:grid; place-items:center; padding:20px; border:1px dashed #aebacc; border-radius:9px; color:var(--muted); background:#f1f5f9; text-align:center; }
.next-step { padding:11px 13px; border-left:4px solid var(--primary); border-radius:6px; color:#29466f; background:var(--primary-soft); }
.drawer-actions { display:flex; justify-content:flex-end; gap:9px; padding:13px 20px; border-top:1px solid var(--border); background:#fff; }
@keyframes shimmer { to { background-position:-100% 0; } }
@keyframes new-row { from { background:#fff2bf; } to { background:#fff; } }
@media (max-width:767px) {
.app-header { top:33px; padding-inline:16px; gap:10px; }
.header-account { display:none; }
nav.primary a { padding-inline:8px; }
main { width:min(100% - 20px,1480px); padding-top:18px; }
.page-head { align-items:start; flex-direction:column; }
.safety-note { text-align:left; }
.toolbar-row,.filter-form,.selection-mode { align-items:stretch; flex-direction:column; }
.toolbar-group,.filter-field { width:100%; }
.toolbar-group .button,.filter-form .button,.selection-mode .button { flex:1 1 auto; }
.filter-actions { width:100%; align-items:stretch; flex-direction:column; }
.advanced-filters,.advanced-filters > summary { width:100%; }
.filter-popover { position:static; width:100%; margin-top:7px; box-shadow:none; }
.form-grid,.detail-grid { grid-template-columns:1fr; }
.field.full { grid-column:auto; }
.create-dialog { width:100%; max-height:100dvh; height:100dvh; margin:0; border-radius:0; }
.detail-drawer { width:100vw; }
.drawer-actions { flex-direction:column-reverse; }
.drawer-actions .button { width:100%; }
}
@media (max-width: 430px) {
.prototype-banner { position: static; padding-inline: 8px; }
.app-header { top: 0; }
.brand span:last-child, nav.primary a:last-child { display: none; }
@media (max-width:430px) {
.prototype-banner { position:static; padding-inline:8px; }
.app-header { top:0; }
.brand span:last-child,nav.primary a:last-child { display:none; }
.toolbar-group { display:grid; grid-template-columns:1fr 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
@media (prefers-reduced-motion:reduce) {
*,*::before,*::after { scroll-behavior:auto!important; animation-duration:.01ms!important; animation-iteration-count:1!important; transition-duration:.01ms!important; }
}
</style>
</head>
@@ -119,156 +185,413 @@
<div class="prototype-banner">PROTOTYPE - 仅供枚举交互,非实现依据</div>
<header class="app-header">
<a class="brand" href="web-task-workbench.html"><span class="brand-mark" aria-hidden="true">CM</span><span>cmbuyer</span></a>
<nav class="primary" aria-label="主导航"><a href="web-task-workbench.html" aria-current="page">任务工作台</a><a href="web-task-create.html">新建任务</a></nav>
<nav class="primary" aria-label="主导航"><a href="web-task-workbench.html" aria-current="page">采购任务</a><a href="web-task-create.html">建单直达页</a></nav>
<span class="header-account">采购管理员 · caigou_admin</span>
</header>
<main id="main">
<header class="page-head">
<div><h1>任务工作台</h1><p class="subtitle">按“谁持球”处理任务;第一条待决策任务默认展开。</p></div>
<a class="button button-primary" href="web-task-create.html">新建采购任务</a>
<div><h1>采购任务</h1><p class="subtitle">创建后先保存为待开始;勾选后统一进入第一趟试选队列。</p></div>
<p class="safety-note"><strong>只开始试选,不会下单。</strong> 下单必须在试选完成并经人工确认后另行授权,付款始终由人完成。</p>
</header>
<section class="query-panel" aria-label="任务查询">
<div class="keyword"><label for="keyword">关键词</label><input id="keyword" type="search" placeholder="标题、规格或任务编号" autocomplete="off"></div>
<div><label for="lane-filter">泳道</label><select id="lane-filter"><option value="all">全部泳道</option><option value="decision">等你决定</option><option value="machine">机器在跑</option><option value="done">已结束</option></select></div>
<div><label for="time-filter">时间范围</label><select id="time-filter"><option value="all">全部时间</option><option value="today">今天</option><option value="week">最近 7 天</option></select></div>
<button class="button button-secondary" id="clear-filters" type="button">清除筛选</button>
</section>
<p class="query-hint">各泳道计数只随关键词与时间范围变化,不随泳道选择变化。</p>
<div class="summary-line">
<span id="result-summary" aria-live="polite">共 6 条假数据任务,其中 3 条等你决定。</span>
<div class="demo-controls" aria-label="原型状态控制"><strong>原型状态:</strong><button type="button" data-demo="default">默认</button><button type="button" data-demo="loading">加载中</button><button type="button" data-demo="empty">空结果</button><button type="button" data-demo="error">加载失败</button></div>
</div>
<section class="panel-state" id="loading-state" aria-live="polite">
<div class="skeletons" aria-label="正在加载任务"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div><p>正在加载任务,请稍候…</p></div>
</section>
<section class="panel-state" id="empty-state">
<div><h2>没有匹配的任务</h2><p>调整关键词或时间范围,或者清除全部筛选条件。</p><button class="button button-primary" type="button" data-action="clear">清除筛选</button></div>
</section>
<section class="panel-state error" id="error-state" role="alert">
<div><h2>任务列表加载失败</h2><p>原型模拟服务暂时不可用。当前筛选已保留,可以安全重试。</p><button class="button button-primary" type="button" data-action="retry">重试加载</button></div>
</section>
<section class="lanes" id="lanes" aria-label="任务泳道">
<section class="lane" data-lane="decision" aria-labelledby="decision-title">
<header class="lane-head"><div class="lane-title"><span class="lane-mark" aria-hidden="true"></span><h2 id="decision-title">等你决定</h2></div><span class="count" id="count-decision">3</span></header>
<p class="lane-description">确认试选、核对待付款或处理结果不明。</p>
<div class="task-list">
<article class="task-card" data-task data-lane-name="decision" data-date="today" data-search="T-20260803-018 工作服 白色 XL 等待确认">
<div class="card-top"><div><span class="task-id">T-20260803-018</span><h3>8 月仓库工作服补货</h3></div><span class="badge">等待确认</span></div>
<dl class="task-meta"><div><dt>要求</dt><dd>白色 · XL · 2 件</dd></div><div><dt>机器选到</dt><dd>白色 · XL</dd></div><div><dt>读到单价</dt><dd>¥32.50</dd></div><div><dt>上限 / 合计</dt><dd>¥80.00 / ¥65.00</dd></div></dl>
<details open><summary>已展开:机器选对了吗?</summary><div class="decision-preview"><p><strong>规格一致、金额未超上限。</strong>确认前仍需查看规格面板截图。</p><a class="button button-primary" href="web-task-detail.html">查看证据并决定</a></div></details>
<footer class="card-footer"><span>更新于 10:42</span><a href="web-task-detail.html">详情</a></footer>
</article>
<article class="task-card" data-task data-lane-name="decision" data-date="today" data-search="T-20260803-016 办公室收纳盒 透明 L 结果不明 调和">
<div class="card-top"><div><span class="task-id">T-20260803-016</span><h3>办公室收纳盒</h3></div><span class="badge warning">结果不明</span></div>
<dl class="task-meta"><div><dt>状态</dt><dd>可能已创建订单</dd></div><div><dt>提交记录</dt><dd>SUB-016-A</dd></div></dl>
<footer class="card-footer"><span>更新于 09:58</span><a href="web-task-detail.html">人工核查</a></footer>
</article>
<article class="task-card" data-task data-lane-name="decision" data-date="week" data-search="T-20260801-009 标签打印纸 白色 50x30 待付款">
<div class="card-top"><div><span class="task-id">T-20260801-009</span><h3>标签打印纸补货</h3></div><span class="badge warning">待付款</span></div>
<dl class="task-meta"><div><dt>规格</dt><dd>白色 · 50×30</dd></div><div><dt>授权金额</dt><dd>¥46.00</dd></div></dl>
<footer class="card-footer"><span>更新于 8 月 1 日</span><a href="web-task-detail.html">核对订单</a></footer>
</article>
<section class="workbench" aria-label="采购任务表格工作台">
<div class="toolbar">
<div class="toolbar-row">
<div class="toolbar-group">
<button class="button button-secondary" type="button" disabled aria-describedby="import-hint">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 19h14"/></svg>导入
</button>
<button class="button button-primary" id="open-create" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>创建
</button>
</div>
<p class="toolbar-hint" id="import-hint">导入暂未开放;MVP 只支持手工创建。</p>
</div>
</section>
<section class="lane" data-lane="machine" aria-labelledby="machine-title">
<header class="lane-head"><div class="lane-title"><span class="lane-mark" aria-hidden="true"></span><h2 id="machine-title">机器在跑</h2></div><span class="count" id="count-machine">2</span></header>
<p class="lane-description">待领取、试选、授权后第二趟与围栏前执行。</p>
<div class="task-list">
<article class="task-card" data-task data-lane-name="machine" data-date="today" data-search="T-20260803-019 工作服 深灰 L 待领取">
<div class="card-top"><div><span class="task-id">T-20260803-019</span><h3>新建的工作服任务</h3></div><span class="badge">待领取</span></div>
<dl class="task-meta"><div><dt>要求</dt><dd>深灰 · L · 1 件</dd></div><div><dt>价格上限</dt><dd>¥45.00</dd></div></dl>
<footer class="card-footer"><span>更新于 10:47</span><a href="web-task-detail.html">查看进度</a></footer>
</article>
<article class="task-card" data-task data-lane-name="machine" data-date="today" data-search="T-20260803-017 防滑手套 黑色 均码 第二趟 围栏前">
<div class="card-top"><div><span class="task-id">T-20260803-017</span><h3>仓库防滑手套</h3></div><span class="badge">第二趟 · 围栏前</span></div>
<dl class="task-meta"><div><dt>锁定单价</dt><dd>¥12.80</dd></div><div><dt>授权到期</dt><dd>11:25</dd></div></dl>
<footer class="card-footer"><span>更新于 10:45</span><a href="web-task-detail.html">查看授权</a></footer>
</article>
<div class="toolbar-row">
<form class="filter-form" id="filter-form" role="search">
<div class="filter-field"><label for="keyword">标题关键词</label><input id="keyword" type="search" placeholder="输入采购任务标题" autocomplete="off"></div>
<div class="filter-actions">
<details class="advanced-filters" id="advanced-filters"><summary>更多条件</summary><div class="filter-popover"><div><label for="status-filter">状态</label><select id="status-filter"><option value="all">全部状态</option><option value="DRAFT">待开始</option><option value="PENDING">待领取</option><option value="WAITING_CONFIRMATION">等待确认</option><option value="WAITING_PAYMENT">待付款</option><option value="RECONCILIATION_REQUIRED">结果待核查</option><option value="SUCCEEDED">已完成</option></select></div><div><label for="time-filter">创建时间</label><select id="time-filter"><option value="all">全部时间</option><option value="today">今天</option><option value="week">最近 7 天</option></select></div></div></details>
<div class="toolbar-group"><button class="button button-primary" type="submit">筛选</button><button class="button button-secondary" id="clear-filter" type="button">清除</button></div>
</div>
</form>
<div class="selection-mode" id="selection-mode" hidden>
<div class="selection-copy"><strong id="selection-count">已选择 0 条</strong><span>只会加入第一趟试选队列,不签发授权、不创建订单、不付款。</span></div>
<div class="toolbar-group"><button class="button button-primary" id="start-trial" type="button">开始试选</button><button class="button button-secondary" id="clear-selection" type="button">清除选择</button></div>
</div>
</div>
</section>
</div>
<section class="lane" data-lane="done" aria-labelledby="done-title">
<header class="lane-head"><div class="lane-title"><span class="lane-mark" aria-hidden="true"></span><h2 id="done-title">已结束</h2></div><span class="count" id="count-done">1</span></header>
<p class="lane-description">已付款完成、失败或已取消的归档任务。</p>
<div class="task-list">
<article class="task-card" data-task data-lane-name="done" data-date="week" data-search="T-20260731-042 打包胶带 透明 45mm 已完成">
<div class="card-top"><div><span class="task-id">T-20260731-042</span><h3>打包胶带补货</h3></div><span class="badge success">已完成</span></div>
<dl class="task-meta"><div><dt>规格</dt><dd>透明 · 45mm</dd></div><div><dt>人工标记</dt><dd>采购管理员</dd></div></dl>
<footer class="card-footer"><span>更新于 7 月 31 日</span><a href="web-task-detail.html">查看记录</a></footer>
</article>
</div>
</section>
<div class="table-summary">
<span id="result-summary" aria-live="polite">正在准备假数据任务…</span>
<details class="prototype-controls"><summary>原型状态</summary><div class="demo-menu"><button type="button" data-demo="default">默认表格</button><button type="button" data-demo="loading">加载中</button><button type="button" data-demo="empty">空结果</button><button type="button" data-demo="error">加载失败</button></div></details>
</div>
<div class="table-scroll" id="table-scroll" tabindex="0" aria-label="采购任务表格,可横向滚动">
<table aria-describedby="table-help">
<caption id="table-help" style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0">双击非控件区域或聚焦行后按 Enter 查看详情;空格勾选待开始任务。</caption>
<thead><tr><th scope="col"><input class="head-check" id="select-all" type="checkbox" aria-label="选择当前筛选结果中全部待开始任务"></th><th scope="col">标题</th><th scope="col">颜色</th><th scope="col">尺码</th><th scope="col" class="money">价格上限</th><th scope="col" class="quantity">数量</th><th scope="col">采购结果 <span title="固定顺序:颜色、尺码、单价、数量">ⓘ</span></th><th scope="col">状态</th><th scope="col" aria-sort="descending">创建时间 ↓</th></tr></thead>
<tbody id="task-rows"></tbody>
</table>
</div>
<section class="table-state" id="loading-state" aria-live="polite"><div class="skeletons"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div><p>正在加载采购任务,请稍候…</p></div></section>
<section class="table-state" id="empty-state"><div><h2>没有匹配的采购任务</h2><p>更换标题关键词,或者清除当前筛选。</p><button class="button button-primary" type="button" data-action="clear-filter">清除筛选</button></div></section>
<section class="table-state error" id="error-state" role="alert"><div><h2>任务列表加载失败</h2><p>当前筛选条件已保留。检查连接后可以安全重试。</p><button class="button button-primary" type="button" data-action="retry">重试加载</button></div></section>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<dialog class="create-dialog" id="create-dialog" aria-labelledby="create-title">
<header class="dialog-head"><div><h2 id="create-title">创建采购任务</h2><p>保存后为待开始,不会立即驱动手机。</p></div><button class="icon-button" type="button" data-close-create aria-label="关闭创建弹窗"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button></header>
<form class="create-form" id="create-form" novalidate>
<div class="error-summary" id="create-error-summary" role="alert" tabindex="-1"></div>
<div class="form-grid">
<div class="field full"><label for="create-name">任务标题 *</label><input id="create-name" name="title" value="秋季仓库工作服补货" aria-describedby="create-name-error"><p class="field-error" id="create-name-error"></p></div>
<div class="field full"><label for="create-url">拼多多商品链接 *</label><input id="create-url" name="product_url" type="url" inputmode="url" value="https://mobile.yangkeduo.invalid/goods.html?goods_id=900000001" aria-describedby="create-url-hint create-url-error"><p class="field-hint" id="create-url-hint">原型使用 .invalid 假链接;生产环境需解析出 goods_id。</p><p class="field-error" id="create-url-error"></p></div>
<div class="field"><label for="create-color">颜色 *</label><input id="create-color" name="color" value="藏青色" aria-describedby="create-color-error"><p class="field-error" id="create-color-error"></p></div>
<div class="field"><label for="create-size">尺码 *</label><input id="create-size" name="size" value="XL" aria-describedby="create-size-error"><p class="field-error" id="create-size-error"></p></div>
<div class="field"><label for="create-price">价格上限(总额) *</label><input id="create-price" name="max_price" type="text" inputmode="decimal" value="80.00" aria-describedby="create-price-hint create-price-error"><p class="field-hint" id="create-price-hint">金额使用十进制字符串,最多两位小数。</p><p class="field-error" id="create-price-error"></p></div>
<div class="field"><label for="create-quantity">数量 *</label><input id="create-quantity" name="quantity" type="number" inputmode="numeric" min="1" step="1" value="2" aria-describedby="create-quantity-error"><p class="field-error" id="create-quantity-error"></p></div>
</div>
<div class="dialog-actions"><button class="button button-secondary" type="button" data-close-create>取消</button><button class="button button-primary" type="submit">保存任务</button></div>
</form>
</dialog>
<dialog class="detail-drawer" id="detail-drawer" aria-labelledby="drawer-title">
<header class="drawer-head"><div><p class="drawer-eyebrow" id="drawer-id">任务详情</p><h2 id="drawer-title" tabindex="-1">采购任务</h2></div><button class="icon-button" id="close-drawer" type="button" aria-label="关闭任务详情"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button></header>
<div class="drawer-body">
<p class="drawer-safety"><strong>系统只创建待付款订单,绝不自动付款。</strong> 当前详情使用假数据,不访问拼多多或真实接口。</p>
<section class="drawer-panel"><h3>采购要求</h3><dl class="detail-grid" id="drawer-requirements"></dl></section>
<section class="drawer-panel"><h3>采购结果</h3><div id="drawer-result"></div></section>
<section class="drawer-panel"><h3>当前状态与下一步</h3><div class="next-step" id="drawer-next-step"></div></section>
<section class="drawer-panel"><h3>执行证据</h3><div class="evidence-placeholder"><div><strong>假数据证据占位</strong><br>真实可读字段必须等待 T-103 真机取证;不展示地址、手机号或付款信息。</div></div></section>
</div>
<footer class="drawer-actions"><button class="button button-secondary" id="drawer-close-secondary" type="button">关闭</button><a class="button button-primary" id="full-detail-link" href="web-task-detail.html">在完整页面打开</a></footer>
</dialog>
<script>
(() => {
'use strict';
const keyword = document.querySelector('#keyword');
const laneFilter = document.querySelector('#lane-filter');
const timeFilter = document.querySelector('#time-filter');
const lanes = document.querySelector('#lanes');
const tasks = [
{ id:'T-20260803-023', title:'仓库雨衣补货', productUrl:'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000023', color:'荧光黄', size:'XXL', maxPrice:'96.00', quantity:3, status:'DRAFT', createdAt:'2026-08-03T14:42:00+08:00', result:null },
{ id:'T-20260803-022', title:'8 月仓库工作服补货', productUrl:'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000022', color:'白色', size:'XL', maxPrice:'80.00', quantity:2, status:'WAITING_CONFIRMATION', createdAt:'2026-08-03T13:18:00+08:00', result:{ phase:'试选', color:'白色', size:'XL', unitPrice:'32.50', quantity:2 } },
{ id:'T-20260803-021', title:'办公室收纳盒', productUrl:'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000021', color:'透明', size:'L', maxPrice:'60.00', quantity:2, status:'RECONCILIATION_REQUIRED', createdAt:'2026-08-03T11:36:00+08:00', result:{ phase:'下单待核查', color:'透明', size:'L', unitPrice:'24.00', quantity:2 } },
{ id:'T-20260803-020', title:'仓库防滑手套', productUrl:'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000020', color:'黑色', size:'均码', maxPrice:'64.00', quantity:5, status:'PENDING', createdAt:'2026-08-03T10:45:00+08:00', result:null },
{ id:'T-20260801-009', title:'标签打印纸补货', productUrl:'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000009', color:'白色', size:'50×30', maxPrice:'50.00', quantity:1, status:'WAITING_PAYMENT', createdAt:'2026-08-01T16:12:00+08:00', result:{ phase:'已下单', color:'白色', size:'50×30', unitPrice:'46.00', quantity:1 } },
{ id:'T-20260731-042', title:'打包胶带补货', productUrl:'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000042', color:'透明', size:'45mm', maxPrice:'55.00', quantity:4, status:'SUCCEEDED', createdAt:'2026-07-31T09:20:00+08:00', result:{ phase:'已下单', color:'透明', size:'45mm', unitPrice:'12.00', quantity:4 } }
];
const statusMeta = {
DRAFT:{ label:'待开始', tone:'neutral', next:'勾选后点击“开始试选”,任务才会进入设备领取队列。' },
PENDING:{ label:'待领取', tone:'', next:'已进入第一趟试选队列,等待桌面端轮询领取。' },
WAITING_CONFIRMATION:{ label:'等待确认', tone:'', next:'查看规格面板证据,确认机器选对后才可签发下单授权。' },
RECONCILIATION_REQUIRED:{ label:'结果待核查', tone:'warning', next:'订单可能已创建。只核查同一提交记录,不得重新提交。' },
WAITING_PAYMENT:{ label:'待付款', tone:'warning', next:'人在拼多多核对商品、规格、数量和金额后手工付款;系统不会付款。' },
SUCCEEDED:{ label:'已完成', tone:'success', next:'已由人工付款并标记完成,可查看执行证据与审计记录。' }
};
const rows = document.querySelector('#task-rows');
const tableScroll = document.querySelector('#table-scroll');
const summary = document.querySelector('#result-summary');
const states = { loading: document.querySelector('#loading-state'), empty: document.querySelector('#empty-state'), error: document.querySelector('#error-state') };
const cards = [...document.querySelectorAll('[data-task]')];
const keyword = document.querySelector('#keyword');
const statusFilter = document.querySelector('#status-filter');
const timeFilter = document.querySelector('#time-filter');
const filterForm = document.querySelector('#filter-form');
const selectionMode = document.querySelector('#selection-mode');
const selectionCount = document.querySelector('#selection-count');
const selectAll = document.querySelector('#select-all');
const states = { loading:document.querySelector('#loading-state'), empty:document.querySelector('#empty-state'), error:document.querySelector('#error-state') };
const selectedIds = new Set();
let appliedKeyword = '';
let appliedStatus = 'all';
let appliedTime = 'all';
let newTaskId = '';
let demoState = 'default';
let lastDetailTrigger = null;
let toastTimer = null;
function hideStates() {
Object.values(states).forEach((state) => state.classList.remove('show'));
lanes.hidden = false;
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (character) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#039;' })[character]);
}
function applyFilters() {
hideStates();
const query = keyword.value.trim().toLowerCase();
const time = timeFilter.value;
const selectedLane = laneFilter.value;
const counts = { decision: 0, machine: 0, done: 0 };
let shown = 0;
cards.forEach((card) => {
const matchesText = !query || card.dataset.search.toLowerCase().includes(query);
const matchesTime = time === 'all' || card.dataset.date === time || (time === 'week' && card.dataset.date === 'today');
const matchesBase = matchesText && matchesTime;
if (matchesBase) counts[card.dataset.laneName] += 1;
const visible = matchesBase && (selectedLane === 'all' || selectedLane === card.dataset.laneName);
card.hidden = !visible;
if (visible) shown += 1;
function normalizeMoney(value) {
const parts = value.split('.');
const whole = parts[0].replace(/^0+(?=\d)/, '') || '0';
const fraction = `${parts[1] || ''}00`.slice(0,2);
return `${whole}.${fraction}`;
}
function formatDate(value) {
return new Intl.DateTimeFormat('zh-CN',{ year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', hour12:false }).format(new Date(value));
}
function visibleTasks() {
const now = new Date();
return [...tasks].sort((a,b) => new Date(b.createdAt) - new Date(a.createdAt)).filter((task) => {
const matchesTitle = !appliedKeyword || task.title.toLowerCase().includes(appliedKeyword.toLowerCase());
const matchesStatus = appliedStatus === 'all' || task.status === appliedStatus;
const created = new Date(task.createdAt);
const matchesTime = appliedTime === 'all' || (appliedTime === 'today' && created.toDateString() === now.toDateString()) || (appliedTime === 'week' && now - created <= 7 * 24 * 60 * 60 * 1000);
return matchesTitle && matchesStatus && matchesTime;
});
Object.entries(counts).forEach(([name, value]) => { document.querySelector(`#count-${name}`).textContent = value; });
document.querySelectorAll('.lane').forEach((lane) => { lane.hidden = selectedLane !== 'all' && lane.dataset.lane !== selectedLane; });
summary.textContent = `当前显示 ${shown} 条;等你决定 ${counts.decision},机器在跑 ${counts.machine},已结束 ${counts.done}。`;
if (!shown) { lanes.hidden = true; states.empty.classList.add('show'); }
}
function clearFilters() {
function formatResult(task) {
if (!task.result) return '<span class="result empty">—</span>';
const result = task.result;
const readable = `${result.phase}:${result.color},${result.size},${result.unitPrice} 元,${result.quantity} 件`;
return `<span class="result" aria-label="${escapeHtml(readable)}"><strong>${escapeHtml(result.phase)}:</strong>${escapeHtml(result.color)} | ${escapeHtml(result.size)} | ¥${escapeHtml(result.unitPrice)} | ${result.quantity}件</span>`;
}
function renderRows() {
const visible = visibleTasks();
const validIds = new Set(tasks.filter((task) => task.status === 'DRAFT').map((task) => task.id));
[...selectedIds].forEach((id) => { if (!validIds.has(id)) selectedIds.delete(id); });
rows.innerHTML = visible.map((task) => {
const selectable = task.status === 'DRAFT';
const checked = selectedIds.has(task.id);
const meta = statusMeta[task.status] || { label:task.status, tone:'neutral', next:'查看完整详情了解下一步。' };
return `<tr tabindex="0" data-task-row="${escapeHtml(task.id)}" data-selected="${checked}" data-new="${task.id === newTaskId}" aria-label="任务 ${escapeHtml(task.title)},状态 ${escapeHtml(meta.label)}。双击或按 Enter 查看详情${selectable ? ',按空格切换选择' : ''}">
<td><input class="row-check" type="checkbox" data-select-task="${escapeHtml(task.id)}" aria-label="选择任务 ${escapeHtml(task.title)}" ${checked ? 'checked' : ''} ${selectable ? '' : `disabled title="仅待开始任务可勾选;当前为${escapeHtml(meta.label)}"`}></td>
<td><a class="product-link" href="${escapeHtml(task.productUrl)}" target="_blank" rel="noopener noreferrer" aria-label="在新标签打开拼多多商品假链接:${escapeHtml(task.title)}">${escapeHtml(task.title)}<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-9 9M19 13v6H5V5h6"/></svg></a><a class="task-link" href="#/tasks/${encodeURIComponent(task.id)}" data-detail-link="${escapeHtml(task.id)}">${escapeHtml(task.id)} · 查看任务详情</a></td>
<td>${escapeHtml(task.color)}</td><td>${escapeHtml(task.size)}</td><td class="money">¥${escapeHtml(task.maxPrice)}</td><td class="quantity">${task.quantity}</td><td>${formatResult(task)}</td><td><span class="status ${meta.tone}">${escapeHtml(meta.label)}</span></td><td class="created"><time datetime="${escapeHtml(task.createdAt)}">${escapeHtml(formatDate(task.createdAt))}</time></td>
</tr>`;
}).join('');
tableScroll.hidden = demoState !== 'default' || visible.length === 0;
Object.values(states).forEach((state) => state.classList.remove('show'));
if (demoState !== 'default') states[demoState].classList.add('show');
else if (!visible.length) states.empty.classList.add('show');
summary.textContent = demoState === 'loading' ? '正在加载任务。' : demoState === 'error' ? '任务加载失败,筛选条件已保留。' : demoState === 'empty' ? '当前演示空结果。' : `共 ${visible.length} 条任务,按创建时间倒序;其中 ${visible.filter((task) => task.status === 'DRAFT').length} 条待开始。`;
updateSelectionUi();
if (newTaskId) window.setTimeout(() => { newTaskId = ''; }, 1200);
}
function updateSelectionUi() {
const count = selectedIds.size;
filterForm.hidden = count > 0;
selectionMode.hidden = count === 0;
selectionCount.textContent = `已选择 ${count} 条待开始任务`;
const selectableVisible = visibleTasks().filter((task) => task.status === 'DRAFT');
const selectedVisible = selectableVisible.filter((task) => selectedIds.has(task.id));
selectAll.checked = selectableVisible.length > 0 && selectedVisible.length === selectableVisible.length;
selectAll.indeterminate = selectedVisible.length > 0 && selectedVisible.length < selectableVisible.length;
selectAll.disabled = selectableVisible.length === 0;
}
function showToast(message) {
const toast = document.querySelector('#toast');
window.clearTimeout(toastTimer);
toast.textContent = message;
toast.hidden = false;
toastTimer = window.setTimeout(() => { toast.hidden = true; }, 4500);
}
function showDemo(state) {
demoState = state;
selectedIds.clear();
document.querySelector('.prototype-controls').open = false;
renderRows();
}
filterForm.addEventListener('submit', (event) => {
event.preventDefault();
appliedKeyword = keyword.value.trim();
appliedStatus = statusFilter.value;
appliedTime = timeFilter.value;
selectedIds.clear();
demoState = 'default';
document.querySelector('#advanced-filters').open = false;
renderRows();
});
function clearFilter() {
keyword.value = '';
laneFilter.value = 'all';
statusFilter.value = 'all';
timeFilter.value = 'all';
applyFilters();
appliedKeyword = '';
appliedStatus = 'all';
appliedTime = 'all';
selectedIds.clear();
demoState = 'default';
renderRows();
keyword.focus();
}
document.querySelector('#clear-filter').addEventListener('click', clearFilter);
document.querySelectorAll('[data-action="clear-filter"]').forEach((button) => button.addEventListener('click', clearFilter));
document.querySelectorAll('[data-action="retry"]').forEach((button) => button.addEventListener('click', () => showDemo('default')));
document.querySelectorAll('[data-demo]').forEach((button) => button.addEventListener('click', () => showDemo(button.dataset.demo)));
function showDemo(name) {
if (name === 'default') { applyFilters(); return; }
lanes.hidden = true;
Object.values(states).forEach((state) => state.classList.remove('show'));
states[name].classList.add('show');
summary.textContent = name === 'loading' ? '正在加载任务。' : name === 'empty' ? '筛选结果为空。' : '任务加载失败,筛选条件已保留。';
selectAll.addEventListener('change', () => {
visibleTasks().filter((task) => task.status === 'DRAFT').forEach((task) => selectAll.checked ? selectedIds.add(task.id) : selectedIds.delete(task.id));
renderRows();
});
rows.addEventListener('change', (event) => {
const checkbox = event.target.closest('[data-select-task]');
if (!checkbox) return;
checkbox.checked ? selectedIds.add(checkbox.dataset.selectTask) : selectedIds.delete(checkbox.dataset.selectTask);
renderRows();
});
document.querySelector('#clear-selection').addEventListener('click', () => { selectedIds.clear(); renderRows(); keyword.focus(); });
document.querySelector('#start-trial').addEventListener('click', () => {
const ids = [...selectedIds];
tasks.forEach((task) => { if (selectedIds.has(task.id) && task.status === 'DRAFT') task.status = 'PENDING'; });
selectedIds.clear();
renderRows();
showToast(`已将 ${ids.length} 条任务加入第一趟试选队列。没有签发下单授权,也不会付款。`);
});
function isInteractive(target) { return Boolean(target.closest('a,button,input,label,select,textarea,summary')); }
rows.addEventListener('dblclick', (event) => {
if (isInteractive(event.target)) return;
const row = event.target.closest('[data-task-row]');
if (row) openDetail(row.dataset.taskRow, row, true);
});
rows.addEventListener('keydown', (event) => {
const row = event.target.closest('[data-task-row]');
if (!row || event.target !== row) return;
if (event.key === 'Enter') { event.preventDefault(); openDetail(row.dataset.taskRow, row, true); }
if (event.key === ' ') {
const task = tasks.find((item) => item.id === row.dataset.taskRow);
if (!task || task.status !== 'DRAFT') return;
event.preventDefault();
selectedIds.has(task.id) ? selectedIds.delete(task.id) : selectedIds.add(task.id);
renderRows();
document.querySelector(`[data-task-row="${CSS.escape(task.id)}"]`)?.focus();
}
});
rows.addEventListener('click', (event) => {
const link = event.target.closest('[data-detail-link]');
if (!link || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault();
openDetail(link.dataset.detailLink, link, true);
});
const createDialog = document.querySelector('#create-dialog');
const createForm = document.querySelector('#create-form');
const createFields = {
title:document.querySelector('#create-name'), url:document.querySelector('#create-url'), color:document.querySelector('#create-color'), size:document.querySelector('#create-size'), price:document.querySelector('#create-price'), quantity:document.querySelector('#create-quantity')
};
const createErrors = Object.fromEntries(Object.keys(createFields).map((name) => [name,document.querySelector(`#create-${name === 'title' ? 'name' : name}-error`)]));
const createSummary = document.querySelector('#create-error-summary');
function clearCreateErrors() {
Object.values(createFields).forEach((field) => field.removeAttribute('aria-invalid'));
Object.values(createErrors).forEach((node) => { node.textContent = ''; });
createSummary.classList.remove('show');
createSummary.textContent = '';
}
keyword.addEventListener('input', applyFilters);
laneFilter.addEventListener('change', applyFilters);
timeFilter.addEventListener('change', applyFilters);
document.querySelector('#clear-filters').addEventListener('click', clearFilters);
document.querySelectorAll('[data-action="clear"]').forEach((button) => button.addEventListener('click', clearFilters));
document.querySelectorAll('[data-action="retry"]').forEach((button) => button.addEventListener('click', applyFilters));
document.querySelectorAll('[data-demo]').forEach((button) => button.addEventListener('click', () => showDemo(button.dataset.demo)));
applyFilters();
function showCreateErrors(messages) {
clearCreateErrors();
Object.entries(messages).forEach(([name,message]) => { createFields[name].setAttribute('aria-invalid','true'); createErrors[name].textContent = message; });
createSummary.textContent = `无法保存任务:请修正 ${Object.keys(messages).length} 个字段。`;
createSummary.classList.add('show');
createSummary.focus();
}
document.querySelector('#open-create').addEventListener('click', () => { clearCreateErrors(); createDialog.showModal(); createFields.title.focus(); });
document.querySelectorAll('[data-close-create]').forEach((button) => button.addEventListener('click', () => createDialog.close()));
createForm.addEventListener('submit', (event) => {
event.preventDefault();
const messages = {};
if (!createFields.title.value.trim()) messages.title = '请输入任务标题。';
if (!/^https:\/\/[^\s]+[?&]goods_id=\d+/i.test(createFields.url.value.trim())) messages.url = '请输入能解析出 goods_id 的 HTTPS 商品链接。';
if (!createFields.color.value.trim()) messages.color = '请输入颜色。';
if (!createFields.size.value.trim()) messages.size = '请输入尺码。';
if (!/^\d+(\.\d{1,2})?$/.test(createFields.price.value.trim()) || /^0+(\.0{1,2})?$/.test(createFields.price.value.trim())) messages.price = '请输入大于 0、最多两位小数的价格上限。';
if (!/^\d+$/.test(createFields.quantity.value) || parseInt(createFields.quantity.value,10) < 1) messages.quantity = '数量必须是大于 0 的整数。';
if (Object.keys(messages).length) { showCreateErrors(messages); return; }
const now = new Date();
const datePart = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}`;
const id = `T-${datePart}-${String(tasks.length + 24).padStart(3,'0')}`;
tasks.push({ id, title:createFields.title.value.trim(), productUrl:createFields.url.value.trim(), color:createFields.color.value.trim(), size:createFields.size.value.trim(), maxPrice:normalizeMoney(createFields.price.value.trim()), quantity:parseInt(createFields.quantity.value,10), status:'DRAFT', createdAt:now.toISOString(), result:null });
newTaskId = id;
appliedKeyword = '';
keyword.value = '';
statusFilter.value = 'all';
timeFilter.value = 'all';
appliedStatus = 'all';
appliedTime = 'all';
selectedIds.clear();
demoState = 'default';
createDialog.close();
renderRows();
const newRow = document.querySelector(`[data-task-row="${CSS.escape(id)}"]`);
newRow?.focus();
showToast(`任务 ${id} 已保存为待开始,并按创建时间显示在第一行。`);
});
const drawer = document.querySelector('#detail-drawer');
const drawerTitle = document.querySelector('#drawer-title');
const drawerId = document.querySelector('#drawer-id');
const drawerRequirements = document.querySelector('#drawer-requirements');
const drawerResult = document.querySelector('#drawer-result');
const drawerNext = document.querySelector('#drawer-next-step');
const fullDetailLink = document.querySelector('#full-detail-link');
function routeTaskId() {
const match = window.location.hash.match(/^#\/tasks\/(.+)$/);
return match ? decodeURIComponent(match[1]) : null;
}
function renderDrawer(task) {
const meta = statusMeta[task.status] || { label:task.status, tone:'neutral', next:'查看完整详情了解下一步。' };
drawerId.textContent = `${task.id} · ${meta.label}`;
drawerTitle.textContent = task.title;
drawerRequirements.innerHTML = `<div><dt>颜色</dt><dd>${escapeHtml(task.color)}</dd></div><div><dt>尺码</dt><dd>${escapeHtml(task.size)}</dd></div><div><dt>价格上限</dt><dd>¥${escapeHtml(task.maxPrice)}</dd></div><div><dt>数量</dt><dd>${task.quantity} 件</dd></div><div><dt>状态</dt><dd><span class="status ${meta.tone}">${escapeHtml(meta.label)}</span></dd></div><div><dt>创建时间</dt><dd>${escapeHtml(formatDate(task.createdAt))}</dd></div>`;
drawerResult.innerHTML = task.result ? `<p style="margin:0 0 10px">${formatResult(task)}</p><p style="margin:0;color:var(--muted);font-size:.82rem">展示值来自结构化颜色、尺码、单价和数量;“|”只用于页面格式化。</p>` : '<p class="result empty" style="margin:0">尚无采购结果。任务未完成试选或下单,不用其他数据填充。</p>';
drawerNext.textContent = meta.next;
fullDetailLink.href = `web-task-detail.html#${encodeURIComponent(task.id)}`;
}
function openDetail(id, trigger, pushRoute) {
const task = tasks.find((item) => item.id === id);
if (!task) { showToast('没有找到该任务,列表可能已经刷新。'); return; }
if (trigger) lastDetailTrigger = trigger.closest?.('[data-task-row]') || trigger;
renderDrawer(task);
if (!drawer.open) drawer.showModal();
if (pushRoute && routeTaskId() !== id) history.pushState({ taskDrawer:true, taskId:id },'',`#/tasks/${encodeURIComponent(id)}`);
window.setTimeout(() => drawerTitle.focus(),0);
}
function closeDrawerPhysical() {
if (drawer.open) drawer.close();
const focusTarget = lastDetailTrigger;
lastDetailTrigger = null;
window.setTimeout(() => focusTarget?.focus?.(),0);
}
function requestDrawerClose() {
if (history.state?.taskDrawer && routeTaskId()) history.back();
else {
history.replaceState({},'',`${location.pathname}${location.search}`);
closeDrawerPhysical();
}
}
function syncDrawerRoute() {
const id = routeTaskId();
if (id) openDetail(id,null,false);
else closeDrawerPhysical();
}
drawer.addEventListener('cancel', (event) => { event.preventDefault(); requestDrawerClose(); });
document.querySelector('#close-drawer').addEventListener('click', requestDrawerClose);
document.querySelector('#drawer-close-secondary').addEventListener('click', requestDrawerClose);
window.addEventListener('popstate', syncDrawerRoute);
window.addEventListener('hashchange', syncDrawerRoute);
renderRows();
syncDrawerRoute();
})();
</script>
</body>
+30 -19
View File
@@ -8,9 +8,9 @@
| 路由 | 页面 | 职责 | 用户故事 | 交互 |
| --- | --- | --- | --- | --- |
| `/login` | 登录 | 建立管理会话 | US-007 | IX-001 |
| `/tasks` | 工作台 | 按「谁持球」查看任务、查询筛选、进入详情 | US-002 | IX-003 |
| `/tasks/new` | 手工建单 | 填链接、颜色分类、尺码、数量、价格上限 | US-001 | IX-002 |
| `/tasks/{id}` | 任务详情 | 看要求与执行结果;**试选确认与授权**;待付款核对 | US-002、US-004、US-005 | IX-004、IX-005、IX-006 |
| `/tasks` | 采购任务 | 传统表格查询、创建弹窗、批量开始试选、进入详情抽屉 | US-001、US-002、US-010 | IX-002、IX-003、IX-004、IX-012 |
| `/tasks/new` | 手工建单直达页 | 复用创建表单,作为无脚本和直接访问的完整页兜底 | US-001 | IX-002 |
| `/tasks/{id}` | 任务详情 | 从列表进入时显示右侧详情抽屉;直接访问时显示完整页;承载试选确认、授权与待付款核对 | US-002、US-004、US-005 | IX-004、IX-005、IX-006 |
> `/tasks/import`(Excel)与 `/freight*`(ERP)随 F-002 / F-003 推迟到 V2。
@@ -19,19 +19,26 @@
## 二、web 端页面职责
### 工作台 `/tasks`
### 采购任务 `/tasks`
按**谁持球**分三条泳道,不按状态码平铺:
采用采购人员熟悉的传统表格,默认按创建时间倒序。固定列为:选择、标题、颜色、尺码、
价格上限、数量、采购结果、状态、创建时间;不设置操作列。
| 泳道 | 包含状态 | 含义 |
| --- | --- | --- |
| 等你决定 | `WAITING_CONFIRMATION`、`WAITING_PAYMENT`、`NEEDS_MANUAL`、`RECONCILIATION_REQUIRED` | 停在这里等人 |
| 机器在跑 | `PENDING`、`PENDING_RETRIAL`、`CLAIMED`、`RUNNING(TRIAL)`、`AUTHORIZED`、`ORDERING` | 不用操作 |
| 已结束 | `SUCCEEDED`、`FAILED`、`CANCELED` | 归档 |
- 落地时**第一条待决策任务直接展开**,不是先看一张表。
- 查询:关键词(标题 / 规格 / 任务编号)、泳道、时间范围。
- 计数只随关键词和时间范围变化,不随泳道选择变化。
- 标题链接在新标签打开拼多多商品页;标题下方任务编号链接打开内部任务详情。
- “采购结果”由结构化字段在展示层格式化为“阶段:颜色 | 尺码 | 单价 | 数量”;未产生
试选或下单结果时显示 `—`,不得把试选写成已下单。
- 双击行非控件区域,或聚焦行后按 Enter,打开 `/tasks/{id}` 的右侧详情抽屉。关闭抽屉、
按 Esc 或浏览器返回后,保留筛选、选择、滚动位置并把焦点还给原行。
- 直接访问 `/tasks/{id}` 或在抽屉中选择“在完整页面打开”时,使用完整详情页;关键路由可
深链接,不能只有无法复制地址的弹层状态。
- 第一工具行:导入(MVP 禁用占位并说明原因)、创建。创建打开模态表单;保存后任务以
`DRAFT` 状态出现在第一行。`/tasks/new` 保留为同表单的直达兜底。
- 第二工具行默认显示标题关键词、筛选、清除;状态和创建时间放在默认折叠的“更多条件”中,
保留 F-004 查询范围而不挤占常用操作。勾选 `DRAFT` 后切换成上下文批量栏,显示已选数量、
开始试选、清除选择;全选只覆盖当前筛选结果中的 `DRAFT`。
- “开始试选”把所选任务原子转为 `PENDING`,只进入第一趟试选队列;不签发授权、不创建
订单、不付款。其他状态复选框禁用并说明原因。
- 窄屏允许表格容器内部横向滚动,但页面本身不得横向溢出;选择列与标题列保持可见。
### 任务详情 `/tasks/{id}`
@@ -72,8 +79,9 @@
### 建单页
`/tasks/new` 填:任务名称、拼多多链接、颜色分类、尺码、数量、价格上限。
链接无法解析出 `goods_id` 时明确报错并保留已填内容。
`/tasks` 的创建弹窗与 `/tasks/new` 直达页复用同一表单:任务名称、拼多多链接、颜色分类、
尺码、数量、价格上限。链接无法解析出 `goods_id` 时明确报错并保留已填内容;保存成功后
任务为 `DRAFT`,尚未进入设备领取队列。
## 三、desk 端界面结构
@@ -102,7 +110,7 @@
## 四、导航规则
- web 端任务详情返回工作台时保留原筛选条件。
- web 端从路由化详情抽屉返回工作台时不重新加载列表;完整详情页返回时保留原筛选条件。
- desk 端启动时若服务端有本设备的活跃任务,优先恢复该任务,不允许直接领取下一条。
- 第一趟试选完成后**必须退出商品页**再进入下一轮轮询,不停在规格面板等人。
- desk 端在真机步骤执行期间禁用硬取消和关闭窗口,避免留下无法判定的中间态。
@@ -115,8 +123,11 @@
| 组件 | 归属 | 说明 |
| --- | --- | --- |
| `AppShell` | web 全局 | 导航、登录态、CSRF 注入 |
| `TaskLanes` | 工作台 | 三泳道渲染与折叠 |
| `QueryBar` | 工作台 | 关键词、泳道、时间范围 |
| `TaskTable` | 采购任务 | 任务表格、创建时间倒序、选择状态与结构化采购结果格式化 |
| `QueryBar` | 采购任务 | 标题关键词、筛选与清除 |
| `BulkTrialBar` | 采购任务 | `DRAFT` 批量选择与开始第一趟试选 |
| `CreateTaskDialog` | 采购任务 / 建单直达页 | 复用手工建单表单与字段错误 |
| `TaskDetailDrawer` | 采购任务 | `/tasks/{id}` 路由驱动的右侧详情容器与焦点恢复 |
| `SpecTrialCard` | 任务详情 | 试选确认卡(MVP 签名组件) |
| `AuthorizePanel` | 任务详情 | 确认 / 退回 / 放弃授权 |
| `PaymentCheckCard` | 任务详情 | 待付款核对与标记完成 |
+53 -4
View File
@@ -11,6 +11,7 @@ needs_device: false
needs_human_review: true
write_paths:
- docs/tasks/T-005.md
- docs/00-ai-start-here.md
- docs/02-requirements.md
- docs/04-architecture.md
- docs/06-tasks.md
@@ -54,7 +55,7 @@ web 端尚无生产代码。若直接实现 SSR 页面,登录、建单、三
## 方案
### 文件与页面
### 第一轮文件与页面(已由第二轮修订替代)
1. `web-login.html`:登录、站内返回提示、默认 / 校验错误 / 提交中状态。
2. `web-task-create.html`:任务名称、商品链接、颜色分类、尺码、数量、价格上限;逐字段错误、
@@ -72,8 +73,9 @@ web 端尚无生产代码。若直接实现 SSR 页面,登录、建单、三
不设置操作列。标题打开拼多多商品假链接,任务编号打开内部详情。
3. 表格上方第一行放“导入”“创建”:导入为有原因说明的禁用占位;创建打开模态表单,
保存后生成 `DRAFT` 假任务并按创建时间倒序插到第一行。
4. 第二行默认显示标题关键词、筛选、清除;勾选可开始的 `DRAFT` 后切换为上下文批量栏,
统一执行“开始试选”。动作只模拟 `DRAFT → PENDING`,固定说明不会创建订单或付款。
4. 第二行默认显示标题关键词、筛选、清除;状态和创建时间收进默认折叠的“更多条件”,不
放弃 F-004 查询范围。勾选可开始的 `DRAFT` 后切换为上下文批量栏,统一执行“开始试选”。
动作只模拟 `DRAFT → PENDING`,固定说明不会创建订单或付款。
5. 双击行的非控件区域,或聚焦行后按 Enter,打开右侧详情抽屉;复选框、标题外链、任务
编号链接不误触发行双击。原型用 `#/tasks/{id}` 演示生产路由 `/tasks/{id}`,关闭抽屉恢复
原筛选、选择和滚动现场;直接访问详情仍由完整页兜底。
@@ -106,7 +108,7 @@ web 端尚无生产代码。若直接实现 SSR 页面,登录、建单、三
- 四个 HTML 均可从本地直接打开,控制台无 error,除本地文档导航外无网络请求。
- 四页之间的主要入口可通过相对链接到达;所有演示按钮均为原型状态切换,不发送真实请求。
- 建单字段、三泳道、试选确认、围栏前放弃、围栏后调和、待付款核对全部有可观察页面状态。
- 建单字段、任务表格、试选确认、围栏前放弃、围栏后调和、待付款核对全部有可观察页面状态。
- 加载、空、错误、禁用与成功反馈均可演示;禁用动作旁有原因,失败旁有恢复路径。
- 键盘可完成全部演示;375 / 768 / 1024 / 1440 px 无内容截断或页面级水平滚动。
- 运行:`python scripts/validate_agent_context.py`、`git diff --check`,并用浏览器逐页检查
@@ -134,6 +136,53 @@ web 端尚无生产代码。若直接实现 SSR 页面,登录、建单、三
## 执行记录
### 2026-08-03 · 第二轮表格工作台实现
- 任务继续保持 `DOING`:本轮自动验证已完成,但 `needs_human_review: true`,仍需人工确认
表格信息密度、上下文批量栏、创建弹窗与详情抽屉。
- 整页重做 `web-task-workbench.html`:传统表格固定九列且无操作列;标题为拼多多商品假链接,
任务编号为内部详情链接;默认按创建时间倒序,采购结果由结构化假数据在展示层用 `|`
格式化,并区分试选、已下单和结果待核查。
- 第一工具行实现禁用的导入占位和创建按钮;创建弹窗覆盖必填、商品链接、金额字符串与数量
校验,失败保留输入,保存成功后生成 `DRAFT` 假任务、清除筛选并聚焦表格第一行。
- 第二工具行默认是标题关键词、筛选、清除;状态和创建时间在“更多条件”中;勾选后切换成
上下文批量栏。只有 `DRAFT` 可选,全选只覆盖当前筛选结果,开始试选后模拟整批
`DRAFT → PENDING`,固定提示不授权、不下单、不付款。
- 双击行非控件区域、任务编号链接和聚焦行按 Enter 均打开右侧详情抽屉;原型地址同步为
`#/tasks/{id}`,用来演示生产 `/tasks/{id}`。关闭按钮、Esc 和浏览器返回均关闭抽屉并把焦点
还给原行;直接带 hash 打开也能恢复抽屉,完整详情页继续作为兜底。
- 同步 `00-ai-start-here.md`、需求、用户故事、交互清单、架构状态机、管理端 API、路线图、
路由、当前状态和原型约定。新增的 MVP 能力仅为批量开始第一趟试选;排序、暂停 / 继续、
运行中接管仍保留在 V2。`web-task-create.html` 的成功态同步为 `DRAFT`。
- `ui-ux-pro-max` 影响了可见焦点、键盘等价操作、原生 `dialog` 焦点约束、路由深链接、
抽屉关闭后的焦点恢复和窄屏表格容器滚动。其自动生成的夸张字体、深色主题和横向旅程模式
不适合运营表格,继续沿用第一轮的 Segoe UI、蓝色主动作与低动效基线。
**已运行验证**
- `& .\init.ps1`:按当前 Phase 0 基线运行;上下文校验通过,因 `web/`、`desk/` 尚未初始化
按设计返回退出码 3 并提示 T-001 / T-002,未误报可运行生产代码。
- `python scripts/validate_agent_context.py`:通过(6 个任务路由,18 个有效仓库路径)。
- Node 内联静态检查:4 个 `web-*.html` 均有 doctype、viewport、原型横幅、
`prefers-reduced-motion`;id 无重复,内联脚本可编译,相对 HTML 链接存在,无外部脚本、
字体或图片资源;工作台必需字段齐全且没有“操作”表头。
- Playwright 浏览器交互:标题筛选得到正确单行;创建表单空标题错误保留弹窗,修正保存后
新任务为第一行并获焦点;全选两条 `DRAFT` 后上下文栏显示 2 条,开始后均转待领取且安全
提示正确;双击和 Enter 均写入详情路由并打开抽屉,关闭和 Esc 清空路由并恢复原行焦点;
直接访问 `#/tasks/{id}` 能打开相应抽屉及完整页链接。
- Playwright 响应式回归:登录、建单、工作台、详情四页在 375 / 768 / 1024 / 1440 px
共 16 个组合均无页面级水平溢出;工作台窄屏只在表格容器内横向滚动。浏览器累计
0 error / 0 warning,未发生外部资源请求。
- `git diff --check`:通过。
**未验证与待人工确认**
- 没有生产 Go / SSR / 数据库代码,因此未运行 `go test ./...` 或 `go vet ./...`;本轮也不
证明 `POST /tasks/start-trials` 的事务、CSRF 或幂等实现。
- 原型只使用 `.invalid` 商品假链接,不访问真实拼多多;不证明真机字段、选择器或 App 版本。
- 待人确认:九列表格的列宽与信息密度、采购结果阶段前缀、选择后替换筛选栏的方式、
720 px 创建弹窗、860 px 详情抽屉,以及 375 px 下表格横向滚动是否符合实际使用习惯。
### 2026-08-03 · 第二轮人工评审修订计划
- 人工评审已确认:工作台改为传统表格;创建改为列表内模态表单;去掉操作列;标题链接