Files
cmautobuy/docs/admin/05-ui-specification.md
T
chengmaandClaude Opus 5 e40ce5a13c fix: 商品卡在「采集中」无法恢复 (#24)
MarkCollecting 原本只在 pending/failed 时成功,而全库只有客户端提交结果
才会把 collecting 改走。客户端离线、崩溃、任务被删都是常态——一旦发生,
这个商品就永久报废,界面上没有任何入口能救,只能改数据库。

改成 collecting 超过 15 分钟视为已超时,允许重新创建采集任务。

判定在读取那一刻现算,仍是同一条原子 UPDATE,不加后台清理协程:
后台扫要处理"扫到一半客户端正好提交了"的竞态,本项目已经在并发上
栽过两次,能不引入并发就不引入。

15 分钟写成有名字的常量并注明理由:采集本身几十秒到两分钟,加上排队
等客户端来领。宁可短也不要长——采集是只读的,多采一次没有副作用,
而卡死的代价是商品永久报废。

已知取舍:超时后老任务还在队列里,客户端上线可能两个都领走、采两次。
可接受(只读,后一次覆盖前一次),已写进代码注释和 03-data-model.md,
免得后来人当成 bug 去"修"成加锁或加租约——租约和心跳是被明确移除的设计。

时间比较用字符串,依赖 NowISO 产出定宽 UTC。已在 NowISO 上加注释:
改成带时区偏移的本地时间会让这个比较静默失效,不报错但判断全错。

界面两处:超时的显示「采集中(超时)」,否则操作员盯着「采集中」
不知道它已经死了;跳过原因按正在采集/已采集/已删除分类计数,
一个都没建成时告诉操作员还要等多久。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:42:54 +08:00

425 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 05 Admin 界面规范
- 文档状态:基线草案,待界面评审
- 技术栈:Go `html/template` 服务端渲染 + 手写 CSS + 原生 JS / htmx
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
## 1. 设计目标
- 五个模块**长得一样**,写完第一个,其余四个照抄结构改字段;
- 操作员一眼看出"这条数据卡在哪一步、下一步该点什么";
- 不引入前端框架和构建流程;
- 破坏性操作(删除、导入)必须有确认,不能手滑。
## 2. 统一的三段式布局
`[必须]` 五个模块全部用这个结构,**不要给某个模块搞特殊**:
```text
┌────────────────────────────────────────────────────────────┐
│ [导航] 蝦皮数据 │ PDD 商品 │ 顺运宝数据 │ 采购任务 │ 客户端 │
├────────────────────────────────────────────────────────────┤
│ 顶部工具条:[操作按钮…] [搜索框] [搜索] [删除] │
├────────────────────────────────────────────────────────────┤
│ ☐ │ 列1 │ 列2 │ 列3 │ … │
│ ☐ │ │ │ │ │
│ 数据表格(占满剩余高度) │
│ │
├────────────────────────────────────────────────────────────┤
│ 底部状态条:最近一次操作的结果和关键统计 │
└────────────────────────────────────────────────────────────┘
```
`[必须]` 三段做成 `templates/partials/` 里的公共片段复用,
**不要每个页面复制一份**——改一次样式要改五处,必然改漏。
`[必须]` 页面在 1366×768 上可用。列多了让表格**横向滚动**,不要压缩列宽把字挤成两行。
## 3. 表格通用规则
- `[必须]` 第一列是勾选框,表头有全选。
- `[必须]` 行的身份用**业务主键**(商品規格ID、货运单ID、任务编号、序列号),
**不得用行号**——排序和筛选一变行号就错位。
- `[必须]` 批量删除要二次确认,弹窗里写清**删几条、能不能恢复**。
硬删写"将删除 N 条,不可恢复";软删除的(如 PDD 商品)要说明恢复方式和会丢什么,
见 §5.6。含糊其辞和吓唬人一样糟——两种都会让操作员不敢动手。
- `[必须]` 删除、导入用 **POST**,不得用 GET。浏览器和插件会预取 GET 链接。
- `[建议]` 默认按 `更新时间 DESC` 排序。
- `[建议]` 一页 50 条,超过分页。搜索走数据库,不要一次查出来在内存里过滤。
- `[必须]` 长文本(商品标题)截断显示,鼠标悬停给完整内容。
- `[必须]` 空状态要分情况,文案不能都是"暂无数据":
- 从没导入过 → "还没有数据,点左上角『导入』开始"
- 搜索无结果 → "没有匹配的记录" + 清除条件入口
## 4. 蝦皮数据页
### 4.1 工具条
```text
[导入 Excel] [批量采集] 商品ID [________] [搜索] [删除]
```
- **导入 Excel**:选文件 → POST 上传 → 显示结果统计
(导入 5195 商品 / 6092 SKU,失败 N 行)。
`[必须]` 失败行要列出行号和原因,不能静默跳过。
- **批量采集**:勾选若干行 → 服务端**按商品去重**后建采集任务。
`[必须]` 已是"采集中"的商品跳过,并在结果里说明跳过了几个。
### 4.2 表格列
| 列 | 显示规则 |
|---|---|
| ☐ | 勾选 |
| 商品 ID | |
| 商品名称 | 截断 + 悬停完整 |
| 颜色 / 尺码 / 建议 | 解析失败的显示为 `—` 并**整行标黄**,提示需人工补 |
| PDD 链接 | 空的显示"**未填写**"并标红,这是最需要操作员注意的状态 |
| 采集状态 | 未填链接 / 未采集 / 采集中 / 已采集 / 采集失败。**数据来自两张表**,判断方式见 [01 需求](01-requirements.md) §6.1 |
| 更新时间 | 本地时区 |
`[必须]` 状态不能只靠颜色区分,必须有文字。
### 4.3 编辑弹窗(双击行打开)
这是**从蝦皮商品出发**填 PDD 链接、发起采集的入口。
> `[注意]` PDD 商品页([§5](#5-pdd-商品页))也能录入链接和发起采集,
> 两处并存。本页的入口目前**还是骨架**(点了返回 501),
> 两者要不要合并、以哪个为准,属于「蝦皮↔PDD 关联入口」的范围,
> 由那张工单统一决定,不要在本页单独改。
```text
┌─────────────────────────────────────────┐
│ 编辑商品 40653075144 │
├─────────────────────────────────────────┤
│ 商品名称 【台灣現貨隔日達】西裝外套… │ ← 只读
│ 规格原文 黑色,M【建議40-50公斤】 │ ← 只读,永远显示
│ │
│ 颜色 [黑色___________] │
│ 尺码 [M_______________] │
│ 建议 [40-50公斤_______] │
│ │
│ PDD 链接 [___________________] [采集] │ ← ★
│ 采集状态 已采集(2026-08-06 15:20) │
├─────────────────────────────────────────┤
│ [取消] [保存] │
└─────────────────────────────────────────┘
```
`[必须]` 几条硬规则:
- **本页的采集按钮只在这个弹窗里出现**,不要放到表格每一行。一个商品有 N 个 SKU 行,
放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。
- **规格原文只读且永远显示**。操作员靠它判断颜色尺码该怎么填。
- PDD 链接为空时**采集按钮置灰**,旁边提示"请先填写 PDD 链接"。
- 采集状态是"采集中"时按钮置灰,提示"已在采集中"。
- 采集失败时显示错误原因,按钮变成"重新采集"。
### 4.4 手动新增
`[必须]` 必须提供"新增 SKU"入口。
原因:蝦皮报表只包含**有销售成绩的** SKU(样本里平均每个商品仅 1.17 个),
订单来了查无此 SKU 是常态。没有这个入口整条链路就断了。
新增的行 `is_manual = 1`,`[必须]` 后续导入**不得删除**它们。
## 5. PDD 商品页
页面 `objectName` 为 `pddProductPage`,路由 `/pdd`。
这个页面**不依赖蝦皮和顺运宝的任何数据**,可以单独跑通整条闭环:
建商品 → 建采集任务 → Client 领走执行 → 提交结果 → 刷新看到"已采集"和规格数。
### 5.1 工具条
```text
[添加 PDD 商品] [创建采集任务] 采集状态[全部▾] 商品ID/链接[___] [搜索] [删除]
```
- **添加 PDD 商品** → 弹窗,只填 PDD 链接。
`[必须]` 文案要写全,**不能简写成「创建」**:同一条工具条上还有
「创建采集任务」,两个都以「创建」开头,第一次用的人分不清
哪个是加商品、哪个是发起采集。
- **创建采集任务** → 勾选多行后可用。
- **采集状态筛选** → 全部 / 未采集 / 采集中 / 已采集 / 采集失败。
`[必须]` 这个筛选是**刚需**,不是锦上添花:这个页面的主要用途是维护
(找出失败的重采、找出还没采的),只按 ID 搜的话要翻页去找。
- **删除** → 勾选多行后可用。
`[必须]` 「创建采集任务」和「删除」共用**同一个表单**(靠按钮上的 `formaction`
决定提交到哪个地址),表格里的勾选框只挂一份。
为每个操作各复制一套勾选框的话,两套迟早会不同步。
`[必须]` 搜索框用 `class="search-narrow"` 收到 30%,让「搜索」按钮紧挨着它。
**不要去改 `.toolbar input[type="text"]` 那条通用规则**——五个页面共用它,
改了会把蝦皮 / 顺运宝 / 任务 / 客户端四页的搜索框一起改掉。
搜索表单上的 `grow` 类要保留,它负责把「删除」按钮顶到最右侧。
### 5.2 表格列
| 列 | 显示规则 |
|---|---|
| 勾选 | 值是 `goods_id`,支持批量删除、批量建采集任务 |
| 商品 ID | `goods_id` |
| 标题 | 采集回来的;未采集时显示"(未采集,采集后自动回填)" |
| PDD 链接 | 截断显示,可点开(`target="_blank"` 要带 `rel="noopener noreferrer"`) |
| 采集状态 | 中文文字,**不能只靠颜色**;失败时在下面补一行原因;`collecting` 超过 15 分钟没动静显示「**采集中(超时)**」,见下 |
| 规格数 | 从 `skus_json` 算 |
| 采集时间 | 本地时区;未采集显示 `—` |
| 更新时间 | 本地时区 |
`[必须]` **规格数这一列不能省。** 采到 1 个和采到 20 个差别很大——
只采到 1 个通常意味着客户端没点开规格面板,是采集有问题。
`[必须]` **未采集显示 `—`,采到 0 个要如实显示 `0`。** 这两种是不同的情况:
前者是还没采,后者是采集出了问题(商品下架、页面改版、解析器没认出来),
都显示成 `—` 就看不出区别了。
`[建议]` 规格数用 SQLite 的 `json_array_length(skus_json, '$.skus')` 直接算,
不要把整个 JSON 读进 Go 再数。`[必须]` 外面要包一层 `json_valid`——
`skus_json` 万一存进了坏数据,`json_array_length` 会让**整条查询报错**,页面直接打不开。
`[必须]` 空状态分两种文案:从没创建过 → 引导去点「创建」;筛选无结果 → 给"查看全部"的入口。
`[必须]` **`collecting` 超过 15 分钟没有更新,显示「采集中(超时)」,不是「采集中」。**
客户端离线、崩溃、任务被删都是常态,不是异常——一旦发生,操作员盯着「采集中」
不知道它已经死了,会一直傻等;显示超时他才知道可以重新点「创建采集任务」。
判定在**读取列表的这一刻现算**,不依赖任何后台任务,见
[03 数据模型](03-data-model.md) §4「采集超时」。
`[必须]` 状态照旧**不能只靠颜色区分**,必须有文字——超时也是文字的一部分,
不能只是把这一行的底色改深。
`[建议]` 筛选下拉里**不新增**「已超时」这个选项。它不是数据库里真实存在的
一种 `collect_status` 取值,只是「采集中」在读取那一刻的一种呈现;
加进筛选会让下拉框的取值和 `collect_status` 对不上。
### 5.3 创建弹窗
`[必须]` **只填 PDD 链接**,其余字段全靠采集回填。
`[必须]` **链接必须能解析出 `goods_id`,解析不出来直接报错并且不写库。**
不接受短链接(`p.pinduoduo.com/xxxx`)——`goods_id` 上有 UNIQUE 约束,
防重全靠它,拿不到就没法查重,同一个商品会存成好几行。
`[必须]` 报错要说清**下一步怎么办**,例如"请在拼多多 App 的商品页点
「分享」→「复制链接」,拿到带 `goods_id=` 的完整链接"。光说"链接无效"操作员不知道改什么。
### 5.4 编辑弹窗(双击行打开)
```text
┌─────────────────────────────────────────────┐
│ PDD 商品 737116531267 │
├─────────────────────────────────────────────┤
│ 商品 ID 737116531267 (只读) │
│ PDD 链接 [___________________](可编辑) │
│ 标题 西装外套三件套 (只读) │
│ 采集状态 已采集 │
│ 失败原因 — │
│ 采集时间 2026-08-07 15:20 │
├─────────────────────────────────────────────┤
│ 规格(3 个) │
│ 颜色分类 尺码 价格 有货 │
│ 黑色 M ¥12.56 是 │
│ 白色 M ¥12.56 否 │
│ 红色 L 未采到 是 │
├─────────────────────────────────────────────┤
│ [重新采集] [取消] [保存] │
└─────────────────────────────────────────────┘
```
`[必须]` **规格和价格必须显示。** 这是采集结果的全部价值所在——
不显示的话操作员没法确认"采得对不对、是不是我要的那个商品"。
`[必须]` 维度列的顺序按 `skus_json` 里的 `dimensions` 排,表头用 `name`。
Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新页面列的先后都可能变。
`[必须]` 价格显示成 `¥12.56`,底层存的是**整数分**。
`price_cent` 为 `null` 时显示"未采到",**不得显示成 ¥0.00**——
0 元和采不到价格是两回事,而这个数要参与价格保护比对(会花钱)。
`[必须]` 三种"没有规格"要分开显示,处理方式不一样:
| 情况 | 显示 |
|---|---|
| 还没采过 | "尚未采集",并说明怎么发起采集 |
| 采完了但一个规格都没有 | 提示可能已下架或没点开规格面板,建议重采 |
| `skus_json` 解析不了 | 明说数据坏了需要重新采集,**不要装作"没有规格"** |
`[必须]` 编辑链接时,新链接必须还是**同一个商品**,否则拒绝并提示改用「创建」。
这一行上挂着采集结果和 SKU 映射,`goods_id` 一换那些数据就全指到错的商品上了,
之后按它下单就是买错东西。
`[必须]` 弹窗内容由**服务端渲染**(`GET /pdd/detail?id=…` 返回 HTML 片段),
前端只负责取回来、放进壳子、显示隐藏。维度顺序、价格格式、`null` 的处理都是业务规则,
散到前端就没人维护得住了。
### 5.5 创建采集任务
`[必须]` **不指定客户端**(`assigned_client` 为 `NULL`,`status` 为 `pending`),
谁领到就在领取时标记谁。采集是纯读取操作,哪台机器跑都一样,
指定了反而会在那台机器关着的时候干等。
- 勾选多行 → 按 `goods_id` 去重 → 建任务;
- `collect_status` 是 `collecting` 且**没超时**(15 分钟内)的**跳过**;
已超时的当作可以重建,不再跳过——这是 #24 修的死锁,
见 [03 数据模型](03-data-model.md) §4「采集超时」;
- 已经是 `collected` 的**跳过**(本来就不需要重新采集);
- 建成功后把状态置为 `collecting`;
- 任务的 `pdd_goods_url` 和 `pdd_goods_id` 必须填(Client 契约要求 `goods_url` 必填)。
`[必须]` 按钮文案用「**创建采集任务**」,不要用「采集」。它做的是建一个任务,
不是立刻去采——真正的采集要等 Client 来领、去手机上跑,可能几秒也可能几分钟。
点完页面上只有状态从"未采集"变成"采集中",文案不说清楚操作员会以为没生效。
`[必须]` 结果在状态条明确提示,**跳过原因要分开说**,不能只给一句笼统的
"跳过 N 个"——那样操作员不知道该等还是该做点什么:
```text
已创建 2 个采集任务,等待客户端领取(跳过 1 个正在采集的、1 个已删除的)
```
`[必须]` **一个都没建成时不要只说「已创建 0 个」**,要让操作员知道下一步该干嘛,
比如还要等多久:
```text
没有创建任何任务:1 个正在采集中(还需等待约 12 分钟才可重试)
```
静默跳过的话,操作员会以为任务都建上了,等半天没动静也不知道为什么。
### 5.6 删除
软删除。`[必须]` 二次确认要写明能不能恢复、恢复后会丢什么:
> 将删除 3 条记录。删除后重新创建同一链接可恢复,**但采集结果会清空**。
(复活时清空旧采集结果是既定行为——记录被删过一次,旧数据不该再当有效的用。)
### 5.7 底部状态条
```text
共 24 条 · 已采集 18 · 未采集 4 · 采集中 1 · 采集失败 1
```
`[必须]` 统计的是**全部未删除记录**,不随筛选变化——状态条是全局概览。
有筛选时在前面加一句 `筛选出 N 条 /`,免得操作员把筛选后的行数当成全部行数,以为记录被删了。
刚做完写操作时,状态条**先显示操作结果**,再接统计。
## 6. 顺运宝数据页
### 6.1 工具条
```text
[同步] [创建采购任务] 订单号 [________] [搜索] [删除]
```
`[待定]` MVP 阶段**同步按钮只做占位**:点击提示"同步功能待接入",不发请求。
### 6.2 表格列
☐ / 货运单ID / 订单号 / 商品标题 / 蝦皮商品ID / 规格SKU / 数量 /
价格(台币)/ 图片 / **匹配状态** / 更新时间
- 图片显示小缩略图,点击看大图。`[必须]` 存 URL,不要把图片塞进数据库。
- 匹配状态是**算出来的**(`sku_mappings` 里有没有记录),不是存的字段。
- 完整货运单 JSON 不作为列显示,在详情里看。
### 6.3 规格匹配弹窗(双击行打开)
```text
┌──────────────────────────────────────────────────┐
│ 规格匹配 · 订单 SO-20260806-001 │
├────────────────────────┬─────────────────────────┤
│ 蝦皮 │ 拼多多 │
│ │ │
│ 颜色 黑色 │ 颜色 [黑色 ▾] │
│ 尺码 M │ 尺码 [M码 ▾] │
│ 建议 40-50公斤 │ │
│ 原文 黑色,M【建議40…】 │ 单价 ¥39.90 │
├────────────────────────┴─────────────────────────┤
│ ⓘ 已有匹配记录,已自动带出,确认无误后保存 │
│ [取消] [保存] │
└──────────────────────────────────────────────────┘
```
`[必须]` 几条硬规则:
- 右侧下拉的选项来自该商品 `pdd_data` 里的 `dimensions`,
**不要写死"颜色/尺码"两个维度**——PDD 商品可能有第三个维度。
- **打开时先查 `sku_mappings`**,有记录就自动带出并提示"已自动带出"。
这是省人工的关键:同一个蝦皮 SKU 只需人工匹配一次。
- 该商品**还没采集**(`pdd_data` 为空)时,不要显示一个空下拉让人困惑,
直接提示:"该商品尚未采集 PDD 数据,请先到『蝦皮数据』填写链接并采集",
并给一个跳转链接。
- 保存写的是 `sku_mappings`(可复用),**不是这一张订单的临时数据**。
### 6.4 创建采购任务
勾选若干行 → 点按钮 → 逐条校验(见 [01 需求](01-requirements.md) §5)。
`[必须]` 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么:
```text
成功创建 3 个任务。以下 2 条未创建:
· SO-...-007 该商品未填写 PDD 链接
· SO-...-009 规格未匹配
```
`[必须]` 创建前弹出确认框,让操作员选**分配给哪个客户端**,并确认价格上限。
价格上限默认从 `pdd_data` 带出,可改,**不允许为空**。
## 7. 采购任务页
工具条:`订单号 [____] [搜索] [删除]`
表格列:☐ / 订单号 / 商品标题 / 颜色 / 尺码 / 数量 / 价格上限 /
蝦皮ID / **分配客户端** / 状态 / 更新时间
- `[必须]` 颜色尺码显示的是 **PDD 侧**的规格(实际要买的),不是蝦皮的。
- `[必须]` 价格上限显示成 `¥42.00`(分转元),底层存的是整数分。
- `[建议]` 提供"改派"操作,把任务分给别的客户端——
没有心跳,客户端挂了要靠人工改派。
- 状态含义见 [01 需求](01-requirements.md) §6.2。
底部状态条显示各状态的条数统计。
## 8. 客户端列表页
工具条:`名称 [____] [搜索] [删除]`
表格列:☐ / 名称 / 序列号 / 状态 / 最近活动 / 更新时间
- `[必须]` **状态是算出来的**:`最近活动` 在 N 分钟内为"在线",否则"离线"。
`[建议]` N 默认 10 分钟。
- `[必须]` 名称可以人工改成好记的("办公室-01")。改过之后
**客户端上报的名称不再覆盖它**。
- `[建议]` 界面上说明一句:"客户端执行长任务期间可能显示为离线,属正常现象。"
因为没有心跳,这是已知且接受的取舍(见 [04](04-client-api.md) §3)。
## 9. 反馈方式
| 场景 | 怎么反馈 |
|---|---|
| 搜索、翻页 | 直接刷新表格,不弹任何东西 |
| 导入完成 | 底部状态条 + 结果统计(成功 N / 失败 M + 失败行号) |
| 保存成功 | 关闭弹窗,刷新行,状态条提示一句 |
| 校验不通过 | 保留用户输入,**焦点移到第一个错误字段**,就近显示错误 |
| 批量操作部分失败 | 列出失败项和原因,不要只说"部分失败" |
| 删除 | 二次确认框,写明"将删除 N 条,不可恢复" |
`[必须]` 报错要说清**哪一步失败、下一步做什么**,不要把 Go 的错误堆栈贴到页面上。
堆栈写日志。
## 10. 可访问性与细节
- `[必须]` 表单控件有可见 `<label>`,占位符不能当标签用。
- `[必须]` 状态不能只靠颜色,必须有文字。
- `[建议]` 搜索框支持回车提交。
- `[建议]` 主要操作支持键盘 Tab 到达。
- `[必须]` 金额显示带币种符号,台币和人民币要能一眼分清(`NT$` / `¥`)。