feat(ui): improve table controls and analytics filters

This commit is contained in:
CaIon
2026-04-30 13:57:10 +08:00
parent 8bff691089
commit 8f3c41ae77
35 changed files with 858 additions and 472 deletions
@@ -5,43 +5,51 @@ import { useTranslation } from 'react-i18next'
import type { TimeGranularity } from '@/lib/time'
import { VCHART_OPTION } from '@/lib/vchart'
import { useTheme } from '@/context/theme-provider'
import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants'
import {
CONSUMPTION_DISTRIBUTION_CHART_OPTIONS,
DEFAULT_TIME_GRANULARITY,
} from '@/features/dashboard/constants'
import { processChartData } from '@/features/dashboard/lib'
import type { QuotaDataItem } from '@/features/dashboard/types'
import type {
ConsumptionDistributionChartType,
QuotaDataItem,
} from '@/features/dashboard/types'
let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager']
> | null = null
type DistributionChartType = 'bar' | 'area'
interface ConsumptionDistributionChartProps {
data: QuotaDataItem[]
loading?: boolean
timeGranularity?: TimeGranularity
defaultChartType?: ConsumptionDistributionChartType
}
const CHART_TYPES: Array<{
value: DistributionChartType
labelKey: string
icon: typeof BarChart3
}> = [
{ value: 'bar', labelKey: 'Bar Chart', icon: BarChart3 },
{ value: 'area', labelKey: 'Area Chart', icon: AreaChart },
]
const CHART_TYPE_ICONS: Record<ConsumptionDistributionChartType, typeof BarChart3> =
{
bar: BarChart3,
area: AreaChart,
}
export function ConsumptionDistributionChart(
props: ConsumptionDistributionChartProps
) {
const { t } = useTranslation()
const { resolvedTheme } = useTheme()
const [chartType, setChartType] = useState<DistributionChartType>('bar')
const [chartType, setChartType] = useState<ConsumptionDistributionChartType>(
props.defaultChartType ?? 'bar'
)
const [themeReady, setThemeReady] = useState(false)
const themeManagerRef = useRef<
(typeof import('@visactor/vchart'))['ThemeManager'] | null
>(null)
const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY
useEffect(() => {
if (props.defaultChartType) setChartType(props.defaultChartType)
}, [props.defaultChartType])
useEffect(() => {
const updateTheme = async () => {
setThemeReady(false)
@@ -81,8 +89,8 @@ export function ConsumptionDistributionChart(
</div>
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
{CHART_TYPES.map((item) => {
const Icon = item.icon
{CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((item) => {
const Icon = CHART_TYPE_ICONS[item.value]
return (
<button
key={item.value}
@@ -5,47 +5,51 @@ import { useTranslation } from 'react-i18next'
import type { TimeGranularity } from '@/lib/time'
import { VCHART_OPTION } from '@/lib/vchart'
import { useTheme } from '@/context/theme-provider'
import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants'
import {
DEFAULT_TIME_GRANULARITY,
MODEL_ANALYTICS_CHART_OPTIONS,
} from '@/features/dashboard/constants'
import { processChartData } from '@/features/dashboard/lib'
import type { QuotaDataItem } from '@/features/dashboard/types'
import type {
ModelAnalyticsChartTab,
QuotaDataItem,
} from '@/features/dashboard/types'
let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager']
> | null = null
type ChartTab = 'trend' | 'proportion' | 'top'
type ChartSpecKey = 'spec_model_line' | 'spec_pie' | 'spec_rank_bar'
const CHART_TABS: {
value: ChartTab
labelKey: string
specKey: ChartSpecKey
}[] = [
{ value: 'trend', labelKey: 'Call Trend', specKey: 'spec_model_line' },
{
value: 'proportion',
labelKey: 'Call Count Distribution',
specKey: 'spec_pie',
},
{ value: 'top', labelKey: 'Call Count Ranking', specKey: 'spec_rank_bar' },
]
const CHART_SPEC_KEYS: Record<ModelAnalyticsChartTab, ChartSpecKey> = {
trend: 'spec_model_line',
proportion: 'spec_pie',
top: 'spec_rank_bar',
}
interface ModelChartsProps {
data: QuotaDataItem[]
loading?: boolean
timeGranularity?: TimeGranularity
defaultChartTab?: ModelAnalyticsChartTab
}
export function ModelCharts(props: ModelChartsProps) {
const { t } = useTranslation()
const { resolvedTheme } = useTheme()
const [activeTab, setActiveTab] = useState<ChartTab>('trend')
const [activeTab, setActiveTab] = useState<ModelAnalyticsChartTab>(
props.defaultChartTab ?? 'trend'
)
const [themeReady, setThemeReady] = useState(false)
const themeManagerRef = useRef<
(typeof import('@visactor/vchart'))['ThemeManager'] | null
>(null)
const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY
useEffect(() => {
if (props.defaultChartTab) setActiveTab(props.defaultChartTab)
}, [props.defaultChartTab])
useEffect(() => {
const updateTheme = async () => {
setThemeReady(false)
@@ -70,8 +74,7 @@ export function ModelCharts(props: ModelChartsProps) {
[props.data, props.loading, timeGranularity, t]
)
const activeSpec = CHART_TABS.find((tab) => tab.value === activeTab)
const spec = activeSpec ? chartData[activeSpec.specKey] : null
const spec = chartData[CHART_SPEC_KEYS[activeTab]]
return (
<div className='overflow-hidden rounded-lg border'>
@@ -87,7 +90,7 @@ export function ModelCharts(props: ModelChartsProps) {
</div>
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
{CHART_TABS.map((tab) => (
{MODEL_ANALYTICS_CHART_OPTIONS.map((tab) => (
<button
key={tab.value}
type='button'
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Filter, RotateCcw, Calendar, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
@@ -26,20 +26,20 @@ import {
} from '@/components/ui/select'
import { DateTimePicker } from '@/components/datetime-picker'
import {
DEFAULT_TIME_GRANULARITY,
TIME_GRANULARITY_OPTIONS,
TIME_RANGE_PRESETS,
EMPTY_DASHBOARD_FILTERS,
} from '@/features/dashboard/constants'
import {
buildDefaultDashboardFilters,
cleanFilters,
getSavedGranularity,
saveGranularity,
getDefaultDays,
} from '@/features/dashboard/lib'
import { type DashboardFilters } from '@/features/dashboard/types'
import type {
DashboardChartPreferences,
DashboardFilters,
} from '@/features/dashboard/types'
interface ModelsFilterProps {
preferences: DashboardChartPreferences
onFilterChange: (filters: DashboardFilters) => void
onReset: () => void
}
@@ -58,30 +58,27 @@ const SectionDivider = ({ label }: { label: string }) => (
</div>
)
export function ModelsFilter({ onFilterChange, onReset }: ModelsFilterProps) {
export function ModelsFilter(props: ModelsFilterProps) {
const { t } = useTranslation()
// 使用已缓存的用户数据,避免重复调用 API
const user = useAuthStore((state) => state.auth.user)
const isAdmin = user?.role && user.role >= 10
const [open, setOpen] = useState(false)
const [filters, setFilters] = useState<DashboardFilters>(() => {
const granularity = getSavedGranularity()
const days = getDefaultDays(granularity)
const { start, end } = getNormalizedDateRange(days)
return {
...EMPTY_DASHBOARD_FILTERS,
start_timestamp: start,
end_timestamp: end,
time_granularity: granularity,
}
})
const [filters, setFilters] = useState<DashboardFilters>(() =>
buildDefaultDashboardFilters(props.preferences)
)
const [selectedRange, setSelectedRange] = useState<number | null>(() =>
getDefaultDays()
props.preferences.defaultTimeRangeDays
)
useEffect(() => {
setFilters(buildDefaultDashboardFilters(props.preferences))
setSelectedRange(props.preferences.defaultTimeRangeDays)
}, [props.preferences])
const handleApply = () => {
onFilterChange(
props.onFilterChange(
cleanFilters(
filters as unknown as Record<string, unknown>
) as typeof filters
@@ -90,17 +87,15 @@ export function ModelsFilter({ onFilterChange, onReset }: ModelsFilterProps) {
}
const handleReset = () => {
const days = getDefaultDays(DEFAULT_TIME_GRANULARITY)
const days = props.preferences.defaultTimeRangeDays
const { start, end } = getNormalizedDateRange(days)
setFilters({
...EMPTY_DASHBOARD_FILTERS,
...buildDefaultDashboardFilters(props.preferences),
start_timestamp: start,
end_timestamp: end,
time_granularity: DEFAULT_TIME_GRANULARITY,
})
setSelectedRange(days)
saveGranularity(DEFAULT_TIME_GRANULARITY)
onReset()
props.onReset()
setOpen(false)
}
@@ -111,8 +106,6 @@ export function ModelsFilter({ onFilterChange, onReset }: ModelsFilterProps) {
setFilters((prev) => ({ ...prev, [field]: value }))
if (field === 'start_timestamp' || field === 'end_timestamp')
setSelectedRange(null)
if (field === 'time_granularity' && typeof value === 'string')
saveGranularity(value as TimeGranularity)
}
const handleQuickRange = (days: number) => {
+21 -1
View File
@@ -1,9 +1,18 @@
import type { DashboardFilters } from './types'
import type { DashboardChartPreferences, DashboardFilters } from './types'
export const TIME_GRANULARITY_STORAGE_KEY = 'data_export_default_time'
export const DASHBOARD_CHART_PREFERENCES_STORAGE_KEY =
'dashboard_models_chart_preferences'
export const DEFAULT_TIME_GRANULARITY = 'hour' as const
export const MAX_CHART_TREND_POINTS = 7
export const DEFAULT_DASHBOARD_CHART_PREFERENCES: DashboardChartPreferences = {
consumptionDistributionChart: 'bar',
modelAnalyticsChart: 'trend',
defaultTimeRangeDays: 1,
defaultTimeGranularity: DEFAULT_TIME_GRANULARITY,
}
export const TIME_RANGE_BY_GRANULARITY = {
hour: 1,
day: 7,
@@ -23,6 +32,17 @@ export const TIME_RANGE_PRESETS = [
{ label: '29 Days', days: 29 },
] as const
export const CONSUMPTION_DISTRIBUTION_CHART_OPTIONS = [
{ value: 'bar', labelKey: 'Bar Chart' },
{ value: 'area', labelKey: 'Area Chart' },
] as const
export const MODEL_ANALYTICS_CHART_OPTIONS = [
{ value: 'trend', labelKey: 'Call Trend' },
{ value: 'proportion', labelKey: 'Call Count Distribution' },
{ value: 'top', labelKey: 'Call Count Ranking' },
] as const
export const EMPTY_DASHBOARD_FILTERS: DashboardFilters = {
start_timestamp: undefined,
end_timestamp: undefined,
+66 -22
View File
@@ -11,6 +11,12 @@ import {
CardStaggerItem,
FadeIn,
} from '@/components/page-transition'
import {
buildDefaultDashboardFilters,
getSavedChartPreferences,
saveChartPreferences,
} from './lib'
import { ModelsChartPreferences } from './components/models/models-chart-preferences'
import { ModelsFilter } from './components/models/models-filter-dialog'
import { AnnouncementsPanel } from './components/overview/announcements-panel'
import { ApiInfoPanel } from './components/overview/api-info-panel'
@@ -23,7 +29,11 @@ import {
DASHBOARD_DEFAULT_SECTION,
DASHBOARD_SECTION_IDS,
} from './section-registry'
import { type DashboardFilters, type QuotaDataItem } from './types'
import {
type DashboardChartPreferences,
type DashboardFilters,
type QuotaDataItem,
} from './types'
const route = getRouteApi('/_authenticated/dashboard/$section')
@@ -107,17 +117,21 @@ export function Dashboard() {
const activeSection = (params.section ??
DASHBOARD_DEFAULT_SECTION) as DashboardSectionId
const [modelFilters, setModelFilters] = useState<DashboardFilters>({})
const [modelData, setModelData] = useState<QuotaDataItem[]>([])
const [dataLoading, setDataLoading] = useState(false)
const [chartPreferences, setChartPreferences] =
useState<DashboardChartPreferences>(() => getSavedChartPreferences())
const [modelFilters, setModelFilters] = useState<DashboardFilters>(() =>
buildDefaultDashboardFilters(getSavedChartPreferences())
)
const handleFilterChange = useCallback((filters: DashboardFilters) => {
setModelFilters(filters)
}, [])
const handleResetFilters = useCallback(() => {
setModelFilters({})
}, [])
setModelFilters(buildDefaultDashboardFilters(chartPreferences))
}, [chartPreferences])
const handleDataUpdate = useCallback(
(data: QuotaDataItem[], loading: boolean) => {
@@ -127,6 +141,15 @@ export function Dashboard() {
[]
)
const handleChartPreferencesChange = useCallback(
(preferences: DashboardChartPreferences) => {
setChartPreferences(preferences)
setModelFilters(buildDefaultDashboardFilters(preferences))
saveChartPreferences(preferences)
},
[]
)
const meta = SECTION_META[activeSection] ?? SECTION_META.overview
const isAdmin = Boolean(userRole && userRole >= ROLE.ADMIN)
const visibleSections = useMemo(
@@ -146,6 +169,20 @@ export function Dashboard() {
[navigate]
)
const showSectionTabs = activeSection !== 'overview' && visibleSections.length > 1
const modelActions =
activeSection === 'models' ? (
<>
<ModelsChartPreferences
preferences={chartPreferences}
onPreferencesChange={handleChartPreferencesChange}
/>
<ModelsFilter
preferences={chartPreferences}
onFilterChange={handleFilterChange}
onReset={handleResetFilters}
/>
</>
) : null
return (
<SectionPageLayout>
@@ -153,26 +190,29 @@ export function Dashboard() {
<SectionPageLayout.Description>
{t(meta.descriptionKey)}
</SectionPageLayout.Description>
{activeSection === 'models' && (
<SectionPageLayout.Actions>
<ModelsFilter
onFilterChange={handleFilterChange}
onReset={handleResetFilters}
/>
</SectionPageLayout.Actions>
)}
<SectionPageLayout.Content>
<div className='space-y-4'>
{showSectionTabs && (
<Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
{visibleSections.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{activeSection !== 'overview' && (
<div className='flex flex-wrap items-center justify-between gap-2'>
{showSectionTabs ? (
<Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
{visibleSections.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : (
<div />
)}
{modelActions != null && (
<div className='flex shrink-0 flex-wrap items-center gap-2'>
{modelActions}
</div>
)}
</div>
)}
{activeSection === 'overview' && (
<>
@@ -208,6 +248,9 @@ export function Dashboard() {
<LazyConsumptionDistributionChart
data={modelData}
loading={dataLoading}
defaultChartType={
chartPreferences.consumptionDistributionChart
}
timeGranularity={
modelFilters.time_granularity || DEFAULT_TIME_GRANULARITY
}
@@ -219,6 +262,7 @@ export function Dashboard() {
<LazyModelCharts
data={modelData}
loading={dataLoading}
defaultChartTab={chartPreferences.modelAnalyticsChart}
timeGranularity={
modelFilters.time_granularity || DEFAULT_TIME_GRANULARITY
}
+12 -2
View File
@@ -910,8 +910,18 @@ export function processUserChartData(
},
},
},
area: { style: { fillOpacity: 0.15 } },
line: { style: { lineWidth: 2 } },
area: {
style: {
fillOpacity: 0.15,
curveType: 'monotone',
},
},
line: {
style: {
lineWidth: 2,
curveType: 'monotone',
},
},
point: { visible: false },
color: { specified: userColorMap },
background: { fill: 'transparent' },
+102 -4
View File
@@ -1,9 +1,46 @@
import type { TimeGranularity } from '@/lib/time'
import { getNormalizedDateRange } from '@/lib/time'
import {
DASHBOARD_CHART_PREFERENCES_STORAGE_KEY,
DEFAULT_DASHBOARD_CHART_PREFERENCES,
DEFAULT_TIME_GRANULARITY,
EMPTY_DASHBOARD_FILTERS,
TIME_GRANULARITY_STORAGE_KEY,
TIME_RANGE_PRESETS,
TIME_RANGE_BY_GRANULARITY,
} from '@/features/dashboard/constants'
import type {
ConsumptionDistributionChartType,
DashboardChartPreferences,
DashboardFilters,
ModelAnalyticsChartTab,
} from '@/features/dashboard/types'
function isTimeGranularity(value: unknown): value is TimeGranularity {
return value === 'hour' || value === 'day' || value === 'week'
}
function getLegacySavedGranularity(): TimeGranularity {
if (typeof window === 'undefined') return DEFAULT_TIME_GRANULARITY
const saved = localStorage.getItem(TIME_GRANULARITY_STORAGE_KEY)
return isTimeGranularity(saved) ? saved : DEFAULT_TIME_GRANULARITY
}
function isConsumptionDistributionChartType(
value: unknown
): value is ConsumptionDistributionChartType {
return value === 'bar' || value === 'area'
}
function isModelAnalyticsChartTab(
value: unknown
): value is ModelAnalyticsChartTab {
return value === 'trend' || value === 'proportion' || value === 'top'
}
function isTimeRangePresetDays(value: unknown): value is number {
return TIME_RANGE_PRESETS.some((preset) => preset.days === value)
}
export function cleanFilters<T extends Record<string, unknown>>(
filters: T
@@ -25,20 +62,81 @@ export function getSavedGranularity(
override?: TimeGranularity
): TimeGranularity {
if (override) return override
if (typeof window === 'undefined') return DEFAULT_TIME_GRANULARITY
const saved = localStorage.getItem(TIME_GRANULARITY_STORAGE_KEY)
if (saved === 'hour' || saved === 'day' || saved === 'week') return saved
return DEFAULT_TIME_GRANULARITY
return getSavedChartPreferences().defaultTimeGranularity
}
export function saveGranularity(granularity: TimeGranularity): void {
if (typeof window === 'undefined') return
saveChartPreferences({
...getSavedChartPreferences(),
defaultTimeGranularity: granularity,
})
localStorage.setItem(TIME_GRANULARITY_STORAGE_KEY, granularity)
}
export function getSavedChartPreferences(): DashboardChartPreferences {
if (typeof window === 'undefined') return DEFAULT_DASHBOARD_CHART_PREFERENCES
const fallbackPreferences = {
...DEFAULT_DASHBOARD_CHART_PREFERENCES,
defaultTimeGranularity: getLegacySavedGranularity(),
}
try {
const raw = localStorage.getItem(DASHBOARD_CHART_PREFERENCES_STORAGE_KEY)
if (!raw) return fallbackPreferences
const parsed = JSON.parse(raw) as Partial<DashboardChartPreferences>
return {
consumptionDistributionChart: isConsumptionDistributionChartType(
parsed.consumptionDistributionChart
)
? parsed.consumptionDistributionChart
: fallbackPreferences.consumptionDistributionChart,
modelAnalyticsChart: isModelAnalyticsChartTab(parsed.modelAnalyticsChart)
? parsed.modelAnalyticsChart
: fallbackPreferences.modelAnalyticsChart,
defaultTimeRangeDays: isTimeRangePresetDays(parsed.defaultTimeRangeDays)
? parsed.defaultTimeRangeDays
: fallbackPreferences.defaultTimeRangeDays,
defaultTimeGranularity: isTimeGranularity(
parsed.defaultTimeGranularity
)
? parsed.defaultTimeGranularity
: fallbackPreferences.defaultTimeGranularity,
}
} catch {
return fallbackPreferences
}
}
export function saveChartPreferences(
preferences: DashboardChartPreferences
): void {
if (typeof window === 'undefined') return
localStorage.setItem(
DASHBOARD_CHART_PREFERENCES_STORAGE_KEY,
JSON.stringify(preferences)
)
}
export function getDefaultDays(granularity?: TimeGranularity): number {
if (!granularity) return getSavedChartPreferences().defaultTimeRangeDays
return TIME_RANGE_BY_GRANULARITY[getSavedGranularity(granularity)]
}
export function buildDefaultDashboardFilters(
preferences: DashboardChartPreferences = getSavedChartPreferences()
): DashboardFilters {
const { start, end } = getNormalizedDateRange(preferences.defaultTimeRangeDays)
return {
...EMPTY_DASHBOARD_FILTERS,
start_timestamp: start,
end_timestamp: end,
time_granularity: preferences.defaultTimeGranularity,
}
}
export function buildQueryParams(
timeRange: { start_timestamp: number; end_timestamp: number },
filters?: { time_granularity?: TimeGranularity; username?: string }
+3
View File
@@ -4,6 +4,9 @@ export {
getSavedGranularity,
saveGranularity,
getDefaultDays,
getSavedChartPreferences,
saveChartPreferences,
buildDefaultDashboardFilters,
} from './filters'
export {
getLatencyColorClass,
+11
View File
@@ -42,6 +42,17 @@ export interface DashboardFilters {
username?: string
}
export type ConsumptionDistributionChartType = 'bar' | 'area'
export type ModelAnalyticsChartTab = 'trend' | 'proportion' | 'top'
export interface DashboardChartPreferences {
consumptionDistributionChart: ConsumptionDistributionChartType
modelAnalyticsChart: ModelAnalyticsChartTab
defaultTimeRangeDays: number
defaultTimeGranularity: TimeGranularity
}
// ============================================================================
// API Info Types
// ============================================================================