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:
@@ -26,14 +26,39 @@ func (h *AdminReadHandler) Users(c *gin.Context) {
|
|||||||
return
|
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 {
|
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 := userPublic(user)
|
||||||
row["generation_count"] = user.GenerationCount
|
row["generation_count"] = user.GenerationCount
|
||||||
row["banned_word_hits"] = user.BannedWordHits
|
row["banned_word_hits"] = user.BannedWordHits
|
||||||
out = append(out, row)
|
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) {
|
func (h *AdminReadHandler) Models(c *gin.Context) {
|
||||||
|
|||||||
@@ -24,8 +24,22 @@ func (h *BannedWordsHandler) List(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load banned words"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load banned words"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]gin.H, 0, len(items))
|
// Server-side 搜索(违禁词) + pagination.
|
||||||
for _, w := range items {
|
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{
|
out = append(out, gin.H{
|
||||||
"id": w.ID,
|
"id": w.ID,
|
||||||
"word": w.Word,
|
"word": w.Word,
|
||||||
@@ -33,7 +47,7 @@ func (h *BannedWordsHandler) List(c *gin.Context) {
|
|||||||
"created_at": w.CreatedAt,
|
"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) {
|
func (h *BannedWordsHandler) Create(c *gin.Context) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"backend/internal/model"
|
"backend/internal/model"
|
||||||
"backend/internal/service"
|
"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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load cdks"})
|
||||||
return
|
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) {
|
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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"backend/internal/service"
|
"backend/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load accounts"})
|
||||||
return
|
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) {
|
func (h *ProviderAdminHandler) AccountQuota(c *gin.Context) {
|
||||||
|
|||||||
@@ -43,3 +43,40 @@ func (h *ShowcaseHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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("/managed-models/:model_id", handlers.AdminWrite.DeleteModel)
|
||||||
authed.DELETE("/logs", handlers.AdminWrite.ClearLogs)
|
authed.DELETE("/logs", handlers.AdminWrite.ClearLogs)
|
||||||
authed.DELETE("/logs/pending", handlers.AdminWrite.ClearPendingLogs)
|
authed.DELETE("/logs/pending", handlers.AdminWrite.ClearPendingLogs)
|
||||||
|
authed.GET("/showcase/admin", handlers.Showcase.AdminList)
|
||||||
authed.POST("/showcase", handlers.AdminWrite.CreateShowcase)
|
authed.POST("/showcase", handlers.AdminWrite.CreateShowcase)
|
||||||
authed.PATCH("/showcase/:entry_id", handlers.AdminWrite.UpdateShowcase)
|
authed.PATCH("/showcase/:entry_id", handlers.AdminWrite.UpdateShowcase)
|
||||||
authed.DELETE("/showcase/:entry_id", handlers.AdminWrite.DeleteShowcase)
|
authed.DELETE("/showcase/:entry_id", handlers.AdminWrite.DeleteShowcase)
|
||||||
|
|||||||
@@ -39,31 +39,26 @@ const search = ref('')
|
|||||||
|
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
// Typing a search term must jump back to page 1 — otherwise a narrowed result
|
const total = ref(0)
|
||||||
// set can leave you stranded on a now-empty page.
|
// Typing a search term must jump back to page 1 and re-query the server —
|
||||||
watch(search, () => { page.value = 1 })
|
// search is cross-page now (server-side).
|
||||||
|
let searchTimer = null
|
||||||
|
watch(search, () => {
|
||||||
|
clearTimeout(searchTimer)
|
||||||
|
searchTimer = setTimeout(() => { resetAndLoad() }, 300)
|
||||||
|
})
|
||||||
|
|
||||||
// 每个类型的 成功/失败/限额 三个数(成功=正常可用, 失败=失效/禁用, 限额=额度耗尽)。
|
const EMPTY_TYPE = { n: 0, ok: 0, dead: 0, quota: 0 }
|
||||||
const stats = computed(() => {
|
// 每个类型的 成功/失败/限额 三个数 — 由后端对全量账号统计(与筛选/分页无关)。
|
||||||
const by = (t) => {
|
const stats = ref({
|
||||||
const s = rows.value.filter((r) => r.type === t)
|
total: 0, dead_total: 0,
|
||||||
return {
|
openai: { ...EMPTY_TYPE }, adobe: { ...EMPTY_TYPE }, runway: { ...EMPTY_TYPE },
|
||||||
n: s.length,
|
leonardo: { ...EMPTY_TYPE }, krea: { ...EMPTY_TYPE }, imagine: { ...EMPTY_TYPE },
|
||||||
ok: s.filter((r) => r.status === 'active').length,
|
grok: { ...EMPTY_TYPE },
|
||||||
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'),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 异常账号 = 已失效(401)被锁定的号(红色锁定行)。用于「一键删除异常账号」。
|
// 异常账号 = 已失效(401)被锁定的号(红色锁定行)。用于「一键删除异常账号」。
|
||||||
const deadCount = computed(() => rows.value.filter((r) => r.dead).length)
|
const deadCount = computed(() => stats.value.dead_total || 0)
|
||||||
|
|
||||||
function typePill(t) {
|
function typePill(t) {
|
||||||
return {
|
return {
|
||||||
@@ -77,31 +72,21 @@ function typePill(t) {
|
|||||||
}
|
}
|
||||||
const STATUS_LABEL = { active: '正常', quota: '额度耗尽', disabled: '已禁用', pending: '检测中' }
|
const STATUS_LABEL = { active: '正常', quota: '额度耗尽', disabled: '已禁用', pending: '检测中' }
|
||||||
|
|
||||||
const filtered = computed(() => {
|
// Server-side pagination: rows IS the current page, already filtered/sorted
|
||||||
const q = search.value.trim().toLowerCase()
|
// by the backend. total = server-side filtered count.
|
||||||
const sorted = [...rows.value].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
const filtered = computed(() => rows.value)
|
||||||
return sorted.filter((a) => {
|
const pagedItems = computed(() => rows.value)
|
||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
const pagedItems = computed(() => {
|
|
||||||
const start = (page.value - 1) * pageSize.value
|
|
||||||
return filtered.value.slice(start, start + pageSize.value)
|
|
||||||
})
|
|
||||||
function goPage(n) {
|
function goPage(n) {
|
||||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||||
if (target !== page.value) page.value = target
|
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 pageNumbers = computed(() => {
|
||||||
const n = totalPages.value
|
const n = totalPages.value
|
||||||
const cur = page.value
|
const cur = page.value
|
||||||
@@ -120,11 +105,28 @@ const pageNumbers = computed(() => {
|
|||||||
|
|
||||||
let pendingTimer = null
|
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() {
|
async function loadAccounts() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
quotaStatus.value = ''
|
quotaStatus.value = ''
|
||||||
const r = await api('/accounts')
|
await fetchAccounts()
|
||||||
rows.value = r.data?.data || []
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (rows.value.length) reconcile()
|
if (rows.value.length) reconcile()
|
||||||
schedulePendingPoll()
|
schedulePendingPoll()
|
||||||
@@ -136,8 +138,7 @@ function schedulePendingPoll() {
|
|||||||
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
|
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
|
||||||
if (!rows.value.some((r) => r.pending)) return
|
if (!rows.value.some((r) => r.pending)) return
|
||||||
pendingTimer = setTimeout(async () => {
|
pendingTimer = setTimeout(async () => {
|
||||||
const r = await api('/accounts')
|
await fetchAccounts()
|
||||||
rows.value = r.data?.data || []
|
|
||||||
schedulePendingPoll()
|
schedulePendingPoll()
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
@@ -211,12 +212,10 @@ async function reconcile() {
|
|||||||
if (myToken === reconcileToken) quotaStatus.value = ''
|
if (myToken === reconcileToken) quotaStatus.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-check the newly visible accounts whenever the page or filters change.
|
// Flipping pages re-queries the server for the new page; loadAccounts() then
|
||||||
// Only the on-screen page is ever probed (see reconcile), so flipping pages is
|
// reconciles just the freshly visible rows. Filter buttons go through
|
||||||
// what triggers checking the rest — never all rows at once.
|
// setFilter → resetAndLoad, so everything funnels into loadAccounts.
|
||||||
watch([page, typeFilter, statusFilter], () => {
|
watch(page, () => { loadAccounts() })
|
||||||
if (rows.value.length) reconcile()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Bounded-concurrency runner: keeps at most `limit` thunks in flight at once.
|
// Bounded-concurrency runner: keeps at most `limit` thunks in flight at once.
|
||||||
async function runWithLimit(thunks, limit) {
|
async function runWithLimit(thunks, limit) {
|
||||||
@@ -272,12 +271,14 @@ async function deleteAccount(pool, id) {
|
|||||||
loadAccounts()
|
loadAccounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 一键删除全部异常(已失效/红色锁定)账号。逐个走与单删相同的 DELETE 接口。
|
// 一键删除全部异常(已失效/红色锁定)账号。先向服务端要全量 dead 列表(跨页),再逐个删除。
|
||||||
async function deleteDeadAccounts() {
|
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 (!dead.length) return
|
||||||
if (!confirm(`确认删除全部 ${dead.length} 个异常(已失效)账号?此操作不可撤销。`)) return
|
await Promise.all(dead.map((a) => api(`/tokens/${a.pool}/${a.id}`, { method: 'DELETE' })))
|
||||||
await Promise.all(dead.map((r) => api(`/tokens/${r.pool}/${r.id}`, { method: 'DELETE' })))
|
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,8 +397,8 @@ onMounted(() => { loadAccounts(); loadModelList() })
|
|||||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
||||||
<Icon name="accounts" class="w-6 h-6" />
|
<Icon name="accounts" class="w-6 h-6" />
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm">{{ rows.length ? '没有匹配的账号' : '还没有账号' }}</span>
|
<span class="text-sm">{{ stats.total ? '没有匹配的账号' : '还没有账号' }}</span>
|
||||||
<button v-if="!rows.length" @click="showImport = true" class="btn-soft mt-1">导入第一个</button>
|
<button v-if="!stats.total" @click="showImport = true" class="btn-soft mt-1">导入第一个</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table v-else class="w-full text-sm table-fixed min-w-[1080px]">
|
<table v-else class="w-full text-sm table-fixed min-w-[1080px]">
|
||||||
@@ -554,8 +555,8 @@ onMounted(() => { loadAccounts(); loadModelList() })
|
|||||||
<div v-if="!loading && totalPages > 1"
|
<div v-if="!loading && totalPages > 1"
|
||||||
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
||||||
<div>
|
<div>
|
||||||
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span>
|
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span>
|
||||||
<span class="ml-1">/ {{ filtered.length }} 条</span>
|
<span class="ml-1">/ {{ total }} 条</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { api, jsonBody } from '../api'
|
import { api, jsonBody } from '../api'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
|
|
||||||
@@ -12,9 +12,16 @@ function flash(msg) { toast.value = msg; clearTimeout(toastTimer); toastTimer =
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const r = await api('/banned-words')
|
const qs = new URLSearchParams({
|
||||||
|
limit: String(pageSize),
|
||||||
|
offset: String((page.value - 1) * pageSize),
|
||||||
|
})
|
||||||
|
const r = await api('/banned-words?' + qs.toString())
|
||||||
items.value = r.data?.data || []
|
items.value = r.data?.data || []
|
||||||
|
total.value = Number(r.data?.total ?? items.value.length)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
// Deleting the last row of the last page can leave the cursor past the end.
|
||||||
|
if (page.value > totalPages.value) page.value = totalPages.value
|
||||||
}
|
}
|
||||||
|
|
||||||
async function add() {
|
async function add() {
|
||||||
@@ -79,14 +86,13 @@ async function delSelected() {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
// pagination (client-side; the full list arrives in one payload)
|
// Server-side pagination: items IS the current page.
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(items.value.length / pageSize)))
|
const total = ref(0)
|
||||||
const pagedItems = computed(() => {
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||||
const start = (Math.min(page.value, totalPages.value) - 1) * pageSize
|
const pagedItems = computed(() => items.value)
|
||||||
return items.value.slice(start, start + pageSize)
|
watch(page, () => { load() })
|
||||||
})
|
|
||||||
function goPage(n) {
|
function goPage(n) {
|
||||||
const t = Math.max(1, Math.min(totalPages.value, n))
|
const t = Math.max(1, Math.min(totalPages.value, n))
|
||||||
if (t !== page.value) page.value = t
|
if (t !== page.value) page.value = t
|
||||||
@@ -156,7 +162,7 @@ onMounted(load)
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div v-if="totalPages > 1" class="flex items-center justify-between px-5 py-3 border-t border-white/[0.06] text-xs text-white/45">
|
<div v-if="totalPages > 1" class="flex items-center justify-between px-5 py-3 border-t border-white/[0.06] text-xs text-white/45">
|
||||||
<div><span class="tabular-nums text-white/75">{{ items.length ? (Math.min(page, totalPages) - 1) * pageSize + 1 : 0 }}–{{ Math.min(items.length, Math.min(page, totalPages) * pageSize) }}</span><span class="ml-1">/ {{ items.length }} 条</span></div>
|
<div><span class="tabular-nums text-white/75">{{ total ? (Math.min(page, totalPages) - 1) * pageSize + 1 : 0 }}–{{ Math.min(total, Math.min(page, totalPages) * pageSize) }}</span><span class="ml-1">/ {{ total }} 条</span></div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||||
<span v-if="n === null" class="px-1 text-white/30">…</span>
|
<span v-if="n === null" class="px-1 text-white/30">…</span>
|
||||||
|
|||||||
@@ -14,18 +14,18 @@ const loading = ref(false)
|
|||||||
const statusFilter = ref('') // '' | 'active'(未使用) | 'used'(已使用)
|
const statusFilter = ref('') // '' | 'active'(未使用) | 'used'(已使用)
|
||||||
const typeFilter = ref('') // '' | 'normal' | 'marketing'
|
const typeFilter = ref('') // '' | 'normal' | 'marketing'
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
function setFilter(fn) { fn(); page.value = 1 }
|
function setFilter(fn) { fn(); resetAndLoad() }
|
||||||
watch(search, () => { page.value = 1 })
|
function resetAndLoad() {
|
||||||
const filtered = computed(() => {
|
if (page.value !== 1) page.value = 1
|
||||||
let list = items.value
|
else load()
|
||||||
if (statusFilter.value === 'active') list = list.filter((c) => c.status === 'active')
|
}
|
||||||
else if (statusFilter.value === 'used') list = list.filter((c) => c.status !== 'active')
|
let searchTimer = null
|
||||||
if (typeFilter.value === 'marketing') list = list.filter((c) => c.type === 'marketing')
|
watch(search, () => {
|
||||||
else if (typeFilter.value === 'normal') list = list.filter((c) => c.type !== 'marketing')
|
clearTimeout(searchTimer)
|
||||||
const q = search.value.trim().toUpperCase()
|
searchTimer = setTimeout(() => { resetAndLoad() }, 300)
|
||||||
if (q) list = list.filter((c) => (c.code || '').toUpperCase().includes(q))
|
|
||||||
return list
|
|
||||||
})
|
})
|
||||||
|
// Server-side pagination: items IS the current page (筛选/搜索均在后端)。
|
||||||
|
const filtered = computed(() => items.value)
|
||||||
|
|
||||||
const form = ref({ amount: 5000, count: 10, type: 'normal' })
|
const form = ref({ amount: 5000, count: 10, type: 'normal' })
|
||||||
const lastBatch = ref([]) // codes from the most recent generate
|
const lastBatch = ref([]) // codes from the most recent generate
|
||||||
@@ -35,14 +35,27 @@ function flash(m) { flashMsg.value = m; clearTimeout(flashTimer); flashTimer = s
|
|||||||
|
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const r = await api('/cdks')
|
const qs = new URLSearchParams({
|
||||||
|
limit: String(pageSize.value),
|
||||||
|
offset: String((page.value - 1) * pageSize.value),
|
||||||
|
})
|
||||||
|
if (statusFilter.value) qs.set('status', statusFilter.value)
|
||||||
|
if (typeFilter.value) qs.set('type', typeFilter.value)
|
||||||
|
if (search.value.trim()) qs.set('q', search.value.trim())
|
||||||
|
const r = await api('/cdks?' + qs.toString())
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (r.ok) { items.value = r.data?.data || []; stats.value = r.data?.stats || stats.value }
|
if (r.ok) {
|
||||||
|
items.value = r.data?.data || []
|
||||||
|
total.value = Number(r.data?.total ?? items.value.length)
|
||||||
|
stats.value = r.data?.stats || stats.value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
watch(page, () => { load() })
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
const amount = Number(form.value.amount), count = Number(form.value.count)
|
const amount = Number(form.value.amount), count = Number(form.value.count)
|
||||||
@@ -102,13 +115,8 @@ async function copy(text) {
|
|||||||
}
|
}
|
||||||
function copyBatch() { copy(lastBatch.value.join('\n')) }
|
function copyBatch() { copy(lastBatch.value.join('\n')) }
|
||||||
|
|
||||||
// Client-side pagination over the full list (CDK volumes are bounded by
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
// how many the admin generates — comfortably small).
|
const pagedItems = computed(() => items.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)
|
|
||||||
})
|
|
||||||
function goPage(n) {
|
function goPage(n) {
|
||||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||||
if (target !== page.value) page.value = target
|
if (target !== page.value) page.value = target
|
||||||
@@ -220,8 +228,8 @@ const pageNumbers = computed(() => {
|
|||||||
<!-- table -->
|
<!-- table -->
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-16">加载中…</div>
|
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-16">加载中…</div>
|
||||||
<div v-else-if="!items.length" class="text-center text-sm text-white/40 py-16">还没有兑换码</div>
|
<div v-else-if="!items.length && !stats.total" class="text-center text-sm text-white/40 py-16">还没有兑换码</div>
|
||||||
<div v-else-if="!filtered.length" class="text-center text-sm text-white/40 py-16">没有匹配的兑换码</div>
|
<div v-else-if="!items.length" class="text-center text-sm text-white/40 py-16">没有匹配的兑换码</div>
|
||||||
<table v-else class="w-full text-sm">
|
<table v-else class="w-full text-sm">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col class="w-9" />
|
<col class="w-9" />
|
||||||
@@ -291,8 +299,8 @@ const pageNumbers = computed(() => {
|
|||||||
<div v-if="!loading && totalPages > 1"
|
<div v-if="!loading && totalPages > 1"
|
||||||
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
||||||
<div>
|
<div>
|
||||||
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(items.length, page * pageSize) }}</span>
|
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span>
|
||||||
<span class="ml-1">/ {{ items.length }} 条</span>
|
<span class="ml-1">/ {{ total }} 条</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// - work : "我们的作品" marquee — admin-curated featured outputs
|
// - work : "我们的作品" marquee — admin-curated featured outputs
|
||||||
// All three kinds use a real image as the background; admins pick one from
|
// All three kinds use a real image as the background; admins pick one from
|
||||||
// the already-generated files or paste an external URL.
|
// the already-generated files or paste an external URL.
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||||
import { api, jsonBody, generatedUrl } from '../api'
|
import { api, jsonBody, generatedUrl } from '../api'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ const picking = ref(false) // truthy when the image-picker modal is open
|
|||||||
const recentFiles = ref([]) // populated from /stats.recent for the picker
|
const recentFiles = ref([]) // populated from /stats.recent for the picker
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(12)
|
const pageSize = ref(12)
|
||||||
|
const total = ref(0)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: '', kind: 'hero', title: '', subtitle: '', prompt: '',
|
id: '', kind: 'hero', title: '', subtitle: '', prompt: '',
|
||||||
image: '', weight: 100, span: '',
|
image: '', weight: 100, span: '',
|
||||||
@@ -27,28 +28,28 @@ const error = ref('')
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const r = await api('/showcase')
|
const qs = new URLSearchParams({
|
||||||
const grouped = r.data?.data || {}
|
limit: String(pageSize.value),
|
||||||
// Guard every group — a payload missing hero/bento would throw on spread of
|
offset: String((page.value - 1) * pageSize.value),
|
||||||
// undefined and freeze the page on "加载中…".
|
})
|
||||||
items.value = [...(grouped.hero || []), ...(grouped.bento || []), ...(grouped.work || [])]
|
if (filter.value !== 'all') qs.set('kind', filter.value)
|
||||||
|
const r = await api('/showcase/admin?' + qs.toString())
|
||||||
|
items.value = r.data?.data || []
|
||||||
|
total.value = Number(r.data?.total ?? items.value.length)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = computed(() => {
|
// Server-side pagination: items IS the current page (kind 筛选在后端)。
|
||||||
if (filter.value === 'all') return items.value
|
const filtered = computed(() => items.value)
|
||||||
return items.value.filter((x) => x.kind === filter.value)
|
const pagedItems = computed(() => items.value)
|
||||||
})
|
|
||||||
|
|
||||||
// Client-side pagination over the filtered set. The showcase store is small
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
// (admin curates manually) so paging client-side is fine — no extra API calls
|
watch(page, () => { refresh() })
|
||||||
// when the admin flips pages.
|
function setFilter(v) {
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value)))
|
filter.value = v
|
||||||
const pagedItems = computed(() => {
|
if (page.value !== 1) page.value = 1
|
||||||
const start = (page.value - 1) * pageSize.value
|
else refresh()
|
||||||
return filtered.value.slice(start, start + pageSize.value)
|
}
|
||||||
})
|
|
||||||
function setFilter(v) { filter.value = v; page.value = 1 }
|
|
||||||
function goPage(n) {
|
function goPage(n) {
|
||||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||||
if (target !== page.value) page.value = target
|
if (target !== page.value) page.value = target
|
||||||
@@ -234,8 +235,8 @@ onMounted(refresh)
|
|||||||
<div v-if="!loading && totalPages > 1"
|
<div v-if="!loading && totalPages > 1"
|
||||||
class="card !p-3 flex items-center justify-between gap-3">
|
class="card !p-3 flex items-center justify-between gap-3">
|
||||||
<div class="text-xs text-[color:var(--fg-3)] tabular-nums px-2">
|
<div class="text-xs text-[color:var(--fg-3)] tabular-nums px-2">
|
||||||
<span class="text-[color:var(--fg)]">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span>
|
<span class="text-[color:var(--fg)]">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span>
|
||||||
/ {{ filtered.length }} 条
|
/ {{ total }} 条
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { api, jsonBody } from '../api'
|
import { api, jsonBody } from '../api'
|
||||||
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
|
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
@@ -15,6 +15,7 @@ const statusFilter = ref('') // '' | 'active' | 'disabled'
|
|||||||
|
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
const showAdd = ref(false)
|
const showAdd = ref(false)
|
||||||
const editing = ref(null)
|
const editing = ref(null)
|
||||||
@@ -59,41 +60,41 @@ async function loadGroups() {
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const r = await api('/users')
|
const qs = new URLSearchParams({
|
||||||
|
limit: String(pageSize.value),
|
||||||
|
offset: String((page.value - 1) * pageSize.value),
|
||||||
|
})
|
||||||
|
if (roleFilter.value) qs.set('role', roleFilter.value)
|
||||||
|
if (statusFilter.value) qs.set('status', statusFilter.value)
|
||||||
|
if (search.value.trim()) qs.set('q', search.value.trim())
|
||||||
|
const r = await api('/users?' + qs.toString())
|
||||||
items.value = r.data?.data || []
|
items.value = r.data?.data || []
|
||||||
|
total.value = Number(r.data?.total ?? items.value.length)
|
||||||
stats.value = r.data?.stats || stats.value
|
stats.value = r.data?.stats || stats.value
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
onMounted(() => { load(); loadGroups() })
|
onMounted(() => { load(); loadGroups() })
|
||||||
|
|
||||||
const filtered = computed(() => {
|
// Server-side pagination: items IS the current page (filter/搜索/排序均在后端)。
|
||||||
const q = search.value.trim().toLowerCase()
|
const filtered = computed(() => items.value)
|
||||||
// Newest first — created_at desc, falling back to id so users without a
|
const pagedItems = computed(() => items.value)
|
||||||
// timestamp still get a stable order.
|
|
||||||
const sorted = [...items.value].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
|
||||||
return sorted.filter((u) => {
|
|
||||||
if (roleFilter.value && u.role !== roleFilter.value) return false
|
|
||||||
if (statusFilter.value && u.status !== statusFilter.value) return false
|
|
||||||
if (q && !(
|
|
||||||
(u.email || '').toLowerCase().includes(q) ||
|
|
||||||
(u.name || '').toLowerCase().includes(q) ||
|
|
||||||
(u.id || '').toLowerCase().includes(q)
|
|
||||||
)) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Client-side pagination — user list is bounded.
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.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)
|
|
||||||
})
|
|
||||||
function goPage(n) {
|
function goPage(n) {
|
||||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||||
if (target !== page.value) page.value = target
|
if (target !== page.value) page.value = target
|
||||||
}
|
}
|
||||||
function setFilter(fn) { fn(); page.value = 1 }
|
watch(page, () => { load() })
|
||||||
|
function setFilter(fn) { fn(); resetAndLoad() }
|
||||||
|
function resetAndLoad() {
|
||||||
|
if (page.value !== 1) page.value = 1
|
||||||
|
else load()
|
||||||
|
}
|
||||||
|
let searchTimer = null
|
||||||
|
watch(search, () => {
|
||||||
|
clearTimeout(searchTimer)
|
||||||
|
searchTimer = setTimeout(() => { resetAndLoad() }, 300)
|
||||||
|
})
|
||||||
const pageNumbers = computed(() => {
|
const pageNumbers = computed(() => {
|
||||||
const n = totalPages.value
|
const n = totalPages.value
|
||||||
const cur = page.value
|
const cur = page.value
|
||||||
@@ -272,8 +273,8 @@ async function doRecharge() {
|
|||||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
||||||
<Icon name="accounts" class="w-6 h-6" />
|
<Icon name="accounts" class="w-6 h-6" />
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm">{{ items.length ? '没有匹配的用户' : '还没有用户' }}</span>
|
<span class="text-sm">{{ stats.total ? '没有匹配的用户' : '还没有用户' }}</span>
|
||||||
<button v-if="!items.length" @click="showAdd = true" class="btn-soft mt-1">新建第一个</button>
|
<button v-if="!stats.total" @click="showAdd = true" class="btn-soft mt-1">新建第一个</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<div v-else class="overflow-x-auto">
|
||||||
@@ -410,8 +411,8 @@ async function doRecharge() {
|
|||||||
<div v-if="!loading && totalPages > 1"
|
<div v-if="!loading && totalPages > 1"
|
||||||
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
||||||
<div>
|
<div>
|
||||||
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span>
|
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span>
|
||||||
<span class="ml-1">/ {{ filtered.length }} 条</span>
|
<span class="ml-1">/ {{ total }} 条</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||||
|
|||||||
Reference in New Issue
Block a user