Files
EasyFlow/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue
陈子默 516d43ce7d feat: 增强管理端工作台用户活跃榜能力
- 新增用户活跃榜接口、筛选与导出能力

- 支持按智能体过滤排行榜并补充前后端测试
2026-05-10 17:06:22 +08:00

1289 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import type { EchartsUIType } from '@easyflow/plugins/echarts';
import type {
DashboardAssistantTrendSeries,
DashboardDistributionItem,
DashboardOverviewQuery,
DashboardOverviewResponse,
DashboardRange,
DashboardSummary,
DashboardTrendItem,
DashboardUserRankItem,
DashboardUserRankQuery,
} from '#/api/dashboard';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { AnalysisChartCard } from '@easyflow/common-ui';
import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts';
import { useUserStore } from '@easyflow/stores';
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 {
exportDashboardUserRanks,
getDashboardOverview,
getDashboardUserRanks,
} from '#/api/dashboard';
import { requestClient } from '#/api/request';
type DashboardTrendMode = 'assistantActive' | 'usage' | 'userActive';
interface AssistantTrendSelectionItem {
assistantKey: string;
color: string;
isSelected: boolean;
series: DashboardAssistantTrendSeries;
}
interface SummaryCardItem {
available?: boolean;
label: string;
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());
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 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 {
getChartInstance: getTrendChartInstance,
renderEcharts: renderTrendEcharts,
resize: resizeTrendChart,
} = useEcharts(trendChartRef);
const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
{ label: '今日', value: 'today' },
{ label: '近 7 天', value: '7d' },
{ 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,
userTotal: 0,
workflowTotal: 0,
};
const summary = computed(() => overview.value?.summary ?? emptySummary);
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 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[]>(() => [
{
available: chatAvailable.value,
label: 'AI活跃用户',
value: formatOptionalCount(
summary.value.chatActiveUserTotal,
chatAvailable.value,
),
},
{
available: chatAvailable.value,
label: '活跃智能体',
value: formatOptionalCount(
summary.value.activeAssistantTotal,
chatAvailable.value,
),
},
{
available: chatAvailable.value,
label: '聊天会话总数',
value: formatOptionalCount(
summary.value.chatSessionTotal,
chatAvailable.value,
),
},
{
available: chatAvailable.value,
label: '聊天消息总数',
value: formatOptionalCount(
summary.value.chatMessageTotal,
chatAvailable.value,
),
},
{ label: '智能体总数', value: formatCount(summary.value.botTotal) },
{
label: '知识库总数',
value: formatCount(summary.value.knowledgeBaseTotal),
},
]);
const updatedAtText = computed(() => {
if (!overview.value?.updatedAt) {
return '尚未获取';
}
return formatDateTime(overview.value.updatedAt);
});
const displayName = computed(() => {
return (
userStore.userInfo?.nickname?.trim() ||
userStore.userInfo?.loginName?.trim() ||
'同学'
);
});
const greetingText = computed(() => {
const hour = now.value.getHours();
if (hour < 11) {
return '上午好';
}
if (hour < 14) {
return '中午好';
}
if (hour < 18) {
return '下午好';
}
return '晚上好';
});
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);
const showUserRankLoading = computed(
() => userRankLoading.value && userRankItems.value.length === 0,
);
async function loadOverview() {
isLoading.value = true;
errorMessage.value = '';
try {
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 {
isLoading.value = false;
}
}
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) {
return;
}
renderTrendChart();
}
function renderTrendChart() {
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: isUsageTrend.value
? [primaryColor, successColor]
: [primaryColor, warningColor, destructiveColor],
grid: {
bottom: 18,
containLabel: true,
left: 12,
right: 12,
top: 24,
},
legend: {
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: 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: isUsageTrend.value ? usageSeries : activeSeries,
});
}
function handleRangeChange(value: boolean | number | string | undefined) {
if (value !== 'today' && value !== '7d' && value !== '30d') {
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');
}
function formatOptionalCount(value: number | undefined, available: boolean) {
return available ? formatCount(value) : '--';
}
function getChartTokenColor(variableName: string) {
return convertToRgb(`hsl(${getChartTokenHsl(variableName)})`);
}
function getChartTokenHsl(variableName: string) {
if (typeof window === 'undefined') {
return '211 100% 50%';
}
const value = getComputedStyle(document.documentElement)
.getPropertyValue(variableName)
.trim();
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) {
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())) {
return value;
}
return date.toLocaleString('zh-CN', {
hour12: false,
});
}
onMounted(async () => {
greetingTimer = setInterval(() => {
now.value = new Date();
}, 60 * 1000);
await loadOverview();
});
onBeforeUnmount(() => {
if (greetingTimer) {
clearInterval(greetingTimer);
greetingTimer = null;
}
});
</script>
<template>
<div class="space-y-6 px-5 pb-5 pt-1">
<section>
<div
class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"
>
<div>
<div class="text-3xl font-semibold tracking-tight lg:text-4xl">
{{ greetingTitle }}
</div>
</div>
<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"
@update:model-value="handleRangeChange"
>
<ElRadioButton
v-for="item in rangeOptions"
:key="item.value"
:value="item.value"
:label="item.label"
>
{{ item.label }}
</ElRadioButton>
</ElRadioGroup>
<ElButton :icon="RefreshRight" @click="loadOverview">刷新</ElButton>
</div>
</div>
<div class="text-muted-foreground mt-4 text-sm">
最新更新时间{{ updatedAtText }}
</div>
</section>
<section
v-if="isLoading && !overview"
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
<div
v-for="item in 6"
:key="item"
class="border-border bg-muted/50 h-28 animate-pulse rounded-3xl border"
></div>
</section>
<section
v-else-if="errorMessage"
class="border-border bg-card rounded-3xl border border-dashed p-10"
>
<ElEmpty description="工作台加载失败">
<template #default>
<div class="space-y-3 text-center">
<p class="text-muted-foreground text-sm">{{ errorMessage }}</p>
<ElButton type="primary" @click="loadOverview">重新加载</ElButton>
</div>
</template>
</ElEmpty>
</section>
<template v-else>
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div
v-for="item in summaryCards"
:key="item.label"
class="border-border/70 bg-card rounded-3xl border px-5 py-5 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm"
>
<div class="text-muted-foreground text-xs font-medium">
{{ item.label }}
</div>
<div
class="text-foreground mt-3 text-3xl font-semibold tracking-tight"
>
{{ item.value }}
</div>
<div
v-if="item.available === false"
class="text-muted-foreground mt-2 text-xs"
>
{{ chatStatusMessage }}
</div>
</div>
</section>
<section
class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]"
>
<AnalysisChartCard title="趋势概览">
<template v-if="chatAvailable">
<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">
<ElEmpty :description="chatStatusMessage" />
</div>
</AnalysisChartCard>
<AnalysisChartCard title="智能体使用榜">
<template v-if="chatAvailable">
<div v-if="distribution.length > 0" 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.userTotal) }} · 消息
{{ formatCount(item.messageTotal) }}
</div>
</div>
</div>
</div>
<div class="text-right">
<div class="text-foreground text-lg font-semibold">
{{ 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="暂无智能体使用数据" />
</div>
</template>
<div v-else class="flex min-h-[360px] items-center justify-center">
<ElEmpty :description="chatStatusMessage" />
</div>
</AnalysisChartCard>
</section>
<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="showUserRankLoading" class="space-y-3">
<div
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"
>
<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>
</div>
<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.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>