feat: 增强管理员工作台聊天统计看板

- 工作台接入聊天消息总数、会话总数、活跃智能体数、趋势与 Top5 排行

- dashboard 接口新增 chatStatus 与聊天统计字段,分析库不可用时明确降级

- today 维度按小时聚合聊天趋势,并补充后端查询与测试覆盖
This commit is contained in:
2026-04-19 17:40:01 +08:00
parent 1d8b9d9662
commit 5827ecde42
16 changed files with 1206 additions and 90 deletions

View File

@@ -8,30 +8,45 @@ export interface DashboardOverviewQuery {
export interface DashboardSummary {
activeUserTotal: number;
activeAssistantTotal: number;
botTotal: number;
chatMessageTotal: number;
chatSessionTotal: number;
knowledgeBaseTotal: number;
userTotal: number;
workflowTotal: number;
}
export interface DashboardChatStatus {
available: boolean;
message: string;
}
export interface DashboardTrendItem {
activeUserTotal: number;
chatMessageTotal: number;
chatSessionTotal: number;
key: string;
label: string;
}
export interface DashboardDistributionItem {
activeUserTotal: number;
assistantId?: number | string;
avgMessagePerSession?: number;
botTotal: number;
key: string;
knowledgeBaseTotal: number;
label: string;
messageTotal?: number;
sessionTotal?: number;
userTotal: number;
value: number;
workflowTotal: number;
}
export interface DashboardOverviewResponse {
chatStatus: DashboardChatStatus;
distribution: DashboardDistributionItem[];
query: DashboardOverviewQuery;
summary: DashboardSummary;

View File

@@ -2,6 +2,7 @@
import type { EchartsUIType } from '@easyflow/plugins/echarts';
import type {
DashboardDistributionItem,
DashboardOverviewQuery,
DashboardOverviewResponse,
DashboardRange,
@@ -21,6 +22,7 @@ import {
import { AnalysisChartCard } from '@easyflow/common-ui';
import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts';
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';
@@ -28,6 +30,7 @@ import { ElButton, ElEmpty, ElRadioButton, ElRadioGroup } from 'element-plus';
import { getDashboardOverview } from '#/api/dashboard';
interface SummaryCardItem {
available?: boolean;
label: string;
value: string;
}
@@ -54,27 +57,48 @@ const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
];
const emptySummary: DashboardSummary = {
activeAssistantTotal: 0,
activeUserTotal: 0,
botTotal: 0,
chatMessageTotal: 0,
chatSessionTotal: 0,
knowledgeBaseTotal: 0,
userTotal: 0,
workflowTotal: 0,
};
const summary = computed(() => overview.value?.summary ?? emptySummary);
const trends = computed<DashboardTrendItem[]>(
() => overview.value?.trends ?? [],
const trends = computed<DashboardTrendItem[]>(() => overview.value?.trends ?? []);
const distribution = computed<DashboardDistributionItem[]>(
() => overview.value?.distribution ?? [],
);
const chatAvailable = computed(() => overview.value?.chatStatus?.available !== false);
const chatStatusMessage = computed(
() => overview.value?.chatStatus?.message || '聊天数据不可用',
);
const summaryCards = computed<SummaryCardItem[]>(() => [
{ label: '用户总量', value: formatCount(summary.value.userTotal) },
{ label: '活跃用户', value: formatCount(summary.value.activeUserTotal) },
{ label: '助手数量', value: formatCount(summary.value.botTotal) },
{ label: '工作流数量', value: formatCount(summary.value.workflowTotal) },
{
label: '知识库数量',
value: formatCount(summary.value.knowledgeBaseTotal),
},
{
available: chatAvailable.value,
label: '聊天消息总数',
value: formatOptionalCount(summary.value.chatMessageTotal, chatAvailable.value),
},
{
available: chatAvailable.value,
label: '聊天会话总数',
value: formatOptionalCount(summary.value.chatSessionTotal, chatAvailable.value),
},
{
available: chatAvailable.value,
label: '活跃智能体数',
value: formatOptionalCount(summary.value.activeAssistantTotal, chatAvailable.value),
},
]);
const updatedAtText = computed(() => {
@@ -131,15 +155,24 @@ async function loadOverview() {
async function renderCharts() {
await nextTick();
if (!chatAvailable.value) {
return;
}
renderTrendChart();
}
function renderTrendChart() {
const xAxisData = trends.value.map((item) => item.label);
const activeUserData = trends.value.map((item) => item.activeUserTotal);
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 axisColor = getChartTokenColor('--border');
const tooltipLineColor = getChartTokenColor('--accent');
const textColor = getChartTokenColor('--foreground');
renderTrendEcharts({
color: ['hsl(var(--primary))'],
color: [primaryColor, successColor],
grid: {
bottom: 18,
containLabel: true,
@@ -148,21 +181,38 @@ function renderTrendChart() {
top: 24,
},
legend: {
itemGap: 18,
top: 0,
icon: 'circle',
itemGap: 24,
itemHeight: 10,
itemWidth: 10,
padding: [4, 12, 8, 12],
textStyle: {
color: textColor,
fontSize: 14,
fontWeight: 500,
},
top: 4,
},
tooltip: {
axisPointer: {
lineStyle: {
color: tooltipLineColor,
width: 1,
},
type: 'line',
},
trigger: 'axis',
},
xAxis: {
axisLine: {
lineStyle: {
color: 'hsl(var(--border))',
color: axisColor,
},
},
axisTick: {
show: false,
},
boundaryGap: false,
data: xAxisData,
type: 'category',
},
@@ -175,7 +225,7 @@ function renderTrendChart() {
},
splitLine: {
lineStyle: {
color: 'hsl(var(--border))',
color: axisColor,
type: 'dashed',
},
},
@@ -183,10 +233,57 @@ function renderTrendChart() {
},
series: [
{
data: activeUserData,
name: '活跃用户',
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,
symbolSize: 8,
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',
},
],
@@ -205,6 +302,28 @@ function formatCount(value?: number) {
return Number(value || 0).toLocaleString('zh-CN');
}
function formatOptionalCount(value: number | undefined, available: boolean) {
return available ? formatCount(value) : '--';
}
function getChartTokenColor(variableName: string) {
if (typeof window === 'undefined') {
return '#3b82f6';
}
const value = getComputedStyle(document.documentElement)
.getPropertyValue(variableName)
.trim();
return value ? convertToRgb(`hsl(${value})`) : '#3b82f6';
}
function formatAvg(value?: number) {
const safeValue = Number(value || 0);
return safeValue.toLocaleString('zh-CN', {
maximumFractionDigits: 1,
minimumFractionDigits: safeValue > 0 && safeValue < 10 ? 1 : 0,
});
}
function formatDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
@@ -270,7 +389,7 @@ onBeforeUnmount(() => {
<section
v-if="isLoading && !overview"
class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
<div
v-for="item in 8"
@@ -294,7 +413,7 @@ onBeforeUnmount(() => {
</section>
<template v-else>
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div
v-for="item in summaryCards"
:key="item.label"
@@ -308,16 +427,72 @@ onBeforeUnmount(() => {
>
{{ item.value }}
</div>
<div
v-if="item.available === false"
class="text-muted-foreground mt-2 text-xs"
>
{{ chatStatusMessage }}
</div>
</div>
</section>
<section>
<AnalysisChartCard title="趋势变化">
<div class="space-y-2">
<p class="text-muted-foreground text-sm">
观察活跃用户在所选时间范围内的变化趋势
</p>
<EchartsUI ref="trendChartRef" height="360px" />
<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>
</template>
<div v-else class="flex min-h-[360px] items-center justify-center">
<ElEmpty :description="chatStatusMessage" />
</div>
</AnalysisChartCard>
<AnalysisChartCard title="智能体排行">
<template v-if="chatAvailable">
<div v-if="distribution.length" class="space-y-3">
<div
v-for="(item, index) in distribution"
:key="item.key || item.label"
class="border-border/60 bg-muted/20 flex items-start justify-between rounded-2xl border px-4 py-4"
>
<div class="min-w-0 flex-1">
<div class="flex 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) }} · 会话
{{ formatCount(item.sessionTotal) }}
</div>
</div>
</div>
</div>
<div class="text-right">
<div class="text-foreground text-lg font-semibold">
{{ formatAvg(item.avgMessagePerSession) }}
</div>
<div class="text-muted-foreground mt-1 text-xs">
平均每会话消息数
</div>
</div>
</div>
</div>
<div v-else class="flex min-h-[360px] items-center justify-center">
<ElEmpty description="暂无聊天排行数据" />
</div>
</template>
<div v-else class="flex min-h-[360px] items-center justify-center">
<ElEmpty :description="chatStatusMessage" />
</div>
</AnalysisChartCard>
</section>