feat(auth-files): add manual refresh functionality for OAuth credentials and update related components

This commit is contained in:
Supra4E8C
2026-07-25 22:09:27 +08:00
parent b24f3069be
commit 1d7bc0d190
9 changed files with 115 additions and 3 deletions
@@ -7,6 +7,7 @@ import {
IconDownload,
IconInfo,
IconModelCluster,
IconRefreshCw,
IconSettings,
IconTrash2,
} from '@/components/ui/icons';
@@ -32,6 +33,7 @@ import {
isThemeSurfaceIconProvider,
normalizeProviderKey,
parsePriorityValue,
supportsAuthFileManualRefresh,
type QuotaProviderType,
type ResolvedTheme,
} from '@/features/authFiles/constants';
@@ -49,10 +51,12 @@ export type AuthFileCardProps = {
disableControls: boolean;
deleting: string | null;
statusUpdating: Record<string, boolean>;
manualRefreshing: Record<string, boolean>;
quotaFilterType: QuotaProviderType | null;
statusBarCache: Map<string, AuthFileStatusBarData>;
onShowModels: (file: AuthFileItem) => void;
onDownload: (name: string) => void;
onManualRefresh: (file: AuthFileItem) => void;
onOpenPrefixProxyEditor: (file: AuthFileItem) => void;
onDelete: (name: string) => void;
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
@@ -75,10 +79,12 @@ export function AuthFileCard(props: AuthFileCardProps) {
disableControls,
deleting,
statusUpdating,
manualRefreshing,
quotaFilterType,
statusBarCache,
onShowModels,
onDownload,
onManualRefresh,
onOpenPrefixProxyEditor,
onDelete,
onToggleStatus,
@@ -94,6 +100,8 @@ export function AuthFileCard(props: AuthFileCardProps) {
const providerKey = normalizeProviderKey(String(file.type ?? file.provider ?? 'unknown'));
const isAistudio = providerKey === 'aistudio';
const showModelsButton = !isRuntimeOnly || isAistudio;
const showManualRefreshButton = !isRuntimeOnly && supportsAuthFileManualRefresh(providerKey);
const isManualRefreshing = manualRefreshing[file.name] === true;
const typeColor = getTypeColor(providerKey, resolvedTheme);
const typeLabel = getTypeLabel(t, providerKey);
const providerIcon = getAuthFileIcon(providerKey, resolvedTheme);
@@ -292,6 +300,27 @@ export function AuthFileCard(props: AuthFileCardProps) {
)}
{!isRuntimeOnly && (
<div className={styles.cardUtilityActions}>
{showManualRefreshButton && (
<Button
variant="secondary"
size="sm"
onClick={() => onManualRefresh(file)}
className={styles.iconButton}
title={t('auth_files.manual_refresh_button')}
disabled={
disableControls ||
file.disabled ||
statusUpdating[file.name] === true ||
isManualRefreshing
}
>
{isManualRefreshing ? (
<LoadingSpinner size={14} />
) : (
<IconRefreshCw className={styles.actionIcon} size={16} />
)}
</Button>
)}
<Button
variant="secondary"
size="sm"
@@ -308,7 +337,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
onClick={() => onOpenPrefixProxyEditor(file)}
className={styles.iconButton}
title={t('auth_files.prefix_proxy_button')}
disabled={disableControls}
disabled={disableControls || isManualRefreshing}
>
<IconSettings className={styles.actionIcon} size={16} />
</Button>
@@ -318,7 +347,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
onClick={() => onDelete(file.name)}
className={styles.iconButton}
title={t('auth_files.delete_button')}
disabled={disableControls || deleting === file.name}
disabled={disableControls || deleting === file.name || isManualRefreshing}
>
{deleting === file.name ? (
<LoadingSpinner size={14} />
@@ -337,7 +366,9 @@ export function AuthFileCard(props: AuthFileCardProps) {
<ToggleSwitch
ariaLabel={t('auth_files.status_toggle_label')}
checked={!file.disabled}
disabled={disableControls || statusUpdating[file.name] === true}
disabled={
disableControls || statusUpdating[file.name] === true || isManualRefreshing
}
onChange={(value) => onToggleStatus(file, value)}
/>
</div>
+10
View File
@@ -56,6 +56,13 @@ export const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
export const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
export const AUTH_FILE_WEBSOCKET_PROVIDERS = new Set(['codex', 'xai']);
export const AUTH_FILE_USING_API_PROVIDERS = new Set(['xai']);
export const AUTH_FILE_MANUAL_REFRESH_PROVIDERS = new Set([
'antigravity',
'claude',
'codex',
'kimi',
'xai',
]);
// 标签类型颜色配置 — 基于各提供商 Logo 品牌色调配,确保彼此不重复
export const TYPE_COLORS: Record<string, TypeColorSet> = {
@@ -147,6 +154,9 @@ export const resolveQuotaErrorMessage = (
export const normalizeProviderKey = normalizeOAuthProviderKey;
export const supportsAuthFileManualRefresh = (provider: unknown): boolean =>
AUTH_FILE_MANUAL_REFRESH_PROVIDERS.has(normalizeProviderKey(String(provider ?? '')));
export const buildOAuthProviderOptions = (values: Iterable<unknown>): string[] => {
const extraProviders = new Set<string>();
@@ -13,6 +13,7 @@ import {
hasAuthFileStatusMessage,
isRuntimeOnlyAuthFile,
normalizeProviderKey,
supportsAuthFileManualRefresh,
} from '@/features/authFiles/constants';
type DeleteAllOptions = {
@@ -36,6 +37,7 @@ export type UseAuthFilesDataResult = {
deleting: string | null;
deletingAll: boolean;
statusUpdating: Record<string, boolean>;
manualRefreshing: Record<string, boolean>;
batchStatusUpdating: boolean;
fileInputRef: RefObject<HTMLInputElement | null>;
loadFiles: () => Promise<void>;
@@ -44,6 +46,7 @@ export type UseAuthFilesDataResult = {
handleDelete: (name: string) => void;
handleDeleteAll: (options: DeleteAllOptions) => void;
handleDownload: (name: string) => Promise<void>;
handleManualRefresh: (item: AuthFileItem) => Promise<void>;
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
toggleSelect: (name: string) => void;
selectAllVisible: (visibleFiles: AuthFileItem[]) => void;
@@ -65,10 +68,12 @@ export function useAuthFilesData(): UseAuthFilesDataResult {
const [deleting, setDeleting] = useState<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false);
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
const [manualRefreshing, setManualRefreshing] = useState<Record<string, boolean>>({});
const [batchStatusUpdating, setBatchStatusUpdating] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const fileInputRef = useRef<HTMLInputElement | null>(null);
const manualRefreshPendingRef = useRef<Set<string>>(new Set());
const batchStatusPendingRef = useRef(false);
const selectionCount = selectedFiles.size;
const toggleSelect = useCallback((name: string) => {
@@ -431,6 +436,43 @@ export function useAuthFilesData(): UseAuthFilesDataResult {
[showNotification, t]
);
const handleManualRefresh = useCallback(
async (item: AuthFileItem) => {
const name = item.name.trim();
const provider = item.type ?? item.provider;
if (
!name ||
item.disabled === true ||
isRuntimeOnlyAuthFile(item) ||
!supportsAuthFileManualRefresh(provider) ||
manualRefreshPendingRef.current.has(name)
) {
return;
}
manualRefreshPendingRef.current.add(name);
setManualRefreshing((prev) => ({ ...prev, [name]: true }));
try {
await authFilesApi.requestManualRefresh(name);
showNotification(t('auth_files.manual_refresh_requested', { name }), 'info');
notifyAuthFilesChanged();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('notification.update_failed');
showNotification(t('auth_files.manual_refresh_failed', { name, message }), 'error');
} finally {
manualRefreshPendingRef.current.delete(name);
setManualRefreshing((prev) => {
if (!prev[name]) return prev;
const next = { ...prev };
delete next[name];
return next;
});
}
},
[showNotification, t]
);
const handleStatusToggle = useCallback(
async (item: AuthFileItem, enabled: boolean) => {
const name = item.name;
@@ -652,6 +694,7 @@ export function useAuthFilesData(): UseAuthFilesDataResult {
deleting,
deletingAll,
statusUpdating,
manualRefreshing,
batchStatusUpdating,
fileInputRef,
loadFiles,
@@ -660,6 +703,7 @@ export function useAuthFilesData(): UseAuthFilesDataResult {
handleDelete,
handleDeleteAll,
handleDownload,
handleManualRefresh,
handleStatusToggle,
toggleSelect,
selectAllVisible,
+3
View File
@@ -178,6 +178,9 @@
"health_status_warning": "Warning",
"health_status_disabled": "Disabled",
"download_button": "Download",
"manual_refresh_button": "Refresh OAuth credential",
"manual_refresh_requested": "Credential refresh requested for \"{{name}}\"",
"manual_refresh_failed": "Failed to request credential refresh for \"{{name}}\": {{message}}",
"delete_button": "Delete",
"delete_confirm": "Are you sure you want to delete file",
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
+3
View File
@@ -177,6 +177,9 @@
"health_status_warning": "Предупреждение",
"health_status_disabled": "Отключено",
"download_button": "Скачать",
"manual_refresh_button": "Обновить учётные данные OAuth",
"manual_refresh_requested": "Запрошено обновление учётных данных для \"{{name}}\"",
"manual_refresh_failed": "Не удалось запросить обновление учётных данных для \"{{name}}\": {{message}}",
"delete_button": "Удалить",
"delete_confirm": "Удалить файл",
"delete_all_confirm": "Удалить все файлы авторизации? Это действие нельзя отменить!",
+3
View File
@@ -178,6 +178,9 @@
"health_status_warning": "警告",
"health_status_disabled": "已停用",
"download_button": "下载",
"manual_refresh_button": "刷新 OAuth 凭证",
"manual_refresh_requested": "已提交 \"{{name}}\" 的凭证刷新请求",
"manual_refresh_failed": "提交 \"{{name}}\" 的凭证刷新请求失败:{{message}}",
"delete_button": "删除",
"delete_confirm": "确定要删除文件",
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
+3
View File
@@ -178,6 +178,9 @@
"health_status_warning": "警告",
"health_status_disabled": "已停用",
"download_button": "下載",
"manual_refresh_button": "重新整理 OAuth 憑證",
"manual_refresh_requested": "已提交「{{name}}」的憑證重新整理請求",
"manual_refresh_failed": "提交「{{name}}」的憑證重新整理請求失敗:{{message}}",
"delete_button": "刪除",
"delete_confirm": "確定要刪除檔案",
"delete_all_confirm": "確定要刪除所有驗證檔案嗎?此操作無法還原!",
+4
View File
@@ -134,6 +134,7 @@ export function AuthFilesPage() {
deleting,
deletingAll,
statusUpdating,
manualRefreshing,
batchStatusUpdating,
fileInputRef,
loadFiles,
@@ -142,6 +143,7 @@ export function AuthFilesPage() {
handleDelete,
handleDeleteAll,
handleDownload,
handleManualRefresh,
handleStatusToggle,
toggleSelect,
selectAllVisible,
@@ -888,10 +890,12 @@ export function AuthFilesPage() {
disableControls={disableControls}
deleting={deleting}
statusUpdating={statusUpdating}
manualRefreshing={manualRefreshing}
quotaFilterType={quotaFilterType}
statusBarCache={statusBarCache}
onShowModels={showModels}
onDownload={handleDownload}
onManualRefresh={handleManualRefresh}
onOpenPrefixProxyEditor={openPrefixProxyEditor}
onDelete={handleDelete}
onToggleStatus={handleStatusToggle}
+11
View File
@@ -19,6 +19,7 @@ export type AuthFileFieldsPatch = {
websockets?: boolean;
using_api?: boolean;
note?: string;
expired?: string;
};
type AuthFileBatchFailure = { name: string; error: string };
type AuthFileBatchUploadResponse = {
@@ -338,6 +339,10 @@ export const serializeOauthModelAliases = (
});
const OAUTH_MODEL_ALIAS_ENDPOINT = '/oauth-model-alias';
const MANUAL_REFRESH_EXPIRY_OFFSET_MS = 60_000;
export const buildManualRefreshExpiredAt = (nowMs = Date.now()): string =>
new Date(nowMs - MANUAL_REFRESH_EXPIRY_OFFSET_MS).toISOString();
export const authFilesApi = {
list: async () => dedupeAuthFilesResponse(await apiClient.get<AuthFilesResponse>('/auth-files')),
@@ -348,6 +353,12 @@ export const authFilesApi = {
patchFields: (name: string, fields: AuthFileFieldsPatch) =>
apiClient.patch('/auth-files/fields', { name, ...fields }),
requestManualRefresh: (name: string) =>
apiClient.patch('/auth-files/fields', {
name,
expired: buildManualRefreshExpiredAt(),
}),
uploadFiles: async (files: File[]): Promise<AuthFileBatchUploadResult> => {
const requestedNames = files.map((file) => file.name);
if (requestedNames.length === 0) {