diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index f561f565..1114119a 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -485,6 +485,10 @@ "search_empty_desc": "Try a different keyword or clear the search box" }, "auth_files": { + "sort_label": "Sort accounts", + "sort_by_name": "By name", + "sort_quota_asc": "Least quota left first", + "sort_quota_desc": "Most quota left first", "title": "AI Accounts", "title_section": "AI Accounts", "description": "Manage OAuth logins and auth credentials for AI platforms, including usage, quotas, and identity fingerprints.", diff --git a/packages/i18n/src/locales/ru.json b/packages/i18n/src/locales/ru.json index 6c6a5802..737ce7b3 100644 --- a/packages/i18n/src/locales/ru.json +++ b/packages/i18n/src/locales/ru.json @@ -879,7 +879,11 @@ "fork": "Сохранить оригинал", "fork_label": "Сохранить оригинал", "fork_hint": "Если включено, доступны и исходное имя модели, и псевдоним. Если выключено, наружу виден только псевдоним.", - "cycle_tokens_count": "Токены за цикл {{value}}" + "cycle_tokens_count": "Токены за цикл {{value}}", + "sort_label": "Сортировка аккаунтов", + "sort_by_name": "По имени", + "sort_quota_asc": "Сначала с наименьшим остатком", + "sort_quota_desc": "Сначала с наибольшим остатком" }, "antigravity_quota": { "title": "Квота Antigravity", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 54b4258e..dd7163e3 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -1016,7 +1016,11 @@ "no_tags": "暂无标签", "hide_tag": "隐藏 {{tag}}", "restore_tag": "恢复 {{tag}}", - "remove_custom_tag": "移除 {{tag}}" + "remove_custom_tag": "移除 {{tag}}", + "sort_label": "账号排序", + "sort_by_name": "按名称", + "sort_quota_asc": "剩余额度少的在前", + "sort_quota_desc": "剩余额度多的在前" }, "antigravity_quota": { "title": "Antigravity 额度", diff --git a/pages/auth-files/__tests__/authFilesQuotaSort.test.ts b/pages/auth-files/__tests__/authFilesQuotaSort.test.ts new file mode 100644 index 00000000..df754cc4 --- /dev/null +++ b/pages/auth-files/__tests__/authFilesQuotaSort.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "vitest"; +import type { AuthFileItem } from "@code-proxy/api-client"; +import { + isAuthFilesSortMode, + resolveAuthFileQuotaRank, +} from "../hooks/useAuthFilesQuotaSort"; +import type { QuotaItem } from "@features/quota-preview/quota-types"; + +const file = (provider: string): AuthFileItem => + ({ name: `${provider}.json`, provider, type: provider }) as AuthFileItem; + +describe("resolveAuthFileQuotaRank", () => { + // The card shows one row per Codex window; the tightest of them decides the + // account's position, because that is the one that will refuse a request next. + test("ranks a codex account by its tightest visible window", () => { + const items: QuotaItem[] = [ + { key: "code_5h", label: "m_quota.code_5h", percent: 12 }, + { key: "code_week", label: "m_quota.code_weekly", percent: 80 }, + ]; + expect(resolveAuthFileQuotaRank(file("codex"), items)).toBe(12); + }); + + // Antigravity reports a weekly bucket too, but the card renders only the 5h + // one. Ranking by a number the operator cannot see reads as a broken sort. + test("ignores windows the antigravity card does not show", () => { + const items: QuotaItem[] = [ + { + key: "antigravity:gemini_5h", + label: "Gemini Models · 5h", + percent: 72, + windowSeconds: 5 * 60 * 60, + }, + { + key: "antigravity:gemini_weekly", + label: "Gemini Models · weekly", + percent: 3, + windowSeconds: 7 * 24 * 60 * 60, + }, + ]; + expect(resolveAuthFileQuotaRank(file("antigravity"), items)).toBe(72); + }); + + test("is unknown when no visible window carries a number", () => { + expect(resolveAuthFileQuotaRank(file("codex"), [])).toBeNull(); + expect( + resolveAuthFileQuotaRank(file("codex"), [ + { key: "code_5h", label: "m_quota.code_5h", percent: null }, + ]), + ).toBeNull(); + }); + + test("is unknown for a file with no quota provider", () => { + expect( + resolveAuthFileQuotaRank(file("unknown-provider"), [ + { key: "whatever", label: "whatever", percent: 40 }, + ]), + ).toBeNull(); + }); +}); + +describe("isAuthFilesSortMode", () => { + test("accepts known modes only", () => { + expect(isAuthFilesSortMode("name")).toBe(true); + expect(isAuthFilesSortMode("quota_asc")).toBe(true); + expect(isAuthFilesSortMode("quota_desc")).toBe(true); + expect(isAuthFilesSortMode("quota")).toBe(false); + expect(isAuthFilesSortMode(undefined)).toBe(false); + }); +}); + +describe("useAuthFilesSortMode", () => { + test("defaults to name order and shares the choice across instances", async () => { + const { renderHook, act } = await import("@testing-library/react"); + const { useAuthFilesSortMode, resetAuthFilesSortModeForTests } = await import( + "../hooks/useAuthFilesQuotaSort" + ); + localStorage.clear(); + resetAuthFilesSortModeForTests(); + + const first = renderHook(() => useAuthFilesSortMode()); + const second = renderHook(() => useAuthFilesSortMode()); + expect(first.result.current.mode).toBe("name"); + + // The list and the toolbar control read this independently; if they held + // separate state the control would move while the list stayed put. + act(() => first.result.current.setMode("quota_asc")); + expect(second.result.current.mode).toBe("quota_asc"); + + resetAuthFilesSortModeForTests(); + const reopened = renderHook(() => useAuthFilesSortMode()); + expect(reopened.result.current.mode).toBe("quota_asc"); + }); + + test("ignores an unrecognised stored value", async () => { + const { renderHook } = await import("@testing-library/react"); + const { useAuthFilesSortMode, resetAuthFilesSortModeForTests } = await import( + "../hooks/useAuthFilesQuotaSort" + ); + localStorage.setItem("auth-files:sort-mode", "by_feel"); + resetAuthFilesSortModeForTests(); + const { result } = renderHook(() => useAuthFilesSortMode()); + expect(result.current.mode).toBe("name"); + }); +}); diff --git a/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx b/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx new file mode 100644 index 00000000..566fc788 --- /dev/null +++ b/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from "react-i18next"; +import { Select } from "@code-proxy/ui"; +import { + AUTH_FILES_SORT_MODES, + isAuthFilesSortMode, + useAuthFilesSortLoading, + useAuthFilesSortMode, + type AuthFilesSortMode, +} from "../hooks/useAuthFilesQuotaSort"; + +const MODE_LABEL_KEYS: Record = { + name: "auth_files.sort_by_name", + quota_asc: "auth_files.sort_quota_asc", + quota_desc: "auth_files.sort_quota_desc", +}; + +/** + * Sort control for the AI accounts list. + * + * Reads the shared preference rather than taking it as a prop: both + * AuthFilesPage and AuthFilesFilesTab are frozen at their size baselines, and + * the list reads the same value independently to order accounts ahead of + * pagination. + * + * A Select rather than a dropdown menu, matching the column-count control it + * sits beside — same affordance for the same kind of choice, and no second + * popover implementation on this toolbar. + */ +export function AuthFilesQuotaSortMenu() { + const { t } = useTranslation(); + const { mode, setMode } = useAuthFilesSortMode(); + const loading = useAuthFilesSortLoading(); + + return ( +
+