forked from boos/BoosAPI
Merge pull request #120 from cita-777/downstream-keys-ui-harmonize
Refine DownstreamKeys page and unify site badge links
This commit is contained in:
@@ -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"
|
||||||
|
```
|
||||||
@@ -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
@@ -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
@@ -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>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 => (
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user