feat: 增强管理端工作台用户活跃榜能力
- 新增用户活跃榜接口、筛选与导出能力 - 支持按智能体过滤排行榜并补充前后端测试
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user