1289 lines
37 KiB
Vue
1289 lines
37 KiB
Vue
<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>
|