refactor(street): 审核页改为统一网格+勾选成组;移除并入上一张

This commit is contained in:
toom1996
2026-09-21 23:59:59 +08:00
parent 9807752e4e
commit 0d4000bba5
7 changed files with 63 additions and 206 deletions

View File

@ -43,7 +43,7 @@ func NewBackstageHandler(auth service.AuthService, brand service.BrandService, a
// 以「base」为根模板并只含一个 define;renderHTML 每次渲染时 Clone 它再灌入页面正文, // 以「base」为根模板并只含一个 define;renderHTML 每次渲染时 Clone 它再灌入页面正文,
// 这样页面模板即可直接引用 {{template "pager"}} 而无需各自复制翻页 HTML。 // 这样页面模板即可直接引用 {{template "pager"}} 而无需各自复制翻页 HTML。
var pagerTmpl = template.Must(template.New("base").Funcs(template.FuncMap{ var pagerTmpl = template.Must(template.New("base").Funcs(template.FuncMap{
// reviewDetailURL 生成审核详情页地址;main 非零时附加 ?main= 以保留折叠状态。 // reviewDetailURL 生成审核详情页地址。
"reviewDetailURL": reviewDetailURL, "reviewDetailURL": reviewDetailURL,
}).Parse(`{{define "pager"}}<div class="pager"> }).Parse(`{{define "pager"}}<div class="pager">
{{if .HasPrev}}<a href="{{.PrevURL}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}} {{if .HasPrev}}<a href="{{.PrevURL}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
@ -397,8 +397,6 @@ func (h *BackstageHandler) ReviewDetail(c *gin.Context) {
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "草稿不存在或已删除: " + c.Param("id")}) renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "草稿不存在或已删除: " + c.Param("id")})
return return
} }
// 当前选定的主图(用于审核页按组折叠时保持折叠状态),来自 ?main= 查询参数。
draft.MainID = uint32(atoiDefault(c.Query("main"), 0))
// 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。 // 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。
brandName := "" brandName := ""
if draft.BrandID > 0 { 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")) c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id"))
} }
// ReviewAttachDraftImages 把若干张草稿图并入指定主图(街拍审核页「批量并入」)。 // ReviewAttachDraftImages 把勾选的草稿图并入指定主图(街拍审核页「合并选中为一组」)。
// 主图由表单里的单选 name="main" 指定;待并入的图由多个 checkbox name="img" 提交。
func (h *BackstageHandler) ReviewAttachDraftImages(c *gin.Context) { func (h *BackstageHandler) ReviewAttachDraftImages(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
@ -485,32 +484,19 @@ func (h *BackstageHandler) ReviewAttachDraftImages(c *gin.Context) {
imgIDs = append(imgIDs, uint32(v)) imgIDs = append(imgIDs, uint32(v))
} }
} }
if len(imgIDs) == 0 {
renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "请先勾选要合并的图片"})
return
}
if mainID == 0 { if mainID == 0 {
renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "请先选定主图"}) renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "请在要作为主图的图片上点选「主图」"})
return return
} }
if err := h.review.AttachDraftImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil { 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()}) renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id), mainID)) c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
}
// 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))
} }
// ReviewDetachDraftImage 把副图拆出恢复为主图。 // ReviewDetachDraftImage 把副图拆出恢复为主图。
@ -526,18 +512,12 @@ func (h *BackstageHandler) ReviewDetachDraftImage(c *gin.Context) {
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
return return
} }
// 拆出后该图自身成为主图,跳转回到「以它为主图」的折叠视图。 c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id), uint32(imgID)))
} }
// reviewDetailURL 生成审核详情页跳转地址;main 非零时附加 ?main= 以保留折叠状态。 // reviewDetailURL 生成审核详情页跳转地址。
// main 为可选参数:省略或传 0 则不附加查询串。 func reviewDetailURL(kind string, id uint32) string {
func reviewDetailURL(kind string, id uint32, mainArgs ...uint32) string { return "/admin/reviews/" + kind + "/" + strconv.FormatUint(uint64(id), 10)
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
} }
// reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews)。 // 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{font-size:12px;padding:1px 7px;border-radius:10px;background:#eef2ff;color:#3b4bb5}
.kind.street{background:#eafaf0;color:#1a7f43} .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} .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} .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{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 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{padding:8px;display:flex;flex-wrap:wrap;gap:6px}
.cell .acts button{margin:0;flex:1 1 auto;padding:7px;font-size:13px} .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} .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{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} .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} .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}
</style></head> </style></head>
<body> <body>
@ -1375,39 +1341,23 @@ button.ghost{background:#fff;color:#111;border:1px solid #ccc}
<button type="submit">通过并发布</button> <button type="submit">通过并发布</button>
</form> </form>
{{if eq .Draft.Kind "street"}} {{if eq .Draft.Kind "street"}}
<div class="hint">{{if .Draft.MainID}}当前主图:<b>#{{.Draft.MainID}}</b>。{{else}}尚未选定主图。{{end}}最快路径:<b>连拍同一个人</b> → 从上往下逐张点「并入上一张」;<b>非相邻</b>的图要归到某张主图下 → 先点那张图的「设为主图」,再勾选要并入的图,最后点底部「并入到当前主图」。</div> <div class="hint">操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。</div>
<h2>图片流({{len .Draft.Groups}} 张主图 · 共 {{.Draft.ImageCount}} 张)</h2> <h2>图片({{len .Draft.Images}} 张 · {{len .Draft.Groups}} 组)</h2>
<form id="batch" method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/images/attach"> <form id="batch" method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/images/attach"></form>
<input type="hidden" name="main" value="{{.Draft.MainID}}"> <div class="grid">
</form> {{range .Draft.Images}}<div class="cell{{if eq .IsDetail 1}} det{{end}}">
{{if .Draft.Groups}}{{range $gi, $g := .Draft.Groups}}<div class="strip-group{{if eq $g.Image.ID $.Draft.MainID}} cur{{end}}" id="i{{$g.Image.ID}}">
<div class="main-img">
<img src="{{$g.Image.Image}}" loading="lazy">
<div class="meta">
<div class="cap"><span class="tag">主图</span>{{if $g.Image.DetailCount}} · {{$g.Image.DetailCount}} 张副图{{end}}{{if eq $g.Image.ID $.Draft.MainID}} · <b>当前主图</b>{{end}}</div>
<div class="nm">{{$g.Image.Name}}</div>
<div class="acts">
<a class="btn" href="{{reviewDetailURL $.Draft.Kind $.Draft.ID $g.Image.ID}}#i{{$g.Image.ID}}">设为主图</a>
{{if $gi}}<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{$g.Image.ID}}/attach-prev{{if $.Draft.MainID}}?main={{$.Draft.MainID}}{{end}}"><button type="submit">并入上一张</button></form>{{end}}
{{if ne $g.Image.ID $.Draft.MainID}}<label class="chk"><input type="checkbox" form="batch" name="img" value="{{$g.Image.ID}}"> 选入</label>{{end}}
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{$g.Image.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div>
</div>
</div>
{{if $g.Details}}<div class="strip">
{{range $g.Details}}<div class="det" id="i{{.ID}}">
<img src="{{.Image}}" loading="lazy"> <img src="{{.Image}}" loading="lazy">
<div class="cap"><span class="tag det">副图</span> → #{{$g.Image.ID}}</div> <span class="role">{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}}</span>
<div class="nm">{{.Name}}</div> <span class="nm">{{.Name}}</span>
<div class="acts"> <div class="acts">
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/attach-prev{{if $.Draft.MainID}}?main={{$.Draft.MainID}}{{end}}"><button type="submit">并入上一张</button></form> <label class="chk"><input type="checkbox" form="batch" name="img" value="{{.ID}}"> 选入</label>
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/detach"><button type="submit">拆出</button></form> {{if eq .IsDetail 0}}<label class="chk"><input type="radio" form="batch" name="main" value="{{.ID}}"> 主图</label>{{end}}
{{if eq .IsDetail 1}}<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/detach"><button type="submit">拆出</button></form>{{end}}
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form> <form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div> </div>
</div>{{end}} </div>{{end}}
</div>{{end}} </div>
</div>{{end}}{{else}}<p class="muted">该草稿暂无图片。</p>{{end}} <div class="batchbar"><button type="submit" form="batch">合并选中为一组</button><span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span></div>
<div class="batchbar"><button type="submit" form="batch"{{if not .Draft.MainID}} disabled{{end}}>并入到当前主图{{if .Draft.MainID}} #{{.Draft.MainID}}{{end}}</button>{{if not .Draft.MainID}}<span class="muted">(先点某张主图上的「设为主图」)</span>{{end}}</div>
{{else}} {{else}}
<h2>图片管理({{len .Draft.Images}} 张)</h2> <h2>图片管理({{len .Draft.Images}} 张)</h2>
<div class="grid"> <div class="grid">

View File

@ -43,9 +43,6 @@ type ReviewRepository interface {
// 避免出现「副图的副图」破坏不变量(见计划「实现裁决」第 1 条)。 // 避免出现「副图的副图」破坏不变量(见计划「实现裁决」第 1 条)。
// 幂等:已是 mainImgID 副图的行重复并入不报错。 // 幂等:已是 mainImgID 副图的行重复并入不报错。
AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error 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 把副图恢复为主图(is_detail=0, parent_image_id=0)。
// 对主图调用是幂等成功(无副作用)。 // 对主图调用是幂等成功(无副作用)。
DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error 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 把副图恢复为主图。对主图调用为幂等成功。 // DetachStreetDraftImage 把副图恢复为主图。对主图调用为幂等成功。
// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。 // 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。
func (r *reviewRepository) DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error { func (r *reviewRepository) DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {

View File

@ -108,22 +108,6 @@ func TestStreetMainDetail(t *testing.T) {
t.Fatalf("对主图拆出应幂等成功: %v", err) 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) { if err := repo.AttachStreetDraftImages(ctx, draftID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err) t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err)

View File

@ -96,7 +96,6 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewDraftImageDelete) admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewDraftImageDelete)
// 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。 // 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。
admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachDraftImages) 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.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachDraftImage)
// 爬虫入库任务监控:列出处理进度 + 失败重试 // 爬虫入库任务监控:列出处理进度 + 失败重试
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs) admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)

View File

@ -324,13 +324,6 @@ func (f *fakeReview) AttachDraftImages(_ context.Context, kind string, draftID,
} }
return nil 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 { 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" f.last.kind, f.last.draftID, f.last.imgID, f.last.op = kind, draftID, imgID, "detach"
if !f.attachOK { if !f.attachOK {
@ -363,7 +356,7 @@ func reviewToken(t *testing.T, r *gin.Engine) *http.Cookie {
return cookies[0] return cookies[0]
} }
// TestBackstageStreetAttachRedirect 批量并入应 302 跳回带 ?main= 的详情页,且参数透传到 service。 // TestBackstageStreetAttachRedirect 合并选中应 302 跳回详情页,且参数透传到 service。
func TestBackstageStreetAttachRedirect(t *testing.T) { func TestBackstageStreetAttachRedirect(t *testing.T) {
r, rev := newReviewRouter(t) r, rev := newReviewRouter(t)
tok := reviewToken(t, r) tok := reviewToken(t, r)
@ -374,34 +367,35 @@ func TestBackstageStreetAttachRedirect(t *testing.T) {
req.AddCookie(tok) req.AddCookie(tok)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
if w.Code != http.StatusFound { 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") { if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7") {
t.Fatalf("重定向应带 ?main=1,实际 %s", w.Header().Get("Location")) t.Fatalf("应重定向回详情页,实际 %s", w.Header().Get("Location"))
} }
if rev.last.op != "attach" || rev.last.mainID != 1 || len(rev.last.imgIDs) != 2 { if rev.last.op != "attach" || rev.last.mainID != 1 || len(rev.last.imgIDs) != 2 {
t.Fatalf("参数透传错误: %+v", rev.last) t.Fatalf("参数透传错误: %+v", rev.last)
} }
} }
// TestBackstageStreetAttachPrev 并入上一张应 302。 // TestBackstageStreetAttachNeedsMain 未选主图时应 400,而不是静默失败。
func TestBackstageStreetAttachPrev(t *testing.T) { func TestBackstageStreetAttachNeedsMain(t *testing.T) {
r, rev := newReviewRouter(t) r, rev := newReviewRouter(t)
tok := reviewToken(t, r) tok := reviewToken(t, r)
w := httptest.NewRecorder() 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) req.AddCookie(tok)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
if w.Code != http.StatusFound { if w.Code != http.StatusBadRequest {
t.Fatalf("并入上一张应 302,实际 %d", w.Code) t.Fatalf("未选主图应 400,实际 %d", w.Code)
} }
if rev.last.op != "attach-prev" || rev.last.imgID != 2 { if rev.last.op == "attach" {
t.Fatalf("参数透传错误: %+v", rev.last) t.Fatalf("未选主图不应调用 service")
} }
} }
// TestBackstageStreetDetach 拆出应 302 跳回「以该图为主图」的详情页。 // TestBackstageStreetDetach 拆出应 302 跳回详情页。
func TestBackstageStreetDetach(t *testing.T) { func TestBackstageStreetDetach(t *testing.T) {
r, rev := newReviewRouter(t) r, rev := newReviewRouter(t)
tok := reviewToken(t, r) tok := reviewToken(t, r)
@ -413,22 +407,22 @@ func TestBackstageStreetDetach(t *testing.T) {
if w.Code != http.StatusFound { if w.Code != http.StatusFound {
t.Fatalf("拆出应 302,实际 %d", w.Code) t.Fatalf("拆出应 302,实际 %d", w.Code)
} }
if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7?main=2") { if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7") {
t.Fatalf("拆出后应以该图为主图跳转,实际 %s", w.Header().Get("Location")) t.Fatalf("应跳回详情页,实际 %s", w.Header().Get("Location"))
} }
if rev.last.op != "detach" || rev.last.imgID != 2 { if rev.last.op != "detach" || rev.last.imgID != 2 {
t.Fatalf("参数透传错误: %+v", rev.last) t.Fatalf("参数透传错误: %+v", rev.last)
} }
} }
// TestBackstageStreetRenderGroups 详情页应按组折叠渲染(strip-group + 副图名)。 // TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。
func TestBackstageStreetRenderGroups(t *testing.T) { func TestBackstageStreetRenderGroups(t *testing.T) {
r, rev := newReviewRouter(t) r, rev := newReviewRouter(t)
tok := reviewToken(t, r) tok := reviewToken(t, r)
rev.view = &service.DraftDetailView{ rev.view = &service.DraftDetailView{
Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending", Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending",
Images: []service.DraftImageRef{ 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: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0}, {ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
}, },
@ -439,36 +433,32 @@ func TestBackstageStreetRenderGroups(t *testing.T) {
} }
w := httptest.NewRecorder() 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) req.AddCookie(tok)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("详情应 200,实际 %d %s", w.Code, w.Body.String()) t.Fatalf("详情应 200,实际 %d %s", w.Code, w.Body.String())
} }
body := w.Body.String() body := w.Body.String()
if !strings.Contains(body, "strip-group") {
t.Fatalf("应按组折叠渲染 strip-group")
}
if !strings.Contains(body, "detail-a") { if !strings.Contains(body, "detail-a") {
t.Fatalf("应渲染副图 detail-a") t.Fatalf("应渲染副图 detail-a")
} }
// 「设为主图」必须是带 ?main= 的 GET 链接(且带 #i<id> 锚点,选完不跳回顶部)。 // 副图徽标显示所属主图(沿链归并后的实际主图)。
// (曾被误写成 POST 到 /admin/reviews/street/7,而该路由只有 GET,导致 404、主图根本选不了。) if !strings.Contains(body, "副图 → #1") {
if !strings.Contains(body, `href="/admin/reviews/street/7?main=1#i1"`) { t.Fatalf("副图应显示所属主图")
t.Fatalf("「设为主图」应是带 ?main= 与 #i<id> 锚点的 GET 链接")
} }
if strings.Contains(body, `action="/admin/reviews/street/7"`) { // 统一网格 + 勾选 + 主图单选 + 底部提交按钮,全部用 form="batch" 关联(零 JS、不嵌套表单)。
t.Fatalf("不应存在 POST 到详情页的表单(该路由只有 GET)")
}
// 批量并入:勾选框 + 独立提交按钮,用 form="batch" 关联(零 JS、不嵌套表单)。
if !strings.Contains(body, `id="batch"`) || !strings.Contains(body, `form="batch"`) { if !strings.Contains(body, `id="batch"`) || !strings.Contains(body, `form="batch"`) {
t.Fatalf("应有勾选批量并入当前主图的表单") t.Fatalf("应有勾选合并的表单")
} }
// 「并入上一张」必须出现在每一张图(除第一张)上,否则「连拍同一个人」无法快捷归并。 if !strings.Contains(body, `name="main"`) {
if !strings.Contains(body, `/admin/reviews/street/7/images/3/attach-prev`) { t.Fatalf("应有主图单选(name=main)")
t.Fatalf("非首张图应有「并入上一张」")
} }
if strings.Contains(body, `/admin/reviews/street/7/images/1/attach-prev`) { if !strings.Contains(body, "合并选中为一组") {
t.Fatalf("首张图不应有「并入上一张」(没有上一张)") t.Fatalf("底部应有「合并选中为一组」按钮")
}
// 「并入上一张」已废弃:不应再出现在页面或表单里。
if strings.Contains(body, "并入上一张") || strings.Contains(body, "attach-prev") {
t.Fatalf("不应再出现「并入上一张」")
} }
} }

View File

@ -70,9 +70,6 @@ type DraftDetailView struct {
Images []DraftImageRef `json:"images"` Images []DraftImageRef `json:"images"`
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。 // Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
Fields []EditField `json:"fields"` Fields []EditField `json:"fields"`
// MainID 审核页「当前主图」的行 id,来自 URL 查询参数 ?main=;0 表示未选定。
// 只有街拍模块会用到(走秀无主副图概念)。
MainID uint32 `json:"main_id"`
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。 // Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。 // 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
Groups []DraftImageGroup `json:"groups,omitempty"` Groups []DraftImageGroup `json:"groups,omitempty"`
@ -127,8 +124,6 @@ type ReviewService interface {
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
// AttachDraftImages 把若干张草稿图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。 // AttachDraftImages 把若干张草稿图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error 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 把副图拆出恢复为主图(街拍专用)。
DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。 // KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
@ -299,13 +294,6 @@ func (s *reviewService) AttachDraftImages(ctx context.Context, kind string, draf
return translateGroupErr(s.repo.AttachStreetDraftImages(ctx, draftID, mainImgID, imgIDs)) 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 { func (s *reviewService) DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error {
if kind != dto.IngestKindStreet { if kind != dto.IngestKindStreet {
return ErrInvalidImageGroup return ErrInvalidImageGroup
@ -490,8 +478,10 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
root := resolveRoot(im.ID) root := resolveRoot(im.ID)
isDetail := root != im.ID isDetail := root != im.ID
isDetailVal := uint8(0) isDetailVal := uint8(0)
parent := uint32(0) // 副图徽标显示「所属主图」:取沿链归并后的根主图;主图恒为 0
if isDetail { if isDetail {
isDetailVal = 1 isDetailVal = 1
parent = root
} }
ref := DraftImageRef{ ref := DraftImageRef{
ID: im.ID, ID: im.ID,
@ -499,7 +489,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
Name: im.Name, Name: im.Name,
SortOrder: im.SortOrder, SortOrder: im.SortOrder,
IsDetail: isDetailVal, IsDetail: isDetailVal,
ParentImageID: im.ParentImageID, ParentImageID: parent,
DetailCount: detailCount[im.ID], DetailCount: detailCount[im.ID],
} }
v.Images = append(v.Images, ref) v.Images = append(v.Images, ref)

View File

@ -34,13 +34,6 @@ func (f *fakeGroupRepo) AttachStreetDraftImages(_ context.Context, draftID, main
return nil 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 { func (f *fakeGroupRepo) DetachStreetDraftImage(_ context.Context, _ uint32, _ uint32) error {
if f.attachFn != nil { if f.attachFn != nil {
return f.attachFn(0, 0, nil) return f.attachFn(0, 0, nil)