From e8da9a63eb0cda1a2f88367b71af4701f95ac174 Mon Sep 17 00:00:00 2001 From: "agermel@foxmail.com" Date: Mon, 27 Jul 2026 03:10:34 +0800 Subject: [PATCH] feat(accounts): add bulk status actions --- apps/src/app/accounts/accounts-page-view.tsx | 61 +++++++++ apps/src/app/accounts/page.tsx | 71 ++++++++++ apps/src/hooks/useAccounts.ts | 121 ++++++++++++++++++ .../lib/i18n/messages/sections/en-accounts.ts | 19 +++ .../lib/i18n/messages/sections/ko-accounts.ts | 19 +++ .../lib/i18n/messages/sections/ru-accounts.ts | 19 +++ 6 files changed, 310 insertions(+) diff --git a/apps/src/app/accounts/accounts-page-view.tsx b/apps/src/app/accounts/accounts-page-view.tsx index 5a7263a77..2f5f6f641 100644 --- a/apps/src/app/accounts/accounts-page-view.tsx +++ b/apps/src/app/accounts/accounts-page-view.tsx @@ -16,6 +16,8 @@ import { PencilLine, Pin, Plus, + Power, + PowerOff, RefreshCw, Search, Trash2, @@ -135,6 +137,8 @@ export interface AccountsPageViewProps { visibleAccounts: Account[]; filteredAccountIndexMap: Map; effectiveSelectedIds: string[]; + selectedEnableTargetCount: number; + selectedDisableTargetCount: number; addAccountModalOpen: boolean; usageModalOpen: boolean; exportDialogOpen: boolean; @@ -178,6 +182,7 @@ export interface AccountsPageViewProps { isReorderingAccounts: boolean; isUpdatingProfileAccountId: string | null; isUpdatingStatusAccountId: string | null; + isUpdatingManyStatuses: boolean; statusFilterOptions: StatusFilterOption[]; importFileActionLabel: string; importDirectoryActionLabel: string; @@ -210,6 +215,8 @@ export interface AccountsPageViewProps { openUsage: (account: Account) => void; handleUsageModalOpenChange: (open: boolean) => void; handleDeleteSelected: () => void; + handleEnableSelected: () => void; + handleDisableSelected: () => void; openCleanupDialog: () => void; toggleCleanupStatus: (status: string) => void; handleConfirmCleanupStatuses: () => Promise; @@ -264,6 +271,8 @@ export function AccountsPageView(props: AccountsPageViewProps) { visibleAccounts, filteredAccountIndexMap, effectiveSelectedIds, + selectedEnableTargetCount, + selectedDisableTargetCount, addAccountModalOpen, usageModalOpen, exportDialogOpen, @@ -305,6 +314,7 @@ export function AccountsPageView(props: AccountsPageViewProps) { isReorderingAccounts, isUpdatingProfileAccountId, isUpdatingStatusAccountId, + isUpdatingManyStatuses, statusFilterOptions, importFileActionLabel, importDirectoryActionLabel, @@ -335,6 +345,8 @@ export function AccountsPageView(props: AccountsPageViewProps) { openUsage, handleUsageModalOpenChange, handleDeleteSelected, + handleEnableSelected, + handleDisableSelected, openCleanupDialog, toggleCleanupStatus, handleConfirmCleanupStatuses, @@ -375,6 +387,8 @@ export function AccountsPageView(props: AccountsPageViewProps) { cleanupStatusDraft.includes(option.id) ? total + option.count : total, 0, ); + const statusMutationBusy = + isUpdatingManyStatuses || Boolean(isUpdatingStatusAccountId); return (
@@ -581,6 +595,52 @@ export function AccountsPageView(props: AccountsPageViewProps) { + + + {t("批量状态")} + + + {isUpdatingManyStatuses ? ( + + ) : ( + + )} + {t("批量开启选中账号")} + + {selectedEnableTargetCount || "-"} + + + + {isUpdatingManyStatuses ? ( + + ) : ( + + )} + {t("批量关闭选中账号")} + + {selectedDisableTargetCount || "-"} + + + + {t("排序")} @@ -1097,6 +1157,7 @@ export function AccountsPageView(props: AccountsPageViewProps) { className="gap-2" disabled={ !isServiceReady || + isUpdatingManyStatuses || isUpdatingStatusAccountId === account.id || statusAction.action === null } diff --git a/apps/src/app/accounts/page.tsx b/apps/src/app/accounts/page.tsx index 1151da813..99264c202 100644 --- a/apps/src/app/accounts/page.tsx +++ b/apps/src/app/accounts/page.tsx @@ -49,6 +49,23 @@ function normalizeCleanupStatus(status: string): CleanupStatus | null { : null; } +function normalizeAccountStatusValue(status: string): string { + return String(status || "").trim().toLowerCase(); +} + +function isManuallyClosedAccount(account: Account): boolean { + const statusReason = normalizeAccountStatusValue(account.statusReason); + return statusReason === "manual_disable"; +} + +function canBulkEnableAccount(account: Account): boolean { + return isManuallyClosedAccount(account); +} + +function canBulkDisableAccount(account: Account): boolean { + return !isManuallyClosedAccount(account); +} + export default function AccountsPage() { const { t } = useI18n(); const { isDesktopRuntime, canUseBrowserDownloadExport } = @@ -93,7 +110,9 @@ export default function AccountsPage() { updateAccountProfile, isUpdatingProfileAccountId, toggleAccountStatus, + toggleManyAccountStatuses, isUpdatingStatusAccountId, + isUpdatingManyStatuses, } = useAccounts(); const isPageActive = useDesktopPageActive("/accounts/"); usePageTransitionReady("/accounts/", !isServiceReady || !isLoading); @@ -263,6 +282,27 @@ export default function AccountsPage() { () => selectedIds.filter((id) => accountIdSet.has(id)), [accountIdSet, selectedIds], ); + const selectedAccounts = useMemo( + () => + effectiveSelectedIds + .map((id) => accounts.find((account) => account.id === id)) + .filter((account): account is Account => Boolean(account)), + [accounts, effectiveSelectedIds], + ); + const selectedEnableTargetIds = useMemo( + () => + selectedAccounts + .filter((account) => canBulkEnableAccount(account)) + .map((account) => account.id), + [selectedAccounts], + ); + const selectedDisableTargetIds = useMemo( + () => + selectedAccounts + .filter((account) => canBulkDisableAccount(account)) + .map((account) => account.id), + [selectedAccounts], + ); const exportSelectionCount = effectiveSelectedIds.length; const exportTargetCount = exportSelectionCount > 0 ? exportSelectionCount : accounts.length; @@ -361,6 +401,32 @@ export default function AccountsPage() { }); }; + const handleToggleSelectedStatus = async (enabled: boolean) => { + if (!effectiveSelectedIds.length) { + toast.error(t("请先选择账号")); + return; + } + const targetIds = enabled ? selectedEnableTargetIds : selectedDisableTargetIds; + if (targetIds.length === 0) { + toast.info( + enabled + ? t("当前选中账号没有可开启项") + : t("当前选中账号没有可关闭项"), + ); + return; + } + + try { + await toggleManyAccountStatuses( + targetIds, + enabled, + effectiveSelectedIds.length, + ); + } catch { + // hook 内统一处理 toast,这里保留当前选择 + } + }; + const openCleanupDialog = () => { if (!accounts.length) { toast.info(t("当前没有可清理的账号")); @@ -774,6 +840,8 @@ const toggleCleanupStatus = (rawStatus: string) => { visibleAccounts={visibleAccounts} filteredAccountIndexMap={filteredAccountIndexMap} effectiveSelectedIds={effectiveSelectedIds} + selectedEnableTargetCount={selectedEnableTargetIds.length} + selectedDisableTargetCount={selectedDisableTargetIds.length} addAccountModalOpen={addAccountModalOpen} usageModalOpen={usageModalOpen} exportDialogOpen={exportDialogOpen} @@ -817,6 +885,7 @@ const toggleCleanupStatus = (rawStatus: string) => { isReorderingAccounts={isReorderingAccounts} isUpdatingProfileAccountId={isUpdatingProfileAccountId} isUpdatingStatusAccountId={isUpdatingStatusAccountId} + isUpdatingManyStatuses={isUpdatingManyStatuses} statusFilterOptions={statusFilterOptions} importFileActionLabel={importFileActionLabel} importDirectoryActionLabel={importDirectoryActionLabel} @@ -849,6 +918,8 @@ const toggleCleanupStatus = (rawStatus: string) => { openUsage={openUsage} handleUsageModalOpenChange={handleUsageModalOpenChange} handleDeleteSelected={handleDeleteSelected} + handleEnableSelected={() => void handleToggleSelectedStatus(true)} + handleDisableSelected={() => void handleToggleSelectedStatus(false)} openCleanupDialog={openCleanupDialog} toggleCleanupStatus={toggleCleanupStatus} handleConfirmCleanupStatuses={handleConfirmCleanupStatuses} diff --git a/apps/src/hooks/useAccounts.ts b/apps/src/hooks/useAccounts.ts index bb7ba4dcb..682dbe961 100644 --- a/apps/src/hooks/useAccounts.ts +++ b/apps/src/hooks/useAccounts.ts @@ -44,6 +44,17 @@ type DeleteAccountsByStatusesResult = Awaited< ReturnType >; type AccountSortUpdate = { accountId: string; sort: number }; +type ToggleManyAccountStatusInput = { + accountIds: string[]; + enabled: boolean; + selectedCount?: number; +}; + +type ToggleManyAccountStatusResult = { + requested: number; + succeeded: number; + failed: Array<{ accountId: string; reason: string }>; +}; /** * 函数 `isAccountRefreshBlocked` @@ -845,6 +856,103 @@ export function useAccounts() { }, }); + const toggleManyAccountStatusMutation = useMutation({ + mutationFn: async ({ + accountIds, + enabled, + }: ToggleManyAccountStatusInput): Promise => { + const normalizedIds = Array.from( + new Set(accountIds.map((accountId) => accountId.trim()).filter(Boolean)), + ); + if (normalizedIds.length === 0) { + return { requested: 0, succeeded: 0, failed: [] }; + } + + const settled = await Promise.allSettled( + normalizedIds.map((accountId) => + enabled + ? accountClient.enableAccount(accountId) + : accountClient.disableAccount(accountId), + ), + ); + const failed = settled.flatMap((result, index) => + result.status === "rejected" + ? [ + { + accountId: normalizedIds[index], + reason: getAppErrorMessage(result.reason), + }, + ] + : [], + ); + + return { + requested: normalizedIds.length, + succeeded: normalizedIds.length - failed.length, + failed, + }; + }, + onSuccess: async (result, variables) => { + await invalidateAccountData(); + const actionLabel = variables.enabled ? t("开启账号") : t("关闭账号"); + const selectedCount = Math.max( + Number(variables.selectedCount ?? result.requested) || 0, + result.requested, + ); + const skipped = Math.max(selectedCount - result.requested, 0); + + if (result.requested === 0) { + toast.info( + variables.enabled + ? t("当前选中账号没有可开启项") + : t("当前选中账号没有可关闭项"), + ); + return; + } + + if (result.failed.length > 0) { + const summary = + skipped > 0 + ? t("批量{action}完成:成功{success}个,失败{failed}个,跳过{skipped}个", { + action: actionLabel, + success: result.succeeded, + failed: result.failed.length, + skipped, + }) + : t("批量{action}完成:成功{success}个,失败{failed}个", { + action: actionLabel, + success: result.succeeded, + failed: result.failed.length, + }); + toast.warning( + `${summary};${t("首个失败")}: ${result.failed[0].accountId} - ${result.failed[0].reason}`, + ); + return; + } + + toast.success( + skipped > 0 + ? t("批量{action}完成:成功{success}个,跳过{skipped}个", { + action: actionLabel, + success: result.succeeded, + skipped, + }) + : t("批量{action}完成:成功{success}个", { + action: actionLabel, + success: result.succeeded, + }), + ); + }, + onError: (error: unknown, variables) => { + toast.error( + t("批量{action}失败: {error}", { + action: variables.enabled ? t("开启账号") : t("关闭账号"), + error: getAppErrorMessage(error), + }), + ); + }, + }); + const importByDirectoryMutation = useMutation({ mutationFn: () => accountClient.importByDirectory(), onSuccess: async (result: ImportByDirectoryResult) => { @@ -1151,6 +1259,18 @@ export function useAccounts() { if (!ensureServiceReady(enabled ? "启用账号" : "禁用账号")) return; toggleAccountStatusMutation.mutate({ accountId, enabled, sourceStatus }); }, + toggleManyAccountStatuses: async ( + accountIds: string[], + enabled: boolean, + selectedCount?: number, + ) => { + if (!ensureServiceReady(enabled ? "批量开启账号" : "批量关闭账号")) return; + await toggleManyAccountStatusMutation.mutateAsync({ + accountIds, + enabled, + selectedCount, + }); + }, isRefreshingAccountId: refreshAccountMutation.isPending && typeof refreshAccountMutation.variables === "string" ? refreshAccountMutation.variables @@ -1199,5 +1319,6 @@ export function useAccounts() { (toggleAccountStatusMutation.variables as { accountId?: unknown }).accountId || "" ) : "", + isUpdatingManyStatuses: toggleManyAccountStatusMutation.isPending, }; } diff --git a/apps/src/lib/i18n/messages/sections/en-accounts.ts b/apps/src/lib/i18n/messages/sections/en-accounts.ts index b7eb7b70f..e359ad004 100644 --- a/apps/src/lib/i18n/messages/sections/en-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/en-accounts.ts @@ -267,5 +267,24 @@ export const EN_ACCOUNTS_MESSAGES: MessageCatalog = { "暂无重置券记录": "No reset credit history", "无法读取重置券详情,请重新核对。": "Reset credit details could not be loaded. Check again.", + "开启账号": "enable accounts", + "关闭账号": "disable accounts", + "批量开启账号": "Bulk enable accounts", + "批量关闭账号": "Bulk disable accounts", + "批量状态": "Bulk status", + "批量开启选中账号": "Enable selected accounts", + "批量关闭选中账号": "Disable selected accounts", + "请先选择账号": "Select accounts first", + "当前选中账号没有可开启项": "No selected accounts can be enabled", + "当前选中账号没有可关闭项": "No selected accounts can be disabled", + "批量{action}完成:成功{success}个,失败{failed}个,跳过{skipped}个": + "Bulk {action} complete: {success} succeeded, {failed} failed, {skipped} skipped", + "批量{action}完成:成功{success}个,失败{failed}个": + "Bulk {action} complete: {success} succeeded, {failed} failed", + "批量{action}完成:成功{success}个,跳过{skipped}个": + "Bulk {action} complete: {success} succeeded, {skipped} skipped", + "批量{action}完成:成功{success}个": + "Bulk {action} complete: {success} succeeded", + "批量{action}失败: {error}": "Bulk {action} failed: {error}", "预计删除": "Estimated delete", }; diff --git a/apps/src/lib/i18n/messages/sections/ko-accounts.ts b/apps/src/lib/i18n/messages/sections/ko-accounts.ts index e059e897d..69fb9149c 100644 --- a/apps/src/lib/i18n/messages/sections/ko-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/ko-accounts.ts @@ -254,5 +254,24 @@ export const KO_ACCOUNTS_MESSAGES: MessageCatalog = { "暂无重置券记录": "재설정 크레딧 기록 없음", "无法读取重置券详情,请重新核对。": "재설정 크레딧 세부 정보를 불러오지 못했습니다. 다시 확인하세요.", + "开启账号": "계정 활성화", + "关闭账号": "계정 비활성화", + "批量开启账号": "계정 일괄 활성화", + "批量关闭账号": "계정 일괄 비활성화", + "批量状态": "일괄 상태", + "批量开启选中账号": "선택 계정 일괄 활성화", + "批量关闭选中账号": "선택 계정 일괄 비활성화", + "请先选择账号": "먼저 계정을 선택하세요", + "当前选中账号没有可开启项": "선택한 계정 중 활성화할 항목이 없습니다", + "当前选中账号没有可关闭项": "선택한 계정 중 비활성화할 항목이 없습니다", + "批量{action}完成:成功{success}个,失败{failed}个,跳过{skipped}个": + "일괄 {action} 완료: 성공 {success}개, 실패 {failed}개, 건너뜀 {skipped}개", + "批量{action}完成:成功{success}个,失败{failed}个": + "일괄 {action} 완료: 성공 {success}개, 실패 {failed}개", + "批量{action}完成:成功{success}个,跳过{skipped}个": + "일괄 {action} 완료: 성공 {success}개, 건너뜀 {skipped}개", + "批量{action}完成:成功{success}个": + "일괄 {action} 완료: 성공 {success}개", + "批量{action}失败: {error}": "일괄 {action} 실패: {error}", "预计删除": "예상 삭제", }; diff --git a/apps/src/lib/i18n/messages/sections/ru-accounts.ts b/apps/src/lib/i18n/messages/sections/ru-accounts.ts index 9cfd0b892..d80faa8be 100644 --- a/apps/src/lib/i18n/messages/sections/ru-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/ru-accounts.ts @@ -267,5 +267,24 @@ export const RU_ACCOUNTS_MESSAGES: MessageCatalog = { "暂无重置券记录": "Нет истории кредитов сброса", "无法读取重置券详情,请重新核对。": "Не удалось загрузить данные кредитов сброса. Проверьте снова.", + "开启账号": "включение аккаунтов", + "关闭账号": "отключение аккаунтов", + "批量开启账号": "Массово включить аккаунты", + "批量关闭账号": "Массово отключить аккаунты", + "批量状态": "Массовый статус", + "批量开启选中账号": "Включить выбранные аккаунты", + "批量关闭选中账号": "Отключить выбранные аккаунты", + "请先选择账号": "Сначала выберите аккаунты", + "当前选中账号没有可开启项": "Среди выбранных нет аккаунтов для включения", + "当前选中账号没有可关闭项": "Среди выбранных нет аккаунтов для отключения", + "批量{action}完成:成功{success}个,失败{failed}个,跳过{skipped}个": + "Массовое {action} завершено: успешно {success}, с ошибкой {failed}, пропущено {skipped}", + "批量{action}完成:成功{success}个,失败{failed}个": + "Массовое {action} завершено: успешно {success}, с ошибкой {failed}", + "批量{action}完成:成功{success}个,跳过{skipped}个": + "Массовое {action} завершено: успешно {success}, пропущено {skipped}", + "批量{action}完成:成功{success}个": + "Массовое {action} завершено: успешно {success}", + "批量{action}失败: {error}": "Массовое {action} не удалось: {error}", "预计删除": "Ожидается удалить", };