feat: 区分顺运宝标题与图片交互 (#105)

This commit is contained in:
chengma
2026-08-10 16:33:42 +08:00
parent b16f39ab9f
commit fb2e4c4dab
5 changed files with 169 additions and 7 deletions
+43
View File
@@ -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 {
+42
View File
@@ -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 = "<div class='modal-body'>正在加载…</div>";
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();
+45
View File
@@ -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, `<tr data-detail-id=`) {
t.Error("顺运宝列表不应继续使用含义模糊的整行双击入口")
}
}
+33 -4
View File
@@ -101,20 +101,30 @@
</thead>
<tbody>
{{range .Rows}}
<tr data-detail-id="{{.SybID}}" {{if .NeedsAttention}}class="row-warn"{{end}}>
<tr {{if .NeedsAttention}}class="row-warn"{{end}}>
<td class="col-check">
<input type="checkbox" value="{{.SybID}}" name="ids" {{if not .CanPurchase}}disabled{{end}}
aria-label="选择货运单明细 {{.SybID}}">
</td>
<td>{{.SybID}}</td>
<td>{{.OrderNo}}</td>
<td class="truncate" title="{{.Title}}">{{.Title}}</td>
<td class="truncate" title="{{.Title}}">
<button type="button" class="table-title-action" data-detail-open-id="{{.SybID}}"
title="打开商品处理详情">{{.Title}}</button>
</td>
<td class="truncate" title="{{.ProductSpec}}">{{.ProductSpec}}</td>
<td>{{.ShopeeGoodsID}}</td>
<td>{{.Quantity}}</td>
<td>{{.PriceText}}</td>
<td>
{{if .ImageURL}}<img src="{{.ImageURL}}" alt="" class="thumb">{{else}}—{{end}}
{{if .ImageURL}}
<button type="button" class="thumb-action" data-image-preview-url="{{.ImageURL}}"
data-image-preview-title="{{.Title}}" title="查看商品原图"
aria-label="查看 {{.Title}} 的原图">
<img src="{{.ImageURL}}" alt="{{.Title}} 缩略图" class="thumb"
width="32" height="32" loading="lazy">
</button>
{{else}}—{{end}}
</td>
<td><span class="status-pill">{{.StageText}}</span><br><small>{{.StageHelp}}</small></td>
<td><button type="button" data-detail-open-id="{{.SybID}}">{{.ActionText}}</button></td>
@@ -201,9 +211,28 @@
</div>
<p class="hint">
每行只显示当前最需要完成的下一步;双击行或点击“下一步”按钮可以打开处理详情。
每行只显示当前最需要完成的下一步;点击商品标题进入处理详情,点击图片查看原图。
</p>
<div class="modal-backdrop" id="image-preview-modal" hidden>
<div class="modal image-preview-modal" role="dialog" aria-modal="true"
aria-labelledby="image-preview-title">
<div class="modal-head">
<h2 id="image-preview-title" data-image-preview-title>商品原图</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<div class="modal-body image-preview-body">
<p class="hint image-preview-status" data-image-preview-status role="status">正在加载原图…</p>
<img data-image-preview-image alt="" hidden>
</div>
<div class="modal-foot">
<a class="button-link" data-image-preview-link href="#" target="_blank"
rel="noopener noreferrer">新窗口打开原图</a>
<button type="button" data-modal-close>关闭</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}" hidden>
<div class="modal modal-wide" role="dialog" aria-modal="true" data-detail-slot>
<div class="modal-body">正在加载…</div>
+6 -3
View File
@@ -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` 区分采集和采购,本页把两种任务放在同一个