Consolidate managed account entry points

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