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

934 lines
60 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 或直接点击灰色遮罩关闭;
但从输入框或弹窗内容开始拖选、在遮罩上松开时不得关闭。遮罩关闭逻辑必须同时确认
指针按下和最终点击都发生在遮罩本身,避免误丢用户尚未提交的输入。
### 2.1 跨模块导航状态
顶部导航切换六个主模块时,应在当前浏览器标签页内记住每个模块最后一次稳定的
搜索、筛选和页码。返回模块时使用带真实查询参数的 URL,因此 F5、复制链接以及
浏览器前进、后退仍按浏览器原生行为工作。
- 状态按登录账号隔离,退出登录时清理当前账号在本标签页的记录。
- 只保存模块主列表的白名单查询参数;清除筛选后,模块根地址会覆盖旧记录。
- 不保存复选框、弹窗、临时成功或错误提示、未提交表单和敏感信息。
- 不跨标签页、浏览器或设备同步;浏览器存储不可用时,导航退回模块根地址。
- 导航链接本身必须保留可用的模块根地址,不能依赖 JavaScript 才能进入页面。
## 3. 表格通用规则
- `[必须]` 第一列是勾选框,表头有全选。
- `[必须]` 行的身份用**业务主键**(商品規格ID、货运单ID、任务编号、序列号),
**不得用行号**——排序和筛选一变行号就错位。
- `[必须]` 批量删除要二次确认,弹窗里写清**删几条、能不能恢复**。
硬删写"将删除 N 条,不可恢复";软删除的(如 PDD 商品)要说明恢复方式和会丢什么,
见 §5.6。含糊其辞和吓唬人一样糟——两种都会让操作员不敢动手。
- `[必须]` 删除、导入用 **POST**,不得用 GET。浏览器和插件会预取 GET 链接。
- `[建议]` 默认按 `更新时间 DESC` 排序。
- `[必须]` 每页 **20** 条,超过分页,见 §3.2。搜索走数据库,不要一次查出来在内存里过滤。
- `[必须]` 长文本(商品标题)截断显示,鼠标悬停给完整内容。
- `[必须]` 空状态要分情况,文案不能都是"暂无数据":
- 从没导入过 → "还没有数据,点左上角『导入』开始"
- 搜索无结果 → "没有匹配的记录" + 清除条件入口
### 3.1 搜索框宽度
`[必须]` 搜索框加 `class="search-narrow"` 收到 30%,让「搜索」按钮紧挨着它。
目前 **PDD 商品页、采集采购页**在用;
蝦皮数据页和客户端列表页仍是撑满的(暂未统一)。
顺运宝页一行同时有店铺和订单号两个输入框,单独使用 `syb-search-input`,桌面端
固定约 140px;不能为了这个例外修改通用输入框规则。
`[必须]` **不要去改 `.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
[导入记录] [回收站] [创建 PDD 采集任务] 状态[全部▾] 分类[商品 ID▾] 关键词[____] [搜索] [删除]
```
- **导入记录**:管理员进入统一商品目录导入记录页;采购员无系统级审计入口。
蝦皮数据由第三方脚本通过商品目录接口提交,本页不再上传或解析 Excel。
- **状态筛选**(`[必须]`,工单 #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。
- **分类搜索**(工单 #213):分类固定为商品 ID、商品名称、店铺名。商品 ID 使用精确
匹配,商品名称和店铺名使用转义后的包含匹配;后端必须用白名单分支,不能把请求参数
拼成 SQL 列名。未知分类回退到商品 ID。状态、分类和关键词在翻页、详情弹窗和写操作
返回时都要保留。
- **回收站**(工单 #213):不再显示“数据范围”筛选。管理员通过独立回收站按钮查看
软删除商品,并可返回正常商品;已删除列表不允许创建采集任务或打开处理详情。
- **未登记商品**(工单 #212):不在常用筛选中增加额外下拉框。管理员从店铺管理页的
数量链接进入内部 `unlinked=1` 视图,页面显示当前范围提示和清除入口。
- **创建 PDD 采集任务**(工单 #185):勾选蝦皮商品后打开批量确认弹窗,显示选择
数量及带可见标签的“执行客户端”,默认不指定。服务端按当前 PDD 关联和 PDD 商品 ID
去重;未关联、已删除、已采集、正常采集中分别统计并跳过。商品详情的单商品入口保留。
- **删除 / 恢复**(工单 #205):只允许管理员操作。删除前确认文案明确显示选中数量、
可恢复且规格和 PDD 关联会保留;有进行中采集或采购任务时整批拒绝。已删除列表显示
“恢复”按钮,恢复不重建数据、不改变原关联。批量采集、删除和恢复共用同一个表单,
通过提交按钮的 `formaction` 选择接口,避免勾选值落到错误表单。
### 4.2 表格列
| 列 | 显示规则 |
|---|---|
| ☐ | 勾选 |
| 商品 ID | |
| 图片 | 32×32 缩略图,可打开原图;加载失败显示文字占位 |
| 商品名称 | 固定约 120px,截断 + 悬停完整 |
| 店铺 | 导入或同步得到的蝦皮店铺名称;为空显示 `—` |
| 数据来源 | 蝦皮报表 / 顺运宝补建;不只靠颜色区分 |
| 颜色 / 尺码 / SKU / 待补 | 商品级聚合计数;待补大于 0 时**整行标黄** |
| PDD 商品 ID | 正文显示 goods_id,悬停显示完整 URL;空的显示"**未填写**"并标红 |
| 采集状态 | 未填链接 / 未采集 / 采集中 / 已采集 / 采集失败。**数据来自两张表**,判断方式见 [01 需求](01-requirements.md) §6.1 |
| 更新时间 | 本地时区 |
`[必须]` 状态不能只靠颜色区分,必须有文字。
### 4.3 编辑弹窗(双击行打开)
这是**从蝦皮商品出发**填 PDD 链接、发起采集的入口。
> PDD 商品页([§5](#5-pdd-商品页))负责商品档案和诊断;本弹窗负责在
> 蝦皮商品上下文中维护“当前 PDD 商品”并发起单商品采集。顺运宝弹窗复用同一逻辑。
```text
┌─────────────────────────────────────────┐
│ 编辑商品 40653075144 │
├─────────────────────────────────────────┤
│ 商品名称 【台灣現貨隔日達】西裝外套… │ ← 只读
│ 规格表:原文 / 颜色 / 尺码 / 建议 / 状态 │ ← 摘要只读
│ [编辑] → 颜色、尺码、建议的行内折叠表单 │
│ │
│ PDD 链接 [___________________________] │ ← ★
│ 采集状态 已采集(2026-08-06 15:20) │
├─────────────────────────────────────────┤
│ [创建采集任务] [关闭] [保存关联] │
└─────────────────────────────────────────┘
```
`[必须]` 几条硬规则:
- **创建采集任务只在这个弹窗里出现**,不要放到表格每一行或工具条,确保操作员看得到当前关联商品。
- **规格原文只读且永远显示**。操作员靠它判断颜色尺码该怎么填。
- 只允许编辑已有 SKU 的颜色、尺码和建议说明;颜色、尺码必填,建议可清空。真实
Shopee SKU ID、内部记录 ID、规格键和原文不可编辑,也不在页面冒充彼此。
- 保存后颜色、尺码和建议写入字段级 `manual` 来源并标记为已解析;目录接口的补空和
同来源覆盖均不得覆盖这些字段。主动清空建议同样是明确的人工值。
- 本功能不新增或删除 SKU,不修改顺运宝原始规格、既有规格映射或采购任务。
- 保存链接时展示当前解析出的 PDD 商品 ID;新链接指向其他商品时必须勾选明确确认。
- 无法解析 `goods_id` 的链接整体不写入;同一商品复用档案,软删除档案可复活。
- PDD 链接为空时**采集按钮置灰**,旁边提示"请先填写 PDD 链接"。
- 采集状态是"采集中"时按钮置灰,提示"已在采集中"。
- 采集失败时显示错误原因,按钮变成"重新采集"。
### 4.4 手动新增
> 本节是旧 SKU 确认流程的历史要求,已被工单 #88 的顺运宝 `spec_key`
> 主链路取代;保留仅为说明蝦皮报表数据可继续独立维护,顺运宝处理不再以此为前置条件。
`[必须]` 必须提供"新增 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 链接。
`[必须]` 文案要写全,**不能简写成「创建」**:同一条工具条上还有
「创建采集任务」,两个都以「创建」开头,第一次用的人分不清
哪个是加商品、哪个是发起采集。
- **批量导入** → 弹窗选择一列式 `.xlsx`;上传只建档,不自动发采集任务。
- **创建采集任务** → 勾选多行后可用。
- **采集状态筛选** → 全部 / 未采集 / 采集中 / 已采集 / 采集失败。
`[必须]` 这个筛选是**刚需**,不是锦上添花:这个页面的主要用途是维护
(找出失败的重采、找出还没采的),只按 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 批量导入弹窗与结果
弹窗有可见“Excel 文件”标签和持续格式说明:只接受不超过 10MB 的 `.xlsx`;
读取第一个非空工作表,第一列标题必须为“拼多多链接”,每行一个带 `goods_id=` 的
完整链接,最多 5000 条。空工作表、空行忽略,短链接继续拒绝。
上传期间提交按钮显示“导入中…”并禁用,防止重复提交。完成后在工具条下方显示:
```text
本次导入结果
总行数 N · 新建 N · 已存在 N · 恢复 N · 文件内重复 N · 失败 N
[为本次导入商品创建采集任务]
```
- 新建、已存在、软删除恢复和文件内重复必须分开统计;
- 失败明细完整显示 Excel 行号、原始内容和可操作原因,长链接允许换行;
- 结果不能只靠颜色表达,并使用可访问的文字标题和区域标签;
- 本次导入的合法商品超过当前分页时,仍能由结果按钮一次提交完整集合;
- 点击结果按钮才打开现有客户端选择确认流程,默认不指定客户端;
- 上传完成本身不创建任务,已采集/采集中等跳过规则在最终确认后执行。
### 5.5 编辑弹窗(双击行打开)
```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-08] 结束日期 [2026-08-09] [同步] [同步记录]
[创建采集任务] [创建采购任务] 处理阶段 [全部] 店铺 [___] 订单号 [___] [清除] [搜索] [删除]
```
日期始终显示在主工具条,按 UTC+8 解释且两端都包含。首次打开页面固定默认昨天
到今天,不因覆盖游标位置自动扩大范围;需要补历史缺口时由采购员明确选择日期。
同步日期规则和当前启用店铺数量不再使用常驻说明占用列表空间;日期错误、同步警告、
配置问题和登录提示仍按条件显示。管理员通过主导航进入“店铺管理”,顺运宝工具条
不再重复放入口。
管理页一行一个店铺,只展示和编辑一个店铺名称,并使用一个启停状态。该名称同时用于
SYB 同步准入和蝦皮商品关联。启用店铺不显示删除入口,服务端还要拒绝删除有关联货运单
或蝦皮商品的停用店铺;“未登记商品 N 个”链接进入蝦皮未关联数据视图。
空状态必须明确提示先新增并启用至少一个店铺,否则同步会安全停止。
`[必须]` 「同步」是唯一同步入口:
```text
点「同步」
├─ 本地缓存的会话未过期 ──→ 后台开始同步,立即跳回列表页,
│ 状态条显示"同步已开始,请稍后刷新页面查看结果"
└─ 没有缓存会话/已过期 ───→ 弹出登录弹窗(不是报错):
账号(config.yaml 带出,只读,不显示密码)
验证码图片 [点图/换一张 可刷新]
验证码文字输入框
[登录并同步]
```
同步是长任务,不阻塞 HTTP 请求线程——点完立即跳转,结果异步写进内存态的
“最近一次同步报告”和持久化同步记录,下次刷新页面时状态条会显示:
```text
同步完成:日期范围 2026-08-09 ~ 2026-08-09,原始货运单 12 张,接受 9 张,
店铺跳过 3 张;商品明细 27 条(新增 20,更新 7,数量跳过 0)
```
有跳过/失败会在后面列出具体原因,不是只给个数字。同步中再次点「同步」会提示
“同步正在进行,请耐心等待,请勿重复点击”,不会并发跑两个。表单有效提交后,
按钮立即变成禁用的“同步中…”;页面检测到当前进程仍在同步时也保持这个状态。
同步成功启动使用状态条反馈,不弹出需要操作员手工关闭的成功确认框。
- 两端都必填,结束日期不得晚于 UTC+8 下的今天,闭区间最多 31 天。
- 日期范围经过自动 OCR 或手工验证码登录后必须原样保留。
- 日期校验失败时保留原输入,并在工具条下方显示可读错误。
- 是否推进覆盖游标的规则见
[08 顺运宝接口](08-顺运宝接口.md) §4.4。
`[必须]` 「同步记录」打开宽弹窗,按开始时间从新到旧分页显示状态、日期范围、
操作账号、开始/完成时间、同步统计、覆盖游标结果和失败原因。状态至少区分同步中、
成功、失败、已中断;不能只靠颜色表达。刷新或重启 Admin 后记录仍可查看。
同步统计必须区分原始货运单、接受货运单、店铺跳过和数量异常跳过,不能混成一个
“跳过”数字。
弹窗底部右侧依次放“刷新同步记录”、当前页、翻页组件和“关闭”。点击刷新后按钮
显示“刷新中…”并暂时禁用;刷新和弹窗内翻页只请求受网页登录保护的同步记录
HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表的滚动、勾选和筛选
状态不改变,弹窗也不关闭;失败时保留原记录并在弹窗内给出重试提示。无 JavaScript
时翻页链接降级为重载 `/syb` 并自动重开弹窗。本阶段不自动轮询,避免阅读历史
记录时内容突然移动;F5 仍按浏览器默认行为刷新整个 SYB 页面。
`[必须]` 店铺和订单号搜索框桌面端固定约 140px,见 [§3.1](#31-搜索框宽度)。
店铺是独立的模糊搜索条件,可与处理阶段、订单号/商品标题组合;输入会去除首尾空白。
“清除”位于“搜索”左侧,清空处理阶段、店铺、订单号和页码,但保留当前同步日期范围。
翻页、同步、删除、详情内写操作、创建采集任务和创建采购任务返回列表时都要保留店铺条件。
### 6.2 表格列
☐ / 订单号 / 店铺 / 商品标题 / 规格 / 蝦皮商品ID / 数量 /
价格(台币)/ 图片 / **下一步** / 更新时间
- 一行对应顺运宝一张货运单的**一个商品明细**,不是一张货运单——一张货运单
可以有多个商品,各占一行。
- `syb_id` 仍是复选框、详情入口和服务端操作使用的内部标识,但不再单独显示成表格列;
详情和任务来源信息仍可查看该值。
- 店铺显示顺运宝货运单的 `shopName`,列宽约 224px;规格列宽约 140px。长内容
省略显示并通过单元格 `title` 查看全文。
- 图片显示小缩略图。点击缩略图在弹窗中按比例查看顺运宝原图,并提供新窗口打开入口;
图片加载失败时显示可读提示。`[必须]` 只存 URL,不代理、缓存或把图片塞进数据库。
- 处理阶段不作为独立列表列显示,但顶部筛选、勾选门禁、下一步路由和详情说明继续使用。
阶段按当前关联事实**算出来**,不是存一份容易过期的状态字段。阶段至少区分:
采购数据异常(顺运宝未提供规格)、未关联 PDD、PDD 待采集、采集中、采集中(超时)、
采集失败、规格待匹配、可采购、已创建任务、采购完成和采购待人工核对。
- 成功采购任务的下一步显示“查看采购结果”;`manual_review` 显示“核对采购订单”,
并在详情中明确提示可能已经下单、必须先核对。两种状态都不允许勾选或重新采购。
- `collecting` 已超过 15 分钟,或已找不到 `pending/assigned/claimed` 采集任务时,
显示“PDD 采集中(超时)”和“重新创建采集任务”,不能继续伪装成正常进行中。
- 每行显示一个有文字的“下一步”按钮。缺少规格、数量异常、待人工核对分别使用
“核对缺失规格”“核对采购数量”“核对采购订单”,不能使用含义相同的笼统文案。
按钮的 `title` 保留当前阶段原因。商品标题是服务端渲染处理弹窗的明确入口,
支持鼠标和键盘;双击其他普通单元格不执行动作。图片入口只负责查看原图,不能打开处理详情。
- “下一步”随实时阶段变化:未关联 PDD、待采集、采集中、采集失败、规格待匹配、
可创建采购任务、已创建采购任务和异常阶段分别显示对应动作。除“创建采购任务”和
“查看采购任务”外,其余动作进入当前明细的处理弹窗。
- 规格原文为空时不显示匹配表单,明确提示“顺运宝未提供规格”,并禁止建立映射或创建采购任务。
- 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。
- 桌面端顺运宝页面使用纵向 Flex 布局:导航、工具条和条件提示占实际高度,表格取得
到固定底部状态栏之间的全部剩余空间。表格内部纵横滚动并固定表头,横向滚动条位于
该区域底部。窄窗口解除视口高度锁定,回到自然页面滚动。
复选框按批量动作能力启用,不再等同于“可采购”:PDD 待采集、PDD 采集失败、
PDD 采集中(超时)行可用于
“创建采集任务”,可创建采购任务行可用于“创建采购任务”,其他阶段继续禁用并通过
`title` 和无障碍标签说明原因。混合勾选时两个按钮分别统计和提交自身兼容的行,不能把
待采集商品带进采购确认,也不能把采购就绪商品带进采集请求。
批量创建采集任务的确认弹窗显示可采集明细数,并允许不指定客户端或选择当前账号
可见客户端。同一 PDD 商品可能对应多条顺运宝明细,服务端按 PDD 商品 ID 去重,只创建
一条采集任务;浏览器提交的全部明细必须重新校验阶段,参数过期或被篡改时整批拒绝。
### 6.3 采购处理与规格匹配弹窗(点击商品标题或处理类下一步打开)
弹窗按当前处理阶段渐进展示。它直接以顺运宝规格原文及其 `spec_key`
连接当前 PDD 商品选项,不再经过蝦皮 SKU 确认步骤。PDD 关联、采集和规格映射使用同一个弹窗完成。
弹窗先展示当前 PDD 商品 ID、完整链接和采集状态。关联输入框使用可见标签
“PDD 商品链接或商品 ID”和持续帮助文字,允许填写带 `goods_id` 的完整链接,或
6~24 位纯数字商品 ID;不能使用会拦截纯数字的 `type=url`。纯 ID 由服务端转换为
标准商品链接并按 `goods_id` 复用已有档案;换成其他商品需要明确确认。待采集或采集失败时显示
“创建采集任务”或“重新创建采集任务”,采集中和已采集状态只显示说明。
顺运宝明细通过蝦皮商品 ID 命中有效 PDD 关联时,列表阶段区域和详情弹窗显示
“使用蝦皮商品的 PDD 关联:<商品 ID>,无需重复输入链接”。关联表单仍保留给主动换品,
并说明只有需要更换采购商品时才修改;PDD 商品缺失或已软删除时不显示复用提示。
单条创建和重新创建采集任务时,按钮上方显示有可见标签的“执行客户端”
下拉框,只列出当前账号可见的 Client,并显示名称、Client ID 和在线状态。
默认为“不指定(任意客户端可领取)”;指定后只等待该 Client 领取。持续
帮助文字必须说清两种选择的后果,不依赖 placeholder 或颜色。该入口与列表
顶部批量创建共用同一套服务端阶段、客户端可见范围和重复任务校验。
```text
┌──────────────────────────────────────────────────┐
│ 规格匹配 · 订单 SO-20260806-001 │
├────────────────────────┬─────────────────────────┤
│ 蝦皮 │ 拼多多 │
│ │ │
│ 颜色 黑色 │ 颜色 [黑色 ▾] │
│ 尺码 M │ 尺码 [M码 ▾] │
│ 建议 40-50公斤 │ │
│ 原文 黑色,M【建議40…】 │ 单价 ¥39.90 │
├────────────────────────┴─────────────────────────┤
│ ⓘ 已有匹配记录,已自动带出,确认无误后保存 │
│ [取消] [保存] │
└──────────────────────────────────────────────────┘
```
`[必须]` 几条硬规则:
- 右侧下拉的选项来自该商品 `skus_json` 里的 `dimensions`,
**不要写死"颜色/尺码"两个维度**——PDD 商品可能有第三个维度。
- **打开时先按 `(蝦皮商品 ID, spec_key, 当前 PDD 商品 ID)` 查 `spec_mappings`**,
有记录就自动带出并提示"已自动带出"。
- 查询必须同时带当前 `pdd_goods_id`,并核对选项仍存在且可购买;换品或选项消失时回到待匹配。
- 该商品**还没采集**(`skus_json` 为空)时,不要显示空下拉,直接展示关联和创建采集任务入口。
- 保存写的是 `spec_mappings`(可复用),**不是这一张订单的临时数据**。
- 候选按确定性 A/B/C/冲突规则稳定排序,界面不显示数字分数。只有唯一 A 级且无额外维度歧义时预选,但仍必须人工点“保存”。
- `★ 建议` 同时显示码位、体重覆盖和主色等文字理由;保存按钮旁固定提示“请核对颜色、尺码和体重范围”。
- 弹窗携带完整匹配上下文版本;顺运宝明细、PDD 关联/采集结果或规则版本改变后拒绝陈旧保存。
### 6.4 创建采购任务
只有“可创建采购任务”的行能参与采购批量动作。勾选后打开服务端渲染的确认弹窗:选择当前账号
可见的任意客户端,并逐行确认人民币单价上限和自动计算的订单总价上限。离线或尚未就绪的 Client 也可提前
指派,待其就绪后领取;选项继续显示在线状态但不因能力状态禁用。默认价格来自映射规格
的 PDD 采集价;没有价格时留空强制填写。新任务固定为真实下单(不支付),不显示执行
模式选择,也不再要求额外风险复选框、确认短语或浏览器二次确认。
每条确认行同时显示顺运宝规格原文和当前映射的唯一 PDD 规格组合。价格输入只对应该
组合中的选定颜色,不显示同一 PDD 商品的其他颜色或价格。批量创建时每条顺运宝明细
各自显示一个选定组合和一个价格输入;映射或商品上下文在弹窗打开后变化时,服务端拒绝
旧提交并要求刷新。未勾选的确认行必须同时保持 `hidden` 和 input `disabled`;采购行的
grid 样式不得覆盖 `hidden`,关闭后改变勾选再打开也不得残留上一次的确认行。
`[必须]` 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么:
- 同一批中合法行可以创建,失败行逐条显示明细 ID 和原因;数据库错误则整批回滚。
- 同一明细已有 `pending`、`assigned`、`claimed`、`succeeded` 或 `manual_review` 的采购任务时
拒绝重复创建;成功任务是采购终态,待人工核对任务可能已经下单。`failed/cancelled` 的现有
人工判断后重新创建策略不变。
- 顺运宝列表继续显示台币售价,但绝不把它带入人民币单价或订单总价上限。
```text
成功创建 3 个任务。以下 2 条未创建:
· SO-...-007 该商品未填写 PDD 链接
· SO-...-009 规格未匹配
```
`[必须]` 创建前弹出确认框,让操作员选**分配给哪个当前账号可见的 Client**,并确认人民币单价上限。
单价上限默认从当前映射的 PDD 规格采集价带出,可改,**不允许为空**。每行必须同时显示固定数量和即时计算的 `订单总价上限 = 单价上限 × 数量`;总价使用只读 `output` 和 `aria-live` 提供反馈。JavaScript 只负责展示,后端必须用数据库中的最新数量重新计算。
可采购行的“下一步”显示“创建采购任务”。点击后复用同一个确认弹窗并只带入当前明细,
不改变表格复选框状态;顶部批量入口仍按已勾选明细打开。客户端选项显示名称和 Client
ID 和在线状态;所有可见 Client 都可选择且只有一个时自动选中。没有可见 Client 时禁用
提交并说明需要管理员先绑定客户端。已有未结束任务的行显示“查看采购任务”并进入
“采集采购”页,不得再次创建;采购完成行显示
“查看采购结果”,待人工核对行显示“查看采购任务”,两者同样进入按订单号筛选的采集采购页。
服务端仍必须重新
校验映射、数量、人民币单价与订单总价上限、客户端权限、固定 live 模式、创建审计和重复任务,不能
信任列表页的旧状态。
## 7. 采集采购页
`tasks` 一张表用 `task_type` 区分采集和采购,本页把两种任务放在同一个
列表里显示,靠「目标」一列概括各自不同的业务信息,见
[01 需求](01-requirements.md) §4.4。
`[必须]` 导航文字和页面标题都是「**采集采购**」,不是「任务」或「采购任务」。
### 7.1 工具条
```text
[类型▾ 全部] [状态▾ 全部] [创建人▾ 全部] 关键词[___] [搜索] [删除]
```
- **类型**:全部 / 采集 / 采购。
- **状态**:全部 + 7 个状态,见 [01 需求](01-requirements.md) §6.2。
- **关键词**:同时匹配任务编号、采购订单号、PDD 商品 ID、来源顺运宝订单号和明细 ID。
- **创建人**:仅管理员显示;包含全部账号、已禁用账号和“历史任务”。采购员不显示
此控件,服务端固定只查询本人创建的任务。
`[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。
placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD 商品 ID」**——
收到 30% 之后装不下,会被截断成看不出意思的半截文字。
### 7.2 表格列
☐ / 任务编号 / 类型 / **执行模式** / **目标** / PDD 店铺 / 状态 / 客户端 / 创建人(管理员) / 更新时间
```text
☐ │ 任务编号 │ 类型 │ 执行模式 │ 目标 │ PDD 店铺 │ 状态 │ 客户端 │ 更新时间
☐ │ cj123 │ 采集 │ 采集 │ PDD 737116531267 │ 某某店铺 │ 已领取 │ 办公室-01 │ 15:20
☐ │ cg45 │ 采购 │ 真实下单(不支付)│ SO-001 · 黑色/M · 2件 · ≤¥42.00 │ 某某店铺 │ 待领取 │ 办公室-02 │ 15:22
```
- `[必须]` **任务编号列**直接显示真实 `task_id`:采集为 `cjN`,采购为
`cgN`;关键词支持完整或部分编号查询。“类型”列仍显示明确文字,
不能只靠前缀或颜色区分。
- `[必须]` **目标列**:采集显示 `PDD <pdd_goods_id>`(能 join 到未删除商品
的标题时追加显示);采购显示 `<order_no> · <颜色/尺码> · <数量>件 · ≤<订单总价上限>`。
拼接逻辑在 service 层组装成一个字符串,模板只负责显示。
- `[必须]` 订单总价上限显示成 `¥42.00`(分转元),底层存的是整数分。
- `[必须]` **PDD 店铺列**:按任务的 `pdd_goods_id` 关联当前未删除
`pdd_products.shop_name`;没有商品、尚未采集、店铺为空或商品已软删除时显示 `—`。
- `[必须]` **客户端列**:优先显示当前 `clients.name`;名称为空或客户端记录不存在时
回退稳定客户端 ID;无主任务显示 `—`。名称和店铺过长时省略,完整内容放在 `title`。
- `[必须]` 类型和状态都是文字,不能只靠颜色区分。
- `[必须]` 管理员显示创建人列,NULL 显示“历史任务”;采购员省略该列。
### 7.3 详情弹窗(双击行打开)
`[必须]` 详情字段只读,没有改派 / 重试 / 取消或直接复制旧任务的入口。
采购任务可通过文字按钮导航回原顺运宝明细;导航不产生写操作。
弹窗内容:
```text
┌──────────────────────────────────────────────┐
│ 任务 cj123 │
├──────────────────────────────────────────────┤
│ 类型 采集 │
│ 执行模式 采集 │
│ 状态 已领取 │
│ 客户端名称 办公室-01 │
│ 客户端 ID client-001 │
│ 创建人 buyer-a │
│ 领取时间 2026-08-07 15:20:31 │
│ 完成时间 — │
├──────────────────────────────────────────────┤
│ 来源信息 (采购任务才有) │
│ 蝦皮订单号 SO-001 │
│ 顺运宝明细 ID SYB-001 │
│ 蝦皮商品 ID 100001 │
│ PDD 商品 ID 737116531267 │
├──────────────────────────────────────────────┤
│ 执行参数 │
│ PDD 链接 https://mobile.yangkeduo.com/... │
│ PDD 店铺 某某店铺 │
│ 目标规格 黑色 / M (采购任务才有) │
│ 数量 2 (采购任务才有) │
│ 订单总价上限 ¥42.00 (采购任务才有) │
├──────────────────────────────────────────────┤
│ PDD 核单结果 (采购任务才有) │
│ PDD 订单编号 PDD-20260810-001 │
│ 下单时间 2026-08-10 19:12 │
│ 上报时付款状态 未付款(Client 上报时) │
│ Admin 不会查询 PDD 实时付款状态。 │
├──────────────────────────────────────────────┤
│ 错误 │
│ 错误码 PDD_PAGE_TIMEOUT │
│ 错误说明 商品页加载超时 │
├──────────────────────────────────────────────┤
│ [查看原货运单] [重新创建采购任务] [关闭] │
└──────────────────────────────────────────────┘
```
- `[必须]` 采购专有字段(数量、订单总价上限、目标规格)在采集任务的弹窗里
**整段隐藏**,不显示空行。
- 来源信息只在采购任务显示;“查看原货运单”使用稳定 `syb_id` 深链接精确打开明细,
订单号只用于列表上下文,不能代替明细主键。
- “重新创建采购任务”只在 `failed`、`cancelled` 显示。按钮进入原明细的采购确认
弹窗,仍要求选择客户端并确认当前规格、数量和价格;不得在任务详情直接创建。
`pending`、`assigned`、`claimed`、`succeeded`、`manual_review` 均不显示该入口。
- PDD 核单结果只在采购任务显示,订单编号使用可选择复制的普通文本;付款状态标签
必须包含“上报时”,不能暗示为实时状态。
- 尚未上报显示“等待 Client 核单上报”;需人工时显示“订单结果不确定”;结果结构或
带时区时间非法时显示格式异常,并引导展开完整结果诊断。
- 错误段没有错误码/说明时整段不显示。
- `[建议]` `result_data`(Client 提交的完整结果)默认折叠,展开查看;
过长时截断显示。
### 7.4 删除
`[必须]` 批量删除要二次确认,写明"将删除 N 条,不可恢复"——`tasks` 表
没有软删除列,删了就是真删了。
`[必须]` 删除权限在服务端判断。采购员混选本人和他人任务、或提交不存在编号时,
整批拒绝并保持所有任务不变;详情越权与任务不存在都显示 404。
### 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$` / `¥`)。
商品详情在正式 SKU 表之后显示“顺运宝观测规格”。该区域必须明确标注它不是正式
蝦皮 SKU,价格是最近货运单台币售价、不是 PDD 人民币采购价;展示规格原文、订单
数、累计数量、最近图片、最后出现时间及唯一正式 SKU 匹配结果,多条匹配交给人工。
蝦皮商品列表在商品 ID 后显示 32×32 懒加载主图,在标题后显示“店铺”。主图可
打开通用原图弹窗和新窗口,加载失败显示文字占位;无图不打开空弹窗。店铺列最大约
150px并省略,完整值放在 `title` 和详情中。商品标题使用 `.col-title` 的固定 120px,
避免百分比列宽随窗口和浏览器算法波动;1366×768 下通过表格横向滚动保留可读性。详情同时显示图片、
店铺、各自来源和观测时间,顺运宝观测图仍只出现在观测区域。
店铺列显示导入或同步得到的蝦皮店铺原始值。`shop_id` 只作为精确匹配后的稳定内部关联,
不再额外显示“业务店铺”列;未精确关联的数据从店铺管理页的“未登记商品”入口查看。