Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/src/app/accounts/accounts-page-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
PencilLine,
Pin,
Plus,
Power,
PowerOff,
RefreshCw,
Search,
Trash2,
Expand Down Expand Up @@ -135,6 +137,8 @@ export interface AccountsPageViewProps {
visibleAccounts: Account[];
filteredAccountIndexMap: Map<string, number>;
effectiveSelectedIds: string[];
selectedEnableTargetCount: number;
selectedDisableTargetCount: number;
addAccountModalOpen: boolean;
usageModalOpen: boolean;
exportDialogOpen: boolean;
Expand Down Expand Up @@ -178,6 +182,7 @@ export interface AccountsPageViewProps {
isReorderingAccounts: boolean;
isUpdatingProfileAccountId: string | null;
isUpdatingStatusAccountId: string | null;
isUpdatingManyStatuses: boolean;
statusFilterOptions: StatusFilterOption[];
importFileActionLabel: string;
importDirectoryActionLabel: string;
Expand Down Expand Up @@ -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<void>;
Expand Down Expand Up @@ -264,6 +271,8 @@ export function AccountsPageView(props: AccountsPageViewProps) {
visibleAccounts,
filteredAccountIndexMap,
effectiveSelectedIds,
selectedEnableTargetCount,
selectedDisableTargetCount,
addAccountModalOpen,
usageModalOpen,
exportDialogOpen,
Expand Down Expand Up @@ -305,6 +314,7 @@ export function AccountsPageView(props: AccountsPageViewProps) {
isReorderingAccounts,
isUpdatingProfileAccountId,
isUpdatingStatusAccountId,
isUpdatingManyStatuses,
statusFilterOptions,
importFileActionLabel,
importDirectoryActionLabel,
Expand Down Expand Up @@ -335,6 +345,8 @@ export function AccountsPageView(props: AccountsPageViewProps) {
openUsage,
handleUsageModalOpenChange,
handleDeleteSelected,
handleEnableSelected,
handleDisableSelected,
openCleanupDialog,
toggleCleanupStatus,
handleConfirmCleanupStatuses,
Expand Down Expand Up @@ -375,6 +387,8 @@ export function AccountsPageView(props: AccountsPageViewProps) {
cleanupStatusDraft.includes(option.id) ? total + option.count : total,
0,
);
const statusMutationBusy =
isUpdatingManyStatuses || Boolean(isUpdatingStatusAccountId);

return (
<div className="space-y-6">
Expand Down Expand Up @@ -581,6 +595,52 @@ export function AccountsPageView(props: AccountsPageViewProps) {
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="px-2 py-1 text-[11px] uppercase tracking-[0.16em] text-muted-foreground/80">
{t("批量状态")}
</DropdownMenuLabel>
<DropdownMenuItem
className="h-9 rounded-lg px-2"
disabled={
!isServiceReady ||
effectiveSelectedIds.length === 0 ||
selectedEnableTargetCount === 0 ||
statusMutationBusy
}
onClick={handleEnableSelected}
>
{isUpdatingManyStatuses ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Power className="mr-2 h-4 w-4" />
)}
{t("批量开启选中账号")}
<DropdownMenuShortcut>
{selectedEnableTargetCount || "-"}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
className="h-9 rounded-lg px-2"
disabled={
!isServiceReady ||
effectiveSelectedIds.length === 0 ||
selectedDisableTargetCount === 0 ||
statusMutationBusy
}
onClick={handleDisableSelected}
>
{isUpdatingManyStatuses ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<PowerOff className="mr-2 h-4 w-4" />
)}
{t("批量关闭选中账号")}
<DropdownMenuShortcut>
{selectedDisableTargetCount || "-"}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="px-2 py-1 text-[11px] uppercase tracking-[0.16em] text-muted-foreground/80">
{t("排序")}
Expand Down Expand Up @@ -1097,6 +1157,7 @@ export function AccountsPageView(props: AccountsPageViewProps) {
className="gap-2"
disabled={
!isServiceReady ||
isUpdatingManyStatuses ||
isUpdatingStatusAccountId === account.id ||
statusAction.action === null
}
Expand Down
71 changes: 71 additions & 0 deletions apps/src/app/accounts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down Expand Up @@ -93,7 +110,9 @@ export default function AccountsPage() {
updateAccountProfile,
isUpdatingProfileAccountId,
toggleAccountStatus,
toggleManyAccountStatuses,
isUpdatingStatusAccountId,
isUpdatingManyStatuses,
} = useAccounts();
const isPageActive = useDesktopPageActive("/accounts/");
usePageTransitionReady("/accounts/", !isServiceReady || !isLoading);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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("当前没有可清理的账号"));
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -817,6 +885,7 @@ const toggleCleanupStatus = (rawStatus: string) => {
isReorderingAccounts={isReorderingAccounts}
isUpdatingProfileAccountId={isUpdatingProfileAccountId}
isUpdatingStatusAccountId={isUpdatingStatusAccountId}
isUpdatingManyStatuses={isUpdatingManyStatuses}
statusFilterOptions={statusFilterOptions}
importFileActionLabel={importFileActionLabel}
importDirectoryActionLabel={importDirectoryActionLabel}
Expand Down Expand Up @@ -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}
Expand Down
121 changes: 121 additions & 0 deletions apps/src/hooks/useAccounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ type DeleteAccountsByStatusesResult = Awaited<
ReturnType<typeof accountClient.deleteByStatuses>
>;
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`
Expand Down Expand Up @@ -845,6 +856,103 @@ export function useAccounts() {
},
});

const toggleManyAccountStatusMutation = useMutation({
mutationFn: async ({
accountIds,
enabled,
}: ToggleManyAccountStatusInput): Promise<ToggleManyAccountStatusResult> => {
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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1199,5 +1319,6 @@ export function useAccounts() {
(toggleAccountStatusMutation.variables as { accountId?: unknown }).accountId || ""
)
: "",
isUpdatingManyStatuses: toggleManyAccountStatusMutation.isPending,
};
}
Loading