admin lists: server-side pagination + filters for accounts/users/cdks/showcase/banned-words (limit/offset/total; stats over full set; /showcase/admin flat paginated view; dead=1 accounts filter for bulk dead-delete)

This commit is contained in:
Linda
2026-07-16 10:48:35 +08:00
committed by chiyi
parent 86559eb7fc
commit a65f52b7f4
12 changed files with 398 additions and 149 deletions
+27 -2
View File
@@ -26,14 +26,39 @@ func (h *AdminReadHandler) Users(c *gin.Context) {
return
}
out := make([]gin.H, 0, len(users))
// Server-side filtering (role / status / 搜索 邮箱·名称·ID) so pagination stays
// correct across pages. stats is computed over the full set (KPI strip).
role := strings.TrimSpace(c.Query("role"))
status := strings.TrimSpace(c.Query("status"))
q := strings.ToLower(strings.TrimSpace(c.Query("q")))
filtered := make([]model.User, 0, len(users))
for _, user := range users {
if role != "" && user.Role != role {
continue
}
if status != "" && user.Status != status {
continue
}
if q != "" && !strings.Contains(strings.ToLower(user.Email), q) &&
!strings.Contains(strings.ToLower(user.Name), q) &&
!strings.Contains(strings.ToLower(user.ID), q) {
continue
}
filtered = append(filtered, user)
}
total := len(filtered)
limit, offset := pageParams(c, 20)
page := pageSlice(filtered, limit, offset)
out := make([]gin.H, 0, len(page))
for _, user := range page {
row := userPublic(user)
row["generation_count"] = user.GenerationCount
row["banned_word_hits"] = user.BannedWordHits
out = append(out, row)
}
c.JSON(http.StatusOK, gin.H{"data": out, "stats": stats})
c.JSON(http.StatusOK, gin.H{"data": out, "total": total, "limit": limit, "offset": offset, "stats": stats})
}
func (h *AdminReadHandler) Models(c *gin.Context) {
+17 -3
View File
@@ -24,8 +24,22 @@ func (h *BannedWordsHandler) List(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load banned words"})
return
}
out := make([]gin.H, 0, len(items))
for _, w := range items {
// Server-side 搜索(违禁词) + pagination.
q := strings.ToLower(strings.TrimSpace(c.Query("q")))
if q != "" {
kept := items[:0]
for _, w := range items {
if strings.Contains(strings.ToLower(w.Word), q) {
kept = append(kept, w)
}
}
items = kept
}
total := len(items)
limit, offset := pageParams(c, 20)
page := pageSlice(items, limit, offset)
out := make([]gin.H, 0, len(page))
for _, w := range page {
out = append(out, gin.H{
"id": w.ID,
"word": w.Word,
@@ -33,7 +47,7 @@ func (h *BannedWordsHandler) List(c *gin.Context) {
"created_at": w.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"data": out})
c.JSON(http.StatusOK, gin.H{"data": out, "total": total, "limit": limit, "offset": offset})
}
func (h *BannedWordsHandler) Create(c *gin.Context) {
+38 -1
View File
@@ -3,6 +3,7 @@ package handler
import (
"errors"
"net/http"
"strings"
"backend/internal/model"
"backend/internal/service"
@@ -23,7 +24,43 @@ func (h *CDKHandler) List(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load cdks"})
return
}
c.JSON(http.StatusOK, gin.H{"data": cdkPublic(items, names), "stats": stats})
// Server-side filtering (状态 未使用/已使用 · 类型 普通/营销 · 搜索兑换码) + pagination.
statusFilter := strings.TrimSpace(c.Query("status")) // "" | active | used
typeFilter := strings.TrimSpace(c.Query("type")) // "" | normal | marketing
q := strings.ToUpper(strings.TrimSpace(c.Query("q")))
filtered := make([]model.CDKCode, 0, len(items))
for _, item := range items {
switch statusFilter {
case "active":
if item.Status != "active" {
continue
}
case "used":
if item.Status == "active" {
continue
}
}
switch typeFilter {
case "marketing":
if item.Type != "marketing" {
continue
}
case "normal":
if item.Type == "marketing" {
continue
}
}
if q != "" && !strings.Contains(strings.ToUpper(item.Code), q) {
continue
}
filtered = append(filtered, item)
}
total := len(filtered)
limit, offset := pageParams(c, 20)
page := pageSlice(filtered, limit, offset)
c.JSON(http.StatusOK, gin.H{"data": cdkPublic(page, names), "total": total, "limit": limit, "offset": offset, "stats": stats})
}
func (h *CDKHandler) Create(c *gin.Context) {
@@ -0,0 +1,33 @@
package handler
import "github.com/gin-gonic/gin"
// pageParams reads limit/offset query params with the given default page size.
// A limit of 0 (or negative) is treated as "return everything" so callers that
// need the full filtered set (e.g. bulk actions) can pass ?limit=0.
func pageParams(c *gin.Context, defLimit int) (limit, offset int) {
limit = parseInt(c.Query("limit"), defLimit)
if limit < 0 {
limit = 0
}
offset = parseInt(c.Query("offset"), 0)
if offset < 0 {
offset = 0
}
return limit, offset
}
// pageSlice returns items[offset : offset+limit], clamped to the slice bounds.
// limit <= 0 means "from offset to the end". Always returns a non-nil slice so
// it JSON-encodes as [] rather than null.
func pageSlice[T any](items []T, limit, offset int) []T {
n := len(items)
if offset >= n {
return []T{}
}
end := n
if limit > 0 && offset+limit < n {
end = offset + limit
}
return items[offset:end]
}
@@ -3,6 +3,7 @@ package handler
import (
"errors"
"net/http"
"strings"
"backend/internal/service"
"github.com/gin-gonic/gin"
@@ -331,7 +332,91 @@ func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load accounts"})
return
}
c.JSON(http.StatusOK, gin.H{"data": data})
// KPI stats are computed over the FULL set (每个类型的 成功/失败/限额), independent
// of the current filter/page — mirrors the old client-side `stats` computed.
stats := accountsStats(data)
// Server-side filtering (类型 / 状态 / 搜索 邮箱·ID·类型 / dead) so pagination is
// correct across pages. ?dead=1 returns only 异常(已失效) accounts — used by
// 「删除异常账号」to collect every dead id regardless of the current page.
typeFilter := strings.TrimSpace(c.Query("type"))
statusFilter := strings.TrimSpace(c.Query("status"))
deadOnly := c.Query("dead") == "1"
q := strings.ToLower(strings.TrimSpace(c.Query("q")))
filtered := make([]map[string]any, 0, len(data))
for _, row := range data {
if deadOnly && !rowBool(row, "dead") {
continue
}
if typeFilter != "" && rowStr(row, "type") != typeFilter {
continue
}
if statusFilter != "" && rowStr(row, "status") != statusFilter {
continue
}
if q != "" {
email := strings.ToLower(rowStr(row, "email"))
id := strings.ToLower(rowStr(row, "id"))
typ := strings.ToLower(rowStr(row, "type"))
if !strings.Contains(email, q) && !strings.Contains(id, q) && !strings.Contains(typ, q) {
continue
}
}
filtered = append(filtered, row)
}
total := len(filtered)
limit, offset := pageParams(c, 20)
page := pageSlice(filtered, limit, offset)
c.JSON(http.StatusOK, gin.H{"data": page, "total": total, "limit": limit, "offset": offset, "stats": stats})
}
// accountsStats reproduces the 账号 KPI strip: per-type 正常/失效/限额 counts plus a
// grand total and total dead count (drives 「删除异常账号 (N)」).
func accountsStats(rows []map[string]any) gin.H {
types := []string{"openai", "adobe", "runway", "leonardo", "krea", "imagine", "grok"}
by := map[string]*struct{ N, Ok, Dead, Quota int }{}
for _, t := range types {
by[t] = &struct{ N, Ok, Dead, Quota int }{}
}
deadTotal := 0
for _, row := range rows {
dead := rowBool(row, "dead")
if dead {
deadTotal++
}
g, ok := by[rowStr(row, "type")]
if !ok {
continue
}
g.N++
status := rowStr(row, "status")
switch {
case status == "active":
g.Ok++
case dead || status == "disabled":
g.Dead++
case status == "quota":
g.Quota++
}
}
out := gin.H{"total": len(rows), "dead_total": deadTotal}
for _, t := range types {
g := by[t]
out[t] = gin.H{"n": g.N, "ok": g.Ok, "dead": g.Dead, "quota": g.Quota}
}
return out
}
func rowStr(m map[string]any, key string) string {
s, _ := m[key].(string)
return s
}
func rowBool(m map[string]any, key string) bool {
b, _ := m[key].(bool)
return b
}
func (h *ProviderAdminHandler) AccountQuota(c *gin.Context) {
+37
View File
@@ -43,3 +43,40 @@ func (h *ShowcaseHandler) List(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
// AdminList is the paginated flat view for the admin 首页内容 page — the public
// List keeps its grouped shape for the home page, this one adds kind filter +
// limit/offset/total. Order mirrors the old client flattening: hero → bento → work.
func (h *ShowcaseHandler) AdminList(c *gin.Context) {
grouped, err := h.showcase.Grouped(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load showcase"})
return
}
kindFilter := c.Query("kind") // "" | hero | bento | work
flat := make([]gin.H, 0, 16)
for _, kind := range []string{"hero", "bento", "work"} {
if kindFilter != "" && kind != kindFilter {
continue
}
for _, item := range grouped[kind] {
flat = append(flat, gin.H{
"id": item.ID,
"kind": item.Kind,
"title": item.Title,
"subtitle": item.Subtitle,
"prompt": item.Prompt,
"gradient": item.Gradient,
"span": item.Span,
"image": item.Image,
"weight": item.Weight,
"created_at": item.CreatedAt,
"updated_at": item.UpdatedAt,
})
}
}
total := len(flat)
limit, offset := pageParams(c, 12)
page := pageSlice(flat, limit, offset)
c.JSON(http.StatusOK, gin.H{"data": page, "total": total, "limit": limit, "offset": offset})
}
+1
View File
@@ -157,6 +157,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
authed.DELETE("/managed-models/:model_id", handlers.AdminWrite.DeleteModel)
authed.DELETE("/logs", handlers.AdminWrite.ClearLogs)
authed.DELETE("/logs/pending", handlers.AdminWrite.ClearPendingLogs)
authed.GET("/showcase/admin", handlers.Showcase.AdminList)
authed.POST("/showcase", handlers.AdminWrite.CreateShowcase)
authed.PATCH("/showcase/:entry_id", handlers.AdminWrite.UpdateShowcase)
authed.DELETE("/showcase/:entry_id", handlers.AdminWrite.DeleteShowcase)