From 3e55d601a1faebc2d7a52626049178c9e63eae08 Mon Sep 17 00:00:00 2001 From: thanhtunguet Date: Sat, 31 Jan 2026 21:04:34 +0700 Subject: [PATCH 01/13] feat: enhance OAuth model alias management with new UI components and localization updates --- src/components/common/ConfirmationModal.tsx | 6 +- .../ModelMappingDiagram.module.scss | 323 +++++++ .../modelAlias/ModelMappingDiagram.tsx | 902 ++++++++++++++++++ src/components/modelAlias/index.ts | 2 + src/components/ui/Modal.tsx | 4 +- src/i18n/locales/en.json | 31 + src/i18n/locales/zh-CN.json | 31 + src/pages/AuthFilesPage.module.scss | 43 + src/pages/AuthFilesPage.tsx | 376 +++++++- src/stores/useNotificationStore.ts | 3 +- 10 files changed, 1692 insertions(+), 29 deletions(-) create mode 100644 src/components/modelAlias/ModelMappingDiagram.module.scss create mode 100644 src/components/modelAlias/ModelMappingDiagram.tsx create mode 100644 src/components/modelAlias/index.ts diff --git a/src/components/common/ConfirmationModal.tsx b/src/components/common/ConfirmationModal.tsx index 9a8ca0b..6471fc2 100644 --- a/src/components/common/ConfirmationModal.tsx +++ b/src/components/common/ConfirmationModal.tsx @@ -43,7 +43,11 @@ export function ConfirmationModal() { return ( -

{message}

+ {typeof message === 'string' ? ( +

{message}

+ ) : ( +
{message}
+ )}
+ + {provider} + + {sources.length} +
+ ); + })} + + + {/* Column 2: Source Models (children per provider; hidden when provider collapsed) */} +
{ + e.preventDefault(); + e.stopPropagation(); + handleContextMenu(e, 'background'); + }} + > +
{t('oauth_model_alias.diagram_source_models')}
+ {providerNodes.flatMap(({ provider, sources }) => { + if (collapsedProviders.has(provider)) return []; + return sources.map((source) => ( +
{ + if (el) sourceRefs.current.set(source.id, el); + else sourceRefs.current.delete(source.id); + }} + className={`${styles.item} ${styles.sourceItem} ${ + draggedSource?.id === source.id ? styles.dragging : '' + } ${dropTargetSource === source.id ? styles.dropTarget : ''}`} + draggable={!!onUpdate} + onDragStart={(e) => handleDragStart(e, source)} + onDragEnd={() => { + setDraggedSource(null); + setDropTargetAlias(null); + }} + onDragOver={(e) => handleDragOverSource(e, source)} + onDragLeave={handleDragLeaveSource} + onDrop={(e) => handleDropOnSource(e, source)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleContextMenu(e, 'source', source.id); + }} + > + + {source.name} + +
0 ? 1 : 0.3 + }} + /> +
+ )); + })} +
+ +
{ + e.preventDefault(); + e.stopPropagation(); + handleContextMenu(e, 'background'); + }} + > +
{t('oauth_model_alias.diagram_aliases')}
+ {aliasNodes.map((node) => ( +
{ + if (el) aliasRefs.current.set(node.id, el); + else aliasRefs.current.delete(node.id); + }} + className={`${styles.item} ${styles.aliasItem} ${ + dropTargetAlias === node.alias ? styles.dropTarget : '' + } ${draggedAlias === node.alias ? styles.dragging : ''}`} + draggable={!!onUpdate} + onDragStart={(e) => handleDragStartAlias(e, node.alias)} + onDragEnd={() => { + setDraggedAlias(null); + setDropTargetSource(null); + }} + onDragOver={(e) => handleDragOver(e, node.alias)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, node.alias)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleContextMenu(e, 'alias', node.alias); + }} + > +
+ + {node.alias} + + {node.sources.length} +
+ ))} +
+ + {contextMenu && + createPortal( +
e.stopPropagation()} + > + {contextMenu.type === 'background' && ( +
+ {t('oauth_model_alias.diagram_add_alias')} +
+ )} + {contextMenu.type === 'alias' && renderAliasMenu()} + {contextMenu.type === 'provider' && renderProviderMenu()} + {contextMenu.type === 'source' && renderSourceMenu()} +
, + document.body + )} + + setRenameState(null)} + title={t('oauth_model_alias.diagram_rename_alias_title')} + width={400} + footer={ + <> + + + + } + > + { + setRenameValue(e.target.value); + setRenameError(''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRenameSubmit(); + }} + error={renameError} + placeholder={t('oauth_model_alias.diagram_rename_placeholder')} + autoFocus + /> + + + setAddAliasOpen(false)} + title={t('oauth_model_alias.diagram_add_alias_title')} + width={400} + footer={ + <> + + + + } + > + { + setAddAliasValue(e.target.value); + setAddAliasError(''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleAddAliasSubmit(); + }} + error={addAliasError} + placeholder={t('oauth_model_alias.diagram_add_placeholder')} + autoFocus + /> + + + setSettingsAlias(null)} + title={t('oauth_model_alias.diagram_settings_title', { alias: settingsAlias ?? '' })} + width={720} + footer={ + + } + > + {settingsAlias ? ( + (() => { + const node = aliasNodes.find((n) => n.alias === settingsAlias); + if (!node || node.sources.length === 0) { + return
{t('oauth_model_alias.diagram_settings_empty')}
; + } + return ( +
+ {node.sources.map((source) => { + const entry = source.aliases.find((item) => item.alias === settingsAlias); + const forkEnabled = entry?.fork === true; + return ( +
+
+ {source.name} + + {settingsAlias} +
+
+ + {t('oauth_model_alias.alias_fork_label')} + + handleToggleFork(source.provider, source.name, settingsAlias, value)} + ariaLabel={t('oauth_model_alias.alias_fork_label')} + /> + +
+
+ ); + })} +
+ ); + })() + ) : null} +
+ + setSettingsSourceId(null)} + title={t('oauth_model_alias.diagram_settings_source_title')} + width={720} + footer={ + + } + > + {settingsSourceId ? ( + (() => { + const source = resolveSourceById(settingsSourceId); + if (!source || source.aliases.length === 0) { + return
{t('oauth_model_alias.diagram_settings_empty')}
; + } + return ( +
+ {source.aliases.map((entry) => ( +
+
+ {source.name} + + {entry.alias} +
+
+ + {t('oauth_model_alias.alias_fork_label')} + + + handleToggleFork(source.provider, source.name, entry.alias, value) + } + ariaLabel={t('oauth_model_alias.alias_fork_label')} + /> + +
+
+ ))} +
+ ); + })() + ) : null} +
+
+ ); +}); diff --git a/src/components/modelAlias/index.ts b/src/components/modelAlias/index.ts new file mode 100644 index 0000000..6375f4e --- /dev/null +++ b/src/components/modelAlias/index.ts @@ -0,0 +1,2 @@ +export { ModelMappingDiagram } from './ModelMappingDiagram'; +export type { ModelMappingDiagramProps, ModelMappingDiagramRef } from './ModelMappingDiagram'; diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index f129c89..45eff25 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -8,6 +8,7 @@ interface ModalProps { onClose: () => void; footer?: ReactNode; width?: number | string; + className?: string; closeDisabled?: boolean; } @@ -39,6 +40,7 @@ export function Modal({ onClose, footer, width = 520, + className, closeDisabled = false, children }: PropsWithChildren) { @@ -110,7 +112,7 @@ export function Modal({ if (!open && !isVisible) return null; const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`; - const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`; + const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}${className ? ` ${className}` : ''}`; const modalContent = (
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6998856..cb20b89 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -543,11 +543,42 @@ "save_failed": "Failed to update model aliases", "delete": "Delete Provider", "delete_confirm": "Delete model aliases for {{provider}}?", + "delete_link_title": "Unlink mapping", + "delete_link_confirm": "Unlink mapping from {{sourceModel}} ({{provider}}) to alias {{alias}}?", + "delete_alias_title": "Delete Alias", + "delete_alias_confirm": "Delete alias {{alias}} and unmap all associated models?", "delete_success": "Model aliases removed", "delete_failed": "Failed to delete model aliases", "no_models": "No model aliases", "model_count": "{{count}} aliases", "list_empty_all": "No model aliases yet—use “Add Alias” to create one.", + "chart_title": "All mappings overview", + "diagram_providers": "Providers", + "diagram_source_models": "Source Models", + "diagram_aliases": "Aliases", + "diagram_expand": "Expand", + "diagram_collapse": "Collapse", + "diagram_add_alias": "Add Alias", + "diagram_rename": "Rename", + "diagram_rename_alias_title": "Rename alias", + "diagram_rename_alias_label": "New alias name", + "diagram_rename_placeholder": "Enter alias name...", + "diagram_delete_link": "Unlink from {{provider}} / {{name}}", + "diagram_delete_alias": "Delete alias", + "diagram_please_enter_alias": "Please enter an alias name.", + "diagram_alias_exists": "This alias already exists.", + "diagram_add_alias_title": "Add alias", + "diagram_add_alias_label": "Alias name", + "diagram_add_placeholder": "Enter new alias name...", + "diagram_rename_btn": "Rename", + "diagram_add_btn": "Add", + "diagram_settings": "Settings", + "diagram_settings_title": "Alias settings — {{alias}}", + "diagram_settings_source_title": "Source model settings", + "diagram_settings_empty": "No mappings for this alias yet.", + "view_mode": "View mode", + "view_mode_diagram": "Diagram", + "view_mode_list": "List", "provider_required": "Please enter a provider first", "upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.", "upgrade_required_title": "Please upgrade CLI Proxy API", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index c69d536..1f9d728 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -543,11 +543,42 @@ "save_failed": "更新模型别名失败", "delete": "删除提供商", "delete_confirm": "确定要删除 {{provider}} 的模型别名吗?", + "delete_link_title": "取消链接", + "delete_link_confirm": "确定取消 {{sourceModel}}({{provider}})到别名 {{alias}} 的映射?", + "delete_alias_title": "删除别名", + "delete_alias_confirm": "确定删除别名 {{alias}} 并取消所有关联模型的映射?", "delete_success": "已删除该提供商的模型别名", "delete_failed": "删除模型别名失败", "no_models": "未配置模型别名", "model_count": "{{count}} 条别名", "list_empty_all": "暂无任何提供商的模型别名,点击“新增别名”创建。", + "chart_title": "全部映射概览", + "diagram_providers": "提供商", + "diagram_source_models": "源模型", + "diagram_aliases": "别名", + "diagram_expand": "展开", + "diagram_collapse": "收起", + "diagram_add_alias": "添加别名", + "diagram_rename": "重命名", + "diagram_rename_alias_title": "重命名别名", + "diagram_rename_alias_label": "新别名名称", + "diagram_rename_placeholder": "输入别名名称...", + "diagram_delete_link": "取消链接 {{provider}} / {{name}}", + "diagram_delete_alias": "删除别名", + "diagram_please_enter_alias": "请输入别名名称。", + "diagram_alias_exists": "该别名已存在。", + "diagram_add_alias_title": "添加别名", + "diagram_add_alias_label": "别名名称", + "diagram_add_placeholder": "输入新别名名称...", + "diagram_rename_btn": "重命名", + "diagram_add_btn": "添加", + "diagram_settings": "设置", + "diagram_settings_title": "别名设置 — {{alias}}", + "diagram_settings_source_title": "源模型设置", + "diagram_settings_empty": "该别名暂无映射。", + "view_mode": "视图模式", + "view_mode_diagram": "Diagram", + "view_mode_list": "List", "provider_required": "请先填写提供商名称", "upgrade_required": "当前 CPA 版本不支持模型别名功能,请升级 CPA 版本", "upgrade_required_title": "需要升级 CPA 版本", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 7ce60d2..6bfb681 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -843,6 +843,49 @@ } } +// OAuth 模型别名 - 映射概览 +.aliasChartSection { + margin-bottom: $spacing-lg; + padding-bottom: $spacing-lg; + border-bottom: 1px solid var(--border-color); +} + +.aliasChartHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + margin-bottom: $spacing-sm; +} + +.aliasChartTitle { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); +} + +.aliasChart { + width: 100%; + min-height: 120px; +} + +.cardExtraButtons { + display: flex; + gap: $spacing-sm; + align-items: center; +} + +.viewModeSwitch { + display: inline-flex; + align-items: center; + gap: $spacing-xs; + padding: 2px; + border-radius: $radius-md; + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + // OAuth 模型映射表单 .mappingRow { display: grid; diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index abe76d1..09ed9b5 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useInterval } from '@/hooks/useInterval'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; @@ -10,9 +10,11 @@ import { Input } from '@/components/ui/Input'; import { Modal } from '@/components/ui/Modal'; import { EmptyState } from '@/components/ui/EmptyState'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; +import { ModelMappingDiagram, type ModelMappingDiagramRef } from '@/components/modelAlias'; import { IconBot, IconCode, + IconChevronUp, IconDownload, IconInfo, IconTrash2, @@ -230,6 +232,10 @@ export function AuthFilesPage() { // OAuth 模型映射相关 const [modelAlias, setModelAlias] = useState>({}); const [modelAliasError, setModelAliasError] = useState<'unsupported' | null>(null); + const [allProviderModels, setAllProviderModels] = useState>( + {} + ); + const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list'); const [prefixProxyEditor, setPrefixProxyEditor] = useState(null); @@ -237,11 +243,72 @@ export function AuthFilesPage() { const loadingKeyStatsRef = useRef(false); const excludedUnsupportedRef = useRef(false); const mappingsUnsupportedRef = useRef(false); + const diagramRef = useRef(null); const normalizeProviderKey = (value: string) => value.trim().toLowerCase(); const disableControls = connectionStatus !== 'connected'; + useEffect(() => { + let cancelled = false; + + const loadAllModels = async () => { + const providers = new Set(); + + Object.keys(modelAlias).forEach((provider) => { + const key = provider.trim().toLowerCase(); + if (key) providers.add(key); + }); + + files.forEach((file) => { + if (typeof file.type === 'string') { + const key = file.type.trim().toLowerCase(); + if (key) providers.add(key); + } + if (typeof file.provider === 'string') { + const key = file.provider.trim().toLowerCase(); + if (key) providers.add(key); + } + }); + + const providerList = Array.from(providers); + if (providerList.length === 0) { + if (!cancelled) setAllProviderModels({}); + return; + } + + const results = await Promise.all( + providerList.map(async (provider) => { + try { + const models = await authFilesApi.getModelDefinitions(provider); + return { provider, models }; + } catch { + return { provider, models: [] }; + } + }) + ); + + if (cancelled) return; + + const nextModels: Record = {}; + results.forEach(({ provider, models }) => { + if (models.length > 0) { + nextModels[provider] = models; + } + }); + + setAllProviderModels(nextModels); + }; + + void loadAllModels(); + + return () => { + cancelled = true; + }; + }, [files, modelAlias]); + + + useEffect(() => { const persisted = readAuthFilesUiState(); if (!persisted) return; @@ -603,7 +670,9 @@ export function AuthFilesPage() { setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file))); } else { // 删除筛选类型的文件 - const filesToDelete = files.filter((f) => f.type === filter && !isRuntimeOnlyAuthFile(f)); + const filesToDelete = files.filter( + (f) => f.type === filter && !isRuntimeOnlyAuthFile(f) + ); if (filesToDelete.length === 0) { showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info'); @@ -991,6 +1060,205 @@ export function AuthFilesPage() { }); }; + const handleMappingUpdate = async (provider: string, sourceModel: string, newAlias: string) => { + if (!provider || !sourceModel || !newAlias) return; + const normalizedProvider = normalizeProviderKey(provider); + if (!normalizedProvider) return; + + const providerKey = Object.keys(modelAlias).find( + (key) => normalizeProviderKey(key) === normalizedProvider + ); + const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? []; + + const nameTrim = sourceModel.trim(); + const aliasTrim = newAlias.trim(); + const nameKey = nameTrim.toLowerCase(); + const aliasKey = aliasTrim.toLowerCase(); + + if ( + currentMappings.some( + (m) => + (m.name ?? '').trim().toLowerCase() === nameKey && + (m.alias ?? '').trim().toLowerCase() === aliasKey + ) + ) { + return; + } + + const nextMappings: OAuthModelAliasEntry[] = [ + ...currentMappings, + { name: nameTrim, alias: aliasTrim, fork: true }, + ]; + + try { + await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings); + await loadModelAlias(); + showNotification(t('oauth_model_alias.save_success'), 'success'); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : ''; + showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error'); + } + }; + + const handleDeleteLink = (provider: string, sourceModel: string, alias: string) => { + const nameTrim = sourceModel.trim(); + const aliasTrim = alias.trim(); + if (!provider || !nameTrim || !aliasTrim) return; + + showConfirmation({ + title: t('oauth_model_alias.delete_link_title', { defaultValue: 'Unlink mapping' }), + message: ( + }} + /> + ), + variant: 'danger', + confirmText: t('common.confirm'), + onConfirm: async () => { + const normalizedProvider = normalizeProviderKey(provider); + const providerKey = Object.keys(modelAlias).find( + (key) => normalizeProviderKey(key) === normalizedProvider + ); + const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? []; + const nameKey = nameTrim.toLowerCase(); + const aliasKey = aliasTrim.toLowerCase(); + const nextMappings = currentMappings.filter( + (m) => + (m.name ?? '').trim().toLowerCase() !== nameKey || + (m.alias ?? '').trim().toLowerCase() !== aliasKey + ); + if (nextMappings.length === currentMappings.length) return; + + try { + if (nextMappings.length === 0) { + await authFilesApi.deleteOauthModelAlias(normalizedProvider); + } else { + await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings); + } + await loadModelAlias(); + showNotification(t('oauth_model_alias.save_success'), 'success'); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : ''; + showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error'); + } + }, + }); + }; + + const handleToggleFork = async ( + provider: string, + sourceModel: string, + alias: string, + fork: boolean + ) => { + const normalizedProvider = normalizeProviderKey(provider); + if (!normalizedProvider) return; + + const providerKey = Object.keys(modelAlias).find( + (key) => normalizeProviderKey(key) === normalizedProvider + ); + const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? []; + const nameKey = sourceModel.trim().toLowerCase(); + const aliasKey = alias.trim().toLowerCase(); + let changed = false; + + const nextMappings = currentMappings.map((m) => { + const mName = (m.name ?? '').trim().toLowerCase(); + const mAlias = (m.alias ?? '').trim().toLowerCase(); + if (mName === nameKey && mAlias === aliasKey) { + changed = true; + return fork ? { ...m, fork: true } : { name: m.name, alias: m.alias }; + } + return m; + }); + + if (!changed) return; + + try { + await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings); + await loadModelAlias(); + showNotification(t('oauth_model_alias.save_success'), 'success'); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : ''; + showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error'); + } + }; + + const handleRenameAlias = async (oldAlias: string, newAlias: string) => { + const oldTrim = oldAlias.trim(); + const newTrim = newAlias.trim(); + if (!oldTrim || !newTrim || oldTrim === newTrim) return; + + const oldKey = oldTrim.toLowerCase(); + const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) => + mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === oldKey) + ); + + if (providersToUpdate.length === 0) return; + + try { + await Promise.all( + providersToUpdate.map(([provider, mappings]) => { + const nextMappings = mappings.map((m) => + (m.alias ?? '').trim().toLowerCase() === oldKey ? { ...m, alias: newTrim } : m + ); + return authFilesApi.saveOauthModelAlias(provider, nextMappings); + }) + ); + await loadModelAlias(); + showNotification(t('oauth_model_alias.save_success'), 'success'); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : ''; + showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error'); + } + }; + + const handleDeleteAlias = (aliasName: string) => { + const aliasTrim = aliasName.trim(); + if (!aliasTrim) return; + const aliasKey = aliasTrim.toLowerCase(); + const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) => + mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === aliasKey) + ); + + if (providersToUpdate.length === 0) return; + + showConfirmation({ + title: t('oauth_model_alias.delete_alias_title', { defaultValue: 'Delete Alias' }), + message: ( + }} + /> + ), + variant: 'danger', + confirmText: t('common.confirm'), + onConfirm: async () => { + try { + await Promise.all( + providersToUpdate.map(([provider, mappings]) => { + const nextMappings = mappings.filter( + (m) => (m.alias ?? '').trim().toLowerCase() !== aliasKey + ); + if (nextMappings.length === 0) { + return authFilesApi.deleteOauthModelAlias(provider); + } + return authFilesApi.saveOauthModelAlias(provider, nextMappings); + }) + ); + await loadModelAlias(); + showNotification(t('oauth_model_alias.delete_success'), 'success'); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : ''; + showNotification(`${t('oauth_model_alias.delete_failed')}: ${errorMessage}`, 'error'); + } + }, + }); + }; + // 渲染标签筛选器 const renderFilterTags = () => (
@@ -1084,22 +1352,22 @@ export function AuthFilesPage() { }; // 渲染单个认证文件卡片 - const renderFileCard = (item: AuthFileItem) => { - const fileStats = resolveAuthFileStats(item, keyStats); - const isRuntimeOnly = isRuntimeOnlyAuthFile(item); - const isAistudio = (item.type || '').toLowerCase() === 'aistudio'; - const showModelsButton = !isRuntimeOnly || isAistudio; - const typeColor = getTypeColor(item.type || 'unknown'); + const renderFileCard = (item: AuthFileItem) => { + const fileStats = resolveAuthFileStats(item, keyStats); + const isRuntimeOnly = isRuntimeOnlyAuthFile(item); + const isAistudio = (item.type || '').toLowerCase() === 'aistudio'; + const showModelsButton = !isRuntimeOnly || isAistudio; + const typeColor = getTypeColor(item.type || 'unknown'); - return ( -
-
- +
+
- +
+
+ + +
+ +
} > {modelAliasError === 'unsupported' ? ( @@ -1408,6 +1700,39 @@ export function AuthFilesPage() { title={t('oauth_model_alias.upgrade_required_title')} description={t('oauth_model_alias.upgrade_required_desc')} /> + ) : viewMode === 'diagram' ? ( + Object.keys(modelAlias).length === 0 ? ( + + ) : ( +
+
+

{t('oauth_model_alias.chart_title')}

+ +
+ +
+ ) ) : Object.keys(modelAlias).length === 0 ? ( ) : ( @@ -1625,7 +1950,6 @@ export function AuthFilesPage() {
)} -
); } diff --git a/src/stores/useNotificationStore.ts b/src/stores/useNotificationStore.ts index 17971c5..d0adb09 100644 --- a/src/stores/useNotificationStore.ts +++ b/src/stores/useNotificationStore.ts @@ -4,13 +4,14 @@ */ import { create } from 'zustand'; +import type { ReactNode } from 'react'; import type { Notification, NotificationType } from '@/types'; import { generateId } from '@/utils/helpers'; import { NOTIFICATION_DURATION_MS } from '@/utils/constants'; interface ConfirmationOptions { title?: string; - message: string; + message: ReactNode; confirmText?: string; cancelText?: string; variant?: 'danger' | 'primary' | 'secondary'; From fd1174e0102b9bb3efe874c76d765388d5c44a3e Mon Sep 17 00:00:00 2001 From: thanhtunguet Date: Sat, 31 Jan 2026 21:07:06 +0700 Subject: [PATCH 02/13] fix: update event type handling in ModelMappingDiagram for improved type safety --- src/components/modelAlias/ModelMappingDiagram.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/modelAlias/ModelMappingDiagram.tsx b/src/components/modelAlias/ModelMappingDiagram.tsx index af5abf5..0085c17 100644 --- a/src/components/modelAlias/ModelMappingDiagram.tsx +++ b/src/components/modelAlias/ModelMappingDiagram.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent } from 'react'; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import type { OAuthModelAliasEntry } from '@/types'; @@ -104,7 +104,7 @@ export const ModelMappingDiagram = forwardRef { - const handleClick = (event: MouseEvent) => { + const handleClick = (event: globalThis.MouseEvent) => { if (!contextMenuRef.current?.contains(event.target as Node)) { setContextMenu(null); } @@ -355,7 +355,11 @@ export const ModelMappingDiagram = forwardRef { + const handleContextMenu = ( + e: ReactMouseEvent, + type: 'alias' | 'background' | 'provider' | 'source', + data?: string + ) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ From 01a69ff32b5315ec72d450538a70cadc1891786a Mon Sep 17 00:00:00 2001 From: thanhtunguet Date: Sat, 31 Jan 2026 21:33:26 +0700 Subject: [PATCH 03/13] refactor(AuthFilesPage): optimize provider list generation using useMemo for better performance --- .../ModelMappingDiagram.module.scss | 10 +++-- src/pages/AuthFilesPage.tsx | 42 ++++++++++--------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/components/modelAlias/ModelMappingDiagram.module.scss b/src/components/modelAlias/ModelMappingDiagram.module.scss index 3cfd55c..02a2cc7 100644 --- a/src/components/modelAlias/ModelMappingDiagram.module.scss +++ b/src/components/modelAlias/ModelMappingDiagram.module.scss @@ -1,3 +1,5 @@ +@use '../../styles/variables' as *; + .container { display: flex; position: relative; @@ -143,7 +145,8 @@ } } -.sourceItem, .aliasItem { +.sourceItem, +.aliasItem { cursor: grab; &:active { @@ -175,8 +178,7 @@ right: -3px; } -.aliasItem { -} +.aliasItem {} .providerBadge { font-size: 11px; @@ -320,4 +322,4 @@ &:hover { background: var(--bg-error-light); } -} +} \ No newline at end of file diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 09ed9b5..0e91fa0 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -249,29 +249,31 @@ export function AuthFilesPage() { const disableControls = connectionStatus !== 'connected'; + const providerList = useMemo(() => { + const providers = new Set(); + + Object.keys(modelAlias).forEach((provider) => { + const key = provider.trim().toLowerCase(); + if (key) providers.add(key); + }); + + files.forEach((file) => { + if (typeof file.type === 'string') { + const key = file.type.trim().toLowerCase(); + if (key) providers.add(key); + } + if (typeof file.provider === 'string') { + const key = file.provider.trim().toLowerCase(); + if (key) providers.add(key); + } + }); + return Array.from(providers); + }, [files, modelAlias]); + useEffect(() => { let cancelled = false; const loadAllModels = async () => { - const providers = new Set(); - - Object.keys(modelAlias).forEach((provider) => { - const key = provider.trim().toLowerCase(); - if (key) providers.add(key); - }); - - files.forEach((file) => { - if (typeof file.type === 'string') { - const key = file.type.trim().toLowerCase(); - if (key) providers.add(key); - } - if (typeof file.provider === 'string') { - const key = file.provider.trim().toLowerCase(); - if (key) providers.add(key); - } - }); - - const providerList = Array.from(providers); if (providerList.length === 0) { if (!cancelled) setAllProviderModels({}); return; @@ -305,7 +307,7 @@ export function AuthFilesPage() { return () => { cancelled = true; }; - }, [files, modelAlias]); + }, [providerList]); From ce47d6d9853fa4152e6c215235222141a6ff10dd Mon Sep 17 00:00:00 2001 From: thanhtunguet Date: Sat, 31 Jan 2026 21:34:00 +0700 Subject: [PATCH 04/13] style(ModelMappingDiagram): remove unused aliasItem class from SCSS file --- src/components/modelAlias/ModelMappingDiagram.module.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/modelAlias/ModelMappingDiagram.module.scss b/src/components/modelAlias/ModelMappingDiagram.module.scss index 02a2cc7..69f1db1 100644 --- a/src/components/modelAlias/ModelMappingDiagram.module.scss +++ b/src/components/modelAlias/ModelMappingDiagram.module.scss @@ -178,8 +178,6 @@ right: -3px; } -.aliasItem {} - .providerBadge { font-size: 11px; padding: 2px 6px; From 0d40eecbe7e5544372713ad51917d8719b2937f6 Mon Sep 17 00:00:00 2001 From: thanhtunguet Date: Sat, 31 Jan 2026 21:47:49 +0700 Subject: [PATCH 05/13] refactor(ModelMappingDiagram): restructure component to utilize new modular columns and improve type definitions --- .../modelAlias/ModelMappingDiagram.tsx | 573 ++++-------------- .../modelAlias/ModelMappingDiagramColumns.tsx | 229 +++++++ .../ModelMappingDiagramContextMenu.tsx | 111 ++++ .../modelAlias/ModelMappingDiagramModals.tsx | 267 ++++++++ .../modelAlias/ModelMappingDiagramTypes.ts | 33 + 5 files changed, 757 insertions(+), 456 deletions(-) create mode 100644 src/components/modelAlias/ModelMappingDiagramColumns.tsx create mode 100644 src/components/modelAlias/ModelMappingDiagramContextMenu.tsx create mode 100644 src/components/modelAlias/ModelMappingDiagramModals.tsx create mode 100644 src/components/modelAlias/ModelMappingDiagramTypes.ts diff --git a/src/components/modelAlias/ModelMappingDiagram.tsx b/src/components/modelAlias/ModelMappingDiagram.tsx index 0085c17..0425ba9 100644 --- a/src/components/modelAlias/ModelMappingDiagram.tsx +++ b/src/components/modelAlias/ModelMappingDiagram.tsx @@ -1,23 +1,24 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react'; -import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import type { OAuthModelAliasEntry } from '@/types'; import { useThemeStore } from '@/stores'; -import { Modal } from '@/components/ui/Modal'; -import { Input } from '@/components/ui/Input'; -import { Button } from '@/components/ui/Button'; -import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; -import { IconTrash2 } from '@/components/ui/icons'; +import { AliasColumn, ProviderColumn, SourceColumn } from './ModelMappingDiagramColumns'; +import { DiagramContextMenu } from './ModelMappingDiagramContextMenu'; +import { + AddAliasModal, + RenameAliasModal, + SettingsAliasModal, + SettingsSourceModal +} from './ModelMappingDiagramModals'; +import type { + AliasNode, + AuthFileModelItem, + ContextMenuState, + DiagramLine, + SourceNode +} from './ModelMappingDiagramTypes'; import styles from './ModelMappingDiagram.module.scss'; -// Type definition for models from API -export interface AuthFileModelItem { - id: string; - display_name?: string; - type?: string; - owned_by?: string; -} - export interface ModelMappingDiagramProps { modelAlias: Record; allProviderModels?: Record; @@ -31,7 +32,6 @@ export interface ModelMappingDiagramProps { className?: string; } -// Helper to generate consistent colors const PROVIDER_COLORS = [ '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16' @@ -42,26 +42,6 @@ function getProviderColor(provider: string): string { return PROVIDER_COLORS[hash % PROVIDER_COLORS.length]; } -interface SourceNode { - id: string; // unique: provider::name - provider: string; - name: string; - aliases: { alias: string; fork: boolean }[]; // all aliases this source maps to -} - -interface AliasNode { - id: string; // alias - alias: string; - sources: SourceNode[]; -} - -interface ContextMenuState { - x: number; - y: number; - type: 'alias' | 'background' | 'provider' | 'source'; - data?: string; -} - export interface ModelMappingDiagramRef { collapseAll: () => void; refreshLayout: () => void; @@ -84,7 +64,7 @@ export const ModelMappingDiagram = forwardRef(null); - const [lines, setLines] = useState<{ path: string; color: string; id: string }[]>([]); + const [lines, setLines] = useState([]); const [draggedSource, setDraggedSource] = useState(null); const [draggedAlias, setDraggedAlias] = useState(null); const [dropTargetAlias, setDropTargetAlias] = useState(null); @@ -100,18 +80,6 @@ export const ModelMappingDiagram = forwardRef(null); const [settingsSourceId, setSettingsSourceId] = useState(null); - const contextMenuRef = useRef(null); - - // Close context menu on click outside - useEffect(() => { - const handleClick = (event: globalThis.MouseEvent) => { - if (!contextMenuRef.current?.contains(event.target as Node)) { - setContextMenu(null); - } - }; - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, []); // Parse data: each source model (provider+name) and each alias is distinct by id; 1 source -> many aliases. const { aliasNodes, providerNodes } = useMemo(() => { @@ -458,77 +426,6 @@ export const ModelMappingDiagram = forwardRef { - const aliasData = contextMenu?.data; - if (contextMenu?.type !== 'alias' || aliasData == null) return null; - return ( - <> -
handleRenameClick(aliasData)}> - {t('oauth_model_alias.diagram_rename')} -
-
{ - closeContextMenu(); - setSettingsAlias(aliasData); - }} - > - {t('oauth_model_alias.diagram_settings')} -
-
-
handleDeleteClick(aliasData)}> - {t('oauth_model_alias.diagram_delete_alias')} -
- - ); - }; - - const renderProviderMenu = () => { - const provider = contextMenu?.data; - if (contextMenu?.type !== 'provider' || !provider) return null; - return ( - <> -
{ - closeContextMenu(); - onEditProvider?.(provider); - }} - > - {t('common.edit')} -
-
-
{ - closeContextMenu(); - onDeleteProvider?.(provider); - }} - > - {t('oauth_model_alias.delete')} -
- - ); - }; - - const renderSourceMenu = () => { - const sourceId = contextMenu?.data; - const source = resolveSourceById(sourceId ?? null); - if (contextMenu?.type !== 'source' || !source) return null; - return ( - <> -
{ - closeContextMenu(); - setSettingsSourceId(source.id); - }} - > - {t('oauth_model_alias.diagram_settings')} -
- - ); - }; return (
- {/* Column 1: Providers (mindmap root branch) */} -
{ - e.preventDefault(); - e.stopPropagation(); - handleContextMenu(e, 'background'); + handleContextMenu(e, type, data)} + label={t('oauth_model_alias.diagram_providers')} + expandLabel={t('oauth_model_alias.diagram_expand')} + collapseLabel={t('oauth_model_alias.diagram_collapse')} + /> + { + setDraggedSource(null); + setDropTargetAlias(null); }} - > -
{t('oauth_model_alias.diagram_providers')}
- {providerNodes.map(({ provider, sources }) => { - const collapsed = collapsedProviders.has(provider); - return ( -
{ - if (el) providerRefs.current.set(provider, el); - else providerRefs.current.delete(provider); - }} - className={`${styles.item} ${styles.providerItem}`} - style={{ borderLeftColor: getProviderColor(provider) }} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleContextMenu(e, 'provider', provider); - }} - > - - - {provider} - - {sources.length} -
- ); - })} -
- - {/* Column 2: Source Models (children per provider; hidden when provider collapsed) */} -
{ - e.preventDefault(); - e.stopPropagation(); - handleContextMenu(e, 'background'); + onDragOver={handleDragOverSource} + onDragLeave={handleDragLeaveSource} + onDrop={handleDropOnSource} + onContextMenu={(e, type, data) => handleContextMenu(e, type, data)} + label={t('oauth_model_alias.diagram_source_models')} + /> + { + setDraggedAlias(null); + setDropTargetSource(null); }} - > -
{t('oauth_model_alias.diagram_source_models')}
- {providerNodes.flatMap(({ provider, sources }) => { - if (collapsedProviders.has(provider)) return []; - return sources.map((source) => ( -
{ - if (el) sourceRefs.current.set(source.id, el); - else sourceRefs.current.delete(source.id); - }} - className={`${styles.item} ${styles.sourceItem} ${ - draggedSource?.id === source.id ? styles.dragging : '' - } ${dropTargetSource === source.id ? styles.dropTarget : ''}`} - draggable={!!onUpdate} - onDragStart={(e) => handleDragStart(e, source)} - onDragEnd={() => { - setDraggedSource(null); - setDropTargetAlias(null); - }} - onDragOver={(e) => handleDragOverSource(e, source)} - onDragLeave={handleDragLeaveSource} - onDrop={(e) => handleDropOnSource(e, source)} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleContextMenu(e, 'source', source.id); - }} - > - - {source.name} - -
0 ? 1 : 0.3 - }} - /> -
- )); - })} -
+ onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + onContextMenu={(e, type, data) => handleContextMenu(e, type, data)} + label={t('oauth_model_alias.diagram_aliases')} + /> -
{ - e.preventDefault(); - e.stopPropagation(); - handleContextMenu(e, 'background'); + setContextMenu(null)} + onAddAlias={handleAddAlias} + onRenameAlias={handleRenameClick} + onOpenAliasSettings={(alias) => { + setContextMenu(null); + setSettingsAlias(alias); }} - > -
{t('oauth_model_alias.diagram_aliases')}
- {aliasNodes.map((node) => ( -
{ - if (el) aliasRefs.current.set(node.id, el); - else aliasRefs.current.delete(node.id); - }} - className={`${styles.item} ${styles.aliasItem} ${ - dropTargetAlias === node.alias ? styles.dropTarget : '' - } ${draggedAlias === node.alias ? styles.dragging : ''}`} - draggable={!!onUpdate} - onDragStart={(e) => handleDragStartAlias(e, node.alias)} - onDragEnd={() => { - setDraggedAlias(null); - setDropTargetSource(null); - }} - onDragOver={(e) => handleDragOver(e, node.alias)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, node.alias)} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleContextMenu(e, 'alias', node.alias); - }} - > -
- - {node.alias} - - {node.sources.length} -
- ))} -
+ onDeleteAlias={handleDeleteClick} + onEditProvider={(provider) => { + setContextMenu(null); + onEditProvider?.(provider); + }} + onDeleteProvider={(provider) => { + setContextMenu(null); + onDeleteProvider?.(provider); + }} + onOpenSourceSettings={(sourceId) => { + setContextMenu(null); + setSettingsSourceId(sourceId); + }} + /> - {contextMenu && - createPortal( -
e.stopPropagation()} - > - {contextMenu.type === 'background' && ( -
- {t('oauth_model_alias.diagram_add_alias')} -
- )} - {contextMenu.type === 'alias' && renderAliasMenu()} - {contextMenu.type === 'provider' && renderProviderMenu()} - {contextMenu.type === 'source' && renderSourceMenu()} -
, - document.body - )} - - { + setRenameValue(value); + setRenameError(''); + }} onClose={() => setRenameState(null)} - title={t('oauth_model_alias.diagram_rename_alias_title')} - width={400} - footer={ - <> - - - - } - > - { - setRenameValue(e.target.value); - setRenameError(''); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') handleRenameSubmit(); - }} - error={renameError} - placeholder={t('oauth_model_alias.diagram_rename_placeholder')} - autoFocus - /> - - - + { + setAddAliasValue(value); + setAddAliasError(''); + }} onClose={() => setAddAliasOpen(false)} - title={t('oauth_model_alias.diagram_add_alias_title')} - width={400} - footer={ - <> - - - - } - > - { - setAddAliasValue(e.target.value); - setAddAliasError(''); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') handleAddAliasSubmit(); - }} - error={addAliasError} - placeholder={t('oauth_model_alias.diagram_add_placeholder')} - autoFocus - /> - - - + setSettingsAlias(null)} - title={t('oauth_model_alias.diagram_settings_title', { alias: settingsAlias ?? '' })} - width={720} - footer={ - - } - > - {settingsAlias ? ( - (() => { - const node = aliasNodes.find((n) => n.alias === settingsAlias); - if (!node || node.sources.length === 0) { - return
{t('oauth_model_alias.diagram_settings_empty')}
; - } - return ( -
- {node.sources.map((source) => { - const entry = source.aliases.find((item) => item.alias === settingsAlias); - const forkEnabled = entry?.fork === true; - return ( -
-
- {source.name} - - {settingsAlias} -
-
- - {t('oauth_model_alias.alias_fork_label')} - - handleToggleFork(source.provider, source.name, settingsAlias, value)} - ariaLabel={t('oauth_model_alias.alias_fork_label')} - /> - -
-
- ); - })} -
- ); - })() - ) : null} -
- - + setSettingsSourceId(null)} - title={t('oauth_model_alias.diagram_settings_source_title')} - width={720} - footer={ - - } - > - {settingsSourceId ? ( - (() => { - const source = resolveSourceById(settingsSourceId); - if (!source || source.aliases.length === 0) { - return
{t('oauth_model_alias.diagram_settings_empty')}
; - } - return ( -
- {source.aliases.map((entry) => ( -
-
- {source.name} - - {entry.alias} -
-
- - {t('oauth_model_alias.alias_fork_label')} - - - handleToggleFork(source.provider, source.name, entry.alias, value) - } - ariaLabel={t('oauth_model_alias.alias_fork_label')} - /> - -
-
- ))} -
- ); - })() - ) : null} -
+ onToggleFork={handleToggleFork} + onUnlink={handleUnlinkSource} + />
); }); diff --git a/src/components/modelAlias/ModelMappingDiagramColumns.tsx b/src/components/modelAlias/ModelMappingDiagramColumns.tsx new file mode 100644 index 0000000..eaf7d95 --- /dev/null +++ b/src/components/modelAlias/ModelMappingDiagramColumns.tsx @@ -0,0 +1,229 @@ +import type { DragEvent, MouseEvent as ReactMouseEvent, RefObject } from 'react'; +import type { AliasNode, ProviderNode, SourceNode } from './ModelMappingDiagramTypes'; +import styles from './ModelMappingDiagram.module.scss'; + +interface ProviderColumnProps { + providerNodes: ProviderNode[]; + collapsedProviders: Set; + getProviderColor: (provider: string) => string; + providerRefs: RefObject>; + onToggleCollapse: (provider: string) => void; + onContextMenu: (e: ReactMouseEvent, type: 'provider' | 'background', data?: string) => void; + label: string; + expandLabel: string; + collapseLabel: string; +} + +export function ProviderColumn({ + providerNodes, + collapsedProviders, + getProviderColor, + providerRefs, + onToggleCollapse, + onContextMenu, + label, + expandLabel, + collapseLabel +}: ProviderColumnProps) { + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + onContextMenu(e, 'background'); + }} + > +
{label}
+ {providerNodes.map(({ provider, sources }) => { + const collapsed = collapsedProviders.has(provider); + return ( +
{ + if (el) providerRefs.current?.set(provider, el); + else providerRefs.current?.delete(provider); + }} + className={`${styles.item} ${styles.providerItem}`} + style={{ borderLeftColor: getProviderColor(provider) }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(e, 'provider', provider); + }} + > + + + {provider} + + {sources.length} +
+ ); + })} +
+ ); +} + +interface SourceColumnProps { + providerNodes: ProviderNode[]; + collapsedProviders: Set; + sourceRefs: RefObject>; + getProviderColor: (provider: string) => string; + draggedSource: SourceNode | null; + dropTargetSource: string | null; + draggable: boolean; + onDragStart: (e: DragEvent, source: SourceNode) => void; + onDragEnd: () => void; + onDragOver: (e: DragEvent, source: SourceNode) => void; + onDragLeave: () => void; + onDrop: (e: DragEvent, source: SourceNode) => void; + onContextMenu: (e: ReactMouseEvent, type: 'source' | 'background', data?: string) => void; + label: string; +} + +export function SourceColumn({ + providerNodes, + collapsedProviders, + sourceRefs, + getProviderColor, + draggedSource, + dropTargetSource, + draggable, + onDragStart, + onDragEnd, + onDragOver, + onDragLeave, + onDrop, + onContextMenu, + label +}: SourceColumnProps) { + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + onContextMenu(e, 'background'); + }} + > +
{label}
+ {providerNodes.flatMap(({ provider, sources }) => { + if (collapsedProviders.has(provider)) return []; + return sources.map((source) => ( +
{ + if (el) sourceRefs.current?.set(source.id, el); + else sourceRefs.current?.delete(source.id); + }} + className={`${styles.item} ${styles.sourceItem} ${ + draggedSource?.id === source.id ? styles.dragging : '' + } ${dropTargetSource === source.id ? styles.dropTarget : ''}`} + draggable={draggable} + onDragStart={(e) => onDragStart(e, source)} + onDragEnd={onDragEnd} + onDragOver={(e) => onDragOver(e, source)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, source)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(e, 'source', source.id); + }} + > + + {source.name} + +
0 ? 1 : 0.3 + }} + /> +
+ )); + })} +
+ ); +} + +interface AliasColumnProps { + aliasNodes: AliasNode[]; + aliasRefs: RefObject>; + dropTargetAlias: string | null; + draggedAlias: string | null; + draggable: boolean; + onDragStart: (e: DragEvent, alias: string) => void; + onDragEnd: () => void; + onDragOver: (e: DragEvent, alias: string) => void; + onDragLeave: () => void; + onDrop: (e: DragEvent, alias: string) => void; + onContextMenu: (e: ReactMouseEvent, type: 'alias' | 'background', data?: string) => void; + label: string; +} + +export function AliasColumn({ + aliasNodes, + aliasRefs, + dropTargetAlias, + draggedAlias, + draggable, + onDragStart, + onDragEnd, + onDragOver, + onDragLeave, + onDrop, + onContextMenu, + label +}: AliasColumnProps) { + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + onContextMenu(e, 'background'); + }} + > +
{label}
+ {aliasNodes.map((node) => ( +
{ + if (el) aliasRefs.current?.set(node.id, el); + else aliasRefs.current?.delete(node.id); + }} + className={`${styles.item} ${styles.aliasItem} ${ + dropTargetAlias === node.alias ? styles.dropTarget : '' + } ${draggedAlias === node.alias ? styles.dragging : ''}`} + draggable={draggable} + onDragStart={(e) => onDragStart(e, node.alias)} + onDragEnd={onDragEnd} + onDragOver={(e) => onDragOver(e, node.alias)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, node.alias)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(e, 'alias', node.alias); + }} + > +
+ + {node.alias} + + {node.sources.length} +
+ ))} +
+ ); +} diff --git a/src/components/modelAlias/ModelMappingDiagramContextMenu.tsx b/src/components/modelAlias/ModelMappingDiagramContextMenu.tsx new file mode 100644 index 0000000..390ccfc --- /dev/null +++ b/src/components/modelAlias/ModelMappingDiagramContextMenu.tsx @@ -0,0 +1,111 @@ +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import type { TFunction } from 'i18next'; +import type { ContextMenuState } from './ModelMappingDiagramTypes'; +import styles from './ModelMappingDiagram.module.scss'; + +interface DiagramContextMenuProps { + contextMenu: ContextMenuState | null; + t: TFunction; + onRequestClose: () => void; + onAddAlias: () => void; + onRenameAlias: (alias: string) => void; + onOpenAliasSettings: (alias: string) => void; + onDeleteAlias: (alias: string) => void; + onEditProvider: (provider: string) => void; + onDeleteProvider: (provider: string) => void; + onOpenSourceSettings: (sourceId: string) => void; +} + +export function DiagramContextMenu({ + contextMenu, + t, + onRequestClose, + onAddAlias, + onRenameAlias, + onOpenAliasSettings, + onDeleteAlias, + onEditProvider, + onDeleteProvider, + onOpenSourceSettings +}: DiagramContextMenuProps) { + const menuRef = useRef(null); + + useEffect(() => { + if (!contextMenu) return; + const handleClick = (event: globalThis.MouseEvent) => { + if (!menuRef.current?.contains(event.target as Node)) { + onRequestClose(); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [contextMenu, onRequestClose]); + + if (!contextMenu) return null; + + const { type, data } = contextMenu; + + const renderBackground = () => ( +
+ {t('oauth_model_alias.diagram_add_alias')} +
+ ); + + const renderAlias = () => { + if (!data) return null; + return ( + <> +
onRenameAlias(data)}> + {t('oauth_model_alias.diagram_rename')} +
+
onOpenAliasSettings(data)}> + {t('oauth_model_alias.diagram_settings')} +
+
+
onDeleteAlias(data)}> + {t('oauth_model_alias.diagram_delete_alias')} +
+ + ); + }; + + const renderProvider = () => { + if (!data) return null; + return ( + <> +
onEditProvider(data)}> + {t('common.edit')} +
+
+
onDeleteProvider(data)}> + {t('oauth_model_alias.delete')} +
+ + ); + }; + + const renderSource = () => { + if (!data) return null; + return ( +
onOpenSourceSettings(data)}> + {t('oauth_model_alias.diagram_settings')} +
+ ); + }; + + return createPortal( +
e.stopPropagation()} + > + {type === 'background' && renderBackground()} + {type === 'alias' && renderAlias()} + {type === 'provider' && renderProvider()} + {type === 'source' && renderSource()} +
, + document.body + ); +} diff --git a/src/components/modelAlias/ModelMappingDiagramModals.tsx b/src/components/modelAlias/ModelMappingDiagramModals.tsx new file mode 100644 index 0000000..6f8e2fb --- /dev/null +++ b/src/components/modelAlias/ModelMappingDiagramModals.tsx @@ -0,0 +1,267 @@ +import type { KeyboardEvent } from 'react'; +import type { TFunction } from 'i18next'; +import { Modal } from '@/components/ui/Modal'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; +import { IconTrash2 } from '@/components/ui/icons'; +import type { AliasNode, SourceNode } from './ModelMappingDiagramTypes'; +import styles from './ModelMappingDiagram.module.scss'; + +interface RenameAliasModalProps { + open: boolean; + t: TFunction; + value: string; + error: string; + onChange: (value: string) => void; + onClose: () => void; + onSubmit: () => void; +} + +export function RenameAliasModal({ + open, + t, + value, + error, + onChange, + onClose, + onSubmit +}: RenameAliasModalProps) { + return ( + + + + + } + > + onChange(e.target.value)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter') onSubmit(); + }} + error={error} + placeholder={t('oauth_model_alias.diagram_rename_placeholder')} + autoFocus + /> + + ); +} + +interface AddAliasModalProps { + open: boolean; + t: TFunction; + value: string; + error: string; + onChange: (value: string) => void; + onClose: () => void; + onSubmit: () => void; +} + +export function AddAliasModal({ + open, + t, + value, + error, + onChange, + onClose, + onSubmit +}: AddAliasModalProps) { + return ( + + + + + } + > + onChange(e.target.value)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter') onSubmit(); + }} + error={error} + placeholder={t('oauth_model_alias.diagram_add_placeholder')} + autoFocus + /> + + ); +} + +interface SettingsAliasModalProps { + open: boolean; + t: TFunction; + alias: string | null; + aliasNodes: AliasNode[]; + onClose: () => void; + onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void; + onUnlink: (provider: string, sourceModel: string, alias: string) => void; +} + +export function SettingsAliasModal({ + open, + t, + alias, + aliasNodes, + onClose, + onToggleFork, + onUnlink +}: SettingsAliasModalProps) { + return ( + + {t('common.close')} + + } + > + {alias ? ( + (() => { + const node = aliasNodes.find((n) => n.alias === alias); + if (!node || node.sources.length === 0) { + return
{t('oauth_model_alias.diagram_settings_empty')}
; + } + return ( +
+ {node.sources.map((source) => { + const entry = source.aliases.find((item) => item.alias === alias); + const forkEnabled = entry?.fork === true; + return ( +
+
+ {source.name} + + {alias} +
+
+ + {t('oauth_model_alias.alias_fork_label')} + + onToggleFork(source.provider, source.name, alias, value)} + ariaLabel={t('oauth_model_alias.alias_fork_label')} + /> + +
+
+ ); + })} +
+ ); + })() + ) : null} +
+ ); +} + +interface SettingsSourceModalProps { + open: boolean; + t: TFunction; + source: SourceNode | null; + onClose: () => void; + onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void; + onUnlink: (provider: string, sourceModel: string, alias: string) => void; +} + +export function SettingsSourceModal({ + open, + t, + source, + onClose, + onToggleFork, + onUnlink +}: SettingsSourceModalProps) { + return ( + + {t('common.close')} + + } + > + {source ? ( + source.aliases.length === 0 ? ( +
{t('oauth_model_alias.diagram_settings_empty')}
+ ) : ( +
+ {source.aliases.map((entry) => ( +
+
+ {source.name} + + {entry.alias} +
+
+ + {t('oauth_model_alias.alias_fork_label')} + + onToggleFork(source.provider, source.name, entry.alias, value)} + ariaLabel={t('oauth_model_alias.alias_fork_label')} + /> + +
+
+ ))} +
+ ) + ) : null} +
+ ); +} diff --git a/src/components/modelAlias/ModelMappingDiagramTypes.ts b/src/components/modelAlias/ModelMappingDiagramTypes.ts new file mode 100644 index 0000000..704692d --- /dev/null +++ b/src/components/modelAlias/ModelMappingDiagramTypes.ts @@ -0,0 +1,33 @@ +export interface AuthFileModelItem { + id: string; + display_name?: string; + type?: string; + owned_by?: string; +} + +export interface SourceNode { + id: string; // unique: provider::name + provider: string; + name: string; + aliases: { alias: string; fork: boolean }[]; // all aliases this source maps to +} + +export interface AliasNode { + id: string; // alias + alias: string; + sources: SourceNode[]; +} + +export interface ProviderNode { + provider: string; + sources: SourceNode[]; +} + +export interface ContextMenuState { + x: number; + y: number; + type: 'alias' | 'background' | 'provider' | 'source'; + data?: string; +} + +export type DiagramLine = { path: string; color: string; id: string }; From 08e8fe2eddb65c786d54389dfb504f3193e42eae Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Wed, 4 Feb 2026 01:42:32 +0800 Subject: [PATCH 06/13] fix(ProviderNav): remove unused provider icons and clean up code --- src/components/common/PageTransition.tsx | 24 +++++++++++++++++-- .../providers/ProviderNav/ProviderNav.tsx | 5 +++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx index a91e59a..5efd7ad 100644 --- a/src/components/common/PageTransition.tsx +++ b/src/components/common/PageTransition.tsx @@ -1,4 +1,12 @@ -import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { + ReactNode, + createContext, + useCallback, + useContext, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { useLocation, type Location } from 'react-router-dom'; import gsap from 'gsap'; import './PageTransition.scss'; @@ -31,6 +39,16 @@ type TransitionDirection = 'forward' | 'backward'; type TransitionVariant = 'vertical' | 'ios'; +type PageTransitionLayerContextValue = { + status: LayerStatus; +}; + +const PageTransitionLayerContext = createContext(null); + +export function usePageTransitionLayer() { + return useContext(PageTransitionLayerContext); +} + export function PageTransition({ render, getRouteOrder, @@ -363,7 +381,9 @@ export function PageTransition({ : undefined } > - {render(layer.location)} + + {render(layer.location)} +
); }); diff --git a/src/components/providers/ProviderNav/ProviderNav.tsx b/src/components/providers/ProviderNav/ProviderNav.tsx index 7c76ea1..c58c6db 100644 --- a/src/components/providers/ProviderNav/ProviderNav.tsx +++ b/src/components/providers/ProviderNav/ProviderNav.tsx @@ -1,6 +1,7 @@ import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useLocation } from 'react-router-dom'; +import { usePageTransitionLayer } from '@/components/common/PageTransition'; import { useThemeStore } from '@/stores'; import iconGemini from '@/assets/icons/gemini.svg'; import iconOpenaiLight from '@/assets/icons/openai-light.svg'; @@ -34,6 +35,8 @@ type ScrollContainer = HTMLElement | (Window & typeof globalThis); export function ProviderNav() { const location = useLocation(); + const pageTransitionLayer = usePageTransitionLayer(); + const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true; const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const [activeProvider, setActiveProvider] = useState(null); const contentScrollerRef = useRef(null); @@ -62,7 +65,7 @@ export function ProviderNav() { location.pathname.length > 1 && location.pathname.endsWith('/') ? location.pathname.slice(0, -1) : location.pathname; - const shouldShow = normalizedPathname === '/ai-providers'; + const shouldShow = isCurrentLayer && normalizedPathname === '/ai-providers'; const getHeaderHeight = useCallback(() => { const header = document.querySelector('.main-header') as HTMLElement | null; From 473cece09eac8304b652293df5a1ce2bf37c4f6a Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:31:09 +0800 Subject: [PATCH 07/13] fix(quota): classify codex windows by 5-hour and weekly limits --- src/components/quota/quotaConfigs.ts | 138 +++++++++++++++++---------- src/i18n/locales/en.json | 3 +- src/i18n/locales/zh-CN.json | 3 +- 3 files changed, 93 insertions(+), 51 deletions(-) diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index 096bc1f..abdac0e 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -10,13 +10,14 @@ import type { AntigravityModelsPayload, AntigravityQuotaState, AuthFileItem, + CodexRateLimitInfo, CodexQuotaState, CodexUsageWindow, CodexQuotaWindow, CodexUsagePayload, GeminiCliParsedBucket, GeminiCliQuotaBucketState, - GeminiCliQuotaState + GeminiCliQuotaState, } from '@/types'; import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api'; import { @@ -47,7 +48,7 @@ import { isCodexFile, isDisabledAuthFile, isGeminiCliFile, - isRuntimeOnlyAuthFile + isRuntimeOnlyAuthFile, } from '@/utils/quota'; import type { QuotaRenderHelpers } from './QuotaCard'; import styles from '@/pages/QuotaPage.module.scss'; @@ -142,7 +143,7 @@ const fetchAntigravityQuota = async ( method: 'POST', url, header: { ...ANTIGRAVITY_REQUEST_HEADERS }, - data: requestBody + data: requestBody, }); if (result.statusCode < 200 || result.statusCode >= 300) { @@ -189,6 +190,15 @@ const fetchAntigravityQuota = async ( }; const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => { + const FIVE_HOUR_SECONDS = 18000; + const WEEK_SECONDS = 604800; + const WINDOW_META = { + codeFiveHour: { id: 'five-hour', labelKey: 'codex_quota.primary_window' }, + codeWeekly: { id: 'weekly', labelKey: 'codex_quota.secondary_window' }, + codeReviewFiveHour: { id: 'code-review-five-hour', labelKey: 'codex_quota.code_review_primary_window' }, + codeReviewWeekly: { id: 'code-review-weekly', labelKey: 'codex_quota.code_review_secondary_window' }, + } as const; + const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined; const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined; const windows: CodexQuotaWindow[] = []; @@ -210,30 +220,74 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex label: t(labelKey), labelKey, usedPercent, - resetLabel + resetLabel, }); }; + const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => { + if (!window) return null; + return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds); + }; + + const rawLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached; + const rawAllowed = rateLimit?.allowed; + + const pickClassifiedWindows = ( + limitInfo?: CodexRateLimitInfo | null + ): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => { + const rawWindows = [ + limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null, + limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null, + ]; + + let fiveHourWindow: CodexUsageWindow | null = null; + let weeklyWindow: CodexUsageWindow | null = null; + + for (const window of rawWindows) { + if (!window) continue; + const seconds = getWindowSeconds(window); + if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) { + fiveHourWindow = window; + } else if (seconds === WEEK_SECONDS && !weeklyWindow) { + weeklyWindow = window; + } + } + + return { fiveHourWindow, weeklyWindow }; + }; + + const rateWindows = pickClassifiedWindows(rateLimit); addWindow( - 'primary', - 'codex_quota.primary_window', - rateLimit?.primary_window ?? rateLimit?.primaryWindow, - rateLimit?.limit_reached ?? rateLimit?.limitReached, - rateLimit?.allowed + WINDOW_META.codeFiveHour.id, + WINDOW_META.codeFiveHour.labelKey, + rateWindows.fiveHourWindow, + rawLimitReached, + rawAllowed ); addWindow( - 'secondary', - 'codex_quota.secondary_window', - rateLimit?.secondary_window ?? rateLimit?.secondaryWindow, - rateLimit?.limit_reached ?? rateLimit?.limitReached, - rateLimit?.allowed + WINDOW_META.codeWeekly.id, + WINDOW_META.codeWeekly.labelKey, + rateWindows.weeklyWindow, + rawLimitReached, + rawAllowed + ); + + const codeReviewWindows = pickClassifiedWindows(codeReviewLimit); + const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached; + const codeReviewAllowed = codeReviewLimit?.allowed; + addWindow( + WINDOW_META.codeReviewFiveHour.id, + WINDOW_META.codeReviewFiveHour.labelKey, + codeReviewWindows.fiveHourWindow, + codeReviewLimitReached, + codeReviewAllowed ); addWindow( - 'code-review', - 'codex_quota.code_review_window', - codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow, - codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached, - codeReviewLimit?.allowed + WINDOW_META.codeReviewWeekly.id, + WINDOW_META.codeReviewWeekly.labelKey, + codeReviewWindows.weeklyWindow, + codeReviewLimitReached, + codeReviewAllowed ); return windows; @@ -257,14 +311,14 @@ const fetchCodexQuota = async ( const requestHeader: Record = { ...CODEX_REQUEST_HEADERS, - 'Chatgpt-Account-Id': accountId + 'Chatgpt-Account-Id': accountId, }; const result = await apiCallApi.request({ authIndex, method: 'GET', url: CODEX_USAGE_URL, - header: requestHeader + header: requestHeader, }); if (result.statusCode < 200 || result.statusCode >= 300) { @@ -301,7 +355,7 @@ const fetchGeminiCliQuota = async ( method: 'POST', url: GEMINI_CLI_QUOTA_URL, header: { ...GEMINI_CLI_REQUEST_HEADERS }, - data: JSON.stringify({ project: projectId }) + data: JSON.stringify({ project: projectId }), }); if (result.statusCode < 200 || result.statusCode >= 300) { @@ -320,7 +374,9 @@ const fetchGeminiCliQuota = async ( const remainingFractionRaw = normalizeQuotaFraction( bucket.remainingFraction ?? bucket.remaining_fraction ); - const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount); + const remainingAmount = normalizeNumberValue( + bucket.remainingAmount ?? bucket.remaining_amount + ); const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined; let fallbackFraction: number | null = null; if (remainingAmount !== null) { @@ -334,7 +390,7 @@ const fetchGeminiCliQuota = async ( tokenType, remainingFraction, remainingAmount, - resetTime + resetTime, }; }) .filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null); @@ -366,11 +422,7 @@ const renderAntigravityItems = ( h( 'div', { className: styleMap.quotaRowHeader }, - h( - 'span', - { className: styleMap.quotaModel, title: group.models.join(', ') }, - group.label - ), + h('span', { className: styleMap.quotaModel, title: group.models.join(', ') }, group.label), h( 'div', { className: styleMap.quotaMeta }, @@ -403,7 +455,6 @@ const renderCodexItems = ( }; const planLabel = getPlanLabel(planType); - const isFreePlan = normalizePlanType(planType) === 'free'; const nodes: ReactNode[] = []; if (planLabel) { @@ -417,17 +468,6 @@ const renderCodexItems = ( ); } - if (isFreePlan) { - nodes.push( - h( - 'div', - { key: 'warning', className: styleMap.quotaWarning }, - t('codex_quota.no_access') - ) - ); - return h(Fragment, null, ...nodes); - } - if (windows.length === 0) { nodes.push( h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows')) @@ -487,7 +527,7 @@ const renderGeminiCliItems = ( bucket.remainingAmount === null || bucket.remainingAmount === undefined ? null : t('gemini_cli_quota.remaining_amount', { - count: bucket.remainingAmount + count: bucket.remainingAmount, }); const titleBase = bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label; @@ -530,13 +570,13 @@ export const ANTIGRAVITY_CONFIG: QuotaConfig ({ status: 'success', windows: data.windows, - planType: data.planType + planType: data.planType, }), buildErrorState: (message, status) => ({ status: 'error', windows: [], error: message, - errorStatus: status + errorStatus: status, }), cardClassName: styles.codexCard, controlsClassName: styles.codexControls, controlClassName: styles.codexControl, gridClassName: styles.codexGrid, - renderQuotaItems: renderCodexItems + renderQuotaItems: renderCodexItems, }; export const GEMINI_CLI_CONFIG: QuotaConfig = { @@ -582,11 +622,11 @@ export const GEMINI_CLI_CONFIG: QuotaConfig Date: Wed, 4 Feb 2026 22:00:57 +0800 Subject: [PATCH 08/13] fix(ui): scope ToggleSwitch styles with CSS Modules to prevent label text collapsing into a vertical column --- src/components/ui/ToggleSwitch.module.scss | 58 ++++++++++++++++++++++ src/components/ui/ToggleSwitch.tsx | 13 +++-- 2 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 src/components/ui/ToggleSwitch.module.scss diff --git a/src/components/ui/ToggleSwitch.module.scss b/src/components/ui/ToggleSwitch.module.scss new file mode 100644 index 0000000..09045cb --- /dev/null +++ b/src/components/ui/ToggleSwitch.module.scss @@ -0,0 +1,58 @@ +.root { + position: relative; + display: inline-flex; + align-items: center; + gap: $spacing-sm; + cursor: pointer; +} + +.labelLeft { + .label { + order: -1; + } +} + +.disabled { + cursor: not-allowed; +} + +.root input { + width: 0; + height: 0; + opacity: 0; + position: absolute; +} + +.track { + width: 44px; + height: 24px; + background: var(--border-color); + border-radius: $radius-full; + position: relative; + transition: background $transition-fast; +} + +.thumb { + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + background: #fff; + border-radius: $radius-full; + box-shadow: $shadow-sm; + transition: transform $transition-fast; +} + +.root input:checked + .track { + background: var(--primary-color); +} + +.root input:checked + .track .thumb { + transform: translateX(20px); +} + +.label { + color: var(--text-primary); + font-weight: 600; +} diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx index 3fbaa62..7256c62 100644 --- a/src/components/ui/ToggleSwitch.tsx +++ b/src/components/ui/ToggleSwitch.tsx @@ -1,4 +1,5 @@ import type { ChangeEvent, ReactNode } from 'react'; +import styles from './ToggleSwitch.module.scss'; interface ToggleSwitchProps { checked: boolean; @@ -21,7 +22,11 @@ export function ToggleSwitch({ onChange(event.target.checked); }; - const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : ''] + const className = [ + styles.root, + labelPosition === 'left' ? styles.labelLeft : '', + disabled ? styles.disabled : '', + ] .filter(Boolean) .join(' '); @@ -34,10 +39,10 @@ export function ToggleSwitch({ disabled={disabled} aria-label={ariaLabel} /> - - + + - {label && {label}} + {label && {label}} ); } From 759e369d42f11c393bd27e98a6cc4d521bb04fee Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Thu, 5 Feb 2026 00:02:05 +0800 Subject: [PATCH 09/13] fix(drag-and-drop): add data transfer for source and alias during drag events fix(i18n): update view mode labels in Chinese localization fix(auth-files): set fork to true in empty mapping entry and improve error handling in save/delete operations --- .../modelAlias/ModelMappingDiagram.tsx | 4 +- src/i18n/locales/zh-CN.json | 4 +- .../AuthFilesOAuthModelAliasEditPage.tsx | 2 +- src/pages/AuthFilesPage.tsx | 62 ++++++++++++++++--- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/components/modelAlias/ModelMappingDiagram.tsx b/src/components/modelAlias/ModelMappingDiagram.tsx index 0425ba9..d179b0c 100644 --- a/src/components/modelAlias/ModelMappingDiagram.tsx +++ b/src/components/modelAlias/ModelMappingDiagram.tsx @@ -274,6 +274,7 @@ export const ModelMappingDiagram = forwardRef Alias const handleDragStart = (e: DragEvent, source: SourceNode) => { setDraggedSource(source); + e.dataTransfer.setData('text/plain', source.id); e.dataTransfer.effectAllowed = 'link'; }; @@ -300,6 +301,7 @@ export const ModelMappingDiagram = forwardRef Source const handleDragStartAlias = (e: DragEvent, alias: string) => { setDraggedAlias(alias); + e.dataTransfer.setData('text/plain', alias); e.dataTransfer.effectAllowed = 'link'; }; @@ -429,7 +431,7 @@ export const ModelMappingDiagram = forwardRef { e.preventDefault(); diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d00d49d..c748757 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -578,8 +578,8 @@ "diagram_settings_source_title": "源模型设置", "diagram_settings_empty": "该别名暂无映射。", "view_mode": "视图模式", - "view_mode_diagram": "Diagram", - "view_mode_list": "List", + "view_mode_diagram": "概览", + "view_mode_list": "管理", "provider_required": "请先填写提供商名称", "upgrade_required": "当前 CPA 版本不支持模型别名功能,请升级 CPA 版本", "upgrade_required_title": "需要升级 CPA 版本", diff --git a/src/pages/AuthFilesOAuthModelAliasEditPage.tsx b/src/pages/AuthFilesOAuthModelAliasEditPage.tsx index aee8a16..35e617c 100644 --- a/src/pages/AuthFilesOAuthModelAliasEditPage.tsx +++ b/src/pages/AuthFilesOAuthModelAliasEditPage.tsx @@ -40,7 +40,7 @@ const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({ id: generateId(), name: '', alias: '', - fork: false, + fork: true, }); const normalizeMappingEntries = ( diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 0e91fa0..8cc7785 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -271,6 +271,8 @@ export function AuthFilesPage() { }, [files, modelAlias]); useEffect(() => { + if (viewMode !== 'diagram') return; + let cancelled = false; const loadAllModels = async () => { @@ -307,7 +309,7 @@ export function AuthFilesPage() { return () => { cancelled = true; }; - }, [providerList]); + }, [providerList, viewMode]); @@ -1200,8 +1202,11 @@ export function AuthFilesPage() { if (providersToUpdate.length === 0) return; + let hadFailure = false; + let failureMessage = ''; + try { - await Promise.all( + const results = await Promise.allSettled( providersToUpdate.map(([provider, mappings]) => { const nextMappings = mappings.map((m) => (m.alias ?? '').trim().toLowerCase() === oldKey ? { ...m, alias: newTrim } : m @@ -1209,11 +1214,29 @@ export function AuthFilesPage() { return authFilesApi.saveOauthModelAlias(provider, nextMappings); }) ); + + const failures = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + + if (failures.length > 0) { + hadFailure = true; + const reason = failures[0].reason; + failureMessage = reason instanceof Error ? reason.message : String(reason ?? ''); + } + } finally { await loadModelAlias(); + } + + if (hadFailure) { + showNotification( + failureMessage + ? `${t('oauth_model_alias.save_failed')}: ${failureMessage}` + : t('oauth_model_alias.save_failed'), + 'error' + ); + } else { showNotification(t('oauth_model_alias.save_success'), 'success'); - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : ''; - showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error'); } }; @@ -1239,8 +1262,11 @@ export function AuthFilesPage() { variant: 'danger', confirmText: t('common.confirm'), onConfirm: async () => { + let hadFailure = false; + let failureMessage = ''; + try { - await Promise.all( + const results = await Promise.allSettled( providersToUpdate.map(([provider, mappings]) => { const nextMappings = mappings.filter( (m) => (m.alias ?? '').trim().toLowerCase() !== aliasKey @@ -1251,11 +1277,29 @@ export function AuthFilesPage() { return authFilesApi.saveOauthModelAlias(provider, nextMappings); }) ); + + const failures = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + + if (failures.length > 0) { + hadFailure = true; + const reason = failures[0].reason; + failureMessage = reason instanceof Error ? reason.message : String(reason ?? ''); + } + } finally { await loadModelAlias(); + } + + if (hadFailure) { + showNotification( + failureMessage + ? `${t('oauth_model_alias.delete_failed')}: ${failureMessage}` + : t('oauth_model_alias.delete_failed'), + 'error' + ); + } else { showNotification(t('oauth_model_alias.delete_success'), 'success'); - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : ''; - showNotification(`${t('oauth_model_alias.delete_failed')}: ${errorMessage}`, 'error'); } }, }); From 9887a7888950fc30c4c9fc434f0478f890b4ad19 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Thu, 5 Feb 2026 00:23:18 +0800 Subject: [PATCH 10/13] fix(layout): add scroll container for improved responsiveness and adjust container styles --- .../ModelMappingDiagram.module.scss | 21 ++- .../modelAlias/ModelMappingDiagram.tsx | 134 +++++++++--------- 2 files changed, 85 insertions(+), 70 deletions(-) diff --git a/src/components/modelAlias/ModelMappingDiagram.module.scss b/src/components/modelAlias/ModelMappingDiagram.module.scss index 69f1db1..c850f9e 100644 --- a/src/components/modelAlias/ModelMappingDiagram.module.scss +++ b/src/components/modelAlias/ModelMappingDiagram.module.scss @@ -1,13 +1,26 @@ @use '../../styles/variables' as *; -.container { - display: flex; - position: relative; +.scrollContainer { width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; +} + +.container { + display: inline-flex; + position: relative; + min-width: 100%; min-height: 300px; justify-content: space-between; padding: 20px 0; user-select: none; + + @media (max-width: 768px) { + justify-content: flex-start; + gap: 16px; + padding: 12px 0; + } } // SVG layer for connection lines (behind columns so links are visible) @@ -320,4 +333,4 @@ &:hover { background: var(--bg-error-light); } -} \ No newline at end of file +} diff --git a/src/components/modelAlias/ModelMappingDiagram.tsx b/src/components/modelAlias/ModelMappingDiagram.tsx index d179b0c..2fdd84c 100644 --- a/src/components/modelAlias/ModelMappingDiagram.tsx +++ b/src/components/modelAlias/ModelMappingDiagram.tsx @@ -430,73 +430,75 @@ export const ModelMappingDiagram = forwardRef { - e.preventDefault(); - e.stopPropagation(); - handleContextMenu(e, 'background'); - }} - > - - {lines.map((line) => ( - - ))} - +
+
{ + e.preventDefault(); + e.stopPropagation(); + handleContextMenu(e, 'background'); + }} + > + + {lines.map((line) => ( + + ))} + - handleContextMenu(e, type, data)} - label={t('oauth_model_alias.diagram_providers')} - expandLabel={t('oauth_model_alias.diagram_expand')} - collapseLabel={t('oauth_model_alias.diagram_collapse')} - /> - { - setDraggedSource(null); - setDropTargetAlias(null); - }} - onDragOver={handleDragOverSource} - onDragLeave={handleDragLeaveSource} - onDrop={handleDropOnSource} - onContextMenu={(e, type, data) => handleContextMenu(e, type, data)} - label={t('oauth_model_alias.diagram_source_models')} - /> - { - setDraggedAlias(null); - setDropTargetSource(null); - }} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - onContextMenu={(e, type, data) => handleContextMenu(e, type, data)} - label={t('oauth_model_alias.diagram_aliases')} - /> + handleContextMenu(e, type, data)} + label={t('oauth_model_alias.diagram_providers')} + expandLabel={t('oauth_model_alias.diagram_expand')} + collapseLabel={t('oauth_model_alias.diagram_collapse')} + /> + { + setDraggedSource(null); + setDropTargetAlias(null); + }} + onDragOver={handleDragOverSource} + onDragLeave={handleDragLeaveSource} + onDrop={handleDropOnSource} + onContextMenu={(e, type, data) => handleContextMenu(e, type, data)} + label={t('oauth_model_alias.diagram_source_models')} + /> + { + setDraggedAlias(null); + setDropTargetSource(null); + }} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + onContextMenu={(e, type, data) => handleContextMenu(e, type, data)} + label={t('oauth_model_alias.diagram_aliases')} + /> +
Date: Thu, 5 Feb 2026 00:55:03 +0800 Subject: [PATCH 11/13] fix(model-alias): improve diagram mobile layout and refresh reliability --- .../ModelMappingDiagram.module.scss | 11 +++- .../modelAlias/ModelMappingDiagram.tsx | 45 +++++++++++++--- .../modelAlias/ModelMappingDiagramColumns.tsx | 54 +++++++++++-------- 3 files changed, 77 insertions(+), 33 deletions(-) diff --git a/src/components/modelAlias/ModelMappingDiagram.module.scss b/src/components/modelAlias/ModelMappingDiagram.module.scss index c850f9e..561a2c2 100644 --- a/src/components/modelAlias/ModelMappingDiagram.module.scss +++ b/src/components/modelAlias/ModelMappingDiagram.module.scss @@ -17,8 +17,8 @@ user-select: none; @media (max-width: 768px) { - justify-content: flex-start; - gap: 16px; + // Give mobile extra horizontal room to reduce line overlap; users can swipe to scroll. + min-width: max(100%, 960px); padding: 12px 0; } } @@ -158,6 +158,13 @@ } } +.providerGroup { + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; +} + .sourceItem, .aliasItem { cursor: grab; diff --git a/src/components/modelAlias/ModelMappingDiagram.tsx b/src/components/modelAlias/ModelMappingDiagram.tsx index 2fdd84c..57906d9 100644 --- a/src/components/modelAlias/ModelMappingDiagram.tsx +++ b/src/components/modelAlias/ModelMappingDiagram.tsx @@ -72,6 +72,7 @@ export const ModelMappingDiagram = forwardRef([]); const [contextMenu, setContextMenu] = useState(null); const [collapsedProviders, setCollapsedProviders] = useState>(new Set()); + const [providerGroupHeights, setProviderGroupHeights] = useState>({}); const [renameState, setRenameState] = useState<{ oldAlias: string } | null>(null); const [renameValue, setRenameValue] = useState(''); const [renameError, setRenameError] = useState(''); @@ -179,6 +180,7 @@ export const ModelMappingDiagram = forwardRef = {}; const bezier = ( x1: number, y1: number, @@ -193,6 +195,15 @@ export const ModelMappingDiagram = forwardRef 0) { + const firstEl = sourceRefs.current.get(sources[0].id); + const lastEl = sourceRefs.current.get(sources[sources.length - 1].id); + if (firstEl && lastEl) { + const height = Math.max(0, Math.round(lastEl.getBoundingClientRect().bottom - firstEl.getBoundingClientRect().top)); + if (height > 0) nextProviderGroupHeights[provider] = height; + } + } + const providerEl = providerRefs.current.get(provider); if (!providerEl) return; const providerRect = providerEl.getBoundingClientRect(); @@ -241,6 +252,17 @@ export const ModelMappingDiagram = forwardRef { + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(nextProviderGroupHeights); + if (prevKeys.length !== nextKeys.length) return nextProviderGroupHeights; + for (const key of nextKeys) { + if (!(key in prev) || prev[key] !== nextProviderGroupHeights[key]) { + return nextProviderGroupHeights; + } + } + return prev; + }); }, [providerNodes, collapsedProviders]); useImperativeHandle( @@ -263,6 +285,12 @@ export const ModelMappingDiagram = forwardRef { + updateLines(); + const raf = requestAnimationFrame(updateLines); + return () => cancelAnimationFrame(raf); + }, [providerGroupHeights, updateLines]); + useEffect(() => { if (!containerRef.current || typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(() => updateLines()); @@ -452,14 +480,15 @@ export const ModelMappingDiagram = forwardRef handleContextMenu(e, type, data)} - label={t('oauth_model_alias.diagram_providers')} - expandLabel={t('oauth_model_alias.diagram_expand')} + providerNodes={providerNodes} + collapsedProviders={collapsedProviders} + getProviderColor={getProviderColor} + providerGroupHeights={providerGroupHeights} + providerRefs={providerRefs} + onToggleCollapse={toggleProviderCollapse} + onContextMenu={(e, type, data) => handleContextMenu(e, type, data)} + label={t('oauth_model_alias.diagram_providers')} + expandLabel={t('oauth_model_alias.diagram_expand')} collapseLabel={t('oauth_model_alias.diagram_collapse')} /> ; getProviderColor: (provider: string) => string; + providerGroupHeights?: Record; providerRefs: RefObject>; onToggleCollapse: (provider: string) => void; onContextMenu: (e: ReactMouseEvent, type: 'provider' | 'background', data?: string) => void; @@ -18,6 +19,7 @@ export function ProviderColumn({ providerNodes, collapsedProviders, getProviderColor, + providerGroupHeights = {}, providerRefs, onToggleCollapse, onContextMenu, @@ -37,34 +39,40 @@ export function ProviderColumn({
{label}
{providerNodes.map(({ provider, sources }) => { const collapsed = collapsedProviders.has(provider); + const groupHeight = collapsed ? undefined : providerGroupHeights[provider]; return (
{ - if (el) providerRefs.current?.set(provider, el); - else providerRefs.current?.delete(provider); - }} - className={`${styles.item} ${styles.providerItem}`} - style={{ borderLeftColor: getProviderColor(provider) }} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu(e, 'provider', provider); - }} + className={styles.providerGroup} + style={groupHeight ? { height: groupHeight } : undefined} > - - - {provider} - - {sources.length} + + + {provider} + + {sources.length} +
); })} From d4bc0bc6228e2c85530b967976c039a291dcfc52 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Thu, 5 Feb 2026 01:26:01 +0800 Subject: [PATCH 12/13] fix(model-alias): restore diagram drag-and-drop and add touch tap-to-link fallback --- .../ModelMappingDiagram.module.scss | 16 ++++ .../modelAlias/ModelMappingDiagram.tsx | 89 +++++++++++++++---- .../modelAlias/ModelMappingDiagramColumns.tsx | 18 +++- src/i18n/locales/en.json | 1 + src/i18n/locales/zh-CN.json | 1 + 5 files changed, 108 insertions(+), 17 deletions(-) diff --git a/src/components/modelAlias/ModelMappingDiagram.module.scss b/src/components/modelAlias/ModelMappingDiagram.module.scss index 561a2c2..20c3194 100644 --- a/src/components/modelAlias/ModelMappingDiagram.module.scss +++ b/src/components/modelAlias/ModelMappingDiagram.module.scss @@ -7,6 +7,16 @@ -webkit-overflow-scrolling: touch; } +.tapHint { + position: sticky; + left: 0; + z-index: 3; + font-size: 12px; + color: var(--text-secondary); + padding: 0 4px; + margin-bottom: 8px; +} + .container { display: inline-flex; position: relative; @@ -100,6 +110,12 @@ border-color: var(--primary-color); border-width: 2px; } + + &.selected { + border-color: var(--primary-color); + background-color: var(--bg-secondary); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); + } } // Mindmap-style provider branch (root node) diff --git a/src/components/modelAlias/ModelMappingDiagram.tsx b/src/components/modelAlias/ModelMappingDiagram.tsx index 57906d9..209a181 100644 --- a/src/components/modelAlias/ModelMappingDiagram.tsx +++ b/src/components/modelAlias/ModelMappingDiagram.tsx @@ -62,6 +62,13 @@ export const ModelMappingDiagram = forwardRef state.resolvedTheme); const isDark = resolvedTheme === 'dark'; + const enableTapLinking = useMemo(() => { + if (typeof window === 'undefined' || typeof window.matchMedia === 'undefined') return false; + return ( + window.matchMedia('(any-pointer: coarse)').matches && + !window.matchMedia('(any-pointer: fine)').matches + ); + }, []); const containerRef = useRef(null); const [lines, setLines] = useState([]); @@ -69,6 +76,8 @@ export const ModelMappingDiagram = forwardRef(null); const [dropTargetAlias, setDropTargetAlias] = useState(null); const [dropTargetSource, setDropTargetSource] = useState(null); + const [tapSourceId, setTapSourceId] = useState(null); + const [tapAlias, setTapAlias] = useState(null); const [extraAliases, setExtraAliases] = useState([]); const [contextMenu, setContextMenu] = useState(null); const [collapsedProviders, setCollapsedProviders] = useState>(new Set()); @@ -301,16 +310,18 @@ export const ModelMappingDiagram = forwardRef Alias const handleDragStart = (e: DragEvent, source: SourceNode) => { + setTapSourceId(null); + setTapAlias(null); setDraggedSource(source); e.dataTransfer.setData('text/plain', source.id); e.dataTransfer.effectAllowed = 'link'; }; const handleDragOver = (e: DragEvent, alias: string) => { + if (!draggedSource || draggedSource.aliases.some((entry) => entry.alias === alias)) return; e.preventDefault(); // Allow drop - if (draggedSource && !draggedSource.aliases.some((entry) => entry.alias === alias)) { - setDropTargetAlias(alias); - } + e.dataTransfer.dropEffect = 'link'; + setDropTargetAlias(alias); }; const handleDragLeave = () => { @@ -328,16 +339,18 @@ export const ModelMappingDiagram = forwardRef Source const handleDragStartAlias = (e: DragEvent, alias: string) => { + setTapSourceId(null); + setTapAlias(null); setDraggedAlias(alias); e.dataTransfer.setData('text/plain', alias); e.dataTransfer.effectAllowed = 'link'; }; const handleDragOverSource = (e: DragEvent, source: SourceNode) => { + if (!draggedAlias || source.aliases.some((entry) => entry.alias === draggedAlias)) return; e.preventDefault(); - if (draggedAlias && !source.aliases.some((entry) => entry.alias === draggedAlias)) { - setDropTargetSource(source.id); - } + e.dataTransfer.dropEffect = 'link'; + setDropTargetSource(source.id); }; const handleDragLeaveSource = () => { @@ -382,6 +395,45 @@ export const ModelMappingDiagram = forwardRef { + if (!onUpdate) return; + if (tapSourceId === source.id) { + setTapSourceId(null); + return; + } + + if (tapAlias) { + onUpdate(source.provider, source.name, tapAlias); + setTapSourceId(null); + setTapAlias(null); + return; + } + + setTapSourceId(source.id); + setTapAlias(null); + }; + + const handleTapSelectAlias = (alias: string) => { + if (!onUpdate) return; + if (tapAlias === alias) { + setTapAlias(null); + return; + } + + if (tapSourceId) { + const source = resolveSourceById(tapSourceId); + if (source) { + onUpdate(source.provider, source.name, alias); + } + setTapSourceId(null); + setTapAlias(null); + return; + } + + setTapAlias(alias); + setTapSourceId(null); + }; + const handleUnlinkSource = (provider: string, sourceModel: string, alias: string) => { if (onDeleteLink) onDeleteLink(provider, sourceModel, alias); }; @@ -459,6 +511,9 @@ export const ModelMappingDiagram = forwardRef + {enableTapLinking && onUpdate && ( +
{t('oauth_model_alias.diagram_tap_hint')}
+ )}
handleContextMenu(e, type, data)} - label={t('oauth_model_alias.diagram_providers')} - expandLabel={t('oauth_model_alias.diagram_expand')} + providerNodes={providerNodes} + collapsedProviders={collapsedProviders} + getProviderColor={getProviderColor} + providerGroupHeights={providerGroupHeights} + providerRefs={providerRefs} + onToggleCollapse={toggleProviderCollapse} + onContextMenu={(e, type, data) => handleContextMenu(e, type, data)} + label={t('oauth_model_alias.diagram_providers')} + expandLabel={t('oauth_model_alias.diagram_expand')} collapseLabel={t('oauth_model_alias.diagram_collapse')} /> { diff --git a/src/components/modelAlias/ModelMappingDiagramColumns.tsx b/src/components/modelAlias/ModelMappingDiagramColumns.tsx index 69e4244..bd8f04b 100644 --- a/src/components/modelAlias/ModelMappingDiagramColumns.tsx +++ b/src/components/modelAlias/ModelMappingDiagramColumns.tsx @@ -85,6 +85,8 @@ interface SourceColumnProps { collapsedProviders: Set; sourceRefs: RefObject>; getProviderColor: (provider: string) => string; + selectedSourceId?: string | null; + onSelectSource?: (source: SourceNode) => void; draggedSource: SourceNode | null; dropTargetSource: string | null; draggable: boolean; @@ -102,6 +104,8 @@ export function SourceColumn({ collapsedProviders, sourceRefs, getProviderColor, + selectedSourceId, + onSelectSource, draggedSource, dropTargetSource, draggable, @@ -134,7 +138,10 @@ export function SourceColumn({ }} className={`${styles.item} ${styles.sourceItem} ${ draggedSource?.id === source.id ? styles.dragging : '' - } ${dropTargetSource === source.id ? styles.dropTarget : ''}`} + } ${dropTargetSource === source.id ? styles.dropTarget : ''} ${ + selectedSourceId === source.id ? styles.selected : '' + }`} + onClick={() => onSelectSource?.(source)} draggable={draggable} onDragStart={(e) => onDragStart(e, source)} onDragEnd={onDragEnd} @@ -169,6 +176,8 @@ interface AliasColumnProps { aliasRefs: RefObject>; dropTargetAlias: string | null; draggedAlias: string | null; + selectedAlias?: string | null; + onSelectAlias?: (alias: string) => void; draggable: boolean; onDragStart: (e: DragEvent, alias: string) => void; onDragEnd: () => void; @@ -184,6 +193,8 @@ export function AliasColumn({ aliasRefs, dropTargetAlias, draggedAlias, + selectedAlias, + onSelectAlias, draggable, onDragStart, onDragEnd, @@ -212,7 +223,10 @@ export function AliasColumn({ }} className={`${styles.item} ${styles.aliasItem} ${ dropTargetAlias === node.alias ? styles.dropTarget : '' - } ${draggedAlias === node.alias ? styles.dragging : ''}`} + } ${draggedAlias === node.alias ? styles.dragging : ''} ${ + selectedAlias === node.alias ? styles.selected : '' + }`} + onClick={() => onSelectAlias?.(node.alias)} draggable={draggable} onDragStart={(e) => onDragStart(e, node.alias)} onDragEnd={onDragEnd} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 82f948e..e6155e3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -577,6 +577,7 @@ "diagram_settings_title": "Alias settings — {{alias}}", "diagram_settings_source_title": "Source model settings", "diagram_settings_empty": "No mappings for this alias yet.", + "diagram_tap_hint": "On touch devices: tap a source model, then tap an alias to link.", "view_mode": "View mode", "view_mode_diagram": "Diagram", "view_mode_list": "List", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index c748757..10f85c6 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -577,6 +577,7 @@ "diagram_settings_title": "别名设置 — {{alias}}", "diagram_settings_source_title": "源模型设置", "diagram_settings_empty": "该别名暂无映射。", + "diagram_tap_hint": "触摸设备上:先点选源模型,再点选别名即可建立映射。", "view_mode": "视图模式", "view_mode_diagram": "概览", "view_mode_list": "管理", From 7d41afb5f1d116bdc54098f4361452c8948b90cf Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Thu, 5 Feb 2026 02:22:23 +0800 Subject: [PATCH 13/13] feat(auth-files): add quota management features and enhance UI layout --- src/i18n/locales/en.json | 7 +- src/i18n/locales/zh-CN.json | 7 +- src/pages/AuthFilesPage.module.scss | 72 +++++ src/pages/AuthFilesPage.tsx | 450 +++++++++++++++++++++------- 4 files changed, 423 insertions(+), 113 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e6155e3..46fbd2b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -416,7 +416,12 @@ "prefix_placeholder": "", "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", "prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.", - "prefix_proxy_saved_success": "Updated \"{{name}}\" successfully" + "prefix_proxy_saved_success": "Updated \"{{name}}\" successfully", + "card_tools_title": "Tools", + "quota_refresh_single": "Refresh quota", + "quota_refresh_hint": "Refresh quota for this credential only", + "quota_refresh_success": "Quota refreshed for \"{{name}}\"", + "quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}" }, "antigravity_quota": { "title": "Antigravity Quota", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 10f85c6..6bcf788 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -416,7 +416,12 @@ "prefix_placeholder": "", "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", "prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。", - "prefix_proxy_saved_success": "已更新 \"{{name}}\"" + "prefix_proxy_saved_success": "已更新 \"{{name}}\"", + "card_tools_title": "配置管理", + "quota_refresh_single": "刷新额度", + "quota_refresh_hint": "仅刷新当前凭证的额度数据", + "quota_refresh_success": "已刷新 \"{{name}}\" 的额度", + "quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}" }, "antigravity_quota": { "title": "Antigravity 额度", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 6bfb681..471ae0c 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -184,6 +184,18 @@ } } +.fileGridQuotaManaged { + grid-template-columns: repeat(auto-fill, minmax(520px, 1fr)); + + @include tablet { + grid-template-columns: 1fr; + } + + @include mobile { + grid-template-columns: 1fr; + } +} + .antigravityGrid { display: grid; gap: $spacing-md; @@ -469,6 +481,66 @@ } } +.fileCardLayout { + display: flex; + align-items: stretch; + gap: $spacing-md; +} + +.fileCardLayoutQuota { + display: grid; + grid-template-columns: 1fr 156px; + gap: $spacing-md; + align-items: stretch; + + @include mobile { + grid-template-columns: 1fr; + } +} + +.fileCardMain { + display: flex; + flex-direction: column; + gap: $spacing-sm; + flex: 1; + min-width: 0; +} + +.fileCardSidebar { + display: flex; + flex-direction: column; + gap: $spacing-sm; + padding-left: $spacing-md; + border-left: 1px dashed var(--border-color); + + @include mobile { + border-left: none; + border-top: 1px dashed var(--border-color); + padding-left: 0; + padding-top: $spacing-md; + } +} + +.fileCardSidebarHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-xs; +} + +.fileCardSidebarTitle { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + white-space: nowrap; +} + +.fileCardSidebarHint { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.4; +} + .cardHeader { display: flex; align-items: center; diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 8cc7785..f6cb03b 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { useEffect, useMemo, useRef, useState, useCallback, type ReactNode } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useInterval } from '@/hooks/useInterval'; @@ -17,12 +17,16 @@ import { IconChevronUp, IconDownload, IconInfo, + IconRefreshCw, IconTrash2, } from '@/components/ui/icons'; -import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores'; +import type { TFunction } from 'i18next'; +import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota'; +import { useAuthStore, useNotificationStore, useQuotaStore, useThemeStore } from '@/stores'; import { authFilesApi, usageApi } from '@/services/api'; import { apiClient } from '@/services/api/client'; import type { AuthFileItem, OAuthModelAliasEntry } from '@/types'; +import { getStatusFromError, resolveAuthProvider } from '@/utils/quota'; import { calculateStatusBarData, collectUsageDetails, @@ -91,6 +95,49 @@ const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState'; const clampCardPageSize = (value: number) => Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value))); +type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli'; + +const QUOTA_PROVIDER_TYPES = new Set(['antigravity', 'codex', 'gemini-cli']); + +const resolveQuotaErrorMessage = ( + t: TFunction, + status: number | undefined, + fallback: string +): string => { + if (status === 404) return t('common.quota_update_required'); + if (status === 403) return t('common.quota_check_credential'); + return fallback; +}; + +type QuotaProgressBarProps = { + percent: number | null; + highThreshold: number; + mediumThreshold: number; +}; + +function QuotaProgressBar({ percent, highThreshold, mediumThreshold }: QuotaProgressBarProps) { + const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + const normalized = percent === null ? null : clamp(percent, 0, 100); + const fillClass = + normalized === null + ? styles.quotaBarFillMedium + : normalized >= highThreshold + ? styles.quotaBarFillHigh + : normalized >= mediumThreshold + ? styles.quotaBarFillMedium + : styles.quotaBarFillLow; + const widthPercent = Math.round(normalized ?? 0); + + return ( +
+
+
+ ); +} + type AuthFilesUiState = { filter?: string; search?: string; @@ -195,6 +242,12 @@ export function AuthFilesPage() { const { showNotification, showConfirmation } = useNotificationStore(); const connectionStatus = useAuthStore((state) => state.connectionStatus); const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme); + const antigravityQuota = useQuotaStore((state) => state.antigravityQuota); + const codexQuota = useQuotaStore((state) => state.codexQuota); + const geminiCliQuota = useQuotaStore((state) => state.geminiCliQuota); + const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota); + const setCodexQuota = useQuotaStore((state) => state.setCodexQuota); + const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota); const navigate = useNavigate(); const [files, setFiles] = useState([]); @@ -248,6 +301,12 @@ export function AuthFilesPage() { const normalizeProviderKey = (value: string) => value.trim().toLowerCase(); const disableControls = connectionStatus !== 'connected'; + const normalizedFilter = normalizeProviderKey(String(filter)); + const quotaFilterType: QuotaProviderType | null = QUOTA_PROVIDER_TYPES.has( + normalizedFilter as QuotaProviderType + ) + ? (normalizedFilter as QuotaProviderType) + : null; const providerList = useMemo(() => { const providers = new Set(); @@ -1397,6 +1456,124 @@ export function AuthFilesPage() { ); }; + const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => { + const provider = resolveAuthProvider(file); + if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null; + return provider as QuotaProviderType; + }; + + const getQuotaConfig = (type: QuotaProviderType) => { + if (type === 'antigravity') return ANTIGRAVITY_CONFIG; + if (type === 'codex') return CODEX_CONFIG; + return GEMINI_CLI_CONFIG; + }; + + const getQuotaState = (type: QuotaProviderType, fileName: string) => { + if (type === 'antigravity') return antigravityQuota[fileName]; + if (type === 'codex') return codexQuota[fileName]; + return geminiCliQuota[fileName]; + }; + + const updateQuotaState = useCallback( + ( + type: QuotaProviderType, + updater: (prev: Record) => Record + ) => { + if (type === 'antigravity') { + setAntigravityQuota(updater as never); + return; + } + if (type === 'codex') { + setCodexQuota(updater as never); + return; + } + setGeminiCliQuota(updater as never); + }, + [setAntigravityQuota, setCodexQuota, setGeminiCliQuota] + ); + + const refreshQuotaForFile = useCallback( + async (file: AuthFileItem, quotaType: QuotaProviderType) => { + if (disableControls) return; + if (isRuntimeOnlyAuthFile(file)) return; + if (file.disabled) return; + + const currentState = getQuotaState(quotaType, file.name); + if (currentState?.status === 'loading') return; + + const config = getQuotaConfig(quotaType) as unknown as { + i18nPrefix: string; + fetchQuota: (file: AuthFileItem, t: TFunction) => Promise; + buildLoadingState: () => unknown; + buildSuccessState: (data: unknown) => unknown; + buildErrorState: (message: string, status?: number) => unknown; + }; + + updateQuotaState(quotaType, (prev) => ({ + ...prev, + [file.name]: config.buildLoadingState() + })); + + try { + const data = await config.fetchQuota(file, t); + updateQuotaState(quotaType, (prev) => ({ + ...prev, + [file.name]: config.buildSuccessState(data) + })); + showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('common.unknown_error'); + const status = getStatusFromError(err); + updateQuotaState(quotaType, (prev) => ({ + ...prev, + [file.name]: config.buildErrorState(message, status) + })); + showNotification( + t('auth_files.quota_refresh_failed', { name: file.name, message }), + 'error' + ); + } + }, + [disableControls, getQuotaState, showNotification, t, updateQuotaState] + ); + + const renderQuotaSection = (item: AuthFileItem, quotaType: QuotaProviderType) => { + const config = getQuotaConfig(quotaType) as unknown as { + i18nPrefix: string; + renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown; + }; + + const quota = getQuotaState(quotaType, item.name) as + | { status?: string; error?: string; errorStatus?: number } + | undefined; + const quotaStatus = quota?.status ?? 'idle'; + const quotaErrorMessage = resolveQuotaErrorMessage( + t, + quota?.errorStatus, + quota?.error || t('common.unknown_error') + ); + + return ( +
+ {quotaStatus === 'loading' ? ( +
{t(`${config.i18nPrefix}.loading`)}
+ ) : quotaStatus === 'idle' ? ( +
{t(`${config.i18nPrefix}.idle`)}
+ ) : quotaStatus === 'error' ? ( +
+ {t(`${config.i18nPrefix}.load_failed`, { + message: quotaErrorMessage + })} +
+ ) : quota ? ( + (config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode) + ) : ( +
{t(`${config.i18nPrefix}.idle`)}
+ )} +
+ ); + }; + // 渲染单个认证文件卡片 const renderFileCard = (item: AuthFileItem) => { const fileStats = resolveAuthFileStats(item, keyStats); @@ -1405,120 +1582,167 @@ export function AuthFilesPage() { const showModelsButton = !isRuntimeOnly || isAistudio; const typeColor = getTypeColor(item.type || 'unknown'); + const quotaType = + quotaFilterType && resolveQuotaType(item) === quotaFilterType ? quotaFilterType : null; + + const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly; + const quotaState = quotaType ? getQuotaState(quotaType, item.name) : undefined; + const quotaRefreshing = quotaState?.status === 'loading'; + + const providerCardClass = + quotaType === 'antigravity' + ? styles.antigravityCard + : quotaType === 'codex' + ? styles.codexCard + : quotaType === 'gemini-cli' + ? styles.geminiCliCard + : ''; + return (
-
- - {getTypeLabel(item.type || 'unknown')} - - {item.name} -
- -
- - {t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'} - - - {t('auth_files.file_modified')}: {formatModified(item)} - -
- -
- - {t('stats.success')}: {fileStats.success} - - - {t('stats.failure')}: {fileStats.failure} - -
- - {/* 状态监测栏 */} - {renderStatusBar(item)} - -
- {showModelsButton && ( - - )} - {!isRuntimeOnly && ( - <> - - - - - - )} - {!isRuntimeOnly && ( -
- void handleStatusToggle(item, value)} - /> + {getTypeLabel(item.type || 'unknown')} + + {item.name}
- )} - {isRuntimeOnly && ( -
- {t('auth_files.type_virtual') || '虚拟认证文件'} + +
+ + {t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'} + + + {t('auth_files.file_modified')}: {formatModified(item)} + +
+ +
+ + {t('stats.success')}: {fileStats.success} + + + {t('stats.failure')}: {fileStats.failure} + +
+ + {/* 状态监测栏 */} + {renderStatusBar(item)} + + {showQuotaLayout && quotaType && renderQuotaSection(item, quotaType)} + +
+ {showModelsButton && ( + + )} + {!isRuntimeOnly && ( + <> + + + + + + )} + {!isRuntimeOnly && ( +
+ void handleStatusToggle(item, value)} + /> +
+ )} + {isRuntimeOnly && ( +
+ {t('auth_files.type_virtual') || '虚拟认证文件'} +
+ )} +
+
+ + {showQuotaLayout && quotaType && ( +
+
+ + {t('auth_files.card_tools_title')} + + +
+
{t('auth_files.quota_refresh_hint')}
)}
@@ -1625,7 +1849,11 @@ export function AuthFilesPage() { description={t('auth_files.search_empty_desc')} /> ) : ( -
{pageItems.map(renderFileCard)}
+
+ {pageItems.map(renderFileCard)} +
)} {/* 分页 */}