forked from boos/BoosAPI
Unify site badge links across console pages
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+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>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -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