[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:
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user