diff --git a/Cargo.lock b/Cargo.lock index 3eaaa14a8..d36e9dd48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,6 +309,7 @@ dependencies = [ "log", "os_info", "rand 0.8.5", + "rcgen", "reqwest", "rhai", "rusqlite", @@ -531,6 +532,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1432,6 +1442,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1684,6 +1700,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1732,6 +1758,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1905,6 +1937,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "regex" version = "1.12.3" @@ -2446,6 +2491,25 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -3306,6 +3370,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/apps/src-tauri/src/commands/account/remote.rs b/apps/src-tauri/src/commands/account/remote.rs index 970c68af4..96e6a3f40 100644 --- a/apps/src-tauri/src/commands/account/remote.rs +++ b/apps/src-tauri/src/commands/account/remote.rs @@ -248,6 +248,54 @@ pub async fn service_account_warmup( rpc_call_in_background("account/warmup", addr, Some(params)).await } +#[tauri::command] +pub async fn service_account_proxy_get( + addr: Option, + account_id: String, +) -> Result { + let params = serde_json::json!({ "accountId": account_id }); + rpc_call_in_background("account/proxy/get", addr, Some(params)).await +} + +#[tauri::command] +pub async fn service_account_proxy_set( + addr: Option, + account_id: String, + enabled: bool, + proxy_url: Option, +) -> Result { + let params = serde_json::json!({ + "accountId": account_id, + "enabled": enabled, + "proxyUrl": proxy_url, + }); + rpc_call_in_background("account/proxy/set", addr, Some(params)).await +} + +#[tauri::command] +pub async fn service_account_proxy_clear( + addr: Option, + account_id: String, +) -> Result { + let params = serde_json::json!({ "accountId": account_id }); + rpc_call_in_background("account/proxy/clear", addr, Some(params)).await +} + +#[tauri::command] +pub async fn service_account_proxy_test( + addr: Option, + account_id: String, + enabled: Option, + proxy_url: Option, +) -> Result { + let params = serde_json::json!({ + "accountId": account_id, + "enabled": enabled, + "proxyUrl": proxy_url, + }); + rpc_call_in_background("account/proxy/test", addr, Some(params)).await +} + #[cfg(test)] mod tests { use super::account_update_payload; diff --git a/apps/src-tauri/src/commands/registry.rs b/apps/src-tauri/src/commands/registry.rs index 21b9e81fe..22137c639 100644 --- a/apps/src-tauri/src/commands/registry.rs +++ b/apps/src-tauri/src/commands/registry.rs @@ -88,6 +88,10 @@ macro_rules! invoke_handler { crate::commands::account::remote::service_account_delete_by_statuses, crate::commands::account::remote::service_account_update, crate::commands::account::remote::service_account_warmup, + crate::commands::account::remote::service_account_proxy_get, + crate::commands::account::remote::service_account_proxy_set, + crate::commands::account::remote::service_account_proxy_clear, + crate::commands::account::remote::service_account_proxy_test, crate::commands::account::transfer::service_account_import, crate::commands::account::transfer::service_account_import_by_directory, crate::commands::account::transfer::service_account_import_by_file, diff --git a/apps/src/app/accounts/accounts-page-helpers.tsx b/apps/src/app/accounts/accounts-page-helpers.tsx index 7249682e3..631f6ae37 100644 --- a/apps/src/app/accounts/accounts-page-helpers.tsx +++ b/apps/src/app/accounts/accounts-page-helpers.tsx @@ -5,826 +5,838 @@ import { Power, PowerOff, RefreshCw, Zap } from "lucide-react"; import { useI18n } from "@/lib/i18n/provider"; import { cn } from "@/lib/utils"; import { - formatRemainingDurationFromSeconds, - formatTsFromSeconds, - getExtraUsageDisplayRows, - getUsageDisplayBuckets, - isPrimaryWindowOnlyUsage, - isSecondaryWindowOnlyUsage, + formatRemainingDurationFromSeconds, + formatTsFromSeconds, + getExtraUsageDisplayRows, + getUsageDisplayBuckets, + isPrimaryWindowOnlyUsage, + isSecondaryWindowOnlyUsage, } from "@/lib/utils/usage"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; import { - Tooltip, - TooltipContent, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/ui/tooltip"; import type { Account } from "@/types"; -export type StatusFilter = "all" | "available" | "low_quota" | "limited" | "banned"; +export type StatusFilter = + | "all" + | "available" + | "low_quota" + | "limited" + | "banned"; export type AccountExportMode = "single" | "multiple"; export type AccountSizeSortMode = "large-first" | "small-first"; const ACCOUNT_SORT_STEP = 5; export function fitLongTextClassName( - value: string, - baseClassName: string, - defaultSizeClassName: string, + value: string, + baseClassName: string, + defaultSizeClassName: string, ): string { - const length = Array.from(String(value || "")).length; - if (length > 96) return cn(baseClassName, "text-[8px] leading-tight"); - if (length > 72) return cn(baseClassName, "text-[9px] leading-snug"); - if (length > 40) return cn(baseClassName, "text-[10px] leading-snug"); - if (length > 24) return cn(baseClassName, "text-[11px] leading-snug"); - return cn(baseClassName, defaultSizeClassName); + const length = Array.from(String(value || "")).length; + if (length > 96) return cn(baseClassName, "text-[8px] leading-tight"); + if (length > 72) return cn(baseClassName, "text-[9px] leading-snug"); + if (length > 40) return cn(baseClassName, "text-[10px] leading-snug"); + if (length > 24) return cn(baseClassName, "text-[11px] leading-snug"); + return cn(baseClassName, defaultSizeClassName); } export type TranslateFn = ( - key: string, - values?: Record, + key: string, + values?: Record, ) => string; export function formatAccountPlanValueLabel(value: string, t: TranslateFn) { - const normalized = String(value || "") - .trim() - .toLowerCase(); - switch (normalized) { - case "free": - return "FREE"; - case "go": - return "GO"; - case "plus": - return "PLUS"; - case "pro": - return "PRO"; - case "team": - return "TEAM"; - case "business": - return "BUSINESS"; - case "enterprise": - return "ENTERPRISE"; - case "edu": - return "EDU"; - case "unknown": - return t("未知"); - default: - return normalized ? normalized.toUpperCase() : t("未知"); - } + const normalized = String(value || "") + .trim() + .toLowerCase(); + switch (normalized) { + case "free": + return "FREE"; + case "go": + return "GO"; + case "plus": + return "PLUS"; + case "pro": + return "PRO"; + case "team": + return "TEAM"; + case "business": + return "BUSINESS"; + case "enterprise": + return "ENTERPRISE"; + case "edu": + return "EDU"; + case "unknown": + return t("未知"); + default: + return normalized ? normalized.toUpperCase() : t("未知"); + } } export function normalizeAccountPlanKey(account: Account) { - return ( - String(account.planType || "") - .trim() - .toLowerCase() || "unknown" - ); + return ( + String(account.planType || "") + .trim() + .toLowerCase() || "unknown" + ); } export function formatPlanFilterLabel(value: string, t: TranslateFn) { - const nextValue = String(value || "").trim(); - if (!nextValue || nextValue === "all") { - return t("全部类型"); - } - return formatAccountPlanValueLabel(nextValue, t); + const nextValue = String(value || "").trim(); + if (!nextValue || nextValue === "all") { + return t("全部类型"); + } + return formatAccountPlanValueLabel(nextValue, t); } export function formatStatusFilterLabel(value: string, t: TranslateFn) { - const nextValue = String(value || "").trim(); - switch (nextValue) { - case "available": - return t("可用"); - case "low_quota": - return t("低配额"); - case "limited": - return t("限流"); - case "banned": - return t("封禁"); - case "all": - default: - return t("全部"); - } + const nextValue = String(value || "").trim(); + switch (nextValue) { + case "available": + return t("可用"); + case "low_quota": + return t("低配额"); + case "limited": + return t("限流"); + case "banned": + return t("封禁"); + case "all": + default: + return t("全部"); + } } export interface QuotaProgressProps { - label: string; - remainPercent: number | null; - resetsAt: number | null; - icon: LucideIcon; - tone: "green" | "blue" | "amber"; - caption?: string; - emptyText?: string; - emptyResetText?: string; + label: string; + remainPercent: number | null; + resetsAt: number | null; + icon: LucideIcon; + tone: "green" | "blue" | "amber"; + caption?: string; + emptyText?: string; + emptyResetText?: string; } export interface QuotaSummaryItem extends QuotaProgressProps { - id: string; + id: string; } export interface AccountEditorState { - accountId: string; - accountName: string; - currentLabel: string; - currentTags: string; - currentNote: string; - currentSort: number; - currentModelSlugs: string; - currentQuotaPrimaryWindowTokens: number | null; - currentQuotaSecondaryWindowTokens: number | null; + accountId: string; + accountName: string; + currentLabel: string; + currentTags: string; + currentNote: string; + currentSort: number; + currentModelSlugs: string; + currentQuotaPrimaryWindowTokens: number | null; + currentQuotaSecondaryWindowTokens: number | null; } export type DeleteDialogState = - | { kind: "single"; account: Account } - | { kind: "selected"; ids: string[]; count: number } - | null; + | { kind: "single"; account: Account } + | { kind: "selected"; ids: string[]; count: number } + | null; + +export const QUOTA_TONE_CLASSES = { + blue: { + track: "bg-blue-500/20", + indicator: "bg-blue-500", + icon: "text-blue-500", + }, + green: { + track: "bg-green-500/20", + indicator: "bg-green-500", + icon: "text-green-500", + }, + amber: { + track: "bg-amber-500/20", + indicator: "bg-amber-500", + icon: "text-amber-500", + }, +} as const; + function QuotaProgress({ - label, - remainPercent, - resetsAt, - icon: Icon, - tone, - caption, - emptyText = "--", - emptyResetText = "未知", + label, + remainPercent, + resetsAt, + icon: Icon, + tone, + caption, + emptyText = "--", + emptyResetText = "未知", }: QuotaProgressProps) { - const { t } = useI18n(); - const value = remainPercent ?? 0; - const toneClasses = { - blue: { - track: "bg-blue-500/20", - indicator: "bg-blue-500", - icon: "text-blue-500", - }, - green: { - track: "bg-green-500/20", - indicator: "bg-green-500", - icon: "text-green-500", - }, - amber: { - track: "bg-amber-500/20", - indicator: "bg-amber-500", - icon: "text-amber-500", - }, - } as const; - const palette = toneClasses[tone]; - - return ( -
-
-
-
- - {label} -
- {caption ? ( -
- {caption} -
- ) : null} -
- - {remainPercent == null ? emptyText : `${value}%`} - -
- -
- {t("重置")}: {formatTsFromSeconds(resetsAt, emptyResetText)} -
-
- ); + const { t } = useI18n(); + const value = remainPercent ?? 0; + const centerText = caption ?? label; + const showLabelOnRight = Boolean(caption); + const palette = QUOTA_TONE_CLASSES[tone]; + + return ( +
+
+
+ + {caption ? ( + + {label} + + ) : null} + + {caption ?? label} + +
+
+ {remainPercent == null ? emptyText : `${value}%`} +
+
+ +
+ {t("重置")}: {formatTsFromSeconds(resetsAt, emptyResetText)} +
+
+ ); } export function QuotaOverviewCell({ items }: { items: QuotaSummaryItem[] }) { - const { t } = useI18n(); - const summaryItems = items.slice(0, 2); - - return ( - - } className="block min-w-0 cursor-help"> -
-
- {summaryItems.map((item) => ( -
-
- - {item.label} - - - {item.remainPercent == null - ? (item.emptyText ?? "--") - : `${item.remainPercent}%`} - -
- -
- ))} -
-
- {summaryItems.map((item) => ( -
- - {formatTsFromSeconds( - item.resetsAt, - item.emptyResetText ?? t("未知"), - )} - - - {formatRemainingDurationFromSeconds( - item.resetsAt, - item.id.endsWith("-primary") ? "hours" : "days", - item.emptyResetText ?? t("未知"), - )} - {t("后刷新")} - -
- ))} -
-
-
- -
-
-

- {t("额度详情(悬停查看所有额度)")} -

-

- {t("标准额度与专属额度统一在这里查看。")} -

-
-
- {items.map((item) => ( - - ))} -
-
-
-
- ); + const { locale, t } = useI18n(); + const summaryItems = items.slice(0, 2); + + return ( + + } className="block min-w-0 cursor-help"> +
+
+ {summaryItems.map((item) => { + const palette = QUOTA_TONE_CLASSES[item.tone]; + return ( +
+
+
+ {item.icon && ( + + )} + {item.caption ? ( + + {item.label} + + ) : null} + + {item.caption ?? item.label} + +
+
+ + {item.remainPercent == null + ? (item.emptyText ?? "--") + : `${item.remainPercent}%`} + +
+
+ +
+ + {t("重置")}: {formatTsFromSeconds( + item.resetsAt, + item.emptyResetText ?? t("未知"), + )} + + + {formatRemainingDurationFromSeconds( + item.resetsAt, + item.id.endsWith("-primary") ? "hours" : "days", + item.emptyResetText ?? t("未知"), + locale, + )} + +
+
+ ); + })} +
+
+
+ +
+
+

+ {t("额度详情(悬停查看所有额度)")} +

+

+ {t("标准额度与专属额度统一在这里查看。")} +

+
+
+ {items.map((item) => ( + + ))} +
+
+
+
+ ); } export function getAccountStatusAction( - account: Account, - t: TranslateFn, + account: Account, + t: TranslateFn, ): { - action: "enable" | "disable" | null; - label: string; - icon: LucideIcon; + action: "enable" | "disable" | null; + label: string; + icon: LucideIcon; } { - const normalizedStatus = String(account.status || "") - .trim() - .toLowerCase(); - if (normalizedStatus === "disabled") { - return { action: "enable", label: t("启用账号"), icon: Power }; - } - if (normalizedStatus === "inactive") { - return { action: "enable", label: t("恢复账号"), icon: Power }; - } - if (normalizedStatus === "banned") { - return { action: null, label: t("封禁账号"), icon: PowerOff }; - } - return { action: "disable", label: t("禁用账号"), icon: PowerOff }; + const normalizedStatus = String(account.status || "") + .trim() + .toLowerCase(); + if (normalizedStatus === "disabled") { + return { action: "enable", label: t("启用账号"), icon: Power }; + } + if (normalizedStatus === "inactive") { + return { action: "enable", label: t("恢复账号"), icon: Power }; + } + if (normalizedStatus === "banned") { + return { action: null, label: t("封禁账号"), icon: PowerOff }; + } + return { action: "disable", label: t("禁用账号"), icon: PowerOff }; } export function getAccountStatusReasonCode(account: Account): string { - const reason = String(account.statusReason || "").trim(); - return reason.toLowerCase() === "usage_ok" ? "" : reason; + const reason = String(account.statusReason || "").trim(); + return reason.toLowerCase() === "usage_ok" ? "" : reason; } export function formatAccountStatusReasonLabel( - account: Account, - t: TranslateFn, + account: Account, + t: TranslateFn, ): string | null { - const reasonCode = getAccountStatusReasonCode(account); - if (!reasonCode) { - return null; - } - - const reason = reasonCode.toLowerCase(); - if (reason.startsWith("refresh_token_invalid:")) { - const detail = reason.slice("refresh_token_invalid:".length); - switch (detail) { - case "refresh_token_reused": - return t("Refresh Token 已被重复使用,需要重新登录"); - case "refresh_token_invalidated": - return t("Refresh Token 已被撤销,需要重新登录"); - case "refresh_token_expired": - return t("Refresh Token 已过期,需要重新登录"); - case "invalid_grant": - return t("Refresh Token 授权无效,需要重新登录"); - case "refresh_token_unknown_401": - return t("刷新登录凭证返回 401,需要重新登录"); - default: - return t("Refresh Token 失效,需要重新登录"); - } - } - if (reason === "refresh_token_region_blocked") { - return t("代理地区不受支持,已暂停账号刷新"); - } - - const usageHttpStatus = reason.match(/^usage_http_(\d{3})$/); - if (usageHttpStatus) { - const statusCode = usageHttpStatus[1]; - if (statusCode === "401") { - return t("用量接口返回 401,账号授权失效"); - } - if (statusCode === "403") { - return t("用量接口返回 403,账号权限不足或被限制"); - } - return t("用量接口返回 HTTP {status}", { status: statusCode }); - } - - switch (reason) { - case "account_deactivated": - return t("账号已停用"); - case "workspace_deactivated": - case "deactivated_workspace": - return t("工作区已停用"); - case "usage_limit_exhausted": - return t("额度已耗尽"); - default: - return reasonCode; - } + const reasonCode = getAccountStatusReasonCode(account); + if (!reasonCode) { + return null; + } + + const reason = reasonCode.toLowerCase(); + if (reason.startsWith("refresh_token_invalid:")) { + const detail = reason.slice("refresh_token_invalid:".length); + switch (detail) { + case "refresh_token_reused": + return t("Refresh Token 已被重复使用,需要重新登录"); + case "refresh_token_invalidated": + return t("Refresh Token 已被撤销,需要重新登录"); + case "refresh_token_expired": + return t("Refresh Token 已过期,需要重新登录"); + case "invalid_grant": + return t("Refresh Token 授权无效,需要重新登录"); + case "refresh_token_unknown_401": + return t("刷新登录凭证返回 401,需要重新登录"); + default: + return t("Refresh Token 失效,需要重新登录"); + } + } + if (reason === "refresh_token_region_blocked") { + return t("代理地区不受支持,已暂停账号刷新"); + } + + const usageHttpStatus = reason.match(/^usage_http_(\d{3})$/); + if (usageHttpStatus) { + const statusCode = usageHttpStatus[1]; + if (statusCode === "401") { + return t("用量接口返回 401,账号授权失效"); + } + if (statusCode === "403") { + return t("用量接口返回 403,账号权限不足或被限制"); + } + return t("用量接口返回 HTTP {status}", { status: statusCode }); + } + + switch (reason) { + case "account_deactivated": + return t("账号已停用"); + case "workspace_deactivated": + case "deactivated_workspace": + return t("工作区已停用"); + case "usage_limit_exhausted": + return t("额度已耗尽"); + default: + return reasonCode; + } } export function AccountStatusCell({ account }: { account: Account }) { - const { t } = useI18n(); - const statusReasonCode = getAccountStatusReasonCode(account); - const statusReasonLabel = formatAccountStatusReasonLabel(account, t); - const statusText = t(account.availabilityText || "未知"); - - return ( - - } className="block min-w-0 cursor-help"> -
-
-
- - {statusText} - -
- {statusReasonLabel ? ( - - {statusReasonLabel} - - ) : null} -
- - -
-
-
{t("当前状态")}
-
{statusText}
-
- {statusReasonLabel ? ( -
-
{t("状态原因")}
-
{statusReasonLabel}
-
- ) : null} - {statusReasonCode ? ( -
-
{t("原因码")}
-
- {statusReasonCode} -
-
- ) : null} -
-
- - ); + const { t } = useI18n(); + const statusReasonCode = getAccountStatusReasonCode(account); + const statusReasonLabel = formatAccountStatusReasonLabel(account, t); + const statusText = t(account.availabilityText || "未知"); + + return ( + + } className="block min-w-0 cursor-help"> +
+
+
+ + {statusText} + +
+ {statusReasonLabel ? ( + + {statusReasonLabel} + + ) : null} +
+ + +
+
+
+ {t("当前状态")} +
+
{statusText}
+
+ {statusReasonLabel ? ( +
+
+ {t("状态原因")} +
+
{statusReasonLabel}
+
+ ) : null} + {statusReasonCode ? ( +
+
+ {t("原因码")} +
+
+ {statusReasonCode} +
+
+ ) : null} +
+
+ + ); } export function formatAccountPlanLabel( - account: Account, - t: TranslateFn, + account: Account, + t: TranslateFn, ): string | null { - const normalized = normalizeAccountPlanKey(account); - return normalized === "unknown" - ? null - : formatAccountPlanValueLabel(normalized, t); + const normalized = normalizeAccountPlanKey(account); + return normalized === "unknown" + ? null + : formatAccountPlanValueLabel(normalized, t); } export function formatAccountSubscriptionPlanLabel( - account: Account, - t: TranslateFn, + account: Account, + t: TranslateFn, ): string { - const normalized = String(account.subscriptionPlan || account.planType || "") - .trim() - .toLowerCase(); - return normalized - ? formatAccountPlanValueLabel(normalized, t) - : t("未知"); + const normalized = String(account.subscriptionPlan || account.planType || "") + .trim() + .toLowerCase(); + return normalized ? formatAccountPlanValueLabel(normalized, t) : t("未知"); } export function formatAccountSubscriptionStatusLabel( - account: Account, - t: TranslateFn, + account: Account, + t: TranslateFn, ): string { - const hasSubscriptionEvidence = - Boolean(String(account.subscriptionPlan || "").trim()) || - account.subscriptionExpiresAt != null || - account.subscriptionRenewsAt != null; - const nowSeconds = Math.floor(Date.now() / 1000); - const isExpired = - account.subscriptionExpiresAt != null && - account.subscriptionExpiresAt < nowSeconds && - Boolean(String(account.subscriptionPlan || account.planType || "").trim()); - - if ( - account.hasSubscription === true || - (account.hasSubscription == null && hasSubscriptionEvidence) - ) { - return t("已订阅"); - } - if (isExpired) { - return t("已过期"); - } - if (account.hasSubscription === false) { - return t("未订阅"); - } - return t("未知"); + const hasSubscriptionEvidence = + Boolean(String(account.subscriptionPlan || "").trim()) || + account.subscriptionExpiresAt != null || + account.subscriptionRenewsAt != null; + const nowSeconds = Math.floor(Date.now() / 1000); + const isExpired = + account.subscriptionExpiresAt != null && + account.subscriptionExpiresAt < nowSeconds && + Boolean(String(account.subscriptionPlan || account.planType || "").trim()); + + if ( + account.hasSubscription === true || + (account.hasSubscription == null && hasSubscriptionEvidence) + ) { + return t("已订阅"); + } + if (isExpired) { + return t("已过期"); + } + if (account.hasSubscription === false) { + return t("未订阅"); + } + return t("未知"); } export function getAccountPlanBadgeClassName(planLabel: string | null): string { - switch (planLabel) { - case "FREE": - return "bg-slate-500/10 text-slate-700 dark:text-slate-300"; - case "GO": - return "bg-sky-500/10 text-sky-700 dark:text-sky-300"; - case "PLUS": - return "bg-amber-500/10 text-amber-700 dark:text-amber-300"; - case "PRO": - return "bg-fuchsia-500/10 text-fuchsia-700 dark:text-fuchsia-300"; - case "TEAM": - return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; - case "BUSINESS": - return "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300"; - case "ENTERPRISE": - return "bg-rose-500/10 text-rose-700 dark:text-rose-300"; - case "EDU": - return "bg-cyan-500/10 text-cyan-700 dark:text-cyan-300"; - default: - return "bg-accent/50"; - } + switch (planLabel) { + case "FREE": + return "bg-slate-500/10 text-slate-700 dark:text-slate-300"; + case "GO": + return "bg-sky-500/10 text-sky-700 dark:text-sky-300"; + case "PLUS": + return "bg-amber-500/10 text-amber-700 dark:text-amber-300"; + case "PRO": + return "bg-fuchsia-500/10 text-fuchsia-700 dark:text-fuchsia-300"; + case "TEAM": + return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; + case "BUSINESS": + return "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300"; + case "ENTERPRISE": + return "bg-rose-500/10 text-rose-700 dark:text-rose-300"; + case "EDU": + return "bg-cyan-500/10 text-cyan-700 dark:text-cyan-300"; + default: + return "bg-accent/50"; + } } export function formatAccountTags(tags: string[]): string { - return tags - .map((tag) => String(tag || "").trim()) - .filter(Boolean) - .join("、"); + return tags + .map((tag) => String(tag || "").trim()) + .filter(Boolean) + .join("、"); } export function normalizeTagsDraft(tagsDraft: string): string[] { - return tagsDraft - .split(",") - .map((tag) => tag.trim()) - .filter(Boolean); + return tagsDraft + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); } export function buildAccountOrderUpdates(orderedAccounts: Account[]) { - return orderedAccounts.reduce>( - (updates, account, index) => { - const nextSort = index * ACCOUNT_SORT_STEP; - const currentSort = Number.isFinite(account.priority) - ? account.priority - : Number(account.sort) || 0; - if (currentSort !== nextSort) { - updates.push({ accountId: account.id, sort: nextSort }); - } - return updates; - }, - [], - ); + return orderedAccounts.reduce>( + (updates, account, index) => { + const nextSort = index * ACCOUNT_SORT_STEP; + const currentSort = Number.isFinite(account.priority) + ? account.priority + : Number(account.sort) || 0; + if (currentSort !== nextSort) { + updates.push({ accountId: account.id, sort: nextSort }); + } + return updates; + }, + [], + ); } export function getAccountSizeGroup( - account: Account, + account: Account, ): "large" | "standard" | "small" { - switch (normalizeAccountPlanKey(account)) { - case "plus": - case "pro": - case "team": - case "business": - case "enterprise": - return "large"; - case "free": - return "small"; - default: - return "standard"; - } + switch (normalizeAccountPlanKey(account)) { + case "plus": + case "pro": + case "team": + case "business": + case "enterprise": + return "large"; + case "free": + return "small"; + default: + return "standard"; + } } export function buildAccountsBySizeOrder( - orderedAccounts: Account[], - mode: AccountSizeSortMode, + orderedAccounts: Account[], + mode: AccountSizeSortMode, ) { - const buckets = { - large: [] as Account[], - standard: [] as Account[], - small: [] as Account[], - }; - - for (const account of orderedAccounts) { - buckets[getAccountSizeGroup(account)].push(account); - } - - return mode === "large-first" - ? [...buckets.large, ...buckets.standard, ...buckets.small] - : [...buckets.small, ...buckets.standard, ...buckets.large]; + const buckets = { + large: [] as Account[], + standard: [] as Account[], + small: [] as Account[], + }; + + for (const account of orderedAccounts) { + buckets[getAccountSizeGroup(account)].push(account); + } + + return mode === "large-first" + ? [...buckets.large, ...buckets.standard, ...buckets.small] + : [...buckets.small, ...buckets.standard, ...buckets.large]; } export function formatAccountExportModeLabel(value: string, t: TranslateFn) { - return value === "single" ? t("单 JSON") : t("多 JSON"); + return value === "single" ? t("单 JSON") : t("多 JSON"); } export function buildQuotaSummaryItems( - account: Account, - t: TranslateFn, + account: Account, + t: TranslateFn, ): QuotaSummaryItem[] { - const primaryWindowOnly = isPrimaryWindowOnlyUsage(account.usage); - const secondaryWindowOnly = isSecondaryWindowOnlyUsage(account.usage); - const usageBuckets = getUsageDisplayBuckets(account.usage); - const extraUsageRows = getExtraUsageDisplayRows(account.usage); - return [ - { - id: `${account.id}-primary`, - label: t("5小时"), - remainPercent: account.primaryRemainPercent, - resetsAt: usageBuckets.primaryResetsAt, - icon: RefreshCw, - tone: "green", - caption: t("标准模型窗口"), - emptyText: secondaryWindowOnly ? t("未提供") : "--", - emptyResetText: secondaryWindowOnly ? t("未提供") : t("未知"), - }, - { - id: `${account.id}-secondary`, - label: t("7天"), - remainPercent: account.secondaryRemainPercent, - resetsAt: usageBuckets.secondaryResetsAt, - icon: RefreshCw, - tone: "blue", - caption: t("长周期窗口"), - emptyText: primaryWindowOnly ? t("未提供") : "--", - emptyResetText: primaryWindowOnly ? t("未提供") : t("未知"), - }, - ...extraUsageRows.map((item) => ({ - id: item.id, - label: `${t(item.label, item.labelValues)}${item.labelSuffix ? t(item.labelSuffix) : ""}`, - remainPercent: item.remainPercent, - resetsAt: item.resetsAt, - icon: Zap, - tone: "amber" as const, - caption: t(item.windowLabel, item.windowLabelValues), - emptyText: "--", - emptyResetText: t("未知"), - })), - ]; + const primaryWindowOnly = isPrimaryWindowOnlyUsage(account.usage); + const secondaryWindowOnly = isSecondaryWindowOnlyUsage(account.usage); + const usageBuckets = getUsageDisplayBuckets(account.usage); + const extraUsageRows = getExtraUsageDisplayRows(account.usage); + return [ + { + id: `${account.id}-primary`, + label: t("5小时"), + remainPercent: account.primaryRemainPercent, + resetsAt: usageBuckets.primaryResetsAt, + icon: RefreshCw, + tone: "green", + caption: t("标准模型窗口"), + emptyText: secondaryWindowOnly ? t("未提供") : "--", + emptyResetText: secondaryWindowOnly ? t("未提供") : t("未知"), + }, + { + id: `${account.id}-secondary`, + label: t("7天"), + remainPercent: account.secondaryRemainPercent, + resetsAt: usageBuckets.secondaryResetsAt, + icon: RefreshCw, + tone: "blue", + caption: t("长周期窗口"), + emptyText: primaryWindowOnly ? t("未提供") : "--", + emptyResetText: primaryWindowOnly ? t("未提供") : t("未知"), + }, + ...extraUsageRows.map((item) => ({ + id: item.id, + label: `${t(item.label, item.labelValues)}${item.labelSuffix ? t(item.labelSuffix) : ""}`, + remainPercent: item.remainPercent, + resetsAt: item.resetsAt, + icon: Zap, + tone: "amber" as const, + caption: t(item.windowLabel, item.windowLabelValues), + emptyText: "--", + emptyResetText: t("未知"), + })), + ]; } export function AccountInfoCell({ - account, - isPreferred, + account, + isPreferred, }: { - account: Account; - isPreferred: boolean; + account: Account; + isPreferred: boolean; }) { - const { t } = useI18n(); - const accountPlanLabel = formatAccountPlanLabel(account, t); - const subscriptionStatusLabel = formatAccountSubscriptionStatusLabel(account, t); - const subscriptionPlanLabel = formatAccountSubscriptionPlanLabel(account, t); - const subscriptionExpiryText = - account.subscriptionExpiresAt != null - ? formatTsFromSeconds(account.subscriptionExpiresAt, t("未知")) - : account.hasSubscription === false - ? t("未订阅") - : t("未知"); - const statusReasonCode = getAccountStatusReasonCode(account); - const statusReasonLabel = formatAccountStatusReasonLabel(account, t); - const tagsText = formatAccountTags(account.tags); - const noteText = String(account.note || "").trim(); - - return ( - - } - className="block min-w-0 max-w-full cursor-help text-left" - > -
-
- - {account.name} - - {accountPlanLabel ? ( - - {accountPlanLabel} - - ) : null} - {isPreferred ? ( - - {t("优先")} - - ) : null} -
- - {account.id} - - - {t("最近刷新")}:{" "} - {formatTsFromSeconds(account.lastRefreshAt, t("从未刷新"))} - - - {t("订阅到期")}: {subscriptionExpiryText} - -
-
- -
-
-
-
- {t("账号类型")} -
-
{accountPlanLabel || t("未知")}
-
-
-
- {t("当前状态")} -
-
- {t(account.availabilityText || "未知")} -
-
- {statusReasonLabel ? ( -
-
- {t("状态原因")} -
-
{statusReasonLabel}
- {statusReasonCode ? ( -
- {statusReasonCode} -
- ) : null} -
- ) : null} -
-
- {t("订阅状态")} -
-
{subscriptionStatusLabel}
-
-
-
- {t("订阅方案")} -
-
{subscriptionPlanLabel}
-
-
-
-
-
- {t("到期时间")} -
-
- {formatTsFromSeconds(account.subscriptionExpiresAt, t("未知"))} -
-
-
-
- {t("续费时间")} -
-
- {formatTsFromSeconds(account.subscriptionRenewsAt, t("未知"))} -
-
-
-
-
{t("标签")}
-
{tagsText || t("未设置")}
-
-
-
{t("备注")}
-
- {noteText || t("未设置")} -
-
-
-
{t("账号 ID")}
-
{account.id}
-
-
-
-
- ); + const { t } = useI18n(); + const accountPlanLabel = formatAccountPlanLabel(account, t); + const subscriptionStatusLabel = formatAccountSubscriptionStatusLabel( + account, + t, + ); + const subscriptionPlanLabel = formatAccountSubscriptionPlanLabel(account, t); + const subscriptionExpiryText = + account.subscriptionExpiresAt != null + ? formatTsFromSeconds(account.subscriptionExpiresAt, t("未知")) + : account.hasSubscription === false + ? t("未订阅") + : t("未知"); + const statusReasonCode = getAccountStatusReasonCode(account); + const statusReasonLabel = formatAccountStatusReasonLabel(account, t); + const tagsText = formatAccountTags(account.tags); + const noteText = String(account.note || "").trim(); + + return ( + + } + className="block min-w-0 max-w-full cursor-help text-left" + > +
+
+ + {account.name} + + {accountPlanLabel ? ( + + {accountPlanLabel} + + ) : null} + {isPreferred ? ( + + {t("优先")} + + ) : null} +
+ + {account.id} + + + {t("最近刷新")}:{" "} + {formatTsFromSeconds(account.lastRefreshAt, t("从未刷新"))} + + + {t("订阅到期")}: {subscriptionExpiryText} + +
+
+ +
+
+
+
+ {t("账号类型")} +
+
{accountPlanLabel || t("未知")}
+
+
+
+ {t("当前状态")} +
+
+ {t(account.availabilityText || "未知")} +
+
+ {statusReasonLabel ? ( +
+
+ {t("状态原因")} +
+
{statusReasonLabel}
+ {statusReasonCode ? ( +
+ {statusReasonCode} +
+ ) : null} +
+ ) : null} +
+
+ {t("订阅状态")} +
+
{subscriptionStatusLabel}
+
+
+
+ {t("订阅方案")} +
+
{subscriptionPlanLabel}
+
+
+
+
+
+ {t("到期时间")} +
+
+ {formatTsFromSeconds(account.subscriptionExpiresAt, t("未知"))} +
+
+
+
+ {t("续费时间")} +
+
+ {formatTsFromSeconds(account.subscriptionRenewsAt, t("未知"))} +
+
+
+
+
{t("标签")}
+
{tagsText || t("未设置")}
+
+
+
{t("备注")}
+
+ {noteText || t("未设置")} +
+
+
+
{t("账号 ID")}
+
{account.id}
+
+
+
+
+ ); } diff --git a/apps/src/app/accounts/accounts-page-view.tsx b/apps/src/app/accounts/accounts-page-view.tsx index 36c516cf2..17b26a0c1 100644 --- a/apps/src/app/accounts/accounts-page-view.tsx +++ b/apps/src/app/accounts/accounts-page-view.tsx @@ -2,23 +2,24 @@ import type { Dispatch, SetStateAction } from "react"; import { - ArrowDown, - ArrowUp, - ArrowUpDown, - BarChart3, - Download, - FileUp, - FolderOpen, - KeyRound, - Loader2, - MoreVertical, - PencilLine, - Pin, - Plus, - RefreshCw, - Search, - Trash2, - Zap, + ArrowDown, + ArrowUp, + ArrowUpDown, + BarChart3, + Download, + FileUp, + FolderOpen, + KeyRound, + Loader2, + MoreVertical, + Network, + PencilLine, + Pin, + Plus, + RefreshCw, + Search, + Trash2, + Zap, } from "lucide-react"; import { AddAccountModal } from "@/components/modals/add-account-modal"; import { ConfirmDialog } from "@/components/modals/confirm-dialog"; @@ -27,1315 +28,1593 @@ import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { - Tooltip, - TooltipContent, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/ui/tooltip"; import { useI18n } from "@/lib/i18n/provider"; import { cn } from "@/lib/utils"; import { formatCompactNumber } from "@/lib/utils/usage"; +import type { AccountProxySettings } from "@/lib/api/account-client"; import type { Account } from "@/types"; +import { Badge } from "@/components/ui/badge"; +import { AccountProxyCell } from "@/components/accounts/account-proxy-cell"; +import { AccountProxyGeoStatusGrid } from "@/components/accounts/account-proxy-status-grid"; import { - type AccountEditorState, - type AccountExportMode, - type AccountSizeSortMode, - type DeleteDialogState, - type StatusFilter, - AccountInfoCell, - AccountStatusCell, - QuotaOverviewCell, - buildQuotaSummaryItems, - formatAccountExportModeLabel, - formatAccountPlanLabel, - formatAccountPlanValueLabel, - fitLongTextClassName, - formatPlanFilterLabel, - formatStatusFilterLabel, - getAccountStatusAction, + type AccountEditorState, + type AccountExportMode, + type AccountSizeSortMode, + type DeleteDialogState, + type StatusFilter, + AccountInfoCell, + AccountStatusCell, + QuotaOverviewCell, + buildQuotaSummaryItems, + formatAccountExportModeLabel, + formatAccountPlanLabel, + formatAccountPlanValueLabel, + fitLongTextClassName, + formatPlanFilterLabel, + formatStatusFilterLabel, + getAccountStatusAction, + getAccountPlanBadgeClassName, } from "@/app/accounts/accounts-page-helpers"; interface PlanTypeOption { - value: string; - count: number; + value: string; + count: number; } interface StatusFilterOption { - id: StatusFilter; - label: string; + id: StatusFilter; + label: string; } interface CleanupStatusOption { - id: string; - label: string; - description: string; - count: number; + id: string; + label: string; + description: string; + count: number; } export interface AccountsPageViewProps { - accounts: Account[]; - planTypes: PlanTypeOption[]; - isLoading: boolean; - isServiceReady: boolean; - isPageActive: boolean; - search: string; - planFilter: string; - statusFilter: StatusFilter; - pageSize: string; - safePage: number; - totalPages: number; - filteredAccounts: Account[]; - visibleAccounts: Account[]; - filteredAccountIndexMap: Map; - effectiveSelectedIds: string[]; - addAccountModalOpen: boolean; - usageModalOpen: boolean; - exportDialogOpen: boolean; - exportModeDraft: AccountExportMode; - exportTargetCount: number; - exportScopeText: string; - cleanupDialogOpen: boolean; - cleanupStatusDraft: string[]; - cleanupStatusOptions: CleanupStatusOption[]; - selectedAccount: Account | null; - accountEditorState: AccountEditorState | null; - deleteDialogState: DeleteDialogState; - currentEditingAccount: Account | null; - labelDraft: string; - tagsDraft: string; - noteDraft: string; - sortDraft: string; - modelWhitelistDraft: string; - quotaPrimaryDraft: string; - quotaSecondaryDraft: string; - isRefreshingAllAccounts: boolean; - isRefreshingAccountId: string | null; - isRefreshingRtAccountId: string | null; - isRefreshingAllRtAccounts: boolean; - isExporting: boolean; - isWarmingUpAccounts: boolean; - isDeletingMany: boolean; - isCleaningAccountsByStatus: boolean; - isUpdatingPreferred: boolean; - isReorderingAccounts: boolean; - isUpdatingProfileAccountId: string | null; - isUpdatingStatusAccountId: string | null; - statusFilterOptions: StatusFilterOption[]; - importFileActionLabel: string; - importDirectoryActionLabel: string; - exportActionLabel: string; - exportActionShortcut: string; - setAddAccountModalOpen: Dispatch>; - setExportDialogOpen: Dispatch>; - setExportModeDraft: Dispatch>; - setDeleteDialogState: Dispatch>; - setCleanupDialogOpen: Dispatch>; - setAccountEditorState: Dispatch>; - setLabelDraft: Dispatch>; - setTagsDraft: Dispatch>; - setNoteDraft: Dispatch>; - setSortDraft: Dispatch>; - setModelWhitelistDraft: Dispatch>; - setQuotaPrimaryDraft: Dispatch>; - setQuotaSecondaryDraft: Dispatch>; - setPage: Dispatch>; - handleSearchChange: (value: string) => void; - handlePlanFilterChange: (value: string | null) => void; - handleStatusFilterChange: (value: StatusFilter) => void; - handlePageSizeChange: (value: string | null) => void; - toggleSelect: (id: string) => void; - toggleSelectAllVisible: () => void; - openUsage: (account: Account) => void; - handleUsageModalOpenChange: (open: boolean) => void; - handleDeleteSelected: () => void; - openCleanupDialog: () => void; - toggleCleanupStatus: (status: string) => void; - handleConfirmCleanupStatuses: () => Promise; - handleWarmupAccounts: () => Promise; - openExportDialog: () => void; - handleConfirmExport: () => Promise; - handleDeleteSingle: (account: Account) => void; - openAccountEditor: (account: Account) => void; - handleMoveAccount: ( - account: Account, - direction: "up" | "down", - ) => Promise; - handleApplyAccountSizeSort: (mode: AccountSizeSortMode) => Promise; - handleConfirmAccountEditor: () => Promise; - handleConfirmDelete: () => void; - refreshAllAccounts: () => void; - refreshAllAccountRt: () => void; - refreshAccountList: () => void; - refreshAccountRt: (accountId: string) => void; - importByFile: () => void; - importByDirectory: () => void; - refreshAccount: (accountId: string) => void; - clearPreferredAccount: (accountId: string) => void; - setPreferredAccount: (accountId: string) => void; - toggleAccountStatus: ( - accountId: string, - enabled: boolean, - currentStatus: string, - ) => void; + accounts: Account[]; + planTypes: PlanTypeOption[]; + isLoading: boolean; + isServiceReady: boolean; + isPageActive: boolean; + search: string; + planFilter: string; + statusFilter: StatusFilter; + pageSize: string; + safePage: number; + totalPages: number; + filteredAccounts: Account[]; + visibleAccounts: Account[]; + filteredAccountIndexMap: Map; + effectiveSelectedIds: string[]; + addAccountModalOpen: boolean; + usageModalOpen: boolean; + exportDialogOpen: boolean; + exportModeDraft: AccountExportMode; + exportTargetCount: number; + exportScopeText: string; + cleanupDialogOpen: boolean; + cleanupStatusDraft: string[]; + cleanupStatusOptions: CleanupStatusOption[]; + proxyDialogAccount: Account | null; + proxySettings: AccountProxySettings | null; + isProxySettingsLoading: boolean; + proxyEnabledDraft: boolean; + proxyUrlDraft: string; + selectedAccount: Account | null; + accountEditorState: AccountEditorState | null; + deleteDialogState: DeleteDialogState; + currentEditingAccount: Account | null; + labelDraft: string; + tagsDraft: string; + noteDraft: string; + sortDraft: string; + modelWhitelistDraft: string; + quotaPrimaryDraft: string; + quotaSecondaryDraft: string; + isRefreshingAllAccounts: boolean; + isRefreshingAccountId: string | null; + isRefreshingRtAccountId: string | null; + isRefreshingAllRtAccounts: boolean; + isExporting: boolean; + isWarmingUpAccounts: boolean; + isDeletingMany: boolean; + isCleaningAccountsByStatus: boolean; + isUpdatingPreferred: boolean; + isSavingAccountProxy: boolean; + isClearingAccountProxy: boolean; + isTestingAccountProxy: boolean; + isReorderingAccounts: boolean; + isUpdatingProfileAccountId: string | null; + isUpdatingStatusAccountId: string | null; + statusFilterOptions: StatusFilterOption[]; + importFileActionLabel: string; + importDirectoryActionLabel: string; + exportActionLabel: string; + exportActionShortcut: string; + setAddAccountModalOpen: Dispatch>; + setExportDialogOpen: Dispatch>; + setExportModeDraft: Dispatch>; + setDeleteDialogState: Dispatch>; + setCleanupDialogOpen: Dispatch>; + setProxyEnabledDraft: Dispatch>; + setProxyUrlDraft: Dispatch>; + setAccountEditorState: Dispatch>; + setLabelDraft: Dispatch>; + setTagsDraft: Dispatch>; + setNoteDraft: Dispatch>; + setSortDraft: Dispatch>; + setModelWhitelistDraft: Dispatch>; + setQuotaPrimaryDraft: Dispatch>; + setQuotaSecondaryDraft: Dispatch>; + setPage: Dispatch>; + handleSearchChange: (value: string) => void; + handlePlanFilterChange: (value: string | null) => void; + handleStatusFilterChange: (value: StatusFilter) => void; + handlePageSizeChange: (value: string | null) => void; + toggleSelect: (id: string) => void; + toggleSelectAllVisible: () => void; + openUsage: (account: Account) => void; + handleUsageModalOpenChange: (open: boolean) => void; + handleDeleteSelected: () => void; + openCleanupDialog: () => void; + toggleCleanupStatus: (status: string) => void; + handleConfirmCleanupStatuses: () => Promise; + handleWarmupAccounts: () => Promise; + openExportDialog: () => void; + handleConfirmExport: () => Promise; + handleDeleteSingle: (account: Account) => void; + openProxyDialog: (account: Account) => void; + handleProxyDialogOpenChange: (open: boolean) => void; + handleSaveProxySettings: () => Promise; + handleClearProxySettings: () => Promise; + handleTestProxySettings: () => Promise; + openAccountEditor: (account: Account) => void; + handleMoveAccount: ( + account: Account, + direction: "up" | "down", + ) => Promise; + handleApplyAccountSizeSort: (mode: AccountSizeSortMode) => Promise; + handleConfirmAccountEditor: () => Promise; + handleConfirmDelete: () => void; + refreshAllAccounts: () => void; + refreshAllAccountRt: () => void; + refreshAccountList: () => void; + refreshAccountRt: (accountId: string) => void; + importByFile: () => void; + importByDirectory: () => void; + refreshAccount: (accountId: string) => void; + clearPreferredAccount: (accountId: string) => void; + setPreferredAccount: (accountId: string) => void; + toggleAccountStatus: ( + accountId: string, + enabled: boolean, + currentStatus: string, + ) => void; } export function AccountsPageView(props: AccountsPageViewProps) { - const { t } = useI18n(); - const { - accounts, - planTypes, - isLoading, - isServiceReady, - isPageActive, - search, - planFilter, - statusFilter, - pageSize, - safePage, - totalPages, - filteredAccounts, - visibleAccounts, - filteredAccountIndexMap, - effectiveSelectedIds, - addAccountModalOpen, - usageModalOpen, - exportDialogOpen, - exportModeDraft, - exportTargetCount, - exportScopeText, - cleanupDialogOpen, - cleanupStatusDraft, - cleanupStatusOptions, - selectedAccount, - accountEditorState, - deleteDialogState, - currentEditingAccount, - labelDraft, - tagsDraft, - noteDraft, - sortDraft, - modelWhitelistDraft, - quotaPrimaryDraft, - quotaSecondaryDraft, - isRefreshingAllAccounts, - isRefreshingAccountId, - isRefreshingRtAccountId, - isRefreshingAllRtAccounts, - isExporting, - isWarmingUpAccounts, - isDeletingMany, - isCleaningAccountsByStatus, - isUpdatingPreferred, - isReorderingAccounts, - isUpdatingProfileAccountId, - isUpdatingStatusAccountId, - statusFilterOptions, - importFileActionLabel, - importDirectoryActionLabel, - exportActionLabel, - exportActionShortcut, - setAddAccountModalOpen, - setExportDialogOpen, - setExportModeDraft, - setDeleteDialogState, - setCleanupDialogOpen, - setAccountEditorState, - setLabelDraft, - setTagsDraft, - setNoteDraft, - setSortDraft, - setModelWhitelistDraft, - setQuotaPrimaryDraft, - setQuotaSecondaryDraft, - setPage, - handleSearchChange, - handlePlanFilterChange, - handleStatusFilterChange, - handlePageSizeChange, - toggleSelect, - toggleSelectAllVisible, - openUsage, - handleUsageModalOpenChange, - handleDeleteSelected, - openCleanupDialog, - toggleCleanupStatus, - handleConfirmCleanupStatuses, - handleWarmupAccounts, - openExportDialog, - handleConfirmExport, - handleDeleteSingle, - openAccountEditor, - handleMoveAccount, - handleApplyAccountSizeSort, - handleConfirmAccountEditor, - handleConfirmDelete, - refreshAllAccounts, - refreshAllAccountRt, - refreshAccountList, - refreshAccountRt, - importByFile, - importByDirectory, - refreshAccount, - clearPreferredAccount, - setPreferredAccount, - toggleAccountStatus, - } = props; - const cleanupSelectedCount = cleanupStatusOptions.reduce( - (total, option) => - cleanupStatusDraft.includes(option.id) ? total + option.count : total, - 0, - ); + const { t } = useI18n(); + const { + accounts, + planTypes, + isLoading, + isServiceReady, + isPageActive, + search, + planFilter, + statusFilter, + pageSize, + safePage, + totalPages, + filteredAccounts, + visibleAccounts, + filteredAccountIndexMap, + effectiveSelectedIds, + addAccountModalOpen, + usageModalOpen, + exportDialogOpen, + exportModeDraft, + exportTargetCount, + exportScopeText, + cleanupDialogOpen, + cleanupStatusDraft, + cleanupStatusOptions, + proxyDialogAccount, + proxySettings, + isProxySettingsLoading, + proxyEnabledDraft, + proxyUrlDraft, + selectedAccount, + accountEditorState, + deleteDialogState, + currentEditingAccount, + labelDraft, + tagsDraft, + noteDraft, + sortDraft, + modelWhitelistDraft, + quotaPrimaryDraft, + quotaSecondaryDraft, + isRefreshingAllAccounts, + isRefreshingAccountId, + isRefreshingRtAccountId, + isRefreshingAllRtAccounts, + isExporting, + isWarmingUpAccounts, + isDeletingMany, + isCleaningAccountsByStatus, + isUpdatingPreferred, + isSavingAccountProxy, + isClearingAccountProxy, + isTestingAccountProxy, + isReorderingAccounts, + isUpdatingProfileAccountId, + isUpdatingStatusAccountId, + statusFilterOptions, + importFileActionLabel, + importDirectoryActionLabel, + exportActionLabel, + exportActionShortcut, + setAddAccountModalOpen, + setExportDialogOpen, + setExportModeDraft, + setDeleteDialogState, + setCleanupDialogOpen, + setProxyEnabledDraft, + setProxyUrlDraft, + setAccountEditorState, + setLabelDraft, + setTagsDraft, + setNoteDraft, + setSortDraft, + setModelWhitelistDraft, + setQuotaPrimaryDraft, + setQuotaSecondaryDraft, + setPage, + handleSearchChange, + handlePlanFilterChange, + handleStatusFilterChange, + handlePageSizeChange, + toggleSelect, + toggleSelectAllVisible, + openUsage, + handleUsageModalOpenChange, + handleDeleteSelected, + openCleanupDialog, + toggleCleanupStatus, + handleConfirmCleanupStatuses, + handleWarmupAccounts, + openExportDialog, + handleConfirmExport, + handleDeleteSingle, + openProxyDialog, + handleProxyDialogOpenChange, + handleSaveProxySettings, + handleClearProxySettings, + handleTestProxySettings, + openAccountEditor, + handleMoveAccount, + handleApplyAccountSizeSort, + handleConfirmAccountEditor, + handleConfirmDelete, + refreshAllAccounts, + refreshAllAccountRt, + refreshAccountList, + refreshAccountRt, + importByFile, + importByDirectory, + refreshAccount, + clearPreferredAccount, + setPreferredAccount, + toggleAccountStatus, + } = props; + const cleanupSelectedCount = cleanupStatusOptions.reduce( + (total, option) => + cleanupStatusDraft.includes(option.id) ? total + option.count : total, + 0, + ); + const accountProxyBusy = + isProxySettingsLoading || + isSavingAccountProxy || + isClearingAccountProxy || + isTestingAccountProxy; + const accountProxyStatusText = (() => { + const status = String(proxySettings?.status || "not_configured"); + switch (status) { + case "ok": + return t("可用"); + case "runtime_error": + return t("运行时错误"); + case "failed": + return t("测试失败"); + case "invalid_url": + return t("地址无效"); + case "checking": + return t("测试中"); + case "unchecked": + return t("未测试"); + case "not_configured": + default: + return t("未配置"); + } + })(); + const accountProxyStatusColorClass = (() => { + const status = String(proxySettings?.status || "not_configured"); + switch (status) { + case "ok": + return "text-green-600 dark:text-green-400"; + case "checking": + return "text-yellow-600 dark:text-yellow-400"; + case "unchecked": + return "text-orange-600 dark:text-orange-400"; + case "failed": + case "runtime_error": + case "invalid_url": + return "text-red-600 dark:text-red-400"; + case "not_configured": + default: + return "text-muted-foreground"; + } + })(); + const accountProxyLastCheckText = + proxySettings?.lastCheckAt != null + ? new Date(proxySettings.lastCheckAt * 1000).toLocaleString() + : t("从未检查"); - return ( -
- {!isServiceReady ? ( - - - {t( - "服务未连接,账号列表与相关操作暂不可用;连接恢复后会自动继续加载。", - )} - - - ) : null} + return ( +
+ {!isServiceReady ? ( + + + {t( + "服务未连接,账号列表与相关操作暂不可用;连接恢复后会自动继续加载。", + )} + + + ) : null} - - -
- handleSearchChange(event.target.value)} - /> -
+ + +
+ handleSearchChange(event.target.value)} + /> +
-
- - -
+
+ + +
-
+
-
- - } className="inline-flex"> - - - - {t( - "向选中账号发送 hi 进行预热;如果未选中账号,则默认预热全部账号。", - )} - - - - - - - - - - {t("刷新")} - - - - {t("刷新账号用量")} - ALL - - - - {t("刷新全部 AT/RT")} - RT - - - - {t("刷新列表")} - LIST - - - - - - {t("号池管理")} - - setAddAccountModalOpen(true)} - > - {t("添加账号")} - - - {importFileActionLabel} - FILE - - - - {importDirectoryActionLabel} - DIR - - - - {exportActionLabel} - - {exportActionShortcut} - - - - - - - {t("排序")} - - void handleApplyAccountSizeSort("large-first")} - > - - {t("大号优先排序")} - BIZ - - void handleApplyAccountSizeSort("small-first")} - > - - {t("小号优先排序")} - FREE - - - - - - {t("清理")} - - - {t("删除选中账号")} - - {effectiveSelectedIds.length || "-"} - - - - {t("按状态清理账号")} - - - - -
- - +
+ + } className="inline-flex"> + + + + {t( + "向选中账号发送 hi 进行预热;如果未选中账号,则默认预热全部账号。", + )} + + + + + + + + + + {t("刷新")} + + + + {t("刷新账号用量")} + ALL + + + + {t("刷新全部 AT/RT")} + RT + + + + {t("刷新列表")} + LIST + + + + + + {t("号池管理")} + + setAddAccountModalOpen(true)} + > + {t("添加账号")} + + + {importFileActionLabel} + FILE + + + + {importDirectoryActionLabel} + DIR + + + + {exportActionLabel} + + {exportActionShortcut} + + + + + + + {t("排序")} + + + void handleApplyAccountSizeSort("large-first") + } + > + + {t("大号优先排序")} + BIZ + + + void handleApplyAccountSizeSort("small-first") + } + > + + {t("小号优先排序")} + FREE + + + + + + {t("清理")} + + + {t("删除选中账号")} + + {effectiveSelectedIds.length || "-"} + + + + {t("按状态清理账号")} + + + + +
+ + - - - - {t("导出账号")} - - {t("选择导出方式;如果已勾选账号,则只导出当前选中项。")} - - -
-
- {exportScopeText} -
-
- - -
-
- {exportModeDraft === "single" - ? t( - "导出为一个 `accounts.json` 数组文件,适合整体备份和再次导入。", - ) - : t( - "每个账号导出为一个独立 JSON 文件,适合逐个分发或单独管理。", - )} -
-
-
-
- - - {t("取消")} - - - -
-
+ + + + {t("导出账号")} + + {t("选择导出方式;如果已勾选账号,则只导出当前选中项。")} + + +
+
+ {exportScopeText} +
+
+ + +
+
+ {exportModeDraft === "single" + ? t( + "导出为一个 `accounts.json` 数组文件,适合整体备份和再次导入。", + ) + : t( + "每个账号导出为一个独立 JSON 文件,适合逐个分发或单独管理。", + )} +
+
+
+
+ + + {t("取消")} + + + +
+
- { - if (!isCleaningAccountsByStatus) { - setCleanupDialogOpen(open); - } - }} - > - - - {t("按状态清理账号")} - - {t("选择要删除的账号状态;删除后不可恢复。")} - - -
-
- {t("将删除所有匹配所选状态的账号,不再额外限制账号套餐。")} -
-
- {cleanupStatusOptions.map((option) => { - const checked = cleanupStatusDraft.includes(option.id); - return ( -
- toggleCleanupStatus(option.id)} - aria-label={option.label} - /> -
-
- - {option.label} - - - {option.count} - -
-

- {option.description} -

-
-
- ); - })} -
-
- {t("预计删除")}{" "} - - {cleanupSelectedCount} - {" "} - {t("个账号")} -
-
- - - {t("取消")} - - - -
-
+ { + if (!isCleaningAccountsByStatus) { + setCleanupDialogOpen(open); + } + }} + > + + + {t("按状态清理账号")} + + {t("选择要删除的账号状态;删除后不可恢复。")} + + +
+
+ {t("将删除所有匹配所选状态的账号,不再额外限制账号套餐。")} +
+
+ {cleanupStatusOptions.map((option) => { + const checked = cleanupStatusDraft.includes(option.id); + return ( +
+ toggleCleanupStatus(option.id)} + aria-label={option.label} + /> +
+
+ + {option.label} + + + {option.count} + +
+

+ {option.description} +

+
+
+ ); + })} +
+
+ {t("预计删除")}{" "} + + {cleanupSelectedCount} + {" "} + {t("个账号")} +
+
+ + + {t("取消")} + + + +
+
- - - - - - - 0 && - visibleAccounts.every((account) => - effectiveSelectedIds.includes(account.id), - ) - } - onCheckedChange={toggleSelectAllVisible} - /> - - - {t("账号信息")} - - - {t("额度详情")} - - {t("顺序")} - {t("状态")} - - {t("操作")} - - - - - {isLoading ? ( - Array.from({ length: 5 }).map((_, index) => ( - - - - - - - - -
- - - -
-
- - - - - - - - - -
- )) - ) : visibleAccounts.length === 0 ? ( - - -
- -

{t("未找到符合条件的账号")}

-
-
-
- ) : ( - visibleAccounts.map((account) => { - const quotaItems = buildQuotaSummaryItems(account, t); - const statusAction = getAccountStatusAction(account, t); - const StatusActionIcon = statusAction.icon; - const modelPoolText = account.modelSlugs.length - ? account.modelSlugs.slice(0, 2).join(", ") - : t("全部 API 模型"); - const modelPoolDisplayText = `${t("模型池")}: ${modelPoolText}${ - account.modelSlugs.length > 2 - ? ` +${account.modelSlugs.length - 2}` - : "" - }`; - const isRefreshingCurrentAccount = - isRefreshingAccountId === account.id; - const isRefreshingCurrentRt = - isRefreshingRtAccountId === account.id; - const filteredIndex = - filteredAccountIndexMap.get(account.id) ?? -1; - const canMoveUp = filteredIndex > 0; - const canMoveDown = - filteredIndex !== -1 && - filteredIndex < filteredAccounts.length - 1; - return ( - - - toggleSelect(account.id)} - /> - - - - - - -
- - {modelPoolDisplayText} - - {account.quotaCapacityPrimaryWindowTokens || - account.quotaCapacitySecondaryWindowTokens ? ( - - {t("容量覆盖")}:{" "} - {account.quotaCapacityPrimaryWindowTokens - ? `5h ${formatCompactNumber( - account.quotaCapacityPrimaryWindowTokens, - "0.00", - 2, - true, - )}` - : "5h --"} - {" / "} - {account.quotaCapacitySecondaryWindowTokens - ? `7d ${formatCompactNumber( - account.quotaCapacitySecondaryWindowTokens, - "0.00", - 2, - true, - )}` - : "7d --"} - - ) : ( - - {t("未设置账号容量覆盖")} - - )} -
-
- -
- - {account.priority} - - - - -
-
- - - - -
- - - - - - - - refreshAccount(account.id)} - > - - {t("刷新用量")} - - refreshAccountRt(account.id)} - > - - {t("刷新 AT/RT")} - RT - - - - account.preferred - ? clearPreferredAccount(account.id) - : setPreferredAccount(account.id) - } - > - - {account.preferred ? t("取消优先") : t("设为优先")} - - - statusAction.action && - toggleAccountStatus( - account.id, - statusAction.action === "enable", - account.status, - ) - } - > - - {statusAction.label} - - - handleDeleteSingle(account)} - > - {t("删除")} - - - - -
-
-
- ); - }) - )} -
-
-
-
+ + + + + + + 0 && + visibleAccounts.every((account) => + effectiveSelectedIds.includes(account.id), + ) + } + onCheckedChange={toggleSelectAllVisible} + /> + + + {t("账号信息")} + + + {t("模型白名单 / 额度管理")} + + {t("顺序")} + {t("代理")} + {t("状态")} + + {t("操作")} + + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, index) => ( + + + + + + + + +
+ + + +
+
+ + + + + + + + + + + + +
+ )) + ) : visibleAccounts.length === 0 ? ( + + +
+ +

{t("未找到符合条件的账号")}

+
+
+
+ ) : ( + visibleAccounts.map((account) => { + const quotaItems = buildQuotaSummaryItems(account, t); + const statusAction = getAccountStatusAction(account, t); + const StatusActionIcon = statusAction.icon; + const modelPoolText = account.modelSlugs.length + ? account.modelSlugs.slice(0, 2).join(", ") + : t("全部 API 模型"); + const modelPoolDisplayText = `${t("模型池")}: ${modelPoolText}${ + account.modelSlugs.length > 2 + ? ` +${account.modelSlugs.length - 2}` + : "" + }`; + const isRefreshingCurrentAccount = + isRefreshingAccountId === account.id; + const isRefreshingCurrentRt = + isRefreshingRtAccountId === account.id; + const filteredIndex = + filteredAccountIndexMap.get(account.id) ?? -1; + const canMoveUp = filteredIndex > 0; + const canMoveDown = + filteredIndex !== -1 && + filteredIndex < filteredAccounts.length - 1; + return ( + + + toggleSelect(account.id)} + /> + + + + + + +
+ + {modelPoolDisplayText} + + {account.quotaCapacityPrimaryWindowTokens || + account.quotaCapacitySecondaryWindowTokens ? ( + + {t("容量覆盖")}:{" "} + {account.quotaCapacityPrimaryWindowTokens + ? `5h ${formatCompactNumber( + account.quotaCapacityPrimaryWindowTokens, + "0.00", + 2, + true, + )}` + : "5h --"} + {" / "} + {account.quotaCapacitySecondaryWindowTokens + ? `7d ${formatCompactNumber( + account.quotaCapacitySecondaryWindowTokens, + "0.00", + 2, + true, + )}` + : "7d --"} + + ) : ( + + {t("未设置账号容量覆盖")} + + )} +
+
+ +
+ + {account.priority} + + + + +
+
+ + + + + + + +
+ + + + + + + + refreshAccount(account.id)} + > + + {t("刷新用量")} + + refreshAccountRt(account.id)} + > + + {t("刷新 AT/RT")} + + RT + + + + + account.preferred + ? clearPreferredAccount(account.id) + : setPreferredAccount(account.id) + } + > + + {account.preferred + ? t("取消优先") + : t("设为优先")} + + void openProxyDialog(account)} + > + + {t("账号代理")} + + + statusAction.action && + toggleAccountStatus( + account.id, + statusAction.action === "enable", + account.status, + ) + } + > + + {statusAction.label} + + + handleDeleteSingle(account)} + > + {t("删除")} + + + + +
+
+
+ ); + }) + )} +
+
+
+
-
-
- {t("共")} {filteredAccounts.length} {t("个账号")} - {effectiveSelectedIds.length > 0 ? ( - - ({t("已选择")} {effectiveSelectedIds.length} {t("个")}) - - ) : null} -
-
-
- - {t("每页显示")} - - -
-
- -
- {t("第")} {safePage} / {totalPages} {t("页")} -
- -
-
-
+
+
+ {t("共")} {filteredAccounts.length} {t("个账号")} + {effectiveSelectedIds.length > 0 ? ( + + ({t("已选择")} {effectiveSelectedIds.length} {t("个")}) + + ) : null} +
+
+
+ + {t("每页显示")} + + +
+
+ +
+ {t("第")} {safePage} / {totalPages} {t("页")} +
+ +
+
+
- {addAccountModalOpen ? ( - - ) : null} - - { - if (!open) { - setDeleteDialogState(null); - } - }} - title={ - deleteDialogState?.kind === "single" - ? t("删除账号") - : t("批量删除账号") - } - description={ - deleteDialogState?.kind === "single" - ? `${t("确定删除账号")} ${deleteDialogState.account.name} ${t("吗?删除后不可恢复。")}` - : `${t("确定删除选中的")} ${deleteDialogState?.count || 0} ${t("个账号吗?删除后不可恢复。")}` - } - confirmText={t("删除")} - confirmVariant="destructive" - onConfirm={handleConfirmDelete} - /> - { - if (!open && !isUpdatingProfileAccountId) { - setAccountEditorState(null); - } - }} - > - - - {t("编辑账号信息")} - - {accountEditorState - ? `${t("修改")} ${accountEditorState.accountName} ${t("的名称、标签、备注、排序与额度池配置。")}` - : t("修改账号的基础资料。")} - - -
-
-
- - setLabelDraft(event.target.value)} - /> -
-
- - setTagsDraft(event.target.value)} - placeholder={t("例如:高频, 团队A")} - /> -
-
-
- -