refactor(street): 审核页改为统一网格+勾选成组;移除并入上一张
This commit is contained in:
@ -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">
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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("不应再出现「并入上一张」")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user