1
0
forked from boos/BoosAPI

refactor: streamline explicit group editor flow

This commit is contained in:
cita
2026-03-18 21:21:38 +08:00
parent f769f43909
commit efe60d7cc2
8 changed files with 612 additions and 327 deletions
+4 -2
View File
@@ -16,6 +16,7 @@ import { fuzzyMatch } from './helpers/fuzzySearch.js';
import { clearAuthSession } from '../authSession.js'; import { clearAuthSession } from '../authSession.js';
import { clearAppInstallationState } from '../appLocalState.js'; import { clearAppInstallationState } from '../appLocalState.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
const PROXY_TOKEN_PREFIX = 'sk-'; const PROXY_TOKEN_PREFIX = 'sk-';
const ROUTE_BRAND_ICON_PREFIX = 'brand:'; const ROUTE_BRAND_ICON_PREFIX = 'brand:';
@@ -1677,7 +1678,8 @@ export default function Settings() {
) : filteredGroupRouteOptions.map((route) => { ) : filteredGroupRouteOptions.map((route) => {
const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id); const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id);
const explicitBrandIcon = parseBrandIconValue(route.displayIcon); const explicitBrandIcon = parseBrandIconValue(route.displayIcon);
const textIcon = explicitBrandIcon ? '' : (route.displayIcon || '').trim(); const explicitNoIcon = (route.displayIcon || '').trim() === ROUTE_ICON_NONE_VALUE;
const textIcon = explicitBrandIcon || explicitNoIcon ? '' : (route.displayIcon || '').trim();
return ( return (
<label <label
key={route.id} key={route.id}
@@ -1740,7 +1742,7 @@ export default function Settings() {
/> />
) : textIcon ? ( ) : textIcon ? (
textIcon textIcon
) : ( ) : explicitNoIcon ? null : (
<InlineBrandIcon model={resolveRouteBrandSource(route)} size={18} /> <InlineBrandIcon model={resolveRouteBrandSource(route)} size={18} />
)} )}
</span> </span>
+237 -87
View File
@@ -4,8 +4,10 @@ import ModernSelect from '../../components/ModernSelect.js';
import { tr } from '../../i18n.js'; import { tr } from '../../i18n.js';
import type { RouteIconOption, RouteMode, RouteSummaryRow } from './types.js'; import type { RouteIconOption, RouteMode, RouteSummaryRow } from './types.js';
import { import {
ROUTE_ICON_NONE_VALUE,
getModelPatternError, getModelPatternError,
isExactModelPattern, isExactModelPattern,
isRouteIconNoneValue,
matchesModelPattern, matchesModelPattern,
normalizeRouteDisplayIconValue, normalizeRouteDisplayIconValue,
normalizeRouteMode, normalizeRouteMode,
@@ -46,6 +48,29 @@ function toggleSourceRouteId(sourceRouteIds: number[], routeId: number): number[
return [...sourceRouteIds, routeId].sort((a, b) => a - b); return [...sourceRouteIds, routeId].sort((a, b) => a - b);
} }
function SearchField({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (nextValue: string) => void;
placeholder: string;
}) {
return (
<div className="toolbar-search" style={{ width: '100%' }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
/>
</div>
);
}
export default function ManualRoutePanel({ export default function ManualRoutePanel({
show, show,
editingRouteId, editingRouteId,
@@ -59,11 +84,15 @@ export default function ManualRoutePanel({
onSave, onSave,
onCancel, onCancel,
}: ManualRoutePanelProps) { }: ManualRoutePanelProps) {
const [showSourcePicker, setShowSourcePicker] = useState(false);
const [sourceSearch, setSourceSearch] = useState(''); const [sourceSearch, setSourceSearch] = useState('');
const [sourcePickerSelection, setSourcePickerSelection] = useState<number[]>([]);
useEffect(() => { useEffect(() => {
if (!show) { if (!show) {
setShowSourcePicker(false);
setSourceSearch(''); setSourceSearch('');
setSourcePickerSelection([]);
} }
}, [show]); }, [show]);
@@ -106,6 +135,29 @@ export default function ManualRoutePanel({
.filter((route): route is RouteSummaryRow => !!route); .filter((route): route is RouteSummaryRow => !!route);
}, [exactSourceRouteOptions, form.sourceRouteIds]); }, [exactSourceRouteOptions, form.sourceRouteIds]);
const autoBrandIconEnabled = !isRouteIconNoneValue(form.displayIcon);
const hasExplicitIconValue = !!normalizeRouteDisplayIconValue(form.displayIcon);
const openSourcePicker = () => {
setSourcePickerSelection([...form.sourceRouteIds]);
setSourceSearch('');
setShowSourcePicker(true);
};
const closeSourcePicker = () => {
setShowSourcePicker(false);
setSourceSearch('');
};
const confirmSourcePicker = () => {
setForm((current) => ({
...current,
sourceRouteIds: [...sourcePickerSelection].sort((a, b) => a - b),
}));
setShowSourcePicker(false);
setSourceSearch('');
};
const footer = ( const footer = (
<> <>
<button <button
@@ -137,7 +189,36 @@ export default function ManualRoutePanel({
</> </>
); );
const sourcePickerFooter = (
<>
<button
type="button"
onClick={closeSourcePicker}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{tr('取消')}
</button>
<button
type="button"
onClick={() => setSourcePickerSelection([])}
className="btn btn-ghost"
disabled={sourcePickerSelection.length === 0}
>
{tr('清空')}
</button>
<button
type="button"
onClick={confirmSourcePicker}
className="btn btn-primary"
>
{`确认选择 (${sourcePickerSelection.length})`}
</button>
</>
);
return ( return (
<>
<CenteredModal <CenteredModal
open={show} open={show}
onClose={onCancel} onClose={onCancel}
@@ -205,7 +286,7 @@ export default function ManualRoutePanel({
<input <input
placeholder={tr('对外模型名(例如 claude-opus-4-6)')} placeholder={tr('对外模型名(例如 claude-opus-4-6)')}
value={form.displayName} value={form.displayName}
onChange={(e) => setForm((current) => ({ ...current, displayName: e.target.value }))} onChange={(event) => setForm((current) => ({ ...current, displayName: event.target.value }))}
style={{ style={{
width: '100%', width: '100%',
padding: '10px 14px', padding: '10px 14px',
@@ -230,6 +311,7 @@ export default function ManualRoutePanel({
{tr('选择一个或多个现有精确模型路由作为来源。')} {tr('选择一个或多个现有精确模型路由作为来源。')}
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{!editingRouteId && ( {!editingRouteId && (
<button <button
type="button" type="button"
@@ -239,27 +321,36 @@ export default function ManualRoutePanel({
{tr('改用高级规则')} {tr('改用高级规则')}
</button> </button>
)} )}
<button
type="button"
className="btn btn-ghost"
onClick={openSourcePicker}
>
{tr('选择来源模型')}
</button>
</div>
</div> </div>
<input <div
placeholder={tr('搜索来源模型')}
value={sourceSearch}
onChange={(e) => setSourceSearch(e.target.value)}
style={{ style={{
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', borderRadius: 'var(--radius-md)',
fontSize: 13, padding: '12px 14px',
outline: 'none', background: 'var(--color-bg-card)',
background: 'var(--color-bg)', display: 'flex',
color: 'var(--color-text-primary)', flexDirection: 'column',
gap: 12,
}} }}
/> >
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{selectedSourceRoutes.length > 0
? `已选择 ${selectedSourceRoutes.length} 个来源模型`
: tr('尚未选择来源模型。')}
</div>
{selectedSourceRoutes.length > 0 && ( {selectedSourceRoutes.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedSourceRoutes.map((route) => ( {selectedSourceRoutes.slice(0, 8).map((route) => (
<button <button
key={`selected-${route.id}`} key={`selected-${route.id}`}
type="button" type="button"
@@ -273,90 +364,65 @@ export default function ManualRoutePanel({
{renderRouteOptionLabel(route)} × {renderRouteOptionLabel(route)} ×
</button> </button>
))} ))}
{selectedSourceRoutes.length > 8 && (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{`+${selectedSourceRoutes.length - 8}`}
</span>
)}
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('点击“选择来源模型”打开大列表,搜索后快速勾选。')}
</div> </div>
)} )}
</div>
</div>
<div <div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 10,
maxHeight: 280,
overflowY: 'auto',
paddingRight: 4,
}}
>
{filteredSourceRoutes.map((route) => {
const selected = form.sourceRouteIds.includes(route.id);
const label = renderRouteOptionLabel(route);
return (
<button
key={route.id}
type="button"
onClick={() => setForm((current) => ({
...current,
sourceRouteIds: toggleSourceRouteId(current.sourceRouteIds, route.id),
}))}
className="btn btn-ghost"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 6,
padding: '12px 14px',
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border)'}`,
background: selected ? 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))' : 'var(--color-bg-card)',
boxShadow: selected ? '0 0 0 1px color-mix(in srgb, var(--color-primary) 25%, transparent)' : 'none',
}}
>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{label}</span>
{label !== route.modelPattern && (
<code style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
)}
<span style={{ fontSize: 11, color: selected ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>
{selected ? tr('已选中') : tr('点击添加')}
</span>
</button>
);
})}
</div>
{filteredSourceRoutes.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('没有匹配的来源模型。')}
</div>
)}
</div>
<details
open={form.advancedOpen}
onToggle={(event) => {
const nextOpen = (event.currentTarget as HTMLDetailsElement).open;
setForm((current) => ({ ...current, advancedOpen: nextOpen }));
}}
style={{ style={{
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)', borderRadius: 'var(--radius-md)',
padding: '0 14px', padding: '12px 14px',
background: 'var(--color-bg-card)', background: 'var(--color-bg-card)',
display: 'flex',
flexDirection: 'column',
gap: 12,
}} }}
> >
<summary style={{ cursor: 'pointer', padding: '12px 0', fontSize: 13, fontWeight: 600 }}> <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
{tr('高级配置')} {tr('高级配置')}
</summary> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gap: 12, paddingBottom: 14 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer' }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('群组图标')}</span> <input
<ModernSelect type="checkbox"
value={routeIconSelectValue} checked={autoBrandIconEnabled}
onChange={(nextValue) => setForm((current) => ({ ...current, displayIcon: nextValue }))} onChange={(event) => {
options={routeIconSelectOptions} const checked = !!event.target.checked;
placeholder={tr('图标(可选,选择品牌图标)')} setForm((current) => ({
emptyLabel={tr('暂无可选品牌图标')} ...current,
displayIcon: checked
? (isRouteIconNoneValue(current.displayIcon) ? '' : current.displayIcon)
: ROUTE_ICON_NONE_VALUE,
}));
}}
style={{ marginTop: 2, width: 16, height: 16, accentColor: 'var(--color-primary)' }}
/> />
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 13, color: 'var(--color-text-primary)' }}>{tr('自动品牌图标')}</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
{autoBrandIconEnabled
? tr('开启后会优先保留现有图标策略;未设置固定图标时按模型自动识别品牌。')
: tr('关闭后该群组不显示品牌图标,只保留文字标题。')}
</span>
{hasExplicitIconValue && autoBrandIconEnabled && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前群组如果已设置固定品牌图标,保持开启不会移除它。')}
</span>
)}
</div>
</label> </label>
</div> </div>
</details>
</> </>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -382,7 +448,7 @@ export default function ManualRoutePanel({
<input <input
placeholder={tr('群组显示名(可选,例如 claude-opus-4-6)')} placeholder={tr('群组显示名(可选,例如 claude-opus-4-6)')}
value={form.displayName} value={form.displayName}
onChange={(e) => setForm((current) => ({ ...current, displayName: e.target.value }))} onChange={(event) => setForm((current) => ({ ...current, displayName: event.target.value }))}
style={{ style={{
width: '100%', width: '100%',
padding: '10px 14px', padding: '10px 14px',
@@ -413,7 +479,7 @@ export default function ManualRoutePanel({
<input <input
placeholder={tr('模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)')} placeholder={tr('模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)')}
value={form.modelPattern} value={form.modelPattern}
onChange={(e) => setForm((current) => ({ ...current, modelPattern: e.target.value }))} onChange={(event) => setForm((current) => ({ ...current, modelPattern: event.target.value }))}
style={{ style={{
width: '100%', width: '100%',
padding: '10px 14px', padding: '10px 14px',
@@ -491,5 +557,89 @@ export default function ManualRoutePanel({
)} )}
</div> </div>
</CenteredModal> </CenteredModal>
<CenteredModal
open={show && showSourcePicker}
onClose={closeSourcePicker}
title={tr('选择来源模型')}
footer={sourcePickerFooter}
maxWidth={980}
closeOnEscape
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{`已选择 ${sourcePickerSelection.length} 个来源模型`}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{`候选 ${filteredSourceRoutes.length} / ${exactSourceRouteOptions.length}`}
</div>
</div>
</div>
<SearchField
value={sourceSearch}
onChange={setSourceSearch}
placeholder={tr('搜索来源模型')}
/>
<div style={{ maxHeight: 440, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
{filteredSourceRoutes.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '12px 0', textAlign: 'center' }}>
{exactSourceRouteOptions.length === 0
? tr('当前没有可选的精确模型路由。')
: tr('没有匹配的来源模型。')}
</div>
) : (
filteredSourceRoutes.map((route) => {
const selected = sourcePickerSelection.includes(route.id);
const label = renderRouteOptionLabel(route);
return (
<button
key={route.id}
type="button"
onClick={() => setSourcePickerSelection((current) => toggleSourceRouteId(current, route.id))}
className="btn btn-ghost"
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
padding: '12px 14px',
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border)'}`,
background: selected
? 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))'
: 'var(--color-bg-card)',
boxShadow: selected
? '0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent)'
: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
<input
type="checkbox"
checked={selected}
readOnly
style={{ marginTop: 2, cursor: 'pointer', pointerEvents: 'none' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{label}</span>
{label !== route.modelPattern && (
<code style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
)}
</div>
</div>
<span className={selected ? 'badge badge-info' : 'badge badge-muted'} style={{ fontSize: 10, flexShrink: 0 }}>
{selected ? tr('已选中') : tr('可选择')}
</span>
</button>
);
})
)}
</div>
</div>
</CenteredModal>
</>
); );
} }
+6 -6
View File
@@ -176,11 +176,11 @@ function RouteCardInner({
<BrandGlyph icon={routeIcon.value} alt={title} size={18} fallbackText={title} /> <BrandGlyph icon={routeIcon.value} alt={title} size={18} fallbackText={title} />
) : routeIcon.kind === 'text' ? ( ) : routeIcon.kind === 'text' ? (
<span style={{ fontSize: 14, lineHeight: 1 }}>{routeIcon.value}</span> <span style={{ fontSize: 14, lineHeight: 1 }}>{routeIcon.value}</span>
) : brand ? ( ) : routeIcon.kind === 'auto' && brand ? (
<BrandGlyph brand={brand} alt={title} size={18} fallbackText={title} /> <BrandGlyph brand={brand} alt={title} size={18} fallbackText={title} />
) : ( ) : routeIcon.kind === 'auto' ? (
<InlineBrandIcon model={route.modelPattern} size={18} /> <InlineBrandIcon model={route.modelPattern} size={18} />
)} ) : null}
</span> </span>
<code style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}> <code style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
@@ -245,11 +245,11 @@ function RouteCardInner({
<span style={{ width: 20, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, background: 'var(--color-bg-card)', fontSize: 14, lineHeight: 1 }}> <span style={{ width: 20, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, background: 'var(--color-bg-card)', fontSize: 14, lineHeight: 1 }}>
{routeIcon.value} {routeIcon.value}
</span> </span>
) : brand ? ( ) : routeIcon.kind === 'auto' && brand ? (
<BrandGlyph brand={brand} alt={title} size={20} fallbackText={title} /> <BrandGlyph brand={brand} alt={title} size={20} fallbackText={title} />
) : ( ) : routeIcon.kind === 'auto' ? (
<InlineBrandIcon model={route.modelPattern} size={20} /> <InlineBrandIcon model={route.modelPattern} size={20} />
)} ) : null}
{title} {title}
</code> </code>
{route.displayName && route.displayName.trim() !== route.modelPattern ? ( {route.displayName && route.displayName.trim() !== route.modelPattern ? (
@@ -209,11 +209,11 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} /> <BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'text' ? ( ) : groupRoute.icon.kind === 'text' ? (
<span style={{ fontSize: 10, lineHeight: 1 }}>{groupRoute.icon.value}</span> <span style={{ fontSize: 10, lineHeight: 1 }}>{groupRoute.icon.value}</span>
) : groupRoute.brand ? ( ) : groupRoute.icon.kind === 'auto' && groupRoute.brand ? (
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} /> <BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : ( ) : groupRoute.icon.kind === 'auto' ? (
<InlineBrandIcon model={groupRoute.modelPattern} size={12} /> <InlineBrandIcon model={groupRoute.modelPattern} size={12} />
) ) : undefined
} }
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)} onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
/> />
+1 -1
View File
@@ -161,7 +161,7 @@ export type SortableChannelRowProps = {
export type GroupRouteItem = { export type GroupRouteItem = {
id: number; id: number;
title: string; title: string;
icon: { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string }; icon: { kind: 'auto' } | { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string };
brand: BrandInfo | null; brand: BrandInfo | null;
modelPattern: string; modelPattern: string;
channelCount: number; channelCount: number;
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../components/BrandIcon.js', () => ({
getBrand: () => null,
normalizeBrandIconKey: (icon: string) => icon.trim().toLowerCase(),
}));
import {
ROUTE_ICON_NONE_VALUE,
normalizeRouteDisplayIconValue,
resolveRouteIcon,
} from './utils.js';
describe('token route icon helpers', () => {
it('preserves the explicit no-icon sentinel during normalization', () => {
expect(normalizeRouteDisplayIconValue(ROUTE_ICON_NONE_VALUE)).toBe(ROUTE_ICON_NONE_VALUE);
});
it('treats the explicit no-icon sentinel as no icon', () => {
expect(resolveRouteIcon({ displayIcon: ROUTE_ICON_NONE_VALUE })).toEqual({ kind: 'none' });
});
});
+9 -2
View File
@@ -5,6 +5,7 @@ import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionSta
export const AUTO_ROUTE_DECISION_LIMIT = 80; export const AUTO_ROUTE_DECISION_LIMIT = 80;
export const ROUTE_RENDER_CHUNK = 40; export const ROUTE_RENDER_CHUNK = 40;
export const ROUTE_BRAND_ICON_PREFIX = 'brand:'; export const ROUTE_BRAND_ICON_PREFIX = 'brand:';
export const ROUTE_ICON_NONE_VALUE = '__route_icon_none__';
export const ENDPOINT_TYPE_ICON_MODEL_MAP: Record<string, string> = { export const ENDPOINT_TYPE_ICON_MODEL_MAP: Record<string, string> = {
openai: 'chatgpt', openai: 'chatgpt',
@@ -172,8 +173,13 @@ export function parseBrandIconValue(raw: string): string | null {
return normalizeBrandIconKey(icon); return normalizeBrandIconKey(icon);
} }
export function isRouteIconNoneValue(raw: string | null | undefined): boolean {
return (raw || '').trim() === ROUTE_ICON_NONE_VALUE;
}
export function normalizeRouteDisplayIconValue(raw: string | null | undefined): string { export function normalizeRouteDisplayIconValue(raw: string | null | undefined): string {
const normalized = (raw || '').trim(); const normalized = (raw || '').trim();
if (isRouteIconNoneValue(normalized)) return ROUTE_ICON_NONE_VALUE;
const brandIcon = parseBrandIconValue(normalized); const brandIcon = parseBrandIconValue(normalized);
if (brandIcon) return toBrandIconValue(brandIcon); if (brandIcon) return toBrandIconValue(brandIcon);
return normalized; return normalized;
@@ -212,9 +218,10 @@ export function siteAvatarLetters(siteName: string): string {
return compact.slice(0, 2).toUpperCase(); return compact.slice(0, 2).toUpperCase();
} }
export function resolveRouteIcon(route: Pick<RouteRow | RouteSummaryRow, 'displayIcon'>): { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } { export function resolveRouteIcon(route: Pick<RouteRow | RouteSummaryRow, 'displayIcon'>): { kind: 'auto' } | { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } {
const icon = (route.displayIcon || '').trim(); const icon = (route.displayIcon || '').trim();
if (!icon) return { kind: 'none' }; if (isRouteIconNoneValue(icon)) return { kind: 'none' };
if (!icon) return { kind: 'auto' };
const brandIcon = parseBrandIconValue(icon); const brandIcon = parseBrandIconValue(icon);
if (brandIcon) return { kind: 'brand', value: brandIcon }; if (brandIcon) return { kind: 'brand', value: brandIcon };
return { kind: 'text', value: icon }; return { kind: 'text', value: icon };
@@ -3,6 +3,7 @@ import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js'; import { ToastProvider } from '../components/Toast.js';
import TokenRoutes from './TokenRoutes.js'; import TokenRoutes from './TokenRoutes.js';
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({ const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
@@ -55,6 +56,15 @@ function findInputByPlaceholder(root: ReactTestInstance, placeholderText: string
)); ));
} }
function findCheckboxByLabelText(root: ReactTestInstance, text: string): ReactTestInstance {
return root.find((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
&& !!node.parent
&& collectText(node.parent).includes(text)
));
}
async function flushMicrotasks() { async function flushMicrotasks() {
await act(async () => { await act(async () => {
await Promise.resolve(); await Promise.resolve();
@@ -855,7 +865,7 @@ describe('TokenRoutes grouped source models', () => {
} }
}); });
it('opens a modal-based group creator and submits explicit-group sourceRouteIds', async () => { it('uses a dedicated source picker modal and submits explicit-group sourceRouteIds', async () => {
apiMock.getRoutesSummary.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 11, modelPattern: 'claude-opus-4-5', displayName: null, id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
@@ -896,12 +906,26 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
expect(root.root.findAll((node) => typeof node.props?.placeholder === 'string' && node.props.placeholder.includes('搜索来源模型'))).toHaveLength(0);
await act(async () => {
findButtonByText(root.root, '选择来源模型').props.onClick();
});
await flushMicrotasks();
expect(findInputByPlaceholder(root.root, '搜索来源模型')).toBeTruthy();
await act(async () => { await act(async () => {
findButtonByText(root.root, 'claude-opus-4-5').props.onClick(); findButtonByText(root.root, 'claude-opus-4-5').props.onClick();
findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick(); findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick();
}); });
await flushMicrotasks(); await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '确认选择').props.onClick();
});
await flushMicrotasks();
await act(async () => { await act(async () => {
findButtonByText(root.root, '创建群组').props.onClick(); findButtonByText(root.root, '创建群组').props.onClick();
}); });
@@ -918,6 +942,76 @@ describe('TokenRoutes grouped source models', () => {
} }
}); });
it('saves explicit groups with auto brand icon disabled as a no-icon sentinel', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '新建群组').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findInputByPlaceholder(root.root, '对外模型名').props.onChange({ target: { value: 'claude-opus-4-6' } });
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '选择来源模型').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, 'claude-opus-4-5').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '确认选择').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findCheckboxByLabelText(root.root, '自动品牌图标').props.onChange({ target: { checked: false } });
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '创建群组').props.onClick();
});
await flushMicrotasks();
expect(apiMock.addRoute).toHaveBeenCalledWith(expect.objectContaining({
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [11],
displayIcon: ROUTE_ICON_NONE_VALUE,
}));
} finally {
root?.unmount();
}
});
it('edits legacy regex groups in advanced mode only', async () => { it('edits legacy regex groups in advanced mode only', async () => {
apiMock.getRoutesSummary apiMock.getRoutesSummary
.mockResolvedValueOnce([ .mockResolvedValueOnce([
@@ -1045,11 +1139,21 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '选择来源模型').props.onClick();
});
await flushMicrotasks();
await act(async () => { await act(async () => {
findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick(); findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick();
}); });
await flushMicrotasks(); await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '确认选择').props.onClick();
});
await flushMicrotasks();
await act(async () => { await act(async () => {
findButtonByText(root.root, '保存群组').props.onClick(); findButtonByText(root.root, '保存群组').props.onClick();
}); });