Files
cmautobuy/docs/admin/05-ui-specification.md
T

747 lines
42 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 上可用。列多了让表格**横向滚动**,不要压缩列宽把字挤成两行。
`[必须]` 公共弹窗可以通过关闭按钮、取消按钮、Esc 或直接点击灰色遮罩关闭;
但从输入框或弹窗内容开始拖选、在遮罩上松开时不得关闭。遮罩关闭逻辑必须同时确认
指针按下和最终点击都发生在遮罩本身,避免误丢用户尚未提交的输入。
## 3. 表格通用规则
- `[必须]` 第一列是勾选框,表头有全选。
- `[必须]` 行的身份用**业务主键**(商品規格ID、货运单ID、任务编号、序列号),
**不得用行号**——排序和筛选一变行号就错位。
- `[必须]` 批量删除要二次确认,弹窗里写清**删几条、能不能恢复**。
硬删写"将删除 N 条,不可恢复";软删除的(如 PDD 商品)要说明恢复方式和会丢什么,
见 §5.6。含糊其辞和吓唬人一样糟——两种都会让操作员不敢动手。
- `[必须]` 删除、导入用 **POST**,不得用 GET。浏览器和插件会预取 GET 链接。
- `[建议]` 默认按 `更新时间 DESC` 排序。
- `[必须]` 每页 **20** 条,超过分页,见 §3.2。搜索走数据库,不要一次查出来在内存里过滤。
- `[必须]` 长文本(商品标题)截断显示,鼠标悬停给完整内容。
- `[必须]` 空状态要分情况,文案不能都是"暂无数据":
- 从没导入过 → "还没有数据,点左上角『导入』开始"
- 搜索无结果 → "没有匹配的记录" + 清除条件入口
### 3.1 搜索框宽度
`[必须]` 搜索框加 `class="search-narrow"` 收到 30%,让「搜索」按钮紧挨着它。
目前 **PDD 商品页、顺运宝数据页、采集采购页**在用;
蝦皮数据页和客户端列表页仍是撑满的(暂未统一)。
`[必须]` **不要去改 `.toolbar input[type="text"]` 那条通用规则**——
五个页面共用它,改了会把没打算改的页面一起改掉。
`[必须]` 搜索表单上的 `grow` 类**要保留**,它负责把「删除」按钮顶到最右侧。
去掉的话删除按钮会跑到搜索按钮旁边。
`[必须]` **不要额外加对齐样式**(`margin-right: auto`、`justify-content` 之类)。
输入框不再伸展之后,多余空间自然留在按钮之后,按钮就贴着输入框了;
额外加会和 `grow` 叠加出难预料的结果。
`[建议]` placeholder 要短。收到 30% 之后长文案显示不全——
真装不下就缩短文案,**不要把宽度调回去**。
### 3.2 分页
`[必须]` 六个主列表(蝦皮、PDD、顺运宝、采集采购、客户端、用户)
**统一每页 20 条**(原来这里写的是"建议一页 50 条",
已按工单 #43 改为 20 并升级为 `[必须]`)——20 行在 1366×768 上正好一屏,
不用滚动就能看完;50 条要滚动,操作员容易漏看最下面几行。
蝦皮数据页(§4)是全项目第一个做分页的页面,本节是它定下的通用规则,
其余主列表复用公共实现,不要各写一套:
- `[必须]` 分页用 SQL 的 `LIMIT ? OFFSET ?`,**不得**把全量查出来在 Go 里
切片——这正是蝦皮数据页改之前一次吐 3.4MB HTML 的成因(实测 5195 行)。
- `[必须]` 总数用**单独的 `COUNT(*)`** 查询,并且和列表查询**共用同一套
筛选条件拼装函数**。分开写两份 `WHERE` 迟早会漏改一处,页码跟着算错,
而且不会报错、不容易发现(#19 已经踩过一次)。
- `[必须]` 页码参数是 `?page=N`,从 **1** 开始,不是 0——给操作员看的东西
不要 0-based。
- `[必须]` 越界要兜住:
- `page` 非数字、`0`、负数 → 当作第 1 页;
- `page` 超过总页数 → 显示**最后一页**(有数据的那页),不是空表格;
- 总数为 0 → 显示"第 1/1 页",不出现"第 1/0 页"。
- `[必须]` 底部状态条显示**全量总数**(当前筛选条件下的总数),不是本页
行数——"共 20 个商品"会让操作员以为总共就 20 个。筛选后显示筛选结果的
总数,例如"待补规格:6 个商品 · 第 1/1 页",不是本页凑出来的数字。
- `[必须]` 翻页时**保留当前筛选和关键词**——用查询参数原样带过去,
丢了的话操作员翻到第二页筛选就没了,会以为数据变了。
- `[必须]` 分页控件用 `<a href>`,**不要用 JS**。这是纯 GET 导航,
浏览器的前进后退和书签都该正常工作。
- `[必须]` 第一页时"首页 / 上一页"禁用,最后一页时"下一页 / 末页"禁用;
禁用要有**视觉 + 语义**区分(不可点的元素不要还是 `<a>`),不能只靠颜色。
- `[必须]` 固定底部状态条把状态提示和分页组件作为同一组整体靠右,状态提示
位于分页左侧;窄窗口可以换成上下两行,两行仍分别靠右,且不得遮住状态文字、
分页按钮或表格最后一行。
- `[必须]` 表格勾选只代表当前页。翻页后不保留勾选,也不提供跨页批量操作,
避免操作员看不到将被处理的记录。
- `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义,
操作员应该靠筛选定位,不是靠翻页数页码。
公共实现放在 `admin/service/pagination.go`(`PageSize` 常量、
`ParsePage` / `ClampPage` / `TotalPages` / `NewPaginationView`)和
`admin/templates/partials/footer.html`(分页控件,`Pagination` 字段
为空时不渲染)。六个主列表全部直接复用,不要各写一份。
## 4. 蝦皮数据页
### 4.1 工具条
```text
[导入 Excel] 状态[全部▾] 商品ID [________] [搜索] [删除]
```
- **导入 Excel**:选文件 → POST 上传 → 显示结果统计
(导入 5195 商品 / 6092 SKU,失败 N 行)。
`[必须]` 失败行要列出行号和原因,不能静默跳过。
- **状态筛选**(`[必须]`,工单 #43):全部 / 待补规格 / 未填 PDD 链接 / 已填链接。
这个筛选是**刚需**,不是锦上添花——本页主要用途是维护
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID
搜索的话,操作员得先知道 ID 才能搜,而"哪些商品需要处理"恰恰是
不知道 ID 的时候才要问的。实测样本 5195 个商品里只有 6 个待补规格,
只做分页(260 页)翻不出来。
- 「待补规格」:`EXISTS (SELECT 1 FROM shopee_skus WHERE goods_id = 该商品 AND parse_ok = 0)`。
`[必须]` 用 `EXISTS`,不用 `JOIN` + `DISTINCT`——一个商品有多个失败
SKU 时 `JOIN` 会出重复行,`DISTINCT` 又会让分页的 `LIMIT/OFFSET`
行为难推理。
- 「未填 PDD 链接」:`pdd_goods_url IS NULL OR pdd_goods_url = ''`。
- 「已填链接」:`pdd_goods_url IS NOT NULL AND pdd_goods_url <> ''`。
- `[必须]` 筛选参数认不出来的一律当"全部",不报错——地址栏是用户可以
随便改的。
- `[必须]` 筛选与关键词搜索可以叠加,翻页时都要保留,见 §3.2。
### 4.2 表格列
| 列 | 显示规则 |
|---|---|
| ☐ | 勾选 |
| 商品 ID | |
| 商品名称 | 截断 + 悬停完整 |
| 颜色 / 尺码 / 建议 | 解析失败的显示为 `—` 并**整行标黄**,提示需人工补 |
| PDD 链接 | 空的显示"**未填写**"并标红,这是最需要操作员注意的状态 |
| 采集状态 | 未填链接 / 未采集 / 采集中 / 已采集 / 采集失败。**数据来自两张表**,判断方式见 [01 需求](01-requirements.md) §6.1 |
| 更新时间 | 本地时区 |
`[必须]` 状态不能只靠颜色区分,必须有文字。
### 4.3 编辑弹窗(双击行打开)
这是**从蝦皮商品出发**填 PDD 链接、发起采集的入口。
> PDD 商品页([§5](#5-pdd-商品页))负责商品档案和诊断;本弹窗负责在
> 蝦皮商品上下文中维护“当前 PDD 商品”并发起单商品采集。顺运宝弹窗复用同一逻辑。
```text
┌─────────────────────────────────────────┐
│ 编辑商品 40653075144 │
├─────────────────────────────────────────┤
│ 商品名称 【台灣現貨隔日達】西裝外套… │ ← 只读
│ 规格原文 黑色,M【建議40-50公斤】 │ ← 只读,永远显示
│ │
│ 颜色 [黑色___________] │
│ 尺码 [M_______________] │
│ 建议 [40-50公斤_______] │
│ │
│ PDD 链接 [___________________________] │ ← ★
│ 采集状态 已采集(2026-08-06 15:20) │
├─────────────────────────────────────────┤
│ [创建采集任务] [关闭] [保存关联] │
└─────────────────────────────────────────┘
```
`[必须]` 几条硬规则:
- **创建采集任务只在这个弹窗里出现**,不要放到表格每一行或工具条,确保操作员看得到当前关联商品。
- **规格原文只读且永远显示**。操作员靠它判断颜色尺码该怎么填。
- 保存链接时展示当前解析出的 PDD 商品 ID;新链接指向其他商品时必须勾选明确确认。
- 无法解析 `goods_id` 的链接整体不写入;同一商品复用档案,软删除档案可复活。
- PDD 链接为空时**采集按钮置灰**,旁边提示"请先填写 PDD 链接"。
- 采集状态是"采集中"时按钮置灰,提示"已在采集中"。
- 采集失败时显示错误原因,按钮变成"重新采集"。
### 4.4 手动新增
`[必须]` 必须提供"新增 SKU"入口。
原因:蝦皮报表只包含**有销售成绩的** SKU(样本里平均每个商品仅 1.17 个),
订单来了查无此 SKU 是常态。没有这个入口整条链路就断了。
新增的行 `is_manual = 1`,`[必须]` 后续导入**不得删除**它们。
### 4.5 底部状态条
```text
共 5195 个商品 · 第 1/260 页 [首页] [上一页] [下一页] [末页]
```
筛选后(例如状态选了"待补规格"):
```text
待补规格:6 个商品 · 第 1/1 页 [首页] [上一页] [下一页] [末页]
```
`[必须]` 分页与状态条的通用规则见 §3.2,不在这里重复。这里只强调
蝦皮页特有的一点:导入完成后状态条**显示导入结果**("导入完成:N 个商品 /
M 个 SKU,K 行解析失败"),覆盖掉正常的统计文案,并且回到第 1 页、清空筛选——
刚导入的数据从第一页就能看到,沿用旧筛选反而可能让人以为导入没生效。
## 5. PDD 商品页
页面 `objectName` 为 `pddProductPage`,路由 `/pdd`。
这个页面**不依赖蝦皮和顺运宝的任何数据**,可以单独跑通整条闭环:
建商品 → 建采集任务 → Client 领走执行 → 提交结果 → 刷新看到"已采集"和规格数。
### 5.1 工具条
```text
[添加 PDD 商品] [创建采集任务] 采集状态[全部▾] 商品ID/链接[___] [搜索] [删除]
```
- **添加 PDD 商品** → 弹窗,只填 PDD 链接。
`[必须]` 文案要写全,**不能简写成「创建」**:同一条工具条上还有
「创建采集任务」,两个都以「创建」开头,第一次用的人分不清
哪个是加商品、哪个是发起采集。
- **创建采集任务** → 勾选多行后可用。
- **采集状态筛选** → 全部 / 未采集 / 采集中 / 已采集 / 采集失败。
`[必须]` 这个筛选是**刚需**,不是锦上添花:这个页面的主要用途是维护
(找出失败的重采、找出还没采的),只按 ID 搜的话要翻页去找。
- **删除** → 勾选多行后可用。
`[必须]` 「创建采集任务」和「删除」共用**同一个表单**(靠按钮上的 `formaction`
决定提交到哪个地址),表格里的勾选框只挂一份。
为每个操作各复制一套勾选框的话,两套迟早会不同步。
`[必须]` 搜索框用 `class="search-narrow"` 收到 30%,见 [§3.1](#31-搜索框宽度)。
### 5.2 表格列
| 列 | 显示规则 |
|---|---|
| 勾选 | 值是 `goods_id`,支持批量删除、批量建采集任务 |
| 商品 ID | `goods_id` |
| 标题 | 采集回来的;未采集时显示"(未采集,采集后自动回填)" |
| 店铺 | 采集回来的 `shop_name`;未采到显示 `—`,截断处可查看完整文字 |
| PDD 链接 | 截断显示,可点开(`target="_blank"` 要带 `rel="noopener noreferrer"`) |
| 采集状态 | 中文文字,**不能只靠颜色**;失败时在下面补一行原因;`collecting` 超过 15 分钟没动静显示「**采集中(超时)**」,见下 |
| 规格数 | 从 `skus_json` 算 |
| 采集时间 | 本地时区;未采集显示 `—` |
| 更新时间 | 本地时区 |
`[必须]` **规格数这一列不能省。** 采到 1 个和采到 20 个差别很大——
只采到 1 个通常意味着客户端没点开规格面板,是采集有问题。
`[必须]` **未采集显示 `—`,采到 0 个要如实显示 `0`。** 这两种是不同的情况:
前者是还没采,后者是采集出了问题(商品下架、页面改版、解析器没认出来),
都显示成 `—` 就看不出区别了。
`[建议]` 规格数用 MySQL 的 `JSON_LENGTH(skus_json, '$.skus')` 直接算,
不要把整个 JSON 读进 Go 再数。`[必须]` 外面要包一层 `JSON_VALID`——
`skus_json` 万一存进了坏数据,`JSON_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 链接 [___________________](可编辑) │
│ 标题 西装外套三件套 (只读) │
│ 店铺 XX旗舰店 (只读) │
│ 采集状态 已采集 │
│ 失败原因 — │
│ 采集时间 2026-08-07 15:20 │
├─────────────────────────────────────────────┤
│ 规格(3 个) │
│ 颜色分类 尺码 价格 价格来源 有货 │
│ 黑色 M ¥12.56 ✓ 实测 是 │
│ 黑色 L ¥12.56 推断 是 │
├─────────────────────────────────────────────┤
│ [重新采集] [取消] [保存] │
└─────────────────────────────────────────────┘
```
`[必须]` **规格和价格必须显示。** 这是采集结果的全部价值所在——
不显示的话操作员没法确认"采得对不对、是不是我要的那个商品"。
`[必须]` 维度列的顺序按 `skus_json` 里的 `dimensions` 排,表头用 `name`。
Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新页面列的先后都可能变。
`[必须]` 价格显示成 `¥12.56`,底层存的是**整数分**。
`price_cent` 为 `null` 时显示"未采到",**不得显示成 ¥0.00**——
0 元和采不到价格是两回事,而这个数要参与价格保护比对(会花钱)。
`[必须]` `price_granularity == "color"` 时,规格表上方显示:
“价格按颜色采样,同一颜色下各尺码显示同一价格。标 ✓ 的是实测价,其余为推断值。”
规格表增加“价格来源”列;`options` 与 `price_observed_at` 完全相同的行显示
“✓ 实测”,其余显示“推断”。状态不能只靠颜色表达。
`[必须]` 三种"没有规格"要分开显示,处理方式不一样:
| 情况 | 显示 |
|---|---|
| 还没采过 | "尚未采集",并说明怎么发起采集 |
| 采完了但一个规格都没有 | 提示可能已下架或没点开规格面板,建议重采 |
| `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
开始日期 [2026-08-07] 结束日期 [2026-08-09] [同步] [同步记录]
[创建采购任务] 订单号 [___] [搜索] [删除]
```
日期始终显示在主工具条,按 UTC+8 解释且两端都包含。通常默认最近 3 天;如果
覆盖游标更早,则从游标当天开始补齐。缺口超过 31 天时只预选最早 31 天,并明确
提示本段成功后继续下一段。
`[必须]` 「同步」是唯一同步入口:
```text
点「同步」
├─ 本地缓存的会话未过期 ──→ 后台开始同步,立即跳回列表页,
│ 状态条显示"同步已开始,请稍后刷新页面查看结果"
└─ 没有缓存会话/已过期 ───→ 弹出登录弹窗(不是报错):
账号(config.yaml 带出,只读,不显示密码)
验证码图片 [点图/换一张 可刷新]
验证码文字输入框
[登录并同步]
```
同步是长任务,不阻塞 HTTP 请求线程——点完立即跳转,结果异步写进内存态的
“最近一次同步报告”和持久化同步记录,下次刷新页面时状态条会显示:
```text
同步完成:日期范围 2026-08-09 ~ 2026-08-09,货运单 12 张,商品明细 27 条
(新增 20,更新 7,跳过 0)
```
有跳过/失败会在后面列出具体原因,不是只给个数字。同步中再次点「同步」会提示
“同步正在进行,请耐心等待,请勿重复点击”,不会并发跑两个。表单有效提交后,
按钮立即变成禁用的“同步中…”;页面检测到当前进程仍在同步时也保持这个状态。
同步成功启动使用状态条反馈,不弹出需要操作员手工关闭的成功确认框。
- 两端都必填,结束日期不得晚于 UTC+8 下的今天,闭区间最多 31 天。
- 日期范围经过自动 OCR 或手工验证码登录后必须原样保留。
- 日期校验失败时保留原输入,并在工具条下方显示可读错误。
- 是否推进覆盖游标的规则见
[08 顺运宝接口](08-顺运宝接口.md) §4.4。
`[必须]` 「同步记录」打开宽弹窗,按开始时间从新到旧分页显示状态、日期范围、
操作账号、开始/完成时间、同步统计、覆盖游标结果和失败原因。状态至少区分同步中、
成功、失败、已中断;不能只靠颜色表达。刷新或重启 Admin 后记录仍可查看。
弹窗底部右侧依次放“刷新同步记录”、当前页、翻页组件和“关闭”。点击刷新后按钮
显示“刷新中…”并暂时禁用;刷新和弹窗内翻页只请求受网页登录保护的同步记录
HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表的滚动、勾选和筛选
状态不改变,弹窗也不关闭;失败时保留原记录并在弹窗内给出重试提示。无 JavaScript
时翻页链接降级为重载 `/syb` 并自动重开弹窗。本阶段不自动轮询,避免阅读历史
记录时内容突然移动;F5 仍按浏览器默认行为刷新整个 SYB 页面。
`[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。
### 6.2 表格列
☐ / 货运单明细ID / 订单号 / 商品标题 / 规格 / 蝦皮商品ID / 规格SKU / 数量 /
价格(台币)/ 图片 / **处理阶段** / **下一步** / 更新时间
- 一行对应顺运宝一张货运单的**一个商品明细**,不是一张货运单——一张货运单
可以有多个商品,各占一行。
- 图片显示小缩略图。`[必须]` 存 URL,不要把图片塞进数据库。
- 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分:
未找到蝦皮商品、待确认蝦皮规格、未关联 PDD、PDD 待采集、采集中、采集失败、
规格待匹配、可采购、采购数据异常和已创建任务。`shopee_sku_id` 非空只说明蝦皮规格已确认。
- 每行显示一个有文字的“下一步”按钮;双击行和按钮打开同一个服务端渲染处理弹窗。
- 规格原文完全一致,或解析后的颜色尺码只有一个唯一候选时,系统可以自动确认
蝦皮 SKU;零候选或多候选必须由采购员选择,不能模糊猜测。
- 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。
### 6.3 采购处理与规格匹配弹窗(双击行或点击下一步打开)
弹窗按当前处理阶段渐进展示。第一步先确认蝦皮 SKU;顺运宝同步本身仍不提供
`shopee_sku_id`,但同步写入明细后可以根据本地蝦皮数据做确定性唯一匹配。
后续 PDD 关联、采集和规格映射使用同一个弹窗继续完成。
确认蝦皮 SKU 后,弹窗先展示当前 PDD 商品 ID、完整链接和采集状态。未关联时
可直接填写完整链接;换成其他商品需要明确确认。待采集或采集失败时显示
“创建采集任务”或“重新创建采集任务”,采集中和已采集状态只显示说明。
```text
┌──────────────────────────────────────────────────┐
│ 规格匹配 · 订单 SO-20260806-001 │
├────────────────────────┬─────────────────────────┤
│ 蝦皮 │ 拼多多 │
│ │ │
│ 颜色 黑色 │ 颜色 [黑色 ▾] │
│ 尺码 M │ 尺码 [M码 ▾] │
│ 建议 40-50公斤 │ │
│ 原文 黑色,M【建議40…】 │ 单价 ¥39.90 │
├────────────────────────┴─────────────────────────┤
│ ⓘ 已有匹配记录,已自动带出,确认无误后保存 │
│ [取消] [保存] │
└──────────────────────────────────────────────────┘
```
`[必须]` 几条硬规则:
- 右侧下拉的选项来自该商品 `skus_json` 里的 `dimensions`,
**不要写死"颜色/尺码"两个维度**——PDD 商品可能有第三个维度。
- **打开时先查 `sku_mappings`**,有记录就自动带出并提示"已自动带出"。
这是省人工的关键:同一个蝦皮 SKU 只需人工匹配一次。
- 查询必须同时带当前 `pdd_goods_id`,并核对选项仍存在且可购买;换品或选项消失时回到待匹配。
- 该商品**还没采集**(`skus_json` 为空)时,不要显示空下拉,直接展示关联和创建采集任务入口。
- 保存写的是 `sku_mappings`(可复用),**不是这一张订单的临时数据**。
### 6.4 创建采购任务
只有“可创建采购任务”的行可勾选。勾选后打开服务端渲染的确认弹窗:选择当前账号
可见的客户端,并逐行确认人民币价格上限。默认值来自映射规格的 PDD 采集价;没有价格时留空强制填写。
`[必须]` 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么:
- 同一批中合法行可以创建,失败行逐条显示明细 ID 和原因;数据库错误则整批回滚。
- 同一明细已有 `pending`、`assigned` 或 `claimed` 的采购任务时拒绝重复创建。
- 顺运宝列表继续显示台币售价,但绝不把它带入人民币价格上限。
```text
成功创建 3 个任务。以下 2 条未创建:
· SO-...-007 该商品未填写 PDD 链接
· SO-...-009 规格未匹配
```
`[必须]` 创建前弹出确认框,让操作员选**分配给哪个客户端**,并确认价格上限。
价格上限默认从当前映射的 PDD 规格采集价带出,可改,**不允许为空**。
## 7. 采集采购页
`tasks` 一张表用 `task_type` 区分采集和采购,本页把两种任务放在同一个
列表里显示,靠「目标」一列概括各自不同的业务信息,见
[01 需求](01-requirements.md) §4.4。
`[必须]` 导航文字和页面标题都是「**采集采购**」,不是「任务」或「采购任务」。
### 7.1 工具条
```text
[类型▾ 全部] [状态▾ 全部] 关键词[___] [搜索] [删除]
```
- **类型**:全部 / 采集 / 采购。
- **状态**:全部 + 7 个状态,见 [01 需求](01-requirements.md) §6.2。
- **关键词**:同时匹配任务编号、订单号、PDD 商品 ID。
`[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。
placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD 商品 ID」**——
收到 30% 之后装不下,会被截断成看不出意思的半截文字。
### 7.2 表格列
☐ / 任务编号 / 类型 / **目标** / 状态 / 客户端 / 更新时间
```text
☐ │ 任务编号 │ 类型 │ 目标 │ 状态 │ 客户端 │ 更新时间
☐ │ PDD-20260807-01 │ 采集 │ PDD 737116531267 │ 已领取 │ 办公室-01 │ 15:20
☐ │ PDD-20260807-02 │ 采购 │ SO-001 · 黑色/M · 2件 · ≤¥42.00 │ 待领取 │ — │ 15:22
```
- `[必须]` **目标列**:采集显示 `PDD <pdd_goods_id>`(能 join 到未删除商品
的标题时追加显示);采购显示 `<order_no> · <颜色/尺码> · <数量>件 · ≤<价格上限>`。
拼接逻辑在 service 层组装成一个字符串,模板只负责显示。
- `[必须]` 价格上限显示成 `¥42.00`(分转元),底层存的是整数分。
- `[必须]` **客户端列**:无主任务显示 `—`,不是空白或 `<nil>`——#17 之后
采集任务默认无主,这一列会大量为空。
- `[必须]` 类型和状态都是文字,不能只靠颜色区分。
### 7.3 详情弹窗(双击行打开)
`[必须]` **只读**。没有改派 / 重试 / 取消入口,那些是后续工单的范围。
弹窗内容:
```text
┌──────────────────────────────────────────────┐
│ 任务 PDD-20260807-01 │
├──────────────────────────────────────────────┤
│ 类型 采集 │
│ 状态 已领取 │
│ 分配客户端 办公室-01 │
│ 领取时间 2026-08-07 15:20:31 │
│ 完成时间 — │
├──────────────────────────────────────────────┤
│ 执行参数 │
│ PDD 链接 https://mobile.yangkeduo.com/... │
│ 目标规格 黑色 / M (采购任务才有) │
│ 数量 2 (采购任务才有) │
│ 价格上限 ¥42.00 (采购任务才有) │
├──────────────────────────────────────────────┤
│ 错误 │
│ 错误码 PDD_PAGE_TIMEOUT │
│ 错误说明 商品页加载超时 │
├──────────────────────────────────────────────┤
│ [关闭] │
└──────────────────────────────────────────────┘
```
- `[必须]` 采购专有字段(数量、价格上限、目标规格)在采集任务的弹窗里
**整段隐藏**,不显示空行。
- 错误段没有错误码/说明时整段不显示。
- `[建议]` `result_data`(Client 提交的完整结果)默认折叠,展开查看;
过长时截断显示。
### 7.4 删除
`[必须]` 批量删除要二次确认,写明"将删除 N 条,不可恢复"——`tasks` 表
没有软删除列,删了就是真删了。
### 7.5 底部状态条
```text
共 42 条 · 待分配 3 · 待领取 5 · 已领取 2 · 成功 30 · 需人工 1 · 失败 1 · 已取消 0
```
`[必须]` 统计要**跟随当前筛选**:筛了「采集」就只统计采集任务,
筛了某个状态统计也随之变化,否则数字和表格对不上,操作员会以为
页面出错。这一点跟 PDD 商品页不同——那边的统计是全局概览,
不受筛选影响;本页反过来是有意的,见 [01 需求](01-requirements.md) §4.4。
## 8. 客户端列表页
管理员工具条:`名称 [____] [搜索] [删除]`;采购员不显示删除按钮。
管理员表格列:☐ / 名称 / 序列号 / 状态 / 当前负责人 / 最近活动 / 更新时间 / 操作
采购员表格列:名称 / 序列号 / 状态 / 当前负责人 / 最近活动 / 更新时间
- `[必须]` **状态是算出来的**:`最近活动` 在 N 分钟内为"在线",否则"离线"。
`[建议]` N 默认 10 分钟。
- `[必须]` 名称可以人工改成好记的("办公室-01")。改过之后
**客户端上报的名称不再覆盖它**。
- `[建议]` 界面上说明一句:"客户端执行长任务期间可能显示为离线,属正常现象。"
因为没有心跳,这是已知且接受的取舍(见 [04](04-client-api.md) §3)。
- `[必须]` 管理员看到全部客户端;采购员只看到当前绑定给自己的客户端,页面只读。
- `[必须]` 未绑定显示文字“未绑定”,不能只靠颜色或留空表达。
- 管理员操作列:未绑定显示“绑定”;已绑定显示“转交”和“解绑”。
- 绑定/转交共用弹窗,负责人下拉框只列启用中的采购员。转交时明确显示当前负责人。
- 解绑前二次确认,说明采购员会立即看不到客户端,但归属历史和既有任务都会保留。
- 弹窗说明:“只改变负责人和网页可见范围,不影响 Client 接口或既有任务。”
- 采购员没有客户端时显示“请联系管理员绑定”,不显示容易误解为系统无数据的通用空状态。
### 8.1 首次管理员初始化与登录
数据库没有任何用户时,访问 Admin 业务页面跳转到 `/setup`。初始化页只包含:
```text
初始化管理员
用户名 [admin____________]
密码 [••••••••••••••••]
确认密码 [••••••••••••••]
[创建管理员]
```
- 用户名可以预填 `admin`,但用户可以修改;不得提供固定默认密码。
- 密码和确认密码最少 6 个字符;字段下方显示同样的辅助文字。
- 密码框不回显,校验失败时清空密码和确认密码。
- 初始化成功后跳转登录页;此后访问 `/setup` 不再显示初始化表单。
- 两个浏览器并发初始化时,最多一个成功,另一个提示“管理员已经初始化,请登录”。
已有用户时,未登录访问业务页面跳转到 `/login`:
```text
登录 Admin
用户名 [_________________]
密码 [••••••••••••••••]
[登录]
```
- 用户名或密码错误统一提示“用户名或密码错误”,不透露账号是否存在。
- 登录成功回到原目标页面;目标地址只允许站内路径,不能跳转到外部网站。
- 顶部导航显示当前用户名和“退出登录”;退出必须使用 POST。
- Client API 不显示登录页,也不返回网页登录重定向。
### 8.2 用户管理页
只有管理员显示“用户管理”导航并可访问 `/users`。采购员访问时返回 `403`。
工具条:`用户名 [____] 状态 [全部⌄] [搜索] [新增采购员]`
表格列:用户名 / 角色 / 状态 / 最后登录 / 创建时间 / 操作
- 管理员只能新增 `purchaser` 角色,不通过普通页面新增第二个管理员。
- 操作包含“重置密码”“禁用/启用”;第一版不做物理删除。
- 新增采购员和重置密码的密码、确认密码字段最少 6 个字符,浏览器原生约束、
辅助文字与服务端错误必须一致。
- 禁用账号或重置密码前二次确认;成功后该用户现有 Session 全部失效。
- 不能禁用最后一个有效管理员,也不能让唯一管理员禁用自己。
- 完整密码不在列表、弹窗关闭后的页面或日志中出现。
### 8.3 管理员修改自己的密码
已登录管理员的顶部账号区域显示“修改密码”,采购员不显示该入口。点击后打开公共弹窗:
```text
修改我的密码
当前密码 [••••••••••••••••]
新密码 [••••••••••••••••]
确认新密码 [••••••••••••••••]
[取消] [修改密码]
```
- 必须校验当前密码;目标用户只取当前 Session,不接受表单传入用户编号。
- 新密码至少 6 个字符、最多 72 个字节,两次输入必须一致。
- 当前密码使用 `autocomplete="current-password"`,新密码和确认密码使用
`autocomplete="new-password"`。
- 校验失败时不回显三个密码,自动重新打开弹窗,在对应字段旁显示错误并聚焦该字段。
- 修改成功后撤销该管理员在所有浏览器中的 Session、清除当前 Cookie,并跳转登录页要求使用新密码登录。
- 采购员仍由管理员在用户管理页重置密码;本阶段不提供采购员自助改密和忘记密码入口。
- 弹窗继续遵守公共关闭规则:取消、关闭按钮、Esc 和直接点击遮罩可关闭,从密码框拖选到遮罩不会误关闭。
## 9. 反馈方式
| 场景 | 怎么反馈 |
|---|---|
| 搜索、翻页 | 直接刷新表格,不弹任何东西 |
| 导入完成 | 底部状态条 + 结果统计(成功 N / 失败 M + 失败行号) |
| 保存成功 | 关闭弹窗,刷新行,状态条提示一句 |
| 校验不通过 | 保留用户输入,**焦点移到第一个错误字段**,就近显示错误 |
| 批量操作部分失败 | 列出失败项和原因,不要只说"部分失败" |
| 删除 | 二次确认框,写明"将删除 N 条,不可恢复" |
| 客户端绑定/转交 | 弹窗确认目标采购员;成功后刷新负责人并显示结果 |
| 客户端解绑 | 二次确认,写明可见范围立即变化、历史和既有任务保留 |
`[必须]` 报错要说清**哪一步失败、下一步做什么**,不要把 Go 的错误堆栈贴到页面上。
堆栈写日志。
## 10. 可访问性与细节
- `[必须]` 表单控件有可见 `<label>`,占位符不能当标签用。
- `[必须]` 状态不能只靠颜色,必须有文字。
- `[建议]` 搜索框支持回车提交。
- `[建议]` 主要操作支持键盘 Tab 到达。
- `[必须]` 金额显示带币种符号,台币和人民币要能一眼分清(`NT$` / `¥`)。