[codex] support downstream key site and credential exclusions (#431)

* support downstream key site and credential exclusions

* skip electron binary download in non-desktop ci

* fix migrate partial-journal test for 0020
This commit is contained in:
Cita
2026-04-05 23:43:07 +08:00
committed by GitHub
parent 492f43c5dc
commit 38721cb890
30 changed files with 1718 additions and 793 deletions
+127
View File
@@ -9,6 +9,8 @@ const { apiMock } = vi.hoisted(() => ({
getDownstreamApiKeysSummary: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getAccounts: vi.fn(),
getAccountTokens: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(),
@@ -135,6 +137,50 @@ beforeEach(() => {
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
]);
apiMock.getAccounts.mockResolvedValue([
{
id: 101,
username: '站点A账号',
apiToken: 'sk-default-a',
status: 'active',
credentialMode: 'session',
site: {
id: 201,
name: '站点A',
status: 'active',
},
},
{
id: 102,
username: '站点B账号',
apiToken: 'sk-default-b',
status: 'active',
credentialMode: 'session',
site: {
id: 202,
name: '站点B',
status: 'active',
},
},
]);
apiMock.getAccountTokens.mockResolvedValue([
{
id: 301,
accountId: 101,
name: 'token-a',
tokenGroup: 'group-a',
enabled: true,
valueStatus: 'ready',
account: {
id: 101,
username: '站点A账号',
},
site: {
id: 201,
name: '站点A',
},
},
]);
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
success: true,
item: buildSummaryItem(),
@@ -776,4 +822,85 @@ describe('DownstreamKeys page', () => {
root?.unmount();
}
});
it('lazy loads exclusion sources and submits excluded sites and credentials', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(apiMock.getAccounts).not.toHaveBeenCalled();
expect(apiMock.getAccountTokens).not.toHaveBeenCalled();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getAccounts).toHaveBeenCalledTimes(1);
expect(apiMock.getAccountTokens).toHaveBeenCalledTimes(1);
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('排除站点');
expect(text).toContain('排除 API Key/令牌');
expect(text).toContain('默认 API Key');
expect(text).toContain('group-a');
const inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
await act(async () => {
nameInput!.props.onChange({ target: { value: 'excluded-key' } });
keyInput!.props.onChange({ target: { value: 'sk-excluded-key-0405' } });
});
await flushMicrotasks();
const siteLabel = root!.root.findAll((node) => node.type === 'label' && collectText(node).includes('站点B'))[0];
const tokenLabel = root!.root.findAll((node) => node.type === 'label' && collectText(node).includes('token-a'))[0];
const defaultApiKeyLabel = root!.root.findAll((node) => node.type === 'label' && collectText(node).includes('默认 API Key'))[0];
const siteCheckbox = siteLabel.findByType('input');
const tokenCheckbox = tokenLabel.findByType('input');
const defaultApiKeyCheckbox = defaultApiKeyLabel.findByType('input');
await act(async () => {
siteCheckbox.props.onChange({ target: { checked: true } });
tokenCheckbox.props.onChange({ target: { checked: true } });
defaultApiKeyCheckbox.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
name: 'excluded-key',
key: 'sk-excluded-key-0405',
excludedSiteIds: [202],
excludedCredentialRefs: [
{ kind: 'account_token', siteId: 201, accountId: 101, tokenId: 301 },
{ kind: 'default_api_key', siteId: 201, accountId: 101 },
],
}));
} finally {
root?.unmount();
}
});
});
+145 -431
View File
@@ -9,6 +9,13 @@ import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { tr } from '../i18n.js';
import DownstreamKeyEditorModal, {
TagInput,
type DownstreamCredentialOption,
type DownstreamExcludedCredentialRef,
type DownstreamKeyEditorForm,
type DownstreamSiteOption,
} from './downstream-keys/DownstreamKeyEditorModal.js';
import DownstreamKeyDrawer from './downstream-keys/DownstreamKeyDrawer.js';
import {
formatCompactTokens,
@@ -20,9 +27,7 @@ import {
type Range,
type SummaryItem,
} from './downstream-keys/shared.js';
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
const PROXY_TOKEN_PREFIX = 'sk-';
type Status = 'all' | 'enabled' | 'disabled';
type DownstreamApiKeyItem = {
@@ -42,6 +47,8 @@ type DownstreamApiKeyItem = {
supportedModels: string[];
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
excludedSiteIds: number[];
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
lastUsedAt: string | null;
};
@@ -56,21 +63,6 @@ type RouteSelectorItem = {
enabled: boolean;
};
type EditorForm = {
name: string;
key: string;
description: string;
groupName: string;
tags: string[];
maxCost: string;
maxRequests: string;
expiresAt: string;
enabled: boolean;
selectedModels: string[];
selectedGroupRouteIds: number[];
siteWeightMultipliersText: string;
};
type DeleteConfirmState =
| null
| { mode: 'single'; item: ManagedItem }
@@ -85,7 +77,7 @@ type BatchMetadataForm = {
tags: string[];
};
type DefaultRouteSelections = Pick<EditorForm, 'selectedModels' | 'selectedGroupRouteIds'>;
type DefaultRouteSelections = Pick<DownstreamKeyEditorForm, 'selectedModels' | 'selectedGroupRouteIds'>;
function toDateTimeLocal(isoString: string | null | undefined): string {
if (!isoString) return '';
const ts = Date.parse(isoString);
@@ -139,6 +131,41 @@ function uniqIds(values: number[]): number[] {
return [...new Set(values.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0).map((value) => Math.trunc(value)))];
}
function buildExcludedCredentialRefKey(ref: DownstreamExcludedCredentialRef): string {
return ref.kind === 'account_token'
? `${ref.kind}:${ref.siteId}:${ref.accountId}:${ref.tokenId}`
: `${ref.kind}:${ref.siteId}:${ref.accountId}`;
}
function normalizeExcludedSiteIds(values: number[]): number[] {
return uniqIds(values).sort((left, right) => left - right);
}
function normalizeExcludedCredentialRefs(values: DownstreamExcludedCredentialRef[]): DownstreamExcludedCredentialRef[] {
const deduped = new Map<string, DownstreamExcludedCredentialRef>();
for (const value of values) {
if (!value || !Number.isFinite(value.siteId) || !Number.isFinite(value.accountId)) continue;
if (value.kind === 'account_token') {
if (!Number.isFinite(value.tokenId)) continue;
const normalized: DownstreamExcludedCredentialRef = {
kind: 'account_token',
siteId: Math.trunc(value.siteId),
accountId: Math.trunc(value.accountId),
tokenId: Math.trunc(value.tokenId),
};
deduped.set(buildExcludedCredentialRefKey(normalized), normalized);
continue;
}
const normalized: DownstreamExcludedCredentialRef = {
kind: 'default_api_key',
siteId: Math.trunc(value.siteId),
accountId: Math.trunc(value.accountId),
};
deduped.set(buildExcludedCredentialRefKey(normalized), normalized);
}
return Array.from(deduped.values()).sort((left, right) => buildExcludedCredentialRefKey(left).localeCompare(buildExcludedCredentialRefKey(right)));
}
function buildDefaultRouteSelections(routeOptions: RouteSelectorItem[]): DefaultRouteSelections {
return {
selectedModels: uniqStrings(
@@ -299,7 +326,7 @@ function buildEditorForm(
item?: ManagedItem | DownstreamApiKeyItem | null,
routeOptions: RouteSelectorItem[] = [],
selectAllByDefault = false,
): EditorForm {
): DownstreamKeyEditorForm {
const defaultSelections = selectAllByDefault
? buildDefaultRouteSelections(routeOptions)
: { selectedModels: [], selectedGroupRouteIds: [] };
@@ -323,6 +350,8 @@ function buildEditorForm(
selectedModels: uniqStrings(selectedModels),
selectedGroupRouteIds: uniqIds(selectedGroupRouteIds),
siteWeightMultipliersText: JSON.stringify(item?.siteWeightMultipliers || {}, null, 2),
excludedSiteIds: normalizeExcludedSiteIds(Array.isArray(item?.excludedSiteIds) ? item.excludedSiteIds : []),
excludedCredentialRefs: normalizeExcludedCredentialRefs(Array.isArray(item?.excludedCredentialRefs) ? item.excludedCredentialRefs : []),
};
}
@@ -356,90 +385,6 @@ function summarizeTags(tags: string[]): string {
return `${tags[0]} +${tags.length - 1}`;
}
function TagInput({
tags,
onChange,
suggestions = [],
placeholder,
}: {
tags: string[];
onChange: (tags: string[]) => void;
suggestions?: string[];
placeholder?: string;
}) {
const [draft, setDraft] = useState('');
useEffect(() => {
setDraft('');
}, [tags.length]);
const commitDraft = () => {
const nextTags = normalizeTags([...tags, ...parseTagText(draft)]);
if (nextTags.length !== tags.length) {
onChange(nextTags);
}
setDraft('');
};
const removeTag = (target: string) => {
onChange(tags.filter((tag) => tag !== target));
};
const suggestionPool = suggestions.filter((tag) => !tags.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 12);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)', padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{tags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => removeTag(tag)}
style={{ ...tagChipStyle('accent'), cursor: 'pointer' }}
title={`移除 ${tag}`}
>
<span>{tag}</span>
<span aria-hidden="true">×</span>
</button>
))}
</div>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitDraft();
} else if (e.key === 'Backspace' && !draft && tags.length > 0) {
e.preventDefault();
onChange(tags.slice(0, -1));
}
}}
placeholder={placeholder || '输入标签后按回车或逗号'}
style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', color: 'var(--color-text-primary)', padding: 0, fontSize: 13, lineHeight: 1.45 }}
/>
</div>
{suggestionPool.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{suggestionPool.map((tag) => (
<button
key={tag}
type="button"
className="btn btn-ghost"
style={{ ...tagChipStyle(), cursor: 'pointer' }}
onClick={() => onChange(normalizeTags([...tags, tag]))}
>
{tag}
</button>
))}
</div>
) : null}
</div>
);
}
function SummaryMetric({
label,
value,
@@ -508,332 +453,6 @@ function InlineToggle({
);
}
function EditorModal({
open,
editingItem,
form,
onChange,
onClose,
onSave,
saving,
routeOptions,
groupSuggestions,
tagSuggestions,
}: {
open: boolean;
editingItem: ManagedItem | null;
form: EditorForm;
onChange: (updater: (prev: EditorForm) => EditorForm) => void;
onClose: () => void;
onSave: () => void;
saving: boolean;
routeOptions: RouteSelectorItem[];
groupSuggestions: string[];
tagSuggestions: string[];
}) {
const [modelSearch, setModelSearch] = useState('');
const [groupSearch, setGroupSearch] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
if (!open) {
setModelSearch('');
setGroupSearch('');
setAdvancedOpen(false);
}
}, [open]);
const exactModels = useMemo(
() => uniqStrings(routeOptions.filter((item) => isExactModelPattern(item.modelPattern)).map((item) => item.modelPattern)).sort((a, b) => a.localeCompare(b)),
[routeOptions],
);
const groupRouteOptions = useMemo(
() => routeOptions.filter(isGroupRouteOption),
[routeOptions],
);
const validGroupRouteIdSet = useMemo(
() => new Set(groupRouteOptions.map((route) => route.id)),
[groupRouteOptions],
);
const normalizedSelectedGroupRouteIds = useMemo(
() => uniqIds(form.selectedGroupRouteIds.filter((id) => validGroupRouteIdSet.has(id))),
[form.selectedGroupRouteIds, validGroupRouteIdSet],
);
const filteredModels = useMemo(() => {
const keyword = modelSearch.trim().toLowerCase();
if (!keyword) return exactModels;
return exactModels.filter((model) => model.toLowerCase().includes(keyword));
}, [exactModels, modelSearch]);
const filteredGroups = useMemo(() => {
const keyword = groupSearch.trim().toLowerCase();
if (!keyword) return groupRouteOptions;
return groupRouteOptions.filter((route) => {
const title = routeTitle(route).toLowerCase();
return title.includes(keyword) || route.modelPattern.toLowerCase().includes(keyword);
});
}, [groupRouteOptions, groupSearch]);
const selectedModelCount = form.selectedModels.length;
const selectedGroupCount = normalizedSelectedGroupRouteIds.length;
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 13,
lineHeight: 1.45,
};
return (
<CenteredModal
open={open}
onClose={onClose}
title={editingItem ? '编辑下游密钥' : '新增下游密钥'}
maxWidth={860}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost" disabled={saving}>取消</button>
<button onClick={onSave} className="btn btn-primary" disabled={saving}>
{saving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</>
: (editingItem ? '保存修改' : '创建密钥')}
</button>
</>
)}
>
<div className="info-tip" style={{ marginBottom: 0 }}>
支持为每个下游密钥独立配置分组、标签、额度与有效期。高级限制项可按需展开。
</div>
<div className="downstream-key-modal-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">名称</div>
<input value={form.name} onChange={(e) => onChange((prev) => ({ ...prev, name: e.target.value }))} placeholder="例如:项目 A / 移动端" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">下游密钥</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', minWidth: 0 }}>
<input
value={form.key}
onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value }))}
placeholder="sk-..."
style={{ ...inputStyle, flex: 1, minWidth: 0, fontFamily: 'var(--font-mono)' }}
/>
<button
type="button"
className="btn btn-ghost"
style={{ flexShrink: 0, whiteSpace: 'nowrap', alignSelf: 'stretch' }}
onClick={() => onChange((prev) => ({ ...prev, key: generateDownstreamSkKey(PROXY_TOKEN_PREFIX) }))}
>
随机
</button>
</div>
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">主分组</div>
<input
value={form.groupName}
onChange={(e) => onChange((prev) => ({ ...prev, groupName: e.target.value }))}
placeholder="例如:VIP / 内部项目 / A组"
list="downstream-group-suggestions"
style={inputStyle}
/>
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">请求额度</div>
<input value={form.maxRequests} onChange={(e) => onChange((prev) => ({ ...prev, maxRequests: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">成本额度</div>
<input value={form.maxCost} onChange={(e) => onChange((prev) => ({ ...prev, maxCost: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">过期时间</div>
<input type="datetime-local" value={form.expiresAt} onChange={(e) => onChange((prev) => ({ ...prev, expiresAt: e.target.value }))} style={inputStyle} />
</div>
<label
className="downstream-key-modal-toggle"
>
<input type="checkbox" checked={form.enabled} onChange={(e) => onChange((prev) => ({ ...prev, enabled: e.target.checked }))} />
<div>
<div className="downstream-key-modal-toggle-title">创建后立即启用</div>
<div className="downstream-key-modal-help">关闭后该密钥将无法继续分发请求</div>
</div>
</label>
</div>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">备注说明</div>
<textarea
value={form.description}
onChange={(e) => onChange((prev) => ({ ...prev, description: e.target.value }))}
placeholder="填写业务场景、负责人或限制说明"
style={{ ...inputStyle, minHeight: 84, resize: 'vertical' }}
/>
</div>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">标签</div>
<TagInput
tags={form.tags}
onChange={(tags) => onChange((prev) => ({ ...prev, tags }))}
suggestions={tagSuggestions}
placeholder="输入标签后按回车或逗号,例如:移动端、VIP、项目A"
/>
<div className="downstream-key-modal-help">标签用于搜索、筛选和辅助归类,不影响路由与权限。</div>
</div>
<div className="downstream-key-advanced">
<button type="button" className={`downstream-key-advanced-toggle ${advancedOpen ? 'is-open' : ''}`.trim()} onClick={() => setAdvancedOpen((value) => !value)}>
<span>高级配置</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{advancedOpen ? '收起' : '展开'}</span>
</button>
{advancedOpen ? (
<div className="downstream-key-advanced-content">
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">站点倍率 JSON</div>
<textarea
value={form.siteWeightMultipliersText}
onChange={(e) => onChange((prev) => ({ ...prev, siteWeightMultipliersText: e.target.value }))}
placeholder={'例如:{\n "1": 1.2,\n "7": 0.8\n}'}
style={{ ...inputStyle, minHeight: 96, resize: 'vertical', fontFamily: 'var(--font-mono)' }}
/>
<div className="downstream-key-modal-help">用于对特定站点做分发倍率微调;留空或 `{}` 表示走默认倍率。</div>
</div>
<div className="downstream-key-advanced-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">模型白名单</div>
<div className="downstream-key-modal-help">只展示精确模型;未勾选时默认不允许任何精确模型,可点“全选”一次性放开。</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedModels: exactModels }))}
>
全选
</button>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}
>
清空
</button>
</div>
</div>
<div className="downstream-key-modal-meta">已选 {selectedModelCount} 个模型</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" 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={modelSearch} onChange={(e) => setModelSearch(e.target.value)} placeholder="搜索模型" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredModels.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无匹配模型</div>
) : filteredModels.map((model) => {
const checked = form.selectedModels.includes(model);
return (
<label key={model} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onChange((prev) => ({
...prev,
selectedModels: checked ? prev.selectedModels.filter((item) => item !== model) : [...prev.selectedModels, model],
}))}
/>
<code style={{ color: 'var(--color-text-primary)', fontSize: 12 }}>{model}</code>
</label>
);
})}
</div>
</div>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">群组范围</div>
<div className="downstream-key-modal-help">限制可访问的群组路由;未勾选时默认不允许任何群组,可点“全选”一次性放开。</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: groupRouteOptions.map((route) => route.id) }))}
>
全选
</button>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}
>
清空
</button>
</div>
</div>
<div className="downstream-key-modal-meta">已选 {selectedGroupCount} 个群组</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" 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={groupSearch} onChange={(e) => setGroupSearch(e.target.value)} placeholder="搜索群组或模型模式" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredGroups.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无匹配群组</div>
) : filteredGroups.map((route) => {
const checked = normalizedSelectedGroupRouteIds.includes(route.id);
return (
<label key={route.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onChange((prev) => ({
...prev,
selectedGroupRouteIds: checked
? prev.selectedGroupRouteIds.filter((item) => item !== route.id)
: uniqIds([...prev.selectedGroupRouteIds.filter((item) => validGroupRouteIdSet.has(item)), route.id]),
}))}
style={{ marginTop: 2 }}
/>
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--color-text-primary)', fontSize: 13, fontWeight: 600 }}>
{routeTitle(route)}
{!route.enabled ? <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--color-danger)' }}>已禁用</span> : null}
</div>
<code style={{ display: 'block', marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
</div>
</label>
);
})}
</div>
</div>
</div>
</div>
) : null}
</div>
<datalist id="downstream-group-suggestions">
{groupSuggestions.map((group) => <option key={group} value={group} />)}
</datalist>
</CenteredModal>
);
}
export default function DownstreamKeys() {
const toast = useToast();
const [range, setRange] = useState<Range>('24h');
@@ -852,8 +471,12 @@ export default function DownstreamKeys() {
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
const [editorOpen, setEditorOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editorForm, setEditorForm] = useState<EditorForm>(() => buildEditorForm());
const [editorForm, setEditorForm] = useState<DownstreamKeyEditorForm>(() => buildEditorForm());
const [createDefaultsPending, setCreateDefaultsPending] = useState(false);
const [exclusionSourceLoading, setExclusionSourceLoading] = useState(false);
const [exclusionSourceLoaded, setExclusionSourceLoaded] = useState(false);
const [exclusionSiteOptions, setExclusionSiteOptions] = useState<DownstreamSiteOption[]>([]);
const [exclusionCredentialOptions, setExclusionCredentialOptions] = useState<DownstreamCredentialOption[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -891,10 +514,94 @@ export default function DownstreamKeys() {
}
};
const loadExclusionSources = async () => {
if (exclusionSourceLoading || exclusionSourceLoaded) return;
setExclusionSourceLoading(true);
try {
const [accountsRes, tokensRes] = await Promise.all([
api.getAccounts(),
api.getAccountTokens(),
]);
const accountRows = Array.isArray(accountsRes) ? accountsRes : [];
const tokenRows = Array.isArray(tokensRes) ? tokensRes : [];
const siteMap = new Map<number, { siteId: number; siteName: string; accountIds: Set<number> }>();
for (const account of accountRows) {
const siteId = Number(account?.site?.id);
const accountId = Number(account?.id);
if (!Number.isFinite(siteId) || siteId <= 0 || !Number.isFinite(accountId) || accountId <= 0) continue;
const siteName = String(account?.site?.name || `站点 ${siteId}`).trim() || `站点 ${siteId}`;
if (!siteMap.has(siteId)) {
siteMap.set(siteId, { siteId, siteName, accountIds: new Set<number>() });
}
siteMap.get(siteId)!.accountIds.add(accountId);
}
const siteOptions = Array.from(siteMap.values())
.map((item) => ({
siteId: item.siteId,
siteName: item.siteName,
accountCount: item.accountIds.size,
}))
.sort((left, right) => left.siteName.localeCompare(right.siteName));
const credentialOptions: DownstreamCredentialOption[] = [];
for (const account of accountRows) {
const siteId = Number(account?.site?.id);
const accountId = Number(account?.id);
const apiToken = String(account?.apiToken || '').trim();
if (!Number.isFinite(siteId) || siteId <= 0 || !Number.isFinite(accountId) || accountId <= 0 || !apiToken) continue;
credentialOptions.push({
key: `default_api_key:${siteId}:${accountId}`,
ref: { kind: 'default_api_key', siteId: Math.trunc(siteId), accountId: Math.trunc(accountId) },
siteName: String(account?.site?.name || `站点 ${siteId}`).trim() || `站点 ${siteId}`,
accountName: String(account?.username || `账号 ${accountId}`).trim() || `账号 ${accountId}`,
label: '默认 API Key',
detail: `使用账号默认 API Key (${apiToken.slice(0, 6)}...)`,
});
}
for (const token of tokenRows) {
const siteId = Number(token?.site?.id);
const accountId = Number(token?.account?.id ?? token?.accountId);
const tokenId = Number(token?.id);
if (!Number.isFinite(siteId) || siteId <= 0 || !Number.isFinite(accountId) || accountId <= 0 || !Number.isFinite(tokenId) || tokenId <= 0) continue;
credentialOptions.push({
key: `account_token:${siteId}:${accountId}:${tokenId}`,
ref: { kind: 'account_token', siteId: Math.trunc(siteId), accountId: Math.trunc(accountId), tokenId: Math.trunc(tokenId) },
siteName: String(token?.site?.name || `站点 ${siteId}`).trim() || `站点 ${siteId}`,
accountName: String(token?.account?.username || `账号 ${accountId}`).trim() || `账号 ${accountId}`,
label: String(token?.name || `token-${tokenId}`).trim() || `token-${tokenId}`,
detail: String(token?.tokenGroup || 'default').trim() || 'default',
});
}
setExclusionSiteOptions(siteOptions);
setExclusionCredentialOptions(
credentialOptions.sort((left, right) => (
`${left.siteName}:${left.accountName}:${left.label}:${left.detail}`.localeCompare(
`${right.siteName}:${right.accountName}:${right.label}:${right.detail}`,
)
)),
);
setExclusionSourceLoaded(true);
} catch (err: any) {
toast.error(err?.message || '加载可排除站点与令牌失败');
} finally {
setExclusionSourceLoading(false);
}
};
useEffect(() => {
void load();
}, [range]);
useEffect(() => {
if (!editorOpen) return;
void loadExclusionSources();
}, [editorOpen]);
const rawItemMap = useMemo(() => new Map(rawItems.map((item) => [item.id, item])), [rawItems]);
const routeMap = useMemo(() => new Map(routeOptions.map((item) => [item.id, item])), [routeOptions]);
@@ -917,6 +624,8 @@ export default function DownstreamKeys() {
supportedModels: raw?.supportedModels ?? item.supportedModels,
allowedRouteIds: raw?.allowedRouteIds ?? item.allowedRouteIds,
siteWeightMultipliers: raw?.siteWeightMultipliers ?? item.siteWeightMultipliers,
excludedSiteIds: raw?.excludedSiteIds ?? item.excludedSiteIds,
excludedCredentialRefs: raw?.excludedCredentialRefs ?? item.excludedCredentialRefs,
lastUsedAt: raw?.lastUsedAt ?? item.lastUsedAt,
};
})
@@ -1125,6 +834,8 @@ export default function DownstreamKeys() {
supportedModels: uniqStrings(editorForm.selectedModels),
allowedRouteIds: uniqIds(editorForm.selectedGroupRouteIds).filter((id) => routeMap.has(id) && isGroupRouteOption(routeMap.get(id)!)),
siteWeightMultipliers,
excludedSiteIds: normalizeExcludedSiteIds(editorForm.excludedSiteIds),
excludedCredentialRefs: normalizeExcludedCredentialRefs(editorForm.excludedCredentialRefs),
};
if (editingId) {
await api.updateDownstreamApiKey(editingId, payload);
@@ -1555,7 +1266,7 @@ export default function DownstreamKeys() {
)}
</div>
<EditorModal
<DownstreamKeyEditorModal
open={editorOpen}
editingItem={editingItem}
form={editorForm}
@@ -1566,6 +1277,9 @@ export default function DownstreamKeys() {
routeOptions={routeOptions}
groupSuggestions={groupSuggestions}
tagSuggestions={tagSuggestions}
exclusionSourceLoading={exclusionSourceLoading}
siteOptions={exclusionSiteOptions}
credentialOptions={exclusionCredentialOptions}
/>
<CenteredModal
+1 -343
View File
@@ -6,24 +6,17 @@ import ChangeKeyModal from '../components/ChangeKeyModal.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import ModernSelect from '../components/ModernSelect.js';
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js';
import FactoryResetModal from './settings/FactoryResetModal.js';
import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js';
import RouteSelectorModal from './settings/RouteSelectorModal.js';
import UpdateCenterSection from './settings/UpdateCenterSection.js';
import {
applyRoutingProfilePreset,
resolveRoutingProfilePreset,
type RoutingWeights,
} from './helpers/routingProfiles.js';
import { fuzzyMatch } from './helpers/fuzzySearch.js';
import { clearAuthSession } from '../authSession.js';
import { clearAppInstallationState } from '../appLocalState.js';
import { tr } from '../i18n.js';
import {
isExactModelPattern,
resolveRouteTitle,
} from './token-routes/utils.js';
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
const PROXY_TOKEN_PREFIX = 'sk-';
@@ -87,42 +80,6 @@ type SystemProxyTestState =
| { kind: 'error'; text: string }
| null;
type DownstreamApiKeyItem = {
id: number;
name: string;
key: string;
keyMasked: string;
description: string | null;
enabled: boolean;
expiresAt: string | null;
maxCost: number | null;
usedCost: number;
maxRequests: number | null;
usedRequests: number;
supportedModels: string[];
allowedRouteIds: number[];
lastUsedAt: string | null;
};
type DownstreamCreateForm = {
name: string;
key: string;
description: string;
maxCost: string;
maxRequests: string;
expiresAt: string;
selectedModels: string[];
selectedGroupRouteIds: number[];
};
type RouteSelectorItem = {
id: number;
modelPattern: string;
displayName?: string | null;
displayIcon?: string | null;
enabled: boolean;
};
type DatabaseMigrationSummary = {
dialect: DbDialect;
connection: string;
@@ -300,15 +257,6 @@ export default function Settings() {
const [migrationSummary, setMigrationSummary] = useState<DatabaseMigrationSummary | null>(null);
const [runtimeDatabaseState, setRuntimeDatabaseState] = useState<RuntimeDatabaseState | null>(null);
const [showChangeKey, setShowChangeKey] = useState(false);
const [downstreamKeys, setDownstreamKeys] = useState<DownstreamApiKeyItem[]>([]);
const [downstreamLoading, setDownstreamLoading] = useState(false);
const [downstreamSaving, setDownstreamSaving] = useState(false);
const [downstreamOps, setDownstreamOps] = useState<Record<number, boolean>>({});
const [editingDownstreamId, setEditingDownstreamId] = useState<number | null>(null);
const [downstreamModalOpen, setDownstreamModalOpen] = useState(false);
const downstreamModalPresence = useAnimatedVisibility(downstreamModalOpen, 220);
const [selectorOpen, setSelectorOpen] = useState(false);
const selectorModalPresence = useAnimatedVisibility(selectorOpen, 220);
const [modelAvailabilityProbeConfirmOpen, setModelAvailabilityProbeConfirmOpen] = useState(false);
const modelAvailabilityProbeConfirmPresence = useAnimatedVisibility(modelAvailabilityProbeConfirmOpen, 220);
const [modelAvailabilityProbeConfirmationInput, setModelAvailabilityProbeConfirmationInput] = useState('');
@@ -317,20 +265,6 @@ export default function Settings() {
const factoryResetPresence = useAnimatedVisibility(factoryResetOpen, 220);
const [factoryResetting, setFactoryResetting] = useState(false);
const [factoryResetSecondsLeft, setFactoryResetSecondsLeft] = useState(FACTORY_RESET_CONFIRM_SECONDS);
const [selectorLoading, setSelectorLoading] = useState(false);
const [selectorRoutes, setSelectorRoutes] = useState<RouteSelectorItem[]>([]);
const [selectorModelSearch, setSelectorModelSearch] = useState('');
const [selectorGroupSearch, setSelectorGroupSearch] = useState('');
const [downstreamCreate, setDownstreamCreate] = useState<DownstreamCreateForm>({
name: '',
key: '',
description: '',
maxCost: '',
maxRequests: '',
expiresAt: '',
selectedModels: [],
selectedGroupRouteIds: [],
});
const toast = useToast();
const activeRoutingProfile = useMemo(
@@ -338,35 +272,6 @@ export default function Settings() {
[runtime.routingWeights],
);
const exactModelOptions = useMemo(() => (
selectorRoutes
.filter((route) => isExactModelPattern(route.modelPattern))
.map((route) => route.modelPattern.trim())
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index)
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
), [selectorRoutes]);
const groupRouteOptions = useMemo(() => (
selectorRoutes
.filter((route) => !isExactModelPattern(route.modelPattern))
.sort((a, b) => resolveRouteTitle(a).localeCompare(resolveRouteTitle(b), undefined, { sensitivity: 'base' }))
), [selectorRoutes]);
const filteredExactModelOptions = useMemo(() => {
const query = selectorModelSearch.trim();
if (!query) return exactModelOptions;
return exactModelOptions.filter((modelName) => fuzzyMatch(modelName, query));
}, [exactModelOptions, selectorModelSearch]);
const filteredGroupRouteOptions = useMemo(() => {
const query = selectorGroupSearch.trim();
if (!query) return groupRouteOptions;
return groupRouteOptions.filter((route) => {
const matchText = `${resolveRouteTitle(route)} ${route.modelPattern}`;
return fuzzyMatch(matchText, query);
});
}, [groupRouteOptions, selectorGroupSearch]);
const generatedConnectionString = useMemo(() => (
buildShorthandConnectionString(migrationDialect, shorthandConnection)
), [migrationDialect, shorthandConnection]);
@@ -560,58 +465,12 @@ export default function Settings() {
? '已启用'
: '已关闭';
const toDateTimeLocal = (isoString: string | null | undefined): string => {
if (!isoString) return '';
const ts = Date.parse(isoString);
if (!Number.isFinite(ts)) return '';
const date = new Date(ts);
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mi = String(date.getMinutes()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
};
const loadDownstreamKeys = async () => {
setDownstreamLoading(true);
try {
const res = await api.getDownstreamApiKeys();
const items = Array.isArray(res?.items) ? res.items : [];
setDownstreamKeys(items);
} catch (err: any) {
toast.error(err?.message || '加载下游 API Key 失败');
} finally {
setDownstreamLoading(false);
}
};
const loadRouteSelectorRoutes = async () => {
setSelectorLoading(true);
try {
const rows = await api.getRoutesLite();
setSelectorRoutes((Array.isArray(rows) ? rows : []).map((row: any) => ({
id: row.id,
modelPattern: row.modelPattern,
displayName: row.displayName,
displayIcon: row.displayIcon,
enabled: !!row.enabled,
})));
} catch (err: any) {
toast.error(err?.message || '加载路由列表失败');
} finally {
setSelectorLoading(false);
}
};
const loadSettings = async () => {
setLoading(true);
try {
const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo] = await Promise.all([
const [authInfo, runtimeInfo, runtimeDatabaseInfo] = await Promise.all([
api.getAuthInfo(),
api.getRuntimeSettings(),
api.getDownstreamApiKeys(),
api.getRoutesLite(),
api.getRuntimeDatabaseConfig(),
]);
setMaskedToken(authInfo.masked || '****');
@@ -677,14 +536,6 @@ export default function Settings() {
? runtimeInfo.adminIpAllowlist.join('\n')
: '',
);
setDownstreamKeys(Array.isArray(downstreamInfo?.items) ? downstreamInfo.items : []);
setSelectorRoutes((Array.isArray(routeRows) ? routeRows : []).map((row: any) => ({
id: row.id,
modelPattern: row.modelPattern,
displayName: row.displayName,
displayIcon: row.displayIcon,
enabled: !!row.enabled,
})));
if (runtimeDatabaseInfo?.active?.dialect) {
const preferredDialect = (runtimeDatabaseInfo?.saved?.dialect || runtimeDatabaseInfo.active.dialect) as DbDialect;
setMigrationDialect(preferredDialect);
@@ -932,163 +783,6 @@ export default function Settings() {
}
};
const resetDownstreamForm = () => {
setEditingDownstreamId(null);
setDownstreamCreate({
name: '',
key: '',
description: '',
maxCost: '',
maxRequests: '',
expiresAt: '',
selectedModels: [],
selectedGroupRouteIds: [],
});
};
const openCreateDownstreamModal = () => {
resetDownstreamForm();
setDownstreamModalOpen(true);
};
const closeDownstreamModal = () => {
setDownstreamModalOpen(false);
resetDownstreamForm();
};
const closeSelectorModal = () => {
setSelectorOpen(false);
setSelectorModelSearch('');
setSelectorGroupSearch('');
};
const beginEditDownstream = (item: DownstreamApiKeyItem) => {
setEditingDownstreamId(item.id);
setDownstreamCreate({
name: item.name || '',
key: item.key || '',
description: item.description || '',
maxCost: item.maxCost === null || item.maxCost === undefined ? '' : String(item.maxCost),
maxRequests: item.maxRequests === null || item.maxRequests === undefined ? '' : String(item.maxRequests),
expiresAt: toDateTimeLocal(item.expiresAt),
selectedModels: Array.isArray(item.supportedModels)
? [...new Set(item.supportedModels.map((model) => String(model).trim()).filter((model) => model.length > 0))]
: [],
selectedGroupRouteIds: Array.isArray(item.allowedRouteIds)
? [...new Set(item.allowedRouteIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0).map((id) => Math.trunc(id)))]
: [],
});
setDownstreamModalOpen(true);
};
const saveDownstreamKey = async () => {
const name = downstreamCreate.name.trim();
const rawKey = downstreamCreate.key.trim();
if (!name) {
toast.info('Please enter a name');
return;
}
if (!rawKey) {
toast.info('请填写 API Key');
return;
}
if (!rawKey.startsWith(PROXY_TOKEN_PREFIX)) {
toast.info('API Key must start with sk-');
return;
}
setDownstreamSaving(true);
try {
const payload = {
name,
key: rawKey,
description: downstreamCreate.description.trim(),
expiresAt: downstreamCreate.expiresAt ? new Date(downstreamCreate.expiresAt).toISOString() : null,
maxCost: downstreamCreate.maxCost.trim() ? Number(downstreamCreate.maxCost.trim()) : null,
maxRequests: downstreamCreate.maxRequests.trim() ? Number(downstreamCreate.maxRequests.trim()) : null,
supportedModels: downstreamCreate.selectedModels,
allowedRouteIds: downstreamCreate.selectedGroupRouteIds,
};
if (editingDownstreamId) {
await api.updateDownstreamApiKey(editingDownstreamId, payload);
toast.success('Downstream API Key updated');
} else {
await api.createDownstreamApiKey(payload);
toast.success('Downstream API Key created');
}
setDownstreamModalOpen(false);
resetDownstreamForm();
await loadDownstreamKeys();
} catch (err: any) {
toast.error(err?.message || '保存下游 API Key 失败');
} finally {
setDownstreamSaving(false);
}
};
const toggleModelSelection = (modelName: string) => {
setDownstreamCreate((prev) => {
const exists = prev.selectedModels.includes(modelName);
return {
...prev,
selectedModels: exists
? prev.selectedModels.filter((item) => item !== modelName)
: [...prev.selectedModels, modelName],
};
});
};
const toggleGroupRouteSelection = (routeId: number) => {
setDownstreamCreate((prev) => {
const exists = prev.selectedGroupRouteIds.includes(routeId);
return {
...prev,
selectedGroupRouteIds: exists
? prev.selectedGroupRouteIds.filter((item) => item !== routeId)
: [...prev.selectedGroupRouteIds, routeId],
};
});
};
const runDownstreamOp = async (id: number, action: () => Promise<void>) => {
setDownstreamOps((prev) => ({ ...prev, [id]: true }));
try {
await action();
} finally {
setDownstreamOps((prev) => ({ ...prev, [id]: false }));
}
};
const toggleDownstreamEnabled = async (item: DownstreamApiKeyItem) => {
await runDownstreamOp(item.id, async () => {
await api.updateDownstreamApiKey(item.id, { enabled: !item.enabled });
await loadDownstreamKeys();
toast.success(item.enabled ? 'Disabled' : 'Enabled');
});
};
const resetDownstreamUsage = async (item: DownstreamApiKeyItem) => {
await runDownstreamOp(item.id, async () => {
await api.resetDownstreamApiKeyUsage(item.id);
await loadDownstreamKeys();
toast.success('Usage reset');
});
};
const deleteDownstreamKey = async (item: DownstreamApiKeyItem) => {
if (!window.confirm('Confirm delete API Key?')) return;
await runDownstreamOp(item.id, async () => {
await api.deleteDownstreamApiKey(item.id);
if (editingDownstreamId === item.id) {
setDownstreamModalOpen(false);
resetDownstreamForm();
}
await loadDownstreamKeys();
toast.success('Deleted');
});
};
const saveRouting = async () => {
setSavingRouting(true);
try {
@@ -2419,22 +2113,6 @@ export default function Settings() {
</div>
</div>
</div>
<DownstreamApiKeyModal
presence={downstreamModalPresence}
editingDownstreamId={editingDownstreamId}
downstreamCreate={downstreamCreate}
downstreamSaving={downstreamSaving}
inputStyle={inputStyle}
onChange={(updater) => setDownstreamCreate((prev) => updater(prev))}
onOpenSelector={async () => {
if (selectorRoutes.length === 0) await loadRouteSelectorRoutes();
setSelectorModelSearch('');
setSelectorGroupSearch('');
setSelectorOpen(true);
}}
onClose={closeDownstreamModal}
onSave={saveDownstreamKey}
/>
<FactoryResetModal
presence={factoryResetPresence}
factoryResetting={factoryResetting}
@@ -2452,26 +2130,6 @@ export default function Settings() {
onClose={closeModelAvailabilityProbeConfirmModal}
onConfirm={handleConfirmModelAvailabilityProbe}
/>
<RouteSelectorModal
presence={selectorModalPresence}
loading={selectorLoading}
exactModelOptions={exactModelOptions}
filteredExactModelOptions={filteredExactModelOptions}
groupRouteOptions={groupRouteOptions}
filteredGroupRouteOptions={filteredGroupRouteOptions}
selectorModelSearch={selectorModelSearch}
selectorGroupSearch={selectorGroupSearch}
onSelectorModelSearchChange={setSelectorModelSearch}
onSelectorGroupSearchChange={setSelectorGroupSearch}
selection={{
selectedModels: downstreamCreate.selectedModels,
selectedGroupRouteIds: downstreamCreate.selectedGroupRouteIds,
}}
onToggleModelSelection={toggleModelSelection}
onToggleGroupRouteSelection={toggleGroupRouteSelection}
onClose={closeSelectorModal}
inputStyle={inputStyle}
/>
</div>
);
}
@@ -0,0 +1,658 @@
import React, { useEffect, useMemo, useState } from 'react';
import CenteredModal from '../../components/CenteredModal.js';
import { generateDownstreamSkKey } from '../helpers/generateDownstreamSkKey.js';
const PROXY_TOKEN_PREFIX = 'sk-';
export type DownstreamExcludedCredentialRef =
| {
kind: 'account_token';
siteId: number;
accountId: number;
tokenId: number;
}
| {
kind: 'default_api_key';
siteId: number;
accountId: number;
};
export type DownstreamKeyEditorForm = {
name: string;
key: string;
description: string;
groupName: string;
tags: string[];
maxCost: string;
maxRequests: string;
expiresAt: string;
enabled: boolean;
selectedModels: string[];
selectedGroupRouteIds: number[];
siteWeightMultipliersText: string;
excludedSiteIds: number[];
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
};
export type DownstreamSiteOption = {
siteId: number;
siteName: string;
accountCount: number;
};
export type DownstreamCredentialOption = {
key: string;
ref: DownstreamExcludedCredentialRef;
siteName: string;
accountName: string;
label: string;
detail: string;
};
type RouteSelectorItem = {
id: number;
modelPattern: string;
displayName?: string | null;
enabled: boolean;
};
function parseTagText(value: string): string[] {
return value
.split(/[\r\n,,]+/g)
.map((item) => item.trim())
.filter(Boolean);
}
function normalizeTags(values: string[]): string[] {
const result: string[] = [];
const seen = new Set<string>();
for (const raw of values) {
const value = String(raw || '').trim();
if (!value) continue;
const normalized = value.slice(0, 32);
const dedupeKey = normalized.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
result.push(normalized);
if (result.length >= 20) break;
}
return result;
}
function uniqStrings(values: string[]): string[] {
return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))];
}
function uniqIds(values: number[]): number[] {
return [...new Set(values.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0).map((value) => Math.trunc(value)))];
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?]/.test(normalized);
}
function isGroupRouteOption(route: RouteSelectorItem): boolean {
return !isExactModelPattern(route.modelPattern);
}
function routeTitle(route: RouteSelectorItem): string {
const displayName = (route.displayName || '').trim();
return displayName || route.modelPattern;
}
function tagChipStyle(kind: 'normal' | 'accent' = 'normal'): React.CSSProperties {
return {
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
border: '1px solid var(--color-border-light)',
color: kind === 'accent' ? 'var(--color-primary)' : 'var(--color-text-secondary)',
background: kind === 'accent'
? 'color-mix(in srgb, var(--color-primary) 10%, transparent)'
: 'var(--color-bg-card)',
};
}
function buildExcludedCredentialRefKey(ref: DownstreamExcludedCredentialRef): string {
return ref.kind === 'account_token'
? `${ref.kind}:${ref.siteId}:${ref.accountId}:${ref.tokenId}`
: `${ref.kind}:${ref.siteId}:${ref.accountId}`;
}
function normalizeExcludedSiteIds(values: number[]): number[] {
return uniqIds(values).sort((left, right) => left - right);
}
function normalizeExcludedCredentialRefs(values: DownstreamExcludedCredentialRef[]): DownstreamExcludedCredentialRef[] {
const deduped = new Map<string, DownstreamExcludedCredentialRef>();
for (const value of values) {
if (!value || !Number.isFinite(value.siteId) || !Number.isFinite(value.accountId)) continue;
if (value.kind === 'account_token') {
if (!Number.isFinite(value.tokenId)) continue;
const normalized: DownstreamExcludedCredentialRef = {
kind: 'account_token',
siteId: Math.trunc(value.siteId),
accountId: Math.trunc(value.accountId),
tokenId: Math.trunc(value.tokenId),
};
deduped.set(buildExcludedCredentialRefKey(normalized), normalized);
continue;
}
const normalized: DownstreamExcludedCredentialRef = {
kind: 'default_api_key',
siteId: Math.trunc(value.siteId),
accountId: Math.trunc(value.accountId),
};
deduped.set(buildExcludedCredentialRefKey(normalized), normalized);
}
return Array.from(deduped.values()).sort((left, right) => buildExcludedCredentialRefKey(left).localeCompare(buildExcludedCredentialRefKey(right)));
}
export function TagInput({
tags,
onChange,
suggestions = [],
placeholder,
}: {
tags: string[];
onChange: (tags: string[]) => void;
suggestions?: string[];
placeholder?: string;
}) {
const [draft, setDraft] = useState('');
useEffect(() => {
setDraft('');
}, [tags.length]);
const commitDraft = () => {
const nextTags = normalizeTags([...tags, ...parseTagText(draft)]);
if (nextTags.length !== tags.length) {
onChange(nextTags);
}
setDraft('');
};
const removeTag = (target: string) => {
onChange(tags.filter((tag) => tag !== target));
};
const suggestionPool = suggestions.filter((tag) => !tags.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 12);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)', padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{tags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => removeTag(tag)}
style={{ ...tagChipStyle('accent'), cursor: 'pointer' }}
title={`移除 ${tag}`}
>
<span>{tag}</span>
<span aria-hidden="true">×</span>
</button>
))}
</div>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitDraft();
} else if (e.key === 'Backspace' && !draft && tags.length > 0) {
e.preventDefault();
onChange(tags.slice(0, -1));
}
}}
placeholder={placeholder || '输入标签后按回车或逗号'}
style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', color: 'var(--color-text-primary)', padding: 0, fontSize: 13, lineHeight: 1.45 }}
/>
</div>
{suggestionPool.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{suggestionPool.map((tag) => (
<button
key={tag}
type="button"
className="btn btn-ghost"
style={{ ...tagChipStyle(), cursor: 'pointer' }}
onClick={() => onChange(normalizeTags([...tags, tag]))}
>
{tag}
</button>
))}
</div>
) : null}
</div>
);
}
export default function DownstreamKeyEditorModal({
open,
editingItem,
form,
onChange,
onClose,
onSave,
saving,
routeOptions,
groupSuggestions,
tagSuggestions,
exclusionSourceLoading,
siteOptions,
credentialOptions,
}: {
open: boolean;
editingItem: { id: number } | null;
form: DownstreamKeyEditorForm;
onChange: (updater: (prev: DownstreamKeyEditorForm) => DownstreamKeyEditorForm) => void;
onClose: () => void;
onSave: () => void;
saving: boolean;
routeOptions: RouteSelectorItem[];
groupSuggestions: string[];
tagSuggestions: string[];
exclusionSourceLoading: boolean;
siteOptions: DownstreamSiteOption[];
credentialOptions: DownstreamCredentialOption[];
}) {
const [modelSearch, setModelSearch] = useState('');
const [groupSearch, setGroupSearch] = useState('');
const [siteSearch, setSiteSearch] = useState('');
const [credentialSearch, setCredentialSearch] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
if (!open) {
setModelSearch('');
setGroupSearch('');
setSiteSearch('');
setCredentialSearch('');
setAdvancedOpen(false);
}
}, [open]);
const exactModels = useMemo(
() => uniqStrings(routeOptions.filter((item) => isExactModelPattern(item.modelPattern)).map((item) => item.modelPattern)).sort((a, b) => a.localeCompare(b)),
[routeOptions],
);
const groupRouteOptions = useMemo(
() => routeOptions.filter(isGroupRouteOption),
[routeOptions],
);
const validGroupRouteIdSet = useMemo(
() => new Set(groupRouteOptions.map((route) => route.id)),
[groupRouteOptions],
);
const normalizedSelectedGroupRouteIds = useMemo(
() => uniqIds(form.selectedGroupRouteIds.filter((id) => validGroupRouteIdSet.has(id))),
[form.selectedGroupRouteIds, validGroupRouteIdSet],
);
const filteredModels = useMemo(() => {
const keyword = modelSearch.trim().toLowerCase();
if (!keyword) return exactModels;
return exactModels.filter((model) => model.toLowerCase().includes(keyword));
}, [exactModels, modelSearch]);
const filteredGroups = useMemo(() => {
const keyword = groupSearch.trim().toLowerCase();
if (!keyword) return groupRouteOptions;
return groupRouteOptions.filter((route) => {
const title = routeTitle(route).toLowerCase();
return title.includes(keyword) || route.modelPattern.toLowerCase().includes(keyword);
});
}, [groupRouteOptions, groupSearch]);
const filteredSites = useMemo(() => {
const keyword = siteSearch.trim().toLowerCase();
if (!keyword) return siteOptions;
return siteOptions.filter((site) => site.siteName.toLowerCase().includes(keyword));
}, [siteOptions, siteSearch]);
const filteredCredentials = useMemo(() => {
const keyword = credentialSearch.trim().toLowerCase();
if (!keyword) return credentialOptions;
return credentialOptions.filter((item) => (
item.siteName.toLowerCase().includes(keyword)
|| item.accountName.toLowerCase().includes(keyword)
|| item.label.toLowerCase().includes(keyword)
|| item.detail.toLowerCase().includes(keyword)
));
}, [credentialOptions, credentialSearch]);
const selectedModelCount = form.selectedModels.length;
const selectedGroupCount = normalizedSelectedGroupRouteIds.length;
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 13,
lineHeight: 1.45,
};
return (
<CenteredModal
open={open}
onClose={onClose}
title={editingItem ? '编辑下游密钥' : '新增下游密钥'}
maxWidth={860}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost" disabled={saving}>取消</button>
<button onClick={onSave} className="btn btn-primary" disabled={saving}>
{saving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</>
: (editingItem ? '保存修改' : '创建密钥')}
</button>
</>
)}
>
<div className="info-tip" style={{ marginBottom: 0 }}>
支持为每个下游密钥独立配置分组、标签、额度与有效期。高级限制项可按需展开。
</div>
<div className="downstream-key-modal-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">名称</div>
<input value={form.name} onChange={(e) => onChange((prev) => ({ ...prev, name: e.target.value }))} placeholder="例如:项目 A / 移动端" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">下游密钥</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', minWidth: 0 }}>
<input
value={form.key}
onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value }))}
placeholder="sk-..."
style={{ ...inputStyle, flex: 1, minWidth: 0, fontFamily: 'var(--font-mono)' }}
/>
<button
type="button"
className="btn btn-ghost"
style={{ flexShrink: 0, whiteSpace: 'nowrap', alignSelf: 'stretch' }}
onClick={() => onChange((prev) => ({ ...prev, key: generateDownstreamSkKey(PROXY_TOKEN_PREFIX) }))}
>
随机
</button>
</div>
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">主分组</div>
<input
value={form.groupName}
onChange={(e) => onChange((prev) => ({ ...prev, groupName: e.target.value }))}
placeholder="例如:VIP / 内部项目 / A组"
list="downstream-group-suggestions"
style={inputStyle}
/>
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">请求额度</div>
<input value={form.maxRequests} onChange={(e) => onChange((prev) => ({ ...prev, maxRequests: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">成本额度</div>
<input value={form.maxCost} onChange={(e) => onChange((prev) => ({ ...prev, maxCost: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">过期时间</div>
<input type="datetime-local" value={form.expiresAt} onChange={(e) => onChange((prev) => ({ ...prev, expiresAt: e.target.value }))} style={inputStyle} />
</div>
<label className="downstream-key-modal-toggle">
<input type="checkbox" checked={form.enabled} onChange={(e) => onChange((prev) => ({ ...prev, enabled: e.target.checked }))} />
<div>
<div className="downstream-key-modal-toggle-title">创建后立即启用</div>
<div className="downstream-key-modal-help">关闭后该密钥将无法继续分发请求</div>
</div>
</label>
</div>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">备注说明</div>
<textarea
value={form.description}
onChange={(e) => onChange((prev) => ({ ...prev, description: e.target.value }))}
placeholder="填写业务场景、负责人或限制说明"
style={{ ...inputStyle, minHeight: 84, resize: 'vertical' }}
/>
</div>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">标签</div>
<TagInput
tags={form.tags}
onChange={(tags) => onChange((prev) => ({ ...prev, tags }))}
suggestions={tagSuggestions}
placeholder="输入标签后按回车或逗号,例如:移动端、VIP、项目A"
/>
<div className="downstream-key-modal-help">标签用于搜索、筛选和辅助归类,不影响路由与权限。</div>
</div>
<div className="downstream-key-advanced">
<button type="button" className={`downstream-key-advanced-toggle ${advancedOpen ? 'is-open' : ''}`.trim()} onClick={() => setAdvancedOpen((value) => !value)}>
<span>高级配置</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{advancedOpen ? '收起' : '展开'}</span>
</button>
{advancedOpen ? (
<div className="downstream-key-advanced-content">
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">站点倍率 JSON</div>
<textarea
value={form.siteWeightMultipliersText}
onChange={(e) => onChange((prev) => ({ ...prev, siteWeightMultipliersText: e.target.value }))}
placeholder={'例如:{\n "1": 1.2,\n "7": 0.8\n}'}
style={{ ...inputStyle, minHeight: 96, resize: 'vertical', fontFamily: 'var(--font-mono)' }}
/>
<div className="downstream-key-modal-help">用于对特定站点做分发倍率微调;留空或 `{}` 表示走默认倍率。</div>
</div>
<div className="downstream-key-advanced-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">模型白名单</div>
<div className="downstream-key-modal-help">只展示精确模型;未勾选时默认不允许任何精确模型,可点“全选”一次性放开。</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedModels: exactModels }))}>全选</button>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}>清空</button>
</div>
</div>
<div className="downstream-key-modal-meta">已选 {selectedModelCount} 个模型</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" 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={modelSearch} onChange={(e) => setModelSearch(e.target.value)} placeholder="搜索模型" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredModels.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无匹配模型</div>
) : filteredModels.map((model) => {
const checked = form.selectedModels.includes(model);
return (
<label key={model} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onChange((prev) => ({
...prev,
selectedModels: checked ? prev.selectedModels.filter((item) => item !== model) : [...prev.selectedModels, model],
}))}
/>
<code style={{ color: 'var(--color-text-primary)', fontSize: 12 }}>{model}</code>
</label>
);
})}
</div>
</div>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">群组范围</div>
<div className="downstream-key-modal-help">限制可访问的群组路由;未勾选时默认不允许任何群组,可点“全选”一次性放开。</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: groupRouteOptions.map((route) => route.id) }))}>全选</button>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}>清空</button>
</div>
</div>
<div className="downstream-key-modal-meta">已选 {selectedGroupCount} 个群组</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" 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={groupSearch} onChange={(e) => setGroupSearch(e.target.value)} placeholder="搜索群组或模型模式" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredGroups.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无匹配群组</div>
) : filteredGroups.map((route) => {
const checked = normalizedSelectedGroupRouteIds.includes(route.id);
return (
<label key={route.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onChange((prev) => ({
...prev,
selectedGroupRouteIds: checked
? prev.selectedGroupRouteIds.filter((item) => item !== route.id)
: uniqIds([...prev.selectedGroupRouteIds.filter((item) => validGroupRouteIdSet.has(item)), route.id]),
}))}
style={{ marginTop: 2 }}
/>
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--color-text-primary)', fontSize: 13, fontWeight: 600 }}>
{routeTitle(route)}
{!route.enabled ? <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--color-danger)' }}>已禁用</span> : null}
</div>
<code style={{ display: 'block', marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
</div>
</label>
);
})}
</div>
</div>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">排除站点</div>
<div className="downstream-key-modal-help">命中的站点会直接跳过,不参与当前下游密钥的通道路由。</div>
</div>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, excludedSiteIds: [] }))}>清空</button>
</div>
<div className="downstream-key-modal-meta">已排除 {form.excludedSiteIds.length} 个站点</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" 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={siteSearch} onChange={(e) => setSiteSearch(e.target.value)} placeholder="搜索站点" />
</div>
<div style={{ maxHeight: 240, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{exclusionSourceLoading ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载站点与令牌中...</div>
) : filteredSites.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可排除站点</div>
) : filteredSites.map((site) => {
const checked = form.excludedSiteIds.includes(site.siteId);
return (
<label key={site.siteId} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange((prev) => ({
...prev,
excludedSiteIds: normalizeExcludedSiteIds(
e.target.checked
? [...prev.excludedSiteIds, site.siteId]
: prev.excludedSiteIds.filter((item) => item !== site.siteId),
),
}))}
/>
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--color-text-primary)', fontSize: 13, fontWeight: 600 }}>{site.siteName}</div>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)' }}>{site.accountCount} 个账号</div>
</div>
</label>
);
})}
</div>
</div>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">排除 API Key/令牌</div>
<div className="downstream-key-modal-help">支持排除显式令牌,以及 `tokenId` 为空时实际使用的默认 API Key。</div>
</div>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, excludedCredentialRefs: [] }))}>清空</button>
</div>
<div className="downstream-key-modal-meta">已排除 {form.excludedCredentialRefs.length} 个凭证</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" 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={credentialSearch} onChange={(e) => setCredentialSearch(e.target.value)} placeholder="搜索站点 / 账号 / 令牌" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{exclusionSourceLoading ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载站点与令牌中...</div>
) : filteredCredentials.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可排除 API Key/令牌</div>
) : filteredCredentials.map((item) => {
const checked = form.excludedCredentialRefs.some((ref) => buildExcludedCredentialRefKey(ref) === buildExcludedCredentialRefKey(item.ref));
return (
<label key={item.key} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange((prev) => ({
...prev,
excludedCredentialRefs: normalizeExcludedCredentialRefs(
e.target.checked
? [...prev.excludedCredentialRefs, item.ref]
: prev.excludedCredentialRefs.filter((ref) => buildExcludedCredentialRefKey(ref) !== buildExcludedCredentialRefKey(item.ref)),
),
}))}
style={{ marginTop: 2 }}
/>
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--color-text-primary)', fontSize: 13, fontWeight: 600 }}>{item.label}</div>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)' }}>
{item.siteName} / {item.accountName}
</div>
<div style={{ marginTop: 2, fontSize: 11, color: 'var(--color-text-muted)' }}>{item.detail}</div>
</div>
</label>
);
})}
</div>
</div>
</div>
</div>
) : null}
</div>
<datalist id="downstream-group-suggestions">
{groupSuggestions.map((group) => <option key={group} value={group} />)}
</datalist>
</CenteredModal>
);
}
+5
View File
@@ -18,6 +18,11 @@ export type SummaryItem = {
supportedModels: string[];
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
excludedSiteIds: number[];
excludedCredentialRefs: Array<
| { kind: 'account_token'; siteId: number; accountId: number; tokenId: number }
| { kind: 'default_api_key'; siteId: number; accountId: number }
>;
lastUsedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
@@ -3,10 +3,11 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Settings downstream modal extraction', () => {
it('delegates the downstream API key portal to a dedicated settings component', () => {
it('no longer keeps a dedicated downstream API key editor inside Settings', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Settings.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain("import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js'");
expect(source).not.toContain('{downstreamModalPresence.shouldRender && (() => {');
expect(source).not.toContain("import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js'");
expect(source).not.toContain('downstreamModalOpen');
expect(source).not.toContain('downstreamCreate');
});
});
@@ -3,10 +3,11 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Settings route selector modal extraction', () => {
it('delegates the downstream route selector portal to a dedicated settings component', () => {
it('no longer keeps a downstream route selector inside Settings', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Settings.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain("import RouteSelectorModal from './settings/RouteSelectorModal.js'");
expect(source).not.toContain('{selectorModalPresence.shouldRender && (() => {');
expect(source).not.toContain("import RouteSelectorModal from './settings/RouteSelectorModal.js'");
expect(source).not.toContain('selectorOpen');
expect(source).not.toContain('selectorRoutes');
});
});