forked from boos/BoosAPI
Refine DownstreamKeys management page
This commit is contained in:
@@ -1,67 +0,0 @@
|
|||||||
# Downstream Key UI And Site Links Design
|
|
||||||
|
|
||||||
**Goal:** Unify the downstream key page with the restrained management-console style already used by the sites and accounts pages, and make site entry points behave consistently across pages.
|
|
||||||
|
|
||||||
**Context**
|
|
||||||
|
|
||||||
The current downstream key page is functionally rich but visually heavier than the rest of the console. It relies on dense inline styling and presents summary, filters, and row content with a stronger dashboard tone than the nearby management pages.
|
|
||||||
|
|
||||||
At the same time, site references are inconsistent. The accounts page already exposes a hoverable, clickable site badge that navigates back to site management with focus context. Other pages such as models, proxy logs, token routes, and downstream keys do not consistently reuse that interaction.
|
|
||||||
|
|
||||||
**User-Approved Direction**
|
|
||||||
|
|
||||||
- Align downstream key visuals with the existing `站点管理 / 连接管理` style.
|
|
||||||
- Reuse the same site button interaction already present on the accounts page.
|
|
||||||
- Prefer direct reuse over inventing a new visual pattern.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### 1. Downstream key page visual alignment
|
|
||||||
|
|
||||||
Keep the data model and operations unchanged, but reduce the visual mismatch:
|
|
||||||
|
|
||||||
- Keep the existing `page-header`, `card`, and `data-table` structure.
|
|
||||||
- Restyle summary/filter regions to read like management controls rather than dashboard hero sections.
|
|
||||||
- Preserve the drawer/details flow, but make the main list view feel closer to `Sites.tsx` and `Accounts.tsx`.
|
|
||||||
- Replace repeated ad-hoc chip/button styling where possible with the same badge/link language already used elsewhere.
|
|
||||||
|
|
||||||
This is intentionally a presentation cleanup, not a workflow redesign.
|
|
||||||
|
|
||||||
### 2. Shared site button behavior
|
|
||||||
|
|
||||||
Extract the accounts page site badge behavior into a reusable component:
|
|
||||||
|
|
||||||
- Same hover affordance.
|
|
||||||
- Same visual style.
|
|
||||||
- Same navigation target: `/sites?focusSiteId=<id>`.
|
|
||||||
- Graceful fallback when `siteId` is missing or invalid.
|
|
||||||
|
|
||||||
Pages to update:
|
|
||||||
|
|
||||||
- `src/web/pages/Models.tsx`
|
|
||||||
- `src/web/pages/ProxyLogs.tsx`
|
|
||||||
- `src/web/pages/TokenRoutes.tsx`
|
|
||||||
- `src/web/pages/DownstreamKeys.tsx`
|
|
||||||
|
|
||||||
The `Sites.tsx` page itself continues to use direct external URL links, because those links serve a different purpose.
|
|
||||||
|
|
||||||
### 3. Testing approach
|
|
||||||
|
|
||||||
Cover the change with focused UI tests:
|
|
||||||
|
|
||||||
- One shared component test for hover/click navigation behavior.
|
|
||||||
- Page-level regression tests where existing snapshots/assertions are already present.
|
|
||||||
- Keep tests narrow: validate that site labels render with the shared class/behavior and navigate to the focused site management route.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- `TokenRoutes.tsx` contains multiple kinds of site labels; only labels intended to behave like the accounts page should be converted.
|
|
||||||
- `DownstreamKeys.tsx` may not currently expose enough site metadata in every row; if site links are only meaningful in detail regions, apply the shared component there instead of forcing low-value links into the table.
|
|
||||||
- Visual cleanup should not destabilize mobile layout or row actions.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- The downstream key page looks consistent with the restrained management-console pages.
|
|
||||||
- Site entry points in models, proxy logs, token routes, and downstream keys match the accounts page interaction.
|
|
||||||
- Hover and navigation behavior are consistent.
|
|
||||||
- Existing relevant tests pass, and new tests cover the shared site link behavior.
|
|
||||||
@@ -1891,6 +1891,149 @@ body {
|
|||||||
outline: none;
|
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 {
|
.route-enable-toggle {
|
||||||
border: 1px solid transparent;
|
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;
|
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ beforeEach(() => {
|
|||||||
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ success: true, items: [buildSummaryItem()] });
|
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ success: true, items: [buildSummaryItem()] });
|
||||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ success: true, items: [buildRawItem()] });
|
apiMock.getDownstreamApiKeys.mockResolvedValue({ success: true, items: [buildRawItem()] });
|
||||||
apiMock.getRoutesLite.mockResolvedValue([
|
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 },
|
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
|
||||||
]);
|
]);
|
||||||
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
|
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
|
||||||
@@ -184,6 +184,8 @@ describe('DownstreamKeys page', () => {
|
|||||||
|
|
||||||
const text = collectText(root!.root);
|
const text = collectText(root!.root);
|
||||||
expect(text).toContain('下游密钥');
|
expect(text).toContain('下游密钥');
|
||||||
|
expect(text).toContain('范围概览');
|
||||||
|
expect(text).toContain('筛选与列表');
|
||||||
expect(text).toContain('smoke-key');
|
expect(text).toContain('smoke-key');
|
||||||
expect(text).toContain('sk-s****0315');
|
expect(text).toContain('sk-s****0315');
|
||||||
expect(text).toContain('默认群组');
|
expect(text).toContain('默认群组');
|
||||||
@@ -264,9 +266,14 @@ describe('DownstreamKeys page', () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(collectText(root!.root)).toContain('高级配置');
|
||||||
|
expect(collectText(root!.root)).not.toContain('站点倍率 JSON');
|
||||||
|
|
||||||
const inputs = root!.root.findAllByType('input');
|
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 nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
|
||||||
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
|
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
|
||||||
|
expect(tagInput?.props.style?.fontSize).toBe(13);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
nameInput!.props.onChange({ target: { value: 'new-key' } });
|
nameInput!.props.onChange({ target: { value: 'new-key' } });
|
||||||
keyInput!.props.onChange({ target: { value: 'sk-new-key-0315' } });
|
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 () => {
|
it('uses backend batch api for selected rows', async () => {
|
||||||
let root: ReturnType<typeof create> | null = null;
|
let root: ReturnType<typeof create> | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -329,6 +380,7 @@ describe('DownstreamKeys page', () => {
|
|||||||
checkbox!.props.onChange({ target: { checked: true } });
|
checkbox!.props.onChange({ target: { checked: true } });
|
||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
expect(collectText(root!.root)).toContain('已选 1 个密钥');
|
||||||
|
|
||||||
const batchButton = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量启用'))[0];
|
const batchButton = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量启用'))[0];
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -365,6 +417,12 @@ describe('DownstreamKeys page', () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
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 inputs = root!.root.findAllByType('input');
|
||||||
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
|
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
|
||||||
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
|
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
|
||||||
|
|||||||
+129
-124
@@ -170,6 +170,10 @@ function routeTitle(route: RouteSelectorItem): string {
|
|||||||
return displayName || route.modelPattern;
|
return displayName || route.modelPattern;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGroupRouteOption(route: RouteSelectorItem): boolean {
|
||||||
|
return !isExactModelPattern(route.modelPattern);
|
||||||
|
}
|
||||||
|
|
||||||
function uniqStrings(values: string[]): string[] {
|
function uniqStrings(values: string[]): string[] {
|
||||||
return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))];
|
return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))];
|
||||||
}
|
}
|
||||||
@@ -312,7 +316,7 @@ function TagChips({
|
|||||||
maxVisible?: number;
|
maxVisible?: number;
|
||||||
}) {
|
}) {
|
||||||
if (!Array.isArray(tags) || tags.length === 0) {
|
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);
|
const visible = tags.slice(0, maxVisible);
|
||||||
@@ -320,11 +324,15 @@ function TagChips({
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
{visible.map((tag) => (
|
{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}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{hidden > 0 ? <span style={tagChipStyle()}>{`+${hidden}`}</span> : null}
|
{hidden > 0 ? <span className="badge badge-muted" style={{ fontSize: 11 }}>{`+${hidden}`}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -391,7 +399,7 @@ function TagInput({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder || '输入标签后按回车或逗号'}
|
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>
|
</div>
|
||||||
{suggestionPool.length > 0 ? (
|
{suggestionPool.length > 0 ? (
|
||||||
@@ -421,9 +429,9 @@ function SummaryMetric({
|
|||||||
value: string;
|
value: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -440,15 +448,7 @@ function resolveOverviewUsageByRange(
|
|||||||
|
|
||||||
function TrendChartFallback({ height = 260 }: { height?: number }) {
|
function TrendChartFallback({ height = 260 }: { height?: number }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="card" style={{ padding: 16 }}>
|
||||||
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="skeleton" style={{ width: 140, height: 28, borderRadius: 'var(--radius-sm)', marginBottom: 10 }} />
|
<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 className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -542,29 +542,7 @@ function RangeToggle({ range, onChange }: { range: Range; onChange: (r: Range) =
|
|||||||
|
|
||||||
function StatusBadge({ enabled }: { enabled: boolean }) {
|
function StatusBadge({ enabled }: { enabled: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={`badge ${enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||||
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)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{enabled ? '启用' : '禁用'}
|
{enabled ? '启用' : '禁用'}
|
||||||
</span>
|
</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',
|
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', flexDirection: 'column', gap: 4 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<span>{item?.name || '--'}</span>
|
<span>{item?.name || '--'}</span>
|
||||||
@@ -670,7 +648,7 @@ function Drawer({
|
|||||||
{item?.keyMasked || '****'}
|
{item?.keyMasked || '****'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 6 }}>
|
<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}` : '未分组'}
|
{item?.groupName ? `主分组 · ${item.groupName}` : '未分组'}
|
||||||
</span>
|
</span>
|
||||||
<TagChips tags={item?.tags || []} accent maxVisible={4} />
|
<TagChips tags={item?.tags || []} accent maxVisible={4} />
|
||||||
@@ -682,9 +660,11 @@ function Drawer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-body" style={{ paddingTop: 0 }}>
|
<div className="modal-body" style={{ paddingTop: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', fontWeight: 600, letterSpacing: '0.02em' }}>
|
<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>
|
</div>
|
||||||
<RangeToggle range={trendRange} onChange={setTrendRange} />
|
<RangeToggle range={trendRange} onChange={setTrendRange} />
|
||||||
</div>
|
</div>
|
||||||
@@ -692,12 +672,11 @@ function Drawer({
|
|||||||
<Suspense fallback={<TrendChartFallback height={260} />}>
|
<Suspense fallback={<TrendChartFallback height={260} />}>
|
||||||
<DownstreamKeyTrendChart buckets={buckets} loading={trendLoading} height={260} />
|
<DownstreamKeyTrendChart buckets={buckets} loading={trendLoading} height={260} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ height: 16 }} />
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
|
||||||
<div className="card" style={{ padding: 16 }}>
|
基础信息
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 10 }}>
|
|
||||||
概览
|
|
||||||
</div>
|
</div>
|
||||||
{overviewLoading ? (
|
{overviewLoading ? (
|
||||||
<div className="skeleton" style={{ width: '100%', height: 72, borderRadius: 'var(--radius-sm)' }} />
|
<div className="skeleton" style={{ width: '100%', height: 72, borderRadius: 'var(--radius-sm)' }} />
|
||||||
@@ -731,10 +710,8 @@ function Drawer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ height: 16 }} />
|
|
||||||
|
|
||||||
<div className="card" style={{ padding: 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>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 12 }}>
|
||||||
@@ -759,9 +736,8 @@ function Drawer({
|
|||||||
|
|
||||||
{overview?.usage ? (
|
{overview?.usage ? (
|
||||||
<>
|
<>
|
||||||
<div style={{ height: 16 }} />
|
<div className="card" style={{ padding: 16, marginTop: 16 }}>
|
||||||
<div className="card" style={{ padding: 16 }}>
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 10 }}>
|
|
||||||
固定窗口对比
|
固定窗口对比
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, fontSize: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, fontSize: 12 }}>
|
||||||
@@ -817,11 +793,13 @@ function EditorModal({
|
|||||||
}) {
|
}) {
|
||||||
const [modelSearch, setModelSearch] = useState('');
|
const [modelSearch, setModelSearch] = useState('');
|
||||||
const [groupSearch, setGroupSearch] = useState('');
|
const [groupSearch, setGroupSearch] = useState('');
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setModelSearch('');
|
setModelSearch('');
|
||||||
setGroupSearch('');
|
setGroupSearch('');
|
||||||
|
setAdvancedOpen(false);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -829,6 +807,18 @@ function EditorModal({
|
|||||||
() => uniqStrings(routeOptions.filter((item) => isExactModelPattern(item.modelPattern)).map((item) => item.modelPattern)).sort((a, b) => a.localeCompare(b)),
|
() => uniqStrings(routeOptions.filter((item) => isExactModelPattern(item.modelPattern)).map((item) => item.modelPattern)).sort((a, b) => a.localeCompare(b)),
|
||||||
[routeOptions],
|
[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 filteredModels = useMemo(() => {
|
||||||
const keyword = modelSearch.trim().toLowerCase();
|
const keyword = modelSearch.trim().toLowerCase();
|
||||||
@@ -838,15 +828,15 @@ function EditorModal({
|
|||||||
|
|
||||||
const filteredGroups = useMemo(() => {
|
const filteredGroups = useMemo(() => {
|
||||||
const keyword = groupSearch.trim().toLowerCase();
|
const keyword = groupSearch.trim().toLowerCase();
|
||||||
if (!keyword) return routeOptions;
|
if (!keyword) return groupRouteOptions;
|
||||||
return routeOptions.filter((route) => {
|
return groupRouteOptions.filter((route) => {
|
||||||
const title = routeTitle(route).toLowerCase();
|
const title = routeTitle(route).toLowerCase();
|
||||||
return title.includes(keyword) || route.modelPattern.toLowerCase().includes(keyword);
|
return title.includes(keyword) || route.modelPattern.toLowerCase().includes(keyword);
|
||||||
});
|
});
|
||||||
}, [groupSearch, routeOptions]);
|
}, [groupRouteOptions, groupSearch]);
|
||||||
|
|
||||||
const selectedModelCount = form.selectedModels.length;
|
const selectedModelCount = form.selectedModels.length;
|
||||||
const selectedGroupCount = form.selectedGroupRouteIds.length;
|
const selectedGroupCount = normalizedSelectedGroupRouteIds.length;
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '10px 12px',
|
padding: '10px 12px',
|
||||||
@@ -854,6 +844,8 @@ function EditorModal({
|
|||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
background: 'var(--color-bg)',
|
background: 'var(--color-bg)',
|
||||||
color: 'var(--color-text-primary)',
|
color: 'var(--color-text-primary)',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.45,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -861,7 +853,7 @@ function EditorModal({
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={editingItem ? '编辑下游密钥' : '新增下游密钥'}
|
title={editingItem ? '编辑下游密钥' : '新增下游密钥'}
|
||||||
maxWidth={1040}
|
maxWidth={860}
|
||||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
@@ -875,20 +867,20 @@ function EditorModal({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="info-tip" style={{ marginBottom: 0 }}>
|
<div className="info-tip" style={{ marginBottom: 0 }}>
|
||||||
支持为每个下游密钥独立配置主分组、标签、过期时间、额度上限、模型白名单与群组范围。留空表示不限制。
|
支持为每个下游密钥独立配置分组、标签、额度与有效期。高级限制项可按需展开。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
|
<div className="downstream-key-modal-grid" style={{ gridTemplateColumns: '1fr' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="downstream-key-modal-field downstream-key-modal-field-full">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>名称</div>
|
<div className="downstream-key-modal-label">名称</div>
|
||||||
<input value={form.name} onChange={(e) => onChange((prev) => ({ ...prev, name: e.target.value }))} placeholder="例如:项目 A / 移动端" style={inputStyle} />
|
<input value={form.name} onChange={(e) => onChange((prev) => ({ ...prev, name: e.target.value }))} placeholder="例如:项目 A / 移动端" style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="downstream-key-modal-field">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>下游密钥</div>
|
<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)' }} />
|
<input value={form.key} onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value }))} placeholder="sk-..." style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="downstream-key-modal-field">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>主分组</div>
|
<div className="downstream-key-modal-label">主分组</div>
|
||||||
<input
|
<input
|
||||||
value={form.groupName}
|
value={form.groupName}
|
||||||
onChange={(e) => onChange((prev) => ({ ...prev, groupName: e.target.value }))}
|
onChange={(e) => onChange((prev) => ({ ...prev, groupName: e.target.value }))}
|
||||||
@@ -897,42 +889,31 @@ function EditorModal({
|
|||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="downstream-key-modal-field">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>请求额度</div>
|
<div className="downstream-key-modal-label">请求额度</div>
|
||||||
<input value={form.maxRequests} onChange={(e) => onChange((prev) => ({ ...prev, maxRequests: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
|
<input value={form.maxRequests} onChange={(e) => onChange((prev) => ({ ...prev, maxRequests: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="downstream-key-modal-field">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>成本额度</div>
|
<div className="downstream-key-modal-label">成本额度</div>
|
||||||
<input value={form.maxCost} onChange={(e) => onChange((prev) => ({ ...prev, maxCost: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
|
<input value={form.maxCost} onChange={(e) => onChange((prev) => ({ ...prev, maxCost: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="downstream-key-modal-field">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>过期时间</div>
|
<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} />
|
<input type="datetime-local" value={form.expiresAt} onChange={(e) => onChange((prev) => ({ ...prev, expiresAt: e.target.value }))} style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
<label
|
<label
|
||||||
style={{
|
className="downstream-key-modal-toggle"
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<input type="checkbox" checked={form.enabled} onChange={(e) => onChange((prev) => ({ ...prev, enabled: e.target.checked }))} />
|
<input type="checkbox" checked={form.enabled} onChange={(e) => onChange((prev) => ({ ...prev, enabled: e.target.checked }))} />
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>创建后立即启用</div>
|
<div className="downstream-key-modal-toggle-title">创建后立即启用</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>关闭后该密钥将无法继续分发请求</div>
|
<div className="downstream-key-modal-help">关闭后该密钥将无法继续分发请求</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="downstream-key-modal-field downstream-key-modal-field-full">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>备注说明</div>
|
<div className="downstream-key-modal-label">备注说明</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(e) => onChange((prev) => ({ ...prev, description: e.target.value }))}
|
onChange={(e) => onChange((prev) => ({ ...prev, description: e.target.value }))}
|
||||||
@@ -941,40 +922,47 @@ function EditorModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="downstream-key-modal-field downstream-key-modal-field-full">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>标签</div>
|
<div className="downstream-key-modal-label">标签</div>
|
||||||
<TagInput
|
<TagInput
|
||||||
tags={form.tags}
|
tags={form.tags}
|
||||||
onChange={(tags) => onChange((prev) => ({ ...prev, tags }))}
|
onChange={(tags) => onChange((prev) => ({ ...prev, tags }))}
|
||||||
suggestions={tagSuggestions}
|
suggestions={tagSuggestions}
|
||||||
placeholder="输入标签后按回车或逗号,例如:移动端、VIP、项目A"
|
placeholder="输入标签后按回车或逗号,例如:移动端、VIP、项目A"
|
||||||
/>
|
/>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>标签用于搜索、筛选和辅助归类,不影响路由与权限。</div>
|
<div className="downstream-key-modal-help">标签用于搜索、筛选和辅助归类,不影响路由与权限。</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="downstream-key-advanced">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>站点倍率 JSON</div>
|
<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
|
<textarea
|
||||||
value={form.siteWeightMultipliersText}
|
value={form.siteWeightMultipliersText}
|
||||||
onChange={(e) => onChange((prev) => ({ ...prev, siteWeightMultipliersText: e.target.value }))}
|
onChange={(e) => onChange((prev) => ({ ...prev, siteWeightMultipliersText: e.target.value }))}
|
||||||
placeholder={'例如:{\n "1": 1.2,\n "7": 0.8\n}'}
|
placeholder={'例如:{\n "1": 1.2,\n "7": 0.8\n}'}
|
||||||
style={{ ...inputStyle, minHeight: 96, resize: 'vertical', fontFamily: 'var(--font-mono)' }}
|
style={{ ...inputStyle, minHeight: 96, resize: 'vertical', fontFamily: 'var(--font-mono)' }}
|
||||||
/>
|
/>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>用于对特定站点做分发倍率微调;留空或 `{}` 表示走默认倍率。</div>
|
<div className="downstream-key-modal-help">用于对特定站点做分发倍率微调;留空或 `{}` 表示走默认倍率。</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 12 }}>
|
<div className="downstream-key-advanced-grid" style={{ gridTemplateColumns: '1fr' }}>
|
||||||
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)', padding: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div className="downstream-key-advanced-panel">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 700, color: 'var(--color-text-primary)' }}>模型白名单</div>
|
<div className="downstream-key-modal-section-title">模型白名单</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>只展示精确模型,未勾选则视为全部模型可用</div>
|
<div className="downstream-key-modal-help">只展示精确模型,未勾选则视为全部模型可用</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}>
|
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}>
|
||||||
清空
|
清空
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>已选 {selectedModelCount} 个模型</div>
|
<div className="downstream-key-modal-meta">已选 {selectedModelCount} 个模型</div>
|
||||||
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
|
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
|
||||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
@@ -1003,17 +991,17 @@ function EditorModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)', padding: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div className="downstream-key-advanced-panel">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 700, color: 'var(--color-text-primary)' }}>群组范围</div>
|
<div className="downstream-key-modal-section-title">群组范围</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>限制可访问的群组路由,未勾选则视为全部群组可用</div>
|
<div className="downstream-key-modal-help">限制可访问的群组路由,未勾选则视为全部群组可用</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}>
|
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}>
|
||||||
清空
|
清空
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>已选 {selectedGroupCount} 个群组</div>
|
<div className="downstream-key-modal-meta">已选 {selectedGroupCount} 个群组</div>
|
||||||
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
|
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
|
||||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
@@ -1024,7 +1012,7 @@ function EditorModal({
|
|||||||
{filteredGroups.length === 0 ? (
|
{filteredGroups.length === 0 ? (
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无匹配群组</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无匹配群组</div>
|
||||||
) : filteredGroups.map((route) => {
|
) : filteredGroups.map((route) => {
|
||||||
const checked = form.selectedGroupRouteIds.includes(route.id);
|
const checked = normalizedSelectedGroupRouteIds.includes(route.id);
|
||||||
return (
|
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)' }}>
|
<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
|
<input
|
||||||
@@ -1034,7 +1022,7 @@ function EditorModal({
|
|||||||
...prev,
|
...prev,
|
||||||
selectedGroupRouteIds: checked
|
selectedGroupRouteIds: checked
|
||||||
? prev.selectedGroupRouteIds.filter((item) => item !== route.id)
|
? prev.selectedGroupRouteIds.filter((item) => item !== route.id)
|
||||||
: [...prev.selectedGroupRouteIds, route.id],
|
: uniqIds([...prev.selectedGroupRouteIds.filter((item) => validGroupRouteIdSet.has(item)), route.id]),
|
||||||
}))}
|
}))}
|
||||||
style={{ marginTop: 2 }}
|
style={{ marginTop: 2 }}
|
||||||
/>
|
/>
|
||||||
@@ -1051,6 +1039,9 @@ function EditorModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<datalist id="downstream-group-suggestions">
|
<datalist id="downstream-group-suggestions">
|
||||||
{groupSuggestions.map((group) => <option key={group} value={group} />)}
|
{groupSuggestions.map((group) => <option key={group} value={group} />)}
|
||||||
</datalist>
|
</datalist>
|
||||||
@@ -1318,7 +1309,7 @@ export default function DownstreamKeys() {
|
|||||||
maxCost: editorForm.maxCost.trim() ? Number(editorForm.maxCost.trim()) : null,
|
maxCost: editorForm.maxCost.trim() ? Number(editorForm.maxCost.trim()) : null,
|
||||||
maxRequests: editorForm.maxRequests.trim() ? Number(editorForm.maxRequests.trim()) : null,
|
maxRequests: editorForm.maxRequests.trim() ? Number(editorForm.maxRequests.trim()) : null,
|
||||||
supportedModels: uniqStrings(editorForm.selectedModels),
|
supportedModels: uniqStrings(editorForm.selectedModels),
|
||||||
allowedRouteIds: uniqIds(editorForm.selectedGroupRouteIds),
|
allowedRouteIds: uniqIds(editorForm.selectedGroupRouteIds).filter((id) => routeMap.has(id) && isGroupRouteOption(routeMap.get(id)!)),
|
||||||
siteWeightMultipliers,
|
siteWeightMultipliers,
|
||||||
};
|
};
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
@@ -1470,7 +1461,14 @@ export default function DownstreamKeys() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<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' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span className="kpi-chip">当前范围</span>
|
<span className="kpi-chip">当前范围</span>
|
||||||
<span className="kpi-chip kpi-chip-success">
|
<span className="kpi-chip kpi-chip-success">
|
||||||
@@ -1480,7 +1478,8 @@ export default function DownstreamKeys() {
|
|||||||
Tokens {formatCompactTokens(totals.tokens)}
|
Tokens {formatCompactTokens(totals.tokens)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px 18px', alignItems: 'center' }}>
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px 18px', alignItems: 'center' }}>
|
||||||
<SummaryMetric label="可见密钥" value={String(visibleItems.length)} />
|
<SummaryMetric label="可见密钥" value={String(visibleItems.length)} />
|
||||||
<SummaryMetric label="启用中" value={String(totals.enabled)} />
|
<SummaryMetric label="启用中" value={String(totals.enabled)} />
|
||||||
<SummaryMetric label="已选中" value={String(selectedIds.length)} />
|
<SummaryMetric label="已选中" value={String(selectedIds.length)} />
|
||||||
@@ -1490,8 +1489,25 @@ export default function DownstreamKeys() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
{selectedIds.length > 0 ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<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 className="toolbar" style={{ marginBottom: 0, alignItems: 'center' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 420px', minWidth: 280, flexWrap: 'wrap' }}>
|
<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' }}>
|
<div className="toolbar-search" style={{ maxWidth: 'unset', flex: '1 1 320px' }}>
|
||||||
@@ -1552,17 +1568,6 @@ export default function DownstreamKeys() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 ? (
|
{loading ? (
|
||||||
<div className="skeleton" style={{ width: '100%', height: 280, borderRadius: 'var(--radius-sm)' }} />
|
<div className="skeleton" style={{ width: '100%', height: 280, borderRadius: 'var(--radius-sm)' }} />
|
||||||
) : empty ? (
|
) : 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>
|
<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}
|
{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 }}>
|
<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}` : '未分组'}
|
{row.groupName ? `主分组 · ${row.groupName}` : '未分组'}
|
||||||
</span>
|
</span>
|
||||||
<TagChips tags={row.tags || []} maxVisible={3} />
|
<TagChips tags={row.tags || []} maxVisible={3} />
|
||||||
@@ -1631,7 +1636,7 @@ export default function DownstreamKeys() {
|
|||||||
</td>
|
</td>
|
||||||
<td style={{ color: 'var(--color-text-muted)' }}>{formatIso(row.lastUsedAt)}</td>
|
<td style={{ color: 'var(--color-text-muted)' }}>{formatIso(row.lastUsedAt)}</td>
|
||||||
<td onClick={(e) => e.stopPropagation()}>
|
<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={() => { setSelectedId(row.id); setDrawerOpen(true); }}>查看</button>
|
||||||
<button className="btn btn-link" onClick={() => openEdit(row)}>编辑</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>
|
<button className="btn btn-link" onClick={() => void toggleEnabled(row)} disabled={loadingToggle}>{loadingToggle ? '处理中...' : (row.enabled ? '禁用' : '启用')}</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user