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

@@ -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>