diff --git a/app/control/account/backends/local.py b/app/control/account/backends/local.py index 85eb3971f..ab0fe3575 100644 --- a/app/control/account/backends/local.py +++ b/app/control/account/backends/local.py @@ -449,6 +449,10 @@ def _sync() -> AccountPage: if query.status: where_parts.append("status = ?") params.append(query.status.value) + if query.exclude_statuses: + placeholders = ", ".join("?" for _ in query.exclude_statuses) + where_parts.append(f"status NOT IN ({placeholders})") + params.extend(s.value for s in query.exclude_statuses) where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else "" order_dir = "DESC" if query.sort_desc else "ASC" @@ -512,5 +516,77 @@ def _sync() -> AccountMutationResult: async def close(self) -> None: """No-op for SQLite — connections are opened and closed per operation.""" + async def get_stats(self) -> dict: + """Return aggregated stats via SQL — no Python-side row iteration.""" + import json as _json + import time as _time + + def _sync() -> dict: + t0 = _time.monotonic() + with closing(self._connect()) as conn: + # 1. Status counts + status_rows = conn.execute( + f"SELECT status, COUNT(*) FROM {_TBL} WHERE deleted_at IS NULL GROUP BY status" + ).fetchall() + status_counts: dict[str, int] = {} + for row in status_rows: + status_counts[row[0]] = row[1] + total = sum(status_counts.values()) + + # 2. Pool counts + pool_rows = conn.execute( + f"SELECT pool, COUNT(*) FROM {_TBL} WHERE deleted_at IS NULL GROUP BY pool" + ).fetchall() + pool_counts: dict[str, int] = {} + for row in pool_rows: + pool_counts[row[0]] = row[1] + + # 3. Pool × status + ps_rows = conn.execute( + f"SELECT pool, status, COUNT(*) FROM {_TBL} WHERE deleted_at IS NULL GROUP BY pool, status" + ).fetchall() + pool_status: dict[str, dict[str, int]] = {} + for row in ps_rows: + pool_status.setdefault(row[0], {})[row[1]] = row[2] + + # 4. Usage sums + usage_row = conn.execute( + f"SELECT COALESCE(SUM(usage_use_count),0), COALESCE(SUM(usage_fail_count),0) " + f"FROM {_TBL} WHERE deleted_at IS NULL" + ).fetchone() + success = int(usage_row[0]) + fail = int(usage_row[1]) + + # 5. Quota sums — extract "remaining" from JSON without Python parse + quota_sums: dict[str, int] = {"auto": 0, "fast": 0, "expert": 0, "heavy": 0} + for mode in ("auto", "fast", "expert", "heavy"): + row = conn.execute( + f"SELECT COALESCE(SUM(CAST(" + f" json_extract(quota_{mode}, '$.remaining') AS INTEGER" + f")), 0) FROM {_TBL} WHERE deleted_at IS NULL" + ).fetchone() + quota_sums[mode] = int(row[0]) + + # 6. NSFW counts — tags is stored as JSON array, check for "nsfw" + nsfw_row = conn.execute( + f"SELECT COUNT(*) FROM {_TBL} WHERE deleted_at IS NULL AND tags LIKE '%\"nsfw\"%'" + ).fetchone() + nsfw_enabled = int(nsfw_row[0]) + nsfw_disabled = total - nsfw_enabled + + elapsed = _time.monotonic() - t0 + return { + "total": total, + "status_counts": status_counts, + "pool_counts": pool_counts, + "pool_status": pool_status, + "usage": {"success": success, "fail": fail, "calls": success + fail}, + "quota_sums": quota_sums, + "nsfw": {"enabled": nsfw_enabled, "disabled": nsfw_disabled}, + "elapsed_ms": round(elapsed * 1000, 1), + } + + return await asyncio.to_thread(_sync) + __all__ = ["LocalAccountRepository"] diff --git a/app/control/account/backends/redis.py b/app/control/account/backends/redis.py index 68c4d4b49..122ce3354 100644 --- a/app/control/account/backends/redis.py +++ b/app/control/account/backends/redis.py @@ -369,6 +369,8 @@ async def list_accounts( continue if query.status and r.status != query.status: continue + if query.exclude_statuses and r.status in query.exclude_statuses: + continue all_records.append(r) # Sort. @@ -407,6 +409,56 @@ async def replace_pool( revision=upserted_result.revision, ) + async def get_stats(self) -> dict: + """Return aggregated stats — scans all record keys (Redis has no aggregation).""" + import time as _time + + t0 = _time.monotonic() + status_counts: dict[str, int] = {} + pool_counts: dict[str, int] = {} + pool_status: dict[str, dict[str, int]] = {} + success = fail = 0 + quota_sums: dict[str, int] = {"auto": 0, "fast": 0, "expert": 0, "heavy": 0} + nsfw_enabled = 0 + total = 0 + + async for key in self._r.scan_iter("accounts:record:*"): + token = (key.decode() if isinstance(key, bytes) else key).split(":", 2)[-1] + h = await self._r.hgetall(key) + if not h: + continue + r = self._from_hash(token, h) + if r.is_deleted(): + continue + total += 1 + st = r.status or "active" + status_counts[st] = status_counts.get(st, 0) + 1 + pool = r.pool or "basic" + pool_counts[pool] = pool_counts.get(pool, 0) + 1 + ps = pool_status.setdefault(pool, {}) + ps[st] = ps.get(st, 0) + 1 + success += r.usage_use_count or 0 + fail += r.usage_fail_count or 0 + if "nsfw" in (r.tags or []): + nsfw_enabled += 1 + if isinstance(r.quota, dict): + for mode in ("auto", "fast", "expert", "heavy"): + v = r.quota.get(mode) + if isinstance(v, dict): + quota_sums[mode] += int(v.get("remaining", 0) or 0) + + elapsed = _time.monotonic() - t0 + return { + "total": total, + "status_counts": status_counts, + "pool_counts": pool_counts, + "pool_status": pool_status, + "usage": {"success": success, "fail": fail, "calls": success + fail}, + "quota_sums": quota_sums, + "nsfw": {"enabled": nsfw_enabled, "disabled": total - nsfw_enabled}, + "elapsed_ms": round(elapsed * 1000, 1), + } + async def close(self) -> None: """Close the underlying Redis connection pool.""" await self._r.aclose() diff --git a/app/control/account/backends/sql.py b/app/control/account/backends/sql.py index 66b605bb6..59d7e36fa 100644 --- a/app/control/account/backends/sql.py +++ b/app/control/account/backends/sql.py @@ -785,6 +785,10 @@ async def list_accounts( stmt = stmt.where(accounts_table.c.pool == query.pool) if query.status: stmt = stmt.where(accounts_table.c.status == query.status.value) + if query.exclude_statuses: + stmt = stmt.where(accounts_table.c.status.notin_( + [s.value for s in query.exclude_statuses] + )) total_row = (await conn.execute( sa.select(sa.func.count()).select_from(stmt.subquery()) @@ -835,6 +839,89 @@ async def replace_pool( revision=upserted_result.revision, ) + async def get_stats(self) -> dict: + """Return aggregated stats via SQL — no Python-side row iteration.""" + import time as _time + t = accounts_table + + t0 = _time.monotonic() + async with self._session() as session: + # 1. Status counts + stmt = ( + sa.select(t.c.status, sa.func.count()) + .where(t.c.deleted_at.is_(None)) + .group_by(t.c.status) + ) + rows = (await session.execute(stmt)).all() + status_counts = {r[0]: r[1] for r in rows} + total = sum(status_counts.values()) + + # 2. Pool counts + stmt = ( + sa.select(t.c.pool, sa.func.count()) + .where(t.c.deleted_at.is_(None)) + .group_by(t.c.pool) + ) + rows = (await session.execute(stmt)).all() + pool_counts = {r[0]: r[1] for r in rows} + + # 3. Pool × status + stmt = ( + sa.select(t.c.pool, t.c.status, sa.func.count()) + .where(t.c.deleted_at.is_(None)) + .group_by(t.c.pool, t.c.status) + ) + rows = (await session.execute(stmt)).all() + pool_status: dict[str, dict[str, int]] = {} + for r in rows: + pool_status.setdefault(r[0], {})[r[1]] = r[2] + + # 4. Usage sums + stmt = sa.select( + sa.func.coalesce(sa.func.sum(t.c.usage_use_count), 0), + sa.func.coalesce(sa.func.sum(t.c.usage_fail_count), 0), + ).where(t.c.deleted_at.is_(None)) + row = (await session.execute(stmt)).one() + success, fail = int(row[0]), int(row[1]) + + # 5. Quota sums (dialect-aware JSON extraction) + quota_sums: dict[str, int] = {} + for mode in ("auto", "fast", "expert", "heavy"): + col = getattr(t.c, f"quota_{mode}") + if self._dialect == "mysql": + remaining_expr = sa.cast( + sa.func.json_extract(col, "$.remaining"), sa.Integer + ) + else: # postgresql + remaining_expr = sa.cast( + sa.func.json_extract_path_text(col, "remaining"), sa.Integer + ) + stmt = sa.select( + sa.func.coalesce(sa.func.sum(remaining_expr), 0) + ).where(t.c.deleted_at.is_(None)) + r = (await session.execute(stmt)).scalar() + quota_sums[mode] = int(r) + + # 6. NSFW + stmt = sa.select(sa.func.count()).where( + t.c.deleted_at.is_(None), + t.c.tags.like('%"nsfw"%'), + ) + nsfw_enabled = int((await session.execute(stmt)).scalar()) + nsfw_disabled = total - nsfw_enabled + + elapsed = _time.monotonic() - t0 + return { + "total": total, + "status_counts": status_counts, + "pool_counts": pool_counts, + "pool_status": pool_status, + "usage": {"success": success, "fail": fail, "calls": success + fail}, + "quota_sums": quota_sums, + "nsfw": {"enabled": nsfw_enabled, "disabled": nsfw_disabled}, + "elapsed_ms": round(elapsed * 1000, 1), + } + async def close(self) -> None: """Dispose the SQLAlchemy connection pool.""" if self._dispose_engine: diff --git a/app/control/account/commands.py b/app/control/account/commands.py index ba5011432..93540c2cf 100644 --- a/app/control/account/commands.py +++ b/app/control/account/commands.py @@ -59,6 +59,7 @@ class ListAccountsQuery(BaseModel): page_size: int = Field(default=50, ge=1, le=2000) pool: str | None = None status: AccountStatus | None = None + exclude_statuses: list[AccountStatus] = Field(default_factory=list) tags: list[str] = Field(default_factory=list) include_deleted: bool = False sort_by: str = "updated_at" # field name diff --git a/app/control/account/repository.py b/app/control/account/repository.py index 776046288..38f2812b8 100644 --- a/app/control/account/repository.py +++ b/app/control/account/repository.py @@ -79,6 +79,10 @@ async def replace_pool( """Atomically replace all accounts in a pool.""" ... + async def get_stats(self) -> dict: + """Return aggregated stats (status/pool/usage/quota counts).""" + ... + async def close(self) -> None: """Release database connections / file handles.""" ... diff --git a/app/products/openai/router.py b/app/products/openai/router.py index 01a27504a..293346244 100644 --- a/app/products/openai/router.py +++ b/app/products/openai/router.py @@ -37,12 +37,31 @@ async def _available_pools(request: Request) -> frozenset[str]: - repo = getattr(request.app.state, "repository", None) - if repo is None: - return frozenset() - - snapshot = await repo.runtime_snapshot() - pools = {record.pool for record in snapshot.items if is_manageable(record)} + """Return the set of pool names that have at least one manageable account. + + Uses the in-memory AccountRuntimeTable (O(n) array scan, no DB hit) + instead of repo.runtime_snapshot() which would deserialise every row. + """ + from app.dataplane.account import _directory + from app.dataplane.shared.enums import POOL_ID_TO_STR, StatusId + + if _directory is None or _directory._table is None: + # Fallback: no runtime table yet — use repo (startup path) + repo = getattr(request.app.state, "repository", None) + if repo is None: + return frozenset() + snapshot = await repo.runtime_snapshot() + pools = {record.pool for record in snapshot.items if is_manageable(record)} + return frozenset(pools) + + table = _directory._table + manageable_statuses = {int(StatusId.ACTIVE), int(StatusId.COOLING)} + pools: set[str] = set() + for i in range(len(table.pool_by_idx)): + if table.status_by_idx[i] in manageable_statuses: + pool_name = POOL_ID_TO_STR.get(table.pool_by_idx[i]) + if pool_name: + pools.add(pool_name) return frozenset(pools) @@ -64,7 +83,9 @@ def _model_available_for_pools(spec: ModelSpec, pools: frozenset[str]) -> bool: @router.get("/models", tags=[_TAG_MODELS], dependencies=[Depends(verify_api_key)]) async def list_models(request: Request): import time + import time as _time + t0 = _time.monotonic() pools = await _available_pools(request) models = [ { @@ -77,6 +98,8 @@ async def list_models(request: Request): for m in model_registry.list_enabled() if _model_available_for_pools(m, pools) ] + elapsed = _time.monotonic() - t0 + logger.info("openai list_models: pools={} models={} elapsed_ms={:.1f}", pools, len(models), elapsed * 1000) return JSONResponse({"object": "list", "data": models}) diff --git a/app/products/web/admin/tokens.py b/app/products/web/admin/tokens.py index bf5bf10f6..5a4797230 100644 --- a/app/products/web/admin/tokens.py +++ b/app/products/web/admin/tokens.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING import orjson -from fastapi import APIRouter, Body, Depends +from fastapi import APIRouter, Body, Depends, Query from fastapi.responses import Response from pydantic import BaseModel, RootModel @@ -125,6 +125,7 @@ def _serialize_record(r) -> dict: "status": r.status, "quota": _quota_brief(r.quota) if isinstance(r.quota, dict) else {}, "use_count": r.usage_use_count or 0, + "fail_count": r.usage_fail_count or 0, "last_used_at": r.last_use_at, "tags": r.tags or [], } @@ -140,18 +141,77 @@ def _json(data) -> Response: # --------------------------------------------------------------------------- @router.get("/tokens") -async def list_tokens(repo: "AccountRepository" = Depends(get_repo)): - """Return flat token list.""" - all_items: list = [] - page_num = 1 - while True: - page = await repo.list_accounts(ListAccountsQuery(page=page_num, page_size=2000)) - all_items.extend(page.items) - if page_num * 2000 >= page.total: - break - page_num += 1 - - return _json({"tokens": [_serialize_record(r) for r in all_items]}) +async def list_tokens( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=2000), + pool: str | None = Query(None), + status: str | None = Query(None), + exclude_statuses: str | None = Query(None, description="Comma-separated statuses to exclude"), + repo: "AccountRepository" = Depends(get_repo), +): + """Return paginated token list.""" + import time as _time + _t0 = _time.monotonic() + + query = ListAccountsQuery(page=page, page_size=page_size) + if pool: + query.pool = pool + if status: + try: + query.status = AccountStatus(status) + except ValueError: + raise ValidationError(f"Invalid status: {status}", param="status") + if exclude_statuses: + for s in exclude_statuses.split(","): + s = s.strip() + if s: + try: + query.exclude_statuses.append(AccountStatus(s)) + except ValueError: + pass + + result = await repo.list_accounts(query) + elapsed = _time.monotonic() - _t0 + logger.info( + "admin list_tokens: page={} page_size={} total={} elapsed_ms={:.1f}", + page, page_size, result.total, elapsed * 1000, + ) + return _json({ + "tokens": [_serialize_record(r) for r in result.items], + "total": result.total, + "page": result.page, + "page_size": result.page_size, + "total_pages": result.total_pages, + }) + + +@router.get("/tokens/stats") +async def token_stats(repo: "AccountRepository" = Depends(get_repo)): + """Return aggregated stats across all tokens (SQL-optimised).""" + import time as _time + _t0 = _time.monotonic() + raw = await repo.get_stats() + elapsed = _time.monotonic() - _t0 + logger.info("admin token_stats: total={} elapsed_ms={:.1f}", raw.get("total", 0), elapsed * 1000) + sc = raw.get("status_counts", {}) + usage = raw.get("usage", {}) + nsfw = raw.get("nsfw", {}) + return _json({ + "stats": { + "total": raw.get("total", 0), + "active": sc.get("active", 0), + "cooling": sc.get("cooling", 0), + "expired": sc.get("expired", 0), + "disabled": sc.get("disabled", 0), + "calls": usage.get("calls", 0), + "success": usage.get("success", 0), + "fail": usage.get("fail", 0), + }, + "quota_sums": raw.get("quota_sums", {}), + "pool_counts": raw.get("pool_counts", {}), + "pool_status": raw.get("pool_status", {}), + "nsfw_counts": {"enabled": nsfw.get("enabled", 0), "disabled": nsfw.get("disabled", 0)}, + }) @router.post("/tokens") diff --git a/app/products/web/webui/chat.py b/app/products/web/webui/chat.py index 9d1dc2f9c..f479afd3c 100644 --- a/app/products/web/webui/chat.py +++ b/app/products/web/webui/chat.py @@ -2,12 +2,16 @@ import time -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from app.control.model import registry as model_registry from app.platform.auth.middleware import verify_webui_key -from app.products.openai.router import chat_completions_endpoint +from app.products.openai.router import ( + _available_pools, + _model_available_for_pools, + chat_completions_endpoint, +) from app.products.openai.schemas import ChatCompletionRequest router = APIRouter(prefix="/webui/api", dependencies=[Depends(verify_webui_key)], tags=["WebUI - Chat"]) @@ -24,7 +28,12 @@ def _capability_name(spec) -> str: @router.get("/models") -async def list_webui_models(): +async def list_webui_models(request: Request): + import time as _time + from app.platform.logging.logger import logger + + t0 = _time.monotonic() + pools = await _available_pools(request) models = [ { "id": spec.model_name, @@ -35,7 +44,10 @@ async def list_webui_models(): "capability": _capability_name(spec), } for spec in model_registry.list_enabled() + if _model_available_for_pools(spec, pools) ] + elapsed = _time.monotonic() - t0 + logger.info("webui list_models: pools={} models={} elapsed_ms={:.1f}", pools, len(models), elapsed * 1000) return JSONResponse({"object": "list", "data": models}) diff --git a/app/statics/admin/account.html b/app/statics/admin/account.html index 85f609a68..935587d22 100644 --- a/app/statics/admin/account.html +++ b/app/statics/admin/account.html @@ -1094,21 +1094,14 @@ const PAGE_SIZE_KEY = 'admin.account.page_size'; const PAGE_SIZE_OPTIONS = [50, 100, 200, 500, 1000, 2000]; -let allTokens = [], curStatus = 'all', curNsfw = 'all', curPool = 'all', curPage = 1, pageSize = loadSavedPageSize(); +let pageTokens = [], curStatus = 'all', curNsfw = 'all', curPool = 'all', curPage = 1, pageSize = loadSavedPageSize(); +let globalStats = null; // from /tokens/stats +let serverTotal = 0, serverTotalPages = 0; const sel = new Set(); const refreshingTokens = new Set(); let _cb = null; let _editingToken = ''; let _filterMenuBound = false; -let _tokenViewVersion = 0; -let _tokenViewCacheKey = ''; -let _tokenViewCache = null; - -function invalidateTokenView() { - _tokenViewVersion += 1; - _tokenViewCacheKey = ''; - _tokenViewCache = null; -} function tr(key, params, fallback) { const value = t(key, params); @@ -1158,7 +1151,7 @@ const key = await adminKey.get(); if (!key || !await verifyKey(ADMIN_API + '/verify', key).catch(() => false)) return location.href = '/admin/login'; - load(); + await load(); })(); // ── API ──────────────────────────────────────────────────────────────────── @@ -1180,20 +1173,44 @@ // ── Load ─────────────────────────────────────────────────────────────────── async function load() { try { - const [data, status] = await Promise.all([ - _api('GET', '/tokens'), + const [statsData, status] = await Promise.all([ + _api('GET', '/tokens/stats'), _api('GET', '/status').catch(() => null), ]); if (status && typeof status.selection_strategy === 'string') { selectionStrategy = status.selection_strategy; } - allTokens = Array.isArray(data.tokens) - ? data.tokens - : Object.entries(data.tokens || {}).flatMap(([pool, items]) => - Array.isArray(items) ? items.map(t => ({ ...t, pool: t.pool || pool })) : []); + globalStats = statsData; applyStrategyUI(); - invalidateTokenView(); - render(); + renderStats(); + renderFilters(); + await loadPage(); + } catch (e) { showToast(`${tr('account.loadFailed', null, '加载失败')}: ${e.message}`, 'error'); } +} + +async function loadPage() { + try { + let url = `/tokens?page=${curPage}&page_size=${pageSize}`; + if (curPool !== 'all') url += `&pool=${encodeURIComponent(curPool)}`; + // Status / exclude sent to backend for server-side filtering + if (curStatus === 'invalid') { + url += `&exclude_statuses=active,cooling,disabled`; + } else if (curStatus !== 'all') { + url += `&status=${encodeURIComponent(curStatus)}`; + } + const data = await _api('GET', url); + let items = Array.isArray(data.tokens) ? data.tokens : []; + serverTotal = data.total || 0; + serverTotalPages = data.total_pages || 1; + // NSFW filter: client-side on current page (tag-based, not server-filtered) + if (curNsfw !== 'all') { + items = items.filter(t => curNsfw === 'enabled' + ? (t.tags || []).includes('nsfw') + : !(t.tags || []).includes('nsfw')); + } + pageTokens = items; + sel.clear(); + renderTable(); } catch (e) { showToast(`${tr('account.loadFailed', null, '加载失败')}: ${e.message}`, 'error'); } } @@ -1209,102 +1226,12 @@ // ── Render ───────────────────────────────────────────────────────────────── function render() { - const view = getTokenView(); - renderStats(view); - renderFilters(view); - renderTable(view); + renderStats(); + renderFilters(); + renderTable(); applyStrategyUI(); } -function getTokenView() { - const cacheKey = `${_tokenViewVersion}|${curStatus}|${curNsfw}|${curPool}`; - if (_tokenViewCache && _tokenViewCacheKey === cacheKey) return _tokenViewCache; - - const stats = { - active: 0, - cooling: 0, - invalid: 0, - disabled: 0, - calls: 0, - success: 0, - fail: 0, - qa: 0, - qf: 0, - qe: 0, - qh: 0, - }; - const statusCounts = { all: 0, active: 0, cooling: 0, invalid: 0, disabled: 0 }; - const nsfwCounts = { all: 0, enabled: 0, disabled: 0 }; - const poolCounts = new Map([['all', 0]]); - const poolsSet = new Set(['basic', 'super', 'heavy']); - const filteredItems = []; - - for (const token of allTokens) { - const pool = token.pool || 'basic'; - const quota = token.quota || {}; - const nsfwEnabled = (token.tags || []).includes('nsfw'); - const invalid = isInvalidStatus(token.status); - const disabled = isDisabledStatus(token.status); - const matchesPool = curPool === 'all' || pool === curPool; - const matchesStatus = curStatus === 'all' - || (curStatus === 'invalid' ? invalid : curStatus === 'disabled' ? disabled : token.status === curStatus); - const matchesNsfw = curNsfw === 'all' || (curNsfw === 'enabled' ? nsfwEnabled : !nsfwEnabled); - - poolsSet.add(pool); - const successCount = token.use_count || 0; - const failCount = token.fail_count || 0; - stats.success += successCount; - stats.fail += failCount; - stats.calls += successCount + failCount; - stats.qa += quota.auto?.remaining || 0; - stats.qf += quota.fast?.remaining || 0; - stats.qe += quota.expert?.remaining || 0; - stats.qh += quota.heavy?.remaining || 0; - - if (token.status === 'active') stats.active += 1; - if (token.status === 'cooling') stats.cooling += 1; - if (invalid) stats.invalid += 1; - if (disabled) stats.disabled += 1; - - if (matchesPool && matchesNsfw) { - statusCounts.all += 1; - if (token.status === 'active') statusCounts.active += 1; - if (token.status === 'cooling') statusCounts.cooling += 1; - if (invalid) statusCounts.invalid += 1; - if (disabled) statusCounts.disabled += 1; - } - - if (matchesPool && matchesStatus) { - nsfwCounts.all += 1; - if (nsfwEnabled) nsfwCounts.enabled += 1; - else nsfwCounts.disabled += 1; - } - - if (matchesStatus && matchesNsfw) { - poolCounts.set('all', poolCounts.get('all') + 1); - poolCounts.set(pool, (poolCounts.get(pool) || 0) + 1); - } - - if (matchesPool && matchesStatus && matchesNsfw) filteredItems.push(token); - } - - if (curPool !== 'all' && !poolsSet.has(curPool)) { - curPool = 'all'; - return getTokenView(); - } - - _tokenViewCacheKey = `${_tokenViewVersion}|${curStatus}|${curNsfw}|${curPool}`; - _tokenViewCache = { - stats, - statusCounts, - nsfwCounts, - poolCounts, - pools: Array.from(poolsSet), - filteredItems, - }; - return _tokenViewCache; -} - function poolLabel(pool) { return tr(`account.pool.${pool}`, null, pool); } @@ -1317,23 +1244,25 @@ return !['active', 'cooling', 'disabled'].includes(status); } -function renderStats(view = getTokenView()) { - const { stats } = view; - $('s-total', allTokens.length); - $('s-active', stats.active); - $('s-cooling', stats.cooling); - $('s-invalid', stats.invalid); - $('s-disabled', stats.disabled); - $('s-calls', fmt(stats.calls)); - $('s-success', fmt(stats.success)); - $('s-rate', fmtRate(stats.success, stats.fail)); - $('s-qa', fmt(stats.qa)); $('s-qf', fmt(stats.qf)); $('s-qe', fmt(stats.qe)); $('s-qh', fmt(stats.qh)); -} - -function renderFilters(view = getTokenView()) { - renderStatusFilters(view); - renderNsfwFilters(view); - renderPoolFilters(view); +function renderStats() { + if (!globalStats) return; + const s = globalStats.stats || {}; + const qs = globalStats.quota_sums || {}; + $('s-total', s.total || 0); + $('s-active', s.active || 0); + $('s-cooling', s.cooling || 0); + $('s-invalid', s.expired || 0); + $('s-disabled', s.disabled || 0); + $('s-calls', fmt(s.calls || 0)); + $('s-success', fmt(s.success || 0)); + $('s-rate', fmtRate(s.success || 0, s.fail || 0)); + $('s-qa', fmt(qs.auto || 0)); $('s-qf', fmt(qs.fast || 0)); $('s-qe', fmt(qs.expert || 0)); $('s-qh', fmt(qs.heavy || 0)); +} + +function renderFilters() { + renderStatusFilters(); + renderNsfwFilters(); + renderPoolFilters(); syncFilterTrigger(); } @@ -1377,30 +1306,39 @@ trigger.classList.toggle('is-active', active); } -function renderStatusFilters(view = getTokenView()) { - setFilterCount('fc-status-all', view.statusCounts.all); - setFilterCount('fc-status-active', view.statusCounts.active); - setFilterCount('fc-status-cooling', view.statusCounts.cooling); - setFilterCount('fc-status-invalid', view.statusCounts.invalid); - setFilterCount('fc-status-disabled', view.statusCounts.disabled); +function renderStatusFilters() { + if (!globalStats) return; + const s = globalStats.stats || {}; + const invalidCount = s.expired || 0; + setFilterCount('fc-status-all', s.total || 0); + setFilterCount('fc-status-active', s.active || 0); + setFilterCount('fc-status-cooling', s.cooling || 0); + setFilterCount('fc-status-invalid', invalidCount); + setFilterCount('fc-status-disabled', s.disabled || 0); document.querySelectorAll('[data-status]').forEach(el => el.classList.toggle('active', el.dataset.status === curStatus)); } -function renderNsfwFilters(view = getTokenView()) { - setFilterCount('fc-nsfw-all', view.nsfwCounts.all); - setFilterCount('fc-nsfw-enabled', view.nsfwCounts.enabled); - setFilterCount('fc-nsfw-disabled', view.nsfwCounts.disabled); +function renderNsfwFilters() { + if (!globalStats) return; + const nc = globalStats.nsfw_counts || {}; + const total = (nc.enabled || 0) + (nc.disabled || 0); + setFilterCount('fc-nsfw-all', total); + setFilterCount('fc-nsfw-enabled', nc.enabled || 0); + setFilterCount('fc-nsfw-disabled', nc.disabled || 0); document.querySelectorAll('[data-nsfw]').forEach(el => el.classList.toggle('active', el.dataset.nsfw === curNsfw)); } -function renderPoolFilters(view = getTokenView()) { +function renderPoolFilters() { const wrap = document.getElementById('pool-filter-chips'); if (!wrap) return; - const pools = view.pools; + const pc = (globalStats && globalStats.pool_counts) || {}; + const pools = ['basic', 'super', 'heavy', ...Object.keys(pc).filter(p => !['basic','super','heavy'].includes(p))]; + const uniquePools = [...new Set(pools)]; + const allCount = Object.values(pc).reduce((a, b) => a + b, 0); wrap.innerHTML = [ - ``, - ...pools.map(pool => { - const count = view.poolCounts.get(pool) || 0; + ``, + ...uniquePools.map(pool => { + const count = pc[pool] || 0; return ``; }), ].join(''); @@ -1411,43 +1349,31 @@ if (el) el.textContent = value; } -function applyPoolFilter(items, pool = curPool) { - if (pool === 'all') return items; - return items.filter(t => (t.pool || 'basic') === pool); -} - -function applyStatusFilter(items, status = curStatus) { - if (status === 'all') return items; - if (status === 'invalid') return items.filter(t => isInvalidStatus(t.status)); - if (status === 'disabled') return items.filter(t => isDisabledStatus(t.status)); - return items.filter(t => t.status === status); -} - -function applyNsfwFilter(items, mode = curNsfw) { - if (mode === 'all') return items; - if (mode === 'enabled') return items.filter(t => (t.tags || []).includes('nsfw')); - return items.filter(t => !(t.tags || []).includes('nsfw')); -} - function filtered() { - return getTokenView().filteredItems; + // For export: return current page tokens (server already filtered by pool/status). + // NSFW filter applied client-side. + if (curNsfw === 'enabled') return pageTokens.filter(t => (t.tags || []).includes('nsfw')); + if (curNsfw === 'disabled') return pageTokens.filter(t => !(t.tags || []).includes('nsfw')); + return pageTokens; } function preserveScroll(fn) { const y = window.scrollY; - fn(); - requestAnimationFrame(() => window.scrollTo({ top: y, behavior: 'auto' })); + const result = fn(); + if (result && typeof result.then === 'function') { + result.then(() => requestAnimationFrame(() => window.scrollTo({ top: y, behavior: 'auto' }))); + } else { + requestAnimationFrame(() => window.scrollTo({ top: y, behavior: 'auto' })); + } } -function renderTable(view = getTokenView()) { - const items = view.filteredItems; - const total = items.length; - const pages = Math.max(1, Math.ceil(total / pageSize)); - if (curPage > pages) curPage = pages; - const slice = items.slice((curPage - 1) * pageSize, curPage * pageSize); +function renderTable() { + const items = pageTokens; + const total = serverTotal; + const pages = serverTotalPages; - document.getElementById('tbody').innerHTML = slice.length - ? slice.map(rowHtml).join('') + document.getElementById('tbody').innerHTML = items.length + ? items.map(rowHtml).join('') : `${tr('account.empty', null, '暂无 Token,请点击右上角导入或添加。')}`; $('pagi-page', tr('account.pageIndicator', { current: total ? curPage : 0, total: pages }, `第 ${total ? curPage : 0} / ${pages} 页`)); @@ -1534,55 +1460,50 @@ // ── Nav & Pagination ──────────────────────────────────────────────────────── function switchStatus(status) { - preserveScroll(() => { + preserveScroll(async () => { curStatus = status || 'all'; curPage = 1; - sel.clear(); renderFilters(); - renderTable(); + await loadPage(); }); } function switchNsfw(mode) { - preserveScroll(() => { + preserveScroll(async () => { curNsfw = mode || 'all'; curPage = 1; - sel.clear(); renderFilters(); - renderTable(); + await loadPage(); }); } function switchPool(pool) { - preserveScroll(() => { + preserveScroll(async () => { curPool = pool || 'all'; curPage = 1; - sel.clear(); renderFilters(); - renderTable(); + await loadPage(); }); } -function prevPage() { if (curPage > 1) { curPage--; renderTable(); } } -function nextPage() { - const pages = Math.max(1, Math.ceil(getTokenView().filteredItems.length / pageSize)); - if (curPage < pages) { curPage++; renderTable(); } +async function prevPage() { if (curPage > 1) { curPage--; await loadPage(); } } +async function nextPage() { + if (curPage < serverTotalPages) { curPage++; await loadPage(); } } -function changePageSize(v) { +async function changePageSize(v) { const next = Number(v); pageSize = PAGE_SIZE_OPTIONS.includes(next) ? next : 50; localStorage.setItem(PAGE_SIZE_KEY, String(pageSize)); curPage = 1; - renderTable(); + await loadPage(); } function toggleAll(checked) { - getTokenView().filteredItems.slice((curPage-1)*pageSize, curPage*pageSize).forEach(t => checked ? sel.add(t.token) : sel.delete(t.token)); + pageTokens.forEach(t => checked ? sel.add(t.token) : sel.delete(t.token)); document.querySelectorAll('.row-cb').forEach(el => el.checked = checked); updateBatchBtns(); } function toggleRow(el) { el.checked ? sel.add(el.dataset.token) : sel.delete(el.dataset.token); - const page = getTokenView().filteredItems.slice((curPage-1)*pageSize, curPage*pageSize); const cbAll = document.getElementById('cb-all'); - cbAll.checked = page.every(t => sel.has(t.token)); + cbAll.checked = pageTokens.every(t => sel.has(t.token)); cbAll.indeterminate = !cbAll.checked && sel.size > 0; updateBatchBtns(); } @@ -1633,18 +1554,20 @@ // For import modals: includes "auto(自动检测)" as the first / default option. function fillImportPoolOptions(id) { - const known = [...new Set(['basic', 'super', 'heavy', ...allTokens.map(t => t.pool || 'basic')])]; + const known = ['basic', 'super', 'heavy', ...Object.keys((globalStats && globalStats.pool_counts) || {}).filter(p => !['basic','super','heavy'].includes(p))]; + const uniquePools = [...new Set(known)]; const opts = [ ``, - ...known.map(p => ``), + ...uniquePools.map(p => ``), ]; document.getElementById(id).innerHTML = opts.join(''); } // For edit modal: shows real pool names only (no "auto"). function fillPoolOptions(id) { - const pools = [...new Set(['basic', 'super', 'heavy', ...allTokens.map(t => t.pool || 'basic')])]; - document.getElementById(id).innerHTML = pools.map(p => ``).join(''); + const known = ['basic', 'super', 'heavy', ...Object.keys((globalStats && globalStats.pool_counts) || {}).filter(p => !['basic','super','heavy'].includes(p))]; + const uniquePools = [...new Set(known)]; + document.getElementById(id).innerHTML = uniquePools.map(p => ``).join(''); } function openAdd() { @@ -1661,7 +1584,7 @@ } function openEdit(token) { - const item = allTokens.find(t => t.token === token); + const item = pageTokens.find(t => t.token === token); if (!item) return showToast(tr('account.notFound', null, '账户不存在'), 'error'); _editingToken = item.token; document.getElementById('edit-token').value = item.token; @@ -1704,7 +1627,7 @@ await _api('POST', '/tokens', parsed); closeModal('modal-import-file'); showToast(tr('account.importJsonDone', { n: total }, `导入完成,共 ${total} 个`), 'success'); - load(); + await load(); } catch (e) { showToast(`${tr('account.importFailed', null, '导入失败')}: ${e.message}`, 'error'); } } else { // TXT 格式: 每行一个 token @@ -1733,7 +1656,7 @@ closeModal('modal-edit'); _editingToken = ''; showToast(tr('account.editDone', null, '编辑完成'), 'success'); - load(); + await load(); } catch (e) { showToast(`${tr('account.editFailed', null, '编辑失败')}: ${e.message}`, 'error'); } } @@ -1762,7 +1685,7 @@ ? tr('account.saveResultSkipped', { prefix: successPrefix, count: d.count, skipped: d.skipped }, `${successPrefix},新增 ${d.count},已存在跳过 ${d.skipped}`) : tr('account.saveResult', { prefix: successPrefix, count: d.count }, `${successPrefix},新增 ${d.count} 个`); showToast(msg, 'success'); - load(); + await load(); } catch (e) { showToast(`${errorPrefix}: ${e.message}`, 'error'); } } @@ -1807,7 +1730,7 @@ await _api('DELETE', '/tokens', tokens); tokens.forEach(t => sel.delete(t)); showToast(tr('account.deleteDone', { n }, `已删除 ${n} 个`), 'success'); - load(); + await load(); } catch (e) { showToast(`${tr('account.deleteFailed', null, '删除失败')}: ${e.message}`, 'error'); } }); } @@ -1845,7 +1768,7 @@ const es = new EventSource(`${ADMIN_API}/batch/${_batchTaskId}/stream?app_key=${encodeURIComponent(key)}`); _batchEs = es; - es.onmessage = (e) => { + es.onmessage = async (e) => { const ev = JSON.parse(e.data); if (ev.type === 'snapshot' || ev.type === 'progress') { progress.update(ev.processed || 0, total); @@ -1857,7 +1780,7 @@ const ok = s.ok ?? 0, fail = s.fail ?? 0; progress.finish(`${label.replace('…','').replace('正在','')} 完成:成功 ${ok},失败 ${fail}`, fail > 0 ? 'error' : 'success'); onDone(ok, fail); - load(); + await load(); } else if (ev.type === 'cancelled') { progress.finish('已取消', 'error'); } else { @@ -1996,7 +1919,7 @@ ? tr('account.nsfwDone', { ok: d.summary?.ok ?? 0, fail: d.summary?.fail ?? 0 }, `NSFW 已开启:成功 ${d.summary?.ok??0},失败 ${d.summary?.fail??0}`) : tr('account.nsfwDisableDone', { ok: d.summary?.ok ?? 0, fail: d.summary?.fail ?? 0 }, `NSFW 已关闭:成功 ${d.summary?.ok??0},失败 ${d.summary?.fail??0}`), 'success'); - load(); + await load(); } catch (e) { showToast(`${tr('account.operationFailed', null, '操作失败')}: ${e.message}`, 'error'); } }