feat: 增强工作台趋势概览与聊天排行
- 支持用户活跃与智能体活跃趋势统计及自定义时间范围 - 增加用户活跃榜与智能体趋势数据结构及查询实现 - 同步补齐工作台页面展示与定向测试
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export type DashboardRange = '7d' | '30d' | 'today';
|
||||
export type DashboardRange = '7d' | '30d' | 'custom' | 'today';
|
||||
|
||||
export interface DashboardOverviewQuery {
|
||||
endDate?: string;
|
||||
range?: DashboardRange;
|
||||
startDate?: string;
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
activeUserTotal: number;
|
||||
activeAssistantTotal: number;
|
||||
botTotal: number;
|
||||
chatActiveUserTotal: number;
|
||||
chatMessageTotal: number;
|
||||
chatSessionTotal: number;
|
||||
knowledgeBaseTotal: number;
|
||||
@@ -30,10 +33,24 @@ export interface DashboardTrendItem {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DashboardAssistantTrendPoint {
|
||||
key: string;
|
||||
label: string;
|
||||
sessionTotal: number;
|
||||
}
|
||||
|
||||
export interface DashboardAssistantTrendSeries {
|
||||
assistantId?: number | string;
|
||||
label: string;
|
||||
points: DashboardAssistantTrendPoint[];
|
||||
totalSessionCount: number;
|
||||
}
|
||||
|
||||
export interface DashboardDistributionItem {
|
||||
activeUserTotal: number;
|
||||
assistantId?: number | string;
|
||||
avgMessagePerSession?: number;
|
||||
avgSessionPerUser?: number;
|
||||
botTotal: number;
|
||||
key: string;
|
||||
knowledgeBaseTotal: number;
|
||||
@@ -45,13 +62,23 @@ export interface DashboardDistributionItem {
|
||||
workflowTotal: number;
|
||||
}
|
||||
|
||||
export interface DashboardUserRankItem {
|
||||
assistantTotal: number;
|
||||
label: string;
|
||||
messageTotal: number;
|
||||
sessionTotal: number;
|
||||
userId?: number | string;
|
||||
}
|
||||
|
||||
export interface DashboardOverviewResponse {
|
||||
assistantTrends: DashboardAssistantTrendSeries[];
|
||||
chatStatus: DashboardChatStatus;
|
||||
distribution: DashboardDistributionItem[];
|
||||
query: DashboardOverviewQuery;
|
||||
summary: DashboardSummary;
|
||||
trends: DashboardTrendItem[];
|
||||
updatedAt: string;
|
||||
userRanks: DashboardUserRankItem[];
|
||||
}
|
||||
|
||||
export async function getDashboardOverview(params: DashboardOverviewQuery) {
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import type { EchartsUIType } from '@easyflow/plugins/echarts';
|
||||
|
||||
import type {
|
||||
DashboardAssistantTrendSeries,
|
||||
DashboardDistributionItem,
|
||||
DashboardOverviewQuery,
|
||||
DashboardOverviewResponse,
|
||||
DashboardRange,
|
||||
DashboardSummary,
|
||||
DashboardTrendItem,
|
||||
DashboardUserRankItem,
|
||||
} from '#/api/dashboard';
|
||||
|
||||
import {
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { AnalysisChartCard } from '@easyflow/common-ui';
|
||||
@@ -25,10 +28,26 @@ import { useUserStore } from '@easyflow/stores';
|
||||
import { convertToRgb } from '@easyflow/utils';
|
||||
|
||||
import { RefreshRight } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElEmpty, ElRadioButton, ElRadioGroup } from 'element-plus';
|
||||
import {
|
||||
ElButton,
|
||||
ElDatePicker,
|
||||
ElEmpty,
|
||||
ElPopover,
|
||||
ElRadioButton,
|
||||
ElRadioGroup,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getDashboardOverview } from '#/api/dashboard';
|
||||
|
||||
type DashboardTrendMode = 'assistantActive' | 'usage' | 'userActive';
|
||||
|
||||
interface AssistantTrendSelectionItem {
|
||||
assistantKey: string;
|
||||
color: string;
|
||||
isSelected: boolean;
|
||||
series: DashboardAssistantTrendSeries;
|
||||
}
|
||||
|
||||
interface SummaryCardItem {
|
||||
available?: boolean;
|
||||
label: string;
|
||||
@@ -39,16 +58,23 @@ let greetingTimer: null | ReturnType<typeof setInterval> = null;
|
||||
const userStore = useUserStore();
|
||||
const now = ref(new Date());
|
||||
|
||||
const filters = reactive<Required<DashboardOverviewQuery>>({
|
||||
const filters = reactive<DashboardOverviewQuery>({
|
||||
range: '7d',
|
||||
});
|
||||
const customDateRange = ref<string[]>([]);
|
||||
|
||||
const overview = ref<DashboardOverviewResponse | null>(null);
|
||||
const isLoading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const trendMode = ref<DashboardTrendMode>('usage');
|
||||
const selectedAssistantTrendKeys = ref<string[]>([]);
|
||||
|
||||
const trendChartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts: renderTrendEcharts } = useEcharts(trendChartRef);
|
||||
const {
|
||||
getChartInstance: getTrendChartInstance,
|
||||
renderEcharts: renderTrendEcharts,
|
||||
resize: resizeTrendChart,
|
||||
} = useEcharts(trendChartRef);
|
||||
|
||||
const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
|
||||
{ label: '今日', value: 'today' },
|
||||
@@ -56,10 +82,17 @@ const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
|
||||
{ label: '近 30 天', value: '30d' },
|
||||
];
|
||||
|
||||
const trendModeOptions: Array<{ label: string; value: DashboardTrendMode }> = [
|
||||
{ label: '使用量趋势', value: 'usage' },
|
||||
{ label: '用户活跃', value: 'userActive' },
|
||||
{ label: '智能体活跃', value: 'assistantActive' },
|
||||
];
|
||||
|
||||
const emptySummary: DashboardSummary = {
|
||||
activeAssistantTotal: 0,
|
||||
activeUserTotal: 0,
|
||||
botTotal: 0,
|
||||
chatActiveUserTotal: 0,
|
||||
chatMessageTotal: 0,
|
||||
chatSessionTotal: 0,
|
||||
knowledgeBaseTotal: 0,
|
||||
@@ -68,36 +101,67 @@ const emptySummary: DashboardSummary = {
|
||||
};
|
||||
|
||||
const summary = computed(() => overview.value?.summary ?? emptySummary);
|
||||
const trends = computed<DashboardTrendItem[]>(() => overview.value?.trends ?? []);
|
||||
const trends = computed<DashboardTrendItem[]>(
|
||||
() => overview.value?.trends ?? [],
|
||||
);
|
||||
const assistantTrends = computed<DashboardAssistantTrendSeries[]>(
|
||||
() => overview.value?.assistantTrends ?? [],
|
||||
);
|
||||
const distribution = computed<DashboardDistributionItem[]>(
|
||||
() => overview.value?.distribution ?? [],
|
||||
);
|
||||
const chatAvailable = computed(() => overview.value?.chatStatus?.available !== false);
|
||||
const userRanks = computed<DashboardUserRankItem[]>(
|
||||
() => overview.value?.userRanks ?? [],
|
||||
);
|
||||
const chatAvailable = computed(
|
||||
() => overview.value?.chatStatus?.available !== false,
|
||||
);
|
||||
const chatStatusMessage = computed(
|
||||
() => overview.value?.chatStatus?.message || '聊天数据不可用',
|
||||
);
|
||||
const isUsageTrend = computed(() => trendMode.value === 'usage');
|
||||
const isUserActiveTrend = computed(() => trendMode.value === 'userActive');
|
||||
const isAssistantActiveTrend = computed(
|
||||
() => trendMode.value === 'assistantActive',
|
||||
);
|
||||
|
||||
const summaryCards = computed<SummaryCardItem[]>(() => [
|
||||
{ label: '助手数量', value: formatCount(summary.value.botTotal) },
|
||||
{ label: '工作流数量', value: formatCount(summary.value.workflowTotal) },
|
||||
{
|
||||
label: '知识库数量',
|
||||
value: formatCount(summary.value.knowledgeBaseTotal),
|
||||
available: chatAvailable.value,
|
||||
label: 'AI活跃用户',
|
||||
value: formatOptionalCount(
|
||||
summary.value.chatActiveUserTotal,
|
||||
chatAvailable.value,
|
||||
),
|
||||
},
|
||||
{
|
||||
available: chatAvailable.value,
|
||||
label: '聊天消息总数',
|
||||
value: formatOptionalCount(summary.value.chatMessageTotal, chatAvailable.value),
|
||||
label: '活跃智能体',
|
||||
value: formatOptionalCount(
|
||||
summary.value.activeAssistantTotal,
|
||||
chatAvailable.value,
|
||||
),
|
||||
},
|
||||
{
|
||||
available: chatAvailable.value,
|
||||
label: '聊天会话总数',
|
||||
value: formatOptionalCount(summary.value.chatSessionTotal, chatAvailable.value),
|
||||
value: formatOptionalCount(
|
||||
summary.value.chatSessionTotal,
|
||||
chatAvailable.value,
|
||||
),
|
||||
},
|
||||
{
|
||||
available: chatAvailable.value,
|
||||
label: '活跃智能体数',
|
||||
value: formatOptionalCount(summary.value.activeAssistantTotal, chatAvailable.value),
|
||||
label: '聊天消息总数',
|
||||
value: formatOptionalCount(
|
||||
summary.value.chatMessageTotal,
|
||||
chatAvailable.value,
|
||||
),
|
||||
},
|
||||
{ label: '智能体总数', value: formatCount(summary.value.botTotal) },
|
||||
{
|
||||
label: '知识库总数',
|
||||
value: formatCount(summary.value.knowledgeBaseTotal),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -134,15 +198,99 @@ const greetingTitle = computed(
|
||||
() => `${greetingText.value},${displayName.value}`,
|
||||
);
|
||||
|
||||
const trendDescription = computed(() => {
|
||||
if (isUsageTrend.value) {
|
||||
return '消息与会话趋势';
|
||||
}
|
||||
if (isUserActiveTrend.value) {
|
||||
return '用户活跃趋势';
|
||||
}
|
||||
return '智能体活跃趋势';
|
||||
});
|
||||
|
||||
const selectedAssistantTrendSet = computed(
|
||||
() => new Set(selectedAssistantTrendKeys.value),
|
||||
);
|
||||
|
||||
const assistantTrendColors = computed(() =>
|
||||
buildAssistantTrendPalette(assistantTrends.value.length),
|
||||
);
|
||||
|
||||
const assistantTrendSelectionItems = computed<AssistantTrendSelectionItem[]>(
|
||||
() =>
|
||||
assistantTrends.value.map((series, index) => {
|
||||
const assistantKey = getAssistantTrendKey(
|
||||
series.assistantId,
|
||||
series.label,
|
||||
);
|
||||
return {
|
||||
assistantKey,
|
||||
color:
|
||||
assistantTrendColors.value[index] || getChartTokenColor('--primary'),
|
||||
isSelected: selectedAssistantTrendSet.value.has(assistantKey),
|
||||
series,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const selectedAssistantTrends = computed<AssistantTrendSelectionItem[]>(() =>
|
||||
assistantTrendSelectionItems.value.filter((item) => item.isSelected),
|
||||
);
|
||||
|
||||
const selectedAssistantTrendCount = computed(
|
||||
() => selectedAssistantTrends.value.length,
|
||||
);
|
||||
|
||||
const selectedAssistantTrendPreviewItems = computed(() =>
|
||||
selectedAssistantTrends.value.slice(0, 3),
|
||||
);
|
||||
|
||||
const assistantTrendSelectorLabel = computed(() => {
|
||||
const total = assistantTrendSelectionItems.value.length;
|
||||
if (total === 0) {
|
||||
return '无可选智能体';
|
||||
}
|
||||
return `${selectedAssistantTrendCount.value}/${total} 已选`;
|
||||
});
|
||||
|
||||
const assistantTrendSelectorSummary = computed(() => {
|
||||
const count = selectedAssistantTrendCount.value;
|
||||
if (count === 0) {
|
||||
return '未选择智能体';
|
||||
}
|
||||
if (count === 1) {
|
||||
return selectedAssistantTrends.value[0]?.series.label || '已选择 1 个';
|
||||
}
|
||||
return `${selectedAssistantTrends.value[0]?.series.label || '已选择'} 等 ${count} 个`;
|
||||
});
|
||||
|
||||
const showAssistantTrendNoData = computed(
|
||||
() =>
|
||||
isAssistantActiveTrend.value &&
|
||||
assistantTrendSelectionItems.value.length === 0,
|
||||
);
|
||||
|
||||
const showAssistantTrendEmptySelection = computed(
|
||||
() =>
|
||||
isAssistantActiveTrend.value &&
|
||||
assistantTrendSelectionItems.value.length > 0 &&
|
||||
selectedAssistantTrends.value.length === 0,
|
||||
);
|
||||
|
||||
const showTrendChart = computed(() => chatAvailable.value);
|
||||
|
||||
async function loadOverview() {
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const data = await getDashboardOverview({
|
||||
endDate: filters.range === 'custom' ? filters.endDate : undefined,
|
||||
range: filters.range,
|
||||
startDate: filters.range === 'custom' ? filters.startDate : undefined,
|
||||
});
|
||||
overview.value = data;
|
||||
resetAssistantTrendSelection();
|
||||
await renderCharts();
|
||||
} catch (error) {
|
||||
overview.value = null;
|
||||
@@ -155,24 +303,197 @@ async function loadOverview() {
|
||||
|
||||
async function renderCharts() {
|
||||
await nextTick();
|
||||
if (!chatAvailable.value) {
|
||||
if (!showTrendChart.value) {
|
||||
return;
|
||||
}
|
||||
renderTrendChart();
|
||||
}
|
||||
|
||||
function renderTrendChart() {
|
||||
const xAxisData = trends.value.map((item) => item.label);
|
||||
const messageData = trends.value.map((item) => item.chatMessageTotal);
|
||||
const sessionData = trends.value.map((item) => item.chatSessionTotal);
|
||||
const primaryColor = getChartTokenColor('--primary');
|
||||
const successColor = getChartTokenColor('--success');
|
||||
const warningColor = getChartTokenColor('--warning');
|
||||
const destructiveColor = getChartTokenColor('--destructive');
|
||||
const axisColor = getChartTokenColor('--border');
|
||||
const tooltipLineColor = getChartTokenColor('--accent');
|
||||
const textColor = getChartTokenColor('--foreground');
|
||||
|
||||
if (isAssistantActiveTrend.value) {
|
||||
const xAxisData =
|
||||
assistantTrendSelectionItems.value[0]?.series.points.map(
|
||||
(point) => point.label,
|
||||
) ?? [];
|
||||
const assistantSeries = assistantTrendSelectionItems.value.map((item) => ({
|
||||
data: item.series.points.map((point) => point.sessionTotal),
|
||||
emphasis: {
|
||||
focus: 'series' as const,
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: item.color,
|
||||
borderWidth: 2,
|
||||
color: '#ffffff',
|
||||
},
|
||||
lineStyle: {
|
||||
color: item.color,
|
||||
width: 3,
|
||||
},
|
||||
name: item.series.label,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
type: 'line' as const,
|
||||
}));
|
||||
|
||||
renderTrendEcharts({
|
||||
color: assistantTrendSelectionItems.value.map((item) => item.color),
|
||||
grid: {
|
||||
bottom: 18,
|
||||
containLabel: true,
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 12,
|
||||
},
|
||||
legend: {
|
||||
data: assistantTrendSelectionItems.value.map(
|
||||
(item) => item.series.label,
|
||||
),
|
||||
selected: buildAssistantTrendSelectedMap(),
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: tooltipLineColor,
|
||||
width: 1,
|
||||
},
|
||||
type: 'line',
|
||||
},
|
||||
trigger: 'axis',
|
||||
},
|
||||
xAxis: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: axisColor,
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
boundaryGap: false,
|
||||
data: xAxisData,
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: axisColor,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
type: 'value',
|
||||
},
|
||||
series: assistantSeries,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const xAxisData = trends.value.map((item) => item.label);
|
||||
const messageData = trends.value.map((item) => item.chatMessageTotal);
|
||||
const sessionData = trends.value.map((item) => item.chatSessionTotal);
|
||||
const activeUserData = trends.value.map((item) => item.activeUserTotal);
|
||||
|
||||
const usageSeries = [
|
||||
{
|
||||
data: messageData,
|
||||
emphasis: {
|
||||
focus: 'none' as const,
|
||||
itemStyle: {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 3,
|
||||
color: primaryColor,
|
||||
},
|
||||
scale: true,
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: primaryColor,
|
||||
borderWidth: 2,
|
||||
color: '#ffffff',
|
||||
},
|
||||
lineStyle: {
|
||||
color: primaryColor,
|
||||
width: 3,
|
||||
},
|
||||
name: '消息数',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 9,
|
||||
type: 'line' as const,
|
||||
},
|
||||
{
|
||||
data: sessionData,
|
||||
emphasis: {
|
||||
focus: 'none' as const,
|
||||
itemStyle: {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 3,
|
||||
color: successColor,
|
||||
},
|
||||
scale: true,
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: successColor,
|
||||
borderWidth: 2,
|
||||
color: '#ffffff',
|
||||
},
|
||||
lineStyle: {
|
||||
color: successColor,
|
||||
width: 3,
|
||||
},
|
||||
name: '会话数',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 9,
|
||||
type: 'line' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const activeSeries = [
|
||||
{
|
||||
data: activeUserData,
|
||||
emphasis: {
|
||||
focus: 'none' as const,
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: primaryColor,
|
||||
borderWidth: 2,
|
||||
color: '#ffffff',
|
||||
},
|
||||
lineStyle: {
|
||||
color: primaryColor,
|
||||
width: 3,
|
||||
},
|
||||
name: '活跃用户数',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 9,
|
||||
type: 'line' as const,
|
||||
},
|
||||
];
|
||||
|
||||
renderTrendEcharts({
|
||||
color: [primaryColor, successColor],
|
||||
color: isUsageTrend.value
|
||||
? [primaryColor, successColor]
|
||||
: [primaryColor, warningColor, destructiveColor],
|
||||
grid: {
|
||||
bottom: 18,
|
||||
containLabel: true,
|
||||
@@ -231,62 +552,7 @@ function renderTrendChart() {
|
||||
},
|
||||
type: 'value',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: messageData,
|
||||
emphasis: {
|
||||
focus: 'none',
|
||||
itemStyle: {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 3,
|
||||
color: primaryColor,
|
||||
},
|
||||
scale: true,
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: primaryColor,
|
||||
borderWidth: 2,
|
||||
color: '#ffffff',
|
||||
},
|
||||
lineStyle: {
|
||||
color: primaryColor,
|
||||
width: 3,
|
||||
},
|
||||
name: '消息数',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 9,
|
||||
type: 'line',
|
||||
},
|
||||
{
|
||||
data: sessionData,
|
||||
emphasis: {
|
||||
focus: 'none',
|
||||
itemStyle: {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 3,
|
||||
color: successColor,
|
||||
},
|
||||
scale: true,
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: successColor,
|
||||
borderWidth: 2,
|
||||
color: '#ffffff',
|
||||
},
|
||||
lineStyle: {
|
||||
color: successColor,
|
||||
width: 3,
|
||||
},
|
||||
name: '会话数',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 9,
|
||||
type: 'line',
|
||||
},
|
||||
],
|
||||
series: isUsageTrend.value ? usageSeries : activeSeries,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -295,9 +561,130 @@ function handleRangeChange(value: boolean | number | string | undefined) {
|
||||
return;
|
||||
}
|
||||
filters.range = value;
|
||||
customDateRange.value = [];
|
||||
filters.startDate = undefined;
|
||||
filters.endDate = undefined;
|
||||
void loadOverview();
|
||||
}
|
||||
|
||||
function handleCustomRangeChange(value: string[] | undefined) {
|
||||
if (!value || value.length !== 2 || !value[0] || !value[1]) {
|
||||
customDateRange.value = [];
|
||||
filters.range = '7d';
|
||||
filters.startDate = undefined;
|
||||
filters.endDate = undefined;
|
||||
void loadOverview();
|
||||
return;
|
||||
}
|
||||
filters.range = 'custom';
|
||||
filters.startDate = value[0];
|
||||
filters.endDate = value[1];
|
||||
void loadOverview();
|
||||
}
|
||||
|
||||
function handleTrendModeChange(value: boolean | number | string | undefined) {
|
||||
if (
|
||||
value !== 'usage' &&
|
||||
value !== 'userActive' &&
|
||||
value !== 'assistantActive'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
trendMode.value = value;
|
||||
}
|
||||
|
||||
function resetAssistantTrendSelection() {
|
||||
selectedAssistantTrendKeys.value = assistantTrends.value.map((series) =>
|
||||
getAssistantTrendKey(series.assistantId, series.label),
|
||||
);
|
||||
}
|
||||
|
||||
function toggleAssistantTrend(assistantKey: string) {
|
||||
const currentItem = assistantTrendSelectionItems.value.find(
|
||||
(item) => item.assistantKey === assistantKey,
|
||||
);
|
||||
if (!currentItem) {
|
||||
return;
|
||||
}
|
||||
const selectedKeys = new Set(selectedAssistantTrendKeys.value);
|
||||
let actionType: 'legendSelect' | 'legendUnSelect' = 'legendSelect';
|
||||
if (selectedKeys.has(assistantKey)) {
|
||||
selectedKeys.delete(assistantKey);
|
||||
actionType = 'legendUnSelect';
|
||||
} else {
|
||||
selectedKeys.add(assistantKey);
|
||||
}
|
||||
selectedAssistantTrendKeys.value = [...selectedKeys];
|
||||
if (!isAssistantActiveTrend.value) {
|
||||
return;
|
||||
}
|
||||
void nextTick(() => {
|
||||
const chartInstance = getTrendChartInstance();
|
||||
if (!chartInstance) {
|
||||
return;
|
||||
}
|
||||
chartInstance.dispatchAction({
|
||||
name: currentItem.series.label,
|
||||
type: actionType,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function selectAllAssistantTrends() {
|
||||
selectedAssistantTrendKeys.value = assistantTrendSelectionItems.value.map(
|
||||
(item) => item.assistantKey,
|
||||
);
|
||||
if (!isAssistantActiveTrend.value) {
|
||||
return;
|
||||
}
|
||||
void nextTick(() => {
|
||||
renderTrendChart();
|
||||
resizeTrendChart();
|
||||
});
|
||||
}
|
||||
|
||||
function clearAssistantTrendSelection() {
|
||||
const previousItems = [...selectedAssistantTrends.value];
|
||||
selectedAssistantTrendKeys.value = [];
|
||||
if (!isAssistantActiveTrend.value) {
|
||||
return;
|
||||
}
|
||||
void nextTick(() => {
|
||||
const chartInstance = getTrendChartInstance();
|
||||
if (!chartInstance) {
|
||||
return;
|
||||
}
|
||||
for (const item of previousItems) {
|
||||
chartInstance.dispatchAction({
|
||||
name: item.series.label,
|
||||
type: 'legendUnSelect',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildAssistantTrendSelectedMap() {
|
||||
return Object.fromEntries(
|
||||
assistantTrendSelectionItems.value.map((item) => [
|
||||
item.series.label,
|
||||
item.isSelected,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
[trendMode, chatAvailable, trends, assistantTrends],
|
||||
() => {
|
||||
if (!showTrendChart.value) {
|
||||
return;
|
||||
}
|
||||
void renderCharts();
|
||||
},
|
||||
{
|
||||
flush: 'post',
|
||||
},
|
||||
);
|
||||
|
||||
function formatCount(value?: number) {
|
||||
return Number(value || 0).toLocaleString('zh-CN');
|
||||
}
|
||||
@@ -307,13 +694,54 @@ function formatOptionalCount(value: number | undefined, available: boolean) {
|
||||
}
|
||||
|
||||
function getChartTokenColor(variableName: string) {
|
||||
return convertToRgb(`hsl(${getChartTokenHsl(variableName)})`);
|
||||
}
|
||||
|
||||
function getChartTokenHsl(variableName: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return '#3b82f6';
|
||||
return '211 100% 50%';
|
||||
}
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(variableName)
|
||||
.trim();
|
||||
return value ? convertToRgb(`hsl(${value})`) : '#3b82f6';
|
||||
return value || '211 100% 50%';
|
||||
}
|
||||
|
||||
function buildAssistantTrendPalette(total: number) {
|
||||
const palette = [
|
||||
getChartTokenColor('--primary'),
|
||||
getChartTokenColor('--success'),
|
||||
getChartTokenColor('--warning'),
|
||||
getChartTokenColor('--destructive'),
|
||||
];
|
||||
if (total <= palette.length) {
|
||||
return palette.slice(0, total);
|
||||
}
|
||||
const primaryHue = extractHue(getChartTokenHsl('--primary')) ?? 211;
|
||||
const derivedHues = [46, 92, 138, 184, 230, 276].map(
|
||||
(offset) => `hsl(${normalizeHue(primaryHue + offset)} 68% 52%)`,
|
||||
);
|
||||
return [...palette, ...derivedHues].slice(0, total);
|
||||
}
|
||||
|
||||
function getAssistantTrendKey(
|
||||
assistantId: number | string | undefined,
|
||||
label: string,
|
||||
) {
|
||||
return assistantId === undefined
|
||||
? `label:${label}`
|
||||
: `assistant:${assistantId}`;
|
||||
}
|
||||
|
||||
function extractHue(hslValue: string) {
|
||||
const [hue] = hslValue.split(/\s+/);
|
||||
const parsed = Number.parseFloat(hue || '');
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeHue(value: number) {
|
||||
const normalized = value % 360;
|
||||
return normalized < 0 ? normalized + 360 : normalized;
|
||||
}
|
||||
|
||||
function formatAvg(value?: number) {
|
||||
@@ -363,6 +791,16 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end"
|
||||
>
|
||||
<ElDatePicker
|
||||
v-model="customDateRange"
|
||||
class="w-full sm:w-[280px]"
|
||||
type="daterange"
|
||||
unlink-panels
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@change="handleCustomRangeChange"
|
||||
/>
|
||||
<ElRadioGroup
|
||||
:model-value="filters.range"
|
||||
size="default"
|
||||
@@ -392,7 +830,7 @@ onBeforeUnmount(() => {
|
||||
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="item in 8"
|
||||
v-for="item in 6"
|
||||
:key="item"
|
||||
class="border-border bg-muted/50 h-28 animate-pulse rounded-3xl border"
|
||||
></div>
|
||||
@@ -436,14 +874,134 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
|
||||
<AnalysisChartCard title="聊天趋势">
|
||||
<section
|
||||
class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]"
|
||||
>
|
||||
<AnalysisChartCard title="趋势概览">
|
||||
<template v-if="chatAvailable">
|
||||
<div class="space-y-2">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
观察所选时间范围内消息数与会话数的趋势变化。
|
||||
</p>
|
||||
<EchartsUI ref="trendChartRef" height="360px" />
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="text-muted-foreground text-sm">
|
||||
{{ trendDescription }}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<ElPopover
|
||||
v-if="
|
||||
isAssistantActiveTrend &&
|
||||
assistantTrendSelectionItems.length > 0
|
||||
"
|
||||
placement="bottom-end"
|
||||
trigger="click"
|
||||
:width="340"
|
||||
>
|
||||
<template #reference>
|
||||
<button
|
||||
type="button"
|
||||
class="border-border/60 bg-background/88 hover:border-border hover:bg-muted/20 text-foreground inline-flex h-9 max-w-[260px] items-center gap-2 rounded-2xl border px-3.5 text-sm transition-[border-color,background-color,box-shadow] duration-200"
|
||||
>
|
||||
<span
|
||||
v-if="selectedAssistantTrendPreviewItems.length > 0"
|
||||
class="flex shrink-0 items-center -space-x-1.5"
|
||||
>
|
||||
<span
|
||||
v-for="item in selectedAssistantTrendPreviewItems"
|
||||
:key="item.assistantKey"
|
||||
class="border-background inline-block h-2.5 w-2.5 rounded-full border"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
></span>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium">
|
||||
{{ assistantTrendSelectorSummary }}
|
||||
</span>
|
||||
<span
|
||||
class="bg-muted text-muted-foreground shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||||
>
|
||||
{{ assistantTrendSelectorLabel }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm font-semibold">智能体</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary text-xs font-medium"
|
||||
@click="selectAllAssistantTrends"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground text-xs font-medium"
|
||||
@click="clearAssistantTrendSelection"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid max-h-64 gap-2 overflow-y-auto">
|
||||
<button
|
||||
v-for="item in assistantTrendSelectionItems"
|
||||
:key="item.assistantKey"
|
||||
type="button"
|
||||
class="border-border/60 bg-background hover:border-border flex items-center gap-3 rounded-2xl border px-3 py-2 text-left transition-colors"
|
||||
:class="
|
||||
item.isSelected
|
||||
? 'border-primary/40 bg-primary/5'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
@click="toggleAssistantTrend(item.assistantKey)"
|
||||
>
|
||||
<span
|
||||
class="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
></span>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium"
|
||||
>
|
||||
{{ item.series.label }}
|
||||
</span>
|
||||
<span class="text-muted-foreground shrink-0 text-xs">
|
||||
{{ formatCount(item.series.totalSessionCount) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
<ElRadioGroup
|
||||
:model-value="trendMode"
|
||||
size="small"
|
||||
@update:model-value="handleTrendModeChange"
|
||||
>
|
||||
<ElRadioButton
|
||||
v-for="item in trendModeOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
:label="item.label"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative min-h-[360px]">
|
||||
<div
|
||||
v-if="showAssistantTrendNoData"
|
||||
class="bg-background/92 absolute inset-0 z-10 flex items-center justify-center"
|
||||
>
|
||||
<ElEmpty description="暂无智能体活跃数据" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showAssistantTrendEmptySelection"
|
||||
class="bg-background/92 absolute inset-0 z-10 flex items-center justify-center"
|
||||
>
|
||||
<ElEmpty description="请选择智能体" />
|
||||
</div>
|
||||
<EchartsUI ref="trendChartRef" height="360px" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||
@@ -451,9 +1009,9 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</AnalysisChartCard>
|
||||
|
||||
<AnalysisChartCard title="智能体排行">
|
||||
<AnalysisChartCard title="智能体使用榜">
|
||||
<template v-if="chatAvailable">
|
||||
<div v-if="distribution.length" class="space-y-3">
|
||||
<div v-if="distribution.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(item, index) in distribution"
|
||||
:key="item.key || item.label"
|
||||
@@ -471,24 +1029,24 @@ onBeforeUnmount(() => {
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-xs">
|
||||
消息 {{ formatCount(item.messageTotal) }} · 会话
|
||||
{{ formatCount(item.sessionTotal) }}
|
||||
用户 {{ formatCount(item.userTotal) }} · 消息
|
||||
{{ formatCount(item.messageTotal) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-foreground text-lg font-semibold">
|
||||
{{ formatAvg(item.avgMessagePerSession) }}
|
||||
{{ formatCount(item.sessionTotal) }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-xs">
|
||||
平均每会话消息数
|
||||
会话数 · 人均 {{ formatAvg(item.avgSessionPerUser) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||
<ElEmpty description="暂无聊天排行数据" />
|
||||
<ElEmpty description="暂无智能体使用数据" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||
@@ -496,6 +1054,66 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</AnalysisChartCard>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<AnalysisChartCard title="用户活跃榜">
|
||||
<template v-if="chatAvailable">
|
||||
<div v-if="userRanks.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(item, index) in userRanks"
|
||||
:key="item.userId || item.label"
|
||||
class="border-border/60 bg-card flex flex-col gap-4 rounded-2xl border px-4 py-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
class="bg-primary/10 text-primary flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-xs">
|
||||
消息 {{ formatCount(item.messageTotal) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<div class="bg-muted/30 rounded-2xl px-4 py-3 text-right">
|
||||
<div class="text-foreground text-base font-semibold">
|
||||
{{ formatCount(item.sessionTotal) }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-xs">会话数</div>
|
||||
</div>
|
||||
<div class="bg-muted/30 rounded-2xl px-4 py-3 text-right">
|
||||
<div class="text-foreground text-base font-semibold">
|
||||
{{ formatCount(item.assistantTotal) }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-xs">
|
||||
使用智能体数
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-muted/30 col-span-2 rounded-2xl px-4 py-3 text-right md:col-span-1"
|
||||
>
|
||||
<div class="text-foreground text-base font-semibold">
|
||||
{{ formatCount(item.messageTotal) }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-xs">消息数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex min-h-[220px] items-center justify-center">
|
||||
<ElEmpty description="暂无用户活跃数据" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex min-h-[220px] items-center justify-center">
|
||||
<ElEmpty :description="chatStatusMessage" />
|
||||
</div>
|
||||
</AnalysisChartCard>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user