feat(ui): add model checklist for oauth exclusions

This commit is contained in:
hkfires
2026-01-27 14:56:23 +08:00
parent 2bf721974b
commit 9515d88e3c
4 changed files with 170 additions and 20 deletions

View File

@@ -488,8 +488,10 @@
"provider_placeholder": "e.g. gemini-cli",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"models_label": "Models to exclude",
"models_placeholder": "gpt-4.1-mini\n*-preview",
"models_hint": "Separate by commas or new lines; saving an empty list removes that provider. * wildcards are supported.",
"models_loading": "Loading models...",
"models_unsupported": "Current CPA version does not support fetching model lists.",
"models_loaded": "{{count}} models loaded. Check the models to exclude.",
"no_models_available": "No models available for this provider.",
"save": "Save/Update",
"saving": "Saving...",
"save_success": "Excluded models updated",

View File

@@ -488,8 +488,10 @@
"provider_placeholder": "例如 gemini-cli / openai",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"models_label": "排除的模型",
"models_placeholder": "gpt-4.1-mini\n*-preview",
"models_hint": "逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。",
"models_loading": "正在加载模型列表...",
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
"models_loaded": "已加载 {{count}} 个模型,勾选要排除的模型。",
"no_models_available": "该提供商暂无可用模型列表。",
"save": "保存/更新",
"saving": "正在保存...",
"save_success": "排除列表已更新",

View File

@@ -995,3 +995,53 @@
border: 1px solid var(--danger-color);
flex-shrink: 0;
}
// 排除模型勾选列表
.excludedCheckList {
display: flex;
flex-direction: column;
gap: $spacing-xs;
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: $spacing-sm;
background-color: var(--bg-secondary);
}
.excludedCheckItem {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-sm;
cursor: pointer;
transition: background-color $transition-fast;
&:hover {
background-color: var(--bg-hover);
}
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--primary-color);
}
}
.excludedCheckLabel {
display: flex;
align-items: center;
gap: $spacing-sm;
font-size: 13px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
color: var(--text-primary);
word-break: break-all;
}
.excludedCheckDisplayName {
font-size: 12px;
color: var(--text-tertiary);
font-family: inherit;
}

View File

@@ -104,7 +104,7 @@ const clampCardPageSize = (value: number) =>
interface ExcludedFormState {
provider: string;
modelsText: string;
selectedModels: Set<string>;
}
type OAuthModelMappingFormEntry = OAuthModelAliasEntry & { id: string };
@@ -232,8 +232,11 @@ export function AuthFilesPage() {
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({
provider: '',
modelsText: '',
selectedModels: new Set(),
});
const [excludedModelsList, setExcludedModelsList] = useState<AuthFileModelItem[]>([]);
const [excludedModelsLoading, setExcludedModelsLoading] = useState(false);
const [excludedModelsError, setExcludedModelsError] = useState<'unsupported' | null>(null);
const [savingExcluded, setSavingExcluded] = useState(false);
// OAuth 模型映射相关
@@ -321,6 +324,61 @@ export function AuthFilesPage() {
};
}, [mappingModalOpen, mappingForm.provider, showNotification, t]);
// 排除列表弹窗:根据 provider 加载模型定义
useEffect(() => {
if (!excludedModalOpen) return;
const channel = normalizeProviderKey(excludedForm.provider);
if (!channel) {
setExcludedModelsList([]);
setExcludedModelsError(null);
setExcludedModelsLoading(false);
return;
}
const cached = modelDefinitionsCacheRef.current.get(channel);
if (cached) {
setExcludedModelsList(cached);
setExcludedModelsError(null);
setExcludedModelsLoading(false);
return;
}
let cancelled = false;
setExcludedModelsLoading(true);
setExcludedModelsError(null);
authFilesApi
.getModelDefinitions(channel)
.then((models) => {
if (cancelled) return;
modelDefinitionsCacheRef.current.set(channel, models);
setExcludedModelsList(models);
})
.catch((err: unknown) => {
if (cancelled) return;
const errorMessage = err instanceof Error ? err.message : '';
if (
errorMessage.includes('404') ||
errorMessage.includes('not found') ||
errorMessage.includes('Not Found')
) {
setExcludedModelsList([]);
setExcludedModelsError('unsupported');
return;
}
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
})
.finally(() => {
if (cancelled) return;
setExcludedModelsLoading(false);
});
return () => {
cancelled = true;
};
}, [excludedModalOpen, excludedForm.provider, showNotification, t]);
const prefixProxyUpdatedText = useMemo(() => {
if (!prefixProxyEditor?.json) return prefixProxyEditor?.rawText ?? '';
const next: Record<string, unknown> = { ...prefixProxyEditor.json };
@@ -1008,11 +1066,13 @@ export function AuthFilesPage() {
const fallbackProvider =
normalizedProvider || (filter !== 'all' ? normalizeProviderKey(String(filter)) : '');
const lookupKey = fallbackProvider ? excludedProviderLookup.get(fallbackProvider) : undefined;
const models = lookupKey ? excluded[lookupKey] : [];
const existingModels = lookupKey ? excluded[lookupKey] : [];
setExcludedForm({
provider: lookupKey || fallbackProvider,
modelsText: Array.isArray(models) ? models.join('\n') : '',
selectedModels: new Set(existingModels),
});
setExcludedModelsList([]);
setExcludedModelsError(null);
setExcludedModalOpen(true);
};
@@ -1022,10 +1082,7 @@ export function AuthFilesPage() {
showNotification(t('oauth_excluded.provider_required'), 'error');
return;
}
const models = excludedForm.modelsText
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
const models = [...excludedForm.selectedModels];
setSavingExcluded(true);
try {
if (models.length) {
@@ -1886,16 +1943,55 @@ export function AuthFilesPage() {
</div>
)}
</div>
{/* 模型勾选列表 */}
<div className={styles.formGroup}>
<label>{t('oauth_excluded.models_label')}</label>
<textarea
className={styles.textarea}
rows={4}
placeholder={t('oauth_excluded.models_placeholder')}
value={excludedForm.modelsText}
onChange={(e) => setExcludedForm((prev) => ({ ...prev, modelsText: e.target.value }))}
/>
<div className={styles.hint}>{t('oauth_excluded.models_hint')}</div>
{excludedModelsLoading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : excludedModelsList.length > 0 ? (
<>
<div className={styles.excludedCheckList}>
{excludedModelsList.map((model) => {
const isChecked = excludedForm.selectedModels.has(model.id);
return (
<label key={model.id} className={styles.excludedCheckItem}>
<input
type="checkbox"
checked={isChecked}
disabled={savingExcluded}
onChange={(e) => {
setExcludedForm((prev) => {
const next = new Set(prev.selectedModels);
if (e.target.checked) {
next.add(model.id);
} else {
next.delete(model.id);
}
return { ...prev, selectedModels: next };
});
}}
/>
<span className={styles.excludedCheckLabel}>
{model.id}
{model.display_name && model.display_name !== model.id && (
<span className={styles.excludedCheckDisplayName}>{model.display_name}</span>
)}
</span>
</label>
);
})}
</div>
{excludedForm.provider.trim() && (
<div className={styles.hint}>
{excludedModelsError === 'unsupported'
? t('oauth_excluded.models_unsupported')
: t('oauth_excluded.models_loaded', { count: excludedModelsList.length })}
</div>
)}
</>
) : excludedForm.provider.trim() && !excludedModelsLoading ? (
<div className={styles.hint}>{t('oauth_excluded.no_models_available')}</div>
) : null}
</div>
</Modal>