From 85ad3a3f68d77e800cfd5d684b7b9f58cf50c571 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Mon, 21 Sep 2026 23:24:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(street):=20=E5=85=AC=E5=BC=80=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E6=94=B9=E4=B8=BArunway=E5=90=8C=E5=BD=A2=E5=B5=8C?= =?UTF-8?q?=E5=A5=97=EF=BC=9B=E5=AE=A1=E6=A0=B8=E9=A1=B5=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E4=B8=BA=E5=9B=BE=E7=89=87=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/dto/street_snap.go | 22 +++---- internal/handler/backstage_handler.go | 62 +++++++++--------- internal/router/backstage_test.go | 13 +++- internal/service/street_snap_group_test.go | 56 ++++++++-------- internal/service/street_snap_service.go | 74 +++++++++++++--------- 5 files changed, 119 insertions(+), 108 deletions(-) diff --git a/internal/dto/street_snap.go b/internal/dto/street_snap.go index d99d4bc..aaddfc7 100644 --- a/internal/dto/street_snap.go +++ b/internal/dto/street_snap.go @@ -60,19 +60,13 @@ type PublicStreetSnap struct { Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图 } -// PublicStreetSnapDetail 对外只读街拍详情:含完整图片集与主副图分组。 +// PublicStreetSnapDetail 对外只读街拍详情:与走秀详情同形(主图 + 细节图)。 type PublicStreetSnapDetail struct { - UID string `json:"id"` - Title string `json:"title"` - Cover string `json:"cover"` - Images []PublicArticleImage `json:"images"` // 扁平图片集(保持不变,向后兼容) - Groups []PublicStreetSnapGroup `json:"groups,omitempty"` // 按主副图分组(主图→其副图),与 Images 同源同序 - Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级) -} - -// PublicStreetSnapGroup 一张主图及其副图,供前端按组折叠展示。 -// Details 为空表示该主图没有副图(等价于 Images 里的一张独立图)。 -type PublicStreetSnapGroup struct { - Image PublicArticleImage `json:"image"` // 主图 - Details []PublicArticleImage `json:"details"` // 该主图名下的副图(可为空) + UID string `json:"id"` + Title string `json:"title"` + Cover string `json:"cover"` // 封面:独立字段,与主/副图分组无关 + // Images 只含主图(is_detail=0),默认展示;每张主图的副图挂在它自己的 Detail 子数组下。 + // 结构与走秀详情完全一致(见 PublicArticleImage),前端可复用同一套渲染。 + Images []PublicArticleImage `json:"images"` + Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级) } diff --git a/internal/handler/backstage_handler.go b/internal/handler/backstage_handler.go index a05b10f..a731018 100644 --- a/internal/handler/backstage_handler.go +++ b/internal/handler/backstage_handler.go @@ -1358,6 +1358,13 @@ button.ghost{background:#fff;color:#111;border:1px solid #ccc} .btn{display:inline-block;flex:1 1 auto;margin:0;padding:7px 10px;background:#fff;color:#111;border:1px solid #ccc;border-radius:6px;text-decoration:none;font-size:13px;text-align:center} .chk{display:flex;align-items:center;gap:4px;flex:1 1 auto;font-size:12px;color:#333;white-space:nowrap;cursor:pointer} .chk input{margin:0} +.meta{flex:1;min-width:0} +.cap{font-size:12px;color:#666;margin-bottom:2px} +.cap b{color:#1a7f43} +.nm{font-size:13px;color:#111;margin-bottom:6px;word-break:break-all} +.tag{display:inline-block;font-size:11px;padding:1px 6px;border-radius:8px;background:#eafaf0;color:#1a7f43} +.tag.det{background:#eef2ff;color:#3b4bb5} +.batchbar{position:sticky;bottom:0;background:#fff;border-top:1px solid #eee;padding:10px 0;margin-top:16px;display:flex;align-items:center;gap:10px}
管理后台 · 草稿审核 #{{.Draft.ID}}返回列表 · 退出
@@ -1367,49 +1374,40 @@ button.ghost{background:#fff;color:#111;border:1px solid #ccc} {{range .Draft.Fields}}
{{if eq .Type "textarea"}}{{else}}{{end}}
{{end}} -{{if eq .Draft.Kind "street"}}
{{if .Draft.MainID}}当前主图:#{{.Draft.MainID}}。操作三步:① 在下方任意「主图」上点「设为主图」切换当前主图 → ② 勾选要并入的图 → ③ 点页面底部「并入到当前主图」。连拍快速归并可用每张图上的「并入上一张」。{{else}}尚未选定主图:请在下方某张图上点「设为主图」(页面会带上 ?main= 参数),再勾选要并入的图、点底部「并入到当前主图」。{{end}}
-

主副图分组({{len .Draft.Groups}} 组 · 共 {{.Draft.ImageCount}} 张)

-{{if .Draft.Groups}}{{range .Draft.Groups}}
+{{if eq .Draft.Kind "street"}} +
{{if .Draft.MainID}}当前主图:#{{.Draft.MainID}}。{{else}}尚未选定主图。{{end}}最快路径:连拍同一个人 → 从上往下逐张点「并入上一张」;非相邻的图要归到某张主图下 → 先点那张图的「设为主图」,再勾选要并入的图,最后点底部「并入到当前主图」。
+

图片流({{len .Draft.Groups}} 张主图 · 共 {{.Draft.ImageCount}} 张)

+
+ +
+{{if .Draft.Groups}}{{range $gi, $g := .Draft.Groups}}
- + +
+
主图{{if $g.Image.DetailCount}} · {{$g.Image.DetailCount}} 张副图{{end}}{{if eq $g.Image.ID $.Draft.MainID}} · 当前主图{{end}}
+
{{$g.Image.Name}}
-设为主图 -
+设为主图 +{{if $gi}}
{{end}} +{{if ne $g.Image.ID $.Draft.MainID}}{{end}} +
-{{if .Details}}
-{{range .Details}}
+
+{{if $g.Details}}
+{{range $g.Details}}
-{{.Name}} +
副图 → #{{$g.Image.ID}}
+
{{.Name}}
-
-
+
+
{{end}}
{{end}}
{{end}}{{else}}

该草稿暂无图片。

{{end}} -

全部图片(勾选后批量并入当前主图)

-
- -
-
-{{range .Draft.Images}}
- -{{.Name}}{{if eq .IsDetail 1}} · 副图 #{{.ParentImageID}}{{else if eq .ID $.Draft.MainID}} · 当前主图{{end}} -
-{{if eq .IsDetail 1}} - -
-{{else}} -设为主图 - -{{end}} -
-
-
{{end}} -
-
+
{{if not .Draft.MainID}}(先点某张主图上的「设为主图」){{end}}
{{else}}

图片管理({{len .Draft.Images}} 张)

diff --git a/internal/router/backstage_test.go b/internal/router/backstage_test.go index f365ced..188ee9d 100644 --- a/internal/router/backstage_test.go +++ b/internal/router/backstage_test.go @@ -452,10 +452,10 @@ func TestBackstageStreetRenderGroups(t *testing.T) { if !strings.Contains(body, "detail-a") { t.Fatalf("应渲染副图 detail-a") } - // 「设为主图」必须是带 ?main= 的 GET 链接:选主图只改 URL,不写库。 + // 「设为主图」必须是带 ?main= 的 GET 链接(且带 #i 锚点,选完不跳回顶部)。 // (曾被误写成 POST 到 /admin/reviews/street/7,而该路由只有 GET,导致 404、主图根本选不了。) - if !strings.Contains(body, `href="/admin/reviews/street/7?main=1"`) { - t.Fatalf("「设为主图」应是带 ?main= 的 GET 链接") + if !strings.Contains(body, `href="/admin/reviews/street/7?main=1#i1"`) { + t.Fatalf("「设为主图」应是带 ?main= 与 #i 锚点的 GET 链接") } if strings.Contains(body, `action="/admin/reviews/street/7"`) { t.Fatalf("不应存在 POST 到详情页的表单(该路由只有 GET)") @@ -464,4 +464,11 @@ func TestBackstageStreetRenderGroups(t *testing.T) { if !strings.Contains(body, `id="batch"`) || !strings.Contains(body, `form="batch"`) { t.Fatalf("应有勾选批量并入当前主图的表单") } + // 「并入上一张」必须出现在每一张图(除第一张)上,否则「连拍同一个人」无法快捷归并。 + if !strings.Contains(body, `/admin/reviews/street/7/images/3/attach-prev`) { + t.Fatalf("非首张图应有「并入上一张」") + } + if strings.Contains(body, `/admin/reviews/street/7/images/1/attach-prev`) { + t.Fatalf("首张图不应有「并入上一张」(没有上一张)") + } } diff --git a/internal/service/street_snap_group_test.go b/internal/service/street_snap_group_test.go index 6c63e29..a2c5945 100644 --- a/internal/service/street_snap_group_test.go +++ b/internal/service/street_snap_group_test.go @@ -7,12 +7,13 @@ import ( "fashionapi/internal/pkg/imgurl" ) -// TestGroupSnapImages 验证主副图分组与「副图的副图」上浮归并。 +// TestBuildSnapImages 验证公开详情的 image 只含主图、副图嵌套在其主图的 Detail 下, +// 且「副图的副图」上浮归并到该副图所在组的主图。 // // 5 张图:A 主,B 副(挂 A);C 主;D 副(挂 C);E 副(挂 B → 应上浮归并到 A) // -// 期望 2 组:第 0 组 = A + [B, E],第 1 组 = C + [D]。 -func TestGroupSnapImages(t *testing.T) { +// 期望 images 只有 2 张主图:A(Detail=[B,E], LookIndex=1)、C(Detail=[D], LookIndex=2)。 +func TestBuildSnapImages(t *testing.T) { s := &streetSnapService{img: imgurl.New("", "", "")} imgs := []model.StreetSnapImage{ {ID: 1, Image: "A", Name: "A", IsDetail: 0, ParentImageID: 0}, @@ -22,46 +23,45 @@ func TestGroupSnapImages(t *testing.T) { {ID: 5, Image: "E", Name: "E", IsDetail: 1, ParentImageID: 2}, // 2 是副图 → 上浮到 A } - flat, groups := s.groupSnapImages(imgs) - if len(flat) != 5 { - t.Fatalf("flat 应 5 张,实际 %d", len(flat)) - } - if len(groups) != 2 { - t.Fatalf("groups 应为 2 组,实际 %d", len(groups)) + out := s.buildSnapImages(imgs) + if len(out) != 2 { + t.Fatalf("images 应只含 2 张主图,实际 %d", len(out)) } - g0 := groups[0] - if g0.Image.Name != "A" { - t.Fatalf("第 0 组主图应为 A,实际 %s", g0.Image.Name) + a := out[0] + if a.Name != "A" || a.IsDetail != 0 || a.LookIndex != 1 { + t.Fatalf("第 1 张应为 A 主图 LookIndex=1,实际 name=%s is_detail=%d look=%d", a.Name, a.IsDetail, a.LookIndex) } - if len(g0.Details) != 2 { - t.Fatalf("第 0 组副图应为 2 张,实际 %d", len(g0.Details)) + if len(a.Detail) != 2 || a.Detail[0].Name != "B" || a.Detail[1].Name != "E" { + t.Fatalf("A 的副图应依序为 [B,E],实际 %d 张", len(a.Detail)) } - if g0.Details[0].Name != "B" || g0.Details[1].Name != "E" { - t.Fatalf("第 0 组副图顺序应为 [B, E],实际 %s, %s", g0.Details[0].Name, g0.Details[1].Name) + for _, d := range a.Detail { + if d.IsDetail != 1 || d.LookIndex != 1 { + t.Fatalf("副图 %s 的 is_detail/look_index 应为 1/1,实际 %d/%d", d.Name, d.IsDetail, d.LookIndex) + } } - g1 := groups[1] - if g1.Image.Name != "C" { - t.Fatalf("第 1 组主图应为 C,实际 %s", g1.Image.Name) + c := out[1] + if c.Name != "C" || c.LookIndex != 2 { + t.Fatalf("第 2 张应为 C 主图 LookIndex=2,实际 name=%s look=%d", c.Name, c.LookIndex) } - if len(g1.Details) != 1 || g1.Details[0].Name != "D" { - t.Fatalf("第 1 组副图应为 [D],实际 %d 张", len(g1.Details)) + if len(c.Detail) != 1 || c.Detail[0].Name != "D" { + t.Fatalf("C 的副图应为 [D],实际 %d 张", len(c.Detail)) } } -// TestGroupSnapImagesOrphan 父引用指向失效行(不存在)时,该图按主图渲染,不丢图。 -func TestGroupSnapImagesOrphan(t *testing.T) { +// TestBuildSnapImagesOrphan 父引用指向失效行(不存在)时,该图按主图渲染,不丢图。 +func TestBuildSnapImagesOrphan(t *testing.T) { s := &streetSnapService{img: imgurl.New("", "", "")} imgs := []model.StreetSnapImage{ {ID: 1, Image: "A", Name: "A", IsDetail: 1, ParentImageID: 99}, // 父 99 不存在 {ID: 2, Image: "B", Name: "B", IsDetail: 0, ParentImageID: 0}, } - flat, groups := s.groupSnapImages(imgs) - if len(flat) != 2 || len(groups) != 2 { - t.Fatalf("孤儿副图应自成主图:flat=%d groups=%d", len(flat), len(groups)) + out := s.buildSnapImages(imgs) + if len(out) != 2 { + t.Fatalf("孤儿副图应自成主图:images=%d", len(out)) } - if groups[0].Image.Name != "A" || len(groups[0].Details) != 0 { - t.Fatalf("A 应作为主图且无副图,实际 %s details=%d", groups[0].Image.Name, len(groups[0].Details)) + if out[0].Name != "A" || out[0].IsDetail != 0 || len(out[0].Detail) != 0 { + t.Fatalf("A 应作为无副图的主图,实际 name=%s is_detail=%d details=%d", out[0].Name, out[0].IsDetail, len(out[0].Detail)) } } diff --git a/internal/service/street_snap_service.go b/internal/service/street_snap_service.go index 6385959..6e6effa 100644 --- a/internal/service/street_snap_service.go +++ b/internal/service/street_snap_service.go @@ -134,24 +134,22 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS return nil, internalErr(err.Error()) } - flat, groups := s.groupSnapImages(imgs) return &dto.PublicStreetSnapDetail{ UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap), Title: snap.Title, Cover: s.img.Compose(snap.Cover), - Images: flat, - Groups: groups, + Images: s.buildSnapImages(imgs), }, nil } -// groupSnapImages 把街拍图片按主副图分组:主图各成一组,副图归入其(存活)主图组。 -// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失(被软删)则把该图按主图渲染(不丢图)。 -// 返回的 flat(扁平)与 groups 同源同序:groups 中每张主图对应的 Details 顺序,等于 flat 中该主图之后的副图出现顺序。 -func (s *streetSnapService) groupSnapImages(imgs []model.StreetSnapImage) ([]dto.PublicArticleImage, []dto.PublicStreetSnapGroup) { +// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形): +// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。 +// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。 +// LookIndex 是合成的 1-based 主图序号(街拍不落库组序号)。 +func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { if len(imgs) == 0 { - return nil, nil + return nil } - flat := s.toPublicSnapImages(imgs) byID := make(map[uint32]*model.StreetSnapImage, len(imgs)) for i := range imgs { @@ -178,26 +176,35 @@ func (s *streetSnapService) groupSnapImages(imgs []model.StreetSnapImage) ([]dto } } - groups := make([]dto.PublicStreetSnapGroup, 0, len(imgs)) + // 第一遍:按 sort_order 建立主图(根),并记录「主图行 id → 输出下标」,副图排在主图之前也能正确归位。 + out := make([]dto.PublicArticleImage, 0, len(imgs)) index := make(map[uint32]int, len(imgs)) - for i, im := range imgs { - root := resolveRoot(im.ID) - isDetail := root != im.ID - ref := flat[i] - if !isDetail { - groups = append(groups, dto.PublicStreetSnapGroup{Image: ref}) - index[im.ID] = len(groups) - 1 + for _, im := range imgs { + if resolveRoot(im.ID) != im.ID { continue } - if gi, ok := index[root]; ok { - groups[gi].Details = append(groups[gi].Details, ref) - } else { - // 兜底(理论不发生):主图未先出现,自成一组 - groups = append(groups, dto.PublicStreetSnapGroup{Image: ref}) - index[im.ID] = len(groups) - 1 - } + pub := s.toPublicSnapImage(im) + pub.LookIndex = uint32(len(out) + 1) // 合成的 1-based 主图序号 + index[im.ID] = len(out) + out = append(out, pub) } - return flat, groups + + // 第二遍:把副图挂到所属主图的 Detail 下(LookIndex 取所属主图的值)。 + for _, im := range imgs { + root := resolveRoot(im.ID) + if root == im.ID { + continue + } + gi, ok := index[root] + if !ok { + continue // 理论不发生(孤儿已按主图渲染) + } + pub := s.toPublicSnapImage(im) + pub.IsDetail = 1 + pub.LookIndex = out[gi].LookIndex + out[gi].Detail = append(out[gi].Detail, pub) + } + return out } // IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。 @@ -229,16 +236,21 @@ func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage) []d } out := make([]dto.PublicArticleImage, 0, len(imgs)) for _, im := range imgs { - out = append(out, dto.PublicArticleImage{ - ID: hashid.EncodeWithType(im.ID, hashid.TypeSnapImage), - Image: s.img.Compose(im.Image), - Thumb: s.img.ComposeThumb(im.Image), - Name: im.Name, - }) + out = append(out, s.toPublicSnapImage(im)) } return out } +// toPublicSnapImage 把单张图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。 +func (s *streetSnapService) toPublicSnapImage(im model.StreetSnapImage) dto.PublicArticleImage { + return dto.PublicArticleImage{ + ID: hashid.EncodeWithType(im.ID, hashid.TypeSnapImage), + Image: s.img.Compose(im.Image), + Thumb: s.img.ComposeThumb(im.Image), + Name: im.Name, + } +} + // toThumbSnapImages 与 toPublicSnapImages 同构,但图片地址走「列表缩略图」样式(ComposeThumb), // 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicSnapImages(展示样式)。 func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {