1
0
forked from boos/BoosAPI

Unify site badge links across console pages

This commit is contained in:
ciat-777
2026-03-16 14:48:46 +08:00
parent bfb7ea180f
commit d9e33e71ed
5 changed files with 113 additions and 37 deletions
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
import { Link } from 'react-router-dom';
type SiteBadgeLinkProps = {
siteId?: number | null;
siteName?: string | null;
className?: string;
badgeClassName?: string;
badgeStyle?: React.CSSProperties;
};
export default function SiteBadgeLink({
siteId,
siteName,
className = 'badge-link',
badgeClassName = 'badge badge-muted',
badgeStyle,
}: SiteBadgeLinkProps) {
const label = String(siteName || '').trim() || '-';
const normalizedSiteId = Number(siteId);
if (!Number.isFinite(normalizedSiteId) || normalizedSiteId <= 0) {
return (
<span className={badgeClassName} style={badgeStyle}>
{label}
</span>
);
}
return (
<Link to={`/sites?focusSiteId=${Math.trunc(normalizedSiteId)}`} className={className}>
<span className={badgeClassName} style={badgeStyle}>
{label}
</span>
</Link>
);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import SiteBadgeLink from './SiteBadgeLink.js';
describe('SiteBadgeLink', () => {
it('renders a focus-navigation link when site id is valid', () => {
const root = create(
<MemoryRouter>
<SiteBadgeLink siteId={7} siteName="Demo Site" />
</MemoryRouter>,
);
const link = root.root.findByType('a');
expect(String(link.props.href || '')).toContain('/sites?focusSiteId=7');
expect(String(link.props.className || '')).toContain('badge-link');
expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Demo Site');
root.unmount();
});
it('falls back to plain badge text when site id is invalid', () => {
const root = create(
<MemoryRouter>
<SiteBadgeLink siteId={0} siteName="Unknown Site" />
</MemoryRouter>,
);
expect(root.root.findAllByType('a')).toHaveLength(0);
expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Unknown Site');
root.unmount();
});
});
+12 -31
View File
@@ -7,6 +7,7 @@ import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js'; import { MobileCard, MobileField } from '../components/MobileCard.js';
import { useIsMobile } from '../components/useIsMobile.js'; import { useIsMobile } from '../components/useIsMobile.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js'; import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { import {
buildAddAccountPrereqHint, buildAddAccountPrereqHint,
buildVerifyFailureHint, buildVerifyFailureHint,
@@ -1634,21 +1635,12 @@ export default function Accounts() {
<div className="mobile-card-extra"> <div className="mobile-card-extra">
<MobileField <MobileField
label="站点" label="站点"
value={a.site?.url ? ( value={(
<a <SiteBadgeLink
href={a.site.url} siteId={a.site?.id}
target="_blank" siteName={a.site?.name}
rel="noopener noreferrer" badgeStyle={{ fontSize: 11 }}
className="badge-link" />
>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
)} )}
/> />
<MobileField <MobileField
@@ -1803,22 +1795,11 @@ export default function Accounts() {
</div> </div>
</td> </td>
<td> <td>
{a.site?.url ? ( <SiteBadgeLink
<a siteId={a.site?.id}
href={a.site.url} siteName={a.site?.name}
target="_blank" badgeStyle={{ fontSize: 11 }}
rel="noopener noreferrer" />
className="badge-link"
>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
)}
</td> </td>
<td> <td>
{(() => { {(() => {
+17 -4
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js'; import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
@@ -168,6 +169,18 @@ export default function Models() {
const latestPrimaryRequestRef = useRef(0); const latestPrimaryRequestRef = useRef(0);
const latestMetadataRequestRef = useRef(0); const latestMetadataRequestRef = useRef(0);
const location = useLocation(); const location = useLocation();
const siteIdByName = useMemo(() => {
const index = new Map<string, number>();
for (const model of data.models) {
for (const source of model.pricingSources || []) {
const siteName = String(source.siteName || '').trim();
const siteId = Number(source.siteId);
if (!siteName || !Number.isFinite(siteId) || siteId <= 0 || index.has(siteName)) continue;
index.set(siteName, Math.trunc(siteId));
}
}
return index;
}, [data.models]);
const loadBaseMarketplace = useCallback(async (refresh = false) => { const loadBaseMarketplace = useCallback(async (refresh = false) => {
const requestId = ++latestPrimaryRequestRef.current; const requestId = ++latestPrimaryRequestRef.current;
@@ -688,7 +701,7 @@ export default function Models() {
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }} style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
> >
<div style={{ fontSize: 12, marginBottom: 6 }}> <div style={{ fontSize: 12, marginBottom: 6 }}>
<strong>{source.siteName}</strong> · {source.username || `ID:${source.accountId}`} <SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => ( {Object.entries(source.groupPricing).map(([group, pricing]) => (
@@ -719,7 +732,7 @@ export default function Models() {
<tbody> <tbody>
{m.accounts.map(a => ( {m.accounts.map(a => (
<tr key={a.id}> <tr key={a.id}>
<td><span className="badge badge-info">{a.site}</span></td> <td><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
<td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td> <td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td>
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}> <td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => ( {a.tokens.length > 0 ? a.tokens.map(t => (
@@ -849,7 +862,7 @@ export default function Models() {
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }} style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
> >
<div style={{ fontSize: 12, marginBottom: 6 }}> <div style={{ fontSize: 12, marginBottom: 6 }}>
<strong>{source.siteName}</strong> · {source.username || `ID:${source.accountId}`} <SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => ( {Object.entries(source.groupPricing).map(([group, pricing]) => (
@@ -878,7 +891,7 @@ export default function Models() {
<tbody> <tbody>
{m.accounts.map(a => ( {m.accounts.map(a => (
<tr key={a.id} style={{ borderTop: '1px solid var(--color-border-light)' }}> <tr key={a.id} style={{ borderTop: '1px solid var(--color-border-light)' }}>
<td style={{ padding: 8 }}>{a.site}</td> <td style={{ padding: 8 }}><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
<td style={{ padding: 8 }}>{a.username || `ID:${a.id}`}</td> <td style={{ padding: 8 }}>{a.username || `ID:${a.id}`}</td>
<td style={{ padding: 8, display: 'flex', gap: 4, flexWrap: 'wrap' }}> <td style={{ padding: 8, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => ( {a.tokens.length > 0 ? a.tokens.map(t => (
+13 -2
View File
@@ -10,6 +10,7 @@ import {
} from '../api.js'; } from '../api.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import { ModelBadge } from '../components/BrandIcon.js'; import { ModelBadge } from '../components/BrandIcon.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { MobileCard, MobileField } from '../components/MobileCard.js'; import { MobileCard, MobileField } from '../components/MobileCard.js';
import { MobileDrawer } from '../components/MobileDrawer.js'; import { MobileDrawer } from '../components/MobileDrawer.js';
import { useIsMobile } from '../components/useIsMobile.js'; import { useIsMobile } from '../components/useIsMobile.js';
@@ -329,6 +330,16 @@ export default function ProxyLogs() {
if (!siteFilter) return '全部站点'; if (!siteFilter) return '全部站点';
return siteOptions.find((option) => option.value === String(siteFilter))?.label || `站点 #${siteFilter}`; return siteOptions.find((option) => option.value === String(siteFilter))?.label || `站点 #${siteFilter}`;
}, [siteFilter, siteOptions]); }, [siteFilter, siteOptions]);
const siteIdByName = useMemo(() => {
const index = new Map<string, number>();
for (const site of sites) {
const siteName = String(site?.name || '').trim();
const siteId = Number(site?.id);
if (!siteName || !Number.isFinite(siteId) || siteId <= 0 || index.has(siteName)) continue;
index.set(siteName, Math.trunc(siteId));
}
return index;
}, [sites]);
const load = useCallback(async (silent = false) => { const load = useCallback(async (silent = false) => {
const seq = ++loadSeq.current; const seq = ++loadSeq.current;
@@ -608,7 +619,7 @@ export default function ProxyLogs() {
)} )}
> >
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} /> <MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
<MobileField label="站点" value={log.siteName || '-'} /> <MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
{downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null} {downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null}
<MobileField label="用时" value={formatLatency(log.latencyMs)} /> <MobileField label="用时" value={formatLatency(log.latencyMs)} />
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} /> <MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
@@ -709,7 +720,7 @@ export default function ProxyLogs() {
</div> </div>
</td> </td>
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}> <td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{log.siteName || '-'} <SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />
</td> </td>
<td> <td>
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}> <span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>