feat(quota): add a soonest-recovery-first sort to the quota page

Card order was fixed: provider grouping, then whatever order the backend
listed files in. With more than a handful of credentials, "which one is
usable next" meant reading every card.

The new sort ranks credentials by their nearest upcoming reset, across
windows and reset credits alike, and it runs before pagination so the
answer is global rather than per-page.

Quota is click-to-fetch, so most of the list has no instant to sort by.
Those credentials sink to the bottom keeping their provider-grouped
order — the unloaded tail still reads like the default view — and slot
into place as their data arrives. The comparator takes the instant as an
injected resolver, which keeps it store-free and directly testable.

writeQuotaUiState now merges. It previously wrote the whole object, so
adding a second preference would have made changing a tab silently reset
the sort.

The Select sits inside the existing tabs reveal node rather than beside
it: useRevealGroup staggers every [data-reveal] descendant, and a
sibling would have added a cascade step. Reordering relies on stable
card keys, so React moves nodes instead of remounting them — no FLIP and
no replayed entrance, because a re-sort triggered by a minute tick
should not move things with fanfare.
This commit is contained in:
Supra4E8C
2026-08-02 01:52:49 +08:00
parent c277ad353c
commit 001e1308cf
11 changed files with 326 additions and 23 deletions
+30
View File
@@ -21,6 +21,36 @@
min-width: 0;
}
/* tabs 与排序同一行:tabs 会自己横向滚动,所以排序固定不压缩。 */
.tabsRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
> :first-child {
min-width: 0;
}
}
.sort {
flex: 0 0 auto;
min-width: 148px;
}
@include mobile {
.tabsRow {
flex-direction: column;
align-items: stretch;
}
.sort {
width: 100%;
}
}
.errorBanner {
font-size: 12.5px;
line-height: 1.5;
+67 -19
View File
@@ -13,8 +13,10 @@ import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { Select } from '@/components/ui/Select';
import { Skeleton } from '@/components/ui/Skeleton';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useNow } from '@/hooks/useNow';
import { useRevealGroup } from '@/hooks/motion';
import { useAuthStore, useQuotaStore, useThemeStore } from '@/stores';
import type { AuthFileItem, ResolvedTheme } from '@/types';
@@ -25,7 +27,9 @@ import { QuotaTimeline } from './components/QuotaTimeline';
import {
CARD_ENTRANCE_BUDGET_MS,
QUOTA_PAGE_SIZE,
QUOTA_SORT_MODES,
QUOTA_TAB_ORDER,
type QuotaSortMode,
type QuotaTabId,
} from './constants';
import {
@@ -33,8 +37,10 @@ import {
classifyQuotaFiles,
filterEntriesByTab,
paginate,
sortQuotaEntries,
type QuotaFileEntry,
} from './logic';
import { nextRecoveryMs } from './resetSchedule';
import { QUOTA_ADAPTERS, getQuotaSetter, type QuotaCardState } from './providers';
import type { QuotaProviderType } from './providers/types';
import { useQuotaActions } from './hooks/useQuotaActions';
@@ -60,6 +66,9 @@ export function QuotaPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [tab, setTab] = useState<QuotaTabId>(() => readQuotaUiState()?.tab ?? 'all');
const [sortMode, setSortMode] = useState<QuotaSortMode>(
() => readQuotaUiState()?.sortMode ?? 'default'
);
const [page, setPage] = useState(1);
// 页头 + tabs 的入场级联(标题 → meta → 动作 → tabs,级差 70ms
const revealRef = useRevealGroup<HTMLDivElement>();
@@ -88,23 +97,8 @@ export function QuotaPage() {
void loadFiles();
}, [loadFiles]);
/* ---------- 归类 / 过滤 / 分页 ---------- */
const entries = useMemo(() => classifyQuotaFiles(files), [files]);
const tabCounts = useMemo(() => buildTabCounts(entries), [entries]);
const filteredEntries = useMemo(() => filterEntriesByTab(entries, tab), [entries, tab]);
const { pageItems, currentPage, totalPages } = useMemo(
() => paginate(filteredEntries, page, QUOTA_PAGE_SIZE),
[filteredEntries, page]
);
const handleTabChange = useCallback((next: string) => {
setTab(next as QuotaTabId);
setPage(1);
writeQuotaUiState({ tab: next as QuotaTabId });
}, []);
/* ---------- 额度缓存 ---------- */
/* ---------- 额度缓存 ----------
* 排在归类/排序之前:「最快恢复优先」要读它算排序键。 */
const antigravityQuota = useQuotaStore((state) => state.antigravityQuota);
const claudeQuota = useQuotaStore((state) => state.claudeQuota);
@@ -129,6 +123,50 @@ export function QuotaPage() {
[quotaByType]
);
/* ---------- 归类 / 过滤 / 排序 / 分页 ---------- */
// 只在「最快恢复优先」下订阅分钟时钟。默认序下不门控的话,pageItems 每分钟
// 换一次身份,会反复空转下面那个「刷新全部」的 loading 下降沿 effect。
const tick = useNow(sortMode !== 'default');
const sortNow = sortMode === 'default' ? 0 : tick;
const entries = useMemo(() => classifyQuotaFiles(files), [files]);
const tabCounts = useMemo(() => buildTabCounts(entries), [entries]);
const filteredEntries = useMemo(() => filterEntriesByTab(entries, tab), [entries, tab]);
const resolveNextRecovery = useCallback(
(entry: QuotaFileEntry) => nextRecoveryMs(entry.type, getQuota(entry), sortNow),
[getQuota, sortNow]
);
// 排序在分页之前:否则「最快恢复」只在当前页内成立。
const sortedEntries = useMemo(
() => sortQuotaEntries(filteredEntries, sortMode, resolveNextRecovery),
[filteredEntries, sortMode, resolveNextRecovery]
);
const { pageItems, currentPage, totalPages } = useMemo(
() => paginate(sortedEntries, page, QUOTA_PAGE_SIZE),
[sortedEntries, page]
);
const handleTabChange = useCallback((next: string) => {
setTab(next as QuotaTabId);
setPage(1);
writeQuotaUiState({ tab: next as QuotaTabId });
}, []);
const handleSortModeChange = useCallback((next: string) => {
setSortMode(next as QuotaSortMode);
setPage(1);
writeQuotaUiState({ sortMode: next as QuotaSortMode });
}, []);
const sortOptions = useMemo(
() =>
QUOTA_SORT_MODES.map((mode) => ({ value: mode, label: t(`quota_management.sort_${mode}`) })),
[t]
);
const { loadedCount, attentionCount } = useMemo(() => {
let loaded = 0;
let attention = 0;
@@ -223,8 +261,9 @@ export function QuotaPage() {
/>
<section className={styles.workbench}>
{/* tabs 作为一个整体入场(不做逐 tab 级差 —— 克制优先) */}
<div data-reveal>
{/* tabs + 排序作为一个整体入场(useRevealGroup 会给每个 [data-reveal]
后代加一级级差,所以排序控件放在同一个节点里而不是做兄弟) */}
<div className={styles.tabsRow} data-reveal>
<ProviderTabs
types={TAB_IDS}
counts={tabCounts}
@@ -232,6 +271,15 @@ export function QuotaPage() {
resolvedTheme={resolvedTheme}
onChange={handleTabChange}
/>
<div className={styles.sort}>
<Select
value={sortMode}
options={sortOptions}
onChange={handleSortModeChange}
ariaLabel={t('quota_management.sort_label')}
size="sm"
/>
</div>
</div>
{error && (
+5
View File
@@ -14,5 +14,10 @@ export type QuotaTabId = 'all' | QuotaProviderType;
/** 页级分页固定 20/页,同时把「刷新全部」的上游并发限制在 20。 */
export const QUOTA_PAGE_SIZE = 20;
/** 卡片排序:默认 = provider 分组序;soonest = 最快恢复优先。 */
export const QUOTA_SORT_MODES = ['default', 'soonest'] as const;
export type QuotaSortMode = (typeof QUOTA_SORT_MODES)[number];
/** 与 useRevealGroup 的 GROUP_MAX_TOTAL 一致:卡片级联总预算 360ms。 */
export const CARD_ENTRANCE_BUDGET_MS = 360;
+36 -1
View File
@@ -10,7 +10,7 @@ import { CODEX_CONFIG } from './providers/codex/data';
import { KIMI_CONFIG } from './providers/kimi/data';
import { XAI_CONFIG } from './providers/xai/data';
import type { QuotaProviderType } from './providers/types';
import { QUOTA_TAB_ORDER, type QuotaTabId } from './constants';
import { QUOTA_TAB_ORDER, type QuotaSortMode, type QuotaTabId } from './constants';
const QUOTA_FILTER_MAP: Record<QuotaProviderType, (file: AuthFileItem) => boolean> = {
antigravity: ANTIGRAVITY_CONFIG.filterFn,
@@ -49,6 +49,41 @@ export function filterEntriesByTab(entries: QuotaFileEntry[], tab: QuotaTabId):
return entries.filter((entry) => entry.type === tab);
}
/**
* Order the grid by whichever credential recovers first.
*
* The instant is injected rather than read here: quota lives in the store and
* arrives asynchronously, and keeping this function store-free is what makes
* the ordering rules directly testable.
*
* Credentials with no instant — not loaded yet, failed, or reporting no
* upcoming reset — sink to the bottom rather than sorting as "now". They keep
* their incoming provider-grouped order, so the unloaded tail still reads like
* the default view instead of an arbitrary shuffle. Because loading is
* click-to-fetch, that tail is most of the list until the user asks for data.
*
* The original index is the final tiebreak, making stability an asserted
* property rather than an assumption about the engine's sort.
*/
export function sortQuotaEntries(
entries: QuotaFileEntry[],
mode: QuotaSortMode,
resolveNextRecoveryMs: (entry: QuotaFileEntry) => number | null
): QuotaFileEntry[] {
if (mode !== 'soonest') return [...entries];
// Decorate once — resolving pokes at provider-shaped state per entry.
return entries
.map((entry, index) => ({ entry, index, atMs: resolveNextRecoveryMs(entry) }))
.sort((a, b) => {
if (a.atMs === null && b.atMs === null) return a.index - b.index;
if (a.atMs === null) return 1;
if (b.atMs === null) return -1;
return a.atMs - b.atMs || a.index - b.index;
})
.map((decorated) => decorated.entry);
}
export function buildTabCounts(entries: QuotaFileEntry[]): Record<string, number> {
const counts: Record<string, number> = { all: entries.length };
for (const type of QUOTA_TAB_ORDER) {
+24 -3
View File
@@ -1,17 +1,27 @@
import { QUOTA_TAB_ORDER, type QuotaTabId } from './constants';
import {
QUOTA_SORT_MODES,
QUOTA_TAB_ORDER,
type QuotaSortMode,
type QuotaTabId,
} from './constants';
/** 额度页 UI 偏好:会话级持久化(sessionStorage),跨会话不携带。 */
export type QuotaUiState = {
tab?: QuotaTabId;
sortMode?: QuotaSortMode;
};
const QUOTA_UI_STATE_KEY = 'quotaPage.uiState';
const QUOTA_TAB_ID_SET = new Set<string>(['all', ...QUOTA_TAB_ORDER]);
const QUOTA_SORT_MODE_SET = new Set<string>(QUOTA_SORT_MODES);
export const isQuotaTabId = (value: unknown): value is QuotaTabId =>
typeof value === 'string' && QUOTA_TAB_ID_SET.has(value);
export const isQuotaSortMode = (value: unknown): value is QuotaSortMode =>
typeof value === 'string' && QUOTA_SORT_MODE_SET.has(value);
export const readQuotaUiState = (): QuotaUiState | null => {
if (typeof window === 'undefined') return null;
try {
@@ -19,16 +29,27 @@ export const readQuotaUiState = (): QuotaUiState | null => {
if (!raw) return null;
const parsed = JSON.parse(raw) as QuotaUiState;
if (!parsed || typeof parsed !== 'object') return null;
return { tab: isQuotaTabId(parsed.tab) ? parsed.tab : undefined };
return {
tab: isQuotaTabId(parsed.tab) ? parsed.tab : undefined,
sortMode: isQuotaSortMode(parsed.sortMode) ? parsed.sortMode : undefined,
};
} catch {
return null;
}
};
/**
* Merge into whatever is already stored.
*
* Callers write one preference at a time — the tab strip knows nothing about
* the sort control — so a whole-object write would silently drop the other
* field every time either one changed.
*/
export const writeQuotaUiState = (state: QuotaUiState) => {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(QUOTA_UI_STATE_KEY, JSON.stringify(state));
const next = { ...readQuotaUiState(), ...state };
window.sessionStorage.setItem(QUOTA_UI_STATE_KEY, JSON.stringify(next));
} catch {
// ignore
}
+3
View File
@@ -1152,6 +1152,9 @@
"title": "Quota Management",
"description": "Monitor OAuth quota status for Antigravity, Codex, Claude, Kimi, and xAI credentials.",
"refresh_all_credentials": "Refresh all credentials",
"sort_label": "Sort",
"sort_default": "Default",
"sort_soonest": "Soonest recovery",
"meta_credentials": "{{count}} credentials",
"meta_loaded": "{{count}} loaded",
"meta_attention": "{{count}} need attention",
+3
View File
@@ -1139,6 +1139,9 @@
"title": "Управление квотами",
"description": "Следите за статусом квот OAuth для учётных данных Antigravity, Codex, Claude, Kimi и xAI.",
"refresh_all_credentials": "Обновить все учётные данные",
"sort_label": "Сортировка",
"sort_default": "По умолчанию",
"sort_soonest": "Сначала ближайшее восстановление",
"meta_credentials": "{{count}} учётных данных",
"meta_loaded": "{{count}} загружено",
"meta_attention": "{{count}} требуют внимания",
+3
View File
@@ -1152,6 +1152,9 @@
"title": "配额管理",
"description": "集中查看 OAuth 额度与剩余情况",
"refresh_all_credentials": "刷新全部凭证",
"sort_label": "排序",
"sort_default": "默认",
"sort_soonest": "最快恢复优先",
"meta_credentials": "{{count}} 个凭证",
"meta_loaded": "{{count}} 个已加载",
"meta_attention": "{{count}} 个需关注",
+3
View File
@@ -1178,6 +1178,9 @@
"title": "配額管理",
"description": "集中查看 OAuth 配額與剩餘情況",
"refresh_all_credentials": "重新整理全部憑證",
"sort_label": "排序",
"sort_default": "預設",
"sort_soonest": "最快恢復優先",
"meta_credentials": "{{count}} 個憑證",
"meta_loaded": "{{count}} 個已載入",
"meta_attention": "{{count}} 個需關注",
+77
View File
@@ -7,6 +7,8 @@ import {
isQuotaRefreshDisabled,
paginate,
resolveQuotaProviderType,
sortQuotaEntries,
type QuotaFileEntry,
} from '@/features/quota/logic';
import type { AuthFileItem } from '@/types';
@@ -108,3 +110,78 @@ describe('paginate', () => {
});
});
});
describe('sortQuotaEntries', () => {
const entries = classifyQuotaFiles(FILES);
const byName = (list: QuotaFileEntry[]) => list.map((entry) => entry.file.name);
/** Recovery instants keyed by file name; anything absent resolves to null. */
const resolver = (instants: Record<string, number>) => (entry: QuotaFileEntry) =>
instants[entry.file.name] ?? null;
test('default mode preserves order but returns a new array', () => {
const sorted = sortQuotaEntries(entries, 'default', () => 1);
expect(byName(sorted)).toEqual(byName(entries));
expect(sorted).not.toBe(entries);
});
test('orders loaded credentials by how soon they recover, across providers', () => {
const sorted = sortQuotaEntries(
entries,
'soonest',
resolver({
'codex-a.json': 300,
'claude-a.json': 100,
'kimi-a.json': 200,
'codex-b.json': 400,
'grok-a.json': 50,
})
);
expect(byName(sorted)).toEqual([
'grok-a.json',
'claude-a.json',
'kimi-a.json',
'codex-a.json',
'codex-b.json',
]);
});
test('sinks credentials with no instant, keeping their provider-grouped order', () => {
// Loading is click-to-fetch, so an unloaded tail is the normal case.
const sorted = sortQuotaEntries(
entries,
'soonest',
resolver({ 'codex-b.json': 200, 'kimi-a.json': 100 })
);
expect(byName(sorted)).toEqual([
'kimi-a.json',
'codex-b.json',
// unresolved tail, in the order classifyQuotaFiles produced
'claude-a.json',
'codex-a.json',
'grok-a.json',
]);
});
test('leaves the order untouched when nothing has loaded', () => {
expect(byName(sortQuotaEntries(entries, 'soonest', () => null))).toEqual(byName(entries));
});
test('breaks ties on the original position, so equal instants stay stable', () => {
const sorted = sortQuotaEntries(entries, 'soonest', () => 500);
expect(byName(sorted)).toEqual(byName(entries));
});
test('does not mutate the input', () => {
const input = [...entries];
sortQuotaEntries(input, 'soonest', resolver({ 'codex-b.json': 1 }));
expect(input).toEqual(entries);
});
test('sorts before paginating, so the globally soonest lands on page one', () => {
// Last in the default order, first to recover.
const last = entries[entries.length - 1].file.name;
const sorted = sortQuotaEntries(entries, 'soonest', resolver({ [last]: 1 }));
expect(paginate(sorted, 1, 2).pageItems[0].file.name).toBe(last);
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* Session-scoped quota page preferences.
*
* The merge-on-write case is the one that matters: the tab strip and the sort
* control each write a single field and know nothing about the other, so a
* whole-object write would make changing a tab silently reset the sort.
*/
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import { readQuotaUiState, writeQuotaUiState } from '@/features/quota/uiState';
const KEY = 'quotaPage.uiState';
/** Test files share one process — leaving a fake `window` behind would leak. */
const originalWindow = (globalThis as { window?: unknown }).window;
/** bun's global has no sessionStorage; a Map-backed stub is enough here. */
function installSessionStorage() {
const store = new Map<string, string>();
const storage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
clear: () => store.clear(),
key: (index: number) => [...store.keys()][index] ?? null,
get length() {
return store.size;
},
};
(globalThis as unknown as { window: unknown }).window = { sessionStorage: storage };
return storage;
}
let storage: ReturnType<typeof installSessionStorage>;
beforeEach(() => {
storage = installSessionStorage();
});
afterAll(() => {
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = originalWindow;
}
});
describe('quota ui state', () => {
test('round-trips both preferences', () => {
writeQuotaUiState({ tab: 'codex', sortMode: 'soonest' });
expect(readQuotaUiState()).toEqual({ tab: 'codex', sortMode: 'soonest' });
});
test('writing one preference preserves the other', () => {
writeQuotaUiState({ sortMode: 'soonest' });
writeQuotaUiState({ tab: 'kimi' });
expect(readQuotaUiState()).toEqual({ tab: 'kimi', sortMode: 'soonest' });
});
test('rejects values that are not part of the current contract', () => {
storage.setItem(KEY, JSON.stringify({ tab: 'not-a-tab', sortMode: 'by-vibes' }));
expect(readQuotaUiState()).toEqual({ tab: undefined, sortMode: undefined });
});
test('survives absent, malformed, and non-object payloads', () => {
expect(readQuotaUiState()).toBeNull();
storage.setItem(KEY, '{not json');
expect(readQuotaUiState()).toBeNull();
storage.setItem(KEY, '"a string"');
expect(readQuotaUiState()).toBeNull();
});
});