feat: update error messages in NewApiAdapter tests to use correct Chinese translations

feat: enhance proxy usage parser to include cache read and creation tokens

feat: add billing details to ProxyLog interface and implement related formatting functions

fix: correct mojibake issues in source files and add regression test for mojibake detection

chore: add SQL migration for billing details in proxy_logs table

test: improve site speed button tests to ensure proper state updates without DOM lookups
This commit is contained in:
cita
2026-03-06 17:10:01 +08:00
parent 2a810facb8
commit 55b1ae564e
23 changed files with 1298 additions and 344 deletions
+62 -17
View File
@@ -29,6 +29,12 @@ function ChartFallback({ height = 280 }: { height?: number }) {
);
}
type SiteSpeedState =
| { status: 'loading' }
| { status: 'timeout' }
| { status: 'done'; ms: number }
| undefined;
export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminName?: string }) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
@@ -38,10 +44,17 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
const [siteTrend, setSiteTrend] = useState<any[]>([]);
const [siteLoading, setSiteLoading] = useState(true);
const [sites, setSites] = useState<any[]>([]);
const [siteSpeedStates, setSiteSpeedStates] = useState<Record<string, SiteSpeedState>>({});
const [trendDays, setTrendDays] = useState(7);
const toast = useToast();
const normalizedAdminName = (adminName || '').trim() || '\u7ba1\u7406\u5458';
const getSiteSpeedKey = (site: any, idx: number) => String(site?.id ?? idx);
const setSiteSpeedState = (siteKey: string, nextState: SiteSpeedState) => {
setSiteSpeedStates((current) => ({ ...current, [siteKey]: nextState }));
};
const load = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
else setRefreshing(true);
@@ -72,6 +85,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
setSiteTrend(trendRes.trend || []);
const siteRows = Array.isArray(sitesRes) ? sitesRes : (sitesRes?.sites || []);
setSites(siteRows.filter((site: any) => site?.status !== 'disabled'));
setSiteSpeedStates({});
} catch (err) {
console.error('Failed to load site stats:', err);
} finally {
@@ -196,8 +210,8 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
const proxy24hTotal = safeNumber(data?.proxy24h?.total);
const totalTokens = safeNumber(data?.proxy24h?.totalTokens);
const latencyDot = (ms: number) => {
const color = ms <= 500
const getLatencyColor = (ms: number) => (
ms <= 500
? 'var(--color-success)'
: ms <= 1000
? 'color-mix(in srgb, var(--color-success) 60%, var(--color-warning))'
@@ -207,8 +221,42 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
? 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))'
: ms < 3000
? 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))'
: 'var(--color-danger)';
return `<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${color};box-shadow:0 0 4px ${color};animation:pulse 1.5s ease-in-out infinite;margin-right:3px;vertical-align:middle"></span><span style="color:${color};font-weight:600">${ms}ms</span>`;
: 'var(--color-danger)'
);
const renderSiteSpeedLabel = (site: any, idx: number) => {
const siteKey = getSiteSpeedKey(site, idx);
const speedState = siteSpeedStates[siteKey];
if (!speedState || speedState.status === 'loading') {
return speedState ? '...' : '测速';
}
if (speedState.status === 'timeout') {
return '超时';
}
const ms = speedState.ms;
const color = getLatencyColor(ms);
return (
<>
<span
style={{
display: 'inline-block',
width: 6,
height: 6,
borderRadius: '50%',
background: color,
boxShadow: `0 0 4px ${color}`,
animation: 'pulse 1.5s ease-in-out infinite',
marginRight: 3,
verticalAlign: 'middle',
}}
/>
<span style={{ color, fontWeight: 600 }}>{ms}ms</span>
</>
);
};
return (
@@ -406,19 +454,16 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
className="btn btn-ghost"
style={{ fontSize: 11, padding: '3px 10px', border: '1px solid var(--color-border)', borderRadius: 6, display: 'inline-flex', alignItems: 'center', gap: 4 }}
onClick={async () => {
for (const s of sites) {
const el = document.getElementById(`speed-${s.id}`);
if (el) el.textContent = '...';
}
await Promise.all(sites.map(async (s: any) => {
const el = document.getElementById(`speed-${s.id}`);
await Promise.all(sites.map(async (s: any, idx: number) => {
const siteKey = getSiteSpeedKey(s, idx);
setSiteSpeedState(siteKey, { status: 'loading' });
try {
const start = performance.now();
await fetch(`${s.url}/v1/models`, { method: 'GET', mode: 'no-cors' });
const ms = Math.round(performance.now() - start);
if (el) el.innerHTML = latencyDot(ms);
setSiteSpeedState(siteKey, { status: 'done', ms });
} catch {
if (el) el.textContent = '超时';
setSiteSpeedState(siteKey, { status: 'timeout' });
}
}));
toast.success('全部测速完成');
@@ -439,22 +484,22 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 8px', border: '1px solid var(--color-border)', borderRadius: 6, display: 'inline-flex', alignItems: 'center', gap: 3 }}
onClick={async () => {
const btn = document.getElementById(`speed-${site.id || idx}`);
if (btn) btn.textContent = '...';
const siteKey = getSiteSpeedKey(site, idx);
setSiteSpeedState(siteKey, { status: 'loading' });
try {
const start = performance.now();
await fetch(`${site.url}/v1/models`, { method: 'GET', mode: 'no-cors' });
const ms = Math.round(performance.now() - start);
if (btn) btn.innerHTML = latencyDot(ms);
setSiteSpeedState(siteKey, { status: 'done', ms });
toast.success(`${site.name}: ${ms}ms`);
} catch {
if (btn) btn.textContent = '超时';
setSiteSpeedState(siteKey, { status: 'timeout' });
toast.error(`${site.name}: 测速失败`);
}
}}
>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span id={`speed-${site.id || idx}`}>测速</span>
<span>{renderSiteSpeedLabel(site, idx)}</span>
</button>
<a
href={site.url}
+128 -17
View File
@@ -26,6 +26,36 @@ interface ProxyLog {
promptTokens?: number;
completionTokens?: number;
estimatedCost?: number;
billingDetails?: {
quotaType: number;
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
billablePromptTokens: number;
promptTokensIncludeCache: boolean | null;
};
pricing: {
modelRatio: number;
completionRatio: number;
cacheRatio: number;
cacheCreationRatio: number;
groupRatio: number;
};
breakdown: {
inputPerMillion: number;
outputPerMillion: number;
cacheReadPerMillion: number;
cacheCreationPerMillion: number;
inputCost: number;
outputCost: number;
cacheReadCost: number;
cacheCreationCost: number;
totalCost: number;
};
} | null;
}
const PAGE_SIZES = [20, 50, 100];
@@ -52,6 +82,57 @@ function latencyBgColor(ms: number) {
return 'color-mix(in srgb, var(--color-success) 12%, transparent)';
}
function formatCompactNumber(value: number, digits = 6) {
if (!Number.isFinite(value)) return '0';
const formatted = value.toFixed(digits).replace(/\.?0+$/, '');
return formatted || '0';
}
function formatPerMillionPrice(value: number) {
return `$${formatCompactNumber(value)} / 1M tokens`;
}
function formatBillingDetailSummary(log: ProxyLog) {
const detail = log.billingDetails;
if (!detail) return null;
return `模型倍率 ${formatCompactNumber(detail.pricing.modelRatio)},输出倍率 ${formatCompactNumber(detail.pricing.completionRatio)},缓存倍率 ${formatCompactNumber(detail.pricing.cacheRatio)},缓存创建倍率 ${formatCompactNumber(detail.pricing.cacheCreationRatio)},分组倍率 ${formatCompactNumber(detail.pricing.groupRatio)}`;
}
function buildBillingProcessLines(log: ProxyLog) {
const detail = log.billingDetails;
if (!detail) return [];
const lines = [
`提示价格:${formatPerMillionPrice(detail.breakdown.inputPerMillion)}`,
`补全价格:${formatPerMillionPrice(detail.breakdown.outputPerMillion)}`,
];
if (detail.usage.cacheReadTokens > 0) {
lines.push(`缓存价格:${formatPerMillionPrice(detail.breakdown.cacheReadPerMillion)} (缓存倍率: ${formatCompactNumber(detail.pricing.cacheRatio)})`);
}
if (detail.usage.cacheCreationTokens > 0) {
lines.push(`缓存创建价格:${formatPerMillionPrice(detail.breakdown.cacheCreationPerMillion)} (缓存创建倍率: ${formatCompactNumber(detail.pricing.cacheCreationRatio)})`);
}
const parts = [
`提示 ${detail.usage.billablePromptTokens.toLocaleString()} tokens / 1M tokens * $${formatCompactNumber(detail.breakdown.inputPerMillion)}`,
];
if (detail.usage.cacheReadTokens > 0) {
parts.push(`缓存 ${detail.usage.cacheReadTokens.toLocaleString()} tokens / 1M tokens * $${formatCompactNumber(detail.breakdown.cacheReadPerMillion)}`);
}
if (detail.usage.cacheCreationTokens > 0) {
parts.push(`缓存创建 ${detail.usage.cacheCreationTokens.toLocaleString()} tokens / 1M tokens * $${formatCompactNumber(detail.breakdown.cacheCreationPerMillion)}`);
}
parts.push(`补全 ${detail.usage.completionTokens.toLocaleString()} tokens / 1M tokens * $${formatCompactNumber(detail.breakdown.outputPerMillion)} = $${detail.breakdown.totalCost.toFixed(6)}`);
lines.push(parts.join(' + '));
return lines;
}
export default function ProxyLogs() {
const [logs, setLogs] = useState<ProxyLog[]>([]);
const [loading, setLoading] = useState(true);
@@ -196,6 +277,8 @@ export default function ProxyLogs() {
<tbody>
{paged.map(log => {
const pathMeta = parseProxyLogPathMeta(log.errorMessage);
const billingDetailSummary = formatBillingDetailSummary(log);
const billingProcessLines = buildBillingProcessLines(log);
return (
<React.Fragment key={log.id}>
<tr
@@ -276,29 +359,57 @@ export default function ProxyLogs() {
{/* 日志详情 section */}
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>日志详情</span>
<span>
请求模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelRequested}</strong>
{log.modelActual && log.modelActual !== log.modelRequested && (
<> → 实际模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelActual}</strong></>
<div>
<div>
请求模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelRequested}</strong>
{log.modelActual && log.modelActual !== log.modelRequested && (
<> → 实际模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelActual}</strong></>
)}
,状态: <strong style={{ color: log.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)' }}>{log.status === 'success' ? '成功' : '失败'}</strong>
,用时 <strong style={{ color: latencyColor(log.latencyMs) }}>{formatLatency(log.latencyMs)}</strong>
,站点: <strong style={{ color: 'var(--color-text-primary)' }}>{log.siteName || '未知站点'}</strong>
,账号: <strong style={{ color: 'var(--color-text-primary)' }}>{log.username || '未知账号'}</strong>
</div>
{billingDetailSummary && (
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
)}
,状态: <strong style={{ color: log.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)' }}>{log.status === 'success' ? '成功' : '失败'}</strong>
,用时 <strong style={{ color: latencyColor(log.latencyMs) }}>{formatLatency(log.latencyMs)}</strong>
,站点: <strong style={{ color: 'var(--color-text-primary)' }}>{log.siteName || '未知站点'}</strong>
,账号: <strong style={{ color: 'var(--color-text-primary)' }}>{log.username || '未知账号'}</strong>
</span>
</div>
</div>
{log.billingDetails && log.billingDetails.usage.cacheReadTokens > 0 && (
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>缓存 Tokens</span>
<span>{log.billingDetails.usage.cacheReadTokens.toLocaleString()}</span>
</div>
)}
{log.billingDetails && log.billingDetails.usage.cacheCreationTokens > 0 && (
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>缓存创建 Tokens</span>
<span>{log.billingDetails.usage.cacheCreationTokens.toLocaleString()}</span>
</div>
)}
{/* 计费过程 section */}
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-info)', flexShrink: 0 }}>计费过程</span>
<span>
输入 {log.promptTokens?.toLocaleString() || 0} tokens
{' + '}输出 {log.completionTokens?.toLocaleString() || 0} tokens
{' = '}总计 {log.totalTokens?.toLocaleString() || 0} tokens
{typeof log.estimatedCost === 'number' && (
<>,预估费用 <strong style={{ color: 'var(--color-text-primary)' }}>${log.estimatedCost.toFixed(6)}</strong></>
)}
</span>
{billingProcessLines.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{billingProcessLines.map((line, index) => (
<span key={`${log.id}-billing-${index}`}>{line}</span>
))}
<span style={{ color: 'var(--color-text-muted)' }}>仅供参考,以实际扣费为准</span>
</div>
) : (
<span>
输入 {log.promptTokens?.toLocaleString() || 0} tokens
{' + '}输出 {log.completionTokens?.toLocaleString() || 0} tokens
{' = '}总计 {log.totalTokens?.toLocaleString() || 0} tokens
{typeof log.estimatedCost === 'number' && (
<>,预估费用 <strong style={{ color: 'var(--color-text-primary)' }}>${log.estimatedCost.toFixed(6)}</strong></>
)}
</span>
)}
</div>
{/* 下游请求路径 */}
@@ -48,6 +48,7 @@ describe('Accounts rebind panel focus', () => {
balanceUsed: 0,
todayReward: 0,
todaySpend: 0,
accessToken: 'session-token',
status: 'expired',
checkinEnabled: true,
siteId: 10,
@@ -84,7 +84,7 @@ describe('Dashboard site speed buttons', () => {
const speedButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('娴嬮€?')
&& collectText(node).trim() === '测速'
));
await act(async () => {
@@ -97,4 +97,34 @@ describe('Dashboard site speed buttons', () => {
root?.unmount();
}
});
it('updates bulk site speed status without imperative document lookups', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<Dashboard />
</ToastProvider>,
);
});
await flushMicrotasks();
const bulkSpeedButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '一键测速'
));
await act(async () => {
await bulkSpeedButton.props.onClick();
});
await flushMicrotasks();
expect(globalThis.document.getElementById).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
});