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:
@@ -74,7 +74,6 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'站点未开启签到': 'Check-in is not enabled on this site',
|
||||
'无需重试(非故障)': 'No retry needed (not a failure)',
|
||||
'该站点未提供签到端点,账号会被自动跳过。': 'This site does not expose a check-in endpoint, so the account is skipped automatically.',
|
||||
'婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}': '婵炴垶铸鎸哥粔颢综合极椤曗偓nan炴劖鎷呴悜榯cum殸濡ょ姷草涢崯鑳亹? ${site.platform}',
|
||||
'超时': 'time out',
|
||||
'成本权重': 'cost weight',
|
||||
'成功': 'success',
|
||||
|
||||
+62
-17
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user