forked from boos/BoosAPI
refactor: streamline explicit group editor flow
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user