Update BossAI workflow and workspace experience
This commit is contained in:
@@ -23,11 +23,11 @@ interface ModelAnalysisPanelProps {
|
||||
data?: ModelAnalysisData | null;
|
||||
}
|
||||
|
||||
const tabs: Array<{ key: TabKey; label: string; icon: string }> = [
|
||||
{ key: 'spend', label: '消耗分布', icon: '💰' },
|
||||
{ key: 'trend', label: '消耗趋势', icon: '📈' },
|
||||
{ key: 'calls', label: '调用分布', icon: '🔄' },
|
||||
{ key: 'rank', label: '排行榜', icon: '🏆' },
|
||||
const tabs: Array<{ key: TabKey; label: string; code: string }> = [
|
||||
{ key: 'spend', label: '消耗分布', code: 'SP' },
|
||||
{ key: 'trend', label: '消耗趋势', code: 'TR' },
|
||||
{ key: 'calls', label: '调用分布', code: 'CL' },
|
||||
{ key: 'rank', label: '排行榜', code: 'RK' },
|
||||
];
|
||||
|
||||
function toSafeNumber(value: unknown): number {
|
||||
@@ -46,6 +46,13 @@ function formatPercent(value: number): string {
|
||||
return `${toSafeNumber(value).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatPiePercent(_text: unknown, datum: unknown): string {
|
||||
const record = datum && typeof datum === 'object' ? datum as Record<string, unknown> : {};
|
||||
const percent = toSafeNumber(record._percent_);
|
||||
if (percent <= 0) return '';
|
||||
return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`;
|
||||
}
|
||||
|
||||
function EmptyBlock() {
|
||||
return (
|
||||
<div className="empty-state" style={{ padding: 28 }}>
|
||||
@@ -71,15 +78,16 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
const callRanking = (data?.callRanking || []).slice(0, 10);
|
||||
|
||||
const hasData = totals.calls > 0
|
||||
|| spendDistribution.length > 0
|
||||
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0);
|
||||
|| spendDistribution.some((item) => toSafeNumber(item.spend) > 0)
|
||||
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0)
|
||||
|| callsDistribution.some((item) => toSafeNumber(item.calls) > 0);
|
||||
|
||||
const spendBarSpec = useMemo(() => ({
|
||||
type: 'bar' as const,
|
||||
data: [{ id: 'data', values: spendDistribution.map(d => ({ model: d.model.length > 25 ? d.model.slice(0, 25) + '...' : d.model, value: toSafeNumber(d.spend) })).reverse() }],
|
||||
xField: 'value', yField: 'model', direction: 'horizontal' as const,
|
||||
bar: { style: { cornerRadius: [0, 6, 6, 0], fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 1, y1: 0, stops: [{ offset: 0, color: '#4f46e5' }, { offset: 1, color: '#818cf8' }] } } },
|
||||
label: { visible: true, position: 'right', formatter: '{value}', style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } },
|
||||
label: { visible: true, position: 'right', formatMethod: (text: unknown) => formatCurrency(Number(text || 0)), style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } },
|
||||
axes: [{ orient: 'left', label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'bottom', visible: false }],
|
||||
animation: true, background: 'transparent',
|
||||
}), [spendDistribution, labelColor]);
|
||||
@@ -102,7 +110,7 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
valueField: 'calls', categoryField: 'model',
|
||||
outerRadius: 0.8, innerRadius: 0.55,
|
||||
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
|
||||
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
|
||||
label: { visible: true, position: 'outside', formatMethod: formatPiePercent, style: { fill: labelColor, fontSize: 12, stroke: 'transparent' } },
|
||||
legends: { visible: false },
|
||||
animation: true,
|
||||
color: ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'],
|
||||
@@ -138,7 +146,8 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
<span className="pill-tab-code">{tab.code}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,13 @@ import { useThemeLabelColor } from '../useThemeLabelColor.js';
|
||||
|
||||
interface SiteDistributionData {
|
||||
siteName: string;
|
||||
platform: string;
|
||||
totalBalance: number;
|
||||
totalSpend: number;
|
||||
accountCount: number;
|
||||
platform?: string | null;
|
||||
totalBalance?: number;
|
||||
totalSpend?: number;
|
||||
accountCount?: number;
|
||||
spend?: number;
|
||||
calls?: number;
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
interface SiteDistributionChartProps {
|
||||
@@ -26,6 +29,13 @@ function safeNumber(value: unknown): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatPercentLabel(datum: unknown): string {
|
||||
const item = coerceDatumRecord(datum);
|
||||
const percent = safeNumber(item._percent_);
|
||||
if (percent <= 0) return '';
|
||||
return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`;
|
||||
}
|
||||
|
||||
function SkeletonCircle() {
|
||||
return (
|
||||
<div
|
||||
@@ -98,8 +108,11 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
return data.map((item) => ({
|
||||
siteName: item.siteName,
|
||||
platform: item.platform,
|
||||
value: safeNumber(viewMode === 'balance' ? item.totalBalance : item.totalSpend),
|
||||
value: safeNumber(viewMode === 'balance'
|
||||
? item.totalBalance
|
||||
: item.totalSpend ?? item.spend),
|
||||
accountCount: safeNumber(item.accountCount),
|
||||
calls: safeNumber(item.calls),
|
||||
}));
|
||||
}, [data, viewMode]);
|
||||
|
||||
@@ -118,7 +131,12 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
outerRadius: 0.8,
|
||||
innerRadius: 0.55,
|
||||
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
|
||||
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
|
||||
label: {
|
||||
visible: true,
|
||||
position: 'outside',
|
||||
formatMethod: (_text: unknown, datum: unknown) => formatPercentLabel(datum),
|
||||
style: { fill: labelColor, fontSize: 12, stroke: 'transparent' },
|
||||
},
|
||||
legends: { visible: false },
|
||||
tooltip: {
|
||||
mark: {
|
||||
@@ -149,6 +167,13 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
return String(item.accountCount || 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '调用',
|
||||
value: (datum: unknown) => {
|
||||
const item = coerceDatumRecord(datum);
|
||||
return String(item.calls || 0);
|
||||
},
|
||||
},
|
||||
] as any,
|
||||
},
|
||||
},
|
||||
@@ -159,6 +184,7 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
}, [chartData, hasData, labelColor]);
|
||||
|
||||
const formatValue = (value: number): string => {
|
||||
if (viewMode === 'balance' && value === 0) return '$0.00';
|
||||
if (value >= 1000) return `$${value.toFixed(2)}`;
|
||||
if (value >= 1) return `$${value.toFixed(3)}`;
|
||||
return `$${value.toFixed(6)}`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
import { useThemeLabelColor } from '../useThemeLabelColor.js';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
@@ -7,7 +8,10 @@ import { VChart } from '@visactor/react-vchart';
|
||||
|
||||
interface SiteTrendData {
|
||||
date: string;
|
||||
sites: Record<string, { spend: number; calls: number }>;
|
||||
sites?: Record<string, { spend?: number; calls?: number; tokens?: number }>;
|
||||
spend?: number;
|
||||
calls?: number;
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
interface SiteTrendChartProps {
|
||||
@@ -43,20 +47,26 @@ const COLOR_PALETTE = [
|
||||
|
||||
export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
||||
const [metric, setMetric] = useState<Metric>('spend');
|
||||
const labelColor = useThemeLabelColor();
|
||||
|
||||
/* ---------- data transform ---------- */
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
if (!data || data.length === 0) return [];
|
||||
return data.flatMap((d) =>
|
||||
Object.entries(d.sites).map(([site, v]) => ({
|
||||
return data.flatMap((d) => {
|
||||
const sites = d.sites && Object.keys(d.sites).length > 0
|
||||
? d.sites
|
||||
: { 全部站点: { spend: d.spend, calls: d.calls, tokens: d.tokens } };
|
||||
return Object.entries(sites).map(([site, v]) => ({
|
||||
date: d.date,
|
||||
site,
|
||||
value: metric === 'spend' ? v.spend : v.calls,
|
||||
})),
|
||||
);
|
||||
value: metric === 'spend' ? Number(v.spend ?? 0) : Number(v.calls ?? 0),
|
||||
}));
|
||||
});
|
||||
}, [data, metric]);
|
||||
|
||||
const hasPositiveValue = flatData.some((item) => Number(item.value || 0) > 0);
|
||||
|
||||
/* ---------- loading state ---------- */
|
||||
|
||||
if (loading) {
|
||||
@@ -72,7 +82,7 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
||||
|
||||
/* ---------- empty state ---------- */
|
||||
|
||||
if (!data || data.length === 0 || flatData.length === 0) {
|
||||
if (!data || data.length === 0 || flatData.length === 0 || !hasPositiveValue) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
@@ -80,7 +90,7 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
||||
</div>
|
||||
<div className="empty-state" style={{ padding: 48 }}>
|
||||
<div className="empty-state-title">暂无趋势数据</div>
|
||||
<div className="empty-state-desc">数据加载后将自动展示趋势图表</div>
|
||||
<div className="empty-state-desc">所选时间范围内还没有有效的{metric === 'spend' ? '消耗' : '调用'}记录</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -144,22 +154,38 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
|
||||
label: {
|
||||
style: { fontSize: 11, fill: labelColor },
|
||||
formatMethod: (value: string) => {
|
||||
const text = String(value || '');
|
||||
if (text.length >= 10) return text.slice(5, 10);
|
||||
return text;
|
||||
},
|
||||
},
|
||||
domainLine: { style: { stroke: 'var(--color-border-light)' } },
|
||||
tick: { style: { stroke: 'var(--color-border-light)' } },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
label: {
|
||||
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
|
||||
style: { fontSize: 11, fill: labelColor },
|
||||
formatMethod: (value: string) => {
|
||||
const n = Number(value || 0);
|
||||
if (!Number.isFinite(n)) return String(value || '');
|
||||
if (metric === 'spend') {
|
||||
if (n >= 1) return `$${n.toFixed(1)}`;
|
||||
return `$${n.toFixed(4)}`;
|
||||
}
|
||||
return Math.round(n).toLocaleString();
|
||||
},
|
||||
},
|
||||
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
|
||||
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [3, 5], lineWidth: 1 } },
|
||||
domainLine: { visible: false },
|
||||
},
|
||||
],
|
||||
color: COLOR_PALETTE,
|
||||
background: 'transparent',
|
||||
padding: { left: 8, right: 16, top: 8, bottom: 8 },
|
||||
padding: { left: 48, right: 18, top: 12, bottom: 36 },
|
||||
};
|
||||
|
||||
/* ---------- render ---------- */
|
||||
|
||||
Reference in New Issue
Block a user