Merge pull request #22 from AoaoMH/feature/auth-model-check

feat: add model list viewer for auth file cards
This commit is contained in:
Supra4E8C
2025-12-14 18:06:25 +08:00
committed by GitHub
4 changed files with 225 additions and 5 deletions

View File

@@ -317,7 +317,17 @@
"type_iflow": "iFlow",
"type_vertex": "Vertex",
"type_empty": "空文件",
"type_unknown": "其他"
"type_unknown": "其他",
"type_virtual": "虚拟认证文件",
"models_button": "模型",
"models_title": "支持的模型",
"models_loading": "正在加载模型列表...",
"models_empty": "该凭证暂无可用模型",
"models_empty_desc": "该认证凭证可能尚未被服务器加载或没有绑定任何模型",
"models_unsupported": "当前版本不支持此功能",
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
"models_excluded_badge": "已排除",
"models_excluded_hint": "此模型已被 OAuth 排除"
},
"vertex_import": {
"title": "Vertex AI 凭证导入",
@@ -670,7 +680,7 @@
"gemini_api_key": "Gemini API密钥",
"codex_api_key": "Codex API密钥",
"claude_api_key": "Claude API密钥",
"link_copied": "链接已复制到剪贴板"
"link_copied": "已复制"
},
"language": {
"switch": "语言",

View File

@@ -260,6 +260,8 @@
padding: 4px 10px;
border-radius: $radius-sm;
font-style: italic;
display: inline-flex;
align-items: center;
}
// 分页
@@ -390,3 +392,84 @@
text-align: center;
padding: $spacing-lg;
}
// 模型列表弹窗
.modelsList {
display: flex;
flex-direction: column;
gap: $spacing-sm;
max-height: 400px;
overflow-y: auto;
}
.modelItem {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-sm $spacing-md;
background-color: var(--bg-secondary);
border-radius: $radius-md;
border: 1px solid var(--border-color);
flex-wrap: wrap;
cursor: pointer;
transition: all $transition-fast;
&:hover {
background-color: var(--bg-hover);
border-color: var(--primary-color);
}
&:active {
transform: scale(0.98);
}
}
.modelId {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
word-break: break-all;
}
.modelDisplayName {
font-size: 12px;
color: var(--text-secondary);
flex-shrink: 0;
}
.modelType {
font-size: 11px;
color: var(--text-tertiary);
background-color: var(--bg-tertiary);
padding: 2px 8px;
border-radius: 10px;
flex-shrink: 0;
margin-left: auto;
}
.modelItemExcluded {
opacity: 0.6;
background-color: var(--bg-tertiary);
border-style: dashed;
.modelId {
text-decoration: line-through;
color: var(--text-tertiary);
}
&:hover {
border-color: var(--danger-color);
}
}
.modelExcludedBadge {
font-size: 10px;
color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.1);
padding: 2px 6px;
border-radius: 8px;
border: 1px solid var(--danger-color);
flex-shrink: 0;
}

View File

@@ -6,7 +6,7 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState';
import { IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
import { IconBot, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import { authFilesApi, usageApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
@@ -140,6 +140,14 @@ export function AuthFilesPage() {
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
// 模型列表弹窗相关
const [modelsModalOpen, setModelsModalOpen] = useState(false);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsList, setModelsList] = useState<{ id: string; display_name?: string; type?: string }[]>([]);
const [modelsFileName, setModelsFileName] = useState('');
const [modelsFileType, setModelsFileType] = useState('');
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
// OAuth 排除模型相关
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
@@ -406,6 +414,43 @@ export function AuthFilesPage() {
setDetailModalOpen(true);
};
// 显示模型列表
const showModels = async (item: AuthFileItem) => {
setModelsFileName(item.name);
setModelsFileType(item.type || '');
setModelsList([]);
setModelsError(null);
setModelsModalOpen(true);
setModelsLoading(true);
try {
const models = await authFilesApi.getModelsForAuthFile(item.name);
setModelsList(models);
} catch (err) {
// 检测是否是 API 不支持的错误 (404 或特定错误消息)
const errorMessage = err instanceof Error ? err.message : '';
if (errorMessage.includes('404') || errorMessage.includes('not found') || errorMessage.includes('Not Found')) {
setModelsError('unsupported');
} else {
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
}
} finally {
setModelsLoading(false);
}
};
// 检查模型是否被 OAuth 排除
const isModelExcluded = (modelId: string, providerType: string): boolean => {
const excludedModels = excluded[providerType] || [];
return excludedModels.some(pattern => {
if (pattern.includes('*')) {
// 支持通配符匹配
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i');
return regex.test(modelId);
}
return pattern.toLowerCase() === modelId.toLowerCase();
});
};
// 获取类型标签显示文本
const getTypeLabel = (type: string): string => {
const key = `auth_files.filter_${type}`;
@@ -538,9 +583,31 @@ export function AuthFilesPage() {
<div className={styles.cardActions}>
{isRuntimeOnly ? (
<span className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</span>
<>
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
<Button
variant="secondary"
size="sm"
onClick={() => showModels(item)}
className={styles.iconButton}
title={t('auth_files.models_button', { defaultValue: '模型' })}
disabled={disableControls}
>
<IconBot className={styles.actionIcon} size={16} />
</Button>
</>
) : (
<>
<Button
variant="secondary"
size="sm"
onClick={() => showModels(item)}
className={styles.iconButton}
title={t('auth_files.models_button', { defaultValue: '模型' })}
disabled={disableControls}
>
<IconBot className={styles.actionIcon} size={16} />
</Button>
<Button
variant="secondary"
size="sm"
@@ -768,6 +835,60 @@ export function AuthFilesPage() {
)}
</Modal>
{/* 模型列表弹窗 */}
<Modal
open={modelsModalOpen}
onClose={() => setModelsModalOpen(false)}
title={t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${modelsFileName}`}
footer={
<Button variant="secondary" onClick={() => setModelsModalOpen(false)}>
{t('common.close')}
</Button>
}
>
{modelsLoading ? (
<div className={styles.hint}>{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}</div>
) : modelsError === 'unsupported' ? (
<EmptyState
title={t('auth_files.models_unsupported', { defaultValue: '当前版本不支持此功能' })}
description={t('auth_files.models_unsupported_desc', { defaultValue: '请更新 CLI Proxy API 到最新版本后重试' })}
/>
) : modelsList.length === 0 ? (
<EmptyState
title={t('auth_files.models_empty', { defaultValue: '该凭证暂无可用模型' })}
description={t('auth_files.models_empty_desc', { defaultValue: '该认证凭证可能尚未被服务器加载或没有绑定任何模型' })}
/>
) : (
<div className={styles.modelsList}>
{modelsList.map((model) => {
const isExcluded = isModelExcluded(model.id, modelsFileType);
return (
<div
key={model.id}
className={`${styles.modelItem} ${isExcluded ? styles.modelItemExcluded : ''}`}
onClick={() => {
navigator.clipboard.writeText(model.id);
showNotification(t('notification.link_copied', { defaultValue: '已复制到剪贴板' }), 'success');
}}
title={isExcluded ? t('auth_files.models_excluded_hint', { defaultValue: '此模型已被 OAuth 排除' }) : t('common.copy', { defaultValue: '点击复制' })}
>
<span className={styles.modelId}>{model.id}</span>
{model.display_name && model.display_name !== model.id && (
<span className={styles.modelDisplayName}>{model.display_name}</span>
)}
{model.type && (
<span className={styles.modelType}>{model.type}</span>
)}
{isExcluded && (
<span className={styles.modelExcludedBadge}>{t('auth_files.models_excluded_badge', { defaultValue: '已排除' })}</span>
)}
</div>
);
})}
</div>
)}
</Modal>
{/* OAuth 排除弹窗 */}
<Modal
open={excludedModalOpen}

View File

@@ -29,5 +29,11 @@ export const authFilesApi = {
apiClient.patch('/oauth-excluded-models', { provider, models }),
deleteOauthExcludedEntry: (provider: string) =>
apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`)
apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`),
// 获取认证凭证支持的模型
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`);
return (data && Array.isArray(data['models'])) ? data['models'] : [];
}
};