feat: PDD 商品页面 (#18)

打通 Client↔Admin 闭环的一环:录入 PDD 链接 → 创建采集任务 →
客户端领走去采 → 规格和价格显示在页面上。#16 写的 MarkCollecting 和
SoftDeletePddProduct 至此才有生产调用方。

链接解析严格、不做容错兜底:goods_id 上有 UNIQUE 约束,防重全靠它。
猜一个的话同一商品会存成好几行、采好几遍,规格映射还说不清指向哪一行。
短链一律拒绝,卡域名是为了拦"粘了淘宝链接"这种失误。

创建采集任务先占状态再建任务:MarkCollecting 只在 pending/failed 时成功,
同时充当"有没有人已经在采"的判断,与建任务在同一事务里,
所以并发点多次只会建出一个任务(实测 4 并发 → 1 条)。

submit.go 的 collectedData 增加 Dimensions —— 没有它就只能按 Go 的 map
遍历,而 map 无序,同一商品每次刷新"颜色/尺码"的先后都可能变。

顺带修 #16 一处缺陷:EnsurePddProduct 复活分支清空了 skus_json 却漏了
title,导致复活后状态显示"未采集"但标题还留着旧值。

审查打回一次:清理"PDD 链接唯一的录入口"这一过期说法(全库 5 处),
以及 shopee.go 里"空 → no_link"的过期 TODO —— no_link 已在 #16
从 CHECK 约束删除,照写会直接撞约束。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-08-07 11:27:06 +08:00
co-authored by Claude Opus 5
parent fab20cfd2f
commit eb8d357f5a
16 changed files with 2333 additions and 72 deletions
+1
View File
@@ -11,6 +11,7 @@
<nav class="nav">
<span class="nav-brand">采集采购管理端</span>
<a href="/shopee" class="{{if eq .Active "shopee"}}active{{end}}">蝦皮数据</a>
<a href="/pdd" class="{{if eq .Active "pdd"}}active{{end}}">PDD 商品</a>
<a href="/syb" class="{{if eq .Active "syb"}}active{{end}}">顺运宝数据</a>
<a href="/tasks" class="{{if eq .Active "tasks"}}active{{end}}">采购任务</a>
<a href="/clients" class="{{if eq .Active "clients"}}active{{end}}">客户端列表</a>
+92
View File
@@ -0,0 +1,92 @@
{{define "pdd/edit_modal"}}
{{/* 双击一行时弹出的内容。
这是一个**片段**,不是整页——外面的弹窗壳子在 pdd/list.html 里。
规格表由服务端渲染是有意的:维度顺序、价格格式、price_cent 为 null
的处理都是业务规则,放到前端去拼就没人维护得住了。 */}}
{{with .D}}
<div class="modal-head">
<h2>PDD 商品 {{.GoodsID}}</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
{{/* 「重新采集」自己一个表单,只提交这一个商品。
HTML 不允许表单套表单,所以它写在保存表单外面,
按钮靠 form="pdd-recollect-form" 挂过来。 */}}
<form id="pdd-recollect-form" method="post" action="/pdd/collect" hidden>
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="ids" value="{{.GoodsID}}">
</form>
<form method="post" action="/pdd/save">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="id" value="{{.ID}}">
<div class="modal-body">
<dl class="detail">
<dt>商品 ID</dt><dd>{{.GoodsID}}<small>(只读,由链接解析得出,不能改)</small></dd>
<dt><label for="detail-url">PDD 链接</label></dt>
<dd><input id="detail-url" type="text" name="url" value="{{.URL}}"
autocomplete="off" class="wide"></dd>
<dt>标题</dt><dd>{{.Title}}<small>(只读,采集后自动回填)</small></dd>
<dt>采集状态</dt><dd>{{.StatusText}}</dd>
<dt>失败原因</dt><dd>{{.CollectMsg}}</dd>
<dt>采集时间</dt><dd>{{.CollectedAt}}</dd>
{{if .ArtifactRef}}
{{/* 诊断产物只报位置、不上传文件:告诉操作员去哪台机器的哪个目录捞截图 */}}
<dt>诊断产物</dt><dd><code>{{.ArtifactRef}}</code></dd>
{{end}}
</dl>
<h3>规格{{if .Collected}}({{len .SKUs}} 个){{end}}</h3>
{{if .SkusError}}
<p class="missing">{{.SkusError}}</p>
{{else if not .Collected}}
<p class="hint">
尚未采集。关掉这个弹窗,勾选这一行再点「创建采集任务」,
等客户端领走执行完,规格和价格就会出现在这里。
</p>
{{else if not .SKUs}}
<p class="missing">
采集完成但一个规格都没有。这通常说明商品已下架,或者采集时没点开规格面板——
建议重新采集一次。
</p>
{{else}}
<div class="table-wrap">
<table>
<thead>
<tr>
{{/* 列顺序按采集结果里的 dimensions 定,不是 Go map 的随机顺序 */}}
{{range .DimensionNames}}<th>{{.}}</th>{{end}}
<th>价格</th>
<th>有货</th>
</tr>
</thead>
<tbody>
{{range .SKUs}}
<tr>
{{range .Options}}<td>{{.}}</td>{{end}}
{{/* 价格底层存的是整数分。采不到价格显示「未采到」,
绝不显示 ¥0.00——那会让人以为是白菜价 */}}
<td>{{.PriceText}}</td>
<td>{{.Available}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
<div class="modal-foot">
<button type="submit" form="pdd-recollect-form">重新采集</button>
<span class="grow"></span>
<button type="button" data-modal-close>取消</button>
<button type="submit" class="primary">保存</button>
</div>
</form>
{{end}}
{{end}}
+142
View File
@@ -0,0 +1,142 @@
{{define "pdd/list"}}
{{template "header" .}}
{{/* 三段式布局的第一段:顶部工具条。
和另外四个页面保持一致,见 docs/admin/05-ui-specification.md §2。 */}}
<div class="toolbar">
<button type="button" data-modal-open="create-modal">创建</button>
{{/* 「创建采集任务」和「删除」用的是**同一个表单**,靠按钮上的
formaction 决定提交到哪个地址。这样表格里的勾选框只需要挂一份,
不用为每个操作各复制一套(复制出来的两套迟早会不同步)。 */}}
<form id="pdd-form" method="post" action="/pdd/collect" hidden></form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="pdd-form">
{{/* 把当前筛选一起带过去,操作完还停在原来的筛选上 */}}
<input type="hidden" name="status" value="{{.StatusFilter}}" form="pdd-form">
<input type="hidden" name="q" value="{{.Keyword}}" form="pdd-form">
{{/* 文案是「创建采集任务」不是「采集」:点完只是排了个队,
真正去手机上采要等客户端来领,可能几秒也可能几分钟 */}}
<button type="submit" form="pdd-form" formaction="/pdd/collect"
data-need-checked>创建采集任务</button>
<form class="inline grow" method="get" action="/pdd">
<label for="status">采集状态</label>
<select id="status" name="status">
{{range .StatusOptions}}
<option value="{{.Value}}" {{if eq .Value $.StatusFilter}}selected{{end}}>{{.Text}}</option>
{{end}}
</select>
<label for="q">商品 ID/链接</label>
<input id="q" type="text" name="q" value="{{.Keyword}}" placeholder="商品 ID 或链接的一部分">
<button type="submit">搜索</button>
</form>
<button type="submit" form="pdd-form" formaction="/pdd/delete" class="danger"
data-need-checked
data-confirm-delete="将删除 {n} 条记录。删除后重新创建同一链接可恢复,但采集结果会清空。"
>删除</button>
</div>
{{/* 第二段:表格。双击一行打开弹窗,见 static/js/app.js */}}
<div class="table-wrap" id="pddProductPage">
<table>
<thead>
<tr>
<th class="col-check"><input type="checkbox" data-check-all></th>
<th>商品 ID</th>
<th>标题</th>
<th>PDD 链接</th>
<th>采集状态</th>
{{/* 采到 1 个和采到 20 个差别很大:只采到 1 个通常是没点开规格面板,
说明这次采集有问题。这一列就是为了让它一眼能看出来 */}}
<th>规格数</th>
<th>采集时间</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr data-detail-id="{{.ID}}" {{if .IsFailed}}class="row-warn"{{end}}>
<td class="col-check">
{{/* 勾选框的值是 goods_id:删除和建采集任务都按它定位。
form 属性把它挂到工具条里那个表单上 */}}
<input type="checkbox" value="{{.GoodsID}}" name="ids" form="pdd-form"
aria-label="选择商品 {{.GoodsID}}">
</td>
<td>{{.GoodsID}}</td>
<td class="truncate" title="{{.Title}}">{{.Title}}</td>
<td class="truncate">
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer" title="{{.URL}}">{{.URL}}</a>
</td>
{{/* 状态是中文文字,不能只靠颜色区分 */}}
<td>{{.StatusText}}{{if .CollectMsg}}<br><small class="missing">{{.CollectMsg}}</small>{{end}}</td>
<td>{{.SkuCountText}}</td>
<td>{{.CollectedAt}}</td>
<td>{{.UpdatedAt}}</td>
</tr>
{{else}}
{{/* 空状态要分情况:从没建过 和 筛选没结果,下一步动作完全不同 */}}
<tr class="empty">
<td colspan="8">
{{if .IsFiltered}}
当前筛选条件下没有商品。<br>
<small>换个采集状态或清空搜索词再试。<a href="/pdd">查看全部</a></small>
{{else}}
还没有 PDD 商品。<br>
<small>点左上角「创建」,粘贴一个拼多多商品链接就行,其余信息采集后自动回填。</small>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p class="hint">
双击任意一行可以查看和编辑这个商品,包括采回来的规格和价格。
</p>
{{/* ── 创建弹窗 ─────────────────────────────────
内容是固定的(只有一个输入框),所以直接写在页面里藏着,
不像编辑弹窗那样要去服务端取。 */}}
<div class="modal-backdrop" id="create-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="create-modal-title">
<div class="modal-head">
<h2 id="create-modal-title">创建 PDD 商品</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<form method="post" action="/pdd/create">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="status" value="{{.StatusFilter}}">
<input type="hidden" name="q" value="{{.Keyword}}">
<div class="modal-body">
<div class="field">
<label for="create-url">PDD 链接</label>
<input id="create-url" type="text" name="url" required autocomplete="off"
placeholder="https://mobile.yangkeduo.com/goods.html?goods_id=737116531267">
</div>
<p class="hint">
只需要链接,标题和规格由客户端采集后自动回填。<br>
链接里必须带 <code>goods_id=</code>,短链接不行——请在拼多多 App
的商品页点「分享」→「复制链接」。
</p>
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
<button type="submit" class="primary">保存</button>
</div>
</form>
</div>
</div>
{{/* ── 编辑弹窗的壳子 ───────────────────────────
里面的内容双击行时由 /pdd/detail 返回,前端只负责放进来和显示。 */}}
<div class="modal-backdrop" id="detail-modal" data-detail-url="/pdd/detail" hidden>
<div class="modal" role="dialog" aria-modal="true" data-detail-slot>
<div class="modal-body">正在加载…</div>
</div>
</div>
{{template "footer" .}}
{{end}}
+8 -3
View File
@@ -60,9 +60,14 @@
</div>
{{/* TODO(骨架): 编辑弹窗 templates/shopee/edit_modal.html
这是 PDD 链接唯一的录入口,采集按钮也只出现在这里——
不要放到表格每一行,一个商品有 N 个 SKU 行,
放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。 */}}
这是从蝦皮商品出发录入 PDD 链接、发起采集的入口。
采集按钮**在本页只出现在这个弹窗里**——不要放到表格每一行,
一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,
还会建出 N 个重复任务。
PDD 商品页(templates/pdd/)也能录入链接和发起采集,两处并存;
本页这个还是骨架(点了返回 501)。合并与否由
「蝦皮↔PDD 关联入口」工单决定,不要在本页单独改。 */}}
{{template "footer" .}}
{{end}}