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) {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const { showNotification, showConfirmation } = useNotificationStore();
const config = useConfigStore((state) => state.config);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
@@ -81,7 +81,12 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
const clearAmpcodeUpstreamApiKey = async () => {
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return;
showConfirmation({
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setSaving(true);
setError('');
try {
@@ -99,14 +104,11 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
} finally {
setSaving(false);
}
},
});
};
const saveAmpcode = async () => {
if (!loaded && mappingsDirty) {
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
if (!confirmed) return;
}
const performSaveAmpcode = async () => {
setSaving(true);
setError('');
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 (
<Modal
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() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const { showNotification, showConfirmation } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
@@ -180,7 +180,12 @@ export function AiProvidersPage() {
const deleteGemini = async (index: number) => {
const entry = geminiKeys[index];
if (!entry) return;
if (!window.confirm(t('ai_providers.gemini_delete_confirm'))) return;
showConfirmation({
title: t('ai_providers.gemini_delete_title', { defaultValue: 'Delete Gemini Key' }),
message: t('ai_providers.gemini_delete_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteGeminiKey(entry.apiKey);
const next = geminiKeys.filter((_, idx) => idx !== index);
@@ -192,6 +197,8 @@ export function AiProvidersPage() {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
};
const setConfigEnabled = async (
@@ -352,7 +359,12 @@ export function AiProvidersPage() {
const source = type === 'codex' ? codexConfigs : claudeConfigs;
const entry = source[index];
if (!entry) return;
if (!window.confirm(t(`ai_providers.${type}_delete_confirm`))) return;
showConfirmation({
title: t(`ai_providers.${type}_delete_title`, { defaultValue: `Delete ${type === 'codex' ? 'Codex' : 'Claude'} Config` }),
message: t(`ai_providers.${type}_delete_confirm`),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
if (type === 'codex') {
await providersApi.deleteCodexConfig(entry.apiKey);
@@ -373,6 +385,8 @@ export function AiProvidersPage() {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
};
const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
@@ -427,7 +441,12 @@ export function AiProvidersPage() {
const deleteVertex = async (index: number) => {
const entry = vertexConfigs[index];
if (!entry) return;
if (!window.confirm(t('ai_providers.vertex_delete_confirm'))) return;
showConfirmation({
title: t('ai_providers.vertex_delete_title', { defaultValue: 'Delete Vertex Config' }),
message: t('ai_providers.vertex_delete_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteVertexConfig(entry.apiKey);
const next = vertexConfigs.filter((_, idx) => idx !== index);
@@ -439,6 +458,8 @@ export function AiProvidersPage() {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
};
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
@@ -485,7 +506,12 @@ export function AiProvidersPage() {
const deleteOpenai = async (index: number) => {
const entry = openaiProviders[index];
if (!entry) return;
if (!window.confirm(t('ai_providers.openai_delete_confirm'))) return;
showConfirmation({
title: t('ai_providers.openai_delete_title', { defaultValue: 'Delete OpenAI Provider' }),
message: t('ai_providers.openai_delete_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteOpenAIProvider(entry.name);
const next = openaiProviders.filter((_, idx) => idx !== index);
@@ -497,6 +523,8 @@ export function AiProvidersPage() {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
};
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 { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
@@ -193,7 +194,7 @@ function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeyStatBucke
export function AuthFilesPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
@@ -689,7 +690,12 @@ export function AuthFilesPage() {
// 删除单个文件
const handleDelete = async (name: string) => {
if (!window.confirm(`${t('auth_files.delete_confirm')} "${name}" ?`)) return;
showConfirmation({
title: t('auth_files.delete_title', { defaultValue: 'Delete File' }),
message: `${t('auth_files.delete_confirm')} "${name}" ?`,
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setDeleting(name);
try {
await authFilesApi.deleteFile(name);
@@ -701,6 +707,8 @@ export function AuthFilesPage() {
} finally {
setDeleting(null);
}
},
});
};
// 删除全部(根据筛选类型)
@@ -711,8 +719,12 @@ export function AuthFilesPage() {
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
: 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) {
@@ -765,6 +777,8 @@ export function AuthFilesPage() {
} finally {
setDeletingAll(false);
}
},
});
};
// 下载文件
@@ -1067,7 +1081,12 @@ export function AuthFilesPage() {
const deleteExcluded = async (provider: string) => {
const providerLabel = provider.trim() || provider;
if (!window.confirm(t('oauth_excluded.delete_confirm', { provider: providerLabel }))) return;
showConfirmation({
title: t('oauth_excluded.delete_title', { defaultValue: 'Delete Exclusion' }),
message: t('oauth_excluded.delete_confirm', { provider: providerLabel }),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
const providerKey = normalizeProviderKey(provider);
if (!providerKey) {
showNotification(t('oauth_excluded.provider_required'), 'error');
@@ -1098,6 +1117,8 @@ export function AuthFilesPage() {
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
}
}
},
});
};
// OAuth 模型映射相关方法
@@ -1218,7 +1239,12 @@ export function AuthFilesPage() {
};
const deleteModelMappings = async (provider: string) => {
if (!window.confirm(t('oauth_model_mappings.delete_confirm', { provider }))) return;
showConfirmation({
title: t('oauth_model_mappings.delete_title', { defaultValue: 'Delete Mappings' }),
message: t('oauth_model_mappings.delete_confirm', { provider }),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await authFilesApi.deleteOauthModelMappings(provider);
await loadModelMappings();
@@ -1227,6 +1253,8 @@ export function AuthFilesPage() {
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}>
<Input
<AutocompleteInput
id="oauth-excluded-provider"
list="oauth-excluded-provider-options"
label={t('oauth_excluded.provider_label')}
hint={t('oauth_excluded.provider_hint')}
placeholder={t('oauth_excluded.provider_placeholder')}
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 && (
<div className={styles.providerTagList}>
{providerOptions.map((provider) => {
@@ -1945,20 +1968,15 @@ export function AuthFilesPage() {
}
>
<div className={styles.providerField}>
<Input
<AutocompleteInput
id="oauth-model-alias-provider"
list="oauth-model-alias-provider-options"
label={t('oauth_model_mappings.provider_label')}
hint={t('oauth_model_mappings.provider_hint')}
placeholder={t('oauth_model_mappings.provider_placeholder')}
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 && (
<div className={styles.providerTagList}>
{providerOptions.map((provider) => {
@@ -1980,9 +1998,8 @@ export function AuthFilesPage() {
)}
</div>
<div className={styles.providerField}>
<Input
<AutocompleteInput
id="oauth-model-mapping-model-source"
list="oauth-model-mapping-model-source-options"
label={t('oauth_model_mappings.model_source_label')}
hint={
mappingModelsLoading
@@ -1997,14 +2014,10 @@ export function AuthFilesPage() {
}
placeholder={t('oauth_model_mappings.model_source_placeholder')}
value={mappingModelsFileName}
onChange={(e) => setMappingModelsFileName(e.target.value)}
onChange={(val) => setMappingModelsFileName(val)}
disabled={savingMappings}
options={modelSourceFileOptions}
/>
<datalist id="oauth-model-mapping-model-source-options">
{modelSourceFileOptions.map((fileName) => (
<option key={fileName} value={fileName} />
))}
</datalist>
</div>
<div className={styles.formGroup}>
<label>{t('oauth_model_mappings.mappings_label')}</label>
@@ -2012,13 +2025,16 @@ export function AuthFilesPage() {
{(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map(
(entry, index) => (
<div key={entry.id} className={styles.mappingRow}>
<input
className="input"
<AutocompleteInput
wrapperStyle={{ flex: 1, marginBottom: 0 }}
placeholder={t('oauth_model_mappings.mapping_name_placeholder')}
list={mappingModelsList.length ? 'oauth-model-mapping-model-options' : undefined}
value={entry.name}
onChange={(e) => updateMappingEntry(index, 'name', e.target.value)}
onChange={(val) => updateMappingEntry(index, 'name', val)}
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>
<input
@@ -2027,6 +2043,7 @@ export function AuthFilesPage() {
value={entry.alias}
onChange={(e) => updateMappingEntry(index, 'alias', e.target.value)}
disabled={savingMappings}
style={{ flex: 1 }}
/>
<div className={styles.mappingFork}>
<ToggleSwitch
@@ -2060,13 +2077,6 @@ export function AuthFilesPage() {
{t('oauth_model_mappings.add_mapping')}
</Button>
</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>
</Modal>

View File

@@ -371,7 +371,7 @@ type TabType = 'logs' | 'errors';
export function LogsPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
@@ -478,7 +478,12 @@ export function LogsPage() {
useHeaderRefresh(() => loadLogs(false));
const clearLogs = async () => {
if (!window.confirm(t('logs.clear_confirm'))) return;
showConfirmation({
title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
message: t('logs.clear_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await logsApi.clearLogs();
setLogState({ buffer: [], visibleFrom: 0 });
@@ -491,6 +496,8 @@ export function LogsPage() {
'error'
);
}
},
});
};
const downloadLogs = () => {

View File

@@ -11,7 +11,7 @@ import styles from './SystemPage.module.scss';
export function SystemPage() {
const { t, i18n } = useTranslation();
const { showNotification } = useNotificationStore();
const { showNotification, showConfirmation } = useNotificationStore();
const auth = useAuthStore();
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
@@ -106,12 +106,19 @@ export function SystemPage() {
};
const handleClearLoginStorage = () => {
if (!window.confirm(t('system_info.clear_login_confirm'))) return;
showConfirmation({
title: t('system_info.clear_login_title', { defaultValue: 'Clear Login Storage' }),
message: t('system_info.clear_login_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
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(() => {