-

+

+
-{{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 {