feat(street): 后台审核页街拍主副图分组渲染与操作路由
This commit is contained in:
@ -42,7 +42,10 @@ func NewBackstageHandler(auth service.AuthService, brand service.BrandService, a
|
||||
// pagerTmpl 是所有后台列表页共享的翻页片段定义(用法:{{template "pager" .Pager}})。
|
||||
// 以「base」为根模板并只含一个 define;renderHTML 每次渲染时 Clone 它再灌入页面正文,
|
||||
// 这样页面模板即可直接引用 {{template "pager"}} 而无需各自复制翻页 HTML。
|
||||
var pagerTmpl = template.Must(template.New("base").Parse(`{{define "pager"}}<div class="pager">
|
||||
var pagerTmpl = template.Must(template.New("base").Funcs(template.FuncMap{
|
||||
// reviewDetailURL 生成审核详情页地址;main 非零时附加 ?main= 以保留折叠状态。
|
||||
"reviewDetailURL": reviewDetailURL,
|
||||
}).Parse(`{{define "pager"}}<div class="pager">
|
||||
{{if .HasPrev}}<a href="{{.PrevURL}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
|
||||
{{if .Label}}<span class="muted">{{.Label}}</span>{{end}}
|
||||
{{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
|
||||
@ -394,6 +397,8 @@ 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 {
|
||||
@ -465,6 +470,76 @@ func (h *BackstageHandler) ReviewDraftImageDelete(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id"))
|
||||
}
|
||||
|
||||
// ReviewAttachDraftImages 把若干张草稿图并入指定主图(街拍审核页「批量并入」)。
|
||||
func (h *BackstageHandler) ReviewAttachDraftImages(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
if id == 0 {
|
||||
renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "参数无效"})
|
||||
return
|
||||
}
|
||||
mainID := uint32(atoiDefault(c.PostForm("main"), 0))
|
||||
imgIDs := make([]uint32, 0)
|
||||
for _, s := range c.PostFormArray("img") {
|
||||
if v := atoiDefault(s, 0); v > 0 {
|
||||
imgIDs = append(imgIDs, uint32(v))
|
||||
}
|
||||
}
|
||||
if mainID == 0 {
|
||||
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))
|
||||
}
|
||||
|
||||
// ReviewDetachDraftImage 把副图拆出恢复为主图。
|
||||
func (h *BackstageHandler) ReviewDetachDraftImage(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.DetachDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
|
||||
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 拆出后该图自身成为主图,跳转回到「以它为主图」的折叠视图。
|
||||
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id), uint32(imgID)))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews)。
|
||||
func reviewListURL(kind string) string {
|
||||
if kind == dto.IngestKindRunway || kind == dto.IngestKindStreet {
|
||||
@ -1252,35 +1327,84 @@ body{font:14px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;backgrou
|
||||
.bar{background:#111;color:#fff;padding:12px 24px;display:flex;justify-content:space-between;align-items:center}
|
||||
.bar a{color:#fff;text-decoration:none}
|
||||
.main{max-width:900px;margin:24px auto;padding:0 24px}
|
||||
h1{font-size:20px}
|
||||
h2{font-size:16px;margin:22px 0 8px}
|
||||
.muted{color:#777}
|
||||
label{display:block;margin:14px 0 4px;color:#555}
|
||||
input,textarea{width:100%;padding:9px 10px;box-sizing:border-box;border:1px solid #ccc;border-radius:6px;font-size:14px;font-family:inherit}
|
||||
textarea{min-height:72px}
|
||||
button{margin-top:16px;padding:10px 18px;background:#111;color:#fff;border:0;border-radius:6px;cursor:pointer;font-size:14px}
|
||||
button.reject{background:#c0392b}
|
||||
.row{display:flex;gap:14px}.row>div{flex:1}
|
||||
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 .acts{padding:8px;display:flex;gap:6px}
|
||||
.cell .acts button{margin:0;flex:1;padding:7px;font-size:13px}
|
||||
.kind{font-size:12px;padding:1px 7px;border-radius:10px;background:#eef2ff;color:#3b4bb5}
|
||||
.kind.street{background:#eafaf0;color:#1a7f43}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="bar"><span>管理后台 · 草稿审核 #{{.Draft.ID}}</span><span><a href="/admin/reviews{{if ne .Draft.Kind ""}}/{{.Draft.Kind}}{{end}}">返回列表</a> · <a href="/admin/logout">退出</a></span></div>
|
||||
<div class="bar"><span>管理后台 · 草稿审核 #{{.Draft.ID}}</span><span><a href="{{reviewDetailURL .Draft.Kind .Draft.ID}}">返回列表</a> · <a href="/admin/logout">退出</a></span></div>
|
||||
<div class="main">
|
||||
<p class="muted">类型 <b>{{.Draft.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Draft.Status}}</b></p>
|
||||
<form method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/approve">
|
||||
{{range .Draft.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}}
|
||||
<button type="submit">通过并发布</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/reject" style="margin-top:18px">
|
||||
<label>驳回理由(可选)</label><input name="reason" placeholder="如:图片缺失 / 信息有误">
|
||||
<button type="submit" class="reject">驳回</button>
|
||||
</form>
|
||||
<h2 style="margin-top:24px">图片管理({{len .Draft.Images}} 张)</h2>
|
||||
{{if eq .Draft.Kind "street"}}{{if .Draft.MainID}}<div class="hint">当前主图:#{{.Draft.MainID}}(折叠视图;点「拆出」可恢复独立主图)</div>{{end}}
|
||||
<h2>主副图分组({{len .Draft.Groups}} 组 · 共 {{.Draft.ImageCount}} 张)</h2>
|
||||
{{if .Draft.Groups}}{{range .Draft.Groups}}<div class="strip-group{{if eq .Image.ID $.Draft.MainID}} cur{{end}}">
|
||||
<div class="main-img">
|
||||
<img src="{{.Image.Image}}" loading="lazy">
|
||||
<div class="acts">
|
||||
<form method="post" class="inline" action="{{reviewDetailURL $.Draft.Kind $.Draft.ID}}"><input type="hidden" name="main" value="{{.Image.ID}}"><button type="submit" class="ghost">设为主图</button></form>
|
||||
<form method="post" class="inline" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.Image.ID}}/detach"><button type="submit" onclick="return confirm('确认把这张主图拆成独立主图?')">拆出</button></form>
|
||||
<form method="post" class="inline" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.Image.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Details}}<div class="strip">
|
||||
{{range .Details}}<div class="det">
|
||||
<img src="{{.Image}}" loading="lazy">
|
||||
<span style="display:block;padding:4px 6px;font-size:11px;color:#666">{{.Name}}</span>
|
||||
<div class="acts">
|
||||
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/attach-prev"><input type="hidden" name="main" value="{{$.Draft.MainID}}"><button type="submit" onclick="return confirm('把这张并入上一张所属主图?')">并入上一张</button></form>
|
||||
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/detach"><button type="submit" 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>{{end}}
|
||||
</div>{{end}}
|
||||
</div>{{end}}{{else}}<p class="muted">该草稿暂无图片。</p>{{end}}
|
||||
<h2>全部图片(点选并入当前主图)</h2>
|
||||
<div class="grid">
|
||||
{{range .Draft.Images}}<div class="cell">
|
||||
<img src="{{.Image}}" loading="lazy">
|
||||
<span style="display:block;padding:6px 8px;font-size:12px;color:#666">{{.Name}}{{if eq .IsDetail 1}} · 副图{{end}}</span>
|
||||
<div class="acts">
|
||||
{{if eq .IsDetail 1}}
|
||||
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/detach"><button type="submit" 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>
|
||||
{{else}}
|
||||
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/attach"><input type="hidden" name="main" value="{{$.Draft.MainID}}"><input type="hidden" name="img" value="{{.ID}}"><button type="submit"{{if eq .ID $.Draft.MainID}} disabled{{end}}>并入当前主图</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>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<h2>图片管理({{len .Draft.Images}} 张)</h2>
|
||||
<div class="grid">
|
||||
{{range .Draft.Images}}<div class="cell">
|
||||
<img src="{{.Image}}" loading="lazy">
|
||||
@ -1290,6 +1414,7 @@ button.reject{background:#c0392b}
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div></body></html>`
|
||||
|
||||
const tplRunwayEdit = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
||||
|
||||
@ -94,6 +94,10 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
||||
admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove)
|
||||
admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject)
|
||||
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)
|
||||
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)
|
||||
|
||||
@ -14,6 +14,8 @@ import (
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/jwt"
|
||||
"fashionapi/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ── 测试用假实现(不连库)──────────────────────────────────────────────
|
||||
@ -175,7 +177,7 @@ func TestBackstage(t *testing.T) {
|
||||
m := jwt.NewManager("test-secret", 168)
|
||||
auth := &fakeAuth{jwt: m}
|
||||
brand := &fakeBrand{}
|
||||
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, nil, nil)
|
||||
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil)
|
||||
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
||||
|
||||
// 1) 登录页可访问
|
||||
@ -288,3 +290,166 @@ func TestBackstage(t *testing.T) {
|
||||
t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── 街拍主副图分组:路由 / 重定向 / 渲染(无需真实数据库)─────────────────
|
||||
|
||||
// fakeReview 实现审核服务接口;嵌入接口兜底未用到的方法,仅覆盖分组相关 4 个。
|
||||
type fakeReview struct {
|
||||
service.ReviewService
|
||||
attachOK bool
|
||||
last struct {
|
||||
kind string
|
||||
draftID uint32
|
||||
mainID uint32
|
||||
imgIDs []uint32
|
||||
imgID uint32
|
||||
op string
|
||||
}
|
||||
view *service.DraftDetailView
|
||||
}
|
||||
|
||||
var _ service.ReviewService = (*fakeReview)(nil)
|
||||
|
||||
func (f *fakeReview) DraftDetail(_ context.Context, _ string, _ uint32) (*service.DraftDetailView, error) {
|
||||
if f.view != nil {
|
||||
return f.view, nil
|
||||
}
|
||||
return &service.DraftDetailView{Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 2, Status: "pending"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeReview) AttachDraftImages(_ context.Context, kind string, draftID, mainID uint32, imgIDs []uint32) error {
|
||||
f.last.kind, f.last.draftID, f.last.mainID, f.last.imgIDs, f.last.op = kind, draftID, mainID, imgIDs, "attach"
|
||||
if !f.attachOK {
|
||||
return errors.New("主图无效")
|
||||
}
|
||||
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 {
|
||||
return errors.New("主图无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
|
||||
t.Helper()
|
||||
m := jwt.NewManager("test-secret", 168)
|
||||
auth := &fakeAuth{jwt: m}
|
||||
brand := &fakeBrand{}
|
||||
rev := &fakeReview{attachOK: true}
|
||||
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil)
|
||||
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
||||
return r, rev
|
||||
}
|
||||
|
||||
func reviewToken(t *testing.T, r *gin.Engine) *http.Cookie {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader("account=admin&password=secret"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.ServeHTTP(w, req)
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatal("登录未下发会话 cookie")
|
||||
}
|
||||
return cookies[0]
|
||||
}
|
||||
|
||||
// TestBackstageStreetAttachRedirect 批量并入应 302 跳回带 ?main= 的详情页,且参数透传到 service。
|
||||
func TestBackstageStreetAttachRedirect(t *testing.T) {
|
||||
r, rev := newReviewRouter(t)
|
||||
tok := reviewToken(t, r)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach", strings.NewReader("main=1&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 %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 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) {
|
||||
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.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("并入上一张应 302,实际 %d", w.Code)
|
||||
}
|
||||
if rev.last.op != "attach-prev" || rev.last.imgID != 2 {
|
||||
t.Fatalf("参数透传错误: %+v", rev.last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackstageStreetDetach 拆出应 302 跳回「以该图为主图」的详情页。
|
||||
func TestBackstageStreetDetach(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/detach", nil)
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
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 rev.last.op != "detach" || rev.last.imgID != 2 {
|
||||
t.Fatalf("参数透传错误: %+v", rev.last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackstageStreetRenderGroups 详情页应按组折叠渲染(strip-group + 副图名)。
|
||||
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: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
|
||||
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
|
||||
},
|
||||
Groups: []service.DraftImageGroup{
|
||||
{Image: service.DraftImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.DraftImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}},
|
||||
{Image: service.DraftImageRef{ID: 3, Image: "solo.jpg", Name: "solo"}},
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7?main=1", 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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user