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() })