feat: 增加商品颜色匹配页面 (#294)

This commit is contained in:
chengma
2026-08-24 00:04:19 +08:00
parent 3cee6cbfaa
commit 8162ae5aca
12 changed files with 697 additions and 1 deletions
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"bytes"
"html/template"
"strings"
"testing"
"cmautobuy/admin/model"
"cmautobuy/admin/service"
)
func TestColorMappingTemplate_渲染分组候选安全表单和独立脚本(t *testing.T) {
tmpl, err := template.ParseFS(templateFS, "templates/*/*.html")
if err != nil {
t.Fatal(err)
}
view := &service.ProductColorMappingPageView{
ShopeeGoodsID: "S-1", ShopeeTitle: "测试商品", PddGoodsID: "P-1", PddTitle: "采购商品",
PddDimensionKey: "color", ContextVersion: strings.Repeat("a", 64), ValidCount: 1,
Rows: []service.ProductColorMappingPageRow{{
ColorKey: "黑色", ColorRaw: "黑色", SourceText: "蝦皮 SKU 1", OriginalValue: "曜石黑",
SelectedValue: "曜石黑", Status: "mapped", StatusText: "已匹配", MappingText: "人工维护 · U-1",
Groups: []service.ProductColorMappingOptionGroup{{Label: "当前选择", Options: []service.ProductColorMappingOption{{Value: "曜石黑", Label: "曜石黑 · ¥11.80", Selected: true}}}},
}},
}
var output bytes.Buffer
err = tmpl.ExecuteTemplate(&output, "shopee/color_mappings", map[string]any{
"Title": "颜色匹配", "Active": "shopee", "CSRFToken": "csrf-test",
"CurrentUser": &model.User{UserID: "U-1", Username: "buyer", Role: model.RolePurchaser},
"V": view, "ReturnTo": "/shopee?page=2", "Status": "共 1 个颜色",
"PageScript": "/static/js/color_mappings.js",
})
if err != nil {
t.Fatal(err)
}
html := output.String()
for _, want := range []string{
`action="/shopee/color-mappings/save"`, `name="csrf_token" value="csrf-test"`,
`name="context_version"`, `<optgroup label="当前选择">`, `data-color-key="黑色"`,
`/static/js/color_mappings.js`, `这里只维护商品颜色关系`,
} {
if !strings.Contains(html, want) {
t.Errorf("页面缺少 %q", want)
}
}
}
+83
View File
@@ -45,6 +45,25 @@ func (h *Handler) ShopeeDetail(c *gin.Context) {
return return
} }
returnValues := url.Values{}
if value := strings.TrimSpace(c.Query("q")); value != "" {
returnValues.Set("q", value)
}
returnValues.Set("search_field", service.ParseShopeeSearchField(c.Query("search_field")))
if value := service.ParseShopeeStatus(c.Query("status")); value != "" {
returnValues.Set("status", value)
}
if currentUser(c).IsAdmin() && c.Query("deleted") == "1" {
returnValues.Set("deleted", "1")
}
if c.Query("unlinked") == "1" {
returnValues.Set("unlinked", "1")
}
returnValues.Set("page", strconv.Itoa(service.ParsePage(c.Query("page"))))
returnValues.Set("page_size", strconv.Itoa(service.ParsePageSize(c.Query("page_size"))))
returnValues.Set("open_id", goodsID)
colorValues := url.Values{"goods_id": {goodsID}, "return_to": {"/shopee?" + returnValues.Encode()}}
c.HTML(http.StatusOK, "shopee/detail_modal", gin.H{ c.HTML(http.StatusOK, "shopee/detail_modal", gin.H{
"D": detail, "CSRFToken": csrfToken(c), "D": detail, "CSRFToken": csrfToken(c),
"Keyword": c.Query("q"), "SearchField": service.ParseShopeeSearchField(c.Query("search_field")), "Keyword": c.Query("q"), "SearchField": service.ParseShopeeSearchField(c.Query("search_field")),
@@ -53,6 +72,7 @@ func (h *Handler) ShopeeDetail(c *gin.Context) {
"UnlinkedFilter": c.Query("unlinked") == "1", "UnlinkedFilter": c.Query("unlinked") == "1",
"CurrentPage": service.ParsePage(c.Query("page")), "CurrentPage": service.ParsePage(c.Query("page")),
"CurrentPageSize": service.ParsePageSize(c.Query("page_size")), "CurrentPageSize": service.ParsePageSize(c.Query("page_size")),
"ColorMappingURL": "/shopee/color-mappings?" + colorValues.Encode(),
}) })
} }
@@ -126,6 +146,69 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, searchFieldRaw, stat
"DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page), "DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page),
"AutoOpenDetailID": strings.TrimSpace(c.Query("open_id")), "AutoOpenDetailID": strings.TrimSpace(c.Query("open_id")),
"AssignableClients": assignableClients, "AssignableClients": assignableClients,
"ReturnToEscaped": url.QueryEscape(c.Request.URL.RequestURI()),
}))
}
// ShopeeColorMappings 渲染当前蝦皮商品的独立颜色匹配工作区。
func (h *Handler) ShopeeColorMappings(c *gin.Context) {
h.renderShopeeColorMappings(c, strings.TrimSpace(c.Query("goods_id")),
safeNext(c.Query("return_to")), nil, c.Query("msg"), "", http.StatusOK)
}
// ShopeeColorMappingsSave 只接收发生变化的颜色键和目标值;所有业务事实都重新从数据库读取。
func (h *Handler) ShopeeColorMappingsSave(c *gin.Context) {
goodsID := strings.TrimSpace(c.PostForm("goods_id"))
returnTo := safeNext(c.PostForm("return_to"))
colorKeys, targets := c.PostFormArray("color_key"), c.PostFormArray("target_value")
overrides := make(map[string]string, len(colorKeys))
updates := make([]service.ProductColorMappingUpdate, 0, len(colorKeys))
if len(colorKeys) != len(targets) {
h.renderShopeeColorMappings(c, goodsID, returnTo, overrides, "", "提交的颜色行不完整,请刷新后重试。", http.StatusBadRequest)
return
}
for i, key := range colorKeys {
key = strings.TrimSpace(key)
overrides[key] = targets[i]
updates = append(updates, service.ProductColorMappingUpdate{ShopeeColorKey: key, TargetValue: targets[i]})
}
changed, err := service.SaveProductColorMappings(h.db, currentUser(c), goodsID,
c.PostForm("context_version"), updates)
if err != nil {
status, message := http.StatusInternalServerError, "保存颜色映射失败,数据库没有改动。请稍后重试。"
if service.IsValidationError(err) {
status, message = http.StatusConflict, err.Error()
}
h.renderShopeeColorMappings(c, goodsID, returnTo, overrides, "", message, status)
return
}
values := url.Values{"goods_id": {goodsID}, "return_to": {returnTo},
"msg": {fmt.Sprintf("已保存 %d 条颜色修改", changed)}}
c.Redirect(http.StatusSeeOther, "/shopee/color-mappings?"+values.Encode())
}
func (h *Handler) renderShopeeColorMappings(c *gin.Context, goodsID, returnTo string, overrides map[string]string, message, alert string, status int) {
if goodsID == "" {
fail(c, http.StatusBadRequest, "商品编号不对,请返回蝦皮数据页重新选择。")
return
}
context, err := service.GetProductColorMappingContext(h.db, goodsID)
if err != nil {
fail(c, http.StatusInternalServerError, "读取商品颜色匹配数据失败,数据没有被改动。")
return
}
if context == nil {
fail(c, http.StatusNotFound, "这个蝦皮商品不存在或已删除,请返回列表刷新。")
return
}
view := service.BuildProductColorMappingPageView(context, overrides)
statusText := service.ProductColorMappingStatusLine(view)
if message != "" {
statusText = message + " · " + statusText
}
c.HTML(status, "shopee/color_mappings", page(c, "shopee", "颜色匹配", gin.H{
"V": view, "ReturnTo": safeNext(returnTo), "Message": message, "Alert": alert,
"Status": statusText, "PageScript": "/static/js/color_mappings.js",
})) }))
} }
+2
View File
@@ -63,7 +63,9 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecret
// 1. 蝦皮数据 // 1. 蝦皮数据
pages.GET("/shopee", h.ShopeeList) pages.GET("/shopee", h.ShopeeList)
pages.GET("/shopee/detail", h.ShopeeDetail) // 双击行时前端来取弹窗内容 pages.GET("/shopee/detail", h.ShopeeDetail) // 双击行时前端来取弹窗内容
pages.GET("/shopee/color-mappings", h.ShopeeColorMappings)
pages.POST("/shopee/save", h.ShopeeSave) pages.POST("/shopee/save", h.ShopeeSave)
pages.POST("/shopee/color-mappings/save", h.ShopeeColorMappingsSave)
pages.POST("/shopee/spec/save", h.ShopeeSpecSave) pages.POST("/shopee/spec/save", h.ShopeeSpecSave)
pages.POST("/shopee/collect", h.ShopeeCollect) pages.POST("/shopee/collect", h.ShopeeCollect)
pages.POST("/shopee/collect-batch", h.ShopeeCollectBatch) pages.POST("/shopee/collect-batch", h.ShopeeCollectBatch)
+147
View File
@@ -0,0 +1,147 @@
package service
import (
"fmt"
"sort"
"strings"
)
// ProductColorMappingOption 是原生 select 中的一个可购买 PDD 颜色。
type ProductColorMappingOption struct {
Value, Label string
Selected bool
}
// ProductColorMappingOptionGroup 用 optgroup 表达当前、未使用和已匹配三组候选。
type ProductColorMappingOptionGroup struct {
Label string
Options []ProductColorMappingOption
}
// ProductColorMappingPageRow 是正式页面的一行。
type ProductColorMappingPageRow struct {
ColorKey, ColorRaw, SourceText string
OriginalValue, SelectedValue string
Status, StatusText string
MappingText string
SelectedInvalid bool
Groups []ProductColorMappingOptionGroup
}
// ProductColorMappingPageView 只负责把领域上下文翻译成模板可直接显示的数据。
type ProductColorMappingPageView struct {
ShopeeGoodsID, ShopeeTitle, PddGoodsID, PddTitle string
PddDimensionKey, ContextVersion string
UnavailableReason string
Rows []ProductColorMappingPageRow
MappedCount, ValidCount, InvalidCount int
}
// BuildProductColorMappingPageView 生成分组候选。overrides 只用于 POST 失败后回显,
// 不会改变服务端候选或有效性判断。
func BuildProductColorMappingPageView(context *ProductColorMappingContext, overrides map[string]string) *ProductColorMappingPageView {
view := &ProductColorMappingPageView{
ShopeeGoodsID: context.ShopeeGoodsID, ShopeeTitle: context.ShopeeTitle,
PddGoodsID: context.PddGoodsID, PddTitle: context.PddTitle,
PddDimensionKey: context.PddDimensionKey, ContextVersion: context.ContextVersion,
UnavailableReason: context.UnavailableReason,
}
occupiedBy := map[string][]string{}
for _, row := range context.Rows {
if row.Mapping != nil && row.MappingValid {
occupiedBy[row.Mapping.PddColorValue] = append(occupiedBy[row.Mapping.PddColorValue], row.Color.Raw)
}
}
for value := range occupiedBy {
sort.Strings(occupiedBy[value])
}
candidateByValue := make(map[string]PddColorCandidate, len(context.Candidates))
for _, candidate := range context.Candidates {
candidateByValue[candidate.Value] = candidate
}
for _, sourceRow := range context.Rows {
row := ProductColorMappingPageRow{
ColorKey: sourceRow.Color.Key, ColorRaw: sourceRow.Color.Raw,
SourceText: sourceRow.Color.SourceText(), Status: "pending", StatusText: "待匹配",
}
if sourceRow.Mapping != nil {
row.OriginalValue = sourceRow.Mapping.PddColorValue
row.SelectedValue = sourceRow.Mapping.PddColorValue
row.MappingText = "人工维护"
if strings.TrimSpace(sourceRow.Mapping.MappedBy) != "" {
row.MappingText += " · " + sourceRow.Mapping.MappedBy
}
if sourceRow.MappingValid {
row.Status, row.StatusText = "mapped", "已匹配"
view.ValidCount++
} else {
row.Status, row.StatusText, row.SelectedInvalid = "invalid", "映射已失效", true
view.InvalidCount++
}
view.MappedCount++
}
if selected, ok := overrides[sourceRow.Color.Key]; ok {
row.SelectedValue = strings.TrimSpace(selected)
_, targetExists := candidateByValue[row.SelectedValue]
row.SelectedInvalid = row.SelectedValue != "" && !targetExists
}
row.Groups = buildColorMappingOptionGroups(context.Candidates, occupiedBy, row.SelectedValue)
view.Rows = append(view.Rows, row)
}
return view
}
func buildColorMappingOptionGroups(candidates []PddColorCandidate, occupiedBy map[string][]string, selected string) []ProductColorMappingOptionGroup {
var current, unused, used []ProductColorMappingOption
for _, candidate := range candidates {
label := pddColorCandidateLabel(candidate)
option := ProductColorMappingOption{Value: candidate.Value, Label: label, Selected: candidate.Value == selected}
if candidate.Value == selected {
current = append(current, option)
continue
}
if colors := occupiedBy[candidate.Value]; len(colors) > 0 {
option.Label += "(已匹配:" + strings.Join(colors, " / ") + ")"
used = append(used, option)
} else {
unused = append(unused, option)
}
}
groups := make([]ProductColorMappingOptionGroup, 0, 3)
if len(current) > 0 {
groups = append(groups, ProductColorMappingOptionGroup{Label: "当前选择", Options: current})
}
if len(unused) > 0 {
groups = append(groups, ProductColorMappingOptionGroup{Label: "未使用的颜色", Options: unused})
}
if len(used) > 0 {
groups = append(groups, ProductColorMappingOptionGroup{Label: "已经匹配(仍可选择)", Options: used})
}
return groups
}
func pddColorCandidateLabel(candidate PddColorCandidate) string {
price := "价格未采到"
if candidate.HasPrice {
if candidate.MinPriceCent == candidate.MaxPriceCent {
price = fmt.Sprintf("¥%.2f", float64(candidate.MinPriceCent)/100)
} else {
price = fmt.Sprintf("¥%.2f–%.2f", float64(candidate.MinPriceCent)/100, float64(candidate.MaxPriceCent)/100)
}
}
return fmt.Sprintf("%s · %s · %d 个可购买规格", candidate.Value, price, candidate.SKUCount)
}
// ProductColorMappingStatusLine 返回页面底部的短状态。
func ProductColorMappingStatusLine(view *ProductColorMappingPageView) string {
if view.UnavailableReason != "" {
return view.UnavailableReason
}
return fmt.Sprintf("共 %d 个蝦皮颜色 · 已匹配 %d · 待匹配 %d · 已失效 %d",
len(view.Rows), view.ValidCount, len(view.Rows)-view.MappedCount, view.InvalidCount)
}
// ProductColorMappingHasChanges 供测试明确局部保存的比较规则。
func ProductColorMappingHasChanges(row ProductColorMappingPageRow) bool {
return row.OriginalValue != row.SelectedValue
}
+24
View File
@@ -42,6 +42,30 @@ func TestAggregatePddColorCandidates_拒绝多个显式颜色维度(t *testing.T
} }
} }
func TestBuildProductColorMappingPageView_未使用优先且已使用仍可多对一(t *testing.T) {
context := &ProductColorMappingContext{
ShopeeGoodsID: "S-1", PddGoodsID: "P-1", PddDimensionKey: "color", ContextVersion: "ctx",
Candidates: []PddColorCandidate{
{DimensionKey: "color", Value: "曜石黑", HasPrice: true, MinPriceCent: 1180, MaxPriceCent: 1280, SKUCount: 2},
{DimensionKey: "color", Value: "奶油白", SKUCount: 1},
},
Rows: []ProductColorMappingRow{
{Color: ProductColorSource{Key: "黑色", Raw: "黑色", FormalCount: 1}, MappingValid: true,
Mapping: &model.ProductColorMapping{PddColorValue: "曜石黑", PddDimensionKey: "color", MappedBy: "U-1"}},
{Color: ProductColorSource{Key: "深黑", Raw: "深黑", SybCount: 2}},
},
}
view := BuildProductColorMappingPageView(context, map[string]string{"深黑": "曜石黑"})
if view.ValidCount != 1 || len(view.Rows) != 2 || !ProductColorMappingHasChanges(view.Rows[1]) {
t.Fatalf("页面汇总或失败回显不正确:%+v", view)
}
groups := view.Rows[1].Groups
if len(groups) != 2 || groups[0].Label != "当前选择" || groups[0].Options[0].Value != "曜石黑" ||
groups[1].Label != "未使用的颜色" || groups[1].Options[0].Value != "奶油白" {
t.Fatalf("候选分组错误:%+v", groups)
}
}
func TestSaveProductColorMappings_保存审计并用上下文版本防过期(t *testing.T) { func TestSaveProductColorMappings_保存审计并用上下文版本防过期(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
seedShopeeProduct(t, db, "S-COLOR", "颜色映射商品") seedShopeeProduct(t, db, "S-COLOR", "颜色映射商品")
+84
View File
@@ -682,6 +682,90 @@ input.wide { width: 100%; }
.inner-code-toolbar .inner-code-filter-form { flex-basis: 100%; } .inner-code-toolbar .inner-code-filter-form { flex-basis: 100%; }
} }
/* ── 商品级颜色匹配 ─────────────────────
独立页面沿用 Admin 的紧凑表格视觉;规则限定在本页,不改变其他主列表。 */
.color-mapping-toolbar { flex-wrap: nowrap; }
.color-mapping-toolbar h1 {
margin: 0 8px 0 0;
font-size: 18px;
line-height: 30px;
}
.color-mapping-toolbar .color-mapping-change-count {
margin-left: auto;
color: #667085;
white-space: nowrap;
}
.feedback,
.guard-note {
margin: 0 0 10px;
padding: 8px 10px;
border: 1px solid #b8d4fa;
border-left: 4px solid #1f6feb;
border-radius: 4px;
color: #174ea6;
background: #eef6ff;
}
.feedback.success { color: #176b36; border-color: #a7d8b7; border-left-color: #176b36; background: #effaf2; }
.feedback.error { color: #8f1d14; border-color: #efb2ac; border-left-color: #b42318; background: #fff5f4; }
.guard-note { color: #5c4400; border-color: #f0d88b; border-left-color: #c58a00; background: #fffbe6; }
.color-mapping-summary {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 180px;
gap: 10px;
margin-bottom: 10px;
}
.color-mapping-summary-card {
min-width: 0;
padding: 10px 12px;
border: 1px solid #e1e4e8;
border-radius: 4px;
background: #fff;
}
.color-mapping-summary-card > span,
.color-mapping-summary-card > small { display: block; color: #667085; font-size: 12px; }
.color-mapping-summary-card > strong {
display: block;
margin: 4px 0 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.color-mapping-progress { display: grid; place-content: center; text-align: center; }
.color-mapping-progress > strong { color: #174ea6; font-size: 22px; font-variant-numeric: tabular-nums; }
.color-mapping-table-wrap {
min-height: 240px;
max-height: calc(100vh - 330px);
overflow: auto;
scrollbar-gutter: stable;
}
.color-mapping-table { min-width: 1050px; }
.color-mapping-table th { position: sticky; top: 0; z-index: 1; }
.color-mapping-table td { vertical-align: middle; }
.color-mapping-table td:first-child { min-width: 170px; }
.color-mapping-table td:nth-child(2) { min-width: 140px; }
.color-mapping-table td:nth-child(4) { min-width: 390px; }
.color-mapping-table td:nth-child(5) { min-width: 190px; }
.color-mapping-key,
.color-mapping-source { display: block; margin-top: 4px; color: #667085; font-size: 12px; }
.color-mapping-arrow { width: 44px; color: #8a94a1; text-align: center; font-size: 18px; }
.color-mapping-select { width: 100%; min-width: 360px; }
.color-mapping-target-label { display: block; color: #667085; font-size: 12px; }
.color-mapping-target-label .color-mapping-select { display: block; margin-top: 4px; color: #222; font-size: 14px; }
.status-mapped { color: #176b36; background: #effaf2; }
.status-pending { color: #57606a; background: #f3f4f6; }
.status-invalid { color: #b42318; background: #fff1f0; }
.status-changed { color: #0969da; background: #eef6ff; }
.color-mapping-table tr.row-changed { background: #eef6ff; }
.color-mapping-table tr.row-changed:hover { background: #e2f0ff; }
.color-mapping-empty-filter { text-align: center; }
@media (max-width: 800px) {
.color-mapping-toolbar { flex-wrap: wrap; }
.color-mapping-toolbar .color-mapping-change-count { margin-left: 0; }
.color-mapping-summary { grid-template-columns: 1fr; }
.color-mapping-table-wrap { max-height: none; }
}
select { select {
padding: 5px 8px; padding: 5px 8px;
border: 1px solid #ccd1d6; border: 1px solid #ccd1d6;
+181
View File
@@ -0,0 +1,181 @@
/* 商品级颜色匹配页面的轻量交互。
* 业务身份、可购买性和保存校验全部在服务端;这里仅分组候选、收集变更和防重复提交。
*/
(function () {
"use strict";
var form = document.querySelector("[data-color-mapping-form]");
if (!form) return;
var selects = Array.prototype.slice.call(document.querySelectorAll("[data-color-mapping-select]"));
var saveButton = document.querySelector("[data-color-mapping-save]");
var changeCount = document.querySelector("[data-color-mapping-change-count]");
var filter = document.querySelector("[data-color-mapping-filter]");
var emptyFilter = document.querySelector("[data-color-mapping-empty-filter]");
var unavailable = form.getAttribute("data-unavailable") === "1";
var submitting = false;
function candidatesFor(select) {
var seen = {};
return Array.prototype.slice.call(select.querySelectorAll("option[data-candidate]")).map(function (option) {
return { value: option.value, label: option.getAttribute("data-base-label") || option.textContent };
}).filter(function (candidate) {
if (seen[candidate.value]) return false;
seen[candidate.value] = true;
return true;
});
}
selects.forEach(function (select) {
select._colorCandidates = candidatesFor(select);
});
function appendGroup(select, label, candidates, selected, occupancy, showOccupants) {
if (!candidates.length) return;
var group = document.createElement("optgroup");
group.label = label;
candidates.forEach(function (candidate) {
var option = document.createElement("option");
option.value = candidate.value;
option.textContent = candidate.label;
option.setAttribute("data-candidate", "");
option.setAttribute("data-base-label", candidate.label);
if (showOccupants && occupancy[candidate.value] && occupancy[candidate.value].length) {
option.textContent += "(已匹配:" + occupancy[candidate.value].join(" / ") + ")";
}
option.selected = candidate.value === selected;
group.appendChild(option);
});
select.appendChild(group);
}
function rebuildGroups() {
var occupancy = {};
selects.forEach(function (select) {
if (!select.value) return;
occupancy[select.value] = occupancy[select.value] || [];
occupancy[select.value].push(select.getAttribute("data-color-raw") || select.getAttribute("data-color-key"));
});
Object.keys(occupancy).forEach(function (value) { occupancy[value].sort(); });
selects.forEach(function (select) {
var selected = select.value;
var candidates = select._colorCandidates || [];
var validSelected = candidates.some(function (candidate) { return candidate.value === selected; });
var current = candidates.filter(function (candidate) { return candidate.value === selected; });
var unused = candidates.filter(function (candidate) { return candidate.value !== selected && !occupancy[candidate.value]; });
var used = candidates.filter(function (candidate) { return candidate.value !== selected && occupancy[candidate.value]; });
select.textContent = "";
var placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "请选择 PDD 颜色";
placeholder.selected = selected === "";
select.appendChild(placeholder);
if (selected && !validSelected) {
var stale = document.createElement("option");
stale.value = selected;
stale.textContent = "原选择已不可用:" + selected;
stale.selected = true;
stale.disabled = true;
select.appendChild(stale);
}
appendGroup(select, "当前选择", current, selected, occupancy, false);
appendGroup(select, "未使用的颜色", unused, selected, occupancy, false);
appendGroup(select, "已经匹配(仍可选择)", used, selected, occupancy, true);
});
}
function dirtySelects() {
return selects.filter(function (select) {
return select.value !== (select.getAttribute("data-original-value") || "");
});
}
function updateState() {
var dirty = dirtySelects();
selects.forEach(function (select) {
var row = select.closest("[data-color-mapping-row]");
var changed = dirty.indexOf(select) !== -1;
if (row) row.classList.toggle("row-changed", changed);
var status = row && row.querySelector("[data-color-mapping-status]");
if (status && changed) {
status.textContent = "待保存";
status.className = "status-pill status-changed";
} else if (status && row) {
var saved = row.getAttribute("data-saved-status") || "pending";
status.className = "status-pill status-" + saved;
status.textContent = saved === "mapped" ? "已匹配" : (saved === "invalid" ? "映射已失效" : "待匹配");
}
var clear = row && row.querySelector("[data-color-mapping-clear]");
if (clear) clear.disabled = unavailable || select.value === "";
});
if (changeCount) changeCount.textContent = dirty.length ? "已修改 " + dirty.length + " 条,尚未保存" : "尚未修改";
if (saveButton) saveButton.disabled = unavailable || submitting || dirty.length === 0;
applyFilter();
}
function applyFilter() {
var value = filter ? filter.value : "all";
var shown = 0;
document.querySelectorAll("[data-color-mapping-row]").forEach(function (row) {
var visible = value === "all" || row.getAttribute("data-saved-status") === value;
row.hidden = !visible;
if (visible) shown += 1;
});
if (emptyFilter) emptyFilter.hidden = shown !== 0;
}
selects.forEach(function (select) {
select.addEventListener("change", function () {
rebuildGroups();
updateState();
});
});
document.querySelectorAll("[data-color-mapping-clear]").forEach(function (button) {
button.addEventListener("click", function () {
var row = button.closest("[data-color-mapping-row]");
var select = row && row.querySelector("[data-color-mapping-select]");
if (!select) return;
select.value = "";
rebuildGroups();
updateState();
select.focus();
});
});
if (filter) filter.addEventListener("change", applyFilter);
form.addEventListener("submit", function (event) {
var dirty = dirtySelects();
if (!dirty.length || submitting) {
event.preventDefault();
return;
}
form.querySelectorAll("[data-color-mapping-generated]").forEach(function (input) { input.remove(); });
dirty.forEach(function (select) {
[["color_key", select.getAttribute("data-color-key") || ""], ["target_value", select.value]].forEach(function (pair) {
var input = document.createElement("input");
input.type = "hidden";
input.name = pair[0];
input.value = pair[1];
input.setAttribute("data-color-mapping-generated", "");
form.appendChild(input);
});
});
submitting = true;
if (saveButton) {
saveButton.disabled = true;
saveButton.textContent = "保存中…";
}
});
window.addEventListener("beforeunload", function (event) {
if (submitting || dirtySelects().length === 0) return;
event.preventDefault();
event.returnValue = "";
});
rebuildGroups();
updateState();
var alertBox = document.querySelector("[data-color-mapping-alert]");
if (alertBox) alertBox.focus();
}());
+1
View File
@@ -46,6 +46,7 @@
</footer> </footer>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
{{if .PageScript}}<script src="{{.PageScript}}"></script>{{end}}
</body> </body>
</html> </html>
{{end}} {{end}}
+104
View File
@@ -0,0 +1,104 @@
{{define "shopee/color_mappings"}}
{{template "header" .}}
<form id="color-mapping-form" method="post" action="/shopee/color-mappings/save"
data-color-mapping-form data-unavailable="{{if .V.UnavailableReason}}1{{end}}">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="goods_id" value="{{.V.ShopeeGoodsID}}">
<input type="hidden" name="context_version" value="{{.V.ContextVersion}}">
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
</form>
<div class="toolbar color-mapping-toolbar">
<a class="button-link" href="{{.ReturnTo}}">返回蝦皮数据</a>
<h1>商品颜色匹配</h1>
<label for="color-mapping-filter">显示</label>
<select id="color-mapping-filter" data-color-mapping-filter>
<option value="all">全部颜色</option>
<option value="pending">待匹配</option>
<option value="invalid">映射已失效</option>
<option value="mapped">已匹配</option>
</select>
<span class="color-mapping-change-count" data-color-mapping-change-count role="status">尚未修改</span>
<button type="submit" class="primary" form="color-mapping-form" data-color-mapping-save disabled>保存修改</button>
</div>
{{if .Message}}<p class="feedback success" role="status"><strong>保存成功</strong>{{.Message}}</p>{{end}}
{{if .Alert}}<p class="feedback error" role="alert" tabindex="-1" data-color-mapping-alert><strong>未保存</strong>{{.Alert}} 你的选择仍保留在页面中。</p>{{end}}
<section class="color-mapping-summary" aria-label="当前商品摘要">
<div class="color-mapping-summary-card">
<span>蝦皮商品</span>
<strong title="{{.V.ShopeeTitle}}">{{.V.ShopeeTitle}}</strong>
<small>商品 ID:{{.V.ShopeeGoodsID}}</small>
</div>
<div class="color-mapping-summary-card">
<span>PDD 商品</span>
<strong title="{{.V.PddTitle}}">{{if .V.PddGoodsID}}{{if .V.PddTitle}}{{.V.PddTitle}}{{else}}—{{end}}{{else}}未关联{{end}}</strong>
<small>商品 ID:{{if .V.PddGoodsID}}{{.V.PddGoodsID}}{{else}}—{{end}}{{if .V.PddDimensionKey}} · 颜色维度:{{.V.PddDimensionKey}}{{end}}</small>
</div>
<div class="color-mapping-summary-card color-mapping-progress">
<strong>{{.V.ValidCount}} / {{len .V.Rows}}</strong>
<small>当前有效映射</small>
</div>
</section>
{{if .V.UnavailableReason}}
<p class="feedback error" role="alert"><strong>暂时不能维护颜色映射</strong>{{.V.UnavailableReason}}。请返回蝦皮数据页处理 PDD 关联或采集后再试。</p>
{{else}}
<p class="guard-note">这里只维护商品颜色关系,不修改蝦皮或 PDD 的原始规格。颜色全部匹配也不等于可以采购;系统仍会按当前可购买的完整颜色、尺码组合生成采购规格。</p>
{{end}}
<div class="table-wrap color-mapping-table-wrap">
<table class="color-mapping-table">
<thead>
<tr>
<th>蝦皮颜色</th>
<th>颜色来源</th>
<th class="color-mapping-arrow" aria-label="映射到"></th>
<th>PDD 可购买颜色</th>
<th>状态 / 维护来源</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{{range .V.Rows}}
<tr data-color-mapping-row data-saved-status="{{.Status}}">
<td>
<strong>{{.ColorRaw}}</strong>
<small class="color-mapping-key">身份键:{{.ColorKey}}</small>
</td>
<td>{{if .SourceText}}{{.SourceText}}{{else}}—{{end}}</td>
<td class="color-mapping-arrow" aria-hidden="true">→</td>
<td>
<label class="color-mapping-target-label">为“{{.ColorRaw}}”选择 PDD 颜色
<select class="color-mapping-select"
data-color-mapping-select data-color-key="{{.ColorKey}}" data-color-raw="{{.ColorRaw}}"
data-original-value="{{.OriginalValue}}" {{if $.V.UnavailableReason}}disabled{{end}}>
<option value=""{{if not .SelectedValue}} selected{{end}}>请选择 PDD 颜色</option>
{{if .SelectedInvalid}}<option value="{{.SelectedValue}}" selected disabled>原选择已不可用:{{.SelectedValue}}</option>{{end}}
{{range .Groups}}
<optgroup label="{{.Label}}">
{{range .Options}}<option value="{{.Value}}" data-candidate data-base-label="{{.Label}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</optgroup>
{{end}}
</select>
</label>
</td>
<td>
<span class="status-pill status-{{.Status}}" data-color-mapping-status>{{.StatusText}}</span>
<small class="color-mapping-source">{{if .MappingText}}{{.MappingText}}{{else}}—{{end}}</small>
</td>
<td><button type="button" class="link-button" data-color-mapping-clear {{if or $.V.UnavailableReason (not .SelectedValue)}}disabled{{end}}>清除</button></td>
</tr>
{{else}}
<tr class="empty"><td colspan="6">没有可用于匹配的蝦皮颜色。请先补全正式 SKU 颜色,或等待顺运宝同步到可确定解析的颜色规格。</td></tr>
{{end}}
</tbody>
</table>
</div>
<p class="hint color-mapping-empty-filter" data-color-mapping-empty-filter hidden>当前筛选下没有颜色。</p>
{{template "footer" .}}
{{end}}
+2
View File
@@ -18,6 +18,8 @@
<dt>采集状态</dt><dd>{{.StatusText}}{{if .CollectMsg}}:{{.CollectMsg}}{{end}}</dd> <dt>采集状态</dt><dd>{{.StatusText}}{{if .CollectMsg}}:{{.CollectMsg}}{{end}}</dd>
</dl> </dl>
<p><a class="button-link primary" href="{{$.ColorMappingURL}}">进入颜色匹配</a></p>
<form method="post" action="/shopee/save" class="form-stack"> <form method="post" action="/shopee/save" class="form-stack">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="shopee_goods_id" value="{{.GoodsID}}"> <input type="hidden" name="shopee_goods_id" value="{{.GoodsID}}">
+3 -1
View File
@@ -82,6 +82,7 @@
<th>来源</th> <th>来源</th>
<th>PDD 商品 ID</th> <th>PDD 商品 ID</th>
<th>采集状态</th> <th>采集状态</th>
<th>颜色匹配</th>
<th>更新时间</th> <th>更新时间</th>
</tr> </tr>
</thead> </thead>
@@ -103,11 +104,12 @@
<td>{{.SourceText}}</td> <td>{{.SourceText}}</td>
<td{{if .PddMissing}} class="missing"{{end}}>{{if .PddMissing}}未填写{{else}}<span title="{{.PddURL}}">{{.PddGoodsID}}</span>{{end}}</td> <td{{if .PddMissing}} class="missing"{{end}}>{{if .PddMissing}}未填写{{else}}<span title="{{.PddURL}}">{{.PddGoodsID}}</span>{{end}}</td>
<td>{{.StatusText}}</td> <td>{{.StatusText}}</td>
<td><a href="/shopee/color-mappings?goods_id={{.GoodsID}}&return_to={{$.ReturnToEscaped}}">进入匹配</a></td>
<td>{{.UpdatedAt}}</td> <td>{{.UpdatedAt}}</td>
</tr> </tr>
{{else}} {{else}}
<tr class="empty"> <tr class="empty">
<td colspan="13"> <td colspan="14">
{{if .IsFiltered}} {{if .IsFiltered}}
当前条件下没有商品。请调整状态、分类或关键词。<br> 当前条件下没有商品。请调整状态、分类或关键词。<br>
<small><a href="/shopee?page_size={{.CurrentPageSize}}">清除筛选条件</a></small> <small><a href="/shopee?page_size={{.CurrentPageSize}}">清除筛选条件</a></small>
+19
View File
@@ -956,6 +956,25 @@ API Key 使用密码输入框,只允许替换或清除。已保存值显示固
映射失效时追加“已失效”。“AI规格匹配”筛选只命中当前有效、来源为 AI、且仍处于采购就绪 映射失效时追加“已失效”。“AI规格匹配”筛选只命中当前有效、来源为 AI、且仍处于采购就绪
阶段的明细,后续进入采购任务阶段后不再命中,但行和详情中的来源标记继续显示。 阶段的明细,后续进入采购任务阶段后不再命中,但行和详情中的来源标记继续显示。
### 8.6 蝦皮与 PDD 商品颜色匹配
蝦皮商品列表和详情提供“颜色匹配”入口,打开独立服务端渲染页面,不增加顶部模块,也不
塞入已有详情长弹窗。返回链接必须保留列表筛选、页码和详情打开状态。
页面显示蝦皮/PDD 商品摘要、当前有效映射进度和一行一个蝦皮颜色的映射表。PDD 使用原生
`select/optgroup`:当前选择在前,其次是未使用颜色,最后是已经被其他蝦皮颜色使用的颜色;
已使用项显示占用者但仍允许选择,以支持多对一。候选附人民币价格区间和当前可购买完整
规格数,金额只能从服务端整数分格式化。
页面只有一个主操作“保存修改”。浏览器只提交变化行,并支持明确清除、保存中防重复提交、
未保存离开提醒和失败后保留选择;浏览器不计算颜色身份、可购买性或完整采购规格。服务端
必须在同一事务中重算上下文并重验每个目标,过期提交整批拒绝。
未关联、PDD 删除、未采集、无可购买规格、颜色维度不唯一和映射目标消失都使用文字说明
原因与下一步。页面必须明确:“颜色映射不修改原始规格,颜色完成不等于完整规格可采购”。
状态不能只靠颜色,动态变更计数使用 `role=status`,保存错误使用 `role=alert`;表格容器在
1366×768 内独立滚动,原生控件保持可见焦点和顺序一致的键盘操作。
## 9. 反馈方式 ## 9. 反馈方式
| 场景 | 怎么反馈 | | 场景 | 怎么反馈 |