diff --git a/admin/static/css/app.css b/admin/static/css/app.css
index 0fbd6dc..a9cc127 100644
--- a/admin/static/css/app.css
+++ b/admin/static/css/app.css
@@ -511,6 +511,49 @@ select {
border-radius: 3px;
vertical-align: middle;
}
+.table-title-action,
+.thumb-action {
+ border: 0;
+ background: transparent;
+ color: inherit;
+}
+.table-title-action {
+ display: block;
+ max-width: 100%;
+ padding: 2px 0;
+ overflow: hidden;
+ color: #0969da;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.table-title-action:hover { text-decoration: underline; }
+.thumb-action {
+ padding: 3px;
+ border-radius: 4px;
+ line-height: 0;
+}
+.thumb-action:hover { background: #e8edf3; }
+.table-title-action:focus-visible,
+.thumb-action:focus-visible {
+ outline: 2px solid #0969da;
+ outline-offset: 2px;
+}
+.image-preview-modal { max-width: min(1120px, calc(100vw - 32px)); }
+.image-preview-body {
+ display: flex;
+ min-height: 160px;
+ align-items: center;
+ justify-content: center;
+}
+.image-preview-body img {
+ display: block;
+ max-width: 100%;
+ max-height: calc(100vh - 190px);
+ object-fit: contain;
+}
+.image-preview-body img[hidden] { display: none; }
+.image-preview-status { margin: 0; }
/* ── 错误页 ───────────────────────────── */
.error-box {
diff --git a/admin/static/js/app.js b/admin/static/js/app.js
index f263a59..46404ec 100644
--- a/admin/static/js/app.js
+++ b/admin/static/js/app.js
@@ -296,6 +296,8 @@
var base = modal.getAttribute("data-detail-url") || "";
function loadDetail(id) {
+ if (!modal.hidden && modal.getAttribute("data-loaded-detail-id") === id) return;
+ modal.setAttribute("data-loaded-detail-id", id);
slot.innerHTML = "
正在加载…
";
openModal(modal);
@@ -336,6 +338,45 @@
});
}
+ /* 顺运宝缩略图使用上游图片 URL。这里只在弹窗里放大展示,
+ 不代理、不下载图片;加载失败时保留新窗口重试入口。 */
+ function setupImagePreview() {
+ var modal = document.getElementById("image-preview-modal");
+ if (!modal) return;
+ var image = modal.querySelector("[data-image-preview-image]");
+ var title = modal.querySelector("[data-image-preview-title]");
+ var status = modal.querySelector("[data-image-preview-status]");
+ var link = modal.querySelector("[data-image-preview-link]");
+ if (!image || !title || !status || !link) return;
+
+ document.querySelectorAll("[data-image-preview-url]").forEach(function (button) {
+ button.addEventListener("click", function () {
+ var url = button.getAttribute("data-image-preview-url") || "";
+ var productTitle = button.getAttribute("data-image-preview-title") || "商品";
+ title.textContent = productTitle + " · 原图";
+ link.href = url;
+ image.alt = productTitle + " 原图";
+ image.hidden = true;
+ status.hidden = false;
+ status.classList.remove("missing");
+ status.textContent = "正在加载原图…";
+ openModal(modal);
+
+ image.onload = function () {
+ status.hidden = true;
+ image.hidden = false;
+ };
+ image.onerror = function () {
+ image.hidden = true;
+ status.hidden = false;
+ status.classList.add("missing");
+ status.textContent = "原图加载失败,请在新窗口打开或稍后重试。";
+ };
+ image.src = url;
+ });
+ });
+ }
+
/* ── 顺运宝登录弹窗:验证码"换一张" ─────────────
图片本身、以及旁边的"换一张"按钮,点了都重新请求验证码接口。
`[必须]` 每次请求带一个新的时间戳查询参数,绕开浏览器缓存——
@@ -446,6 +487,7 @@
setupUploadFeedback();
setupModals();
setupRowDetail();
+ setupImagePreview();
setupCaptchaRefresh();
setupSybSyncFeedback();
setupSybHistoryRefresh();
diff --git a/admin/syb_interaction_template_test.go b/admin/syb_interaction_template_test.go
new file mode 100644
index 0000000..a98fa6b
--- /dev/null
+++ b/admin/syb_interaction_template_test.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "strings"
+ "testing"
+
+ "cmautobuy/admin/service"
+)
+
+func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
+ tmpl, err := template.ParseFS(templateFS, "templates/*/*.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ rows := []service.SybOrderView{{
+ SybID: "SYB-READY", OrderNo: "ORDER-READY", Title: "可采购商品",
+ ImageURL: "https://example.test/original.jpg", Stage: service.SybStagePurchaseReady,
+ StageText: "可创建采购任务", StageHelp: "规格映射有效", ActionText: "核对规格",
+ CanPurchase: true, DefaultMaxPrice: "39.90",
+ }}
+ var output bytes.Buffer
+ data := map[string]any{
+ "Title": "顺运宝数据", "Active": "syb", "CSRFToken": "test-csrf",
+ "Rows": rows, "DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(),
+ }
+ if err := tmpl.ExecuteTemplate(&output, "syb/list", data); err != nil {
+ t.Fatalf("渲染顺运宝列表失败: %v", err)
+ }
+ body := output.String()
+ for _, want := range []string{
+ `class="table-title-action" data-detail-open-id="SYB-READY"`,
+ `data-image-preview-url="https://example.test/original.jpg"`,
+ `aria-label="查看 可采购商品 的原图"`,
+ `id="image-preview-modal"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("顺运宝列表缺少 %q", want)
+ }
+ }
+ if strings.Contains(body, `
{{range .Rows}}
-
+
|
|
{{.SybID}} |
{{.OrderNo}} |
- {{.Title}} |
+
+
+ |
{{.ProductSpec}} |
{{.ShopeeGoodsID}} |
{{.Quantity}} |
{{.PriceText}} |
- {{if .ImageURL}} {{else}}—{{end}}
+ {{if .ImageURL}}
+
+ {{else}}—{{end}}
|
{{.StageText}} {{.StageHelp}} |
|
@@ -201,9 +211,28 @@
- 每行只显示当前最需要完成的下一步;双击行或点击“下一步”按钮可以打开处理详情。
+ 每行只显示当前最需要完成的下一步;点击商品标题进入处理详情,点击图片查看原图。
+
+
+
+
商品原图
+
+
+
+
正在加载原图…
+
![]()
+
+
+
+
+
正在加载…
diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md
index b2dd322..a1b551d 100644
--- a/docs/admin/05-ui-specification.md
+++ b/docs/admin/05-ui-specification.md
@@ -499,15 +499,17 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
- 一行对应顺运宝一张货运单的**一个商品明细**,不是一张货运单——一张货运单
可以有多个商品,各占一行。
-- 图片显示小缩略图。`[必须]` 存 URL,不要把图片塞进数据库。
+- 图片显示小缩略图。点击缩略图在弹窗中按比例查看顺运宝原图,并提供新窗口打开入口;
+ 图片加载失败时显示可读提示。`[必须]` 只存 URL,不代理、缓存或把图片塞进数据库。
- 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分:
采购数据异常(顺运宝未提供规格)、未关联 PDD、PDD 待采集、采集中、
采集失败、规格待匹配、可采购和已创建任务。
-- 每行显示一个有文字的“下一步”按钮;双击行和按钮打开同一个服务端渲染处理弹窗。
+- 每行显示一个有文字的“下一步”按钮。商品标题是服务端渲染处理弹窗的明确入口,
+ 支持鼠标和键盘;双击其他普通单元格不执行动作。图片入口只负责查看原图,不能打开处理详情。
- 规格原文为空时不显示匹配表单,明确提示“顺运宝未提供规格”,并禁止建立映射或创建采购任务。
- 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。
-### 6.3 采购处理与规格匹配弹窗(双击行或点击下一步打开)
+### 6.3 采购处理与规格匹配弹窗(点击商品标题或处理类下一步打开)
弹窗按当前处理阶段渐进展示。它直接以顺运宝规格原文及其 `spec_key`
连接当前 PDD 商品选项,不再经过蝦皮 SKU 确认步骤。PDD 关联、采集和规格映射使用同一个弹窗完成。
@@ -567,6 +569,7 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
`[必须]` 创建前弹出确认框,让操作员选**分配给哪个客户端**,并确认价格上限。
价格上限默认从当前映射的 PDD 规格采集价带出,可改,**不允许为空**。
+
## 7. 采集采购页
`tasks` 一张表用 `task_type` 区分采集和采购,本页把两种任务放在同一个