Refine DownstreamKeys management page

This commit is contained in:
ciat-777
2026-03-16 14:41:29 +08:00
parent 7e7add1343
commit bfb7ea180f
4 changed files with 425 additions and 286 deletions
+144 -1
View File
@@ -1891,6 +1891,149 @@ body {
outline: none;
}
.downstream-key-modal-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.downstream-key-modal-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.downstream-key-modal-label {
font-size: 12px;
line-height: 1.4;
font-weight: 500;
color: var(--color-text-muted);
}
.downstream-key-modal-field-full {
width: 100%;
}
.downstream-key-modal-toggle {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 10px 12px;
background: var(--color-bg);
cursor: pointer;
min-height: 42px;
}
.downstream-key-modal-toggle-title {
font-size: 13px;
line-height: 1.35;
font-weight: 600;
color: var(--color-text-primary);
}
.downstream-key-modal-help {
font-size: 12px;
line-height: 1.55;
color: var(--color-text-muted);
}
.downstream-key-modal-meta {
font-size: 12px;
line-height: 1.45;
color: var(--color-text-secondary);
}
.downstream-key-modal-section-title {
font-size: 13px;
line-height: 1.35;
font-weight: 600;
color: var(--color-text-primary);
}
.downstream-key-advanced {
display: flex;
flex-direction: column;
gap: 10px;
}
.downstream-key-advanced-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text-primary);
cursor: pointer;
font-size: 13px;
font-weight: 600;
line-height: 1.35;
}
.downstream-key-advanced-toggle:hover {
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-bg));
}
.downstream-key-advanced-content {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-bg) 60%, var(--color-bg-card));
}
.downstream-key-advanced-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.downstream-key-advanced-panel {
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--color-bg-card);
}
.downstream-key-modal-grid input,
.downstream-key-modal-grid textarea,
.downstream-key-advanced input,
.downstream-key-advanced textarea {
font-size: 13px;
}
.downstream-key-modal-grid input::placeholder,
.downstream-key-modal-grid textarea::placeholder,
.downstream-key-advanced input::placeholder,
.downstream-key-advanced textarea::placeholder {
font-size: 13px;
color: color-mix(in srgb, var(--color-text-muted) 92%, white);
}
.downstream-key-modal-grid textarea,
.downstream-key-advanced textarea {
line-height: 1.5;
}
@media (max-width: 900px) {
.downstream-key-modal-grid,
.downstream-key-advanced-grid {
grid-template-columns: 1fr;
}
}
.route-enable-toggle {
border: 1px solid transparent;
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
@@ -4724,4 +4867,4 @@ textarea {
.route-card-grid {
grid-template-columns: 1fr;
}
}
}
+59 -1
View File
@@ -132,7 +132,7 @@ beforeEach(() => {
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ success: true, items: [buildSummaryItem()] });
apiMock.getDownstreamApiKeys.mockResolvedValue({ success: true, items: [buildRawItem()] });
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'team/default', displayName: '默认群组', enabled: true },
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
]);
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
@@ -184,6 +184,8 @@ describe('DownstreamKeys page', () => {
const text = collectText(root!.root);
expect(text).toContain('下游密钥');
expect(text).toContain('范围概览');
expect(text).toContain('筛选与列表');
expect(text).toContain('smoke-key');
expect(text).toContain('sk-s****0315');
expect(text).toContain('默认群组');
@@ -264,9 +266,14 @@ describe('DownstreamKeys page', () => {
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('高级配置');
expect(collectText(root!.root)).not.toContain('站点倍率 JSON');
const inputs = root!.root.findAllByType('input');
const tagInput = inputs.find((node) => node.props.placeholder === '输入标签后按回车或逗号,例如:移动端、VIP、项目A');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
expect(tagInput?.props.style?.fontSize).toBe(13);
await act(async () => {
nameInput!.props.onChange({ target: { value: 'new-key' } });
keyInput!.props.onChange({ target: { value: 'sk-new-key-0315' } });
@@ -310,6 +317,50 @@ describe('DownstreamKeys page', () => {
}
});
it('separates exact models from group routes in advanced config and uses single-column layout', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const panels = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-panel');
const modelPanel = panels.find((node) => collectText(node).includes('模型白名单'));
const groupPanel = panels.find((node) => collectText(node).includes('群组范围'));
const advancedGrid = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-grid')[0];
expect(modelPanel).toBeTruthy();
expect(groupPanel).toBeTruthy();
expect(collectText(modelPanel!)).toContain('gpt-4.1-mini');
expect(collectText(modelPanel!)).not.toContain('默认群组');
expect(collectText(modelPanel!)).not.toContain('claude-*');
expect(collectText(groupPanel!)).toContain('默认群组');
expect(collectText(groupPanel!)).not.toContain('GPT 4.1 Mini');
expect(advancedGrid.props.style?.gridTemplateColumns).toBe('1fr');
} finally {
root?.unmount();
}
});
it('uses backend batch api for selected rows', async () => {
let root: ReturnType<typeof create> | null = null;
try {
@@ -329,6 +380,7 @@ describe('DownstreamKeys page', () => {
checkbox!.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('已选 1 个密钥');
const batchButton = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量启用'))[0];
await act(async () => {
@@ -365,6 +417,12 @@ describe('DownstreamKeys page', () => {
});
await flushMicrotasks();
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
+222 -217
View File
@@ -170,6 +170,10 @@ function routeTitle(route: RouteSelectorItem): string {
return displayName || route.modelPattern;
}
function isGroupRouteOption(route: RouteSelectorItem): boolean {
return !isExactModelPattern(route.modelPattern);
}
function uniqStrings(values: string[]): string[] {
return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))];
}
@@ -312,7 +316,7 @@ function TagChips({
maxVisible?: number;
}) {
if (!Array.isArray(tags) || tags.length === 0) {
return <span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>无标签</span>;
return <span className="badge badge-muted" style={{ fontSize: 11 }}>无标签</span>;
}
const visible = tags.slice(0, maxVisible);
@@ -320,11 +324,15 @@ function TagChips({
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{visible.map((tag) => (
<span key={tag} style={tagChipStyle(accent ? 'accent' : 'normal')}>
<span
key={tag}
className={`badge ${accent ? 'badge-info' : 'badge-muted'}`}
style={{ fontSize: 11 }}
>
{tag}
</span>
))}
{hidden > 0 ? <span style={tagChipStyle()}>{`+${hidden}`}</span> : null}
{hidden > 0 ? <span className="badge badge-muted" style={{ fontSize: 11 }}>{`+${hidden}`}</span> : null}
</div>
);
}
@@ -391,7 +399,7 @@ function TagInput({
}
}}
placeholder={placeholder || '输入标签后按回车或逗号'}
style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', color: 'var(--color-text-primary)', padding: 0 }}
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 ? (
@@ -421,9 +429,9 @@ function SummaryMetric({
value: string;
}) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minHeight: 28 }}>
<div style={{ minWidth: 112, display: 'grid', gap: 4 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{label}</span>
<strong style={{ fontSize: 13, color: 'var(--color-text-primary)', fontWeight: 600 }}>{value}</strong>
<strong style={{ fontSize: 14, color: 'var(--color-text-primary)', fontWeight: 700 }}>{value}</strong>
</div>
);
}
@@ -440,15 +448,7 @@ function resolveOverviewUsageByRange(
function TrendChartFallback({ height = 260 }: { height?: number }) {
return (
<div
style={{
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
boxShadow: 'var(--shadow-card)',
padding: 16,
}}
>
<div className="card" style={{ padding: 16 }}>
<div className="skeleton" style={{ width: 140, height: 28, borderRadius: 'var(--radius-sm)', marginBottom: 10 }} />
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
</div>
@@ -542,29 +542,7 @@ function RangeToggle({ range, onChange }: { range: Range; onChange: (r: Range) =
function StatusBadge({ enabled }: { enabled: boolean }) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
border: '1px solid var(--color-border-light)',
color: enabled ? 'var(--color-success)' : 'var(--color-text-muted)',
background: enabled
? 'color-mix(in srgb, var(--color-success) 10%, transparent)'
: 'color-mix(in srgb, var(--color-text-muted) 10%, transparent)',
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 999,
background: enabled ? 'var(--color-success)' : 'var(--color-text-muted)',
}}
/>
<span className={`badge ${enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{enabled ? '启用' : '禁用'}
</span>
);
@@ -660,7 +638,7 @@ function Drawer({
animation: presence.isVisible ? 'drawer-slide-in 0.3s cubic-bezier(0.22, 1, 0.36, 1) both' : 'drawer-slide-out 0.22s cubic-bezier(0.4, 0, 1, 1) both',
}}
>
<div className="modal-header" style={{ paddingTop: 18, paddingBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div className="modal-header" style={{ paddingTop: 18, paddingBottom: 12, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span>{item?.name || '--'}</span>
@@ -670,7 +648,7 @@ function Drawer({
{item?.keyMasked || '****'}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 6 }}>
<span style={tagChipStyle(item?.groupName ? 'accent' : 'normal')}>
<span className={`badge ${item?.groupName ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{item?.groupName ? `主分组 · ${item.groupName}` : '未分组'}
</span>
<TagChips tags={item?.tags || []} accent maxVisible={4} />
@@ -682,22 +660,23 @@ function Drawer({
</div>
<div className="modal-body" style={{ paddingTop: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', fontWeight: 600, letterSpacing: '0.02em' }}>
趋势
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>使用趋势</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>按选定时间窗口查看请求、Tokens 与成本变化。</div>
</div>
<RangeToggle range={trendRange} onChange={setTrendRange} />
</div>
<RangeToggle range={trendRange} onChange={setTrendRange} />
<Suspense fallback={<TrendChartFallback height={260} />}>
<DownstreamKeyTrendChart buckets={buckets} loading={trendLoading} height={260} />
</Suspense>
</div>
<Suspense fallback={<TrendChartFallback height={260} />}>
<DownstreamKeyTrendChart buckets={buckets} loading={trendLoading} height={260} />
</Suspense>
<div style={{ height: 16 }} />
<div className="card" style={{ padding: 16 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 10 }}>
概览
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
基础信息
</div>
{overviewLoading ? (
<div className="skeleton" style={{ width: '100%', height: 72, borderRadius: 'var(--radius-sm)' }} />
@@ -731,10 +710,8 @@ function Drawer({
)}
</div>
<div style={{ height: 16 }} />
<div className="card" style={{ padding: 16 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 10 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
当前范围汇总
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 12 }}>
@@ -759,9 +736,8 @@ function Drawer({
{overview?.usage ? (
<>
<div style={{ height: 16 }} />
<div className="card" style={{ padding: 16 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 10 }}>
<div className="card" style={{ padding: 16, marginTop: 16 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
固定窗口对比
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, fontSize: 12 }}>
@@ -817,11 +793,13 @@ function EditorModal({
}) {
const [modelSearch, setModelSearch] = useState('');
const [groupSearch, setGroupSearch] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
if (!open) {
setModelSearch('');
setGroupSearch('');
setAdvancedOpen(false);
}
}, [open]);
@@ -829,6 +807,18 @@ function EditorModal({
() => 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();
@@ -838,15 +828,15 @@ function EditorModal({
const filteredGroups = useMemo(() => {
const keyword = groupSearch.trim().toLowerCase();
if (!keyword) return routeOptions;
return routeOptions.filter((route) => {
if (!keyword) return groupRouteOptions;
return groupRouteOptions.filter((route) => {
const title = routeTitle(route).toLowerCase();
return title.includes(keyword) || route.modelPattern.toLowerCase().includes(keyword);
});
}, [groupSearch, routeOptions]);
}, [groupRouteOptions, groupSearch]);
const selectedModelCount = form.selectedModels.length;
const selectedGroupCount = form.selectedGroupRouteIds.length;
const selectedGroupCount = normalizedSelectedGroupRouteIds.length;
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
@@ -854,6 +844,8 @@ function EditorModal({
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 13,
lineHeight: 1.45,
};
return (
@@ -861,7 +853,7 @@ function EditorModal({
open={open}
onClose={onClose}
title={editingItem ? '编辑下游密钥' : '新增下游密钥'}
maxWidth={1040}
maxWidth={860}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
@@ -875,20 +867,20 @@ function EditorModal({
)}
>
<div className="info-tip" style={{ marginBottom: 0 }}>
支持为每个下游密钥独立配置主分组、标签、过期时间、额度上限、模型白名单与群组范围。留空表示不限制。
支持为每个下游密钥独立配置分组、标签、额度与有效期。高级限制项可按需展开。
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>名称</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 style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>下游密钥</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">下游密钥</div>
<input value={form.key} onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value }))} placeholder="sk-..." style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>主分组</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 }))}
@@ -897,42 +889,31 @@ function EditorModal({
style={inputStyle}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>请求额度</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 style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>成本额度</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 style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>过期时间</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
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
padding: '10px 12px',
background: 'var(--color-bg)',
cursor: 'pointer',
minHeight: 42,
marginTop: 18,
}}
className="downstream-key-modal-toggle"
>
<input type="checkbox" checked={form.enabled} onChange={(e) => onChange((prev) => ({ ...prev, enabled: e.target.checked }))} />
<div>
<div style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>创建后立即启用</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>关闭后该密钥将无法继续分发请求</div>
<div className="downstream-key-modal-toggle-title">创建后立即启用</div>
<div className="downstream-key-modal-help">关闭后该密钥将无法继续分发请求</div>
</div>
</label>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>备注说明</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 }))}
@@ -941,115 +922,125 @@ function EditorModal({
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>标签</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 style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>标签用于搜索、筛选和辅助归类,不影响路由与权限。</div>
<div className="downstream-key-modal-help">标签用于搜索、筛选和辅助归类,不影响路由与权限。</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>站点倍率 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 style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>用于对特定站点做分发倍率微调;留空或 `{}` 表示走默认倍率。</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 12 }}>
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)', padding: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div style={{ fontWeight: 700, color: 'var(--color-text-primary)' }}>模型白名单</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>只展示精确模型,未勾选则视为全部模型可用</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>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}>
清空
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>已选 {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 style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)', padding: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div style={{ fontWeight: 700, color: 'var(--color-text-primary)' }}>群组范围</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>限制可访问的群组路由,未勾选则视为全部群组可用</div>
</div>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}>
清空
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>已选 {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 = form.selectedGroupRouteIds.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)
: [...prev.selectedGroupRouteIds, 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 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>
</label>
);
})}
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}>
清空
</button>
</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>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}>
清空
</button>
</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>
</div>
) : null}
</div>
<datalist id="downstream-group-suggestions">
{groupSuggestions.map((group) => <option key={group} value={group} />)}
@@ -1318,7 +1309,7 @@ export default function DownstreamKeys() {
maxCost: editorForm.maxCost.trim() ? Number(editorForm.maxCost.trim()) : null,
maxRequests: editorForm.maxRequests.trim() ? Number(editorForm.maxRequests.trim()) : null,
supportedModels: uniqStrings(editorForm.selectedModels),
allowedRouteIds: uniqIds(editorForm.selectedGroupRouteIds),
allowedRouteIds: uniqIds(editorForm.selectedGroupRouteIds).filter((id) => routeMap.has(id) && isGroupRouteOption(routeMap.get(id)!)),
siteWeightMultipliers,
};
if (editingId) {
@@ -1470,17 +1461,25 @@ export default function DownstreamKeys() {
</div>
</div>
<div className="card" style={{ padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className="kpi-chip">当前范围</span>
<span className="kpi-chip kpi-chip-success">
{range === '24h' ? '最近 24 小时' : range === '7d' ? '最近 7 天' : '全部历史'}
</span>
<span className="kpi-chip kpi-chip-warning">
Tokens {formatCompactTokens(totals.tokens)}
</span>
<div className="card" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>范围概览</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
基于当前筛选范围查看密钥规模、使用量和成本概况。
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className="kpi-chip">当前范围</span>
<span className="kpi-chip kpi-chip-success">
{range === '24h' ? '最近 24 小时' : range === '7d' ? '最近 7 天' : '全部历史'}
</span>
<span className="kpi-chip kpi-chip-warning">
Tokens {formatCompactTokens(totals.tokens)}
</span>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px 18px', alignItems: 'center' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px 18px', alignItems: 'center' }}>
<SummaryMetric label="可见密钥" value={String(visibleItems.length)} />
<SummaryMetric label="启用中" value={String(totals.enabled)} />
<SummaryMetric label="已选中" value={String(selectedIds.length)} />
@@ -1490,8 +1489,25 @@ export default function DownstreamKeys() {
</div>
</div>
<div className="card" style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{selectedIds.length > 0 ? (
<div className="card" style={{ padding: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>已选 {selectedIds.length} 个密钥</span>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>批量归类/标签</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}>批量启用</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}>批量禁用</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}>批量清零用量</button>
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}>批量删除</button>
</div>
) : null}
<div className="card" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>筛选与列表</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>按名称、状态、主分组和标签快速定位下游密钥。</div>
</div>
</div>
<div className="toolbar" style={{ marginBottom: 0, alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 420px', minWidth: 280, flexWrap: 'wrap' }}>
<div className="toolbar-search" style={{ maxWidth: 'unset', flex: '1 1 320px' }}>
@@ -1552,17 +1568,6 @@ export default function DownstreamKeys() {
) : null}
</div>
{selectedIds.length > 0 ? (
<div className="card" style={{ padding: '10px 12px', display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', background: 'color-mix(in srgb, var(--color-primary) 5%, var(--color-bg-card))' }}>
<span className="kpi-chip kpi-chip-success">已选 {selectedIds.length} 个密钥</span>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>批量归类/标签</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}>批量启用</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}>批量禁用</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}>批量清零用量</button>
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}>批量删除</button>
</div>
) : null}
{loading ? (
<div className="skeleton" style={{ width: '100%', height: 280, borderRadius: 'var(--radius-sm)' }} />
) : empty ? (
@@ -1605,7 +1610,7 @@ export default function DownstreamKeys() {
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>{row.keyMasked}</div>
{row.description ? <div style={{ fontSize: 12, color: 'var(--color-text-secondary)', maxWidth: 320 }}>{row.description}</div> : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<span style={tagChipStyle(row.groupName ? 'accent' : 'normal')}>
<span className={`badge ${row.groupName ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{row.groupName ? `主分组 · ${row.groupName}` : '未分组'}
</span>
<TagChips tags={row.tags || []} maxVisible={3} />
@@ -1631,7 +1636,7 @@ export default function DownstreamKeys() {
</td>
<td style={{ color: 'var(--color-text-muted)' }}>{formatIso(row.lastUsedAt)}</td>
<td onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, flexWrap: 'wrap' }}>
<div className="accounts-row-actions" style={{ justifyContent: 'flex-end' }}>
<button className="btn btn-link" onClick={() => { setSelectedId(row.id); setDrawerOpen(true); }}>查看</button>
<button className="btn btn-link" onClick={() => openEdit(row)}>编辑</button>
<button className="btn btn-link" onClick={() => void toggleEnabled(row)} disabled={loadingToggle}>{loadingToggle ? '处理中...' : (row.enabled ? '禁用' : '启用')}</button>