feat: 增强管理端工作台用户活跃榜能力

- 新增用户活跃榜接口、筛选与导出能力

- 支持按智能体过滤排行榜并补充前后端测试
This commit is contained in:
2026-05-10 17:06:22 +08:00
parent 8d07b306e5
commit 516d43ce7d
14 changed files with 868 additions and 126 deletions

View File

@@ -1,4 +1,4 @@
import { requestClient } from '#/api/request';
import { api, requestClient } from '#/api/request';
export type DashboardRange = '7d' | '30d' | 'custom' | 'today';
@@ -8,6 +8,10 @@ export interface DashboardOverviewQuery {
startDate?: string;
}
export interface DashboardUserRankQuery extends DashboardOverviewQuery {
assistantId?: string;
}
export interface DashboardSummary {
activeUserTotal: number;
activeAssistantTotal: number;
@@ -63,9 +67,10 @@ export interface DashboardDistributionItem {
}
export interface DashboardUserRankItem {
assistantTotal: number;
label: string;
loginName?: string;
messageTotal: number;
nickname?: string;
sessionTotal: number;
userId?: number | string;
}
@@ -78,7 +83,6 @@ export interface DashboardOverviewResponse {
summary: DashboardSummary;
trends: DashboardTrendItem[];
updatedAt: string;
userRanks: DashboardUserRankItem[];
}
export async function getDashboardOverview(params: DashboardOverviewQuery) {
@@ -89,3 +93,29 @@ export async function getDashboardOverview(params: DashboardOverviewQuery) {
},
);
}
export async function getDashboardUserRanks(params: DashboardUserRankQuery) {
return requestClient.get<DashboardUserRankItem[]>(
'/api/v1/dashboard/user-ranks',
{
params,
},
);
}
export async function exportDashboardUserRanks(params: DashboardUserRankQuery) {
return api.download(buildDashboardUrl('/api/v1/dashboard/user-ranks/export', params));
}
function buildDashboardUrl(url: string, params?: Record<string, any>) {
const base =
typeof window === 'undefined' ? 'http://localhost' : window.location.origin;
const target = new URL(url, base);
Object.entries(params || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
target.searchParams.set(key, String(value));
});
return `${target.pathname}${target.search}`;
}

View File

@@ -10,6 +10,7 @@ import type {
DashboardSummary,
DashboardTrendItem,
DashboardUserRankItem,
DashboardUserRankQuery,
} from '#/api/dashboard';
import {
@@ -25,19 +26,26 @@ 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 { convertToRgb, downloadFileFromBlob } from '@easyflow/utils';
import { RefreshRight } from '@element-plus/icons-vue';
import {
ElButton,
ElDatePicker,
ElEmpty,
ElOption,
ElPopover,
ElRadioButton,
ElRadioGroup,
ElSelect,
} from 'element-plus';
import { getDashboardOverview } from '#/api/dashboard';
import {
exportDashboardUserRanks,
getDashboardOverview,
getDashboardUserRanks,
} from '#/api/dashboard';
import { requestClient } from '#/api/request';
type DashboardTrendMode = 'assistantActive' | 'usage' | 'userActive';
@@ -54,7 +62,13 @@ interface SummaryCardItem {
value: string;
}
interface AssistantOptionItem {
label: string;
value: string;
}
let greetingTimer: null | ReturnType<typeof setInterval> = null;
let userRankRequestId = 0;
const userStore = useUserStore();
const now = ref(new Date());
@@ -68,6 +82,15 @@ const isLoading = ref(false);
const errorMessage = ref('');
const trendMode = ref<DashboardTrendMode>('usage');
const selectedAssistantTrendKeys = ref<string[]>([]);
const assistantOptions = ref<AssistantOptionItem[]>([
{ label: '全部智能体', value: '' },
]);
const assistantOptionsLoading = ref(false);
const selectedAssistantId = ref('');
const userRankItems = ref<DashboardUserRankItem[]>([]);
const userRankLoading = ref(false);
const userRankExportLoading = ref(false);
const userRankErrorMessage = ref('');
const trendChartRef = ref<EchartsUIType>();
const {
@@ -110,9 +133,6 @@ const assistantTrends = computed<DashboardAssistantTrendSeries[]>(
const distribution = computed<DashboardDistributionItem[]>(
() => overview.value?.distribution ?? [],
);
const userRanks = computed<DashboardUserRankItem[]>(
() => overview.value?.userRanks ?? [],
);
const chatAvailable = computed(
() => overview.value?.chatStatus?.available !== false,
);
@@ -278,22 +298,30 @@ const showAssistantTrendEmptySelection = computed(
);
const showTrendChart = computed(() => chatAvailable.value);
const showUserRankLoading = computed(
() => userRankLoading.value && userRankItems.value.length === 0,
);
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,
});
const [data] = await Promise.all([
getDashboardOverview(buildOverviewQuery()),
loadAssistantOptions().catch(() => undefined),
]);
overview.value = data;
resetAssistantTrendSelection();
await renderCharts();
if (data.chatStatus?.available === false) {
resetUserRanks();
} else {
await loadUserRanks();
}
} catch (error) {
overview.value = null;
resetUserRanks();
errorMessage.value =
(error as Error)?.message || '工作台数据加载失败,请稍后重试。';
} finally {
@@ -301,6 +329,110 @@ async function loadOverview() {
}
}
async function loadAssistantOptions() {
assistantOptionsLoading.value = true;
try {
const bots = await requestClient.get<
Array<{ id?: number | string; title?: string }>
>('/api/v1/bot/list', {
params: { status: 1 },
});
const nextOptions: AssistantOptionItem[] = [
{ label: '全部智能体', value: '' },
...(bots || []).map((item) => ({
label: item.title?.trim() || '未命名智能体',
value: item.id === undefined || item.id === null ? '' : String(item.id),
})),
];
assistantOptions.value = nextOptions;
if (!nextOptions.some((item) => item.value === selectedAssistantId.value)) {
selectedAssistantId.value = '';
}
} catch (error) {
assistantOptions.value = [{ label: '全部智能体', value: '' }];
selectedAssistantId.value = '';
throw error;
} finally {
assistantOptionsLoading.value = false;
}
}
async function loadUserRanks() {
if (!overview.value || chatAvailable.value === false) {
resetUserRanks();
return;
}
const currentRequestId = ++userRankRequestId;
userRankLoading.value = true;
userRankErrorMessage.value = '';
userRankItems.value = [];
try {
const data = await getDashboardUserRanks(buildUserRankQuery());
if (currentRequestId !== userRankRequestId) {
return;
}
userRankItems.value = data || [];
} catch (error) {
if (currentRequestId !== userRankRequestId) {
return;
}
userRankItems.value = [];
userRankErrorMessage.value =
(error as Error)?.message || '用户活跃榜加载失败,请稍后重试。';
} finally {
if (currentRequestId === userRankRequestId) {
userRankLoading.value = false;
}
}
}
function resetUserRanks() {
userRankRequestId += 1;
userRankItems.value = [];
userRankLoading.value = false;
userRankErrorMessage.value = '';
}
function buildOverviewQuery(): DashboardOverviewQuery {
return {
endDate: filters.range === 'custom' ? filters.endDate : undefined,
range: filters.range,
startDate: filters.range === 'custom' ? filters.startDate : undefined,
};
}
function buildUserRankQuery(): DashboardUserRankQuery {
return {
...buildOverviewQuery(),
assistantId: selectedAssistantId.value || undefined,
};
}
function handleUserRankAssistantChange(
value: boolean | number | string | undefined,
) {
selectedAssistantId.value =
value === undefined || value === null ? '' : String(value);
void loadUserRanks();
}
async function handleUserRankExport() {
if (userRankExportLoading.value) {
return;
}
userRankExportLoading.value = true;
try {
const blob = await exportDashboardUserRanks(buildUserRankQuery());
downloadFileFromBlob({
fileName: 'dashboard_user_ranks.xlsx',
source: blob,
});
} finally {
userRankExportLoading.value = false;
}
}
async function renderCharts() {
await nextTick();
if (!showTrendChart.value) {
@@ -1057,10 +1189,58 @@ onBeforeUnmount(() => {
<section>
<AnalysisChartCard title="用户活跃榜">
<template #extra v-if="chatAvailable">
<div class="flex w-full items-center justify-end gap-2 sm:w-auto">
<ElSelect
:model-value="selectedAssistantId"
class="w-[220px]"
:clearable="false"
filterable
placeholder="筛选智能体"
:loading="assistantOptionsLoading"
@update:model-value="handleUserRankAssistantChange"
>
<ElOption
v-for="item in assistantOptions"
:key="item.value || item.label"
:label="item.label"
:value="item.value"
/>
</ElSelect>
<ElButton
:loading="userRankExportLoading"
:disabled="userRankLoading"
@click="handleUserRankExport"
>
导出
</ElButton>
</div>
</template>
<template v-if="chatAvailable">
<div v-if="userRanks.length > 0" class="space-y-3">
<div v-if="showUserRankLoading" class="space-y-3">
<div
v-for="(item, index) in userRanks"
v-for="item in 2"
:key="item"
class="border-border/60 bg-muted/25 h-[104px] animate-pulse rounded-2xl border"
></div>
</div>
<div v-else-if="userRankErrorMessage" class="flex min-h-[220px] items-center justify-center">
<ElEmpty description="用户活跃榜加载失败">
<template #default>
<div class="space-y-3 text-center">
<p class="text-muted-foreground text-sm">
{{ userRankErrorMessage }}
</p>
<ElButton type="primary" @click="loadUserRanks">
重新加载
</ElButton>
</div>
</template>
</ElEmpty>
</div>
<div v-else-if="userRankItems.length > 0" class="space-y-3">
<div
v-for="(item, index) in userRankItems"
: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"
>
@@ -1074,28 +1254,17 @@ onBeforeUnmount(() => {
<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="grid grid-cols-2 gap-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"
class="bg-muted/30 rounded-2xl px-4 py-3 text-right"
>
<div class="text-foreground text-base font-semibold">
{{ formatCount(item.messageTotal) }}

View File

@@ -19,8 +19,14 @@ withDefaults(defineProps<Props>(), {});
<template>
<Card>
<CardHeader>
<CardHeader class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<CardTitle class="text-xl">{{ title }}</CardTitle>
<div
v-if="$slots.extra"
class="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto"
>
<slot name="extra"></slot>
</div>
</CardHeader>
<CardContent>
<slot></slot>