fix(ui): simplify credential aliases and usage filters

Use plain model name and alias inputs for credential aliases, discard unsaved credential settings on close, and replace usage filters with select-only menus.
This commit is contained in:
lzt404 committed 2026-10-06 14:26:09 +08:00
1 parent 88d58529f1
commit 072d771b3b
13 files changed
+414 -201

No files matched your search

+9 -38
View File
@@ -46,8 +46,6 @@
.credential-settings-actions { display: flex; gap: 10px; justify-content: flex-end; }
.credential-settings-error { color: var(--failure-badge-text); margin: 0 0 12px; font-size: var(--font-size-caption); overflow-wrap: anywhere; max-height: 90px; overflow-y: auto; }
.credential-settings-warning { color: var(--amber-text); }
.credential-settings-discard { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; font-size: var(--font-size-caption); }
.credential-settings-discard span { flex: 1 1 100%; }
.credential-settings-loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 150px; }
@media (max-width: 560px) {
.credential-settings-grid { grid-template-columns: minmax(0, 1fr); }
@@ -58,41 +56,13 @@
.credential-settings-span { grid-column: 1 / -1; }
.credential-settings-inline-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.credential-alias-list, .credential-header-list { display: grid; gap: 12px; }
.credential-alias-card { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-primary); }
.credential-alias-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; }
.credential-alias-fields .credential-settings-field { margin: 0; }
.credential-alias-fields .credential-settings-field:last-child { grid-column: 1 / -1; }
.credential-settings-dialog .agent-model-trigger { box-sizing: border-box; min-height: 41px; padding: 0 4px 0 11px; border-color: var(--border-color); border-radius: 7px; background: var(--bg-primary); color: var(--text-primary); }
.credential-settings-dialog .agent-model-trigger strong { color: var(--text-primary); }
.credential-settings-dialog .agent-model-editable-trigger input { box-sizing: border-box; width: 100%; height: 39px; margin: 0; padding: 0; border: 0; border-radius: 0; outline: 0; background: transparent; box-shadow: none; color: inherit; font: inherit; font-size: inherit; line-height: 1.5; }
.credential-settings-dialog .credential-model-menu {
position: fixed;
z-index: 2;
margin: 0;
max-height: none;
overflow: hidden;
border: 1px solid var(--border-color);
background: var(--bg-card);
color: var(--text-primary);
box-shadow: 0 16px 40px #0003;
}
.credential-settings-dialog .credential-model-menu .agent-model-option {
background: var(--bg-card);
color: var(--text-primary);
}
.credential-settings-dialog .credential-model-menu .agent-model-option:hover,
.credential-settings-dialog .credential-model-menu .agent-model-option.active {
background: var(--bg-secondary);
}
.credential-settings-dialog .credential-model-menu .agent-model-option.selected {
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-card));
}
.credential-settings-dialog .credential-model-menu .agent-model-option small,
.credential-settings-dialog .credential-model-menu .agent-model-dropdown-footer {
color: var(--text-tertiary);
}
.credential-alias-card-heading, .credential-header-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.credential-alias-card-heading strong { font-size: var(--font-size-meta); }
.credential-alias-card { display: grid; gap: 8px; }
.credential-alias-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: 8px; align-items: center; }
.credential-alias-row input { min-width: 0; }
.credential-alias-row-actions { display: flex; align-items: center; gap: 4px; }
.credential-alias-row-actions .is-expanded { transform: rotate(180deg); }
.credential-alias-details { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-secondary); }
.credential-alias-details .credential-settings-field { margin: 0; }
.credential-alias-checks { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.credential-alias-checks label { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-meta); }
.credential-header-row { display: grid; grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr) auto; }
@@ -102,6 +72,7 @@
}
@media (max-width: 560px) {
.credential-alias-fields { grid-template-columns: minmax(0, 1fr); }
.credential-alias-row { grid-template-columns: minmax(0, 1fr) auto; }
.credential-alias-row input:first-child { grid-column: 1 / -1; }
}
+3 -10
View File
@@ -7,7 +7,7 @@ import { loadAuthFileSettings, saveAuthFileSettings, type AuthFileSettingsDraft,
import { modelMatchesRule, normalizeOAuthExcludedRules, oauthModelCandidates, oauthModelsFromPayload, setOAuthModelsExcluded, type OAuthModelDefinition } from '../services/oauthModels';
import './AuthFileSettingsDialog.css';
import { CredentialAdvancedFields, CredentialHeadersEditor, credentialProviderKey } from './CredentialAdvancedFields';
import { modelSearchText, type ModelOption } from '../services/modelService';
import { modelSearchText } from '../services/modelService';
export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }: {
name: string;
@@ -22,14 +22,12 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
const [original, setOriginal] = useState<AuthFileSettingsDraft | null>(null);
const [draft, setDraft] = useState<AuthFileSettingsDraft | null>(null);
const [models, setModels] = useState<OAuthModelDefinition[]>([]);
const [pickerModels, setPickerModels] = useState<ModelOption[]>([]);
const [catalogError, setCatalogError] = useState('');
const [catalogLoading, setCatalogLoading] = useState(true);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [discard, setDiscard] = useState(false);
const [attempt, setAttempt] = useState(0);
const dirty = draft !== null && (Boolean(draft.normalizeCloakMetadata?.length) || JSON.stringify(draft) !== JSON.stringify(original));
@@ -61,7 +59,6 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
const catalog = oauthModelsFromPayload(payload);
if (!active) return;
setModels(catalog);
setPickerModels(catalog.map((model) => ({ name: model.id, displayName: model.displayName })));
}).catch((reason: unknown) => {
if (active) setCatalogError(reason instanceof Error ? reason.message : String(reason));
}).finally(() => { if (active) setCatalogLoading(false); });
@@ -70,20 +67,17 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
const close = () => {
if (savingRef.current) return;
if (dirty) setDiscard(true);
else onClose();
onClose();
};
const update = <K extends keyof AuthFileSettingsDraft>(key: K, value: AuthFileSettingsDraft[K]) => {
setDraft((current) => current ? { ...current, [key]: value } : current);
setError('');
setDiscard(false);
};
const save = async () => {
if (!draft || !original || savingRef.current) return;
savingRef.current = true;
setSaving(true);
setError('');
setDiscard(false);
try {
const changed = await saveAuthFileSettings(name, original, draft);
if (changed) onSaved();
@@ -175,7 +169,7 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
</div>
</details>
</section>
<CredentialAdvancedFields name={name} provider={provider} advanced={draft.advanced} models={pickerModels} modelsLoading={catalogLoading} modelsError={catalogError} disabled={saving} onChange={(advanced) => update('advanced', advanced)} />
<CredentialAdvancedFields name={name} provider={provider} advanced={draft.advanced} disabled={saving} onChange={(advanced) => update('advanced', advanced)} />
<section className="credential-settings-section">
<h3>{t('authFiles.settings.additional')}</h3>
<CredentialHeadersEditor value={draft.headers} disabled={saving} onChange={(headers) => update('headers', headers)} />
@@ -189,7 +183,6 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
</div>
<footer className="credential-settings-footer">
{error ? <p className="credential-settings-error" role="alert">{error}</p> : null}
{discard ? <div className="credential-settings-discard" role="alert"><span>{t('authFiles.settings.unsaved')}</span><button type="button" className="secondary-button compact-button" onClick={() => setDiscard(false)}>{t('authFiles.settings.keepEditing')}</button><button type="button" className="danger-button compact-button" onClick={onClose}>{t('authFiles.settings.discard')}</button></div> : null}
<div className="credential-settings-actions">
<button type="button" className="secondary-button" disabled={saving} onClick={close}>{t('common.cancel')}</button>
{!loading && !draft ? <button type="button" className="primary-button" onClick={() => setAttempt((value) => value + 1)}>{t('common.refresh')}</button> : <button type="submit" className="primary-button" disabled={!draft || loading || saving || !dirty}>{saving ? <LoaderCircle size={16} className="spin" /> : <Check size={16} />}{t(saving ? 'common.saving' : 'common.save')}</button>}
+25 -30
View File
@@ -1,7 +1,5 @@
import { useState } from 'react';
import { Plus, Undo2, X } from 'lucide-react';
import { AgentModelPicker } from './AgentModelPicker';
import type { ModelOption } from '../services/modelService';
import { ChevronDown, Plus, Undo2, X } from 'lucide-react';
import { useI18n } from '../i18n';
import type { MessageKey } from '../i18n/resources';
import { isRecord } from '../services/managementApi';
@@ -44,14 +42,11 @@ function aliasRows(value: unknown): AliasRow[] {
}
export function CredentialAdvancedFields({
name, provider = '', advanced, models = [], modelsLoading = false, modelsError = '', onChange, disabled = false,
name, provider = '', advanced, onChange, disabled = false,
}: {
name: string;
provider?: string;
advanced: Advanced;
models?: ModelOption[];
modelsLoading?: boolean;
modelsError?: string;
onChange: (advanced: Advanced) => void;
disabled?: boolean;
}) {
@@ -61,6 +56,7 @@ export function CredentialAdvancedFields({
const aliases = aliasRows(advanced.model_aliases);
const aliasesSet = Array.isArray(advanced.model_aliases);
const [wordsDraft, setWordsDraft] = useState<string | null>(null);
const [expandedAlias, setExpandedAlias] = useState<number | null>(null);
const setField = (key: string, value: unknown) => onChange(withoutKey(advanced, key, value));
const tri = (value: unknown) => value === true ? 'true' : value === false ? 'false' : '';
const choice = (label: MessageKey, key: string, options: ReadonlyArray<{ value: string; label: MessageKey }>) => {
@@ -101,34 +97,33 @@ export function CredentialAdvancedFields({
<h3>{t('authFiles.settings.aliases')}</h3>
<p className="credential-settings-lead">{t('authFiles.settings.aliasesHint')}</p>
{aliases.length ? <div className="credential-alias-list">
{aliases.map((row, index) => <div className="credential-alias-card" key={index}>
<div className="credential-alias-card-heading">
<strong>{t('authFiles.settings.aliasItem', { index: index + 1 })}</strong>
<button type="button" className="icon-button quiet" disabled={disabled} aria-label={t('authFiles.settings.aliasRemove', { index: index + 1 })} onClick={() => setField('model_aliases', aliases.filter((_, rowIndex) => rowIndex !== index))}><X size={15} /></button>
{aliases.map((row, index) => {
const expanded = expandedAlias === index;
return <div className="credential-alias-card" key={index}>
<div className="credential-alias-row">
<input aria-label={t('authFiles.settings.aliasUpstream')} placeholder={t('authFiles.settings.aliasUpstream')} value={textValue(row.name)} disabled={disabled} autoComplete="off" spellCheck={false} maxLength={240}
onChange={(event) => updateAlias(index, { name: event.currentTarget.value })} />
<input aria-label={t('authFiles.settings.aliasClient')} placeholder={t('authFiles.settings.aliasClient')} value={textValue(row.alias)} disabled={disabled} autoComplete="off" spellCheck={false} maxLength={240}
onChange={(event) => updateAlias(index, { alias: event.currentTarget.value })} />
<div className="credential-alias-row-actions">
<button type="button" className="icon-button quiet" disabled={disabled} aria-expanded={expanded} aria-label={expanded ? t('appUpdate.notes.collapse') : t('appUpdate.notes.expand')}
onClick={() => setExpandedAlias(expanded ? null : index)}><ChevronDown size={15} className={expanded ? 'is-expanded' : ''} /></button>
<button type="button" className="icon-button quiet" disabled={disabled} aria-label={t('authFiles.settings.aliasRemove', { index: index + 1 })}
onClick={() => { setExpandedAlias((current) => current === index ? null : current !== null && current > index ? current - 1 : current); setField('model_aliases', aliases.filter((_, rowIndex) => rowIndex !== index)); }}><X size={15} /></button>
</div>
</div>
<div className="credential-alias-fields">
<label className="credential-settings-field">
<span>{t('authFiles.settings.aliasUpstream')}</span>
<AgentModelPicker models={models} value={textValue(row.name)} loading={modelsLoading} error={modelsError} disabled={disabled}
editable={{ label: t('authFiles.settings.aliasUpstream'), placeholder: t('authFiles.settings.aliasModelPlaceholder'), maxLength: 240 }}
menuClassName="credential-model-menu" onChange={(value) => updateAlias(index, { name: value })} />
</label>
<label className="credential-settings-field">
<span>{t('authFiles.settings.aliasClient')}</span>
<AgentModelPicker models={models} value={textValue(row.alias)} loading={modelsLoading} error={modelsError} disabled={disabled}
editable={{ label: t('authFiles.settings.aliasClient'), placeholder: t('authFiles.settings.aliasModelPlaceholder'), maxLength: 240 }}
menuClassName="credential-model-menu" onChange={(value) => updateAlias(index, { alias: value })} />
</label>
{expanded ? <div className="credential-alias-details">
<label className="credential-settings-field">
<span>{t('authFiles.settings.aliasDisplay')}</span>
<input value={textValue(row['display-name'])} disabled={disabled} autoComplete="off" spellCheck={false} onChange={(event) => updateAlias(index, { 'display-name': event.currentTarget.value.trim() ? event.currentTarget.value : undefined })} />
</label>
</div>
<div className="credential-alias-checks">
<label><input type="checkbox" checked={row.fork === true} disabled={disabled} onChange={(event) => updateAlias(index, { fork: event.currentTarget.checked })} />{t('authFiles.settings.aliasFork')}</label>
<label><input type="checkbox" checked={row['force-mapping'] === true} disabled={disabled} onChange={(event) => updateAlias(index, { 'force-mapping': event.currentTarget.checked })} />{t('authFiles.settings.aliasForce')}</label>
</div>
</div>)}
<div className="credential-alias-checks">
<label><input type="checkbox" checked={row.fork === true} disabled={disabled} onChange={(event) => updateAlias(index, { fork: event.currentTarget.checked })} />{t('authFiles.settings.aliasFork')}</label>
<label><input type="checkbox" checked={row['force-mapping'] === true} disabled={disabled} onChange={(event) => updateAlias(index, { 'force-mapping': event.currentTarget.checked })} />{t('authFiles.settings.aliasForce')}</label>
</div>
</div> : null}
</div>;
})}
</div> : aliasesSet ? <p className="credential-settings-lead">{t('authFiles.settings.aliasCleared')}</p> : null}
<div className="credential-settings-inline-actions">
<button type="button" className="secondary-button compact-button" disabled={disabled} onClick={(event) => { const scroller = event.currentTarget.closest('.credential-settings-body'); const top = scroller?.scrollTop ?? 0; setField('model_aliases', [...aliases, { name: '', alias: '' }]); requestAnimationFrame(() => { if (scroller) scroller.scrollTop = top; }); }}><Plus size={14} />{t('authFiles.settings.aliasAdd')}</button>
+103
View File
@@ -0,0 +1,103 @@
.select-menu {
position: relative;
min-width: 0;
max-width: 100%;
}
.select-menu-trigger {
display: grid;
grid-template-columns: minmax(0, max-content) 16px;
align-items: center;
gap: 8px;
width: max-content;
max-width: 100%;
min-height: 34px;
padding: 0 10px 0 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
text-align: left;
}
.select-menu-trigger:hover:not(:disabled),
.select-menu.open .select-menu-trigger {
border-color: var(--text-tertiary);
background: var(--bg-secondary);
}
.select-menu-trigger:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.select-menu-trigger strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: inherit;
font-size: var(--font-size-meta);
font-weight: var(--font-weight-medium);
}
.select-menu-trigger > svg {
color: var(--text-tertiary);
transition: transform 0.16s ease;
}
.select-menu.open .select-menu-trigger > svg {
transform: rotate(180deg);
}
.select-menu-dropdown {
position: fixed;
z-index: 45;
display: grid;
min-width: 180px;
padding: 6px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-card);
box-shadow: 0 14px 34px rgba(45, 42, 38, 0.18);
}
.select-menu-list {
min-height: 0;
overflow: auto;
border-radius: 8px;
}
.select-menu-option {
display: grid;
grid-template-columns: minmax(0, 1fr) 16px;
align-items: center;
gap: 8px;
width: 100%;
min-height: 36px;
padding: 6px 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
text-align: left;
}
.select-menu-option span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-meta);
}
.select-menu-option:hover,
.select-menu-option.active {
background: var(--bg-secondary);
}
.select-menu-option.selected {
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-card));
}
.select-menu-option > svg {
color: var(--primary-color);
}
+179
View File
@@ -0,0 +1,179 @@
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type KeyboardEvent,
} from 'react';
import { Check, ChevronDown } from 'lucide-react';
export type SelectMenuOption = {
value: string;
label: string;
};
type MenuLayout = {
top: number;
left: number;
width: number;
height: number;
};
export function SelectMenu({
value,
options,
onChange,
ariaLabel,
disabled = false,
}: {
value: string;
options: SelectMenuOption[];
onChange: (value: string) => void;
ariaLabel: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const [layout, setLayout] = useState<MenuLayout | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const listboxId = useId();
const selected = options.find((option) => option.value === value) ?? options[0];
const updateLayout = useCallback(() => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
const edgeGap = 12;
const triggerGap = 6;
const preferredHeight = Math.min(282, Math.max(options.length, 1) * 42 + 18);
const minimumHeight = 88;
const spaceBelow = Math.max(0, window.innerHeight - edgeGap - rect.bottom - triggerGap);
const spaceAbove = Math.max(0, rect.top - triggerGap - edgeGap);
const placeAbove = spaceBelow < minimumHeight && spaceAbove > spaceBelow;
const available = placeAbove ? spaceAbove : spaceBelow;
const height = Math.min(preferredHeight, Math.max(Math.min(minimumHeight, available), available));
const width = Math.max(rect.width, 180);
const maxLeft = Math.max(edgeGap, window.innerWidth - edgeGap - width);
const left = Math.min(Math.max(edgeGap, rect.left), maxLeft);
const desiredTop = placeAbove ? rect.top - triggerGap - height : rect.bottom + triggerGap;
const top = Math.min(Math.max(edgeGap, desiredTop), Math.max(edgeGap, window.innerHeight - edgeGap - height));
setLayout({ top, left, width, height });
}, [options.length]);
useLayoutEffect(() => {
if (!open) {
setLayout(null);
return undefined;
}
updateLayout();
window.addEventListener('resize', updateLayout);
window.addEventListener('scroll', updateLayout, true);
return () => {
window.removeEventListener('resize', updateLayout);
window.removeEventListener('scroll', updateLayout, true);
};
}, [open, updateLayout]);
useEffect(() => {
if (!open) return undefined;
const close = (event: MouseEvent) => {
const target = event.target as Node;
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [open]);
useEffect(() => {
if (!open) return;
const selectedIndex = options.findIndex((option) => option.value === value);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
}, [open, options, value]);
const choose = (next: string) => {
onChange(next);
setOpen(false);
rootRef.current?.querySelector('button')?.focus();
};
const moveActive = (offset: number) => {
if (!options.length) return;
setActiveIndex((current) => (current + offset + options.length) % options.length);
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (!open && ['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
event.preventDefault();
setOpen(true);
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
moveActive(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
moveActive(-1);
} else if (event.key === 'Enter' && options[activeIndex]) {
event.preventDefault();
choose(options[activeIndex].value);
} else if (event.key === 'Escape') {
event.preventDefault();
setOpen(false);
}
};
return (
<div className={`select-menu ${open ? 'open' : ''}`} ref={rootRef}
onBlur={(event) => {
const next = event.relatedTarget as Node | null;
if (!event.currentTarget.contains(next) && !menuRef.current?.contains(next)) setOpen(false);
}}>
<button
type="button"
className="select-menu-trigger"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onKeyDown={handleKeyDown}
>
<strong title={selected?.label}>{selected?.label ?? ''}</strong>
<ChevronDown size={16} aria-hidden />
</button>
{open ? (
<div
ref={menuRef}
className="select-menu-dropdown"
style={layout
? { top: layout.top, left: layout.left, width: layout.width, height: layout.height }
: { top: 0, left: 0, width: 0, height: 0, visibility: 'hidden' }}
>
<div className="select-menu-list" id={listboxId} role="listbox" aria-label={ariaLabel}>
{options.map((option, index) => {
const isSelected = option.value === value;
return (
<button
type="button"
role="option"
aria-selected={isSelected}
className={`select-menu-option${isSelected ? ' selected' : ''}${index === activeIndex ? ' active' : ''}`}
key={option.value || option.label}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => choose(option.value)}
>
<span title={option.label}>{option.label}</span>
{isSelected ? <Check size={15} aria-hidden /> : null}
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}