feat: add project usage analytics with ECharts
This commit is contained in:
728
public/app.js
728
public/app.js
@@ -272,12 +272,16 @@
|
||||
period: 'week',
|
||||
data: null,
|
||||
selectedMcpKey: '',
|
||||
mcpToolsExpanded: false,
|
||||
openedAt: 0,
|
||||
savedScrollTop: 0,
|
||||
savedFocus: null,
|
||||
savedSelection: null,
|
||||
};
|
||||
let usageDashboardRequestSeq = 0;
|
||||
const usageEchartInstances = new Set();
|
||||
let usageChartResizeFrame = 0;
|
||||
let usageChartRefreshFrame = 0;
|
||||
let lastSessionListStructureSignature = '';
|
||||
const collapsedProjectKeys = (() => {
|
||||
try {
|
||||
@@ -347,23 +351,25 @@
|
||||
const usageDashboardRefresh = $('#usage-dashboard-refresh');
|
||||
const usageDashboardPeriodWeek = $('#usage-dashboard-period-week');
|
||||
const usageDashboardPeriodMonth = $('#usage-dashboard-period-month');
|
||||
const usageDashboardPeriodCustom = $('#usage-dashboard-period-custom');
|
||||
const usageDashboardFrom = $('#usage-dashboard-from');
|
||||
const usageDashboardTo = $('#usage-dashboard-to');
|
||||
const usageDashboardApply = $('#usage-dashboard-apply');
|
||||
const usageDashboardLoading = $('#usage-dashboard-loading');
|
||||
const usageDashboardError = $('#usage-dashboard-error');
|
||||
const usageDashboardTrend = $('#usage-dashboard-trend');
|
||||
const usageDashboardTrendLegend = $('#usage-dashboard-trend-legend');
|
||||
const usageDashboardMcpStatus = $('#usage-dashboard-mcp-status');
|
||||
const usageDashboardMcpRows = $('#usage-dashboard-mcp-rows');
|
||||
const usageDashboardMcpEmpty = $('#usage-dashboard-mcp-empty');
|
||||
const usageDashboardMcpMore = $('#usage-dashboard-mcp-more');
|
||||
const usageDashboardDetail = $('#usage-dashboard-detail');
|
||||
const usageDashboardDetailTitle = $('#usage-dashboard-detail-title');
|
||||
const usageDashboardDetailMeta = $('#usage-dashboard-detail-meta');
|
||||
const usageDashboardDetailRows = $('#usage-dashboard-detail-rows');
|
||||
const usageDashboardDetailClose = $('#usage-dashboard-detail-close');
|
||||
const usageDashboardSkillRows = $('#usage-dashboard-skill-rows');
|
||||
const usageDashboardSkillEmpty = $('#usage-dashboard-skill-empty');
|
||||
const usageDashboardDetailScrim = $('#usage-dashboard-detail-scrim');
|
||||
const usageDashboardProjectRows = $('#usage-dashboard-project-rows');
|
||||
const usageDashboardProjectEmpty = $('#usage-dashboard-project-empty');
|
||||
const usageDashboardSessionRows = $('#usage-dashboard-session-rows');
|
||||
const usageDashboardSessionEmpty = $('#usage-dashboard-session-empty');
|
||||
const sessionList = $('#session-list');
|
||||
@@ -4427,6 +4433,7 @@
|
||||
usageDashboardState.period = period;
|
||||
usageDashboardPeriodWeek?.setAttribute('aria-pressed', period === 'week' ? 'true' : 'false');
|
||||
usageDashboardPeriodMonth?.setAttribute('aria-pressed', period === 'month' ? 'true' : 'false');
|
||||
usageDashboardPeriodCustom?.setAttribute('aria-pressed', period === 'custom' ? 'true' : 'false');
|
||||
if (period === 'week' || period === 'month') {
|
||||
const dates = usagePeriodDates(period);
|
||||
if (usageDashboardFrom) usageDashboardFrom.value = dates.from;
|
||||
@@ -4483,122 +4490,480 @@
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function usageSvgElement(name, attributes = {}) {
|
||||
const element = document.createElementNS('http://www.w3.org/2000/svg', name);
|
||||
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value)));
|
||||
return element;
|
||||
function usageChartAnimationDuration() {
|
||||
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 0 : 320;
|
||||
}
|
||||
|
||||
function getUsageEchart(element) {
|
||||
if (!element || !window.echarts) return null;
|
||||
let chart = window.echarts.getInstanceByDom(element);
|
||||
if (!chart) {
|
||||
chart = window.echarts.init(element, null, {
|
||||
renderer: 'canvas',
|
||||
devicePixelRatio: Math.min(2, Math.max(1, Number(window.devicePixelRatio || 1))),
|
||||
});
|
||||
usageEchartInstances.add(chart);
|
||||
}
|
||||
return chart;
|
||||
}
|
||||
|
||||
function disposeUsageEchartsIn(container) {
|
||||
if (!container || !window.echarts) return;
|
||||
container.querySelectorAll('[_echarts_instance_]').forEach((element) => {
|
||||
const chart = window.echarts.getInstanceByDom(element);
|
||||
if (!chart) return;
|
||||
usageEchartInstances.delete(chart);
|
||||
chart.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
function usageChartColorWithAlpha(color, alpha) {
|
||||
return window.echarts?.color?.modifyAlpha?.(color, alpha) || color;
|
||||
}
|
||||
|
||||
function resolveUsageChartColor(variableName, fallback) {
|
||||
if (!usageDashboardPanel) return fallback;
|
||||
const probe = document.createElement('span');
|
||||
probe.style.position = 'absolute';
|
||||
probe.style.pointerEvents = 'none';
|
||||
probe.style.opacity = '0';
|
||||
probe.style.color = `var(${variableName}, ${fallback})`;
|
||||
usageDashboardPanel.appendChild(probe);
|
||||
const color = getComputedStyle(probe).color || fallback;
|
||||
probe.remove();
|
||||
return color;
|
||||
}
|
||||
|
||||
function getUsageChartPalette() {
|
||||
return {
|
||||
accent: resolveUsageChartColor('--usage-dashboard-accent', '#c45a3c'),
|
||||
messages: resolveUsageChartColor('--usage-dashboard-metric-messages', '#c45a3c'),
|
||||
mcp: resolveUsageChartColor('--usage-dashboard-metric-mcp', '#8b5cf6'),
|
||||
sessions: resolveUsageChartColor('--usage-dashboard-metric-sessions', '#2563eb'),
|
||||
projects: resolveUsageChartColor('--usage-dashboard-metric-projects', '#d97706'),
|
||||
text: resolveUsageChartColor('--usage-dashboard-text-secondary', '#475569'),
|
||||
muted: resolveUsageChartColor('--usage-dashboard-text-muted', '#94a3b8'),
|
||||
border: resolveUsageChartColor('--usage-dashboard-border', '#d7dee8'),
|
||||
surface: resolveUsageChartColor('--usage-dashboard-surface', '#ffffff'),
|
||||
};
|
||||
}
|
||||
|
||||
function resizeUsageEcharts() {
|
||||
usageChartResizeFrame = 0;
|
||||
if (!usageDashboardState.open) return;
|
||||
[...usageEchartInstances].forEach((chart) => {
|
||||
if (!chart || chart.isDisposed?.()) {
|
||||
usageEchartInstances.delete(chart);
|
||||
return;
|
||||
}
|
||||
chart.resize({ animation: { duration: 0 } });
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleUsageEchartResize() {
|
||||
if (usageChartResizeFrame) cancelAnimationFrame(usageChartResizeFrame);
|
||||
usageChartResizeFrame = requestAnimationFrame(resizeUsageEcharts);
|
||||
}
|
||||
|
||||
function refreshUsageEcharts() {
|
||||
usageChartRefreshFrame = 0;
|
||||
if (!usageDashboardState.open || !usageDashboardState.data) return;
|
||||
renderUsageSparklines(usageDashboardState.data);
|
||||
renderUsageTrend(usageDashboardState.data);
|
||||
renderUsageMcpStatus(usageDashboardState.data);
|
||||
scheduleUsageEchartResize();
|
||||
}
|
||||
|
||||
function scheduleUsageEchartRefresh() {
|
||||
if (usageChartRefreshFrame) cancelAnimationFrame(usageChartRefreshFrame);
|
||||
usageChartRefreshFrame = requestAnimationFrame(refreshUsageEcharts);
|
||||
}
|
||||
|
||||
function renderUsageSparklines(data) {
|
||||
if (!usageDashboardPanel) return;
|
||||
const rows = Array.isArray(data?.trend) ? data.trend : [];
|
||||
usageDashboardPanel.querySelectorAll('[data-usage-spark]').forEach((sparkline) => {
|
||||
const key = String(sparkline.dataset.usageSpark || '');
|
||||
const values = rows.map((row) => Math.max(0, Number(row?.[key] || 0)));
|
||||
const chart = getUsageEchart(sparkline);
|
||||
if (!chart) {
|
||||
sparkline.dataset.chartState = 'unavailable';
|
||||
sparkline.textContent = '—';
|
||||
return;
|
||||
}
|
||||
sparkline.dataset.chartState = 'ready';
|
||||
if (values.length === 0) {
|
||||
chart.clear();
|
||||
return;
|
||||
}
|
||||
const lineColor = getComputedStyle(sparkline).color || '#6b7280';
|
||||
const lastIndex = values.length - 1;
|
||||
chart.setOption({
|
||||
animation: usageChartAnimationDuration() > 0,
|
||||
animationDuration: usageChartAnimationDuration(),
|
||||
aria: {
|
||||
enabled: true,
|
||||
description: sparkline.getAttribute('aria-label') || '每日微趋势',
|
||||
},
|
||||
grid: { left: 1, right: 2, top: 3, bottom: 2, containLabel: false },
|
||||
tooltip: { show: false },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((row) => String(row?.date || '')),
|
||||
boundaryGap: false,
|
||||
show: false,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: Math.max(1, ...values),
|
||||
show: false,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: values,
|
||||
smooth: 0.28,
|
||||
symbol: 'none',
|
||||
silent: true,
|
||||
lineStyle: { color: lineColor, width: 1.8 },
|
||||
areaStyle: {
|
||||
color: new window.echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: usageChartColorWithAlpha(lineColor, 0.2) },
|
||||
{ offset: 1, color: usageChartColorWithAlpha(lineColor, 0.02) },
|
||||
]),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'scatter',
|
||||
data: values.map((value, index) => (index === lastIndex ? value : null)),
|
||||
symbolSize: 4,
|
||||
silent: true,
|
||||
itemStyle: {
|
||||
color: lineColor,
|
||||
borderColor: getComputedStyle(usageDashboardPanel).backgroundColor,
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
}, { notMerge: true, lazyUpdate: false, silent: true });
|
||||
});
|
||||
}
|
||||
|
||||
function renderUsageTrend(data) {
|
||||
if (!usageDashboardTrend || !usageDashboardTrendLegend) return;
|
||||
usageDashboardTrend.replaceChildren();
|
||||
usageDashboardTrendLegend.replaceChildren();
|
||||
const rows = Array.isArray(data?.trend) ? data.trend : [];
|
||||
const series = [
|
||||
{ key: 'newSessions', label: '会话', className: 'sessions' },
|
||||
{ key: 'messages', label: '消息', className: 'messages' },
|
||||
{ key: 'mcpCalls', label: 'MCP', className: 'mcp' },
|
||||
{ key: 'skillMentions', label: 'Skill', className: 'skills' },
|
||||
];
|
||||
series.forEach((item) => {
|
||||
const legend = document.createElement('span');
|
||||
legend.className = `usage-dashboard__legend-item usage-dashboard__legend-item--${item.className}`;
|
||||
legend.textContent = item.label;
|
||||
usageDashboardTrendLegend.appendChild(legend);
|
||||
});
|
||||
|
||||
const width = 720;
|
||||
const height = 280;
|
||||
const padding = { left: 46, right: 18, top: 20, bottom: 42 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
const maxValue = Math.max(1, ...rows.flatMap((row) => series.map((item) => Number(row?.[item.key] || 0))));
|
||||
const yTicks = 4;
|
||||
for (let tick = 0; tick <= yTicks; tick += 1) {
|
||||
const y = padding.top + (chartHeight * tick) / yTicks;
|
||||
usageDashboardTrend.appendChild(usageSvgElement('line', {
|
||||
x1: padding.left,
|
||||
x2: width - padding.right,
|
||||
y1: y,
|
||||
y2: y,
|
||||
class: 'usage-dashboard__chart-grid',
|
||||
}));
|
||||
const label = usageSvgElement('text', {
|
||||
x: padding.left - 10,
|
||||
y: y + 4,
|
||||
class: 'usage-dashboard__chart-axis',
|
||||
'text-anchor': 'end',
|
||||
});
|
||||
label.textContent = formatUsageNumber(maxValue - (maxValue * tick) / yTicks);
|
||||
usageDashboardTrend.appendChild(label);
|
||||
if (!usageDashboardTrend) return;
|
||||
const chart = getUsageEchart(usageDashboardTrend);
|
||||
if (!chart) {
|
||||
usageDashboardTrend.dataset.chartState = 'unavailable';
|
||||
usageDashboardTrend.textContent = '图表组件未加载';
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
const xAt = (index) => rows.length === 1
|
||||
? padding.left + chartWidth / 2
|
||||
: padding.left + (chartWidth * index) / (rows.length - 1);
|
||||
const yAt = (value) => padding.top + chartHeight - (Math.max(0, Number(value || 0)) / maxValue) * chartHeight;
|
||||
const labelStride = Math.max(1, Math.ceil(rows.length / 7));
|
||||
rows.forEach((row, index) => {
|
||||
if (index % labelStride !== 0 && index !== rows.length - 1) return;
|
||||
const label = usageSvgElement('text', {
|
||||
x: xAt(index),
|
||||
y: height - 14,
|
||||
class: 'usage-dashboard__chart-axis',
|
||||
'text-anchor': 'middle',
|
||||
});
|
||||
label.textContent = String(row.date || '').slice(5).replace('-', '/');
|
||||
usageDashboardTrend.appendChild(label);
|
||||
});
|
||||
series.forEach((item) => {
|
||||
const points = rows.map((row, index) => `${xAt(index)},${yAt(row?.[item.key])}`);
|
||||
const path = usageSvgElement('path', {
|
||||
d: points.map((point, index) => `${index === 0 ? 'M' : 'L'}${point}`).join(' '),
|
||||
class: `usage-dashboard__chart-line usage-dashboard__chart-line--${item.className}`,
|
||||
});
|
||||
usageDashboardTrend.appendChild(path);
|
||||
if (rows.length <= 14) {
|
||||
rows.forEach((row, index) => {
|
||||
usageDashboardTrend.appendChild(usageSvgElement('circle', {
|
||||
cx: xAt(index),
|
||||
cy: yAt(row?.[item.key]),
|
||||
r: 3,
|
||||
class: `usage-dashboard__chart-dot usage-dashboard__chart-dot--${item.className}`,
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
usageDashboardTrend.dataset.chartState = 'ready';
|
||||
const rows = Array.isArray(data?.trend) ? data.trend : [];
|
||||
if (rows.length === 0) {
|
||||
chart.clear();
|
||||
return;
|
||||
}
|
||||
const palette = getUsageChartPalette();
|
||||
const isMobile = window.matchMedia?.('(max-width: 560px)').matches;
|
||||
const labelInterval = Math.max(0, Math.ceil(rows.length / (isMobile ? 5 : 7)) - 1);
|
||||
const animationDuration = usageChartAnimationDuration();
|
||||
chart.setOption({
|
||||
animation: animationDuration > 0,
|
||||
animationDuration,
|
||||
animationDurationUpdate: animationDuration,
|
||||
aria: {
|
||||
enabled: true,
|
||||
description: usageDashboardTrend.getAttribute('aria-label') || '每日使用趋势',
|
||||
},
|
||||
grid: {
|
||||
left: isMobile ? 36 : 44,
|
||||
right: isMobile ? 34 : 42,
|
||||
top: isMobile ? 50 : 44,
|
||||
bottom: 28,
|
||||
containLabel: false,
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
itemWidth: 12,
|
||||
itemHeight: 6,
|
||||
itemGap: isMobile ? 7 : 11,
|
||||
textStyle: { color: palette.muted, fontSize: 9 },
|
||||
selectedMode: true,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
appendToBody: false,
|
||||
backgroundColor: palette.surface,
|
||||
borderColor: palette.border,
|
||||
borderWidth: 1,
|
||||
padding: [8, 10],
|
||||
textStyle: { color: palette.text, fontSize: 11 },
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
lineStyle: { color: palette.border, type: 'dashed', width: 1 },
|
||||
},
|
||||
valueFormatter: (value) => formatUsageNumber(value),
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((row) => String(row?.date || '').slice(5).replace('-', '/')),
|
||||
boundaryGap: true,
|
||||
axisLine: { lineStyle: { color: palette.border } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: palette.muted,
|
||||
fontSize: 9,
|
||||
interval: labelInterval,
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
min: 0,
|
||||
splitNumber: 4,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: palette.muted, fontSize: 9, formatter: (value) => formatUsageNumber(value) },
|
||||
splitLine: { lineStyle: { color: usageChartColorWithAlpha(palette.border, 0.62), width: 1 } },
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
min: 0,
|
||||
splitNumber: 4,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: palette.muted, fontSize: 9, formatter: (value) => formatUsageNumber(value) },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '消息',
|
||||
type: 'bar',
|
||||
data: rows.map((row) => Math.max(0, Number(row?.messages || 0))),
|
||||
yAxisIndex: 0,
|
||||
barMaxWidth: 8,
|
||||
itemStyle: {
|
||||
color: usageChartColorWithAlpha(palette.messages, 0.52),
|
||||
borderRadius: [2, 2, 0, 0],
|
||||
},
|
||||
emphasis: { focus: 'series' },
|
||||
},
|
||||
{
|
||||
name: 'MCP',
|
||||
type: 'bar',
|
||||
data: rows.map((row) => Math.max(0, Number(row?.mcpCalls || 0))),
|
||||
yAxisIndex: 0,
|
||||
barMaxWidth: 8,
|
||||
itemStyle: {
|
||||
color: usageChartColorWithAlpha(palette.mcp, 0.42),
|
||||
borderRadius: [2, 2, 0, 0],
|
||||
},
|
||||
emphasis: { focus: 'series' },
|
||||
},
|
||||
{
|
||||
name: '新建会话',
|
||||
type: 'line',
|
||||
data: rows.map((row) => Math.max(0, Number(row?.newSessions || 0))),
|
||||
yAxisIndex: 1,
|
||||
smooth: 0.22,
|
||||
showSymbol: rows.length <= 14,
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
lineStyle: { color: palette.sessions, width: 2 },
|
||||
itemStyle: { color: palette.sessions, borderColor: palette.surface, borderWidth: 1 },
|
||||
emphasis: { focus: 'series' },
|
||||
},
|
||||
{
|
||||
name: '项目',
|
||||
type: 'line',
|
||||
data: rows.map((row) => Math.max(0, Number(row?.projects || 0))),
|
||||
yAxisIndex: 1,
|
||||
smooth: 0.22,
|
||||
showSymbol: rows.length <= 14,
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
lineStyle: { color: palette.projects, width: 1.8, type: 'dashed' },
|
||||
itemStyle: { color: palette.projects, borderColor: palette.surface, borderWidth: 1 },
|
||||
emphasis: { focus: 'series' },
|
||||
},
|
||||
],
|
||||
}, { notMerge: true, lazyUpdate: false });
|
||||
}
|
||||
|
||||
function renderUsageMcpStatus(data) {
|
||||
if (!usageDashboardMcpStatus) return;
|
||||
disposeUsageEchartsIn(usageDashboardMcpStatus);
|
||||
usageDashboardMcpStatus.replaceChildren();
|
||||
const status = data?.mcpStatus || {};
|
||||
const rows = [
|
||||
{ key: 'completed', label: '成功', className: 'success' },
|
||||
{ key: 'failed', label: '失败', className: 'failure' },
|
||||
{ key: 'other', label: '其他', className: 'other' },
|
||||
const overview = data?.overview || {};
|
||||
const palette = getUsageChartPalette();
|
||||
const cards = [
|
||||
{
|
||||
title: 'MCP 调用成功率',
|
||||
centerValue: (segments) => {
|
||||
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
|
||||
return total > 0 ? `${((segments[0].value / total) * 100).toFixed(1)}%` : '0%';
|
||||
},
|
||||
centerLabel: '成功率',
|
||||
segments: [
|
||||
{ label: '成功', value: Number(status.completed || 0), colorVar: '--usage-dashboard-success', fallback: '#4f8a56' },
|
||||
{ label: '失败', value: Number(status.failed || 0), colorVar: '--usage-dashboard-danger', fallback: '#d95545' },
|
||||
{ label: '其他', value: Number(status.other || 0), colorVar: '--usage-dashboard-info', fallback: '#5d85a6' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '消息来源占比',
|
||||
centerValue: (segments) => formatUsageNumber(segments.reduce((sum, segment) => sum + segment.value, 0)),
|
||||
centerLabel: '消息',
|
||||
segments: [
|
||||
{ label: '直接消息', value: Number(overview.directMessages || 0), colorVar: '--usage-dashboard-metric-messages', fallback: '#c45a3c' },
|
||||
{ label: '跨会话', value: Number(overview.crossConversationMessages || 0), colorVar: '--usage-dashboard-accent', fallback: '#7f5f52' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '会话项目归属',
|
||||
centerValue: (segments) => formatUsageNumber(segments.reduce((sum, segment) => sum + segment.value, 0)),
|
||||
centerLabel: '会话',
|
||||
segments: [
|
||||
{ label: '已归属项目', value: Number(overview.projectSessions || 0), colorVar: '--usage-dashboard-metric-projects', fallback: '#d97706' },
|
||||
{ label: '未归属', value: Number(overview.unassignedProjectSessions || 0), colorVar: '--usage-dashboard-info', fallback: '#5d85a6' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const total = Math.max(1, rows.reduce((sum, row) => sum + Math.max(0, Number(status[row.key] || 0)), 0));
|
||||
rows.forEach((row) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = `usage-dashboard__status usage-dashboard__status--${row.className}`;
|
||||
const header = document.createElement('div');
|
||||
const label = document.createElement('span');
|
||||
label.textContent = row.label;
|
||||
const value = document.createElement('strong');
|
||||
value.textContent = formatUsageNumber(status[row.key]);
|
||||
header.append(label, value);
|
||||
const track = document.createElement('span');
|
||||
track.className = 'usage-dashboard__status-track';
|
||||
const fill = document.createElement('span');
|
||||
fill.style.width = `${(Math.max(0, Number(status[row.key] || 0)) / total) * 100}%`;
|
||||
track.appendChild(fill);
|
||||
item.append(header, track);
|
||||
cards.forEach((card) => {
|
||||
const segments = card.segments.map((segment) => ({
|
||||
...segment,
|
||||
value: Math.max(0, Number(segment.value || 0)),
|
||||
color: resolveUsageChartColor(segment.colorVar, segment.fallback),
|
||||
}));
|
||||
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
|
||||
const item = document.createElement('article');
|
||||
item.className = 'usage-dashboard__status-card';
|
||||
const title = document.createElement('h4');
|
||||
title.textContent = card.title;
|
||||
const content = document.createElement('div');
|
||||
content.className = 'usage-dashboard__status-content';
|
||||
const donut = document.createElement('div');
|
||||
donut.className = 'usage-dashboard__donut-chart';
|
||||
donut.setAttribute('role', 'img');
|
||||
donut.setAttribute('aria-label', `${card.title},总计 ${formatUsageNumber(total)}`);
|
||||
const legend = document.createElement('div');
|
||||
legend.className = 'usage-dashboard__status-legend';
|
||||
segments.forEach((segment) => {
|
||||
const row = document.createElement('div');
|
||||
const label = document.createElement('span');
|
||||
label.style.setProperty('--usage-segment-color', segment.color);
|
||||
label.textContent = segment.label;
|
||||
const value = document.createElement('strong');
|
||||
const share = total > 0 ? (segment.value / total) * 100 : 0;
|
||||
value.textContent = `${formatUsageNumber(segment.value)} · ${share.toFixed(1)}%`;
|
||||
row.append(label, value);
|
||||
legend.appendChild(row);
|
||||
});
|
||||
content.append(donut, legend);
|
||||
item.append(title, content);
|
||||
usageDashboardMcpStatus.appendChild(item);
|
||||
const chart = getUsageEchart(donut);
|
||||
if (!chart) {
|
||||
donut.dataset.chartState = 'unavailable';
|
||||
donut.textContent = '—';
|
||||
return;
|
||||
}
|
||||
donut.dataset.chartState = 'ready';
|
||||
const centerValue = card.centerValue(segments);
|
||||
const chartData = total > 0
|
||||
? segments.map((segment) => ({
|
||||
name: segment.label,
|
||||
value: segment.value,
|
||||
itemStyle: { color: segment.color },
|
||||
}))
|
||||
: [{
|
||||
name: '无数据',
|
||||
value: 1,
|
||||
itemStyle: { color: usageChartColorWithAlpha(palette.border, 0.34) },
|
||||
}];
|
||||
const animationDuration = usageChartAnimationDuration();
|
||||
chart.setOption({
|
||||
animation: animationDuration > 0,
|
||||
animationDuration,
|
||||
animationDurationUpdate: animationDuration,
|
||||
aria: {
|
||||
enabled: true,
|
||||
description: donut.getAttribute('aria-label') || card.title,
|
||||
},
|
||||
tooltip: {
|
||||
show: total > 0,
|
||||
trigger: 'item',
|
||||
confine: true,
|
||||
backgroundColor: palette.surface,
|
||||
borderColor: palette.border,
|
||||
borderWidth: 1,
|
||||
padding: [7, 9],
|
||||
textStyle: { color: palette.text, fontSize: 10 },
|
||||
valueFormatter: (value) => formatUsageNumber(value),
|
||||
},
|
||||
graphic: [
|
||||
{
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: '34%',
|
||||
silent: true,
|
||||
style: {
|
||||
text: centerValue,
|
||||
fill: palette.text,
|
||||
font: '700 13px sans-serif',
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: '56%',
|
||||
silent: true,
|
||||
style: {
|
||||
text: card.centerLabel,
|
||||
fill: palette.muted,
|
||||
font: '7px sans-serif',
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['62%', '82%'],
|
||||
center: ['50%', '50%'],
|
||||
startAngle: 90,
|
||||
minAngle: total > 0 ? 2 : 360,
|
||||
clockwise: true,
|
||||
avoidLabelOverlap: true,
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
emphasis: { scale: true, scaleSize: 2 },
|
||||
data: chartData,
|
||||
}],
|
||||
}, { notMerge: true, lazyUpdate: false });
|
||||
});
|
||||
}
|
||||
|
||||
function showUsageMcpDetail(toolRow) {
|
||||
function setUsageMcpDetailOpen(open) {
|
||||
const isOpen = !!open;
|
||||
if (usageDashboardDetail) usageDashboardDetail.hidden = !isOpen;
|
||||
if (usageDashboardDetailScrim) usageDashboardDetailScrim.hidden = !isOpen;
|
||||
if (usageDashboardPanel) usageDashboardPanel.dataset.detailOpen = isOpen ? 'true' : 'false';
|
||||
}
|
||||
|
||||
function closeUsageMcpDetail(options = {}) {
|
||||
usageDashboardState.selectedMcpKey = '';
|
||||
setUsageMcpDetailOpen(false);
|
||||
if (options.restoreFocus !== false) usageDashboardMcpRows?.querySelector('button')?.focus();
|
||||
}
|
||||
|
||||
function showUsageMcpDetail(toolRow, options = {}) {
|
||||
if (!usageDashboardDetail || !usageDashboardDetailRows || !usageDashboardState.data) return;
|
||||
const key = String(toolRow?.key || `${toolRow?.server || ''}/${toolRow?.tool || ''}`);
|
||||
usageDashboardState.selectedMcpKey = key;
|
||||
@@ -4636,9 +5001,12 @@
|
||||
row.appendChild(cell);
|
||||
usageDashboardDetailRows.appendChild(row);
|
||||
}
|
||||
usageDashboardDetail.hidden = false;
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
usageDashboardDetail.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
|
||||
usageDashboardDetail.dataset.motion = reduceMotion ? 'reduced' : 'normal';
|
||||
setUsageMcpDetailOpen(true);
|
||||
if (options.focus !== false) {
|
||||
requestAnimationFrame(() => usageDashboardDetailClose?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsageMcpTools(data) {
|
||||
@@ -4646,59 +5014,97 @@
|
||||
usageDashboardMcpRows.replaceChildren();
|
||||
const tools = Array.isArray(data?.mcpTools) ? data.mcpTools : [];
|
||||
usageDashboardMcpEmpty.hidden = tools.length > 0;
|
||||
tools.forEach((toolRow) => {
|
||||
const visibleTools = usageDashboardState.mcpToolsExpanded ? tools : tools.slice(0, 6);
|
||||
if (usageDashboardMcpMore) {
|
||||
const remaining = Math.max(0, tools.length - 6);
|
||||
usageDashboardMcpMore.hidden = remaining === 0;
|
||||
usageDashboardMcpMore.setAttribute('aria-expanded', usageDashboardState.mcpToolsExpanded ? 'true' : 'false');
|
||||
usageDashboardMcpMore.textContent = usageDashboardState.mcpToolsExpanded
|
||||
? '收起其余工具'
|
||||
: `查看其余 ${formatUsageNumber(remaining)} 个工具`;
|
||||
}
|
||||
visibleTools.forEach((toolRow, index) => {
|
||||
const row = document.createElement('tr');
|
||||
const rank = document.createElement('td');
|
||||
rank.className = 'usage-dashboard__table-rank';
|
||||
rank.textContent = String(index + 1);
|
||||
const name = document.createElement('td');
|
||||
const nameWrap = document.createElement('span');
|
||||
nameWrap.className = 'usage-dashboard__tool-name';
|
||||
const server = document.createElement('span');
|
||||
server.className = 'usage-dashboard__tool-server';
|
||||
server.textContent = toolRow.server || '';
|
||||
const separator = document.createElement('span');
|
||||
separator.className = 'usage-dashboard__tool-separator';
|
||||
separator.textContent = ' / ';
|
||||
const tool = document.createElement('strong');
|
||||
tool.textContent = toolRow.tool || '';
|
||||
tool.title = `${toolRow.server || ''}/${toolRow.tool || ''}`;
|
||||
name.append(server, tool);
|
||||
nameWrap.append(server, separator, tool);
|
||||
name.appendChild(nameWrap);
|
||||
const calls = document.createElement('td');
|
||||
calls.textContent = formatUsageNumber(toolRow.calls);
|
||||
const completed = document.createElement('td');
|
||||
completed.className = 'usage-dashboard__success-text';
|
||||
completed.textContent = formatUsageNumber(toolRow.completed);
|
||||
const failed = document.createElement('td');
|
||||
failed.textContent = formatUsageNumber(toolRow.failed);
|
||||
failed.className = Number(toolRow.failed || 0) > 0 ? 'usage-dashboard__failure-text' : '';
|
||||
const rate = document.createElement('td');
|
||||
rate.textContent = `${Number(toolRow.successRate || 0).toFixed(1)}%`;
|
||||
const action = document.createElement('td');
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'usage-dashboard__row-action';
|
||||
button.textContent = '›';
|
||||
button.className = 'usage-dashboard__rate-action';
|
||||
button.title = '查看调用明细';
|
||||
button.setAttribute('aria-label', `查看 ${toolRow.server || ''} ${toolRow.tool || ''} 的调用明细`);
|
||||
const rateTrack = document.createElement('span');
|
||||
rateTrack.className = 'usage-dashboard__rate-track';
|
||||
const rateFill = document.createElement('span');
|
||||
rateFill.style.width = `${Math.max(0, Math.min(100, Number(toolRow.successRate || 0)))}%`;
|
||||
rateTrack.appendChild(rateFill);
|
||||
const rateValue = document.createElement('strong');
|
||||
rateValue.textContent = `${Number(toolRow.successRate || 0).toFixed(1)}%`;
|
||||
button.append(rateTrack, rateValue);
|
||||
button.addEventListener('click', () => showUsageMcpDetail(toolRow));
|
||||
action.appendChild(button);
|
||||
row.append(name, calls, completed, failed, rate, action);
|
||||
rate.appendChild(button);
|
||||
row.append(rank, name, calls, completed, failed, rate);
|
||||
usageDashboardMcpRows.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderUsageSkills(data) {
|
||||
if (!usageDashboardSkillRows || !usageDashboardSkillEmpty) return;
|
||||
usageDashboardSkillRows.replaceChildren();
|
||||
const skills = Array.isArray(data?.skills) ? data.skills : [];
|
||||
usageDashboardSkillEmpty.hidden = skills.length > 0;
|
||||
skills.forEach((skill, index) => {
|
||||
function renderUsageProjects(data) {
|
||||
if (!usageDashboardProjectRows || !usageDashboardProjectEmpty) return;
|
||||
usageDashboardProjectRows.replaceChildren();
|
||||
const projects = Array.isArray(data?.projects) ? data.projects : [];
|
||||
usageDashboardProjectEmpty.hidden = projects.length > 0;
|
||||
projects.forEach((project, index) => {
|
||||
const item = document.createElement('li');
|
||||
const rank = document.createElement('span');
|
||||
rank.className = 'usage-dashboard__rank';
|
||||
rank.textContent = String(index + 1).padStart(2, '0');
|
||||
rank.textContent = String(index + 1);
|
||||
const label = document.createElement('strong');
|
||||
label.textContent = skill.label || `$${skill.name || ''}`;
|
||||
label.title = skill.label || skill.name || '';
|
||||
const value = document.createElement('span');
|
||||
value.textContent = `${formatUsageNumber(skill.uses)} 次`;
|
||||
item.append(rank, label, value);
|
||||
usageDashboardSkillRows.appendChild(item);
|
||||
label.textContent = project.name || '未命名项目';
|
||||
label.title = project.cwd || project.name || '';
|
||||
const sessionCount = document.createElement('span');
|
||||
sessionCount.className = 'usage-dashboard__rank-count';
|
||||
sessionCount.textContent = formatUsageNumber(project.sessions);
|
||||
const messages = document.createElement('span');
|
||||
messages.className = 'usage-dashboard__rank-count';
|
||||
messages.textContent = formatUsageNumber(project.messages);
|
||||
const mcpCalls = document.createElement('span');
|
||||
mcpCalls.className = 'usage-dashboard__rank-count';
|
||||
mcpCalls.textContent = formatUsageNumber(project.mcpCalls);
|
||||
item.append(rank, label, sessionCount, messages, mcpCalls);
|
||||
usageDashboardProjectRows.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function getUsageProjectLabel(sessionRow) {
|
||||
const projectName = String(sessionRow?.projectName || '').trim();
|
||||
if (projectName) return projectName;
|
||||
const cwd = String(sessionRow?.cwd || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
return cwd.split('/').filter(Boolean).pop() || '未归属';
|
||||
}
|
||||
|
||||
function renderUsageSessions(data) {
|
||||
if (!usageDashboardSessionRows || !usageDashboardSessionEmpty) return;
|
||||
usageDashboardSessionRows.replaceChildren();
|
||||
@@ -4712,20 +5118,31 @@
|
||||
button.className = 'usage-dashboard__session-link';
|
||||
button.textContent = sessionRow.title || '未命名会话';
|
||||
button.title = sessionRow.cwd || sessionRow.title || '';
|
||||
button.addEventListener('click', () => {
|
||||
const openTargetSession = () => {
|
||||
closeUsageDashboard({ restoreFocus: false });
|
||||
openSession(sessionRow.sessionId, { force: true, blocking: false });
|
||||
});
|
||||
};
|
||||
button.addEventListener('click', openTargetSession);
|
||||
name.appendChild(button);
|
||||
const messages = document.createElement('td');
|
||||
messages.textContent = formatUsageNumber(sessionRow.messages);
|
||||
const mcp = document.createElement('td');
|
||||
mcp.textContent = formatUsageNumber(sessionRow.mcpCalls);
|
||||
const skills = document.createElement('td');
|
||||
skills.textContent = formatUsageNumber(sessionRow.skillMentions);
|
||||
const project = document.createElement('td');
|
||||
project.className = 'usage-dashboard__session-project';
|
||||
project.textContent = getUsageProjectLabel(sessionRow);
|
||||
project.title = sessionRow.cwd || project.textContent;
|
||||
const time = document.createElement('td');
|
||||
time.textContent = formatUsageTime(sessionRow.lastActivity);
|
||||
row.append(name, messages, mcp, skills, time);
|
||||
const action = document.createElement('td');
|
||||
const view = document.createElement('button');
|
||||
view.type = 'button';
|
||||
view.className = 'usage-dashboard__session-action';
|
||||
view.textContent = '查看';
|
||||
view.setAttribute('aria-label', `查看会话 ${sessionRow.title || '未命名会话'}`);
|
||||
view.addEventListener('click', openTargetSession);
|
||||
action.appendChild(view);
|
||||
row.append(name, messages, mcp, project, time, action);
|
||||
usageDashboardSessionRows.appendChild(row);
|
||||
});
|
||||
}
|
||||
@@ -4754,16 +5171,18 @@
|
||||
const indexed = formatUsageNumber(data.coverage?.indexedSessions);
|
||||
usageDashboardStatus.textContent = `基于当前保留数据 · ${indexed} 个会话 · MCP 按 assistant 消息时间归属`;
|
||||
}
|
||||
renderUsageSparklines(data);
|
||||
renderUsageTrend(data);
|
||||
renderUsageMcpStatus(data);
|
||||
renderUsageMcpTools(data);
|
||||
renderUsageSkills(data);
|
||||
renderUsageProjects(data);
|
||||
renderUsageSessions(data);
|
||||
if (usageDashboardDetail && usageDashboardState.selectedMcpKey) {
|
||||
const selected = (data.mcpTools || []).find((item) => item.key === usageDashboardState.selectedMcpKey);
|
||||
if (selected) showUsageMcpDetail(selected);
|
||||
else usageDashboardDetail.hidden = true;
|
||||
if (selected) showUsageMcpDetail(selected, { focus: false });
|
||||
else setUsageMcpDetailOpen(false);
|
||||
}
|
||||
scheduleUsageEchartResize();
|
||||
}
|
||||
|
||||
function runUsageDashboardQuery() {
|
||||
@@ -4811,6 +5230,7 @@
|
||||
renderUsageDashboard();
|
||||
setUsageDashboardState('ready');
|
||||
}
|
||||
scheduleUsageEchartResize();
|
||||
requestAnimationFrame(() => usageDashboardClose?.focus());
|
||||
runUsageDashboardQuery();
|
||||
}
|
||||
@@ -4843,6 +5263,7 @@
|
||||
if (!requestId || requestId !== usageDashboardState.requestId) return;
|
||||
usageDashboardState.requestId = '';
|
||||
usageDashboardState.data = msg;
|
||||
usageDashboardState.mcpToolsExpanded = false;
|
||||
usageDashboardState.error = '';
|
||||
renderUsageDashboard(msg);
|
||||
setUsageDashboardState('ready');
|
||||
@@ -10563,6 +10984,10 @@
|
||||
usageDashboardOpen.addEventListener('click', openUsageDashboard);
|
||||
usageDashboardClose?.addEventListener('click', () => closeUsageDashboard());
|
||||
usageDashboardRefresh?.addEventListener('click', runUsageDashboardQuery);
|
||||
usageDashboardMcpMore?.addEventListener('click', () => {
|
||||
usageDashboardState.mcpToolsExpanded = !usageDashboardState.mcpToolsExpanded;
|
||||
renderUsageMcpTools(usageDashboardState.data);
|
||||
});
|
||||
usageDashboardApply?.addEventListener('click', () => {
|
||||
usageDashboardState.period = 'custom';
|
||||
syncUsageDashboardPeriod('custom');
|
||||
@@ -10576,16 +11001,36 @@
|
||||
syncUsageDashboardPeriod('month');
|
||||
runUsageDashboardQuery();
|
||||
});
|
||||
usageDashboardPeriodCustom?.addEventListener('click', () => {
|
||||
syncUsageDashboardPeriod('custom');
|
||||
usageDashboardFrom?.focus();
|
||||
});
|
||||
[usageDashboardFrom, usageDashboardTo].forEach((input) => {
|
||||
input?.addEventListener('change', () => {
|
||||
usageDashboardState.period = 'custom';
|
||||
syncUsageDashboardPeriod('custom');
|
||||
});
|
||||
});
|
||||
usageDashboardDetailClose?.addEventListener('click', () => {
|
||||
usageDashboardState.selectedMcpKey = '';
|
||||
if (usageDashboardDetail) usageDashboardDetail.hidden = true;
|
||||
});
|
||||
usageDashboardDetailClose?.addEventListener('click', () => closeUsageMcpDetail());
|
||||
usageDashboardDetailScrim?.addEventListener('click', () => closeUsageMcpDetail({ restoreFocus: false }));
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
const usageDashboardResizeObserver = new ResizeObserver(() => scheduleUsageEchartResize());
|
||||
usageDashboardResizeObserver.observe(usageDashboardPanel);
|
||||
}
|
||||
window.addEventListener('resize', scheduleUsageEchartResize);
|
||||
if (typeof MutationObserver === 'function') {
|
||||
const usageDashboardThemeObserver = new MutationObserver((mutations) => {
|
||||
if (mutations.some((mutation) => mutation.attributeName === 'data-theme')) {
|
||||
scheduleUsageEchartRefresh();
|
||||
}
|
||||
});
|
||||
usageDashboardThemeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
});
|
||||
}
|
||||
const usageDashboardMotionPreference = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
||||
usageDashboardMotionPreference?.addEventListener?.('change', scheduleUsageEchartRefresh);
|
||||
syncUsageDashboardPeriod('week');
|
||||
setUsageDashboardFeatureEnabled(false);
|
||||
}
|
||||
@@ -10596,6 +11041,11 @@
|
||||
openAdvancedSessionSearch();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && usageDashboardState.open && usageDashboardDetail && !usageDashboardDetail.hidden) {
|
||||
event.preventDefault();
|
||||
closeUsageMcpDetail();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && usageDashboardState.open) {
|
||||
event.preventDefault();
|
||||
closeUsageDashboard();
|
||||
|
||||
Reference in New Issue
Block a user