mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-02 02:40:50 +08:00
feat(ui): implement custom AutocompleteInput and refactor model mapping UI
This commit is contained in:
@@ -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,32 +81,34 @@ 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;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await ampcodeApi.clearUpstreamApiKey();
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = { ...previous };
|
||||
delete next.upstreamApiKey;
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
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 {
|
||||
await ampcodeApi.clearUpstreamApiKey();
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = { ...previous };
|
||||
delete next.upstreamApiKey;
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} 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}
|
||||
|
||||
175
src/components/ui/AutocompleteInput.tsx
Normal file
175
src/components/ui/AutocompleteInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,18 +180,25 @@ 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;
|
||||
try {
|
||||
await providersApi.deleteGeminiKey(entry.apiKey);
|
||||
const next = geminiKeys.filter((_, idx) => idx !== index);
|
||||
setGeminiKeys(next);
|
||||
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');
|
||||
}
|
||||
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);
|
||||
setGeminiKeys(next);
|
||||
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 (
|
||||
@@ -352,27 +359,34 @@ 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;
|
||||
try {
|
||||
if (type === 'codex') {
|
||||
await providersApi.deleteCodexConfig(entry.apiKey);
|
||||
const next = codexConfigs.filter((_, idx) => idx !== index);
|
||||
setCodexConfigs(next);
|
||||
updateConfigValue('codex-api-key', next);
|
||||
clearCache('codex-api-key');
|
||||
showNotification(t('notification.codex_config_deleted'), 'success');
|
||||
} else {
|
||||
await providersApi.deleteClaudeConfig(entry.apiKey);
|
||||
const next = claudeConfigs.filter((_, idx) => idx !== index);
|
||||
setClaudeConfigs(next);
|
||||
updateConfigValue('claude-api-key', next);
|
||||
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');
|
||||
}
|
||||
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);
|
||||
const next = codexConfigs.filter((_, idx) => idx !== index);
|
||||
setCodexConfigs(next);
|
||||
updateConfigValue('codex-api-key', next);
|
||||
clearCache('codex-api-key');
|
||||
showNotification(t('notification.codex_config_deleted'), 'success');
|
||||
} else {
|
||||
await providersApi.deleteClaudeConfig(entry.apiKey);
|
||||
const next = claudeConfigs.filter((_, idx) => idx !== index);
|
||||
setClaudeConfigs(next);
|
||||
updateConfigValue('claude-api-key', next);
|
||||
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) => {
|
||||
@@ -427,18 +441,25 @@ 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;
|
||||
try {
|
||||
await providersApi.deleteVertexConfig(entry.apiKey);
|
||||
const next = vertexConfigs.filter((_, idx) => idx !== index);
|
||||
setVertexConfigs(next);
|
||||
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');
|
||||
}
|
||||
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);
|
||||
setVertexConfigs(next);
|
||||
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) => {
|
||||
@@ -485,18 +506,25 @@ 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;
|
||||
try {
|
||||
await providersApi.deleteOpenAIProvider(entry.name);
|
||||
const next = openaiProviders.filter((_, idx) => idx !== index);
|
||||
setOpenaiProviders(next);
|
||||
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');
|
||||
}
|
||||
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);
|
||||
setOpenaiProviders(next);
|
||||
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;
|
||||
|
||||
@@ -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,18 +690,25 @@ export function AuthFilesPage() {
|
||||
|
||||
// 删除单个文件
|
||||
const handleDelete = async (name: string) => {
|
||||
if (!window.confirm(`${t('auth_files.delete_confirm')} "${name}" ?`)) return;
|
||||
setDeleting(name);
|
||||
try {
|
||||
await authFilesApi.deleteFile(name);
|
||||
showNotification(t('auth_files.delete_success'), 'success');
|
||||
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);
|
||||
}
|
||||
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);
|
||||
showNotification(t('auth_files.delete_success'), 'success');
|
||||
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_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);
|
||||
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));
|
||||
if (filesToDelete.length === 0) {
|
||||
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
|
||||
setDeletingAll(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (filesToDelete.length === 0) {
|
||||
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
|
||||
setDeletingAll(false);
|
||||
return;
|
||||
}
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const deletedNames: string[] = [];
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const deletedNames: string[] = [];
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
await authFilesApi.deleteFile(file.name);
|
||||
success++;
|
||||
deletedNames.push(file.name);
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
await authFilesApi.deleteFile(file.name);
|
||||
success++;
|
||||
deletedNames.push(file.name);
|
||||
} catch {
|
||||
failed++;
|
||||
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);
|
||||
}
|
||||
|
||||
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 providerLabel = provider.trim() || provider;
|
||||
if (!window.confirm(t('oauth_excluded.delete_confirm', { provider: providerLabel }))) return;
|
||||
const providerKey = normalizeProviderKey(provider);
|
||||
if (!providerKey) {
|
||||
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await authFilesApi.deleteOauthExcludedEntry(providerKey);
|
||||
await loadExcluded();
|
||||
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
try {
|
||||
const current = await authFilesApi.getOauthExcludedModels();
|
||||
const next: Record<string, string[]> = {};
|
||||
Object.entries(current).forEach(([key, models]) => {
|
||||
if (normalizeProviderKey(key) === providerKey) return;
|
||||
next[key] = models;
|
||||
});
|
||||
await authFilesApi.replaceOauthExcludedModels(next);
|
||||
await loadExcluded();
|
||||
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||
} catch (fallbackErr: unknown) {
|
||||
const errorMessage =
|
||||
fallbackErr instanceof Error
|
||||
? fallbackErr.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: '';
|
||||
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
|
||||
}
|
||||
}
|
||||
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');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await authFilesApi.deleteOauthExcludedEntry(providerKey);
|
||||
await loadExcluded();
|
||||
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
try {
|
||||
const current = await authFilesApi.getOauthExcludedModels();
|
||||
const next: Record<string, string[]> = {};
|
||||
Object.entries(current).forEach(([key, models]) => {
|
||||
if (normalizeProviderKey(key) === providerKey) return;
|
||||
next[key] = models;
|
||||
});
|
||||
await authFilesApi.replaceOauthExcludedModels(next);
|
||||
await loadExcluded();
|
||||
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||
} catch (fallbackErr: unknown) {
|
||||
const errorMessage =
|
||||
fallbackErr instanceof Error
|
||||
? fallbackErr.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: '';
|
||||
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// OAuth 模型映射相关方法
|
||||
@@ -1218,15 +1239,22 @@ export function AuthFilesPage() {
|
||||
};
|
||||
|
||||
const deleteModelMappings = async (provider: string) => {
|
||||
if (!window.confirm(t('oauth_model_mappings.delete_confirm', { provider }))) return;
|
||||
try {
|
||||
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');
|
||||
}
|
||||
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();
|
||||
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}>
|
||||
<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>
|
||||
|
||||
@@ -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,19 +478,26 @@ export function LogsPage() {
|
||||
useHeaderRefresh(() => loadLogs(false));
|
||||
|
||||
const clearLogs = async () => {
|
||||
if (!window.confirm(t('logs.clear_confirm'))) return;
|
||||
try {
|
||||
await logsApi.clearLogs();
|
||||
setLogState({ buffer: [], visibleFrom: 0 });
|
||||
latestTimestampRef.current = 0;
|
||||
showNotification(t('logs.clear_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(
|
||||
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
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 });
|
||||
latestTimestampRef.current = 0;
|
||||
showNotification(t('logs.clear_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(
|
||||
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
|
||||
@@ -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;
|
||||
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');
|
||||
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(() => {
|
||||
|
||||
Reference in New Issue
Block a user