Consolidate managed account entry points

This commit is contained in:
saladday
2026-06-08 05:38:20 +08:00
Unverified
parent 7480cec0ba
commit 39ecccdbdf
10 changed files with 422 additions and 163 deletions
+31 -7
View File
@@ -12,6 +12,7 @@ import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
import { AuthSettingsPanel } from "@/components/providers/AuthSettingsPanel";
import { UniversalProviderFormModal } from "@/components/universal/UniversalProviderFormModal";
import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets";
@@ -21,6 +22,7 @@ import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPres
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import type { ManagedAuthProvider } from "@/lib/api";
interface AddProviderDialogProps {
open: boolean;
@@ -55,6 +57,21 @@ export function AddProviderDialog({
const [selectedUniversalPreset, setSelectedUniversalPreset] =
useState<UniversalProviderPreset | null>(null);
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
const [authSettingsTarget, setAuthSettingsTarget] =
useState<ManagedAuthProvider | null>(null);
const closeDialog = useCallback(() => {
setAuthSettingsTarget(null);
onOpenChange(false);
}, [onOpenChange]);
const handlePanelClose = useCallback(() => {
if (authSettingsTarget) {
setAuthSettingsTarget(null);
return;
}
closeDialog();
}, [authSettingsTarget, closeDialog]);
const handleUniversalProviderSave = useCallback(
async (provider: UniversalProvider) => {
@@ -272,9 +289,9 @@ export function AddProviderDialog({
}
await onSubmit(providerData);
onOpenChange(false);
closeDialog();
},
[appId, onSubmit, onOpenChange],
[appId, onSubmit, closeDialog],
);
const footer =
@@ -282,7 +299,7 @@ export function AddProviderDialog({
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
onClick={closeDialog}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
@@ -301,7 +318,7 @@ export function AddProviderDialog({
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
onClick={closeDialog}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
@@ -320,7 +337,7 @@ export function AddProviderDialog({
<FullScreenPanel
isOpen={open}
title={t("provider.addNewProvider")}
onClose={() => onOpenChange(false)}
onClose={handlePanelClose}
footer={footer}
>
{showUniversalTab ? (
@@ -342,7 +359,8 @@ export function AddProviderDialog({
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
onCancel={closeDialog}
onManageAuthAccounts={setAuthSettingsTarget}
onSubmittingChange={setIsFormSubmitting}
showButtons={false}
/>
@@ -358,7 +376,8 @@ export function AddProviderDialog({
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
onCancel={closeDialog}
onManageAuthAccounts={setAuthSettingsTarget}
onSubmittingChange={setIsFormSubmitting}
showButtons={false}
/>
@@ -372,6 +391,11 @@ export function AddProviderDialog({
initialPreset={selectedUniversalPreset}
/>
)}
<AuthSettingsPanel
target={authSettingsTarget}
onClose={() => setAuthSettingsTarget(null)}
/>
</FullScreenPanel>
);
}
@@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
import type { ManagedAuthProvider } from "@/lib/api";
interface AuthSettingsPanelProps {
target: ManagedAuthProvider | null;
onClose: () => void;
}
export function AuthSettingsPanel({ target, onClose }: AuthSettingsPanelProps) {
const { t } = useTranslation();
const isOpen = target !== null;
return (
<FullScreenPanel
isOpen={isOpen}
title={t("settings.tabAuth", { defaultValue: "认证" })}
onClose={onClose}
>
{target ? <AuthCenterPanel authScrollTarget={target} /> : null}
</FullScreenPanel>
);
}
@@ -8,7 +8,14 @@ import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
import { openclawApi, providersApi, vscodeApi, type AppId } from "@/lib/api";
import { AuthSettingsPanel } from "@/components/providers/AuthSettingsPanel";
import {
openclawApi,
providersApi,
vscodeApi,
type AppId,
type ManagedAuthProvider,
} from "@/lib/api";
interface EditProviderDialogProps {
open: boolean;
@@ -32,6 +39,8 @@ export function EditProviderDialog({
}: EditProviderDialogProps) {
const { t } = useTranslation();
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
const [authSettingsTarget, setAuthSettingsTarget] =
useState<ManagedAuthProvider | null>(null);
// 默认使用传入的 provider.settingsConfig,若当前编辑对象是"当前生效供应商",则尝试读取实时配置替换初始值
const [liveSettings, setLiveSettings] = useState<Record<
@@ -42,6 +51,19 @@ export function EditProviderDialog({
// 使用 ref 标记是否已经加载过,防止重复读取覆盖用户编辑
const [hasLoadedLive, setHasLoadedLive] = useState(false);
const closeDialog = useCallback(() => {
setAuthSettingsTarget(null);
onOpenChange(false);
}, [onOpenChange]);
const handlePanelClose = useCallback(() => {
if (authSettingsTarget) {
setAuthSettingsTarget(null);
return;
}
closeDialog();
}, [authSettingsTarget, closeDialog]);
useEffect(() => {
let cancelled = false;
const load = async () => {
@@ -212,9 +234,9 @@ export function EditProviderDialog({
provider: updatedProvider,
originalId: provider.id,
});
onOpenChange(false);
closeDialog();
},
[appId, onSubmit, onOpenChange, provider],
[appId, onSubmit, closeDialog, provider],
);
if (!provider || !initialData) {
@@ -225,7 +247,7 @@ export function EditProviderDialog({
<FullScreenPanel
isOpen={open}
title={t("provider.editProvider")}
onClose={() => onOpenChange(false)}
onClose={handlePanelClose}
footer={
<Button
type="submit"
@@ -243,12 +265,17 @@ export function EditProviderDialog({
providerId={provider.id}
submitLabel={t("common.save")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
onCancel={closeDialog}
onManageAuthAccounts={setAuthSettingsTarget}
onSubmittingChange={setIsFormSubmitting}
initialData={initialData}
showButtons={false}
isProxyTakeover={isProxyTakeover}
/>
<AuthSettingsPanel
target={authSettingsTarget}
onClose={() => setAuthSettingsTarget(null)}
/>
</FullScreenPanel>
);
}
@@ -66,6 +66,7 @@ import {
type ClaudeDesktopDefaultRoute,
} from "@/lib/api/providers";
import { resolveManagedAccountId } from "@/lib/authBinding";
import type { ManagedAuthProvider } from "@/lib/api";
export type ClaudeDesktopProviderFormValues = ProviderFormData & {
presetId?: string;
@@ -100,6 +101,7 @@ export interface ClaudeDesktopProviderFormProps {
iconColor?: string;
};
showButtons?: boolean;
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
}
type RouteRow = {
@@ -248,6 +250,7 @@ export function ClaudeDesktopProviderForm({
onSubmittingChange,
initialData,
showButtons = true,
onManageAuthAccounts,
}: ClaudeDesktopProviderFormProps) {
const { t } = useTranslation();
const initialMode = initialData?.meta?.claudeDesktopMode ?? "direct";
@@ -749,13 +752,25 @@ export function ClaudeDesktopProviderForm({
<div className="rounded-lg border border-border-default bg-muted/20 p-3">
{activeProviderType === "github_copilot" ? (
<CopilotAuthSection
mode="select"
selectedAccountId={selectedGitHubAccountId}
onAccountSelect={setSelectedGitHubAccountId}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("github_copilot")
: undefined
}
/>
) : (
<CodexOAuthSection
mode="select"
selectedAccountId={selectedCodexAccountId}
onAccountSelect={setSelectedCodexAccountId}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("codex_oauth")
: undefined
}
fastModeEnabled={codexFastMode}
onFastModeChange={setCodexFastMode}
/>
@@ -52,6 +52,7 @@ import type {
ClaudeApiFormat,
ClaudeApiKeyField,
} from "@/types";
import type { ManagedAuthProvider } from "@/lib/api";
import {
hasClaudeOneMMarker,
setClaudeOneMMarker,
@@ -87,6 +88,8 @@ interface ClaudeFormFieldsProps {
selectedGitHubAccountId?: string | null;
/** GitHub 账号选择回调(多账号支持) */
onGitHubAccountSelect?: (accountId: string | null) => void;
/** 打开托管账号管理入口 */
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
// Codex OAuth (ChatGPT Plus/Pro)
isCodexOauthPreset?: boolean;
@@ -155,6 +158,7 @@ export function ClaudeFormFields({
isCopilotAuthenticated,
selectedGitHubAccountId,
onGitHubAccountSelect,
onManageAuthAccounts,
isCodexOauthPreset,
isCodexOauthAuthenticated,
selectedCodexAccountId,
@@ -554,16 +558,28 @@ export function ClaudeFormFields({
{/* GitHub Copilot OAuth 认证 */}
{isCopilotPreset && (
<CopilotAuthSection
mode="select"
selectedAccountId={selectedGitHubAccountId}
onAccountSelect={onGitHubAccountSelect}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("github_copilot")
: undefined
}
/>
)}
{/* Codex OAuth 认证 (ChatGPT Plus/Pro) */}
{isCodexOauthPreset && (
<CodexOAuthSection
mode="select"
selectedAccountId={selectedCodexAccountId}
onAccountSelect={onCodexAccountSelect}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("codex_oauth")
: undefined
}
fastModeEnabled={codexFastMode}
onFastModeChange={onCodexFastModeChange}
/>
@@ -32,6 +32,7 @@ import type {
CodexChatReasoning,
ProviderCategory,
} from "@/types";
import type { ManagedAuthProvider } from "@/lib/api";
interface EndpointCandidate {
url: string;
@@ -50,6 +51,7 @@ interface CodexFormFieldsProps {
isCodexOauthPreset?: boolean;
selectedCodexAccountId?: string | null;
onCodexAccountSelect?: (accountId: string | null) => void;
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
codexOauthNoneOptionLabel?: string;
// Base URL
@@ -119,6 +121,7 @@ export function CodexFormFields({
isCodexOauthPreset = false,
selectedCodexAccountId,
onCodexAccountSelect,
onManageAuthAccounts,
codexOauthNoneOptionLabel,
shouldShowSpeedTest,
codexBaseUrl,
@@ -296,8 +299,14 @@ export function CodexFormFields({
{/* Codex OAuth 账号选择 */}
{isCodexOauthPreset && (
<CodexOAuthSection
mode="select"
selectedAccountId={selectedCodexAccountId}
onAccountSelect={onCodexAccountSelect}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("codex_oauth")
: undefined
}
noneOptionLabel={codexOauthNoneOptionLabel}
/>
)}
@@ -21,16 +21,21 @@ import {
X,
Sparkles,
User,
Settings2,
} from "lucide-react";
import { useCodexOauth } from "./hooks/useCodexOauth";
import { copyText } from "@/lib/clipboard";
interface CodexOAuthSectionProps {
className?: string;
/** select 模式只展示账号选择和管理入口;manage 模式展示完整账号管理 */
mode?: "manage" | "select";
/** 当前选中的 ChatGPT 账号 ID */
selectedAccountId?: string | null;
/** 账号选择回调 */
onAccountSelect?: (accountId: string | null) => void;
/** 打开账号管理入口 */
onManageAccounts?: () => void;
/** 空选择项文案;默认表示使用托管认证的默认账号 */
noneOptionLabel?: string;
/** 是否开启 Codex FAST mode */
@@ -47,8 +52,10 @@ interface CodexOAuthSectionProps {
*/
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
className,
mode = "manage",
selectedAccountId,
onAccountSelect,
onManageAccounts,
noneOptionLabel,
fastModeEnabled = false,
onFastModeChange,
@@ -59,6 +66,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
const {
accounts,
defaultAccountId,
isLoadingStatus,
hasAnyAccount,
pollingState,
deviceCode,
@@ -86,6 +94,21 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
onAccountSelect?.(value === "none" ? null : value);
};
React.useEffect(() => {
if (
mode !== "select" ||
!selectedAccountId ||
!onAccountSelect ||
isLoadingStatus
) {
return;
}
if (!accounts.some((account) => account.id === selectedAccountId)) {
onAccountSelect(null);
}
}, [accounts, isLoadingStatus, mode, onAccountSelect, selectedAccountId]);
const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
@@ -95,63 +118,87 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
}
};
const accountSelect = onAccountSelect &&
(mode === "select" || hasAnyAccount || noneOptionLabel) && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{mode === "select"
? t("codexOauth.chatgptAccount", "ChatGPT 账号")
: t("codexOauth.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"codexOauth.selectAccountPlaceholder",
"选择一个 ChatGPT 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{noneOptionLabel ??
t("codexOauth.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<div className={`space-y-4 ${className || ""}`}>
{/* 认证状态标题 */}
<div className="flex items-center justify-between">
<Label>{t("codexOauth.authStatus", "认证状态")}</Label>
<Badge
variant={hasAnyAccount ? "default" : "secondary"}
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
>
{hasAnyAccount
? t("codexOauth.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("codexOauth.notAuthenticated", "未认证")}
</Badge>
</div>
{/* 账号选择器 */}
{onAccountSelect && (hasAnyAccount || noneOptionLabel) && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("codexOauth.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
{mode === "manage" && (
<div className="flex items-center justify-between">
<Label>{t("codexOauth.authStatus", "认证状态")}</Label>
<Badge
variant={hasAnyAccount ? "default" : "secondary"}
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"codexOauth.selectAccountPlaceholder",
"选择一个 ChatGPT 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{noneOptionLabel ??
t("codexOauth.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{hasAnyAccount
? t("codexOauth.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("codexOauth.notAuthenticated", "未认证")}
</Badge>
</div>
)}
{onFastModeChange && (
{/* 账号选择器 */}
{mode === "select" && accountSelect ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">{accountSelect}</div>
{onManageAccounts && (
<Button
type="button"
variant="outline"
onClick={onManageAccounts}
className="h-9 shrink-0"
>
<Settings2 className="h-4 w-4" />
{t("codexOauth.manageAccounts", "管理账号")}
</Button>
)}
</div>
) : (
accountSelect
)}
{mode === "manage" && onFastModeChange && (
<div className="flex items-center justify-between rounded-md border bg-muted/30 p-3">
<div className="space-y-1 pr-4">
<Label className="text-sm font-medium">
@@ -173,7 +220,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 已登录账号列表 */}
{hasAnyAccount && (
{mode === "manage" && hasAnyAccount && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("codexOauth.loggedInAccounts", "已登录账号")}
@@ -230,7 +277,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 未认证 - 登录按钮 */}
{!hasAnyAccount && pollingState === "idle" && (
{mode === "manage" && !hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
@@ -243,7 +290,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 已有账号 - 添加更多按钮 */}
{hasAnyAccount && pollingState === "idle" && (
{mode === "manage" && hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
@@ -257,7 +304,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 轮询中状态 */}
{isPolling && deviceCode && (
{mode === "manage" && isPolling && deviceCode && (
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -314,7 +361,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 错误状态 */}
{pollingState === "error" && error && (
{mode === "manage" && pollingState === "error" && error && (
<div className="space-y-2">
<p className="text-sm text-red-500">{error}</p>
<div className="flex gap-2">
@@ -339,7 +386,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 注销所有账号 */}
{hasAnyAccount && accounts.length > 1 && (
{mode === "manage" && hasAnyAccount && accounts.length > 1 && (
<Button
type="button"
variant="outline"
@@ -21,6 +21,7 @@ import {
Plus,
X,
User,
Settings2,
} from "lucide-react";
import { useCopilotAuth } from "./hooks/useCopilotAuth";
import { copyText } from "@/lib/clipboard";
@@ -28,10 +29,14 @@ import type { GitHubAccount } from "@/lib/api";
interface CopilotAuthSectionProps {
className?: string;
/** select 模式只展示账号选择和管理入口;manage 模式展示完整账号管理 */
mode?: "manage" | "select";
/** 当前选中的 GitHub 账号 ID */
selectedAccountId?: string | null;
/** 账号选择回调 */
onAccountSelect?: (accountId: string | null) => void;
/** 打开账号管理入口 */
onManageAccounts?: () => void;
}
/**
@@ -41,8 +46,10 @@ interface CopilotAuthSectionProps {
*/
export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
className,
mode = "manage",
selectedAccountId,
onAccountSelect,
onManageAccounts,
}) => {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
@@ -64,6 +71,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
accounts,
defaultAccountId,
migrationError,
isLoadingStatus,
hasAnyAccount,
pollingState,
deviceCode,
@@ -93,6 +101,21 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
onAccountSelect?.(value === "none" ? null : value);
};
React.useEffect(() => {
if (
mode !== "select" ||
!selectedAccountId ||
!onAccountSelect ||
isLoadingStatus
) {
return;
}
if (!accounts.some((account) => account.id === selectedAccountId)) {
onAccountSelect(null);
}
}, [accounts, isLoadingStatus, mode, onAccountSelect, selectedAccountId]);
// 处理移除账号
const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {
e.stopPropagation();
@@ -109,60 +132,103 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
return <CopilotAccountAvatar account={account} />;
};
const accountSelect = onAccountSelect &&
(mode === "select" || hasAnyAccount) && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{mode === "select"
? t("copilot.githubAccount", "GitHub 账号")
: t("copilot.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"copilot.selectAccountPlaceholder",
"选择一个 GitHub 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("copilot.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
{renderAvatar(account)}
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<div className={`space-y-4 ${className || ""}`}>
{/* 认证状态标题 */}
<div className="flex items-center justify-between">
<Label>{t("copilot.authStatus", "GitHub Copilot 认证")}</Label>
<Badge
variant={hasAnyAccount ? "default" : "secondary"}
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
>
{hasAnyAccount
? t("copilot.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("copilot.notAuthenticated", "未认证")}
</Badge>
</div>
{mode === "manage" && (
<div className="flex items-center justify-between">
<Label>{t("copilot.authStatus", "GitHub Copilot 认证")}</Label>
<Badge
variant={hasAnyAccount ? "default" : "secondary"}
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
>
{hasAnyAccount
? t("copilot.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("copilot.notAuthenticated", "未认证")}
</Badge>
</div>
)}
{/* GitHub 部署类型选择 */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.deploymentType", "GitHub 部署类型")}
</Label>
<Select
value={deploymentType}
onValueChange={(v) =>
setDeploymentType(v as "github.com" | "enterprise")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="github.com">
{t("copilot.deploymentGitHubCom", "GitHub.com")}
</SelectItem>
<SelectItem value="enterprise">
{t("copilot.deploymentEnterprise", "GitHub Enterprise Server")}
</SelectItem>
</SelectContent>
</Select>
{deploymentType === "enterprise" && (
<Input
placeholder={t(
"copilot.enterpriseDomainPlaceholder",
"例如:company.ghe.com",
)}
value={enterpriseDomain}
onChange={(e) => setEnterpriseDomain(e.target.value)}
/>
)}
</div>
{mode === "manage" && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.deploymentType", "GitHub 部署类型")}
</Label>
<Select
value={deploymentType}
onValueChange={(v) =>
setDeploymentType(v as "github.com" | "enterprise")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="github.com">
{t("copilot.deploymentGitHubCom", "GitHub.com")}
</SelectItem>
<SelectItem value="enterprise">
{t("copilot.deploymentEnterprise", "GitHub Enterprise Server")}
</SelectItem>
</SelectContent>
</Select>
{deploymentType === "enterprise" && (
<Input
placeholder={t(
"copilot.enterpriseDomainPlaceholder",
"例如:company.ghe.com",
)}
value={enterpriseDomain}
onChange={(e) => setEnterpriseDomain(e.target.value)}
/>
)}
</div>
)}
{migrationError && (
{mode === "manage" && migrationError && (
<p className="text-sm text-amber-600 dark:text-amber-400">
{t("copilot.migrationFailed", {
error: migrationError,
@@ -172,44 +238,27 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 账号选择器(有账号时显示) */}
{hasAnyAccount && onAccountSelect && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"copilot.selectAccountPlaceholder",
"选择一个 GitHub 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("copilot.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
{renderAvatar(account)}
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{mode === "select" && accountSelect ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">{accountSelect}</div>
{onManageAccounts && (
<Button
type="button"
variant="outline"
onClick={onManageAccounts}
className="h-9 shrink-0"
>
<Settings2 className="h-4 w-4" />
{t("copilot.manageAccounts", "管理账号")}
</Button>
)}
</div>
) : (
accountSelect
)}
{/* 已登录账号列表 */}
{hasAnyAccount && (
{mode === "manage" && hasAnyAccount && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.loggedInAccounts", "已登录账号")}
@@ -272,7 +321,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 未认证状态 - 登录按钮 */}
{!hasAnyAccount && pollingState === "idle" && (
{mode === "manage" && !hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
@@ -286,7 +335,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 已有账号 - 添加更多账号按钮 */}
{hasAnyAccount && pollingState === "idle" && (
{mode === "manage" && hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
@@ -303,7 +352,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 轮询中状态 */}
{isPolling && deviceCode && (
{mode === "manage" && isPolling && deviceCode && (
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -363,7 +412,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 错误状态 */}
{pollingState === "error" && error && (
{mode === "manage" && pollingState === "error" && error && (
<div className="space-y-2">
<p className="text-sm text-red-500">{error}</p>
<div className="flex gap-2">
@@ -388,7 +437,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 注销所有账号按钮 */}
{hasAnyAccount && accounts.length > 1 && (
{mode === "manage" && hasAnyAccount && accounts.length > 1 && (
<Button
type="button"
variant="outline"
@@ -8,7 +8,12 @@ import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import { providersApi, settingsApi, type AppId } from "@/lib/api";
import {
providersApi,
settingsApi,
type AppId,
type ManagedAuthProvider,
} from "@/lib/api";
import type {
ProviderCategory,
ProviderMeta,
@@ -228,6 +233,7 @@ export interface ProviderFormProps {
onCancel: () => void;
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
onManageUniversalProviders?: () => void;
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
onSubmittingChange?: (isSubmitting: boolean) => void;
initialData?: {
name?: string;
@@ -259,6 +265,7 @@ function ProviderFormFull({
onCancel,
onUniversalPresetSelect,
onManageUniversalProviders,
onManageAuthAccounts,
onSubmittingChange,
initialData,
showButtons = true,
@@ -655,8 +662,7 @@ function ProviderFormFull({
const selectedPresetEntry = useMemo(
() =>
selectedPresetId && selectedPresetId !== "custom"
? (presetEntries.find((entry) => entry.id === selectedPresetId) ??
null)
? (presetEntries.find((entry) => entry.id === selectedPresetId) ?? null)
: null,
[presetEntries, selectedPresetId],
);
@@ -1162,7 +1168,11 @@ function ProviderFormFull({
}),
);
}
if (!isCopilotProvider && !isClaudeCodexOauthProvider && !apiKey.trim()) {
if (
!isCopilotProvider &&
!isClaudeCodexOauthProvider &&
!apiKey.trim()
) {
issues.push(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
@@ -1423,7 +1433,7 @@ function ProviderFormFull({
authProvider: "codex_oauth",
accountId: selectedCodexAccountId ?? undefined,
}
: undefined,
: undefined,
// GitHub Copilot 多账号:保存关联的账号 ID
githubAccountId:
isCopilotProvider && selectedGitHubAccountId
@@ -2022,6 +2032,7 @@ function ProviderFormFull({
isCopilotAuthenticated={isCopilotAuthenticated}
selectedGitHubAccountId={selectedGitHubAccountId}
onGitHubAccountSelect={setSelectedGitHubAccountId}
onManageAuthAccounts={onManageAuthAccounts}
isCodexOauthAuthenticated={isCodexOauthAuthenticated}
selectedCodexAccountId={selectedCodexAccountId}
onCodexAccountSelect={setSelectedCodexAccountId}
@@ -2074,6 +2085,7 @@ function ProviderFormFull({
isCodexOauthPreset={isCodexOfficialProvider}
selectedCodexAccountId={selectedCodexAccountId}
onCodexAccountSelect={setSelectedCodexAccountId}
onManageAuthAccounts={onManageAuthAccounts}
codexOauthNoneOptionLabel="暂不绑定托管账号"
shouldShowSpeedTest={shouldShowSpeedTest}
codexBaseUrl={codexBaseUrl}
+39 -3
View File
@@ -1,12 +1,42 @@
import { useEffect, useRef } from "react";
import { Github, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { CodexIcon } from "@/components/BrandIcons";
import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection";
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
import type { ManagedAuthProvider } from "@/lib/api";
export function AuthCenterPanel() {
interface AuthCenterPanelProps {
authScrollTarget?: ManagedAuthProvider | null;
}
export function AuthCenterPanel({ authScrollTarget }: AuthCenterPanelProps) {
const { t } = useTranslation();
const copilotSectionRef = useRef<HTMLElement | null>(null);
const codexOauthSectionRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!authScrollTarget) return;
const sectionRef =
authScrollTarget === "github_copilot"
? copilotSectionRef
: codexOauthSectionRef;
const frame = requestAnimationFrame(() => {
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
sectionRef.current?.scrollIntoView({
behavior: prefersReducedMotion ? "auto" : "smooth",
block: "start",
});
});
return () => cancelAnimationFrame(frame);
}, [authScrollTarget]);
return (
<div className="space-y-6">
@@ -34,7 +64,10 @@ export function AuthCenterPanel() {
</div>
</section>
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
<section
ref={copilotSectionRef}
className="scroll-mt-4 rounded-xl border border-border/60 bg-card/60 p-6"
>
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-muted">
<Github className="h-5 w-5" />
@@ -52,7 +85,10 @@ export function AuthCenterPanel() {
<CopilotAuthSection />
</section>
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
<section
ref={codexOauthSectionRef}
className="scroll-mt-4 rounded-xl border border-border/60 bg-card/60 p-6"
>
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-muted">
<CodexIcon size={20} />