package web import ( "fmt" "net/http" "net/url" "strconv" "strings" "time" "github.com/gin-gonic/gin" "cmautobuy/admin/repository" "cmautobuy/admin/service" ) // ShopeeList 渲染蝦皮数据列表页。 // // **商品级一行**(不是 SKU 级),数据来自 shopee_products 和 shopee_skus // 聚合联查。列定义见 docs/admin/05-ui-specification.md §4.2、工单 #41。 func (h *Handler) ShopeeList(c *gin.Context) { h.renderShopeeList(c, c.Query("q"), c.Query("search_field"), c.Query("status"), c.Query("deleted"), c.Query("unlinked"), c.Query("page"), c.Query("msg")) } // ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。 // // 复用 #18 的弹窗机制:行上写 data-detail-id="{{.GoodsID}}", // 前端拿 goods_id 拼到 data-detail-url 后面,以 "id" 作为查询参数名 // (固定写法,见 static/js/app.js「双击行打开详情弹窗」)。 func (h *Handler) ShopeeDetail(c *gin.Context) { goodsID := strings.TrimSpace(c.Query("id")) if goodsID == "" { fail(c, http.StatusBadRequest, "商品编号不对,请刷新页面后重试。") return } detail, err := service.GetShopeeProductDetail(h.db, goodsID) if err != nil { fail(c, http.StatusInternalServerError, "读取商品详情失败,数据没有被改动。") return } if detail == nil { fail(c, http.StatusNotFound, "这个商品不存在,请刷新页面。") 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")), "StatusFilter": service.ParseShopeeStatus(c.Query("status")), "DeletedFilter": currentUser(c).IsAdmin() && c.Query("deleted") == "1", "UnlinkedFilter": c.Query("unlinked") == "1", "CurrentPage": service.ParsePage(c.Query("page")), "CurrentPageSize": service.ParsePageSize(c.Query("page_size")), "ColorMappingURL": "/shopee/color-mappings?" + colorValues.Encode(), }) } // renderShopeeList 统一渲染蝦皮列表;数据写入改由商品目录接口负责。 // // `[必须]` 分页控件用 纯 GET 导航,翻页要保留状态、分类、关键词和内部范围, // 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #43、#213)。 func (h *Handler) renderShopeeList(c *gin.Context, keyword, searchFieldRaw, statusRaw, deletedRaw, unlinkedRaw, pageRaw, msg string) { filter := repository.ShopeeFilter{ Keyword: strings.TrimSpace(keyword), SearchField: service.ParseShopeeSearchField(searchFieldRaw), Status: service.ParseShopeeStatus(statusRaw), Unlinked: unlinkedRaw == "1", } if currentUser(c).IsAdmin() && deletedRaw == "1" { filter.Deleted = true } // 不叫 page:本文件末尾要调用同名的 page(c, ...) 渲染辅助函数, // 局部变量会把它遮住导致编译失败。 pageNum := service.ParsePage(pageRaw) pageSize := service.ParsePageSize(c.Query("page_size")) result, err := service.ListShopeeProductsWithPageSize(h.db, filter, pageNum, pageSize) if err != nil { fail(c, http.StatusInternalServerError, "读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") return } assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold) if err != nil { fail(c, http.StatusInternalServerError, "读取可选客户端失败,数据没有被改动。刷新页面重试。") return } status := msg if status == "" { status = shopeeStatusLine(filter.Status, result) } values := url.Values{} if filter.Keyword != "" { values.Set("q", filter.Keyword) } values.Set("search_field", filter.SearchField) if filter.Status != "" { values.Set("status", filter.Status) } if filter.Deleted { values.Set("deleted", "1") } if filter.Unlinked { values.Set("unlinked", "1") } values.Set("page_size", strconv.Itoa(pageSize)) c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{ "Keyword": filter.Keyword, "SearchField": filter.SearchField, "SearchFieldOptions": service.ShopeeSearchFieldOptions(), "StatusFilter": filter.Status, "StatusOptions": service.ShopeeStatusOptions(), "DeletedFilter": filter.Deleted, "UnlinkedFilter": filter.Unlinked, "Rows": result.Rows, "Status": status, "HasAnyProducts": result.HasAnyProducts, "IsFiltered": result.IsFiltered, "Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, values.Encode()), "CurrentPage": result.Page, "CurrentPageSize": pageSize, "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", })) } func detailValuesForShopee(values url.Values, pageNum int) string { detailValues := url.Values{} for key, entries := range values { detailValues[key] = append([]string(nil), entries...) } detailValues.Set("page", strconv.Itoa(pageNum)) return detailValues.Encode() } // shopeeStatusLine 组装底部状态条的默认文案(没有 msg 覆盖时)。 // // `[必须]` 显示的是筛选后的**全量**总数(result.Total),不是本页行数, // 筛选时前缀用状态文字("待补规格:6 个商品"),不筛选时用"共", // 见工单 #43。 func shopeeStatusLine(statusFilter string, result *service.ShopeeListResult) string { prefix := fmt.Sprintf("共 %d 个商品", result.Total) if text := service.ShopeeStatusLabel(statusFilter); text != "" { prefix = fmt.Sprintf("%s:%d 个商品", text, result.Total) } else if result.IsFiltered { prefix = fmt.Sprintf("筛选结果:%d 个商品", result.Total) } return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages) } // ShopeeSave 保存蝦皮商品当前关联的 PDD 商品。 func (h *Handler) ShopeeSave(c *gin.Context) { goodsID, err := service.AssociateShopeePdd(h.db, c.PostForm("shopee_goods_id"), c.PostForm("pdd_url"), c.PostForm("confirm_replace") == "1") if err != nil { h.shopeeRedirect(c, "PDD 关联未保存:"+err.Error()+"。请核对链接后重试。") return } h.shopeeRedirect(c, fmt.Sprintf("已关联 PDD 商品 %s,可继续创建采集任务", goodsID)) } // ShopeeSpecSave 保存一条现有蝦皮 SKU 的人工颜色、尺码和建议说明。 func (h *Handler) ShopeeSpecSave(c *gin.Context) { err := service.UpdateShopeeSKUManual(h.db, currentUser(c), c.PostForm("shopee_goods_id"), c.PostForm("sku_record_id"), c.PostForm("color"), c.PostForm("size"), c.PostForm("advice"), time.Now()) if err != nil { if service.IsValidationError(err) { h.shopeeRedirect(c, "规格未保存:"+err.Error()) return } fail(c, http.StatusInternalServerError, "保存蝦皮规格失败,数据没有被改动。请刷新页面后重试。") return } h.shopeeRedirect(c, "蝦皮规格已保存,人工字段不会被后续导入覆盖") } // ShopeeDelete 批量删除勾选的行。 func (h *Handler) ShopeeDelete(c *gin.Context) { count, err := service.DeleteShopeeProducts(h.db, currentUser(c), c.PostFormArray("ids")) if err != nil { if service.IsValidationError(err) { h.shopeeRedirect(c, "删除失败:"+err.Error()) return } fail(c, http.StatusInternalServerError, "删除蝦皮商品失败,数据没有被改动。") return } h.shopeeRedirect(c, fmt.Sprintf("已将 %d 个蝦皮商品移入已删除,可由管理员恢复", count)) } // ShopeeRestore 恢复软删除商品及其原有 SKU、PDD 关联。 func (h *Handler) ShopeeRestore(c *gin.Context) { count, err := service.RestoreShopeeProducts(h.db, currentUser(c), c.PostFormArray("ids")) if err != nil { if service.IsValidationError(err) { h.shopeeRedirect(c, "恢复失败:"+err.Error()) return } fail(c, http.StatusInternalServerError, "恢复蝦皮商品失败,数据没有被改动。") return } h.shopeeRedirect(c, fmt.Sprintf("已恢复 %d 个蝦皮商品", count)) } // ShopeeCollect 发起采集任务。 // // 入口在编辑弹窗里,紧挨 PDD 链接输入框——不要放到表格每一行, // 一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事。 // // 规则: // - PDD 链接为空不允许发起; // - collect_status 已经是 collecting 的跳过并说明跳过了几个; // - 批量采集要**按商品去重**后再建任务。 // // `[不做]` 本工单(#38)明确不实现这个接口,保持 501。 func (h *Handler) ShopeeCollect(c *gin.Context) { result, err := service.CreateShopeePddCollectTaskForUser(h.db, currentUser(c), c.PostForm("shopee_goods_id")) if err != nil { h.shopeeRedirect(c, "采集任务未创建:"+err.Error()) return } h.shopeeRedirect(c, service.FormatCollectTaskMessage(result)) } // ShopeeCollectBatch 为勾选的蝦皮商品按当前 PDD 关联批量创建采集任务。 func (h *Handler) ShopeeCollectBatch(c *gin.Context) { ids := c.PostFormArray("ids") if len(ids) == 0 { h.shopeeRedirect(c, "没有勾选任何蝦皮商品,没有创建任务") return } result, err := service.CreateShopeePddCollectTasksForUser( h.db, currentUser(c), ids, c.PostForm("client_id")) if err != nil { status := http.StatusInternalServerError message := "创建采集任务失败,系统没有改动这一批数据。请稍后重试。" if service.IsValidationError(err) { status = http.StatusBadRequest message = err.Error() + "。这一批任务整体没有创建,请重新选择。" } fail(c, status, message) return } h.shopeeRedirect(c, service.FormatCollectTaskMessage(result)) } func (h *Handler) shopeeRedirect(c *gin.Context, msg string) { params := url.Values{} if value := strings.TrimSpace(c.PostForm("q")); value != "" { params.Set("q", value) } params.Set("search_field", service.ParseShopeeSearchField(c.PostForm("search_field"))) if value := strings.TrimSpace(c.PostForm("status")); value != "" { params.Set("status", service.ParseShopeeStatus(value)) } if currentUser(c).IsAdmin() && c.PostForm("deleted") == "1" { params.Set("deleted", "1") } if c.PostForm("unlinked") == "1" { params.Set("unlinked", "1") } if value := strings.TrimSpace(c.PostForm("page")); value != "" { params.Set("page", value) } params.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size")))) if value := strings.TrimSpace(c.PostForm("open_id")); value != "" { params.Set("open_id", value) } if msg != "" { params.Set("msg", msg) } c.Redirect(http.StatusSeeOther, "/shopee?"+params.Encode()) }