feat(ui): implement custom AutocompleteInput and refactor model mapping UI

This commit is contained in:
LTbinglingfeng
2026-01-24 15:55:31 +08:00
parent c89bbd5098
commit 268b92c59b
6 changed files with 484 additions and 240 deletions

View File

@@ -21,7 +21,7 @@ interface AmpcodeModalProps {
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) { export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue); const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache); const clearCache = useConfigStore((state) => state.clearCache);
@@ -81,32 +81,34 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]); }, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
const clearAmpcodeUpstreamApiKey = async () => { const clearAmpcodeUpstreamApiKey = async () => {
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return; showConfirmation({
setSaving(true); title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
setError(''); message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
try { variant: 'danger',
await ampcodeApi.clearUpstreamApiKey(); confirmText: t('common.confirm'),
const previous = config?.ampcode ?? {}; onConfirm: async () => {
const next: AmpcodeConfig = { ...previous }; setSaving(true);
delete next.upstreamApiKey; setError('');
updateConfigValue('ampcode', next); try {
clearCache('ampcode'); await ampcodeApi.clearUpstreamApiKey();
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success'); const previous = config?.ampcode ?? {};
} catch (err: unknown) { const next: AmpcodeConfig = { ...previous };
const message = getErrorMessage(err); delete next.upstreamApiKey;
setError(message); updateConfigValue('ampcode', next);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); clearCache('ampcode');
} finally { showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
setSaving(false); } catch (err: unknown) {
} const message = getErrorMessage(err);
setError(message);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
},
});
}; };
const saveAmpcode = async () => { const performSaveAmpcode = async () => {
if (!loaded && mappingsDirty) {
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
if (!confirmed) return;
}
setSaving(true); setSaving(true);
setError(''); setError('');
try { try {
@@ -173,6 +175,21 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
} }
}; };
const saveAmpcode = async () => {
if (!loaded && mappingsDirty) {
showConfirmation({
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
variant: 'secondary', // Not dangerous, just a warning
confirmText: t('common.confirm'),
onConfirm: performSaveAmpcode,
});
return;
}
await performSaveAmpcode();
};
return ( return (
<Modal <Modal
open={isOpen} open={isOpen}

View File

@@ -0,0 +1,175 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
import { IconChevronDown } from './icons';
interface AutocompleteInputProps {
label?: string;
value: string;
onChange: (value: string) => void;
options: string[] | { value: string; label?: string }[];
placeholder?: string;
disabled?: boolean;
hint?: string;
error?: string;
className?: string;
wrapperClassName?: string;
wrapperStyle?: React.CSSProperties;
id?: string;
rightElement?: ReactNode;
}
export function AutocompleteInput({
label,
value,
onChange,
options,
placeholder,
disabled,
hint,
error,
className = '',
wrapperClassName = '',
wrapperStyle,
id,
rightElement
}: AutocompleteInputProps) {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const normalizedOptions = options.map(opt =>
typeof opt === 'string' ? { value: opt, label: opt } : { value: opt.value, label: opt.label || opt.value }
);
const filteredOptions = normalizedOptions.filter(opt => {
const v = value.toLowerCase();
return opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v));
});
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
setIsOpen(true);
setHighlightedIndex(-1);
};
const handleSelect = (selectedValue: string) => {
onChange(selectedValue);
setIsOpen(false);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
return;
}
setHighlightedIndex(prev =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0);
} else if (e.key === 'Enter') {
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
e.preventDefault();
handleSelect(filteredOptions[highlightedIndex].value);
} else if (isOpen) {
e.preventDefault();
setIsOpen(false);
}
} else if (e.key === 'Escape') {
setIsOpen(false);
} else if (e.key === 'Tab') {
setIsOpen(false);
}
};
return (
<div className={`form-group ${wrapperClassName}`} ref={containerRef} style={wrapperStyle}>
{label && <label htmlFor={id}>{label}</label>}
<div style={{ position: 'relative' }}>
<input
id={id}
className={`input ${className}`.trim()}
value={value}
onChange={handleInputChange}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
style={{ paddingRight: 32 }}
/>
<div
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
alignItems: 'center',
pointerEvents: disabled ? 'none' : 'auto',
cursor: 'pointer',
height: '100%'
}}
onClick={() => !disabled && setIsOpen(!isOpen)}
>
{rightElement}
<IconChevronDown size={16} style={{ opacity: 0.5, marginLeft: 4 }} />
</div>
{isOpen && filteredOptions.length > 0 && !disabled && (
<div className="autocomplete-dropdown" style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
right: 0,
zIndex: 1000,
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-md)',
maxHeight: 200,
overflowY: 'auto',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
}}>
{filteredOptions.map((opt, index) => (
<div
key={`${opt.value}-${index}`}
onClick={() => handleSelect(opt.value)}
style={{
padding: '8px 12px',
cursor: 'pointer',
backgroundColor: index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent',
color: 'var(--text-primary)',
display: 'flex',
flexDirection: 'column',
fontSize: '0.9rem'
}}
onMouseEnter={() => setHighlightedIndex(index)}
>
<span style={{ fontWeight: 500 }}>{opt.value}</span>
{opt.label && opt.label !== opt.value && (
<span style={{ fontSize: '0.85em', color: 'var(--text-secondary)' }}>{opt.label}</span>
)}
</div>
))}
</div>
)}
</div>
{hint && <div className="hint">{hint}</div>}
{error && <div className="error-box">{error}</div>}
</div>
);
}

View File

@@ -28,7 +28,7 @@ import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() { export function AiProvidersPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
@@ -180,18 +180,25 @@ export function AiProvidersPage() {
const deleteGemini = async (index: number) => { const deleteGemini = async (index: number) => {
const entry = geminiKeys[index]; const entry = geminiKeys[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t('ai_providers.gemini_delete_confirm'))) return; showConfirmation({
try { title: t('ai_providers.gemini_delete_title', { defaultValue: 'Delete Gemini Key' }),
await providersApi.deleteGeminiKey(entry.apiKey); message: t('ai_providers.gemini_delete_confirm'),
const next = geminiKeys.filter((_, idx) => idx !== index); variant: 'danger',
setGeminiKeys(next); confirmText: t('common.confirm'),
updateConfigValue('gemini-api-key', next); onConfirm: async () => {
clearCache('gemini-api-key'); try {
showNotification(t('notification.gemini_key_deleted'), 'success'); await providersApi.deleteGeminiKey(entry.apiKey);
} catch (err: unknown) { const next = geminiKeys.filter((_, idx) => idx !== index);
const message = getErrorMessage(err); setGeminiKeys(next);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); updateConfigValue('gemini-api-key', next);
} clearCache('gemini-api-key');
showNotification(t('notification.gemini_key_deleted'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
}; };
const setConfigEnabled = async ( const setConfigEnabled = async (
@@ -352,27 +359,34 @@ export function AiProvidersPage() {
const source = type === 'codex' ? codexConfigs : claudeConfigs; const source = type === 'codex' ? codexConfigs : claudeConfigs;
const entry = source[index]; const entry = source[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t(`ai_providers.${type}_delete_confirm`))) return; showConfirmation({
try { title: t(`ai_providers.${type}_delete_title`, { defaultValue: `Delete ${type === 'codex' ? 'Codex' : 'Claude'} Config` }),
if (type === 'codex') { message: t(`ai_providers.${type}_delete_confirm`),
await providersApi.deleteCodexConfig(entry.apiKey); variant: 'danger',
const next = codexConfigs.filter((_, idx) => idx !== index); confirmText: t('common.confirm'),
setCodexConfigs(next); onConfirm: async () => {
updateConfigValue('codex-api-key', next); try {
clearCache('codex-api-key'); if (type === 'codex') {
showNotification(t('notification.codex_config_deleted'), 'success'); await providersApi.deleteCodexConfig(entry.apiKey);
} else { const next = codexConfigs.filter((_, idx) => idx !== index);
await providersApi.deleteClaudeConfig(entry.apiKey); setCodexConfigs(next);
const next = claudeConfigs.filter((_, idx) => idx !== index); updateConfigValue('codex-api-key', next);
setClaudeConfigs(next); clearCache('codex-api-key');
updateConfigValue('claude-api-key', next); showNotification(t('notification.codex_config_deleted'), 'success');
clearCache('claude-api-key'); } else {
showNotification(t('notification.claude_config_deleted'), 'success'); await providersApi.deleteClaudeConfig(entry.apiKey);
} const next = claudeConfigs.filter((_, idx) => idx !== index);
} catch (err: unknown) { setClaudeConfigs(next);
const message = getErrorMessage(err); updateConfigValue('claude-api-key', next);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); clearCache('claude-api-key');
} showNotification(t('notification.claude_config_deleted'), 'success');
}
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
}; };
const saveVertex = async (form: VertexFormState, editIndex: number | null) => { const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
@@ -427,18 +441,25 @@ export function AiProvidersPage() {
const deleteVertex = async (index: number) => { const deleteVertex = async (index: number) => {
const entry = vertexConfigs[index]; const entry = vertexConfigs[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t('ai_providers.vertex_delete_confirm'))) return; showConfirmation({
try { title: t('ai_providers.vertex_delete_title', { defaultValue: 'Delete Vertex Config' }),
await providersApi.deleteVertexConfig(entry.apiKey); message: t('ai_providers.vertex_delete_confirm'),
const next = vertexConfigs.filter((_, idx) => idx !== index); variant: 'danger',
setVertexConfigs(next); confirmText: t('common.confirm'),
updateConfigValue('vertex-api-key', next); onConfirm: async () => {
clearCache('vertex-api-key'); try {
showNotification(t('notification.vertex_config_deleted'), 'success'); await providersApi.deleteVertexConfig(entry.apiKey);
} catch (err: unknown) { const next = vertexConfigs.filter((_, idx) => idx !== index);
const message = getErrorMessage(err); setVertexConfigs(next);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); updateConfigValue('vertex-api-key', next);
} clearCache('vertex-api-key');
showNotification(t('notification.vertex_config_deleted'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
}; };
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => { const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
@@ -485,18 +506,25 @@ export function AiProvidersPage() {
const deleteOpenai = async (index: number) => { const deleteOpenai = async (index: number) => {
const entry = openaiProviders[index]; const entry = openaiProviders[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t('ai_providers.openai_delete_confirm'))) return; showConfirmation({
try { title: t('ai_providers.openai_delete_title', { defaultValue: 'Delete OpenAI Provider' }),
await providersApi.deleteOpenAIProvider(entry.name); message: t('ai_providers.openai_delete_confirm'),
const next = openaiProviders.filter((_, idx) => idx !== index); variant: 'danger',
setOpenaiProviders(next); confirmText: t('common.confirm'),
updateConfigValue('openai-compatibility', next); onConfirm: async () => {
clearCache('openai-compatibility'); try {
showNotification(t('notification.openai_provider_deleted'), 'success'); await providersApi.deleteOpenAIProvider(entry.name);
} catch (err: unknown) { const next = openaiProviders.filter((_, idx) => idx !== index);
const message = getErrorMessage(err); setOpenaiProviders(next);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); updateConfigValue('openai-compatibility', next);
} clearCache('openai-compatibility');
showNotification(t('notification.openai_provider_deleted'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
}; };
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null; const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;

View File

@@ -6,6 +6,7 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
@@ -193,7 +194,7 @@ function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeyStatBucke
export function AuthFilesPage() { export function AuthFilesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
@@ -689,18 +690,25 @@ export function AuthFilesPage() {
// 删除单个文件 // 删除单个文件
const handleDelete = async (name: string) => { const handleDelete = async (name: string) => {
if (!window.confirm(`${t('auth_files.delete_confirm')} "${name}" ?`)) return; showConfirmation({
setDeleting(name); title: t('auth_files.delete_title', { defaultValue: 'Delete File' }),
try { message: `${t('auth_files.delete_confirm')} "${name}" ?`,
await authFilesApi.deleteFile(name); variant: 'danger',
showNotification(t('auth_files.delete_success'), 'success'); confirmText: t('common.confirm'),
setFiles((prev) => prev.filter((item) => item.name !== name)); onConfirm: async () => {
} catch (err: unknown) { setDeleting(name);
const errorMessage = err instanceof Error ? err.message : ''; try {
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error'); await authFilesApi.deleteFile(name);
} finally { showNotification(t('auth_files.delete_success'), 'success');
setDeleting(null); setFiles((prev) => prev.filter((item) => item.name !== name));
} } catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
} finally {
setDeleting(null);
}
},
});
}; };
// 删除全部(根据筛选类型) // 删除全部(根据筛选类型)
@@ -711,60 +719,66 @@ export function AuthFilesPage() {
? t('auth_files.delete_filtered_confirm', { type: typeLabel }) ? t('auth_files.delete_filtered_confirm', { type: typeLabel })
: t('auth_files.delete_all_confirm'); : t('auth_files.delete_all_confirm');
if (!window.confirm(confirmMessage)) return; showConfirmation({
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
message: confirmMessage,
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setDeletingAll(true);
try {
if (!isFiltered) {
// 删除全部
await authFilesApi.deleteAll();
showNotification(t('auth_files.delete_all_success'), 'success');
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
} else {
// 删除筛选类型的文件
const filesToDelete = files.filter((f) => f.type === filter && !isRuntimeOnlyAuthFile(f));
setDeletingAll(true); if (filesToDelete.length === 0) {
try { showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
if (!isFiltered) { setDeletingAll(false);
// 删除全部 return;
await authFilesApi.deleteAll(); }
showNotification(t('auth_files.delete_all_success'), 'success');
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
} else {
// 删除筛选类型的文件
const filesToDelete = files.filter((f) => f.type === filter && !isRuntimeOnlyAuthFile(f));
if (filesToDelete.length === 0) { let success = 0;
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info'); let failed = 0;
setDeletingAll(false); const deletedNames: string[] = [];
return;
}
let success = 0; for (const file of filesToDelete) {
let failed = 0; try {
const deletedNames: string[] = []; await authFilesApi.deleteFile(file.name);
success++;
deletedNames.push(file.name);
} catch {
failed++;
}
}
for (const file of filesToDelete) { setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
try {
await authFilesApi.deleteFile(file.name); if (failed === 0) {
success++; showNotification(
deletedNames.push(file.name); t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
} catch { 'success'
failed++; );
} else {
showNotification(
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
'warning'
);
}
setFilter('all');
} }
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
} finally {
setDeletingAll(false);
} }
},
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name))); });
if (failed === 0) {
showNotification(
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
'success'
);
} else {
showNotification(
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
'warning'
);
}
setFilter('all');
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
} finally {
setDeletingAll(false);
}
}; };
// 下载文件 // 下载文件
@@ -1067,37 +1081,44 @@ export function AuthFilesPage() {
const deleteExcluded = async (provider: string) => { const deleteExcluded = async (provider: string) => {
const providerLabel = provider.trim() || provider; const providerLabel = provider.trim() || provider;
if (!window.confirm(t('oauth_excluded.delete_confirm', { provider: providerLabel }))) return; showConfirmation({
const providerKey = normalizeProviderKey(provider); title: t('oauth_excluded.delete_title', { defaultValue: 'Delete Exclusion' }),
if (!providerKey) { message: t('oauth_excluded.delete_confirm', { provider: providerLabel }),
showNotification(t('oauth_excluded.provider_required'), 'error'); variant: 'danger',
return; confirmText: t('common.confirm'),
} onConfirm: async () => {
try { const providerKey = normalizeProviderKey(provider);
await authFilesApi.deleteOauthExcludedEntry(providerKey); if (!providerKey) {
await loadExcluded(); showNotification(t('oauth_excluded.provider_required'), 'error');
showNotification(t('oauth_excluded.delete_success'), 'success'); return;
} catch (err: unknown) { }
try { try {
const current = await authFilesApi.getOauthExcludedModels(); await authFilesApi.deleteOauthExcludedEntry(providerKey);
const next: Record<string, string[]> = {}; await loadExcluded();
Object.entries(current).forEach(([key, models]) => { showNotification(t('oauth_excluded.delete_success'), 'success');
if (normalizeProviderKey(key) === providerKey) return; } catch (err: unknown) {
next[key] = models; try {
}); const current = await authFilesApi.getOauthExcludedModels();
await authFilesApi.replaceOauthExcludedModels(next); const next: Record<string, string[]> = {};
await loadExcluded(); Object.entries(current).forEach(([key, models]) => {
showNotification(t('oauth_excluded.delete_success'), 'success'); if (normalizeProviderKey(key) === providerKey) return;
} catch (fallbackErr: unknown) { next[key] = models;
const errorMessage = });
fallbackErr instanceof Error await authFilesApi.replaceOauthExcludedModels(next);
? fallbackErr.message await loadExcluded();
: err instanceof Error showNotification(t('oauth_excluded.delete_success'), 'success');
? err.message } catch (fallbackErr: unknown) {
: ''; const errorMessage =
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error'); fallbackErr instanceof Error
} ? fallbackErr.message
} : err instanceof Error
? err.message
: '';
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
}
}
},
});
}; };
// OAuth 模型映射相关方法 // OAuth 模型映射相关方法
@@ -1218,15 +1239,22 @@ export function AuthFilesPage() {
}; };
const deleteModelMappings = async (provider: string) => { const deleteModelMappings = async (provider: string) => {
if (!window.confirm(t('oauth_model_mappings.delete_confirm', { provider }))) return; showConfirmation({
try { title: t('oauth_model_mappings.delete_title', { defaultValue: 'Delete Mappings' }),
await authFilesApi.deleteOauthModelMappings(provider); message: t('oauth_model_mappings.delete_confirm', { provider }),
await loadModelMappings(); variant: 'danger',
showNotification(t('oauth_model_mappings.delete_success'), 'success'); confirmText: t('common.confirm'),
} catch (err: unknown) { onConfirm: async () => {
const errorMessage = err instanceof Error ? err.message : ''; try {
showNotification(`${t('oauth_model_mappings.delete_failed')}: ${errorMessage}`, 'error'); await authFilesApi.deleteOauthModelMappings(provider);
} await loadModelMappings();
showNotification(t('oauth_model_mappings.delete_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_mappings.delete_failed')}: ${errorMessage}`, 'error');
}
},
});
}; };
// 渲染标签筛选器 // 渲染标签筛选器
@@ -1877,20 +1905,15 @@ export function AuthFilesPage() {
} }
> >
<div className={styles.providerField}> <div className={styles.providerField}>
<Input <AutocompleteInput
id="oauth-excluded-provider" id="oauth-excluded-provider"
list="oauth-excluded-provider-options"
label={t('oauth_excluded.provider_label')} label={t('oauth_excluded.provider_label')}
hint={t('oauth_excluded.provider_hint')} hint={t('oauth_excluded.provider_hint')}
placeholder={t('oauth_excluded.provider_placeholder')} placeholder={t('oauth_excluded.provider_placeholder')}
value={excludedForm.provider} value={excludedForm.provider}
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))} onChange={(val) => setExcludedForm((prev) => ({ ...prev, provider: val }))}
options={providerOptions}
/> />
<datalist id="oauth-excluded-provider-options">
{providerOptions.map((provider) => (
<option key={provider} value={provider} />
))}
</datalist>
{providerOptions.length > 0 && ( {providerOptions.length > 0 && (
<div className={styles.providerTagList}> <div className={styles.providerTagList}>
{providerOptions.map((provider) => { {providerOptions.map((provider) => {
@@ -1945,20 +1968,15 @@ export function AuthFilesPage() {
} }
> >
<div className={styles.providerField}> <div className={styles.providerField}>
<Input <AutocompleteInput
id="oauth-model-alias-provider" id="oauth-model-alias-provider"
list="oauth-model-alias-provider-options"
label={t('oauth_model_mappings.provider_label')} label={t('oauth_model_mappings.provider_label')}
hint={t('oauth_model_mappings.provider_hint')} hint={t('oauth_model_mappings.provider_hint')}
placeholder={t('oauth_model_mappings.provider_placeholder')} placeholder={t('oauth_model_mappings.provider_placeholder')}
value={mappingForm.provider} value={mappingForm.provider}
onChange={(e) => setMappingForm((prev) => ({ ...prev, provider: e.target.value }))} onChange={(val) => setMappingForm((prev) => ({ ...prev, provider: val }))}
options={providerOptions}
/> />
<datalist id="oauth-model-alias-provider-options">
{providerOptions.map((provider) => (
<option key={provider} value={provider} />
))}
</datalist>
{providerOptions.length > 0 && ( {providerOptions.length > 0 && (
<div className={styles.providerTagList}> <div className={styles.providerTagList}>
{providerOptions.map((provider) => { {providerOptions.map((provider) => {
@@ -1980,9 +1998,8 @@ export function AuthFilesPage() {
)} )}
</div> </div>
<div className={styles.providerField}> <div className={styles.providerField}>
<Input <AutocompleteInput
id="oauth-model-mapping-model-source" id="oauth-model-mapping-model-source"
list="oauth-model-mapping-model-source-options"
label={t('oauth_model_mappings.model_source_label')} label={t('oauth_model_mappings.model_source_label')}
hint={ hint={
mappingModelsLoading mappingModelsLoading
@@ -1997,14 +2014,10 @@ export function AuthFilesPage() {
} }
placeholder={t('oauth_model_mappings.model_source_placeholder')} placeholder={t('oauth_model_mappings.model_source_placeholder')}
value={mappingModelsFileName} value={mappingModelsFileName}
onChange={(e) => setMappingModelsFileName(e.target.value)} onChange={(val) => setMappingModelsFileName(val)}
disabled={savingMappings} disabled={savingMappings}
options={modelSourceFileOptions}
/> />
<datalist id="oauth-model-mapping-model-source-options">
{modelSourceFileOptions.map((fileName) => (
<option key={fileName} value={fileName} />
))}
</datalist>
</div> </div>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label>{t('oauth_model_mappings.mappings_label')}</label> <label>{t('oauth_model_mappings.mappings_label')}</label>
@@ -2012,13 +2025,16 @@ export function AuthFilesPage() {
{(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map( {(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map(
(entry, index) => ( (entry, index) => (
<div key={entry.id} className={styles.mappingRow}> <div key={entry.id} className={styles.mappingRow}>
<input <AutocompleteInput
className="input" wrapperStyle={{ flex: 1, marginBottom: 0 }}
placeholder={t('oauth_model_mappings.mapping_name_placeholder')} placeholder={t('oauth_model_mappings.mapping_name_placeholder')}
list={mappingModelsList.length ? 'oauth-model-mapping-model-options' : undefined}
value={entry.name} value={entry.name}
onChange={(e) => updateMappingEntry(index, 'name', e.target.value)} onChange={(val) => updateMappingEntry(index, 'name', val)}
disabled={savingMappings} disabled={savingMappings}
options={mappingModelsList.map((m) => ({
value: m.id,
label: m.display_name && m.display_name !== m.id ? m.display_name : undefined,
}))}
/> />
<span className={styles.mappingSeparator}></span> <span className={styles.mappingSeparator}></span>
<input <input
@@ -2027,6 +2043,7 @@ export function AuthFilesPage() {
value={entry.alias} value={entry.alias}
onChange={(e) => updateMappingEntry(index, 'alias', e.target.value)} onChange={(e) => updateMappingEntry(index, 'alias', e.target.value)}
disabled={savingMappings} disabled={savingMappings}
style={{ flex: 1 }}
/> />
<div className={styles.mappingFork}> <div className={styles.mappingFork}>
<ToggleSwitch <ToggleSwitch
@@ -2060,13 +2077,6 @@ export function AuthFilesPage() {
{t('oauth_model_mappings.add_mapping')} {t('oauth_model_mappings.add_mapping')}
</Button> </Button>
</div> </div>
<datalist id="oauth-model-mapping-model-options">
{mappingModelsList.map((model) => (
<option key={model.id} value={model.id}>
{model.display_name && model.display_name !== model.id ? model.display_name : null}
</option>
))}
</datalist>
<div className={styles.hint}>{t('oauth_model_mappings.mappings_hint')}</div> <div className={styles.hint}>{t('oauth_model_mappings.mappings_hint')}</div>
</div> </div>
</Modal> </Modal>

View File

@@ -371,7 +371,7 @@ type TabType = 'logs' | 'errors';
export function LogsPage() { export function LogsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false); const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
@@ -478,19 +478,26 @@ export function LogsPage() {
useHeaderRefresh(() => loadLogs(false)); useHeaderRefresh(() => loadLogs(false));
const clearLogs = async () => { const clearLogs = async () => {
if (!window.confirm(t('logs.clear_confirm'))) return; showConfirmation({
try { title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
await logsApi.clearLogs(); message: t('logs.clear_confirm'),
setLogState({ buffer: [], visibleFrom: 0 }); variant: 'danger',
latestTimestampRef.current = 0; confirmText: t('common.confirm'),
showNotification(t('logs.clear_success'), 'success'); onConfirm: async () => {
} catch (err: unknown) { try {
const message = getErrorMessage(err); await logsApi.clearLogs();
showNotification( setLogState({ buffer: [], visibleFrom: 0 });
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`, latestTimestampRef.current = 0;
'error' showNotification(t('logs.clear_success'), 'success');
); } catch (err: unknown) {
} const message = getErrorMessage(err);
showNotification(
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
},
});
}; };
const downloadLogs = () => { const downloadLogs = () => {

View File

@@ -11,7 +11,7 @@ import styles from './SystemPage.module.scss';
export function SystemPage() { export function SystemPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const auth = useAuthStore(); const auth = useAuthStore();
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig); const fetchConfig = useConfigStore((state) => state.fetchConfig);
@@ -106,12 +106,19 @@ export function SystemPage() {
}; };
const handleClearLoginStorage = () => { const handleClearLoginStorage = () => {
if (!window.confirm(t('system_info.clear_login_confirm'))) return; showConfirmation({
auth.logout(); title: t('system_info.clear_login_title', { defaultValue: 'Clear Login Storage' }),
if (typeof localStorage === 'undefined') return; message: t('system_info.clear_login_confirm'),
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey']; variant: 'danger',
keysToRemove.forEach((key) => localStorage.removeItem(key)); confirmText: t('common.confirm'),
showNotification(t('notification.login_storage_cleared'), 'success'); onConfirm: () => {
auth.logout();
if (typeof localStorage === 'undefined') return;
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
keysToRemove.forEach((key) => localStorage.removeItem(key));
showNotification(t('notification.login_storage_cleared'), 'success');
},
});
}; };
useEffect(() => { useEffect(() => {