This commit is contained in:
toom1996
2026-10-18 17:42:10 +08:00
parent 1b06bd5bda
commit 1de1ec46a6
58 changed files with 5281 additions and 19191 deletions

View File

@ -243,9 +243,12 @@ tbody tr:last-child td { border-bottom: 0; }
/* published 是单表发布模型的「已通过」(旧值 approved 已被 published 取代)。 */
.badge.published, .badge.approved, .badge.done { background: #e6f7ec; color: #1a7f43; }
.badge.rejected, .badge.failed { background: #fdeaea; color: #c0392b; }
.badge.dup { background: #f3e8ff; color: #6b21a8; }
.badge.free { background: #eef2ff; color: #3b4bb5; }
.badge.vip { background: #fff4e0; color: #a85b00; }
/* 列表页「跨图集重复」标记:用紫(沿用历史上「重复」的色),刻意区别于状态徽标的红/绿。
* 点击直达该图集里第一张重复的图,免得逐张找。 */
.badge.dup { background: #f3ecfd; color: #6b3fa0; text-decoration: none; }
.badge.dup:hover { background: #e9dcfa; }
.kind {
display: inline-block;
font-size: 12px;
@ -502,21 +505,38 @@ tbody tr:last-child td { border-bottom: 0; }
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
}
/* ---------- 编辑页重复图标记 ---------- */
.cell .badge.dup { margin-left: 8px; vertical-align: middle; }
.cell .dup-src { margin: 0 8px 6px; font-size: 12px; }
.cell .dup-src > summary { cursor: pointer; color: #6b21a8; user-select: none; outline: none; }
.cell .dup-src-body {
/* ---------- 编辑页「疑似同款」标记 ---------- */
/* 仅提示、不隐藏:角标用琥珀色,刻意区别于「删除/驳回」的红与旧「重复」的紫,避免误读为已折叠。
* 角标内带对方的缩略图与 Look 名:必须让人一眼看出「和哪张图像」,
* 只给一个相似度分数,浏览者无法在图集里定位到具体那张。 */
.badge.similar {
display: flex;
gap: 8px;
align-items: center;
margin-top: 6px;
padding: 6px;
background: #faf5ff;
border: 1px solid #e9d5ff;
border-radius: 6px;
gap: 6px;
margin: 0 8px 6px;
padding: 4px 6px;
background: #fff8e1;
color: #8a6d00;
border: 1px solid #f0e0a0;
text-decoration: none;
white-space: normal;
}
.cell .dup-src-body img { width: 56px; height: 84px; object-fit: cover; border-radius: 4px; flex: 0 0 auto; background: #eee; }
.cell .dup-title { margin-bottom: 2px; color: #333; }
.cell .dup-link { color: #6b21a8; font-weight: 600; text-decoration: none; }
.cell .dup-link:hover { text-decoration: underline; }
.badge.similar:hover { background: #fff3c4; }
.badge.similar img {
width: 26px;
height: 39px;
object-fit: cover;
border-radius: 3px;
flex: 0 0 auto;
background: #f1f1f2;
}
.badge.similar .sim-text { font-size: 11px; line-height: 1.3; min-width: 0; }
.badge.similar .sim-text b {
display: block;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 近邻若落在默认隐藏的副图(.cell.det)上,锚点定位后必须可见,否则跳过去是一片空白。 */
.cell.det:target { display: flex; }

View File

@ -16,7 +16,9 @@
var img = document.getElementById('zoom-img');
document.querySelectorAll('.thumb[data-zoom]').forEach(function (btn) {
btn.addEventListener('click', function () {
img.src = btn.querySelector('img').src;
// 列表里显示的是缩略图(省流量、页面快),点开时才取 data-full 的全尺寸图;
// 未提供 data-full 时回落可见的 img.src。
img.src = btn.dataset.full || btn.querySelector('img').src;
modal.showModal();
});
});

View File

@ -3,6 +3,8 @@ package handler
import (
"context"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
@ -37,11 +39,27 @@ type BackstageHandler struct {
ingest *service.IngestService
adminRepo repository.AdminRepository
translate *service.TranslateService // 标题翻译(可为 nil:未启用时跳过记忆写入/建议)
// similar 「疑似同款」提示(可为 nil:视觉服务未启用时页面不显示该角标)。
// 只提示、不隐藏、不计入重复率——与去重无关。
similar *service.SimilarService
// manual 后台「手工新建文章」(可为 nil:未装配时新建入口提示不可用)。
// 用 SetManual 注入,避免改构造签名(同 SetSimilar 的取舍)。
manual *service.ManualArticleService
}
// SetManual 注入手工建稿服务(装配期调用;未注入时新建页提示不可用)。
func (h *BackstageHandler) SetManual(svc *service.ManualArticleService) { h.manual = svc }
// manualSeasonOptions / manualCollectionOptions 手工建稿表单的下拉选项。
// 与 season.Derive 支持的取值保持一致(collection_type 优先决定 season_code 前缀)。
var (
manualSeasonOptions = []string{"spring", "fall", "resort", "pre_fall"}
manualCollectionOptions = []string{"rtw", "menswear", "couture", "resort", "pre_fall"}
)
// NewBackstageHandler 创建后台 handler。
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService, adminRepo repository.AdminRepository, translate *service.TranslateService) *BackstageHandler {
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo, translate: translate}
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService, adminRepo repository.AdminRepository, translate *service.TranslateService, similar *service.SimilarService) *BackstageHandler {
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo, translate: translate, similar: similar}
}
// 模板渲染机制见 templates.go:模板已抽成 templates/ 下的独立 .html 文件(go:embed),
@ -122,8 +140,11 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
q := dto.BrandQuery{
Keyword: c.Query("keyword"),
Letter: c.Query("letter"),
Page: atoiDefault(c.Query("page"), 1),
Locale: "en",
// all=1:连「没有图集」的品牌一起列。品牌目录有 2 万+ 条导入数据,
// 默认按「有图集」收敛,否则后台列表等于看不到导入的品牌。
All: c.Query("all") == "1",
Page: atoiDefault(c.Query("page"), 1),
Locale: "en",
}
items, total, err := h.brand.AdminList(c.Request.Context(), q)
if err != nil {
@ -141,6 +162,8 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
"Total": total,
"Keyword": q.Keyword,
"Letter": q.Letter,
"All": q.All,
"Letters": lettersAtoZ(),
"Pager": pagination.Pager{
PrevURL: pagination.PageLink(c, meta.PrevPage),
NextURL: pagination.PageLink(c, meta.NextPage),
@ -151,6 +174,25 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
})
}
// lettersAtoZ 返回 A-Z 字母索引分桶,供品牌页渲染索引条(避免模板里硬写 26 个 <a>)。
func lettersAtoZ() []string {
out := make([]string, 0, 26)
for ch := 'A'; ch <= 'Z'; ch++ {
out = append(out, string(ch))
}
return out
}
// BrandDelete 软删品牌。护栏在 service 层(有未删除图集时拒绝),
// 失败时把原因渲染到错误页(含还剩多少图集),便于运营先处理档案。
func (h *BackstageHandler) BrandDelete(c *gin.Context) {
if err := h.brand.Delete(c.Request.Context(), c.Param("id")); err != nil {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/brands")
}
// BrandNew 渲染品牌新建表单。
func (h *BackstageHandler) BrandNew(c *gin.Context) {
renderPage(c, http.StatusOK, "brand-form", gin.H{})
@ -330,10 +372,10 @@ func (h *BackstageHandler) Articles(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
// 批量统计每篇图集的重复率(基于 is_duplicate 列,阈值 phash.DefaultThreshold=4)。
dupStats := h.dupStatsByCards(c.Request.Context(), cards)
// 批量统计每篇图集的原图总大小(基于 image_size 列,worker 下载时写入源文件字节数)。
imageSizes := h.imageSizeByCards(c.Request.Context(), cards)
// 批量取每篇图集的原图统计(字节数 + 主/副图行数),与列表共用一次扫描。
stats := h.imageStatsByCards(c.Request.Context(), cards)
// 跨图集重复概况:列表上直接标出「哪些图集有重复」,否则只能逐个点进图集才能发现。
dupStats := h.similarDupStats(c.Request.Context())
items := make([]gin.H, 0, len(cards))
for _, card := range cards {
var t byte
@ -342,22 +384,32 @@ func (h *BackstageHandler) Articles(c *gin.Context) {
} else {
t = hashid.TypeSnap
}
ds := dupStats[card.ID]
dupPct := 0.0
if ds.Total > 0 {
dupPct = float64(ds.Dup) / float64(ds.Total) * 100
st := stats[fmt.Sprintf("%s:%d", card.Kind, card.ID)]
dup := service.DupStat{}
if byOwner, ok := dupStats[card.Kind]; ok {
dup = byOwner[card.ID]
}
// 重复率的分母用实时统计的真实图片数,不用过期的 image_count 缓存列。
dupRate := 0.0
if dup.Total > 0 {
dupRate = float64(dup.Images) * 100 / float64(dup.Total)
}
items = append(items, gin.H{
"Kind": card.Kind,
"UID": hashid.EncodeWithType(card.ID, t),
"ID": card.ID,
"Title": card.Title,
"Subtitle": card.Subtitle,
"ImageCount": card.ImageCount,
"ImageTotalSize": imageSizes[card.ID],
"Status": card.Status,
"DupCount": ds.Dup,
"DupPct": dupPct,
"Kind": card.Kind,
"UID": hashid.EncodeWithType(card.ID, t),
"ID": card.ID,
"Title": card.Title,
"Subtitle": card.Subtitle,
"Status": card.Status,
"ImageCount": card.ImageCount, // 图集表缓存值:只数主图(公开角标口径)
"ImageMain": st.Main, // 真实主图行数
"ImageDetail": st.Detail, // 真实副图(细节图)行数
"ImageTotal": st.Main + st.Detail, // 真实图数 = 主 + 副
"ImageTotalSize": st.Size,
"DupImages": dup.Images,
"DupTotal": dup.Total,
"DupRate": dupRate,
"DupFirstImageID": dup.FirstImageID,
})
}
// 状态 tab 角标(空串=全部)。
@ -371,9 +423,11 @@ func (h *BackstageHandler) Articles(c *gin.Context) {
p := pagination.Params{Page: page, PageSize: size}
meta := p.NewMeta(total)
renderPage(c, http.StatusOK, "articles", gin.H{
"Items": items,
"Status": status,
"Kind": kind,
"Items": items,
"Status": status,
"Kind": kind,
// ShowDup 为 false 时模板不渲染「重复」列(视觉服务未启用时整列无意义)。
"ShowDup": len(dupStats) > 0,
"TabCounts": tabCounts,
"Pager": pagination.Pager{
PrevURL: pagination.PageLink(c, meta.PrevPage),
@ -385,64 +439,146 @@ func (h *BackstageHandler) Articles(c *gin.Context) {
})
}
// dupStatsByCards 按卡片类型批量取每篇图集的重复统计(is_duplicate=1 张数 / 总存活图片数)。
// 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无统计」(不阻断列表)。
func (h *BackstageHandler) dupStatsByCards(ctx context.Context, cards []repository.AdminRecordCard) map[uint32]repository.DupStat {
out := make(map[uint32]repository.DupStat)
runwayIDs := make([]uint32, 0, len(cards))
streetIDs := make([]uint32, 0, len(cards))
// similarDupStats 取「跨图集重复」汇总(kind -> 图集 id -> 概况)。
// 服务未注入(视觉服务未启用)时返回 nil,列表页据此整列不渲染。
func (h *BackstageHandler) similarDupStats(ctx context.Context) map[string]map[uint32]service.DupStat {
if h.similar == nil {
return nil
}
return h.similar.DupStats(ctx)
}
// similarHintView 是「疑似同款」提示的模板视图:在 service 的 SimilarHint 基础上
// 补上 hashid 编码后的所属记录 UID(模板里用于生成跳转链接)。
//
// Image / Name / OwnerTitle 让角标能直接显示「和哪张图像」——
// 只给一个分数,浏览者无法在图集里定位到具体是哪张。
type similarHintView struct {
ImageID uint32
Similar float64
UID string // 近邻所属走秀 / 街拍的编码 UID
Image string // 近邻图片存储 key(模板显示缩略图)
Name string // 近邻图片名(如 "Look 12")
OwnerTitle string // 近邻所属图集标题
}
// similarHints 查询一批图片的「疑似同款」提示;similar 未注入(视觉服务未启用)时返回 nil。
//
// 该查询只服务于后台角标展示:service 层已吞掉所有错误(取向量失败、近邻查询失败都只记日志),
// 因此这里不返回 error —— 查不到同款绝不能让图集页渲染失败。
func (h *BackstageHandler) similarHints(ctx context.Context, kind string, imageIDs []uint32) map[uint32][]similarHintView {
if h.similar == nil || len(imageIDs) == 0 {
return nil
}
raw := h.similar.HintsForImages(ctx, kind, imageIDs)
if len(raw) == 0 {
return nil
}
typ := byte(hashid.TypeRunway)
if kind == "street" {
typ = byte(hashid.TypeSnap)
}
out := make(map[uint32][]similarHintView, len(raw))
for imgID, hints := range raw {
views := make([]similarHintView, 0, len(hints))
for _, hint := range hints {
views = append(views, similarHintView{
ImageID: hint.ImageID,
Similar: hint.Similar,
UID: hashid.EncodeWithType(hint.OwnerID, typ),
Image: hint.Image,
Name: hint.Name,
OwnerTitle: hint.OwnerTitle,
})
}
out[imgID] = views
}
return out
}
// imageStatsByCards 按卡片类型批量取每篇图集的原图统计(字节数 + 主/副图行数)。
// 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无数据」(不阻断列表)。
//
// key 用 "kind:id" 而非裸 id:两张表主键各自独立,裸 id 会让同号记录(如 runway 7 与 street 7)
// 互相覆盖,把对方的图数与大小显示成本条的值。
func (h *BackstageHandler) imageStatsByCards(ctx context.Context, cards []repository.AdminRecordCard) map[string]repository.AdminRecordStats {
out := make(map[string]repository.AdminRecordStats, len(cards))
byKind := make(map[string][]uint32, 2)
for _, c := range cards {
if c.Kind == "runway" {
runwayIDs = append(runwayIDs, c.ID)
} else {
streetIDs = append(streetIDs, c.ID)
}
byKind[c.Kind] = append(byKind[c.Kind], c.ID)
}
if len(runwayIDs) > 0 {
if m, err := h.adminRepo.DupRateByRecords(ctx, "runway", runwayIDs); err == nil {
for id, s := range m {
out[id] = s
}
for kind, ids := range byKind {
if len(ids) == 0 {
continue
}
}
if len(streetIDs) > 0 {
if m, err := h.adminRepo.DupRateByRecords(ctx, "street", streetIDs); err == nil {
for id, s := range m {
out[id] = s
}
m, err := h.adminRepo.ImageSizeByRecords(ctx, kind, ids)
if err != nil {
continue
}
for id, st := range m {
out[fmt.Sprintf("%s:%d", kind, id)] = st
}
}
return out
}
// imageSizeByCards 按卡片类型批量取每篇图集的原图字节数之和(image_size 列)。
// 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无大小」(不阻断列表)。
func (h *BackstageHandler) imageSizeByCards(ctx context.Context, cards []repository.AdminRecordCard) map[uint32]int64 {
out := make(map[uint32]int64)
runwayIDs := make([]uint32, 0, len(cards))
streetIDs := make([]uint32, 0, len(cards))
for _, c := range cards {
if c.Kind == "runway" {
runwayIDs = append(runwayIDs, c.ID)
} else {
streetIDs = append(streetIDs, c.ID)
}
// ArticleNew 渲染手工新建文章表单(走秀 / 街拍二选一,一步到位带图片上传)。
func (h *BackstageHandler) ArticleNew(c *gin.Context) {
kind := c.Query("kind")
if kind != "street" {
kind = "runway"
}
if len(runwayIDs) > 0 {
if m, err := h.adminRepo.ImageSizeByRecords(ctx, "runway", runwayIDs); err == nil {
for id, s := range m {
out[id] = s
renderPage(c, http.StatusOK, "article-form", gin.H{
"Kind": kind,
"Seasons": manualSeasonOptions,
"Collections": manualCollectionOptions,
})
}
// ArticleCreate 处理手工新建:解析 multipart(元数据 + 多张图片)→ 上传 + 落库(status=pending)。
// 校验失败时把错误和已填内容一起回填到表单,避免重填。
func (h *BackstageHandler) ArticleCreate(c *gin.Context) {
if h.manual == nil {
renderPage(c, http.StatusServiceUnavailable, "error", gin.H{"Error": "手工建稿未启用(服务未装配)"})
return
}
in := service.ManualArticleInput{
Kind: c.PostForm("kind"),
BrandName: c.PostForm("brand_name"),
TitleEn: c.PostForm("title_en"),
TitleCn: c.PostForm("title_cn"),
Year: uint16(atoiDefault(c.PostForm("year"), 0)),
Season: c.PostForm("season"),
CollectionType: c.PostForm("collection_type"),
City: c.PostForm("city"),
}
// 图片全量读入内存(单张上限由 service 校验;这里只负责搬运)。
if form, err := c.MultipartForm(); err == nil && form != nil {
for _, fh := range form.File["images"] {
f, oerr := fh.Open()
if oerr != nil {
continue
}
}
}
if len(streetIDs) > 0 {
if m, err := h.adminRepo.ImageSizeByRecords(ctx, "street", streetIDs); err == nil {
for id, s := range m {
out[id] = s
data, rerr := io.ReadAll(f)
_ = f.Close()
if rerr != nil {
continue
}
in.Images = append(in.Images, service.ManualImage{Filename: fh.Filename, Data: data})
}
}
return out
id, err := h.manual.Create(c.Request.Context(), in)
if err != nil {
renderPage(c, http.StatusBadRequest, "article-form", gin.H{
"Kind": in.Kind, "Error": err.Error(),
"BrandName": in.BrandName, "TitleEn": in.TitleEn, "TitleCn": in.TitleCn,
"Year": in.Year, "Season": in.Season, "CollectionType": in.CollectionType, "City": in.City,
"Seasons": manualSeasonOptions, "Collections": manualCollectionOptions,
})
return
}
c.Redirect(http.StatusFound, fmt.Sprintf("/admin/articles?created=%d", id))
}
// ArticleApprove 通过:把记录状态置为已发布(单表模型:仅改状态,不重建图片)。
@ -657,6 +793,9 @@ func usernameString(v any) string {
// RunwayEdit 渲染走秀编辑表单(预填现有字段 + 图片管理)。
func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
raw := c.Param("id")
// 临时性能探针:分段计时,定位页面 7 秒慢在哪一段。
start := time.Now()
mark := start
numeric, err := hashid.DecodeWithType(raw, hashid.TypeRunway)
if err != nil {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
@ -674,15 +813,22 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
brandName = b.NameCn
}
}
tFetch := time.Since(mark)
mark = time.Now()
cells := make([]gin.H, 0, len(imgs))
var dupCount int
// 语义向量「疑似同款」提示:只提示、不隐藏、不计入重复率。视觉服务未启用时返回 nil(渲染无角标)。
// 现算现展示、不持久化,因此无需任何「删图后重算」逻辑。
imgIDs := make([]uint32, 0, len(imgs))
for _, im := range imgs {
imgIDs = append(imgIDs, im.ID)
}
hintByImage := h.similarHints(c.Request.Context(), "runway", imgIDs)
tHint := time.Since(mark)
mark = time.Now()
// 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。
detailsByParent := map[uint32][]model.BrandRunwayImage{}
var mains []model.BrandRunwayImage
for _, im := range imgs {
if im.IsDuplicate == 1 {
dupCount++
}
if im.IsDetail == 1 {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
} else {
@ -693,25 +839,23 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
for _, m := range mains {
mainIDs[m.ID] = true
cells = append(cells, gin.H{
"id": m.ID,
"image": m.Image,
"name": m.Name,
"isDetail": 0,
"role": "主图",
"DetCount": len(detailsByParent[m.ID]),
"isDuplicate": int(m.IsDuplicate),
"dupOf": m.DupOf,
"id": m.ID,
"image": m.Image,
"name": m.Name,
"isDetail": 0,
"role": "主图",
"DetCount": len(detailsByParent[m.ID]),
"similarHints": hintByImage[m.ID],
})
for _, d := range detailsByParent[m.ID] {
cells = append(cells, gin.H{
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"isDuplicate": int(d.IsDuplicate),
"dupOf": d.DupOf,
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"similarHints": hintByImage[d.ID],
})
}
}
@ -722,42 +866,36 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
}
for _, d := range ds {
cells = append(cells, gin.H{
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"orphan": true,
"isDuplicate": int(d.IsDuplicate),
"dupOf": d.DupOf,
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"orphan": true,
"similarHints": hintByImage[d.ID],
})
}
}
dupPct := 0.0
if n := len(imgs); n > 0 {
dupPct = float64(dupCount) / float64(n) * 100
}
dupSources := h.buildDupSources(c.Request.Context(), "runway", cells)
var totalSize int64
for _, im := range imgs {
totalSize += im.ImageSize
}
renderPage(c, http.StatusOK, "runway-edit", gin.H{
"UID": raw,
"Kind": "runway",
"RecordID": rw.ID,
"Status": rw.Status,
"Runway": rw,
"UID": raw,
"Kind": "runway",
"RecordID": rw.ID,
"Status": rw.Status,
"Runway": rw,
"ImageTotalSize": totalSize,
"Images": imgs,
"Cells": cells,
"BrandName": brandName,
"IsDown": rw.IsDeleted == 1,
"DupCount": dupCount,
"DupPct": dupPct,
"DupSources": dupSources,
"Images": imgs,
"Cells": cells,
"BrandName": brandName,
"IsDown": rw.IsDeleted == 1,
})
tRender := time.Since(mark)
log.Printf("[slow-edit] %s id=%d 图=%d | 取数=%v 同款=%v 渲染=%v 合计=%v",
raw, rw.ID, len(imgs), tFetch, tHint, tRender, time.Since(start))
}
// RunwayUpdate 应用走秀字段编辑。
@ -874,14 +1012,16 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
return
}
cells := make([]gin.H, 0, len(imgs))
var dupCount int
// 语义向量「疑似同款」提示(同走秀编辑页:只提示、不隐藏、不计入重复率)。
imgIDs := make([]uint32, 0, len(imgs))
for _, im := range imgs {
imgIDs = append(imgIDs, im.ID)
}
hintByImage := h.similarHints(c.Request.Context(), "street", imgIDs)
// 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。
detailsByParent := map[uint32][]model.StreetSnapImage{}
var mains []model.StreetSnapImage
for _, im := range imgs {
if im.IsDuplicate == 1 {
dupCount++
}
if im.IsDetail == 1 {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
} else {
@ -892,25 +1032,23 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
for _, m := range mains {
mainIDs[m.ID] = true
cells = append(cells, gin.H{
"id": m.ID,
"image": m.Image,
"name": m.Name,
"isDetail": 0,
"role": "主图",
"DetCount": len(detailsByParent[m.ID]),
"isDuplicate": int(m.IsDuplicate),
"dupOf": m.DupOf,
"id": m.ID,
"image": m.Image,
"name": m.Name,
"isDetail": 0,
"role": "主图",
"DetCount": len(detailsByParent[m.ID]),
"similarHints": hintByImage[m.ID],
})
for _, d := range detailsByParent[m.ID] {
cells = append(cells, gin.H{
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"isDuplicate": int(d.IsDuplicate),
"dupOf": d.DupOf,
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"similarHints": hintByImage[d.ID],
})
}
}
@ -921,99 +1059,34 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
}
for _, d := range ds {
cells = append(cells, gin.H{
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"orphan": true,
"isDuplicate": int(d.IsDuplicate),
"dupOf": d.DupOf,
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"orphan": true,
"similarHints": hintByImage[d.ID],
})
}
}
dupPct := 0.0
if n := len(imgs); n > 0 {
dupPct = float64(dupCount) / float64(n) * 100
}
dupSources := h.buildDupSources(c.Request.Context(), "street", cells)
var totalSize int64
for _, im := range imgs {
totalSize += im.ImageSize
}
renderPage(c, http.StatusOK, "snap-edit", gin.H{
"UID": raw,
"Kind": "street",
"RecordID": snap.ID,
"Status": snap.Status,
"Snap": snap,
"UID": raw,
"Kind": "street",
"RecordID": snap.ID,
"Status": snap.Status,
"Snap": snap,
"ImageTotalSize": totalSize,
"Images": imgs,
"Cells": cells,
"IsDown": snap.IsDeleted == 1,
"DupCount": dupCount,
"DupPct": dupPct,
"DupSources": dupSources,
"Images": imgs,
"Cells": cells,
"IsDown": snap.IsDeleted == 1,
})
}
// buildDupSources 反查重复源上下文,返回以源图 id(dup_of 字符串)为键的模板数据。
// 仅对有重复标记(isDuplicate==1 且 dup_of 非空)的图做批量反查,避免逐图 N+1。
func (h *BackstageHandler) buildDupSources(ctx context.Context, kind string, cells []gin.H) map[string]gin.H {
out := map[string]gin.H{}
seen := map[uint32]bool{}
ids := make([]uint32, 0)
for _, c := range cells {
if v, ok := c["isDuplicate"].(int); !ok || v != 1 {
continue
}
s, ok := c["dupOf"].(string)
if !ok || s == "" {
continue
}
id, err := strconv.ParseUint(s, 10, 32)
if err != nil {
continue
}
u := uint32(id)
if seen[u] {
continue
}
seen[u] = true
ids = append(ids, u)
}
if len(ids) == 0 {
return out
}
var (
srcs map[uint32]dto.DupSource
err error
)
if kind == "street" {
srcs, err = h.street.GetStreetSnapDupSources(ctx, ids)
} else {
srcs, err = h.article.GetRunwayDupSources(ctx, ids)
}
if err != nil {
return out
}
typ := byte(hashid.TypeRunway)
if kind == "street" {
typ = byte(hashid.TypeSnap)
}
for id, s := range srcs {
out[strconv.FormatUint(uint64(id), 10)] = gin.H{
"srcImage": s.Image,
"srcTitle": s.Title,
"srcBrand": s.Brand,
"srcUID": hashid.EncodeWithType(s.RecordID, typ),
"srcKind": kind,
}
}
return out
}
// StreetSnapUpdate 应用街拍字段编辑。
func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
raw := c.Param("id")

View File

@ -24,6 +24,7 @@ import (
// (renderPage 会 500 "unknown backstage page: xxx")。
func TestBackstageTemplatesParsed(t *testing.T) {
want := []string{
"article-form",
"articles",
"brand-form",
"brands",
@ -92,13 +93,29 @@ func TestBackstageTemplatesRender(t *testing.T) {
data[k] = v
}
data["Record"] = &service.RecordDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"}
// 图片管理块已抽到 imageGrid 共用片段,需要 handler 实际注入的字段:
data["Kind"] = kind
data["RecordID"] = uint32(7)
data["UID"] = "abc123"
data["Status"] = "pending"
data["Cells"] = []gin.H{}
data["BrandName"] = "Brand"
// 图片管理块已抽到 imageGrid 共用片段,需要 handler 实际注入的字段:
data["Kind"] = kind
data["RecordID"] = uint32(7)
data["UID"] = "abc123"
data["Status"] = "pending"
// Cells 里放一个带「疑似同款」提示的单元格:确保徽章 markup 能真正渲染
// (视觉服务未启用时 similarHints 为 nil,range 不产出任何角标)。
// 字段与 handler 的 similarHintView 保持一致:角标要显示对方的缩略图与 Look 名,
// 缺 Image 会让 imgURL 拿到 nil 而渲染失败。
data["Cells"] = []gin.H{{
"id": 1, "image": "/uploads/sample.jpg", "name": "Look 1",
"isDetail": 0, "role": "主图",
"similarHints": []any{
map[string]any{
"UID": "abc123", "Similar": 0.91, "ImageID": uint32(9),
"Image": "runway/abc.jpg", "Name": "Look 12", "OwnerTitle": "Fall 2019 Menswear",
},
},
}}
data["BrandName"] = "Brand"
// 编辑页会渲染「原图总大小」;真实 handler 注入 int64,测试需给同类型零值,
// 否则 formatBytes 收到 nil 会报 "invalid value; expected int64"。
data["ImageTotalSize"] = int64(0)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@ -121,6 +138,66 @@ func TestBackstageTemplatesRender(t *testing.T) {
}
}
// TestArticlesShowsCrossGalleryDuplicates 文章列表必须直接标出「哪些图集有重复」——
// 否则只能一个个点进图集才能发现,132 个图集根本看不过来。
//
// 钉住三件事:有重复时渲染标记并锚点到本图集第一张重复的图;无重复时只渲染占位符;
// 视觉服务未启用(ShowDup=false)时整列不出现。
func TestArticlesShowsCrossGalleryDuplicates(t *testing.T) {
render := func(showDup bool, items []gin.H) string {
data := gin.H{
"Items": items, "Status": "", "Kind": "", "ShowDup": showDup,
"TabCounts": map[string]int64{}, "Username": "admin",
"Pager": pagination.Pager{Label: "第 1 页"},
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/admin/articles", nil)
renderPage(c, http.StatusOK, "articles", data)
if w.Code != http.StatusOK {
t.Fatalf("文章列表渲染失败(%d):%s", w.Code, w.Body.String())
}
return w.Body.String()
}
row := gin.H{
"Kind": "runway", "UID": "abc123", "ID": uint32(7),
"Title": "T", "Subtitle": "",
// image_count 只记主图(49),真实行数是 49 主 + 175 副:列表必须显示后者。
"ImageCount": 49, "ImageMain": 49, "ImageDetail": 175, "ImageTotal": 224,
"ImageTotalSize": int64(0), "Status": "pending",
"DupImages": 3, "DupTotal": 120, "DupRate": 2.5, "DupFirstImageID": uint32(55),
}
body := render(true, []gin.H{row})
if !strings.Contains(body, "跨图集重复") {
t.Fatal("ShowDup 为真时应渲染「跨图集重复」列")
}
if !strings.Contains(body, "/admin/runways/abc123/edit#img-55") {
t.Fatal("重复标记应锚点到本图集第一张重复的图(否则还得逐张找)")
}
if !strings.Contains(body, "3 张") {
t.Fatal("重复标记应显示重复图片数")
}
// 图数必须把副图算上:曾只显示 image_count(主图 49),与编辑页看到的 224 张对不上。
if !strings.Contains(body, "224") || !strings.Contains(body, "主49+副175") {
t.Fatal("图数列应展示真实图数(主+副),而不是只记主图的 image_count")
}
noDup := gin.H{}
for k, v := range row {
noDup[k] = v
}
noDup["DupImages"] = 0
if b := render(true, []gin.H{noDup}); strings.Contains(b, "#img-55") {
t.Fatal("无重复的行不应渲染重复标记")
}
if b := render(false, []gin.H{row}); strings.Contains(b, "跨图集重复") {
t.Fatal("ShowDup 为假(视觉服务未启用)时不应渲染该列")
}
}
// cloneData 复制一份页面数据:renderPage 会往里注入 NavKey / PendingReviews,不能跨子测试共享。
func cloneData(src gin.H) gin.H {
dst := make(gin.H, len(src))

View File

@ -0,0 +1,39 @@
{{define "title"}}新建文章 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 新建文章{{end}}
{{define "barRight"}}<a href="/admin/articles">← 返回列表</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>新建文章</h1>
<p class="muted">走秀与街拍二选一:走秀填品牌/季节/系列,街拍填城市/年份;图片一步到位上传,创建后状态为「待审核」。</p>
{{if .Error}}<p class="badge rejected">{{.Error}}</p>{{end}}
<form method="post" action="/admin/articles" enctype="multipart/form-data">
<table>
<tbody>
<tr><th>类型</th><td>
<select name="kind">
<option value="runway"{{if ne .Kind "street"}} selected{{end}}>走秀</option>
<option value="street"{{if eq .Kind "street"}} selected{{end}}>街拍</option>
</select>
</td></tr>
<tr><th>品牌英文名</th><td><input name="brand_name" value="{{.BrandName}}" placeholder="如 Gucci(走秀必填,须与品牌管理里的英文名完全一致)"></td></tr>
<tr><th>标题(英文)</th><td><input name="title_en" value="{{.TitleEn}}"></td></tr>
<tr><th>标题(中文)</th><td><input name="title_cn" value="{{.TitleCn}}"></td></tr>
<tr><th>年份</th><td><input name="year" value="{{.Year}}" size="6" placeholder="如 2027"></td></tr>
<tr><th>季节</th><td>
<select name="season">{{range .Seasons}}<option value="{{.}}"{{if eq . $.Season}} selected{{end}}>{{.}}</option>{{end}}</select>
</td></tr>
<tr><th>系列</th><td>
<select name="collection_type">{{range .Collections}}<option value="{{.}}"{{if eq . $.CollectionType}} selected{{end}}>{{.}}</option>{{end}}</select>
</td></tr>
<tr><th>城市</th><td><input name="city" value="{{.City}}" placeholder="街拍必填,如 Paris"></td></tr>
<tr><th>图片</th><td>
<input type="file" name="images" multiple required accept="image/jpeg,image/png,image/webp">
<div class="muted">至少 1 张;JPG / PNG / WEBP;单张 ≤ 10MB。按选择顺序作为 Look 1..N,第一张作封面;同内容图片会自动去重。</div>
</td></tr>
</tbody>
</table>
<p><button type="submit" class="op op-primary">创建</button> <a href="/admin/articles">取消</a></p>
</form>
</div>
{{end}}

View File

@ -1,6 +1,6 @@
{{define "title"}}文章管理 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 文章管理{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "barRight"}}<a href="/admin/articles/new">+ 新建</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
@ -21,7 +21,7 @@
<table>
<thead><tr>
<th style="width:30px"><input type="checkbox" id="sel-all" aria-label="全选本页"></th>
<th>类型</th><th>ID</th><th>标题</th><th>副标题</th><th>图数</th><th>原图总大小</th><th>重复率</th><th>状态</th><th>操作</th>
<th>类型</th><th>ID</th><th>标题</th><th>副标题</th><th>图数(主+副)</th>{{if .ShowDup}}<th>跨图集重复</th>{{end}}<th>原图总大小</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody>
{{range .Items}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
@ -30,8 +30,7 @@
<td><code>{{.ID}}</code></td>
<td>{{.Title}}</td>
<td class="muted">{{.Subtitle}}</td>
<td>{{.ImageCount}}</td><td>{{formatBytes .ImageTotalSize}}</td>
<td>{{if .DupCount}}<span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}})</span>{{else}}<span class="muted">—</span>{{end}}</td>
<td>{{.ImageTotal}}{{if .ImageDetail}}<span class="muted" title="真实图片行数:主图 {{.ImageMain}} 张 + 细节图 {{.ImageDetail}} 张。公开卡片角标用的是图集表的 image_count,它只记主图(当前 {{.ImageCount}}),所以两者对不上是正常的。"> 主{{.ImageMain}}+副{{.ImageDetail}}</span>{{end}}</td>{{if $.ShowDup}}<td>{{if .DupImages}}<a class="badge dup" href="/admin/{{if eq .Kind "runway"}}runways{{else}}street-snaps{{end}}/{{.UID}}/edit#img-{{.DupFirstImageID}}" title="本图集 {{.DupTotal}} 张图里有 {{.DupImages}} 张在其他图集里出现过({{printf "%.1f" .DupRate}}%)。点开直接跳到其中第一张。">{{.DupImages}} 张 · {{printf "%.0f" .DupRate}}%</a>{{else}}<span class="muted">—</span>{{end}}</td>{{end}}<td>{{formatBytes .ImageTotalSize}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td>
<div class="ops">

View File

@ -7,43 +7,22 @@
<h1>品牌列表</h1>
<form class="toolbar" method="get" action="/admin/brands">
<input name="keyword" placeholder="搜索品牌名…" value="{{.Keyword}}">
{{if .All}}<input type="hidden" name="all" value="1">{{end}}
<button type="submit">搜索</button>
{{if .Keyword}}<a href="/admin/brands">清除</a>{{end}}
{{if .Keyword}}<a href="/admin/brands{{if .All}}?all=1{{end}}">清除</a>{{end}}
<span class="spacer"></span>
{{if .All}}<a class="view" href="/admin/brands?keyword={{.Keyword}}">只看有图集的品牌</a>{{else}}<a class="view" href="/admin/brands?keyword={{.Keyword}}&all=1">显示全部品牌(含无图集)</a>{{end}}
</form>
<p class="muted">默认只显示「已有图集」的品牌;品牌目录共 2 万余条,其余请用上面的搜索或开关查看。</p>
<div class="idx">
<a href="/admin/brands?keyword={{.Keyword}}" class="{{if eq .Letter ""}}on{{end}}">全部</a>
<a href="/admin/brands?letter=OTHER&keyword={{.Keyword}}" class="{{if eq .Letter "OTHER"}}on{{end}}">中文</a>
<a href="/admin/brands?letter=A&keyword={{.Keyword}}" class="{{if eq .Letter "A"}}on{{end}}">A</a>
<a href="/admin/brands?letter=B&keyword={{.Keyword}}" class="{{if eq .Letter "B"}}on{{end}}">B</a>
<a href="/admin/brands?letter=C&keyword={{.Keyword}}" class="{{if eq .Letter "C"}}on{{end}}">C</a>
<a href="/admin/brands?letter=D&keyword={{.Keyword}}" class="{{if eq .Letter "D"}}on{{end}}">D</a>
<a href="/admin/brands?letter=E&keyword={{.Keyword}}" class="{{if eq .Letter "E"}}on{{end}}">E</a>
<a href="/admin/brands?letter=F&keyword={{.Keyword}}" class="{{if eq .Letter "F"}}on{{end}}">F</a>
<a href="/admin/brands?letter=G&keyword={{.Keyword}}" class="{{if eq .Letter "G"}}on{{end}}">G</a>
<a href="/admin/brands?letter=H&keyword={{.Keyword}}" class="{{if eq .Letter "H"}}on{{end}}">H</a>
<a href="/admin/brands?letter=I&keyword={{.Keyword}}" class="{{if eq .Letter "I"}}on{{end}}">I</a>
<a href="/admin/brands?letter=J&keyword={{.Keyword}}" class="{{if eq .Letter "J"}}on{{end}}">J</a>
<a href="/admin/brands?letter=K&keyword={{.Keyword}}" class="{{if eq .Letter "K"}}on{{end}}">K</a>
<a href="/admin/brands?letter=L&keyword={{.Keyword}}" class="{{if eq .Letter "L"}}on{{end}}">L</a>
<a href="/admin/brands?letter=M&keyword={{.Keyword}}" class="{{if eq .Letter "M"}}on{{end}}">M</a>
<a href="/admin/brands?letter=N&keyword={{.Keyword}}" class="{{if eq .Letter "N"}}on{{end}}">N</a>
<a href="/admin/brands?letter=O&keyword={{.Keyword}}" class="{{if eq .Letter "O"}}on{{end}}">O</a>
<a href="/admin/brands?letter=P&keyword={{.Keyword}}" class="{{if eq .Letter "P"}}on{{end}}">P</a>
<a href="/admin/brands?letter=Q&keyword={{.Keyword}}" class="{{if eq .Letter "Q"}}on{{end}}">Q</a>
<a href="/admin/brands?letter=R&keyword={{.Keyword}}" class="{{if eq .Letter "R"}}on{{end}}">R</a>
<a href="/admin/brands?letter=S&keyword={{.Keyword}}" class="{{if eq .Letter "S"}}on{{end}}">S</a>
<a href="/admin/brands?letter=T&keyword={{.Keyword}}" class="{{if eq .Letter "T"}}on{{end}}">T</a>
<a href="/admin/brands?letter=U&keyword={{.Keyword}}" class="{{if eq .Letter "U"}}on{{end}}">U</a>
<a href="/admin/brands?letter=V&keyword={{.Keyword}}" class="{{if eq .Letter "V"}}on{{end}}">V</a>
<a href="/admin/brands?letter=W&keyword={{.Keyword}}" class="{{if eq .Letter "W"}}on{{end}}">W</a>
<a href="/admin/brands?letter=X&keyword={{.Keyword}}" class="{{if eq .Letter "X"}}on{{end}}">X</a>
<a href="/admin/brands?letter=Y&keyword={{.Keyword}}" class="{{if eq .Letter "Y"}}on{{end}}">Y</a>
<a href="/admin/brands?letter=Z&keyword={{.Keyword}}" class="{{if eq .Letter "Z"}}on{{end}}">Z</a>
<a href="/admin/brands?keyword={{.Keyword}}{{if .All}}&all=1{{end}}" class="{{if eq .Letter ""}}on{{end}}">全部</a>
<a href="/admin/brands?letter=OTHER&keyword={{.Keyword}}{{if .All}}&all=1{{end}}" class="{{if eq .Letter "OTHER"}}on{{end}}">中文</a>
{{range $l := .Letters}}<a href="/admin/brands?letter={{$l}}&keyword={{$.Keyword}}{{if $.All}}&all=1{{end}}" class="{{if eq $l $.Letter}}on{{end}}">{{$l}}</a>{{end}}
</div>
<table>
<thead><tr><th>编码 ID</th><th>英文名</th><th>中文名</th></tr></thead>
<thead><tr><th>编码 ID</th><th>英文名</th><th>中文名</th><th>操作</th></tr></thead>
<tbody>
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.NameEn}}</td><td>{{.NameCn}}</td><td><a class="view" href="/admin/brands/{{.UID}}">编辑</a></td></tr>{{end}}
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.NameEn}}</td><td>{{.NameCn}}</td><td><a class="view" href="/admin/brands/{{.UID}}">编辑</a><form class="inline" method="post" action="/admin/brands/{{.UID}}/delete" onsubmit="return confirm('确认删除品牌「{{.NameEn}}」?该品牌下若还有图集会拒绝删除。')"><button type="submit" class="op op-warn">删除</button></form></td></tr>{{end}}
</tbody>
</table>
{{template "pager" .Pager}}

View File

@ -46,22 +46,20 @@
.Cells []gin.H,每项 {id, image, name, isDetail, role}
*/}}
{{define "imageGrid"}}
<h2>图片管理(<span id="img-stat">{{len .Cells}} 张</span>{{if eq .Status "pending"}} · <span class="badge pending">待审核</span>{{end}}{{if .DupCount}} · 重复率 <span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}} 张)</span>{{end}})</h2>
<h2>图片管理(<span id="img-stat">{{len .Cells}} 张</span>{{if eq .Status "pending"}} · <span class="badge pending">待审核</span>{{end}})</h2>
<div class="hint">
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
{{if eq .Kind "runway"}}走秀图按 Look 分组:同一 Look 的主图 + 其细节图(副图)。{{end}}
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
</div>
{{$kind := .Kind}}{{$uid := .UID}}{{$rid := .RecordID}}{{$dupSources := .DupSources}}
{{$kind := .Kind}}{{$uid := .UID}}{{$rid := .RecordID}}
<form id="batch" method="post" action="/admin/articles/{{$kind}}/{{$rid}}/images/attach"></form>
<div class="grid">
{{range .Cells}}<div class="cell{{if eq .isDetail 1}} det{{if .orphan}} shown{{end}}{{end}}" data-img-id="{{.id}}"{{if eq .isDetail 1}} data-parent="{{.parent}}"{{end}}>
<button class="thumb" type="button" data-zoom><img src="{{imgURL .image}}" loading="lazy"></button>
{{range .Cells}}<div class="cell{{if eq .isDetail 1}} det{{if .orphan}} shown{{end}}{{end}}" id="img-{{.id}}" data-img-id="{{.id}}"{{if eq .isDetail 1}} data-parent="{{.parent}}"{{end}}>
<button class="thumb" type="button" data-zoom data-full="{{imgURL .image}}"><img src="{{imgThumb .image}}" loading="lazy"></button>
<span class="role">{{.role}}</span>
<span class="nm">{{.name}}</span>
{{if eq .isDuplicate 1}}<span class="badge dup">重复</span>
{{with index $dupSources .dupOf}}{{if .srcTitle}}<details class="dup-src"><summary>查看重复来源</summary><div class="dup-src-body"><img src="{{imgURL .srcImage}}" loading="lazy" alt="源图缩略图"><div><div class="dup-title">属于:<b>{{.srcTitle}}</b>{{if .srcBrand}} · {{.srcBrand}}{{end}}</div><a class="dup-link" href="/admin/{{if eq .srcKind "runway"}}runways{{else}}street-snaps{{end}}/{{.srcUID}}/edit" target="_blank">打开所属{{if eq .srcKind "runway"}}走秀{{else}}街拍{{end}} →</a></div></div></details>{{end}}{{end}}
{{end}}
{{range .similarHints}}<a class="badge similar" href="/admin/{{if eq $kind "runway"}}runways{{else}}street-snaps{{end}}/{{.UID}}/edit#img-{{.ImageID}}" target="_blank" title="疑似同款:与「{{.OwnerTitle}}」的 {{.Name}}(#{{.ImageID}})余弦相似度 {{printf "%.2f" .Similar}}。点击跳到那张图。仅供人工参考,不影响前台展示">{{if .Image}}<img src="{{imgThumb .Image}}" loading="lazy" alt="">{{end}}<span class="sim-text"><b>{{.OwnerTitle}}</b>{{.Name}} · {{printf "%.2f" .Similar}}</span></a>{{end}}
<div class="acts">
{{if eq $kind "runway"}}<form method="post" action="/admin/runways/{{$uid}}/cover"><input type="hidden" name="image" value="{{.image}}"><button type="submit">设封面</button></form>
<form method="post" action="/admin/runways/{{$uid}}/images/{{.id}}/delete"><button type="submit" class="reject">删除</button></form>