diff --git a/apps/src/app/accounts/accounts-page-helpers.tsx b/apps/src/app/accounts/accounts-page-helpers.tsx index 6663c0571..31562ec75 100644 --- a/apps/src/app/accounts/accounts-page-helpers.tsx +++ b/apps/src/app/accounts/accounts-page-helpers.tsx @@ -24,6 +24,12 @@ import type { Account } from "@/types"; export type StatusFilter = "all" | "available" | "low_quota" | "limited" | "banned"; export type AccountExportMode = "single" | "multiple"; export type AccountSizeSortMode = "large-first" | "small-first"; +export type AccountMoveDirection = "up" | "down" | "top" | "bottom"; +export type AccountMovePlacement = + | { type: "top" } + | { type: "bottom" } + | { type: "before"; anchorAccountId: string } + | { type: "after"; anchorAccountId: string }; const ACCOUNT_SORT_STEP = 5; @@ -596,6 +602,39 @@ export function buildAccountOrderUpdates(orderedAccounts: Account[]) { ); } +// 按目标位置重排账号;锚点账号不存在时返回 null,由调用方提示刷新。 +export function buildAccountsByMovedOrder( + orderedAccounts: Account[], + account: Account, + placement: AccountMovePlacement, +): Account[] | null { + const reorderedAccounts = orderedAccounts.filter( + (item) => item.id !== account.id, + ); + + if (placement.type === "top") { + reorderedAccounts.unshift(account); + return reorderedAccounts; + } + if (placement.type === "bottom") { + reorderedAccounts.push(account); + return reorderedAccounts; + } + + const anchorIndex = reorderedAccounts.findIndex( + (item) => item.id === placement.anchorAccountId, + ); + if (anchorIndex === -1) { + return null; + } + reorderedAccounts.splice( + placement.type === "before" ? anchorIndex : anchorIndex + 1, + 0, + account, + ); + return reorderedAccounts; +} + export function getAccountSizeGroup( account: Account, ): "large" | "standard" | "small" { diff --git a/apps/src/app/accounts/accounts-page-view.tsx b/apps/src/app/accounts/accounts-page-view.tsx index 5a7263a77..3022829cb 100644 --- a/apps/src/app/accounts/accounts-page-view.tsx +++ b/apps/src/app/accounts/accounts-page-view.tsx @@ -3,8 +3,10 @@ import type { Dispatch, SetStateAction } from "react"; import { ArrowDown, + ArrowDownToLine, ArrowUp, ArrowUpDown, + ArrowUpToLine, BarChart3, Download, FileUp, @@ -87,6 +89,7 @@ import { AccountProxyStatusHeader } from "@/components/accounts/account-proxy-st import { type AccountEditorState, type AccountExportMode, + type AccountMoveDirection, type AccountSizeSortMode, type DeleteDialogState, type StatusFilter, @@ -225,7 +228,7 @@ export interface AccountsPageViewProps { openAccountEditor: (account: Account) => void; handleMoveAccount: ( account: Account, - direction: "up" | "down", + direction: AccountMoveDirection, ) => Promise; handleApplyAccountSizeSort: (mode: AccountSizeSortMode) => Promise; handleConfirmAccountEditor: () => Promise; @@ -899,6 +902,9 @@ export function AccountsPageView(props: AccountsPageViewProps) { const canMoveDown = filteredIndex !== -1 && filteredIndex < filteredAccounts.length - 1; + const isAtListTop = accounts[0]?.id === account.id; + const isAtListBottom = + accounts[accounts.length - 1]?.id === account.id; return ( @@ -1040,6 +1046,40 @@ export function AccountsPageView(props: AccountsPageViewProps) { + + + {t("排序")} + + + void handleMoveAccount(account, "top") + } + > + + {t("移到顶部")} + + + void handleMoveAccount(account, "bottom") + } + > + + {t("移到底部")} + + + { ); }; - const handleMoveAccount = async ( + // 顶部/底部按全量列表定位,上移/下移仍按当前筛选结果取相邻账号。 + const resolveAccountMovePlacement = ( account: Account, - direction: "up" | "down", - ) => { + direction: AccountMoveDirection, + ): AccountMovePlacement | null => { + if (direction === "top" || direction === "bottom") { + const boundaryAccount = + direction === "top" ? accounts[0] : accounts[accounts.length - 1]; + if (boundaryAccount?.id === account.id) { + toast.info( + direction === "top" + ? t("当前账号已经在最前面") + : t("当前账号已经在最后面"), + ); + return null; + } + return { type: direction }; + } + const filteredIndex = filteredAccountIndexMap.get(account.id); if (filteredIndex == null) { toast.error(t("未找到当前账号,请刷新后重试")); - return; + return null; } const targetFilteredIndex = direction === "up" ? filteredIndex - 1 : filteredIndex + 1; if (targetFilteredIndex < 0) { toast.info(t("当前账号已经在最前面")); - return; + return null; } if (targetFilteredIndex >= filteredAccounts.length) { toast.info(t("当前账号已经在最后面")); + return null; + } + + return { + type: direction === "up" ? "before" : "after", + anchorAccountId: filteredAccounts[targetFilteredIndex].id, + }; + }; + + const handleMoveAccount = async ( + account: Account, + direction: AccountMoveDirection, + ) => { + const placement = resolveAccountMovePlacement(account, direction); + if (!placement) { return; } - const targetAccount = filteredAccounts[targetFilteredIndex]; - const reorderedAccounts = accounts.filter((item) => item.id !== account.id); - const anchorIndex = reorderedAccounts.findIndex( - (item) => item.id === targetAccount.id, + const reorderedAccounts = buildAccountsByMovedOrder( + accounts, + account, + placement, ); - if (anchorIndex === -1) { + if (!reorderedAccounts) { toast.error(t("未找到目标账号,请刷新后重试")); return; } - reorderedAccounts.splice( - direction === "up" ? anchorIndex : anchorIndex + 1, - 0, - account, - ); const updates = buildAccountOrderUpdates(reorderedAccounts); if (!updates.length) { toast.info(t("账号顺序未变化")); diff --git a/apps/src/lib/i18n/messages/en.ts b/apps/src/lib/i18n/messages/en.ts index d535119bf..bfe0ca261 100644 --- a/apps/src/lib/i18n/messages/en.ts +++ b/apps/src/lib/i18n/messages/en.ts @@ -429,6 +429,8 @@ export const EN_MESSAGES: MessageCatalog = { 未提供: "Unavailable", 上移一位: "Move up", 下移一位: "Move down", + 移到顶部: "Move to top", + 移到底部: "Move to bottom", 编辑账号信息: "Edit account info", 用量详情: "Usage details", 套餐信息: "Subscription info", diff --git a/apps/src/lib/i18n/messages/ko.ts b/apps/src/lib/i18n/messages/ko.ts index a26a5f2a2..52e3b5fa9 100644 --- a/apps/src/lib/i18n/messages/ko.ts +++ b/apps/src/lib/i18n/messages/ko.ts @@ -403,6 +403,8 @@ export const KO_MESSAGES: MessageCatalog = { 未提供: "제공 안 됨", 上移一位: "한 칸 위로", 下移一位: "한 칸 아래로", + 移到顶部: "맨 위로 이동", + 移到底部: "맨 아래로 이동", 编辑账号信息: "계정 정보 편집", 用量详情: "사용량 상세", 套餐信息: "구독 정보", diff --git a/apps/src/lib/i18n/messages/ru.ts b/apps/src/lib/i18n/messages/ru.ts index fe75137f6..7bbf10857 100644 --- a/apps/src/lib/i18n/messages/ru.ts +++ b/apps/src/lib/i18n/messages/ru.ts @@ -381,6 +381,8 @@ export const RU_MESSAGES: MessageCatalog = { 未提供: "Нет данных", 上移一位: "Переместить вверх", 下移一位: "Переместить вниз", + 移到顶部: "В начало списка", + 移到底部: "В конец списка", 编辑账号信息: "Изменить аккаунт", 用量详情: "Детали использования", 套餐信息: "Информация о подписке", diff --git a/apps/tests/accounts-reorder.spec.ts b/apps/tests/accounts-reorder.spec.ts new file mode 100644 index 000000000..8fc03b716 --- /dev/null +++ b/apps/tests/accounts-reorder.spec.ts @@ -0,0 +1,197 @@ +import { expect, test } from "@playwright/test"; + +const SETTINGS_SNAPSHOT = { + updateAutoCheck: true, + closeToTrayOnClose: false, + closeToTraySupported: false, + lowTransparency: false, + lightweightModeOnCloseToTray: false, + codexCliGuideDismissed: true, + webAccessPasswordConfigured: false, + locale: "zh-CN", + localeOptions: ["zh-CN", "en"], + serviceAddr: "localhost:48760", + serviceListenMode: "loopback", + serviceListenModeOptions: ["loopback", "all_interfaces"], + routeStrategy: "ordered", + routeStrategyOptions: ["ordered", "balanced"], + freeAccountMaxModel: "auto", + freeAccountMaxModelOptions: ["auto", "gpt-5"], + modelForwardRules: "", + accountMaxInflight: 1, + gatewayOriginator: "codex-cli", + gatewayOriginatorDefault: "codex-cli", + gatewayUserAgentVersion: "1.0.0", + gatewayUserAgentVersionDefault: "1.0.0", + gatewayResidencyRequirement: "", + gatewayResidencyRequirementOptions: ["", "us"], + pluginMarketMode: "builtin", + pluginMarketSourceUrl: "", + upstreamProxyUrl: "", + upstreamStreamTimeoutMs: 600000, + upstreamTotalTimeoutMs: 0, + sseKeepaliveIntervalMs: 15000, + backgroundTasks: { + usagePollingEnabled: true, + usagePollIntervalSecs: 600, + gatewayKeepaliveEnabled: true, + gatewayKeepaliveIntervalSecs: 180, + tokenRefreshPollingEnabled: true, + tokenRefreshPollIntervalSecs: 60, + usageRefreshWorkers: 4, + httpWorkerFactor: 4, + httpWorkerMin: 8, + httpStreamWorkerFactor: 1, + httpStreamWorkerMin: 2, + }, + envOverrides: {}, + envOverrideCatalog: [], + envOverrideReservedKeys: [], + envOverrideUnsupportedKeys: [], + theme: "tech", + appearancePreset: "classic", +}; + +const ACCOUNT_ITEMS = [ + { id: "acct-1", label: "first@example.com", plan_type: "plus", status: "active", sort: 0 }, + { id: "acct-2", label: "second@example.com", plan_type: "free", status: "active", sort: 5 }, + { id: "acct-3", label: "third@example.com", plan_type: "pro", status: "active", sort: 10 }, +]; + +test("account row menu moves an account to the top of the pool", async ({ + page, +}) => { + const sortUpdatePayloads: Record[][] = []; + + await page.route("**/api/runtime**", async (route) => { + await route.fulfill({ + contentType: "application/json; charset=utf-8", + body: JSON.stringify({ + mode: "web-gateway", + rpcBaseUrl: "/api/rpc", + canManageService: false, + canSelfUpdate: false, + canCloseToTray: false, + canOpenLocalDir: false, + canUseBrowserFileImport: true, + canUseBrowserDownloadExport: true, + }), + }); + }); + + await page.route("**/api/rpc**", async (route) => { + const payload = route.request().postDataJSON(); + const method = typeof payload?.method === "string" ? payload.method : ""; + const id = payload?.id ?? 1; + + const ok = (result: unknown) => + route.fulfill({ + contentType: "application/json; charset=utf-8", + body: JSON.stringify({ + jsonrpc: "2.0", + id, + result, + }), + }); + + if (method === "appSettings/get") { + await ok(SETTINGS_SNAPSHOT); + return; + } + if (method === "initialize") { + await ok({ + version: "0.3.1", + userAgent: "codex_cli_rs/0.1.19", + codexHome: "/tmp/.codex", + platformFamily: "unix", + platformOs: "macos", + }); + return; + } + if (method === "accountManager/session/current") { + await ok({ + mode: "none", + currentUser: null, + role: "system_admin", + permissions: ["system:admin"], + distributionEnabled: false, + }); + return; + } + if (method === "account/list") { + await ok({ + items: ACCOUNT_ITEMS, + total: ACCOUNT_ITEMS.length, + page: 1, + pageSize: 20, + }); + return; + } + if (method === "account/usage/list") { + await ok([]); + return; + } + if (method === "account/updateSorts") { + const updates = payload?.params?.updates; + sortUpdatePayloads.push(Array.isArray(updates) ? updates : []); + await ok({}); + return; + } + + await route.fulfill({ + status: 500, + contentType: "application/json; charset=utf-8", + body: JSON.stringify({ + jsonrpc: "2.0", + id, + error: { + code: -32000, + message: `Unhandled RPC method in test: ${method}`, + }, + }), + }); + }); + + await page.goto("/accounts/"); + + await expect(page.getByRole("heading", { name: "OpenAI 账号池" })).toBeVisible(); + await expect(page.locator("tbody tr")).toHaveCount(ACCOUNT_ITEMS.length); + + // 关闭后的菜单仍留在 DOM 中,只断言当前可见的那一份。 + const openMenuItem = (name: string) => + page.getByRole("menuitem", { name }).filter({ visible: true }); + + const lastRow = page.locator("tbody tr").last(); + await lastRow.getByLabel("更多账号操作").click(); + + const moveToTopItem = openMenuItem("移到顶部"); + await expect(moveToTopItem).toBeVisible(); + await expect(openMenuItem("移到底部")).toHaveAttribute( + "aria-disabled", + "true", + ); + + await moveToTopItem.click(); + + await expect.poll(() => sortUpdatePayloads.length).toBe(1); + expect(sortUpdatePayloads[0]).toEqual([ + { accountId: "acct-3", sort: 0 }, + { accountId: "acct-1", sort: 5 }, + { accountId: "acct-2", sort: 10 }, + ]); + + // 等待排序落库并关闭上一个菜单,避免重排进行中把菜单项判成禁用。 + await expect(page.getByText("账号顺序已调整(3 项)")).toBeVisible(); + await expect(openMenuItem("移到顶部")).toHaveCount(0); + + const firstRow = page.locator("tbody tr").first(); + await firstRow.getByLabel("更多账号操作").click(); + await expect(openMenuItem("移到顶部")).toHaveAttribute( + "aria-disabled", + "true", + ); + await expect(openMenuItem("移到底部")).not.toHaveAttribute( + "aria-disabled", + "true", + ); +});