Merge pull request #120 from cita-777/downstream-keys-ui-harmonize

Refine DownstreamKeys page and unify site badge links
This commit is contained in:
Cita
2026-03-16 14:54:34 +08:00
committed by GitHub
9 changed files with 698 additions and 256 deletions
@@ -0,0 +1,160 @@
# Downstream Key UI And Site Links Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Restyle the downstream key page to match the restrained management-console pages and standardize site buttons across pages using the accounts page interaction.
**Architecture:** Introduce one shared site link component based on the accounts page behavior, then update each affected page to consume it. Refactor downstream key layout styling toward existing `card`, `badge`, and `data-table` patterns without changing backend behavior.
**Tech Stack:** React, TypeScript, React Router, Vitest, React Test Renderer
---
### Task 1: Extract the shared site button
**Files:**
- Create: `src/web/components/SiteBadgeLink.tsx`
- Test: `src/web/components/site-badge-link.test.tsx`
- Modify: `src/web/pages/Accounts.tsx`
- Modify: `src/web/index.css`
**Step 1: Write the failing test**
Add a component test that renders a site badge link and asserts:
- it renders the site name,
- it has the shared interactive class,
- clicking navigates to `/sites?focusSiteId=<id>`.
**Step 2: Run test to verify it fails**
Run: `npm test -- src/web/components/site-badge-link.test.tsx`
Expected: FAIL because the component does not exist yet.
**Step 3: Write minimal implementation**
- Create a small component that wraps `Link` from `react-router-dom`.
- Accept `siteId`, `siteName`, and optional badge class props.
- Reproduce the accounts page badge-link styling and fallback behavior.
- Move the accounts page site badge rendering to this component.
- Add any missing shared CSS class if accounts currently relies on page-local styling.
**Step 4: Run test to verify it passes**
Run: `npm test -- src/web/components/site-badge-link.test.tsx src/web/pages/accounts.segmented-connections.test.tsx`
Expected: PASS
**Step 5: Commit**
```bash
git add src/web/components/SiteBadgeLink.tsx src/web/components/site-badge-link.test.tsx src/web/pages/Accounts.tsx src/web/index.css
git commit -m "refactor: extract shared site badge link"
```
### Task 2: Apply the shared site button across affected pages
**Files:**
- Modify: `src/web/pages/Models.tsx`
- Modify: `src/web/pages/ProxyLogs.tsx`
- Modify: `src/web/pages/TokenRoutes.tsx`
- Modify: `src/web/pages/DownstreamKeys.tsx`
- Test: `src/web/pages/Models.marketplace-text.test.tsx`
- Test: `src/web/pages/tokenRoutes.group-collapse.test.tsx`
- Test: `src/web/pages/DownstreamKeys.test.tsx`
**Step 1: Write failing assertions**
Add or update tests to assert the affected pages render the shared site button class and, where practical, point to the focused site management route.
**Step 2: Run test to verify it fails**
Run: `npm test -- src/web/pages/Models.marketplace-text.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/DownstreamKeys.test.tsx`
Expected: FAIL because the pages still use inconsistent site rendering.
**Step 3: Write minimal implementation**
- Replace page-local site label rendering with `SiteBadgeLink`.
- Only convert labels that represent navigable management-site references.
- Preserve non-site filters, external URLs, and unrelated badges.
**Step 4: Run test to verify it passes**
Run: `npm test -- src/web/pages/Models.marketplace-text.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/DownstreamKeys.test.tsx src/web/pages/ProxyLogs.server-driven.test.tsx`
Expected: PASS
**Step 5: Commit**
```bash
git add src/web/pages/Models.tsx src/web/pages/ProxyLogs.tsx src/web/pages/TokenRoutes.tsx src/web/pages/DownstreamKeys.tsx src/web/pages/Models.marketplace-text.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/DownstreamKeys.test.tsx
git commit -m "feat: unify site badge navigation across pages"
```
### Task 3: Restyle downstream keys page
**Files:**
- Modify: `src/web/pages/DownstreamKeys.tsx`
- Test: `src/web/pages/DownstreamKeys.test.tsx`
- Modify: `src/web/index.css`
**Step 1: Write failing assertions**
Add focused assertions for the downstream keys page structure where needed, such as shared management-page card classes, reduced bespoke styling hooks, or the new site badge placement.
**Step 2: Run test to verify it fails**
Run: `npm test -- src/web/pages/DownstreamKeys.test.tsx`
Expected: FAIL after adding assertions that reflect the updated restrained layout.
**Step 3: Write minimal implementation**
- Reduce the page’s dashboard-like presentation.
- Align summary, filters, and batch action sections with the same management-console card language used in sites/accounts.
- Reuse shared badge/link patterns instead of one-off visual treatments where practical.
- Keep operations, data fetching, and drawer behavior unchanged.
**Step 4: Run test to verify it passes**
Run: `npm test -- src/web/pages/DownstreamKeys.test.tsx`
Expected: PASS
**Step 5: Commit**
```bash
git add src/web/pages/DownstreamKeys.tsx src/web/pages/DownstreamKeys.test.tsx src/web/index.css
git commit -m "style: align downstream key page with management UI"
```
### Task 4: Final verification
**Files:**
- Modify: none
**Step 1: Run targeted verification**
Run:
- `npm test -- src/web/components/site-badge-link.test.tsx`
- `npm test -- src/web/pages/DownstreamKeys.test.tsx`
- `npm test -- src/web/pages/Models.marketplace-text.test.tsx`
- `npm test -- src/web/pages/tokenRoutes.group-collapse.test.tsx`
- `npm test -- src/web/pages/ProxyLogs.server-driven.test.tsx`
- `npm test -- src/web/pages/accounts.segmented-connections.test.tsx`
Expected: PASS
**Step 2: Run broader sanity verification if time permits**
Run: `npm test -- src/web/App.sidebar.test.ts src/web/App.sidebar-mobile.test.tsx`
Expected: PASS
**Step 3: Commit final polish**
```bash
git add -A
git commit -m "test: verify downstream key UI and site link consistency"
```
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
import { Link } from 'react-router-dom';
type SiteBadgeLinkProps = {
siteId?: number | null;
siteName?: string | null;
className?: string;
badgeClassName?: string;
badgeStyle?: React.CSSProperties;
};
export default function SiteBadgeLink({
siteId,
siteName,
className = 'badge-link',
badgeClassName = 'badge badge-muted',
badgeStyle,
}: SiteBadgeLinkProps) {
const label = String(siteName || '').trim() || '-';
const normalizedSiteId = Number(siteId);
if (!Number.isFinite(normalizedSiteId) || normalizedSiteId <= 0) {
return (
<span className={badgeClassName} style={badgeStyle}>
{label}
</span>
);
}
return (
<Link to={`/sites?focusSiteId=${Math.trunc(normalizedSiteId)}`} className={className}>
<span className={badgeClassName} style={badgeStyle}>
{label}
</span>
</Link>
);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import SiteBadgeLink from './SiteBadgeLink.js';
describe('SiteBadgeLink', () => {
it('renders a focus-navigation link when site id is valid', () => {
const root = create(
<MemoryRouter>
<SiteBadgeLink siteId={7} siteName="Demo Site" />
</MemoryRouter>,
);
const link = root.root.findByType('a');
expect(String(link.props.href || '')).toContain('/sites?focusSiteId=7');
expect(String(link.props.className || '')).toContain('badge-link');
expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Demo Site');
root.unmount();
});
it('falls back to plain badge text when site id is invalid', () => {
const root = create(
<MemoryRouter>
<SiteBadgeLink siteId={0} siteName="Unknown Site" />
</MemoryRouter>,
);
expect(root.root.findAllByType('a')).toHaveLength(0);
expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Unknown Site');
root.unmount();
});
});
+144 -1
View File
@@ -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;
@@ -4724,4 +4867,4 @@ textarea {
.route-card-grid { .route-card-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
+12 -31
View File
@@ -7,6 +7,7 @@ import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js'; import { MobileCard, MobileField } from '../components/MobileCard.js';
import { useIsMobile } from '../components/useIsMobile.js'; import { useIsMobile } from '../components/useIsMobile.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js'; import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { import {
buildAddAccountPrereqHint, buildAddAccountPrereqHint,
buildVerifyFailureHint, buildVerifyFailureHint,
@@ -1634,21 +1635,12 @@ export default function Accounts() {
<div className="mobile-card-extra"> <div className="mobile-card-extra">
<MobileField <MobileField
label="站点" label="站点"
value={a.site?.url ? ( value={(
<a <SiteBadgeLink
href={a.site.url} siteId={a.site?.id}
target="_blank" siteName={a.site?.name}
rel="noopener noreferrer" badgeStyle={{ fontSize: 11 }}
className="badge-link" />
>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
)} )}
/> />
<MobileField <MobileField
@@ -1803,22 +1795,11 @@ export default function Accounts() {
</div> </div>
</td> </td>
<td> <td>
{a.site?.url ? ( <SiteBadgeLink
<a siteId={a.site?.id}
href={a.site.url} siteName={a.site?.name}
target="_blank" badgeStyle={{ fontSize: 11 }}
rel="noopener noreferrer" />
className="badge-link"
>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
)}
</td> </td>
<td> <td>
{(() => { {(() => {
+59 -1
View File
@@ -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-...');
+222 -217
View File
@@ -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,22 +660,23 @@ 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>
<RangeToggle range={trendRange} onChange={setTrendRange} />
</div> </div>
<RangeToggle range={trendRange} onChange={setTrendRange} />
<Suspense fallback={<TrendChartFallback height={260} />}>
<DownstreamKeyTrendChart buckets={buckets} loading={trendLoading} height={260} />
</Suspense>
</div> </div>
<Suspense fallback={<TrendChartFallback height={260} />}> <div className="card" style={{ padding: 16, marginBottom: 16 }}>
<DownstreamKeyTrendChart buckets={buckets} loading={trendLoading} height={260} /> <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
</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> </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,115 +922,125 @@ 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)}>
<textarea <span>高级配置</span>
value={form.siteWeightMultipliersText} <span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{advancedOpen ? '收起' : '展开'}</span>
onChange={(e) => onChange((prev) => ({ ...prev, siteWeightMultipliersText: e.target.value }))} </button>
placeholder={'例如:{\n "1": 1.2,\n "7": 0.8\n}'} {advancedOpen ? (
style={{ ...inputStyle, minHeight: 96, resize: 'vertical', fontFamily: 'var(--font-mono)' }} <div className="downstream-key-advanced-content">
/> <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">站点倍率 JSON</div>
</div> <textarea
value={form.siteWeightMultipliersText}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 12 }}> onChange={(e) => onChange((prev) => ({ ...prev, siteWeightMultipliersText: e.target.value }))}
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)', padding: 12, display: 'flex', flexDirection: 'column', gap: 10 }}> placeholder={'例如:{\n "1": 1.2,\n "7": 0.8\n}'}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}> style={{ ...inputStyle, minHeight: 96, resize: 'vertical', fontFamily: 'var(--font-mono)' }}
<div> />
<div style={{ fontWeight: 700, color: 'var(--color-text-primary)' }}>模型白名单</div> <div className="downstream-key-modal-help">用于对特定站点做分发倍率微调;留空或 `{}` 表示走默认倍率。</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>只展示精确模型,未勾选则视为全部模型可用</div>
</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 className="downstream-key-advanced-grid" style={{ gridTemplateColumns: '1fr' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}> <div className="downstream-key-advanced-panel">
<div> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ fontWeight: 700, color: 'var(--color-text-primary)' }}>群组范围</div> <div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>限制可访问的群组路由,未勾选则视为全部群组可用</div> <div className="downstream-key-modal-section-title">模型白名单</div>
</div> <div className="downstream-key-modal-help">只展示精确模型,未勾选则视为全部模型可用</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> </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>
</div> ) : null}
</div> </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} />)}
@@ -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,17 +1461,25 @@ 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: 'center', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<span className="kpi-chip">当前范围</span> <div>
<span className="kpi-chip kpi-chip-success"> <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>范围概览</div>
{range === '24h' ? '最近 24 小时' : range === '7d' ? '最近 7 天' : '全部历史'} <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
</span> 基于当前筛选范围查看密钥规模、使用量和成本概况。
<span className="kpi-chip kpi-chip-warning"> </div>
Tokens {formatCompactTokens(totals.tokens)} </div>
</span> <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>
<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(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>
+17 -4
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js'; import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
@@ -168,6 +169,18 @@ export default function Models() {
const latestPrimaryRequestRef = useRef(0); const latestPrimaryRequestRef = useRef(0);
const latestMetadataRequestRef = useRef(0); const latestMetadataRequestRef = useRef(0);
const location = useLocation(); const location = useLocation();
const siteIdByName = useMemo(() => {
const index = new Map<string, number>();
for (const model of data.models) {
for (const source of model.pricingSources || []) {
const siteName = String(source.siteName || '').trim();
const siteId = Number(source.siteId);
if (!siteName || !Number.isFinite(siteId) || siteId <= 0 || index.has(siteName)) continue;
index.set(siteName, Math.trunc(siteId));
}
}
return index;
}, [data.models]);
const loadBaseMarketplace = useCallback(async (refresh = false) => { const loadBaseMarketplace = useCallback(async (refresh = false) => {
const requestId = ++latestPrimaryRequestRef.current; const requestId = ++latestPrimaryRequestRef.current;
@@ -688,7 +701,7 @@ export default function Models() {
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }} style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
> >
<div style={{ fontSize: 12, marginBottom: 6 }}> <div style={{ fontSize: 12, marginBottom: 6 }}>
<strong>{source.siteName}</strong> · {source.username || `ID:${source.accountId}`} <SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => ( {Object.entries(source.groupPricing).map(([group, pricing]) => (
@@ -719,7 +732,7 @@ export default function Models() {
<tbody> <tbody>
{m.accounts.map(a => ( {m.accounts.map(a => (
<tr key={a.id}> <tr key={a.id}>
<td><span className="badge badge-info">{a.site}</span></td> <td><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
<td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td> <td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td>
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}> <td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => ( {a.tokens.length > 0 ? a.tokens.map(t => (
@@ -849,7 +862,7 @@ export default function Models() {
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }} style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
> >
<div style={{ fontSize: 12, marginBottom: 6 }}> <div style={{ fontSize: 12, marginBottom: 6 }}>
<strong>{source.siteName}</strong> · {source.username || `ID:${source.accountId}`} <SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => ( {Object.entries(source.groupPricing).map(([group, pricing]) => (
@@ -878,7 +891,7 @@ export default function Models() {
<tbody> <tbody>
{m.accounts.map(a => ( {m.accounts.map(a => (
<tr key={a.id} style={{ borderTop: '1px solid var(--color-border-light)' }}> <tr key={a.id} style={{ borderTop: '1px solid var(--color-border-light)' }}>
<td style={{ padding: 8 }}>{a.site}</td> <td style={{ padding: 8 }}><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
<td style={{ padding: 8 }}>{a.username || `ID:${a.id}`}</td> <td style={{ padding: 8 }}>{a.username || `ID:${a.id}`}</td>
<td style={{ padding: 8, display: 'flex', gap: 4, flexWrap: 'wrap' }}> <td style={{ padding: 8, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => ( {a.tokens.length > 0 ? a.tokens.map(t => (
+13 -2
View File
@@ -10,6 +10,7 @@ import {
} from '../api.js'; } from '../api.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import { ModelBadge } from '../components/BrandIcon.js'; import { ModelBadge } from '../components/BrandIcon.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { MobileCard, MobileField } from '../components/MobileCard.js'; import { MobileCard, MobileField } from '../components/MobileCard.js';
import { MobileDrawer } from '../components/MobileDrawer.js'; import { MobileDrawer } from '../components/MobileDrawer.js';
import { useIsMobile } from '../components/useIsMobile.js'; import { useIsMobile } from '../components/useIsMobile.js';
@@ -329,6 +330,16 @@ export default function ProxyLogs() {
if (!siteFilter) return '全部站点'; if (!siteFilter) return '全部站点';
return siteOptions.find((option) => option.value === String(siteFilter))?.label || `站点 #${siteFilter}`; return siteOptions.find((option) => option.value === String(siteFilter))?.label || `站点 #${siteFilter}`;
}, [siteFilter, siteOptions]); }, [siteFilter, siteOptions]);
const siteIdByName = useMemo(() => {
const index = new Map<string, number>();
for (const site of sites) {
const siteName = String(site?.name || '').trim();
const siteId = Number(site?.id);
if (!siteName || !Number.isFinite(siteId) || siteId <= 0 || index.has(siteName)) continue;
index.set(siteName, Math.trunc(siteId));
}
return index;
}, [sites]);
const load = useCallback(async (silent = false) => { const load = useCallback(async (silent = false) => {
const seq = ++loadSeq.current; const seq = ++loadSeq.current;
@@ -608,7 +619,7 @@ export default function ProxyLogs() {
)} )}
> >
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} /> <MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
<MobileField label="站点" value={log.siteName || '-'} /> <MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
{downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null} {downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null}
<MobileField label="用时" value={formatLatency(log.latencyMs)} /> <MobileField label="用时" value={formatLatency(log.latencyMs)} />
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} /> <MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
@@ -709,7 +720,7 @@ export default function ProxyLogs() {
</div> </div>
</td> </td>
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}> <td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{log.siteName || '-'} <SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />
</td> </td>
<td> <td>
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}> <span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>