From 8162ae5aca99060b66909bccce5ec7349c9b2f0d Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 24 Aug 2026 00:04:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=95=86=E5=93=81?= =?UTF-8?q?=E9=A2=9C=E8=89=B2=E5=8C=B9=E9=85=8D=E9=A1=B5=E9=9D=A2=20(#294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/color_mapping_template_test.go | 47 ++++++ admin/handler/web/shopee.go | 83 ++++++++++ admin/handler/web/web.go | 2 + admin/service/color_mapping_page.go | 147 +++++++++++++++++ admin/service/color_mapping_test.go | 24 +++ admin/static/css/app.css | 84 ++++++++++ admin/static/js/color_mappings.js | 181 +++++++++++++++++++++ admin/templates/partials/footer.html | 1 + admin/templates/shopee/color_mappings.html | 104 ++++++++++++ admin/templates/shopee/detail_modal.html | 2 + admin/templates/shopee/list.html | 4 +- docs/admin/05-ui-specification.md | 19 +++ 12 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 admin/color_mapping_template_test.go create mode 100644 admin/service/color_mapping_page.go create mode 100644 admin/static/js/color_mappings.js create mode 100644 admin/templates/shopee/color_mappings.html diff --git a/admin/color_mapping_template_test.go b/admin/color_mapping_template_test.go new file mode 100644 index 0000000..63665d5 --- /dev/null +++ b/admin/color_mapping_template_test.go @@ -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"`, ``, `data-color-key="黑色"`, + `/static/js/color_mappings.js`, `这里只维护商品颜色关系`, + } { + if !strings.Contains(html, want) { + t.Errorf("页面缺少 %q", want) + } + } +} diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go index cc243c8..3924a81 100644 --- a/admin/handler/web/shopee.go +++ b/admin/handler/web/shopee.go @@ -45,6 +45,25 @@ func (h *Handler) ShopeeDetail(c *gin.Context) { 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{ "D": detail, "CSRFToken": csrfToken(c), "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", "CurrentPage": service.ParsePage(c.Query("page")), "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), "AutoOpenDetailID": strings.TrimSpace(c.Query("open_id")), "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", })) } diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index b1c54c1..bbe3c64 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -63,7 +63,9 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecret // 1. 蝦皮数据 pages.GET("/shopee", h.ShopeeList) pages.GET("/shopee/detail", h.ShopeeDetail) // 双击行时前端来取弹窗内容 + pages.GET("/shopee/color-mappings", h.ShopeeColorMappings) pages.POST("/shopee/save", h.ShopeeSave) + pages.POST("/shopee/color-mappings/save", h.ShopeeColorMappingsSave) pages.POST("/shopee/spec/save", h.ShopeeSpecSave) pages.POST("/shopee/collect", h.ShopeeCollect) pages.POST("/shopee/collect-batch", h.ShopeeCollectBatch) diff --git a/admin/service/color_mapping_page.go b/admin/service/color_mapping_page.go new file mode 100644 index 0000000..95ee4b8 --- /dev/null +++ b/admin/service/color_mapping_page.go @@ -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 +} diff --git a/admin/service/color_mapping_test.go b/admin/service/color_mapping_test.go index f300bdc..4da8a44 100644 --- a/admin/service/color_mapping_test.go +++ b/admin/service/color_mapping_test.go @@ -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) { db := newTestDB(t) seedShopeeProduct(t, db, "S-COLOR", "颜色映射商品") diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 8eea464..f0fcebf 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -682,6 +682,90 @@ input.wide { width: 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 { padding: 5px 8px; border: 1px solid #ccd1d6; diff --git a/admin/static/js/color_mappings.js b/admin/static/js/color_mappings.js new file mode 100644 index 0000000..28af0d6 --- /dev/null +++ b/admin/static/js/color_mappings.js @@ -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(); +}()); diff --git a/admin/templates/partials/footer.html b/admin/templates/partials/footer.html index 1908348..922e903 100644 --- a/admin/templates/partials/footer.html +++ b/admin/templates/partials/footer.html @@ -46,6 +46,7 @@ +{{if .PageScript}}{{end}} {{end}} diff --git a/admin/templates/shopee/color_mappings.html b/admin/templates/shopee/color_mappings.html new file mode 100644 index 0000000..d8594a7 --- /dev/null +++ b/admin/templates/shopee/color_mappings.html @@ -0,0 +1,104 @@ +{{define "shopee/color_mappings"}} +{{template "header" .}} + +
+ + + + +
+ +
+ 返回蝦皮数据 +

商品颜色匹配

+ + + 尚未修改 + +
+ +{{if .Message}}{{end}} +{{if .Alert}}{{end}} + +
+
+ 蝦皮商品 + {{.V.ShopeeTitle}} + 商品 ID:{{.V.ShopeeGoodsID}} +
+
+ PDD 商品 + {{if .V.PddGoodsID}}{{if .V.PddTitle}}{{.V.PddTitle}}{{else}}—{{end}}{{else}}未关联{{end}} + 商品 ID:{{if .V.PddGoodsID}}{{.V.PddGoodsID}}{{else}}—{{end}}{{if .V.PddDimensionKey}} · 颜色维度:{{.V.PddDimensionKey}}{{end}} +
+
+ {{.V.ValidCount}} / {{len .V.Rows}} + 当前有效映射 +
+
+ +{{if .V.UnavailableReason}} + +{{else}} +

这里只维护商品颜色关系,不修改蝦皮或 PDD 的原始规格。颜色全部匹配也不等于可以采购;系统仍会按当前可购买的完整颜色、尺码组合生成采购规格。

+{{end}} + +
+ + + + + + + + + + + + + {{range .V.Rows}} + + + + + + + + + {{else}} + + {{end}} + +
蝦皮颜色颜色来源PDD 可购买颜色状态 / 维护来源操作
+ {{.ColorRaw}} + 身份键:{{.ColorKey}} + {{if .SourceText}}{{.SourceText}}{{else}}—{{end}} + + + {{.StatusText}} + {{if .MappingText}}{{.MappingText}}{{else}}—{{end}} +
没有可用于匹配的蝦皮颜色。请先补全正式 SKU 颜色,或等待顺运宝同步到可确定解析的颜色规格。
+
+ + + +{{template "footer" .}} +{{end}} diff --git a/admin/templates/shopee/detail_modal.html b/admin/templates/shopee/detail_modal.html index 87aecc8..91ab938 100644 --- a/admin/templates/shopee/detail_modal.html +++ b/admin/templates/shopee/detail_modal.html @@ -18,6 +18,8 @@
采集状态
{{.StatusText}}{{if .CollectMsg}}:{{.CollectMsg}}{{end}}
+

进入颜色匹配

+
diff --git a/admin/templates/shopee/list.html b/admin/templates/shopee/list.html index 992eafc..68c7d57 100644 --- a/admin/templates/shopee/list.html +++ b/admin/templates/shopee/list.html @@ -82,6 +82,7 @@ 来源 PDD 商品 ID 采集状态 + 颜色匹配 更新时间 @@ -103,11 +104,12 @@ {{.SourceText}} {{if .PddMissing}}未填写{{else}}{{.PddGoodsID}}{{end}} {{.StatusText}} + 进入匹配 {{.UpdatedAt}} {{else}} - + {{if .IsFiltered}} 当前条件下没有商品。请调整状态、分类或关键词。
清除筛选条件 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 826e2a8..3bf3899 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -956,6 +956,25 @@ API Key 使用密码输入框,只允许替换或清除。已保存值显示固 映射失效时追加“已失效”。“AI规格匹配”筛选只命中当前有效、来源为 AI、且仍处于采购就绪 阶段的明细,后续进入采购任务阶段后不再命中,但行和详情中的来源标记继续显示。 +### 8.6 蝦皮与 PDD 商品颜色匹配 + +蝦皮商品列表和详情提供“颜色匹配”入口,打开独立服务端渲染页面,不增加顶部模块,也不 +塞入已有详情长弹窗。返回链接必须保留列表筛选、页码和详情打开状态。 + +页面显示蝦皮/PDD 商品摘要、当前有效映射进度和一行一个蝦皮颜色的映射表。PDD 使用原生 +`select/optgroup`:当前选择在前,其次是未使用颜色,最后是已经被其他蝦皮颜色使用的颜色; +已使用项显示占用者但仍允许选择,以支持多对一。候选附人民币价格区间和当前可购买完整 +规格数,金额只能从服务端整数分格式化。 + +页面只有一个主操作“保存修改”。浏览器只提交变化行,并支持明确清除、保存中防重复提交、 +未保存离开提醒和失败后保留选择;浏览器不计算颜色身份、可购买性或完整采购规格。服务端 +必须在同一事务中重算上下文并重验每个目标,过期提交整批拒绝。 + +未关联、PDD 删除、未采集、无可购买规格、颜色维度不唯一和映射目标消失都使用文字说明 +原因与下一步。页面必须明确:“颜色映射不修改原始规格,颜色完成不等于完整规格可采购”。 +状态不能只靠颜色,动态变更计数使用 `role=status`,保存错误使用 `role=alert`;表格容器在 +1366×768 内独立滚动,原生控件保持可见焦点和顺序一致的键盘操作。 + ## 9. 反馈方式 | 场景 | 怎么反馈 |