mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-29 00:41:25 +08:00
feat(auth-files): add manual refresh functionality for OAuth credentials and update related components
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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": "Удалить все файлы авторизации? Это действие нельзя отменить!",
|
||||
|
||||
@@ -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": "确定要删除所有认证文件吗?此操作不可恢复!",
|
||||
|
||||
@@ -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": "確定要刪除所有驗證檔案嗎?此操作無法還原!",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user