feat: add project usage analytics with ECharts

This commit is contained in:
shiyue
2026-08-04 00:49:52 +08:00
parent 58d5f816c2
commit c61b7434d2
39 changed files with 5040 additions and 283 deletions

View File

@@ -622,8 +622,8 @@ function assertFrontendSidebarCollapseContract() {
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
);
assert(
indexSource.includes('style.css?v=20260803-usage-statistics')
&& indexSource.includes('app.js?v=20260803-usage-statistics'),
indexSource.includes('style.css?v=20260803-usage-statistics-echarts')
&& indexSource.includes('app.js?v=20260803-usage-statistics-echarts'),
'Sidebar interaction assets should share the reviewed cache-busting version'
);
}
@@ -946,8 +946,8 @@ function assertPlanListProgressContract() {
assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet');
assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments');
assert(indexSource.includes('style.css?v=20260803-usage-statistics'), 'Plan progress CSS should use the current cache-busted URL');
assert(indexSource.includes('app.js?v=20260803-usage-statistics'), 'Plan progress frontend logic should use the current cache-busted URL');
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts'), 'Plan progress CSS should use the current cache-busted URL');
assert(indexSource.includes('app.js?v=20260803-usage-statistics-echarts'), 'Plan progress frontend logic should use the current cache-busted URL');
}
function assertFrontendGildedThemeContract() {
@@ -1062,8 +1062,8 @@ function assertFrontendGildedThemeContract() {
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
assert(indexSource.includes('style.css?v=20260803-usage-statistics'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
assert(indexSource.includes('app.js?v=20260803-usage-statistics'), 'Theme bundle app script should use the current cache-busted asset URL');
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
assert(indexSource.includes('app.js?v=20260803-usage-statistics-echarts'), 'Theme bundle app script should use the current cache-busted asset URL');
}
function assertFrontendWastelandThemeContract() {
@@ -1316,8 +1316,8 @@ function assertFrontendWastelandThemeContract() {
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
});
assert(indexSource.includes('style.css?v=20260803-usage-statistics'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
assert(indexSource.includes('app.js?v=20260803-usage-statistics'), 'Wasteland registration should share the cache-busted theme bundle URL');
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
assert(indexSource.includes('app.js?v=20260803-usage-statistics-echarts'), 'Wasteland registration should share the cache-busted theme bundle URL');
}
function assertFrontendCcwebPromptContract() {
@@ -4449,8 +4449,8 @@ function assertAdvancedSessionSearchContract() {
'Existing sidebar search clear control contract should remain unchanged');
assert(indexSource.includes('id="advanced-search-panel"') && indexSource.includes('id="advanced-search-results"'),
'Advanced search workspace should expose stable panel and result hooks');
assert(indexSource.includes('style.css?v=20260803-usage-statistics')
&& indexSource.includes('app.js?v=20260803-usage-statistics'),
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts')
&& indexSource.includes('app.js?v=20260803-usage-statistics-echarts'),
'Advanced search CSS and frontend script should share the reviewed cache-bust');
const panelStyleStart = styleSource.indexOf('.advanced-search-panel {');
@@ -4519,23 +4519,89 @@ function assertUsageStatisticsContract() {
const usageOpenIndex = indexSource.indexOf('id="usage-dashboard-open"', footerIndex);
const chatMainIndex = indexSource.indexOf('class="chat-main"');
const usagePanelIndex = indexSource.indexOf('id="usage-dashboard-panel"');
const metricGridIndex = indexSource.indexOf('class="usage-dashboard__metric-grid"', usagePanelIndex);
const primaryGridIndex = indexSource.indexOf('usage-dashboard__content-grid--primary', usagePanelIndex);
const trendPanelIndex = indexSource.indexOf('usage-dashboard__panel--trend', primaryGridIndex);
const mcpPanelIndex = indexSource.indexOf('usage-dashboard__panel--mcp', primaryGridIndex);
const lowerGridIndex = indexSource.indexOf('usage-dashboard__content-grid--lower', mcpPanelIndex);
const statusPanelIndex = indexSource.indexOf('usage-dashboard__panel--status', lowerGridIndex);
const projectsPanelIndex = indexSource.indexOf('usage-dashboard__panel--projects', lowerGridIndex);
const sessionsPanelIndex = indexSource.indexOf('usage-dashboard__panel--sessions', projectsPanelIndex);
assert(sessionListIndex >= 0 && footerIndex > sessionListIndex && settingsIndex > footerIndex && usageOpenIndex > settingsIndex,
'Usage entry should stay in the fixed sidebar footer after settings, outside the session list');
assert(chatMainIndex >= 0 && usagePanelIndex > chatMainIndex,
'Usage dashboard should be a chat-main-local workspace');
assert(indexSource.includes('class="usage-dashboard-open"') && !indexSource.includes('class="settings-btn usage-dashboard-open"'),
'Usage entry must use its own class so theme-specific settings pseudo-elements cannot leak');
assert(indexSource.includes('style.css?v=20260803-usage-statistics')
&& indexSource.includes('app.js?v=20260803-usage-statistics'),
'Usage dashboard CSS and frontend script should share the current cache-bust');
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts')
&& indexSource.includes('vendor/echarts.min.js?v=5.6.0')
&& indexSource.includes('app.js?v=20260803-usage-statistics-echarts'),
'Usage dashboard CSS, local ECharts runtime, and frontend script should share the current asset contract');
assert(metricGridIndex > usagePanelIndex
&& primaryGridIndex > metricGridIndex
&& trendPanelIndex > primaryGridIndex
&& mcpPanelIndex > trendPanelIndex
&& lowerGridIndex > mcpPanelIndex
&& statusPanelIndex > lowerGridIndex
&& projectsPanelIndex > statusPanelIndex
&& sessionsPanelIndex > projectsPanelIndex,
'Usage dashboard should follow the reference hierarchy: metrics, trend + MCP, status + projects, then full-width sessions');
assert((indexSource.match(/<div class="usage-dashboard__sparkline" data-usage-spark=/g) || []).length === 5
&& indexSource.includes('<div id="usage-dashboard-trend" class="usage-dashboard__chart"')
&& indexSource.includes('id="usage-dashboard-period-custom"')
&& indexSource.includes('usage-dashboard__table--mcp')
&& indexSource.includes('id="usage-dashboard-mcp-more"')
&& indexSource.includes('id="usage-dashboard-detail-scrim"')
&& indexSource.includes('usage-dashboard__rank-head')
&& indexSource.includes('usage-dashboard__table--sessions'),
'Usage dashboard should expose five ECharts sparkline containers, a trend container, custom range selection, and dense MCP/project/session tables');
assert(!indexSource.includes('Skill')
&& !indexSource.includes('skill')
&& indexSource.includes('项目使用排行')
&& indexSource.includes('id="usage-dashboard-project-rows"')
&& indexSource.includes('<th scope="col">项目</th>'),
'Usage dashboard should replace every Skill surface with project ranking and recent-session project attribution');
assert(fs.existsSync(path.join(REPO_DIR, 'public', 'vendor', 'echarts.min.js'))
&& fs.existsSync(path.join(REPO_DIR, 'public', 'vendor', 'echarts.LICENSE'))
&& fs.existsSync(path.join(REPO_DIR, 'public', 'vendor', 'echarts.NOTICE'))
&& !/https?:\/\/[^"']*echarts/i.test(indexSource),
'Usage dashboard should ship ECharts and its notices locally without a runtime CDN dependency');
assert(!indexSource.includes('独立用户数')
&& !indexSource.includes('会话时长')
&& !indexSource.includes('全部组织'),
'Usage dashboard must not copy reference-only fields that are absent from the retained-session statistics protocol');
const usageFunctionsStart = frontendSource.indexOf('function usageDateInputValue');
const usageFunctionsEnd = frontendSource.indexOf('function getProjectCollapseKey', usageFunctionsStart);
const usageFunctions = frontendSource.slice(usageFunctionsStart, usageFunctionsEnd);
assert(usageFunctions.includes("type: 'usage_stats_query'")
&& usageFunctions.includes('usageDashboardState.requestId')
&& usageFunctions.includes("behavior: reduceMotion ? 'auto' : 'smooth'"),
'Usage dashboard should use an independent request id and respect reduced-motion while navigating details');
&& usageFunctions.includes('usageDashboardDetail.dataset.motion')
&& usageFunctions.includes('setUsageMcpDetailOpen'),
'Usage dashboard should use an independent request id and use an in-context detail drawer with reduced-motion handling');
assert(usageFunctions.includes('function getUsageEchart')
&& usageFunctions.includes('window.echarts')
&& usageFunctions.includes('function renderUsageSparklines')
&& usageFunctions.includes("type: 'bar'")
&& usageFunctions.includes("type: 'line'")
&& usageFunctions.includes("type: 'pie'")
&& usageFunctions.includes('disposeUsageEchartsIn')
&& usageFunctions.includes('scheduleUsageEchartResize')
&& usageFunctions.includes('scheduleUsageEchartRefresh')
&& frontendSource.includes("attributeFilter: ['data-theme']")
&& usageFunctions.includes('function renderUsageProjects')
&& usageFunctions.includes('function getUsageProjectLabel')
&& usageFunctions.includes('data?.projects')
&& usageFunctions.includes('row?.projects')
&& usageFunctions.includes("usage-dashboard__rate-action")
&& usageFunctions.includes('tools.slice(0, 6)')
&& usageFunctions.includes('usageDashboardState.mcpToolsExpanded')
&& frontendSource.includes('usageDashboardPeriodCustom?.addEventListener'),
'Usage dashboard should render ECharts trends and truthful pies with theme/resize lifecycle, project aggregation, bounded MCP rankings, detail affordances, and custom ranges');
assert(!usageFunctions.includes("style.setProperty('--usage-donut'")
&& !styleSource.includes('.usage-dashboard__chart-line')
&& !styleSource.includes('.usage-dashboard__spark-line'),
'Usage dashboard should not retain the former conic-gradient or hand-authored SVG chart implementation');
assert(!/\bcurrentSessionId\s*=/.test(usageFunctions)
&& !/\bsessions\s*=/.test(usageFunctions)
&& !/\bsessionSearchQuery\s*=/.test(usageFunctions)
@@ -4570,15 +4636,28 @@ function assertUsageStatisticsContract() {
'Usage index should count explicit Skill mentions and MCP status without retaining message bodies');
assert(usageSource.includes('DEFAULT_MCP_TOOL_LIMIT = 200')
&& usageSource.includes('DEFAULT_SKILL_LIMIT = 100')
&& usageSource.includes('DEFAULT_PROJECT_LIMIT = 100')
&& usageSource.includes('trendProjectKeys')
&& usageSource.includes('returnedProjects')
&& usageSource.includes('unassignedProjectSessions')
&& usageSource.includes('detailCallsReturned')
&& frontendSource.includes('最近 ${formatUsageNumber(returnedCalls)} / 共 ${formatUsageNumber(totalCalls)} 条'),
'Usage result rankings and detail payloads should be bounded and visibly disclose partial detail windows');
'Usage result MCP/project rankings and detail payloads should be bounded and visibly disclose partial detail windows');
assert(styleSource.includes('/* === 使用统计看板:独立工作区 === */')
&& styleSource.includes('/* 使用统计原型版式:信息架构固定,主题只覆盖语义变量。 */')
&& styleSource.includes("html[data-theme='wasteland'] .usage-dashboard-open")
&& styleSource.includes("html[data-theme='gilded'] .usage-dashboard")
&& styleSource.includes("html[data-theme='coolvibe'] .usage-dashboard-open")
&& styleSource.includes('--usage-dashboard-accent-ink: #0d1b1f')
&& styleSource.includes('--usage-dashboard-metric-sessions: #357df2')
&& styleSource.includes('--usage-dashboard-metric-projects: #f0ad35')
&& styleSource.includes('.usage-dashboard__panel--projects')
&& styleSource.includes('.usage-dashboard__status-card')
&& styleSource.includes('.usage-dashboard__list-more')
&& styleSource.includes('.usage-dashboard__detail-scrim')
&& styleSource.includes('.usage-dashboard__content-grid--primary')
&& styleSource.includes('@media (max-width: 560px)')
&& /html\[data-theme='wasteland'\][\s\S]*?\.usage-dashboard__dates input\s*\{\s*color-scheme:\s*dark;/.test(styleSource)
&& /@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*?\.usage-dashboard/.test(styleSource),
'Usage dashboard should provide isolated theme surfaces, readable native controls, mobile sizing, and reduced-motion coverage');

View File

@@ -123,14 +123,23 @@ async function main() {
assert.strictEqual(result.overview.mcpCalls, 2);
assert.strictEqual(result.overview.mcpFailures, 1, 'done=true 的失败调用仍应计入失败');
assert.strictEqual(result.overview.skillMentions, 1);
assert.strictEqual(result.overview.projects, 1);
assert.strictEqual(result.overview.projectSessions, 1);
assert.strictEqual(result.overview.unassignedProjectSessions, 0);
assert.strictEqual(result.mcpStatus.completed, 1);
assert.strictEqual(result.mcpStatus.failed, 1);
assert.strictEqual(result.mcpTools[0].server, 'ccweb');
assert.strictEqual(result.mcpTools[0].calls, 1, '同调用数时应按工具名稳定排序');
assert.strictEqual(result.mcpTools[0].detailCallsReturned, 1);
assert.strictEqual(result.skills[0].name, 'openai-docs');
assert.strictEqual(result.projects[0].name, 'usage-project');
assert.strictEqual(result.projects[0].sessions, 1);
assert.strictEqual(result.projects[0].messages, 2);
assert.strictEqual(result.projects[0].mcpCalls, 2);
assert.strictEqual(result.recentSessions[0].projectName, 'usage-project');
assert.strictEqual(result.mcpRecentCalls[0].timestamp, '2026-07-28T02:00:00.000Z');
assert(result.trend.find((row) => row.date === '2026-07-28')?.mcpCalls === 2);
assert(result.trend.find((row) => row.date === '2026-07-28')?.projects === 1);
const serialized = JSON.stringify(result);
assert(!serialized.includes('secret-body'));
assert(!serialized.includes('secret-result'));
@@ -145,6 +154,7 @@ async function main() {
timeZone: 'UTC',
mcpToolLimit: 1,
skillLimit: 1,
projectLimit: 1,
mcpDetailLimit: 1,
});
assert.strictEqual(limited.coverage.distinctMcpTools, 2);
@@ -154,6 +164,9 @@ async function main() {
assert.strictEqual(limited.mcpTools[0].detailCallsReturned, 1);
assert.strictEqual(limited.coverage.distinctSkills, 1);
assert.strictEqual(limited.coverage.returnedSkills, 1);
assert.strictEqual(limited.coverage.distinctProjects, 1);
assert.strictEqual(limited.coverage.returnedProjects, 1);
assert.strictEqual(limited.projects.length, 1);
fixture.messages.push({ role: 'user', content: '增量消息', timestamp: '2026-07-30T04:00:00.000Z' });
writeSession(sessionsDir, fixture);