feat: enhance UI and functionality in various components

This commit is contained in:
CaIon
2026-04-28 18:38:02 +08:00
parent fc377dae3e
commit 28f7e9eb2e
40 changed files with 1567 additions and 470 deletions
+239 -90
View File
@@ -1,5 +1,5 @@
import { getChartColor } from '@/lib/colors'
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
import { dataScheme as vchartDefaultDataScheme } from '@visactor/vchart/esm/theme/color-scheme/builtin/default'
import { getCurrencyDisplay } from '@/lib/currency'
import { formatChartTime, type TimeGranularity } from '@/lib/time'
import { MAX_CHART_TREND_POINTS } from '@/features/dashboard/constants'
import type {
@@ -10,6 +10,38 @@ import type {
type TFunction = (key: string) => string
function getVChartDefaultColors(domainLength: number) {
const scheme =
vchartDefaultDataScheme.find(
(item) => !item.maxDomainLength || domainLength <= item.maxDomainLength
) ?? vchartDefaultDataScheme[vchartDefaultDataScheme.length - 1]
return scheme.scheme
}
function buildModelColorSpec(models: string[]) {
const domain = Array.from(new Set(models))
return {
type: 'ordinal',
domain,
range: getVChartDefaultColors(domain.length),
}
}
function renderQuotaCompat(rawQuota: number, digits = 4): string {
const { config, meta } = getCurrencyDisplay()
if (meta.kind === 'tokens') return rawQuota.toLocaleString()
const usd = rawQuota / config.quotaPerUnit
const rate = 'exchangeRate' in meta ? meta.exchangeRate : 1
const symbol = 'symbol' in meta ? meta.symbol : '$'
const value = usd * rate
const fixed = value.toFixed(digits)
if (parseFloat(fixed) === 0 && rawQuota > 0 && value > 0) {
return symbol + Math.pow(10, -digits).toFixed(digits)
}
return symbol + fixed
}
/**
* Process and aggregate chart data
*/
@@ -19,9 +51,61 @@ export function processChartData(
t?: TFunction
): ProcessedChartData {
const tt: TFunction = t ?? ((x) => x)
const otherLabel = tt('Other')
const formatInt = (value: number) =>
Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value)
const formatQuotaValue = (value: number) => renderQuotaCompat(value, 4)
const formatQuotaTotal = (value: number) => renderQuotaCompat(value, 2)
const MAX_TOOLTIP_MODELS = 15
const makeTooltipDimensionUpdateContent = () => {
return (
array: Array<{
key: string
value: string | number
datum?: Record<string, unknown>
}>
) => {
array.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0))
let sum = 0
for (let i = 0; i < array.length; i++) {
if (array[i].key === 'Other' || array[i].key === otherLabel) continue
const v = Number(array[i].value) || 0
if (
array[i].datum &&
(array[i].datum as Record<string, unknown>)?.TimeSum
) {
sum =
Number((array[i].datum as Record<string, unknown>)?.TimeSum) || sum
}
array[i].value = formatQuotaValue(v)
}
if (array.length > MAX_TOOLTIP_MODELS) {
const visible = array.slice(0, MAX_TOOLTIP_MODELS)
let otherSum = 0
for (let i = MAX_TOOLTIP_MODELS; i < array.length; i++) {
const raw = array[i].datum
? Number((array[i].datum as Record<string, unknown>)?.rawQuota) || 0
: 0
otherSum += raw
}
visible.push({
key: otherLabel,
value: formatQuotaValue(otherSum),
})
array = visible
}
array.unshift({
key: tt('Total:'),
value: formatQuotaValue(sum),
})
return array
}
}
if (!data || data.length === 0) {
return {
@@ -35,7 +119,7 @@ export function processChartData(
categoryField: 'type',
title: {
visible: true,
text: tt('Call Proportion'),
text: tt('Call Count Distribution'),
subtext: tt('No data available'),
},
legends: { visible: false },
@@ -54,15 +138,15 @@ export function processChartData(
seriesField: 'Model',
stack: true,
legends: { visible: true, selectMode: 'single' },
title: {
visible: true,
text: tt('Quota Distribution'),
subtext: `${tt('Total:')} ${formatQuotaWithCurrency(0, {
digitsLarge: 2,
digitsSmall: 2,
abbreviate: false,
})}`,
},
},
spec_area: {
type: 'area',
data: [{ id: 'areaData', values: [] }],
xField: 'Time',
yField: 'Usage',
seriesField: 'Model',
stack: true,
legends: { visible: true, selectMode: 'single' },
},
spec_model_line: {
type: 'line',
@@ -86,10 +170,11 @@ export function processChartData(
legends: { visible: true, selectMode: 'single' },
title: {
visible: true,
text: tt('Top Models'),
text: tt('Call Count Ranking'),
subtext: `${tt('Total:')} ${formatInt(0)}`,
},
},
totalQuotaDisplay: formatQuotaTotal(0),
}
}
@@ -142,6 +227,7 @@ export function processChartData(
const allModels = Array.from(modelTotalsMap.keys())
const sortedTimes = Array.from(timeModelMap.keys()).sort()
const sortedModels = [...allModels].sort()
const modelColor = buildModelColorSpec([...sortedModels, otherLabel])
// Pad time points if too few (default 7 points)
const MAX_TREND_POINTS = MAX_CHART_TREND_POINTS
@@ -166,14 +252,6 @@ export function processChartData(
}
const chartTimes = fillTimePoints(sortedTimes)
const modelColorMap = sortedModels.reduce<Record<string, string>>(
(acc, model, index) => {
acc[model] = getChartColor(index)
return acc
},
{}
)
const totalTimes = Array.from(modelTotalsMap.values()).reduce(
(sum, x) => sum + (Number(x.count) || 0),
0
@@ -223,14 +301,70 @@ export function processChartData(
})
lineValues.sort((a, b) => a.Time.localeCompare(b.Time))
// Line chart: model call trend
// Area chart: top models by quota + "Other" bucket (too many series = unreadable)
const MAX_AREA_MODELS = 15
const rankedQuotaModels = Array.from(modelTotalsMap.entries())
.map(([model, stats]) => ({
Model: model,
Quota: Number(stats.quota) || 0,
}))
.sort((a, b) => b.Quota - a.Quota)
const topAreaModels = new Set(
rankedQuotaModels.slice(0, MAX_AREA_MODELS).map((m) => m.Model)
)
const areaValues: typeof lineValues = []
chartTimes.forEach((time) => {
const buckets = new Map<string, { rawQuota: number; usage: number }>()
const modelMap = timeModelMap.get(time)
let timeSum = 0
sortedModels.forEach((model) => {
const stats = modelMap?.get(model)
const rawQuota = Number(stats?.quota) || 0
const usd = rawQuota ? rawQuota / quotaPerUnit : 0
const usage = usd ? Number(usd.toFixed(4)) : 0
timeSum += rawQuota
const key = topAreaModels.has(model) ? model : otherLabel
const prev = buckets.get(key) || { rawQuota: 0, usage: 0 }
buckets.set(key, {
rawQuota: prev.rawQuota + rawQuota,
usage: Number((prev.usage + usage).toFixed(4)),
})
})
for (const [model, vals] of buckets) {
areaValues.push({
Time: time,
Model: model,
rawQuota: vals.rawQuota,
Usage: vals.usage,
TimeSum: timeSum,
})
}
})
areaValues.sort((a, b) => a.Time.localeCompare(b.Time))
// Line chart: model call trend (top models + "Other" bucket)
const MAX_TREND_MODELS = 20
const rankedTrendModels = Array.from(modelTotalsMap.entries())
.map(([model, stats]) => ({
Model: model,
Count: Number(stats.count) || 0,
}))
.sort((a, b) => b.Count - a.Count)
const topTrendModels = rankedTrendModels
.slice(0, MAX_TREND_MODELS)
.map((item) => item.Model)
const otherTrendModels = rankedTrendModels
.slice(MAX_TREND_MODELS)
.map((item) => item.Model)
const modelLineValues: Array<{
Time: string
Model: string
Count: number
}> = []
chartTimes.forEach((time) => {
const timeData = sortedModels.map((model) => {
const timeData = topTrendModels.map((model) => {
const stats = timeModelMap.get(time)?.get(model)
return {
Time: time,
@@ -238,6 +372,17 @@ export function processChartData(
Count: Number(stats?.count) || 0,
}
})
if (otherTrendModels.length > 0) {
const otherCount = otherTrendModels.reduce((sum, model) => {
const stats = timeModelMap.get(time)?.get(model)
return sum + (Number(stats?.count) || 0)
}, 0)
timeData.push({
Time: time,
Model: otherLabel,
Count: otherCount,
})
}
modelLineValues.push(...timeData)
})
modelLineValues.sort((a, b) => a.Time.localeCompare(b.Time))
@@ -257,7 +402,7 @@ export function processChartData(
const otherCount = allRankValues
.slice(MAX_RANK_MODELS)
.reduce((sum, item) => sum + item.Count, 0)
rankValues = [...topModels, { Model: tt('Other'), Count: otherCount }]
rankValues = [...topModels, { Model: otherLabel, Count: otherCount }]
} else {
rankValues = allRankValues
}
@@ -280,11 +425,12 @@ export function processChartData(
},
title: {
visible: true,
text: tt('Call Proportion'),
text: tt('Call Count Distribution'),
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
},
legends: { visible: true, orient: 'left' },
label: { visible: true },
color: modelColor,
tooltip: {
mark: {
content: [
@@ -296,7 +442,6 @@ export function processChartData(
],
},
},
color: { specified: modelColorMap },
background: { fill: 'transparent' },
animation: true,
},
@@ -308,15 +453,7 @@ export function processChartData(
seriesField: 'Model',
stack: true,
legends: { visible: true, selectMode: 'single' },
title: {
visible: true,
text: tt('Quota Distribution'),
subtext: `${tt('Total:')} ${formatQuotaWithCurrency(totalQuotaRaw, {
digitsLarge: 2,
digitsSmall: 2,
abbreviate: false,
})}`,
},
color: modelColor,
bar: {
state: {
hover: { stroke: '#000', lineWidth: 1 },
@@ -328,11 +465,7 @@ export function processChartData(
{
key: (datum: Record<string, unknown>) => datum?.Model,
value: (datum: Record<string, unknown>) =>
formatQuotaWithCurrency(Number(datum?.rawQuota) || 0, {
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
}),
formatQuotaValue(Number(datum?.rawQuota) || 0),
},
],
},
@@ -344,58 +477,67 @@ export function processChartData(
Number(datum?.rawQuota) || 0,
},
],
updateContent: (
array: Array<{
key: string
value: string | number
datum?: Record<string, unknown>
}>
) => {
array.sort(
(a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)
)
let sum = 0
for (let i = 0; i < array.length; i++) {
if (array[i].key === 'Other') continue
const v = Number(array[i].value) || 0
if (
array[i].datum &&
(array[i].datum as Record<string, unknown>)?.TimeSum
) {
sum =
Number(
(array[i].datum as Record<string, unknown>)?.TimeSum
) || sum
}
array[i].value = formatQuotaWithCurrency(v, {
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
})
}
array.unshift({
key: tt('Total:'),
value: formatQuotaWithCurrency(sum, {
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
}),
})
return array
},
updateContent: makeTooltipDimensionUpdateContent(),
},
},
color: { specified: modelColorMap },
background: { fill: 'transparent' },
animation: true,
},
spec_area: {
type: 'area',
data: [{ id: 'areaData', values: areaValues }],
xField: 'Time',
yField: 'Usage',
seriesField: 'Model',
stack: false,
legends: { visible: true, selectMode: 'single' },
color: modelColor,
tooltip: {
mark: {
content: [
{
key: (datum: Record<string, unknown>) => datum?.Model,
value: (datum: Record<string, unknown>) =>
formatQuotaValue(Number(datum?.rawQuota) || 0),
},
],
},
dimension: {
content: [
{
key: (datum: Record<string, unknown>) => datum?.Model,
value: (datum: Record<string, unknown>) =>
Number(datum?.rawQuota) || 0,
},
],
updateContent: makeTooltipDimensionUpdateContent(),
},
},
area: {
style: {
fillOpacity: 0.08,
curveType: 'monotone',
},
},
line: {
style: {
lineWidth: 2,
curveType: 'monotone',
},
},
point: { visible: false },
background: { fill: 'transparent' },
animation: true,
},
spec_model_line: {
type: 'line',
type: 'area',
data: [{ id: 'lineData', values: modelLineValues }],
xField: 'Time',
yField: 'Count',
seriesField: 'Model',
stack: false,
legends: { visible: true, selectMode: 'single' },
color: modelColor,
title: {
visible: true,
text: tt('Call Trend'),
@@ -442,7 +584,18 @@ export function processChartData(
},
},
},
color: { specified: modelColorMap },
area: {
style: {
fillOpacity: 0.08,
curveType: 'monotone',
},
},
line: {
style: {
lineWidth: 2,
curveType: 'monotone',
},
},
point: { visible: false },
background: { fill: 'transparent' },
animation: true,
@@ -454,9 +607,10 @@ export function processChartData(
yField: 'Count',
seriesField: 'Model',
legends: { visible: true, selectMode: 'single' },
color: modelColor,
title: {
visible: true,
text: tt('Top Models'),
text: tt('Call Count Ranking'),
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
},
bar: {
@@ -475,10 +629,10 @@ export function processChartData(
],
},
},
color: { specified: modelColorMap },
background: { fill: 'transparent' },
animation: true,
},
totalQuotaDisplay: formatQuotaTotal(totalQuotaRaw),
}
}
@@ -505,12 +659,7 @@ export function processUserChartData(
const { config } = getCurrencyDisplay()
const quotaPerUnit = config.quotaPerUnit
const formatVal = (raw: number) =>
formatQuotaWithCurrency(raw, {
digitsLarge: 2,
digitsSmall: 2,
abbreviate: false,
})
const formatVal = (raw: number) => renderQuotaCompat(raw, 2)
const emptyResult: ProcessedUserChartData = {
spec_user_rank: {
+2 -4
View File
@@ -45,14 +45,12 @@ export function buildQueryParams(
): {
start_timestamp: number
end_timestamp: number
default_time?: string
default_time: string
username?: string
} {
return {
...timeRange,
...(filters?.time_granularity && {
default_time: filters.time_granularity,
}),
default_time: getSavedGranularity(filters?.time_granularity),
...(filters?.username && { username: filters.username }),
}
}