From a65f52b7f42ba918c1b38cada840463a20c81be0 Mon Sep 17 00:00:00 2001 From: Linda Date: Thu, 16 Jul 2026 02:46:06 +0000 Subject: [PATCH] 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) --- backend/internal/http/handler/admin_read.go | 29 ++++- backend/internal/http/handler/banned_words.go | 20 ++- backend/internal/http/handler/cdk.go | 39 +++++- backend/internal/http/handler/pagination.go | 33 +++++ .../internal/http/handler/provider_admin.go | 87 ++++++++++++- backend/internal/http/handler/showcase.go | 37 ++++++ backend/internal/http/router/router.go | 1 + frontend/src/views/AccountsView.vue | 119 +++++++++--------- frontend/src/views/BannedWordsView.vue | 24 ++-- frontend/src/views/CdksView.vue | 56 +++++---- frontend/src/views/ShowcaseView.vue | 43 +++---- frontend/src/views/UsersView.vue | 59 ++++----- 12 files changed, 398 insertions(+), 149 deletions(-) create mode 100644 backend/internal/http/handler/pagination.go diff --git a/backend/internal/http/handler/admin_read.go b/backend/internal/http/handler/admin_read.go index 09716da..1079caf 100644 --- a/backend/internal/http/handler/admin_read.go +++ b/backend/internal/http/handler/admin_read.go @@ -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) { diff --git a/backend/internal/http/handler/banned_words.go b/backend/internal/http/handler/banned_words.go index e22cffa..c6d8402 100644 --- a/backend/internal/http/handler/banned_words.go +++ b/backend/internal/http/handler/banned_words.go @@ -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) { diff --git a/backend/internal/http/handler/cdk.go b/backend/internal/http/handler/cdk.go index 3f651e1..76b609c 100644 --- a/backend/internal/http/handler/cdk.go +++ b/backend/internal/http/handler/cdk.go @@ -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) { diff --git a/backend/internal/http/handler/pagination.go b/backend/internal/http/handler/pagination.go new file mode 100644 index 0000000..a53ace2 --- /dev/null +++ b/backend/internal/http/handler/pagination.go @@ -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] +} diff --git a/backend/internal/http/handler/provider_admin.go b/backend/internal/http/handler/provider_admin.go index 2311465..4b3d9da 100644 --- a/backend/internal/http/handler/provider_admin.go +++ b/backend/internal/http/handler/provider_admin.go @@ -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) { diff --git a/backend/internal/http/handler/showcase.go b/backend/internal/http/handler/showcase.go index 174539d..c58432e 100644 --- a/backend/internal/http/handler/showcase.go +++ b/backend/internal/http/handler/showcase.go @@ -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}) +} diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go index a6fd432..38bfc16 100644 --- a/backend/internal/http/router/router.go +++ b/backend/internal/http/router/router.go @@ -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) diff --git a/frontend/src/views/AccountsView.vue b/frontend/src/views/AccountsView.vue index daae2dc..c7c2f36 100644 --- a/frontend/src/views/AccountsView.vue +++ b/frontend/src/views/AccountsView.vue @@ -39,31 +39,26 @@ const search = ref('') const page = ref(1) const pageSize = ref(20) -// Typing a search term must jump back to page 1 — otherwise a narrowed result -// set can leave you stranded on a now-empty page. -watch(search, () => { page.value = 1 }) +const total = ref(0) +// Typing a search term must jump back to page 1 and re-query the server — +// search is cross-page now (server-side). +let searchTimer = null +watch(search, () => { + clearTimeout(searchTimer) + searchTimer = setTimeout(() => { resetAndLoad() }, 300) +}) -// 每个类型的 成功/失败/限额 三个数(成功=正常可用, 失败=失效/禁用, 限额=额度耗尽)。 -const stats = computed(() => { - const by = (t) => { - const s = rows.value.filter((r) => r.type === t) - return { - n: s.length, - ok: s.filter((r) => r.status === 'active').length, - dead: s.filter((r) => r.dead || r.status === 'disabled').length, - quota: s.filter((r) => r.status === 'quota').length, - } - } - return { - total: rows.value.length, - openai: by('openai'), adobe: by('adobe'), runway: by('runway'), - leonardo: by('leonardo'), krea: by('krea'), imagine: by('imagine'), - grok: by('grok'), - } +const EMPTY_TYPE = { n: 0, ok: 0, dead: 0, quota: 0 } +// 每个类型的 成功/失败/限额 三个数 — 由后端对全量账号统计(与筛选/分页无关)。 +const stats = ref({ + total: 0, dead_total: 0, + openai: { ...EMPTY_TYPE }, adobe: { ...EMPTY_TYPE }, runway: { ...EMPTY_TYPE }, + leonardo: { ...EMPTY_TYPE }, krea: { ...EMPTY_TYPE }, imagine: { ...EMPTY_TYPE }, + grok: { ...EMPTY_TYPE }, }) // 异常账号 = 已失效(401)被锁定的号(红色锁定行)。用于「一键删除异常账号」。 -const deadCount = computed(() => rows.value.filter((r) => r.dead).length) +const deadCount = computed(() => stats.value.dead_total || 0) function typePill(t) { return { @@ -77,31 +72,21 @@ function typePill(t) { } const STATUS_LABEL = { active: '正常', quota: '额度耗尽', disabled: '已禁用', pending: '检测中' } -const filtered = computed(() => { - const q = search.value.trim().toLowerCase() - const sorted = [...rows.value].sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) - return sorted.filter((a) => { - if (typeFilter.value && a.type !== typeFilter.value) return false - if (statusFilter.value && a.status !== statusFilter.value) return false - if (q && !( - (a.email || '').toLowerCase().includes(q) || - (a.id || '').toLowerCase().includes(q) || - (a.type || '').toLowerCase().includes(q) - )) return false - return true - }) -}) +// Server-side pagination: rows IS the current page, already filtered/sorted +// by the backend. total = server-side filtered count. +const filtered = computed(() => rows.value) +const pagedItems = computed(() => rows.value) -const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value))) -const pagedItems = computed(() => { - const start = (page.value - 1) * pageSize.value - return filtered.value.slice(start, start + pageSize.value) -}) +const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) function goPage(n) { const target = Math.max(1, Math.min(totalPages.value, n)) if (target !== page.value) page.value = target } -function setFilter(fn) { fn(); page.value = 1 } +function setFilter(fn) { fn(); resetAndLoad() } +function resetAndLoad() { + if (page.value !== 1) page.value = 1 // the page watcher triggers the load + else loadAccounts() +} const pageNumbers = computed(() => { const n = totalPages.value const cur = page.value @@ -120,11 +105,28 @@ const pageNumbers = computed(() => { let pendingTimer = null +function buildQs() { + const qs = new URLSearchParams({ + limit: String(pageSize.value), + offset: String((page.value - 1) * pageSize.value), + }) + if (typeFilter.value) qs.set('type', typeFilter.value) + if (statusFilter.value) qs.set('status', statusFilter.value) + if (search.value.trim()) qs.set('q', search.value.trim()) + return qs.toString() +} + +async function fetchAccounts() { + const r = await api('/accounts?' + buildQs()) + rows.value = r.data?.data || [] + total.value = Number(r.data?.total ?? rows.value.length) + if (r.data?.stats) stats.value = r.data.stats +} + async function loadAccounts() { loading.value = true quotaStatus.value = '' - const r = await api('/accounts') - rows.value = r.data?.data || [] + await fetchAccounts() loading.value = false if (rows.value.length) reconcile() schedulePendingPoll() @@ -136,8 +138,7 @@ function schedulePendingPoll() { if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null } if (!rows.value.some((r) => r.pending)) return pendingTimer = setTimeout(async () => { - const r = await api('/accounts') - rows.value = r.data?.data || [] + await fetchAccounts() schedulePendingPoll() }, 2000) } @@ -211,12 +212,10 @@ async function reconcile() { if (myToken === reconcileToken) quotaStatus.value = '' } -// Re-check the newly visible accounts whenever the page or filters change. -// Only the on-screen page is ever probed (see reconcile), so flipping pages is -// what triggers checking the rest — never all rows at once. -watch([page, typeFilter, statusFilter], () => { - if (rows.value.length) reconcile() -}) +// Flipping pages re-queries the server for the new page; loadAccounts() then +// reconciles just the freshly visible rows. Filter buttons go through +// setFilter → resetAndLoad, so everything funnels into loadAccounts. +watch(page, () => { loadAccounts() }) // Bounded-concurrency runner: keeps at most `limit` thunks in flight at once. async function runWithLimit(thunks, limit) { @@ -272,12 +271,14 @@ async function deleteAccount(pool, id) { loadAccounts() } -// 一键删除全部异常(已失效/红色锁定)账号。逐个走与单删相同的 DELETE 接口。 +// 一键删除全部异常(已失效/红色锁定)账号。先向服务端要全量 dead 列表(跨页),再逐个删除。 async function deleteDeadAccounts() { - const dead = rows.value.filter((r) => r.dead) + if (!deadCount.value) return + if (!confirm(`确认删除全部 ${deadCount.value} 个异常(已失效)账号?此操作不可撤销。`)) return + const r = await api('/accounts?dead=1&limit=0') + const dead = r.data?.data || [] if (!dead.length) return - if (!confirm(`确认删除全部 ${dead.length} 个异常(已失效)账号?此操作不可撤销。`)) return - await Promise.all(dead.map((r) => api(`/tokens/${r.pool}/${r.id}`, { method: 'DELETE' }))) + await Promise.all(dead.map((a) => api(`/tokens/${a.pool}/${a.id}`, { method: 'DELETE' }))) loadAccounts() } @@ -396,8 +397,8 @@ onMounted(() => { loadAccounts(); loadModelList() }) - {{ rows.length ? '没有匹配的账号' : '还没有账号' }} - + {{ stats.total ? '没有匹配的账号' : '还没有账号' }} + @@ -554,8 +555,8 @@ onMounted(() => { loadAccounts(); loadModelList() })
- {{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }} - / {{ filtered.length }} 条 + {{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }} + / {{ total }} 条