feat: 增加商品颜色匹配页面 (#294)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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", "颜色映射商品")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}());
|
||||
@@ -46,6 +46,7 @@
|
||||
</footer>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
{{if .PageScript}}<script src="{{.PageScript}}"></script>{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -18,6 +18,8 @@
|
||||
<dt>采集状态</dt><dd>{{.StatusText}}{{if .CollectMsg}}:{{.CollectMsg}}{{end}}</dd>
|
||||
</dl>
|
||||
|
||||
<p><a class="button-link primary" href="{{$.ColorMappingURL}}">进入颜色匹配</a></p>
|
||||
|
||||
<form method="post" action="/shopee/save" class="form-stack">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="shopee_goods_id" value="{{.GoodsID}}">
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
<th>来源</th>
|
||||
<th>PDD 商品 ID</th>
|
||||
<th>采集状态</th>
|
||||
<th>颜色匹配</th>
|
||||
<th>更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -103,11 +104,12 @@
|
||||
<td>{{.SourceText}}</td>
|
||||
<td{{if .PddMissing}} class="missing"{{end}}>{{if .PddMissing}}未填写{{else}}<span title="{{.PddURL}}">{{.PddGoodsID}}</span>{{end}}</td>
|
||||
<td>{{.StatusText}}</td>
|
||||
<td><a href="/shopee/color-mappings?goods_id={{.GoodsID}}&return_to={{$.ReturnToEscaped}}">进入匹配</a></td>
|
||||
<td>{{.UpdatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr class="empty">
|
||||
<td colspan="13">
|
||||
<td colspan="14">
|
||||
{{if .IsFiltered}}
|
||||
当前条件下没有商品。请调整状态、分类或关键词。<br>
|
||||
<small><a href="/shopee?page_size={{.CurrentPageSize}}">清除筛选条件</a></small>
|
||||
|
||||
@@ -956,6 +956,25 @@ API Key 使用密码输入框,只允许替换或清除。已保存值显示固
|
||||
映射失效时追加“已失效”。“AI规格匹配”筛选只命中当前有效、来源为 AI、且仍处于采购就绪
|
||||
阶段的明细,后续进入采购任务阶段后不再命中,但行和详情中的来源标记继续显示。
|
||||
|
||||
### 8.6 蝦皮与 PDD 商品颜色匹配
|
||||
|
||||
蝦皮商品列表和详情提供“颜色匹配”入口,打开独立服务端渲染页面,不增加顶部模块,也不
|
||||
塞入已有详情长弹窗。返回链接必须保留列表筛选、页码和详情打开状态。
|
||||
|
||||
页面显示蝦皮/PDD 商品摘要、当前有效映射进度和一行一个蝦皮颜色的映射表。PDD 使用原生
|
||||
`select/optgroup`:当前选择在前,其次是未使用颜色,最后是已经被其他蝦皮颜色使用的颜色;
|
||||
已使用项显示占用者但仍允许选择,以支持多对一。候选附人民币价格区间和当前可购买完整
|
||||
规格数,金额只能从服务端整数分格式化。
|
||||
|
||||
页面只有一个主操作“保存修改”。浏览器只提交变化行,并支持明确清除、保存中防重复提交、
|
||||
未保存离开提醒和失败后保留选择;浏览器不计算颜色身份、可购买性或完整采购规格。服务端
|
||||
必须在同一事务中重算上下文并重验每个目标,过期提交整批拒绝。
|
||||
|
||||
未关联、PDD 删除、未采集、无可购买规格、颜色维度不唯一和映射目标消失都使用文字说明
|
||||
原因与下一步。页面必须明确:“颜色映射不修改原始规格,颜色完成不等于完整规格可采购”。
|
||||
状态不能只靠颜色,动态变更计数使用 `role=status`,保存错误使用 `role=alert`;表格容器在
|
||||
1366×768 内独立滚动,原生控件保持可见焦点和顺序一致的键盘操作。
|
||||
|
||||
## 9. 反馈方式
|
||||
|
||||
| 场景 | 怎么反馈 |
|
||||
|
||||
Reference in New Issue
Block a user