From 0d4000bba5ce79919246533c8e311034f6b2da59 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Mon, 21 Sep 2026 23:59:59 +0800 Subject: [PATCH] =?UTF-8?q?refactor(street):=20=E5=AE=A1=E6=A0=B8=E9=A1=B5?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=BB=9F=E4=B8=80=E7=BD=91=E6=A0=BC+?= =?UTF-8?q?=E5=8B=BE=E9=80=89=E6=88=90=E7=BB=84=EF=BC=9B=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=E4=B8=8A=E4=B8=80=E5=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/backstage_handler.go | 108 +++++------------- internal/repository/review_repository.go | 49 -------- .../street_main_detail_integration_test.go | 16 --- internal/router/backstage.go | 1 - internal/router/backstage_test.go | 72 +++++------- internal/service/review_service.go | 16 +-- internal/service/review_street_group_test.go | 7 -- 7 files changed, 63 insertions(+), 206 deletions(-) diff --git a/internal/handler/backstage_handler.go b/internal/handler/backstage_handler.go index a731018..0735c4b 100644 --- a/internal/handler/backstage_handler.go +++ b/internal/handler/backstage_handler.go @@ -43,7 +43,7 @@ func NewBackstageHandler(auth service.AuthService, brand service.BrandService, a // 以「base」为根模板并只含一个 define;renderHTML 每次渲染时 Clone 它再灌入页面正文, // 这样页面模板即可直接引用 {{template "pager"}} 而无需各自复制翻页 HTML。 var pagerTmpl = template.Must(template.New("base").Funcs(template.FuncMap{ - // reviewDetailURL 生成审核详情页地址;main 非零时附加 ?main= 以保留折叠状态。 + // reviewDetailURL 生成审核详情页地址。 "reviewDetailURL": reviewDetailURL, }).Parse(`{{define "pager"}}
{{if .HasPrev}}上一页{{else}}上一页{{end}} @@ -397,8 +397,6 @@ func (h *BackstageHandler) ReviewDetail(c *gin.Context) { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "草稿不存在或已删除: " + c.Param("id")}) return } - // 当前选定的主图(用于审核页按组折叠时保持折叠状态),来自 ?main= 查询参数。 - draft.MainID = uint32(atoiDefault(c.Query("main"), 0)) // 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。 brandName := "" if draft.BrandID > 0 { @@ -470,7 +468,8 @@ func (h *BackstageHandler) ReviewDraftImageDelete(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id")) } -// ReviewAttachDraftImages 把若干张草稿图并入指定主图(街拍审核页「批量并入」)。 +// ReviewAttachDraftImages 把勾选的草稿图并入指定主图(街拍审核页「合并选中为一组」)。 +// 主图由表单里的单选 name="main" 指定;待并入的图由多个 checkbox name="img" 提交。 func (h *BackstageHandler) ReviewAttachDraftImages(c *gin.Context) { kind := c.Param("kind") id := atoiDefault(c.Param("id"), 0) @@ -485,32 +484,19 @@ func (h *BackstageHandler) ReviewAttachDraftImages(c *gin.Context) { imgIDs = append(imgIDs, uint32(v)) } } + if len(imgIDs) == 0 { + renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "请先勾选要合并的图片"}) + return + } if mainID == 0 { - renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "请先选定主图"}) + renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "请在要作为主图的图片上点选「主图」"}) return } if err := h.review.AttachDraftImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil { renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id), mainID)) -} - -// ReviewAttachPrevDraftImage 把某张图并入「同草稿内上一行所属组的主图」。 -func (h *BackstageHandler) ReviewAttachPrevDraftImage(c *gin.Context) { - kind := c.Param("kind") - id := atoiDefault(c.Param("id"), 0) - imgID := atoiDefault(c.Param("img"), 0) - if id == 0 || imgID == 0 { - renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "参数无效"}) - return - } - if err := h.review.AttachPrevDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil { - renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) - return - } - mainID := uint32(atoiDefault(c.Query("main"), 0)) - c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id), mainID)) + c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id))) } // ReviewDetachDraftImage 把副图拆出恢复为主图。 @@ -526,18 +512,12 @@ func (h *BackstageHandler) ReviewDetachDraftImage(c *gin.Context) { renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) return } - // 拆出后该图自身成为主图,跳转回到「以它为主图」的折叠视图。 - c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id), uint32(imgID))) + c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id))) } -// reviewDetailURL 生成审核详情页跳转地址;main 非零时附加 ?main= 以保留折叠状态。 -// main 为可选参数:省略或传 0 则不附加查询串。 -func reviewDetailURL(kind string, id uint32, mainArgs ...uint32) string { - u := "/admin/reviews/" + kind + "/" + strconv.FormatUint(uint64(id), 10) - if len(mainArgs) > 0 && mainArgs[0] != 0 { - u += "?main=" + strconv.FormatUint(uint64(mainArgs[0]), 10) - } - return u +// reviewDetailURL 生成审核详情页跳转地址。 +func reviewDetailURL(kind string, id uint32) string { + return "/admin/reviews/" + kind + "/" + strconv.FormatUint(uint64(id), 10) } // reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews)。 @@ -1338,32 +1318,18 @@ button.ghost{background:#fff;color:#111;border:1px solid #ccc} .kind{font-size:12px;padding:1px 7px;border-radius:10px;background:#eef2ff;color:#3b4bb5} .kind.street{background:#eafaf0;color:#1a7f43} .hint{position:sticky;top:0;background:#fffbe6;border:1px solid #f5e08a;color:#7a5b00;padding:8px 12px;border-radius:6px;margin:12px 0;font-size:13px} -.strip-group{background:#fff;border:1px solid #eee;border-radius:10px;padding:12px;margin:12px 0} -.strip-group.cur{border-color:#1a7f43} -.strip-group .main-img{display:flex;gap:14px;align-items:flex-start} -.strip-group .main-img img{width:140px;height:210px;object-fit:cover;border-radius:8px} -.strip-group .main-img .acts{flex:1;display:flex;gap:6px;flex-wrap:wrap} -.strip-group .main-img .acts button{margin:0;padding:7px 12px;font-size:13px} -.strip-group form.inline{display:inline} -.strip{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin-top:10px} -.strip .det{position:relative;background:#fafafa;border:1px solid #eee;border-radius:8px;overflow:hidden} -.strip .det img{width:100%;height:160px;object-fit:cover;display:block} -.strip .det .acts{padding:6px;display:flex;gap:6px} -.strip .det .acts button{margin:0;flex:1;padding:6px;font-size:12px} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:14px} .cell{background:#fff;border:1px solid #eee;border-radius:8px;overflow:hidden} .cell img{width:100%;display:block;aspect-ratio:2/3;object-fit:cover} +.cell .role{display:block;padding:6px 8px 0;font-size:12px;color:#1a7f43} +.cell .nm{display:block;padding:2px 8px 6px;font-size:12px;color:#111;word-break:break-all} +.cell.det{background:#f8faff;border-color:#c7d2fe} +.cell.det .role{color:#3b4bb5} .cell .acts{padding:8px;display:flex;flex-wrap:wrap;gap:6px} .cell .acts button{margin:0;flex:1 1 auto;padding:7px;font-size:13px} .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} @@ -1375,39 +1341,23 @@ button.ghost{background:#fff;color:#111;border:1px solid #ccc} {{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 $g.Details}}
-{{range $g.Details}}
+
操作:勾选属于同一个人的图片 → 在其中一张上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。
+

图片({{len .Draft.Images}} 张 · {{len .Draft.Groups}} 组)

+
+
+{{range .Draft.Images}}
-
副图 → #{{$g.Image.ID}}
-
{{.Name}}
+{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}} +{{.Name}}
-
-
+ +{{if eq .IsDetail 0}}{{end}} +{{if eq .IsDetail 1}}
{{end}}
{{end}} -
{{end}} -
{{end}}{{else}}

该草稿暂无图片。

{{end}} -
{{if not .Draft.MainID}}(先点某张主图上的「设为主图」){{end}}
+
+
勾选同一人的图,并在其中一张主图上点「主图」
{{else}}

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

diff --git a/internal/repository/review_repository.go b/internal/repository/review_repository.go index e56c57e..dc54b53 100644 --- a/internal/repository/review_repository.go +++ b/internal/repository/review_repository.go @@ -43,9 +43,6 @@ type ReviewRepository interface { // 避免出现「副图的副图」破坏不变量(见计划「实现裁决」第 1 条)。 // 幂等:已是 mainImgID 副图的行重复并入不报错。 AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error - // AttachPrevStreetDraftImage 把 imgID 并入「同草稿内 sort_order 紧邻的前一行所属组的主图」。 - // imgID 是首行(没有上一行)时返回 ErrNotFound。 - AttachPrevStreetDraftImage(ctx context.Context, draftID, imgID uint32) error // DetachStreetDraftImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。 // 对主图调用是幂等成功(无副作用)。 DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error @@ -708,52 +705,6 @@ func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID, }) } -// AttachPrevStreetDraftImage 把 imgID 并入「同草稿内 sort_order 紧邻的前一行所属组的主图」。 -// 折回组主图是刻意的:连续「并入上一张」应链式归并到同一主图,而不是造出副图的副图。 -func (r *reviewRepository) AttachPrevStreetDraftImage(ctx context.Context, draftID, imgID uint32) error { - var target uint32 - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var cur model.StreetSnapDraftImage - if err := tx.Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID).First(&cur).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrNotFound - } - return err - } - - var prev model.StreetSnapDraftImage - err := tx.Where("draft_id = ? AND is_deleted = 0 AND (sort_order < ? OR (sort_order = ? AND id < ?))", - draftID, cur.SortOrder, cur.SortOrder, cur.ID). - Order("sort_order DESC, id DESC").First(&prev).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrNotFound - } - return err - } - - if prev.IsDetail == 1 && prev.ParentImageID != 0 { - target = prev.ParentImageID - } else { - target = prev.ID - } - if target == imgID { - return ErrInvalidImageGroup - } - - now := uint32(time.Now().Unix()) - if err := tx.Model(&model.StreetSnapDraftImage{}). - Where("draft_id = ? AND is_deleted = 0 AND parent_image_id = ?", draftID, imgID). - Updates(map[string]any{"parent_image_id": target, "updated_at": now}).Error; err != nil { - return err - } - return tx.Model(&model.StreetSnapDraftImage{}). - Where("id = ? AND draft_id = ?", imgID, draftID). - Updates(map[string]any{"is_detail": 1, "parent_image_id": target, "updated_at": now}).Error - }) - return err -} - // DetachStreetDraftImage 把副图恢复为主图。对主图调用为幂等成功。 // 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。 func (r *reviewRepository) DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error { diff --git a/internal/repository/street_main_detail_integration_test.go b/internal/repository/street_main_detail_integration_test.go index c949e9c..cfc31f9 100644 --- a/internal/repository/street_main_detail_integration_test.go +++ b/internal/repository/street_main_detail_integration_test.go @@ -108,22 +108,6 @@ func TestStreetMainDetail(t *testing.T) { t.Fatalf("对主图拆出应幂等成功: %v", err) } - // 并入上一张:d3 的前一行是 d2(d2 已是 m1 的副图)→ 目标应折回 m1。 - if err := repo.AttachPrevStreetDraftImage(ctx, draftID, d3); err != nil { - t.Fatalf("并入上一张失败: %v", err) - } - for _, im := range reloadStreetImages(t, repo, draftID) { - if im.ID == d3 && (im.IsDetail != 1 || im.ParentImageID != m1) { - t.Fatalf("并入上一张应折回主图 %d,实际 is_detail=%d parent=%d", m1, im.IsDetail, im.ParentImageID) - } - } - - // 首行「并入上一张」应返回 ErrNotFound。 - first := seedStreetDraftImage(t, repo, draftID, "z", 0) - if err := repo.AttachPrevStreetDraftImage(ctx, draftID, first); !errors.Is(err, ErrNotFound) { - t.Fatalf("首行并入上一张应返回 ErrNotFound,实际 %v", err) - } - // 跨草稿拦截:空集合 + 跨草稿主图。 if err := repo.AttachStreetDraftImages(ctx, draftID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) { t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err) diff --git a/internal/router/backstage.go b/internal/router/backstage.go index f418371..c59dfb1 100644 --- a/internal/router/backstage.go +++ b/internal/router/backstage.go @@ -96,7 +96,6 @@ func NewBackstage(opt BackstageOptions) *gin.Engine { admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewDraftImageDelete) // 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。 admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachDraftImages) - admin.POST("/reviews/:kind/:id/images/:img/attach-prev", opt.Backstage.ReviewAttachPrevDraftImage) admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachDraftImage) // 爬虫入库任务监控:列出处理进度 + 失败重试 admin.GET("/ingest-jobs", opt.Backstage.IngestJobs) diff --git a/internal/router/backstage_test.go b/internal/router/backstage_test.go index 188ee9d..51b9569 100644 --- a/internal/router/backstage_test.go +++ b/internal/router/backstage_test.go @@ -324,13 +324,6 @@ func (f *fakeReview) AttachDraftImages(_ context.Context, kind string, draftID, } return nil } -func (f *fakeReview) AttachPrevDraftImage(_ context.Context, kind string, draftID, imgID uint32) error { - f.last.kind, f.last.draftID, f.last.imgID, f.last.op = kind, draftID, imgID, "attach-prev" - if !f.attachOK { - return errors.New("主图无效") - } - return nil -} func (f *fakeReview) DetachDraftImage(_ context.Context, kind string, draftID, imgID uint32) error { f.last.kind, f.last.draftID, f.last.imgID, f.last.op = kind, draftID, imgID, "detach" if !f.attachOK { @@ -363,7 +356,7 @@ func reviewToken(t *testing.T, r *gin.Engine) *http.Cookie { return cookies[0] } -// TestBackstageStreetAttachRedirect 批量并入应 302 跳回带 ?main= 的详情页,且参数透传到 service。 +// TestBackstageStreetAttachRedirect 合并选中应 302 跳回详情页,且参数透传到 service。 func TestBackstageStreetAttachRedirect(t *testing.T) { r, rev := newReviewRouter(t) tok := reviewToken(t, r) @@ -374,34 +367,35 @@ func TestBackstageStreetAttachRedirect(t *testing.T) { req.AddCookie(tok) r.ServeHTTP(w, req) if w.Code != http.StatusFound { - t.Fatalf("并入应 302,实际 %d %s", w.Code, w.Body.String()) + t.Fatalf("合并应 302,实际 %d %s", w.Code, w.Body.String()) } - if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7?main=1") { - t.Fatalf("重定向应带 ?main=1,实际 %s", w.Header().Get("Location")) + if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7") { + t.Fatalf("应重定向回详情页,实际 %s", w.Header().Get("Location")) } if rev.last.op != "attach" || rev.last.mainID != 1 || len(rev.last.imgIDs) != 2 { t.Fatalf("参数透传错误: %+v", rev.last) } } -// TestBackstageStreetAttachPrev 并入上一张应 302。 -func TestBackstageStreetAttachPrev(t *testing.T) { +// TestBackstageStreetAttachNeedsMain 未选主图时应 400,而不是静默失败。 +func TestBackstageStreetAttachNeedsMain(t *testing.T) { r, rev := newReviewRouter(t) tok := reviewToken(t, r) w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/2/attach-prev", nil) + req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach", strings.NewReader("img=2&img=3")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(tok) r.ServeHTTP(w, req) - if w.Code != http.StatusFound { - t.Fatalf("并入上一张应 302,实际 %d", w.Code) + if w.Code != http.StatusBadRequest { + t.Fatalf("未选主图应 400,实际 %d", w.Code) } - if rev.last.op != "attach-prev" || rev.last.imgID != 2 { - t.Fatalf("参数透传错误: %+v", rev.last) + if rev.last.op == "attach" { + t.Fatalf("未选主图不应调用 service") } } -// TestBackstageStreetDetach 拆出应 302 跳回「以该图为主图」的详情页。 +// TestBackstageStreetDetach 拆出应 302 跳回详情页。 func TestBackstageStreetDetach(t *testing.T) { r, rev := newReviewRouter(t) tok := reviewToken(t, r) @@ -413,22 +407,22 @@ func TestBackstageStreetDetach(t *testing.T) { if w.Code != http.StatusFound { t.Fatalf("拆出应 302,实际 %d", w.Code) } - if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7?main=2") { - t.Fatalf("拆出后应以该图为主图跳转,实际 %s", w.Header().Get("Location")) + if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7") { + t.Fatalf("应跳回详情页,实际 %s", w.Header().Get("Location")) } if rev.last.op != "detach" || rev.last.imgID != 2 { t.Fatalf("参数透传错误: %+v", rev.last) } } -// TestBackstageStreetRenderGroups 详情页应按组折叠渲染(strip-group + 副图名)。 +// TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。 func TestBackstageStreetRenderGroups(t *testing.T) { r, rev := newReviewRouter(t) tok := reviewToken(t, r) rev.view = &service.DraftDetailView{ Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending", Images: []service.DraftImageRef{ - {ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0}, + {ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0, DetailCount: 1}, {ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1}, {ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0}, }, @@ -439,36 +433,32 @@ func TestBackstageStreetRenderGroups(t *testing.T) { } w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7?main=1", nil) + req := httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7", nil) req.AddCookie(tok) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("详情应 200,实际 %d %s", w.Code, w.Body.String()) } body := w.Body.String() - if !strings.Contains(body, "strip-group") { - t.Fatalf("应按组折叠渲染 strip-group") - } if !strings.Contains(body, "detail-a") { t.Fatalf("应渲染副图 detail-a") } - // 「设为主图」必须是带 ?main= 的 GET 链接(且带 #i 锚点,选完不跳回顶部)。 - // (曾被误写成 POST 到 /admin/reviews/street/7,而该路由只有 GET,导致 404、主图根本选不了。) - if !strings.Contains(body, `href="/admin/reviews/street/7?main=1#i1"`) { - t.Fatalf("「设为主图」应是带 ?main= 与 #i 锚点的 GET 链接") + // 副图徽标显示所属主图(沿链归并后的实际主图)。 + if !strings.Contains(body, "副图 → #1") { + t.Fatalf("副图应显示所属主图") } - if strings.Contains(body, `action="/admin/reviews/street/7"`) { - t.Fatalf("不应存在 POST 到详情页的表单(该路由只有 GET)") - } - // 批量并入:勾选框 + 独立提交按钮,用 form="batch" 关联(零 JS、不嵌套表单)。 + // 统一网格 + 勾选 + 主图单选 + 底部提交按钮,全部用 form="batch" 关联(零 JS、不嵌套表单)。 if !strings.Contains(body, `id="batch"`) || !strings.Contains(body, `form="batch"`) { - t.Fatalf("应有勾选批量并入当前主图的表单") + t.Fatalf("应有勾选合并的表单") } - // 「并入上一张」必须出现在每一张图(除第一张)上,否则「连拍同一个人」无法快捷归并。 - if !strings.Contains(body, `/admin/reviews/street/7/images/3/attach-prev`) { - t.Fatalf("非首张图应有「并入上一张」") + if !strings.Contains(body, `name="main"`) { + t.Fatalf("应有主图单选(name=main)") } - if strings.Contains(body, `/admin/reviews/street/7/images/1/attach-prev`) { - t.Fatalf("首张图不应有「并入上一张」(没有上一张)") + if !strings.Contains(body, "合并选中为一组") { + t.Fatalf("底部应有「合并选中为一组」按钮") + } + // 「并入上一张」已废弃:不应再出现在页面或表单里。 + if strings.Contains(body, "并入上一张") || strings.Contains(body, "attach-prev") { + t.Fatalf("不应再出现「并入上一张」") } } diff --git a/internal/service/review_service.go b/internal/service/review_service.go index 88b42c0..729cb28 100644 --- a/internal/service/review_service.go +++ b/internal/service/review_service.go @@ -70,9 +70,6 @@ type DraftDetailView struct { Images []DraftImageRef `json:"images"` // Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。 Fields []EditField `json:"fields"` - // MainID 审核页「当前主图」的行 id,来自 URL 查询参数 ?main=;0 表示未选定。 - // 只有街拍模块会用到(走秀无主副图概念)。 - MainID uint32 `json:"main_id"` // Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。 // 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。 Groups []DraftImageGroup `json:"groups,omitempty"` @@ -127,8 +124,6 @@ type ReviewService interface { DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error // AttachDraftImages 把若干张草稿图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。 AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error - // AttachPrevDraftImage 把某张草稿图并入「同草稿内上一行所属组的主图」(街拍专用)。 - AttachPrevDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error // DetachDraftImage 把副图拆出恢复为主图(街拍专用)。 DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error // KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。 @@ -299,13 +294,6 @@ func (s *reviewService) AttachDraftImages(ctx context.Context, kind string, draf return translateGroupErr(s.repo.AttachStreetDraftImages(ctx, draftID, mainImgID, imgIDs)) } -func (s *reviewService) AttachPrevDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error { - if kind != dto.IngestKindStreet { - return ErrInvalidImageGroup - } - return translateGroupErr(s.repo.AttachPrevStreetDraftImage(ctx, draftID, imgID)) -} - func (s *reviewService) DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error { if kind != dto.IngestKindStreet { return ErrInvalidImageGroup @@ -490,8 +478,10 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail root := resolveRoot(im.ID) isDetail := root != im.ID isDetailVal := uint8(0) + parent := uint32(0) // 副图徽标显示「所属主图」:取沿链归并后的根主图;主图恒为 0 if isDetail { isDetailVal = 1 + parent = root } ref := DraftImageRef{ ID: im.ID, @@ -499,7 +489,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail Name: im.Name, SortOrder: im.SortOrder, IsDetail: isDetailVal, - ParentImageID: im.ParentImageID, + ParentImageID: parent, DetailCount: detailCount[im.ID], } v.Images = append(v.Images, ref) diff --git a/internal/service/review_street_group_test.go b/internal/service/review_street_group_test.go index 2c6e2dc..59918b0 100644 --- a/internal/service/review_street_group_test.go +++ b/internal/service/review_street_group_test.go @@ -34,13 +34,6 @@ func (f *fakeGroupRepo) AttachStreetDraftImages(_ context.Context, draftID, main return nil } -func (f *fakeGroupRepo) AttachPrevStreetDraftImage(_ context.Context, _ uint32, _ uint32) error { - if f.attachFn != nil { - return f.attachFn(0, 0, nil) - } - return nil -} - func (f *fakeGroupRepo) DetachStreetDraftImage(_ context.Context, _ uint32, _ uint32) error { if f.attachFn != nil { return f.attachFn(0, 0, nil)