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
+60 -59
View File
@@ -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() })
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
<Icon name="accounts" class="w-6 h-6" />
</span>
<span class="text-sm">{{ rows.length ? '没有匹配的账号' : '还没有账号' }}</span>
<button v-if="!rows.length" @click="showImport = true" class="btn-soft mt-1">导入第一个</button>
<span class="text-sm">{{ stats.total ? '没有匹配的账号' : '还没有账号' }}</span>
<button v-if="!stats.total" @click="showImport = true" class="btn-soft mt-1">导入第一个</button>
</div>
<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"
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
<div>
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}{{ Math.min(filtered.length, page * pageSize) }}</span>
<span class="ml-1">/ {{ filtered.length }} </span>
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}{{ Math.min(total, page * pageSize) }}</span>
<span class="ml-1">/ {{ total }} </span>
</div>
<div class="flex items-center gap-1">
<template v-for="(n, i) in pageNumbers" :key="i">
+15 -9
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { api, jsonBody } from '../api'
import Icon from '../components/Icon.vue'
@@ -12,9 +12,16 @@ function flash(msg) { toast.value = msg; clearTimeout(toastTimer); toastTimer =
async function load() {
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 || []
total.value = Number(r.data?.total ?? items.value.length)
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() {
@@ -79,14 +86,13 @@ async function delSelected() {
load()
}
// pagination (client-side; the full list arrives in one payload)
// Server-side pagination: items IS the current page.
const page = ref(1)
const pageSize = 20
const totalPages = computed(() => Math.max(1, Math.ceil(items.value.length / pageSize)))
const pagedItems = computed(() => {
const start = (Math.min(page.value, totalPages.value) - 1) * pageSize
return items.value.slice(start, start + pageSize)
})
const total = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const pagedItems = computed(() => items.value)
watch(page, () => { load() })
function goPage(n) {
const t = Math.max(1, Math.min(totalPages.value, n))
if (t !== page.value) page.value = t
@@ -156,7 +162,7 @@ onMounted(load)
</tbody>
</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><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">
<template v-for="(n, i) in pageNumbers" :key="i">
<span v-if="n === null" class="px-1 text-white/30"></span>
+32 -24
View File
@@ -14,18 +14,18 @@ const loading = ref(false)
const statusFilter = ref('') // '' | 'active'(未使用) | 'used'(已使用)
const typeFilter = ref('') // '' | 'normal' | 'marketing'
const search = ref('')
function setFilter(fn) { fn(); page.value = 1 }
watch(search, () => { page.value = 1 })
const filtered = computed(() => {
let list = items.value
if (statusFilter.value === 'active') list = list.filter((c) => c.status === 'active')
else if (statusFilter.value === 'used') list = list.filter((c) => c.status !== 'active')
if (typeFilter.value === 'marketing') list = list.filter((c) => c.type === 'marketing')
else if (typeFilter.value === 'normal') list = list.filter((c) => c.type !== 'marketing')
const q = search.value.trim().toUpperCase()
if (q) list = list.filter((c) => (c.code || '').toUpperCase().includes(q))
return list
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)
})
// Server-side pagination: items IS the current page (筛选/搜索均在后端)。
const filtered = computed(() => items.value)
const form = ref({ amount: 5000, count: 10, type: 'normal' })
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 pageSize = ref(20)
const total = ref(0)
async function load() {
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
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)
watch(page, () => { load() })
async function generate() {
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')) }
// Client-side pagination over the full list (CDK volumes are bounded by
// how many the admin generates — comfortably small).
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)))
const pagedItems = computed(() => items.value)
function goPage(n) {
const target = Math.max(1, Math.min(totalPages.value, n))
if (target !== page.value) page.value = target
@@ -220,8 +228,8 @@ const pageNumbers = computed(() => {
<!-- table -->
<div class="card overflow-hidden">
<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="!filtered.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="!items.length" class="text-center text-sm text-white/40 py-16">没有匹配的兑换码</div>
<table v-else class="w-full text-sm">
<colgroup>
<col class="w-9" />
@@ -291,8 +299,8 @@ const pageNumbers = computed(() => {
<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">
<div>
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}{{ Math.min(items.length, page * pageSize) }}</span>
<span class="ml-1">/ {{ items.length }} </span>
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}{{ Math.min(total, page * pageSize) }}</span>
<span class="ml-1">/ {{ total }} </span>
</div>
<div class="flex items-center gap-1">
<template v-for="(n, i) in pageNumbers" :key="i">
+22 -21
View File
@@ -6,7 +6,7 @@
// - work : "我们的作品" marquee — admin-curated featured outputs
// All three kinds use a real image as the background; admins pick one from
// 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 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 page = ref(1)
const pageSize = ref(12)
const total = ref(0)
const form = reactive({
id: '', kind: 'hero', title: '', subtitle: '', prompt: '',
image: '', weight: 100, span: '',
@@ -27,28 +28,28 @@ const error = ref('')
async function refresh() {
loading.value = true
const r = await api('/showcase')
const grouped = r.data?.data || {}
// Guard every group — a payload missing hero/bento would throw on spread of
// undefined and freeze the page on "加载中…".
items.value = [...(grouped.hero || []), ...(grouped.bento || []), ...(grouped.work || [])]
const qs = new URLSearchParams({
limit: String(pageSize.value),
offset: String((page.value - 1) * pageSize.value),
})
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
}
const filtered = computed(() => {
if (filter.value === 'all') return items.value
return items.value.filter((x) => x.kind === filter.value)
})
// Server-side pagination: items IS the current page (kind 筛选在后端)。
const filtered = computed(() => items.value)
const pagedItems = computed(() => items.value)
// Client-side pagination over the filtered set. The showcase store is small
// (admin curates manually) so paging client-side is fine — no extra API calls
// when the admin flips pages.
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 setFilter(v) { filter.value = v; page.value = 1 }
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
watch(page, () => { refresh() })
function setFilter(v) {
filter.value = v
if (page.value !== 1) page.value = 1
else refresh()
}
function goPage(n) {
const target = Math.max(1, Math.min(totalPages.value, n))
if (target !== page.value) page.value = target
@@ -234,8 +235,8 @@ onMounted(refresh)
<div v-if="!loading && totalPages > 1"
class="card !p-3 flex items-center justify-between gap-3">
<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>
/ {{ filtered.length }}
<span class="text-[color:var(--fg)]">{{ (page - 1) * pageSize + 1 }}{{ Math.min(total, page * pageSize) }}</span>
/ {{ total }}
</div>
<div class="flex items-center gap-1">
<template v-for="(n, i) in pageNumbers" :key="i">
+30 -29
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { api, jsonBody } from '../api'
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
import Icon from '../components/Icon.vue'
@@ -15,6 +15,7 @@ const statusFilter = ref('') // '' | 'active' | 'disabled'
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const showAdd = ref(false)
const editing = ref(null)
@@ -59,41 +60,41 @@ async function loadGroups() {
async function load() {
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 || []
total.value = Number(r.data?.total ?? items.value.length)
stats.value = r.data?.stats || stats.value
loading.value = false
}
onMounted(() => { load(); loadGroups() })
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
// Newest first — created_at desc, falling back to id so users without a
// 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
})
})
// Server-side pagination: items IS the current page (filter/搜索/排序均在后端)。
const filtered = computed(() => items.value)
const pagedItems = computed(() => items.value)
// Client-side pagination — user list is bounded.
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 }
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 n = totalPages.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">
<Icon name="accounts" class="w-6 h-6" />
</span>
<span class="text-sm">{{ items.length ? '没有匹配的用户' : '还没有用户' }}</span>
<button v-if="!items.length" @click="showAdd = true" class="btn-soft mt-1">新建第一个</button>
<span class="text-sm">{{ stats.total ? '没有匹配的用户' : '还没有用户' }}</span>
<button v-if="!stats.total" @click="showAdd = true" class="btn-soft mt-1">新建第一个</button>
</div>
<div v-else class="overflow-x-auto">
@@ -410,8 +411,8 @@ async function doRecharge() {
<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">
<div>
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}{{ Math.min(filtered.length, page * pageSize) }}</span>
<span class="ml-1">/ {{ filtered.length }} </span>
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}{{ Math.min(total, page * pageSize) }}</span>
<span class="ml-1">/ {{ total }} </span>
</div>
<div class="flex items-center gap-1">
<template v-for="(n, i) in pageNumbers" :key="i">