diff --git a/.planning/usage-statistics-echarts/findings.md b/.planning/usage-statistics-echarts/findings.md new file mode 100644 index 0000000..97bf824 --- /dev/null +++ b/.planning/usage-statistics-echarts/findings.md @@ -0,0 +1,22 @@ +# 使用统计 ECharts 迁移发现 + +## 基线 + +- codebase-memory 项目 `home-cc-web` 索引 ready:4,817 节点、10,212 条边。 +- 图表入口集中在 `renderUsageSparklines`、`renderUsageTrend`、`renderUsageMcpStatus`,总入口仍是 `renderUsageDashboard`。 +- 当前图表分别使用手写 SVG path/rect 与 CSS conic-gradient;统计数据已经完整,迁移不需要修改后端。 +- cc-web 没有前端打包流水线,页面直接加载 `public` 静态资源,因此 ECharts 应以本地 UMD bundle 接入。 +- 当前工作树已有同一看板未提交修改,迁移必须在这些改动上增量实施,不还原或格式化无关区域。 + +## 设计约束 + +- 不使用 ECharts 默认主题色板,颜色全部读取使用统计语义 CSS 变量。 +- 微趋势不展示坐标轴、图例和 tooltip chrome;主趋势才承担探索交互。 +- 环图继续使用现有 DOM 图例,ECharts 只负责可访问图形与 tooltip,避免信息重复。 +- reduced-motion 下关闭图表更新动画。 + +## 运行时接入 + +- 固定使用 `echarts@5.6.0`,通过单次 proxyd registry 安装,没有修改项目或用户 npm registry 配置。 +- 浏览器运行时位于 `public/vendor/echarts.min.js`,Apache 2.0 `LICENSE` 与 `NOTICE` 同目录保留。 +- npm 安装报告 2 个依赖树漏洞提示;遵照用户此前“不要审计”的要求,本轮不执行 `npm audit` 或自动修复,也不扩大依赖变更。 diff --git a/.planning/usage-statistics-echarts/progress.md b/.planning/usage-statistics-echarts/progress.md new file mode 100644 index 0000000..f835100 --- /dev/null +++ b/.planning/usage-statistics-echarts/progress.md @@ -0,0 +1,14 @@ +# 使用统计 ECharts 迁移进度 + +- 2026-08-03T21:12:00+08:00:完成技能读取、代码入口定位和迁移边界定义,开始引入本地 ECharts 运行时。 +- 2026-08-03T21:37:00+08:00:安装并固定 echarts@5.6.0;本地 bundle 与 npm 源文件 SHA-256 一致,许可证随 public/vendor 保留,开始迁移微趋势图。 +- 2026-08-03T21:41:00+08:00:五项 KPI 微趋势改为 ECharts 平滑折线、渐变面积与末端散点,语法检查通过,开始迁移主趋势混合图。 +- 2026-08-03T21:47:00+08:00:主趋势改为 ECharts 双轴柱线混合图,加入受约束 tooltip、可切换图例和移动标签密度,已删除手写 SVG 构造逻辑。 +- 2026-08-03T21:52:00+08:00:三个状态环图改为 ECharts pie,中心值、tooltip、主题色和无数据占位已接入;重渲染前主动 dispose 旧实例,开始补主题与 resize 生命周期。 +- 2026-08-03T21:53:00+08:00:接入看板打开、容器 resize、窗口 resize、主题 mutation 与 reduced-motion 生命周期;删除手写 SVG 图表残留样式,语法检查通过。 +- 2026-08-03T21:54:00+08:00:ECharts 专项回归通过;真实浏览器首轮停在页面加载完成信号。收到新需求后终止旧版验收,开始将全部 Skill 展示替换为项目统计。 +- 2026-08-03T22:01:00+08:00:新增项目聚合、每日项目去重、会话项目归属和项目排行;顶部、趋势、状态环图、排行、最近会话均已移除 Skill 展示,数据单测与统计专项回归通过。 +- 2026-08-03T22:09:00+08:00:真实浏览器完成 4 视口、11 主题、16 张截图验收;全部视图 ECharts 画布齐全,无横向/文字溢出,无 Skill 可见文案。 +- 2026-08-04T00:02:00+08:00:统计专项、gilded、wasteland、全量回归、语法与 diff 检查通过;在线静态资源均返回 200,本地 ECharts bundle 与 npm 源文件哈希一致。按用户要求进入重新打包与提交推送阶段。 +- 2026-08-04T00:47:58+08:00:使用 Bun 1.3.14 重新构建 CentOS 7 baseline 单文件包;MCP initialize、ELF、归档结构及 ECharts 资源验证通过,归档 SHA-256 为 e1e0afe99a64970e93745feaf4f981213f99490eba04b7050b5900e4b43ed2cc。 +- 2026-08-04T00:50:00+08:00:发布产物、源码、ECharts 运行时、项目统计、规划记录、参考资料和真实浏览器截图已统一纳入最终提交范围;任务跟踪 CSV 已按流程清理。 diff --git a/.planning/usage-statistics-echarts/task_plan.md b/.planning/usage-statistics-echarts/task_plan.md new file mode 100644 index 0000000..589c6b6 --- /dev/null +++ b/.planning/usage-statistics-echarts/task_plan.md @@ -0,0 +1,51 @@ +# 使用统计 ECharts 迁移计划 + +## 目标 + +将使用统计看板的五项微趋势、主趋势和三项环图统一迁移到本地 ECharts 运行时,保留现有真实统计协议、产品化布局、11 个主题与响应式交互。 + +## 完成标准 + +- 页面从本地静态资源加载固定版本 ECharts,不依赖 CDN。 +- 五项 KPI 微趋势、主趋势混合图和三个状态环图全部由 ECharts 渲染。 +- 主趋势支持 tooltip 和图例显隐;微趋势、环图保持克制,不增加无关控件。 +- 主题切换、看板打开、容器 resize 与移动端断点后图表尺寸和颜色正确。 +- 不改变统计协议、数据字段、MCP Top 6、明细抽屉、最近会话和聊天状态。 +- 统计专项、相关主题专项、全量回归和真实浏览器视觉验收通过。 + +## 阶段 + +1. [complete] 固化 ECharts 迁移边界与视觉原则 +2. [complete] 引入本地 ECharts 运行时并接入页面 +3. [complete] 迁移五项指标微趋势图 +4. [complete] 迁移主使用趋势图 +5. [complete] 迁移三项状态环图 +6. [complete] 补齐主题同步与响应式生命周期 +7. [complete] 移除 Skill 展示并接入项目统计 +8. [complete] 更新回归契约与真实浏览器验收 +9. [complete] 执行专项全量回归与在线确认 +10. [complete] 重新构建并验证 CentOS 7 baseline 发布包 +11. [complete] 提交并推送全部修改 + +## 决策 + +- 视觉论点:ECharts 只替换图表几何和交互能力,继续服从 cc-web 当前低装饰、单强调色的运营工作台语言。 +- 内容计划:顶部保留五项微趋势,中部保留一张主趋势,状态区保留三项真实构成;不增加协议不存在的字段。 +- 交互论点:主图提供 tooltip 与可切换图例;所有图表随主题和容器尺寸更新;动画遵守 reduced-motion。 +- 采用本地固定版本 UMD bundle,由 index.html 在 app.js 前加载,单文件发布自动携带 public 静态资源。 +- 按用户此前要求不派审计代理。 +- 用户确认 Skill 统计缺乏参考性;后端保留原字段兼容既有协议,前端所有 Skill 展示统一替换为真实项目聚合。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| 预期的 `node_modules/echarts/LICENSE.txt` 不存在 | 1 | 查找包内许可文件后使用实际的 `LICENSE` 与 `NOTICE`,同时归档到 public/vendor | +| index 脚本补丁上下文遗漏了 Mermaid 引用 | 1 | 读取实际尾部脚本顺序后使用完整稳定上下文重新接入 | +| rg 检查模式以 `--usage-donut` 开头被解析为参数 | 1 | 使用 `--` 终止参数解析后复检,确认旧 donut 实现无残留 | +| 首轮 ECharts 浏览器验收停在页面 complete 信号 | 1 | 已补导航诊断并延长加载窗口;因项目统计需求到达,主动终止旧版验收,待新版 UI 后重跑 | + +## 状态 + +- 当前:全部完成 +- 更新时间:2026-08-04T00:50:00+08:00 diff --git a/.planning/usage-statistics-reference-refit/findings.md b/.planning/usage-statistics-reference-refit/findings.md new file mode 100644 index 0000000..2abd198 --- /dev/null +++ b/.planning/usage-statistics-reference-refit/findings.md @@ -0,0 +1,170 @@ +# 使用统计原型返工发现 + +## 产品化优化三项论点 + +- 视觉论点:把统计页做成连续、克制、带明确阅读节奏的 cc-web 运营工作台;用留白、分隔线和字号建立层级,减少卡片马赛克、重复边框和多色竞争。 +- 内容计划:顶部用周期与五项核心数据回答“发生了什么”;中部用趋势、MCP 与 Skill 回答“功能如何被使用”;底部用最近会话提供可追溯证据。 +- 交互论点:时间筛选固定在浏览起点;排行和成功率保持明确可点击;MCP 明细从页面内跳转改为右侧抽屉;移动端指标带支持横向吸附浏览。 + +## 本轮范围 + +- 保留五项指标、趋势、三组真实构成、MCP 排行、Skill 排行、最近会话与全部日期能力。 +- 不新增环比、独立用户、会话时长等统计协议不存在的数据。 +- 不修改聊天消息 DOM、草稿、附件、生成状态、会话 JSON 与统计后端。 +- 复用现有 11 个主题的语义变量,不新增主题 ID,不引入运行时图片资产。 +- 根据用户要求,本轮不派额外审计代理。 + +## 产品化实现后的视觉证据 + +- 桌面端指标已变为一条连续指标带,图标改为文档、轨迹、节点、警示、火花五种 cc-web 原生线性符号;不再复制原型的圆形彩色图标。 +- 趋势与 MCP 面板通过中间分隔线形成两栏,六行 Top MCP 加“查看其余”入口后高度约 346px,与趋势区约 348px 对齐;不再由长工具清单撑高右栏。 +- MCP 默认只渲染前 6 项,其余数量使用面板底部的展开按钮;展开仍复用同一真实数据和成功率明细入口。 +- MCP 调用明细已从页面内滚动改为右侧抽屉:有遮罩、独立滚动和关闭入口,Esc/遮罩/关闭按钮均可退出,背景上下文保持不变。 +- 手机端指标改为 218px 横向吸附带;首屏可以先看到两项核心指标,趋势和 MCP 表格纵向继续阅读,表格保持横向滚动而不压缩成功率列。 +- 产品化视觉脚本第一轮报告:4 个视口与 11 个主题 dashboard/body 横向溢出均为 0;文字溢出为 0(会话表在移动端恢复合理横向滚动宽度)。 + +## 现版与原型视觉复核后的优化点 + +- 现版已经正确承接原型的信息架构,但五张彩色指标卡、两个主卡、三张内嵌环图卡和底部卡片连续叠加,形成明显的“卡片马赛克”。 +- 状态区同时存在外层面板、内层三卡、环图与图例,边框层级重复;应保留数据,移除内层盒子,用分栏与细分隔线建立关系。 +- 五项指标目前各自使用高饱和图标色和折线色,注意力被平均分散;应统一为主题主强调,失败只保留状态色。 +- 标题旁重复信息图标与辅助说明密度过高;只在统计口径确有必要的位置保留一句说明,其他由标题、标签和数值自身表达。 +- MCP 表成功率是明确交互入口,但现版点击后滚动到页面中部的大明细区,破坏阅读上下文;抽屉更符合 cc-web 已有“主工作区 + 次级上下文”的应用结构。 +- 手机版五张大卡纵向堆叠会消耗过多首屏,应改为横向吸附的紧凑指标带,用户可以先读前两项,再按需横滑。 + +## 用户反馈 + +- 现有实现与用户提供原型的版式、信息密度和视觉层级明显不一致。 +- 原型的关键不是配色,而是完整的大盘信息架构和组件比例。 + +## 参考图初步拆解 + +- 顶部:左侧标题和时间切换,右侧全局操作。 +- 第一行:五张等宽指标卡,每张含彩色圆形图标、指标、数值、环比和小趋势。 +- 第二行:约 1:1 的双栏;左侧大趋势图,右侧 MCP 工具明细表。 +- 第三行:左侧状态统计,内含三个环图;右侧 Skill 排名表。 +- 底部:横跨整行的最近会话表。 +- 视觉:浅灰页面底、白色面板、细边线、小圆角、轻阴影、蓝色主强调,高密度但不拥挤。 + +## 参考图量化基线 + +- 原始尺寸:1467 × 943px,PNG RGBA,文件大小 1,412,493 字节。 +- SHA-256:ddbcc605cdcd4ea6aab943b4fa101c1e4af7e1c729fa090d1c89d847e2fdf91d。 +- 归档路径:.trellis/tasks/08-03-usage-statistics-dashboard-refit/references/source-assets/usage-statistics-dashboard-reference.png。 +- 页面外边距约 8px;指标卡间距约 12px;主要面板圆角约 12px;边线约 1px。 +- 五张指标卡总高约 112px;主趋势/MCP 明细行高约 310px;状态/Skill 行高约 225px。 +- 桌面双栏以近似 1:1 展开,不采用当前实现的窄侧栏或散落卡片结构。 + +## 实现边界 + +- 复用现有统计结果和交互,不增加无法从现有数据得到的“组织、独立用户、会话时长、人工/自动消息”等字段。 +- 不新增主题,不改变统计后端协议。 +- 需要同步桌面和移动端,不强行在手机上维持五列与双栏。 + +## 代码定位 + +- codebase-memory 项目 home-cc-web:4,720 节点、10,178 条边,索引状态 ready。 +- 看板总渲染入口:public/app.js 的 renderUsageDashboard。 +- 趋势渲染:renderUsageTrend;Skill 排行:renderUsageSkills;最近会话:renderUsageSessions。 +- 数据聚合与索引在 lib/usage-statistics.js,当前返工不修改其协议和统计口径。 +- 后续需要继续定位 MCP 表格、状态统计、日期控件和事件绑定函数,再建立“参考区域 → DOM → 数据 → CSS”映射。 + +## 当前渲染能力 + +- renderUsageDashboard 只负责填充 overview 指标、消息拆分、MCP 失败率、更新时间与覆盖说明,然后调用五个子渲染函数。 +- 当前共有 6 个 usage 渲染函数:Dashboard、Trend、McpStatus、McpTools、Skills、Sessions。 +- 现有数据已经足够支撑原型中的五个顶层指标、趋势区、MCP 表格、状态区、Skill 排行与最近会话;主要缺口是 DOM 信息架构和视觉表达,不是后端数据。 +- renderUsageDashboard 由 handleUsageStatisticsResult 和 openUsageDashboard 调用,重构时保持这两个入口不变。 + +## 当前 DOM 与原型偏差 + +- 当前第二行是“趋势 + MCP 状态”,原型要求“使用趋势 + MCP 使用明细”。 +- 当前 MCP 工具表独占整行,原型要求它位于趋势右侧。 +- 当前第三行是“Skill + 最近会话”,原型要求“状态统计 + Skill 排行”。 +- 当前最近会话只占半行,原型要求底部横跨整行。 +- 当前指标卡只有文字/数字/说明,缺少原型中的图标、色彩编码、微趋势和清晰的左右信息分区。 +- 日期控件拆成两个原生 input 和“应用”,原型是一个紧凑日期范围控件;保留现有两个 input 的交互语义,但外观合并为一个控件组。 + +## 参考区域到现有 DOM 映射 + +- 顶部标题/筛选 → usage-dashboard__header + usage-dashboard__toolbar。 +- 五项概览 → usage-dashboard__metric-grid 与 data-usage-metric。 +- 使用趋势 → usage-dashboard__panel--trend + usage-dashboard-trend SVG。 +- MCP 使用明细 → usage-dashboard-mcp-rows。 +- 状态统计 → usage-dashboard-mcp-status;将现有条形状态改为原型式环图卡。 +- Skill 排行 → usage-dashboard-skill-rows;改造成表格行与占比条。 +- 最近会话 → usage-dashboard-session-rows;改为全宽底部表。 +- MCP 调用明细 → usage-dashboard-detail;作为工具表下方/浮层扩展状态继续保留。 + +## 渲染与样式重构决策 + +- 当前主内容列宽为 2.4fr : 1fr,直接导致右侧 MCP 状态变成窄边栏;返工为近似 1fr : 1fr。 +- 顶部指标卡保留五列,但增加图标区、信息区和按 data.trend 生成的真实 sparkline,不使用静态假曲线。 +- 趋势图改为“消息/MCP 柱 + 会话/Skill 线”的混合表达,更接近原型且完全来自现有 trend 数据。 +- MCP 工具表改为“排名、Server / Tool、调用、成功、失败、成功率”;成功率单元格同时承担详情入口。 +- 状态统计改为三张真实环图卡:MCP 结果、直接/跨会话消息来源、MCP/Skill 功能使用构成。 +- Skill 排行增加使用占比和进度条;不展示协议没有的独立用户数。 +- 最近会话改为全宽表,增加明确“查看”操作;不展示协议没有的用户和会话时长。 +- 浅色基础视觉对齐参考图;深色/特殊主题只覆盖语义 token 和局部圆角,不复制业务布局。 + +## 日期范围交互 + +- 现有状态只绑定“本周/本月”,但 syncUsageDashboardPeriod 已允许其他 period 值且不会覆盖日期输入。 +- 新增“自定义”按钮时只需增加 DOM 引用、aria-pressed 同步和事件绑定;查询仍复用 usageRangeFromInputs 与 usage_stats_query。 +- 两个 date input 保持真实可编辑和可访问性,CSS 将它们组合成原型中的单一日期范围控件外观。 + +## 趋势字段核对 + +- lib/usage-statistics.js 的每日 bucket 已包含 newSessions、messages、directMessages、crossConversationMessages、mcpCalls、mcpFailures、skillMentions。 +- 因此五张指标卡都能生成真实 sparkline,MCP 失败卡不需要用静态图或按总失败率推测每日值。 + +## 浏览器验收环境 + +- 现有 cc-web 服务 http://127.0.0.1:8002/ 返回 200;远程命名空间地址为 11.144.144.11。 +- PATH 中没有 Chrome,但可复用 /home/hdzx/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome。 +- 项目已有 ws 依赖,可通过 Chrome DevTools Protocol 完成登录、打开看板、读取几何和截图,无需安装新包。 +- 为避免读取或输出线上密码,视觉验收使用单独端口和临时已知测试密码启动同一代码,不重启线上服务。 +- 打开看板会立即触发默认范围查询;自定义日期脚本必须等待该查询结束,否则 disabled 的“应用”按钮不会响应程序化 click。 +- 服务端对同一 WebSocket 的统计查询设置 250ms 间隔;浏览器验收在默认查询完成后等待 320ms,再提交自定义范围。 + +## 首轮真实视觉结果 + +- 1467×943、washi 主题下,五卡横排,每卡约 222px;主区趋势与 MCP 面板均约 573.5px,严格等宽。 +- 状态与 Skill 面板同样等宽,最近会话从首屏底部露出,整体信息架构已与参考原型一致。 +- 看板和 body 横向溢出均为 0;11 个主题的页面/面板背景均取到对应语义颜色。 +- 待修 1:222px 指标卡中 copy 列过窄,标题和说明出现省略号。 +- 待修 2:MCP 表继承 640px 最小宽度,大于 573.5px 面板内宽,导致成功率百分比需要横向滚动。 + +## 第二轮真实视觉结果 + +- 指标卡标题/数字/说明已无截断;1467、1024、768、390 四档 textOverflow 均为 0。 +- MCP 表最小宽度降为 520px 后,桌面端六列完整显示,成功率进度条和百分比均可见。 +- 1467×943 下仍保持五卡约 222px、主双栏各 573.5px;看板和 body 横向溢出继续为 0。 +- 与原型相比,顶部筛选和五卡的 y/高度已经接近;主行和下排行略高,导致最近会话首屏只露出标题,需要结合移动端与暗色截图决定是否继续压缩。 + +## 移动与暗色对照 + +- 390×844 下筛选、日期、五张单列指标卡完整显示,未发生控件重叠;趋势图按页面纵向继续滚动。 +- wasteland 1440×900 下结构、环图、表格和强调色均正确,主题专属 2px 圆角继续生效。 +- 需要隐藏手机标题下过长覆盖说明,并进一步收紧最长指标标题、MCP 行和 Skill 行。 + +## 第三轮几何结果 + +- 四档视口继续保持横向溢出 0、文字溢出 0;手机标题说明已隐藏。 +- 11 主题中仅 coolvibe 在 1440×900 对最长 Skill 指标标题出现 1 处省略,其他主题均为 0。 +- MCP 紧凑 padding 首次放在通用 table padding 之前,被后置同权重规则覆盖;需调整声明顺序后再测。 + +## 最终视觉验收结果 + +- 1467×943:五卡横排;主趋势/MCP 各 573.5px 宽、309px 高;状态/Skill 各 573.5px 宽、247.5px 高;最近会话 y=819.2,可见表头与首行。 +- 1024×768、768×1024、390×844:按断点折叠,无页面横向溢出、无检测到的文字溢出。 +- 11 个主题在 1440×900 均为 dashboardOverflowX=0、bodyOverflowX=0、textOverflowCount=0。 +- MCP 调用明细已真实点击展开并截图,成功/失败/其他状态、会话和 Agent 列可见。 +- 共保留 16 张真实 Chrome 截图和 visual-report.json,数据来自临时真实会话与真实 usage_stats_query。 + +## 视觉验收数据 + +- 视觉夹具复用真实会话 JSON 结构:composerMentions 表达 Skill,assistant.toolCalls 表达 MCP,时间戳覆盖完整自定义日期范围。 +- 夹具仅写入系统临时目录,通过 CC_WEB_CONFIG_DIR / CC_WEB_SESSIONS_DIR / CC_WEB_LOGS_DIR 注入,不污染项目会话数据。 +- 浏览器会设置自定义日期后触发真实 usage_stats_query,截图不是静态 HTML mock。 +- 直接/跨会话消息由 user message 的 crossConversation 布尔值决定;MCP 状态取 toolCall.meta.status;Skill 取 composerMentions,因此视觉夹具可覆盖三个环图的全部分段。 diff --git a/.planning/usage-statistics-reference-refit/progress.md b/.planning/usage-statistics-reference-refit/progress.md new file mode 100644 index 0000000..58285de --- /dev/null +++ b/.planning/usage-statistics-reference-refit/progress.md @@ -0,0 +1,39 @@ +# 使用统计原型返工进度 + +- 2026-08-03T20:45:00+08:00:用户要求从机械复刻转为自主产品化优化;完成视觉、内容、交互三项论点与范围边界,启动去卡片化和抽屉式明细改造。 +- 2026-08-03T20:46:00+08:00:产品化优化原则与改动边界已固化,开始基于现有真实 DOM 精简信息架构和界面文案。 +- 2026-08-03T21:05:30+08:00:完成产品化 UI、MCP Top 6 折叠、调用明细抽屉、cc-web 线性指标图标、移动横向指标带与 cache bust;4 视口、11 主题和抽屉态真实截图通过,进入最终回归。 +- 2026-08-03T21:08:00+08:00:统计专项、Gilded、Wasteland、全量回归、JS 语法和 git diff 检查全部通过;8002 在线 index/CSS/JS 已加载 productized 版本,PM2 ccweb online,无需重启。 + +- 2026-08-03T20:28:13+08:00:在线 8002 的 index/style/app 均返回 200 且包含新版本内容;PM2 ccweb online,无需重启,任务完成。 +- 2026-08-03T20:26:24+08:00:统计专项、Gilded、Wasteland、全量回归和静态检查全部通过,进入在线静态资源确认。 +- 2026-08-03:最终视觉验收通过:4 视口与 11 主题无横向/文字溢出,桌面主行高度 309px,调用明细展开态可用。 +- 2026-08-03:第三轮几何报告定位到 MCP padding 声明顺序问题和 coolvibe 单一标题溢出,已做精确修正。 +- 2026-08-03:完成移动与 wasteland 真实截图检查;开始最后一轮标题适配和桌面纵向密度收紧。 +- 2026-08-03:第二轮 Chrome 截图确认指标卡与 MCP 表问题已修复,四档视口无文字或横向溢出。 +- 2026-08-03:真实 Chrome 生成 16 张截图;首轮桌面结构对齐,发现指标标题截断和 MCP 表最小宽度两个局部问题。 +- 2026-08-03:诊断确认第二轮超时来自服务端 250ms 查询限频;视觉脚本按阈值增加 320ms 间隔。 +- 2026-08-03:第二轮视觉脚本仍在自定义结果条件超时;已加入结构化页面诊断,下一轮不再盲等。 +- 2026-08-03:首轮浏览器脚本在默认查询 loading 阶段点击了禁用的应用按钮而超时;已改为等待初始查询结束后再提交自定义范围。 +- 2026-08-03:完成视觉夹具字段核对,可真实覆盖 MCP 成功/失败、直接/跨会话消息和多 Skill 排名。 +- 2026-08-03:确定视觉验收采用临时真实会话夹具和真实 WebSocket 查询,不使用静态 mock 看板。 +- 2026-08-03:确认线上端口 8002 可用,并定位本机 Chrome 1228;准备使用独立测试端口进行真实浏览器验收。 +- 2026-08-03T19:37:33+08:00:统计看板专项回归通过,进入自动化与真实浏览器视觉验收。 +- 2026-08-03:专项回归首次失败,定位为测试断言范围错误;功能事件位于初始化区,已修正断言来源。 +- 2026-08-03T19:34:57+08:00:完成自定义日期、明细入口和响应式断点实现;开始补充原型结构回归契约。 +- 2026-08-03T19:31:41+08:00:完成桌面原型版式与语义主题视觉;JS 语法和 git diff --check 通过,进入交互与响应式适配。 +- 2026-08-03T19:25:07+08:00:完成 DOM 与渲染结构重构;新增真实 sparkline、混合趋势、环图状态、MCP/Skill 排行和会话查看操作,语法检查通过。 +- 2026-08-03:核对后端每日趋势字段,五张指标卡均可使用真实日数据生成微趋势。 +- 2026-08-03T19:16:21+08:00:完成原型量化和现有 DOM/数据映射,进入结构重构。 +- 2026-08-03:确认自定义日期无需扩展协议,只需补充第三个周期按钮与 pressed 状态同步。 +- 2026-08-03:完成 JS/CSS 基线阅读,确定采用真实 sparkline、混合趋势图、三张真实环图、MCP/Skill 排名表和全宽最近会话。 +- 2026-08-03:确认根本偏差来自 DOM 排序:现版趋势+状态/MCP 独行/Skill+会话,与原型的趋势+MCP/状态+Skill/会话全宽不一致。 +- 2026-08-03:确认 usage 看板共有 6 个渲染函数,现有数据足以重建原型结构,后端无需扩展。 +- 2026-08-03:通过 codebase-memory 定位 renderUsageDashboard、renderUsageTrend、renderUsageSkills、renderUsageSessions 及后端聚合边界。 + +- 2026-08-03T19:10:12+08:00:用户确认现有看板与原型严重不符,启动高保真返工。 +- 2026-08-03T19:10:12+08:00:完成技能规范读取;确定保留统计逻辑,仅重构看板 DOM、样式、交互和回归。 +- 2026-08-03T19:10:12+08:00:建立持久化计划、发现记录和 TODO CSV。 +- 2026-08-03:首次归档调用因补丁字符串转义错误未执行;第二次错误日志补丁因上下文变量未展开而未应用,现已改用稳定文本。 +- 2026-08-03T19:12:35+08:00:参考图已归档并核对 SHA-256;环境无 identify,使用 file/stat 完成尺寸与格式确认。 +- 2026-08-03T19:12:35+08:00:代码基线确认完成,codebase-memory 索引 home-cc-web 状态为 ready。 diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-carbon-1440x900.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-carbon-1440x900.png new file mode 100644 index 0000000..a5425b5 Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-carbon-1440x900.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-cinder-1440x900.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-cinder-1440x900.png new file mode 100644 index 0000000..a7f38f7 Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-cinder-1440x900.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-coolvibe-1440x900.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-coolvibe-1440x900.png new file mode 100644 index 0000000..10962a3 Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-coolvibe-1440x900.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-dawn-1440x900.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-dawn-1440x900.png new file mode 100644 index 0000000..e05e2b7 Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-dawn-1440x900.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-editorial-1440x900.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-editorial-1440x900.png new file mode 100644 index 0000000..fe33fd1 Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-editorial-1440x900.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-gilded-1440x900.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-gilded-1440x900.png new file mode 100644 index 0000000..61e4429 Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-gilded-1440x900.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-ink-1440x900.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-ink-1440x900.png new file mode 100644 index 0000000..bca7e5d Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-ink-1440x900.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-nocturne-1440x900.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-nocturne-1440x900.png new file mode 100644 index 0000000..c3c39ee Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-nocturne-1440x900.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-sage-1440x900.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-sage-1440x900.png new file mode 100644 index 0000000..9f3bb1e Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-sage-1440x900.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-washi-1440x900.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-washi-1440x900.png new file mode 100644 index 0000000..3e6fa07 Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-washi-1440x900.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-wasteland-1440x900.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-wasteland-1440x900.png new file mode 100644 index 0000000..c5c1d94 Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-theme-wasteland-1440x900.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-1024x768.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-1024x768.png new file mode 100644 index 0000000..1be5031 Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-1024x768.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-1467x943.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-1467x943.png new file mode 100644 index 0000000..0f1b388 Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-1467x943.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-390x844.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-390x844.png new file mode 100644 index 0000000..db1239a Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-390x844.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-768x1024.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-768x1024.png new file mode 100644 index 0000000..0221508 Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-768x1024.png differ diff --git a/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-detail-1467x943.png b/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-detail-1467x943.png new file mode 100644 index 0000000..211afb3 Binary files /dev/null and b/.planning/usage-statistics-reference-refit/screenshots/dashboard-washi-detail-1467x943.png differ diff --git a/.planning/usage-statistics-reference-refit/task_plan.md b/.planning/usage-statistics-reference-refit/task_plan.md new file mode 100644 index 0000000..71f6cb4 --- /dev/null +++ b/.planning/usage-statistics-reference-refit/task_plan.md @@ -0,0 +1,64 @@ +# 使用统计看板产品化优化计划 + +## 目标 + +在不改变统计协议、统计口径和现有聊天功能的前提下,以用户原型提供的信息架构为基础,将使用统计看板优化为符合 cc-web 产品语言的运营工作台,并保持全部现有主题和响应式视口可用。 + +## 完成标准 + +- 桌面端信息架构对齐原型:顶部标题与筛选、五张指标卡、左趋势右 MCP 明细、左状态右 Skill 排行、底部最近会话。 +- 使用现有真实统计数据,不伪造组织、用户、时长或人工/自动消息等当前协议未提供的数据。 +- 保留本周、本月、自定义日期、MCP 明细、会话跳转、加载/空态/错误态。 +- 11 个现有主题均通过语义变量适配;浅色主题贴近原型,深色主题保持相同布局层级。 +- 参考视口、常规桌面、平板和手机通过真实浏览器检查。 +- 不修改聊天消息 DOM、生成状态、草稿、附件和会话 JSON。 + +## 阶段 + +1. [complete] 建立代码基线并归档参考原型 +2. [complete] 量化原型并映射现有 DOM 与数据 +3. [complete] 重构看板 DOM 与渲染结构 +4. [complete] 实现原型版式与主题视觉 +5. [complete] 适配交互状态与响应式布局 +6. [complete] 补充统计看板回归契约 +7. [complete] 执行自动化与真实浏览器验收 +8. [complete] 检查运行会话并安全更新预览 +9. [complete] 固化产品化优化原则与改动边界 +10. [complete] 精简统计看板信息架构与文案 +11. [complete] 将 MCP 明细重构为侧边抽屉 +12. [complete] 重做桌面端去卡片化视觉层级 +13. [complete] 优化手机端指标带与响应式布局 +14. [complete] 更新回归契约与静态资源版本 +15. [complete] 执行多主题多视口视觉验收 +16. [complete] 执行专项全量回归与在线确认 + +## 决策 + +- 这是现有功能页返工,不新增主题 ID。 +- 原型图只作为构图证据并归档,不作为产品运行时资产。 +- 当前数据协议没有的数据不伪造;对应区域用现有指标重新表达。 +- 遵照用户要求,不派额外审计代理。 +- 原型约束信息结构,不逐像素复刻;具体密度、层级和交互按 cc-web 真实使用场景优化。 +- 顶部五项指标改为连续指标带,主色只承担选中、关键走势和状态表达,避免五种颜色同时争抢注意力。 +- MCP 调用明细改为看板内侧边抽屉,保持上下文,不再滚动跳转到页面中部。 +- 移动端优先保证首屏判断效率,指标采用紧凑横向浏览,不做五张纵向大卡堆叠。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| 归档脚本拼接 Markdown 反引号触发 JavaScript SyntaxError | 1 | 改为无嵌套反引号的补丁文本,命令未执行且源文件未变 | +| 错误日志补丁使用了未展开的时间变量,未匹配原文 | 2 | 读取实际文本后改用稳定上下文补丁 | +| 进度补丁使用新时间匹配旧时间,未命中原文 | 1 | 后续进度追加只使用稳定标题或固定文本作为上下文 | +| 阶段状态补丁连续使用错误旧时间 | 3 | 停止猜测,先读取计划尾部再用精确文本同步 | +| 环境没有 ImageMagick identify | 1 | 改用 file 与 stat 读取 PNG 尺寸、通道和大小 | +| usage-statistics 回归把初始化事件断言放进渲染函数切片 | 1 | 将自定义日期事件断言改为检查完整 frontendSource | +| 视觉脚本在初始查询 loading 时点击 disabled 的应用按钮 | 1 | 先等待初始查询离开 loading,再设置自定义日期并应用 | +| 等待初始查询后自定义结果仍未满足非零条件 | 2 | 增加页面诊断快照,先获取状态/错误/日期/指标再决定修复 | +| 自定义查询紧跟默认查询触发 250ms 频率限制 | 3 | 根据服务端阈值在视觉脚本中等待 320ms 后提交 | +| 在线 JS 检查使用 `rg -m1` 提前关闭管道导致 curl 报 23 | 1 | 改为让 rg 消费完整响应,复检命中且退出码为 0 | + +## 状态 + +- 当前:产品化优化已完成 +- 更新时间:2026-08-03T21:08:00+08:00 diff --git a/.planning/usage-statistics-reference-refit/visual-check.js b/.planning/usage-statistics-reference-refit/visual-check.js new file mode 100644 index 0000000..3fbb2ce --- /dev/null +++ b/.planning/usage-statistics-reference-refit/visual-check.js @@ -0,0 +1,502 @@ +'use strict'; + +const childProcess = require('child_process'); +const fs = require('fs'); +const http = require('http'); +const net = require('net'); +const os = require('os'); +const path = require('path'); +const WebSocket = require('ws'); + +const REPO_DIR = path.resolve(__dirname, '..', '..'); +const OUTPUT_DIR = path.join(__dirname, 'screenshots'); +const REPORT_PATH = path.join(__dirname, 'visual-report.json'); +const CHROME_PATH = process.env.CHROME_PATH + || '/home/hdzx/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'; +const PASSWORD = 'UsageVisual!234'; +const THEMES = [ + 'washi', 'coolvibe', 'editorial', 'sage', 'ink', 'dawn', + 'carbon', 'nocturne', 'cinder', 'gilded', 'wasteland', +]; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close(() => resolve(address.port)); + }); + }); +} + +async function getJson(url) { + return new Promise((resolve, reject) => { + const request = http.get(url, (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { body += chunk; }); + response.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + }); + request.on('error', reject); + request.setTimeout(2000, () => request.destroy(new Error(`请求超时:${url}`))); + }); +} + +async function waitFor(check, timeoutMs = 15000, intervalMs = 80) { + const started = Date.now(); + let lastError = null; + while (Date.now() - started < timeoutMs) { + try { + const value = await check(); + if (value) return value; + } catch (error) { + lastError = error; + } + await sleep(intervalMs); + } + throw lastError || new Error(`等待条件超时(${timeoutMs}ms)`); +} + +function writeUsageFixtures(sessionsDir) { + const tools = [ + ['search-server', 'web_search'], + ['data-server', 'query_data'], + ['code-server', 'run_code'], + ['doc-server', 'fetch_doc'], + ['api-server', 'api_request'], + ['file-server', 'read_file'], + ['memory-server', 'remember'], + ['browser-server', 'open_page'], + ['terminal-server', 'run_task'], + ['github-server', 'list_issues'], + ]; + const skills = ['数据分析', '报表生成', '文档总结', '代码解释', '翻译助手']; + for (let day = 1; day <= 31; day += 1) { + const messages = []; + const messageCount = 7 + (day % 7); + for (let index = 0; index < messageCount; index += 1) { + const hour = 1 + (index % 20); + const minute = (index * 7) % 60; + const timestamp = new Date(Date.UTC(2026, 6, day, hour, minute)).toISOString(); + const skill = skills[(day + index) % skills.length]; + messages.push({ + role: 'user', + content: `视觉验收消息 ${day}-${index}`, + timestamp, + crossConversation: (day + index) % 4 === 0, + composerMentions: index % 2 === 0 + ? [{ kind: 'skill', name: skill, label: `$${skill}` }] + : [], + }); + + if (index % 2 === 0) { + const [server, tool] = tools[(day + index) % tools.length]; + const failed = (day + index) % 9 === 0; + const other = !failed && (day + index) % 13 === 0; + messages.push({ + role: 'assistant', + content: `视觉验收回复 ${day}-${index}`, + timestamp: new Date(Date.UTC(2026, 6, day, hour, minute + 1)).toISOString(), + toolCalls: [{ + name: 'McpToolCall', + kind: 'mcp_tool_call', + input: { server, tool, arguments: { fixture: true } }, + result: 'visual-fixture-result', + done: !other, + meta: { + kind: 'mcp_tool_call', + status: failed ? 'failed' : other ? 'pending' : 'completed', + }, + }], + }); + } + } + const created = new Date(Date.UTC(2026, 6, day, 0, 0)).toISOString(); + const updated = new Date(Date.UTC(2026, 6, day, 23, 30)).toISOString(); + const id = `visual-usage-${String(day).padStart(2, '0')}`; + fs.writeFileSync(path.join(sessionsDir, `${id}.json`), JSON.stringify({ + id, + title: `使用统计视觉验收会话 ${String(day).padStart(2, '0')}`, + agent: day % 2 === 0 ? 'codexapp' : 'claude', + cwd: `/tmp/usage-visual/project-${day % 4}`, + projectName: `视觉项目 ${day % 4}`, + created, + updated, + messages, + }, null, 2)); + } +} + +async function connectCdp(webSocketUrl) { + const socket = new WebSocket(webSocketUrl); + await new Promise((resolve, reject) => { + socket.once('open', resolve); + socket.once('error', reject); + }); + let sequence = 0; + const pending = new Map(); + const rejectPending = (error) => { + for (const entry of pending.values()) entry.reject(error); + pending.clear(); + }; + socket.on('message', (payload) => { + const message = JSON.parse(String(payload)); + if (!message.id || !pending.has(message.id)) return; + const entry = pending.get(message.id); + pending.delete(message.id); + if (message.error) entry.reject(new Error(message.error.message || JSON.stringify(message.error))); + else entry.resolve(message.result || {}); + }); + socket.on('close', () => rejectPending(new Error('Chrome CDP 连接已关闭'))); + socket.on('error', (error) => rejectPending(error)); + return { + socket, + send(method, params = {}) { + sequence += 1; + const id = sequence; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + socket.send(JSON.stringify({ id, method, params }), (error) => { + if (!error) return; + pending.delete(id); + reject(error); + }); + }); + }, + }; +} + +async function evaluate(cdp, expression) { + const result = await cdp.send('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.text || '页面脚本执行失败'); + } + return result.result?.value; +} + +async function setViewport(cdp, width, height) { + await cdp.send('Emulation.setDeviceMetricsOverride', { + width, + height, + deviceScaleFactor: 1, + mobile: false, + screenWidth: width, + screenHeight: height, + }); + await sleep(120); +} + +async function capture(cdp, fileName) { + const result = await cdp.send('Page.captureScreenshot', { + format: 'png', + fromSurface: true, + captureBeyondViewport: false, + }); + fs.writeFileSync(path.join(OUTPUT_DIR, fileName), Buffer.from(result.data, 'base64')); +} + +async function stopChildProcess(child) { + if (!child || child.exitCode !== null) return; + const exited = new Promise((resolve) => child.once('exit', resolve)); + child.kill('SIGTERM'); + await Promise.race([exited, sleep(2000)]); +} + +async function collectGeometry(cdp, theme, width, height) { + return evaluate(cdp, `(() => { + const rect = (selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const box = element.getBoundingClientRect(); + return { + x: Math.round(box.x * 10) / 10, + y: Math.round(box.y * 10) / 10, + width: Math.round(box.width * 10) / 10, + height: Math.round(box.height * 10) / 10, + }; + }; + const metricRects = Array.from(document.querySelectorAll('.usage-dashboard__metric')).map((element) => { + const box = element.getBoundingClientRect(); + return { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) }; + }); + const overflowSelectors = [ + '.usage-dashboard__metric-label', + '.usage-dashboard__metric-copy strong', + '.usage-dashboard__panel-heading h3', + '.usage-dashboard__tool-name', + '.usage-dashboard__rank-list strong', + '.usage-dashboard__session-link', + ]; + const textOverflow = overflowSelectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)) + .filter((element) => element.scrollWidth > element.clientWidth + 1) + .map((element) => ({ selector, text: element.textContent.trim().slice(0, 80) }))); + const dashboard = document.querySelector('.usage-dashboard'); + const body = document.querySelector('.usage-dashboard__body'); + const sparklineCanvases = document.querySelectorAll('[data-usage-spark] canvas').length; + const donutCanvases = document.querySelectorAll('.usage-dashboard__donut-chart canvas').length; + return { + theme: ${JSON.stringify(theme)}, + viewport: { width: ${width}, height: ${height} }, + dashboard: rect('.usage-dashboard'), + header: rect('.usage-dashboard__header'), + toolbar: rect('.usage-dashboard__toolbar'), + metricGrid: rect('.usage-dashboard__metric-grid'), + metrics: metricRects, + primary: rect('.usage-dashboard__content-grid--primary'), + trend: rect('.usage-dashboard__panel--trend'), + mcp: rect('.usage-dashboard__panel--mcp'), + lower: rect('.usage-dashboard__content-grid--lower'), + status: rect('.usage-dashboard__panel--status'), + projects: rect('.usage-dashboard__panel--projects'), + sessions: rect('.usage-dashboard__panel--sessions'), + dashboardOverflowX: Math.max(0, dashboard.scrollWidth - dashboard.clientWidth), + bodyOverflowX: Math.max(0, body.scrollWidth - body.clientWidth), + bodyScrollHeight: body.scrollHeight, + bodyClientHeight: body.clientHeight, + metricColumns: getComputedStyle(document.querySelector('.usage-dashboard__metric-grid')).gridTemplateColumns, + primaryColumns: getComputedStyle(document.querySelector('.usage-dashboard__content-grid--primary')).gridTemplateColumns, + panelBackground: getComputedStyle(document.querySelector('.usage-dashboard__panel')).backgroundColor, + pageBackground: getComputedStyle(dashboard).backgroundColor, + charts: { + runtime: typeof window.echarts?.init === 'function', + sparklines: sparklineCanvases, + trend: document.querySelectorAll('#usage-dashboard-trend canvas').length, + donuts: donutCanvases, + ready: sparklineCanvases === 5 + && document.querySelectorAll('#usage-dashboard-trend canvas').length === 1 + && donutCanvases === 3, + }, + skillTextVisible: /Skill/i.test(dashboard.innerText), + textOverflow, + }; + })()`); +} + +async function main() { + if (!fs.existsSync(CHROME_PATH)) throw new Error(`找不到 Chrome:${CHROME_PATH}`); + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-usage-visual-')); + const configDir = path.join(tempRoot, 'config'); + const sessionsDir = path.join(tempRoot, 'sessions'); + const logsDir = path.join(tempRoot, 'logs'); + const profileDir = path.join(tempRoot, 'chrome-profile'); + [configDir, sessionsDir, logsDir, profileDir].forEach((dir) => fs.mkdirSync(dir, { recursive: true })); + writeUsageFixtures(sessionsDir); + + const port = await getFreePort(); + const debugPort = await getFreePort(); + let server = null; + let chrome = null; + let cdp = null; + try { + server = childProcess.spawn(process.execPath, ['server.js'], { + cwd: REPO_DIR, + env: { + ...process.env, + PORT: String(port), + CC_WEB_PASSWORD: PASSWORD, + CC_WEB_CONFIG_DIR: configDir, + CC_WEB_SESSIONS_DIR: sessionsDir, + CC_WEB_LOGS_DIR: logsDir, + CC_WEB_USAGE_STATISTICS: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let serverError = ''; + server.stderr.on('data', (chunk) => { serverError += String(chunk); }); + await waitFor(() => new Promise((resolve) => { + const request = http.get(`http://127.0.0.1:${port}/`, (response) => { + response.resume(); + resolve(response.statusCode === 200); + }); + request.on('error', () => resolve(false)); + }), 20000); + if (server.exitCode !== null) throw new Error(serverError || '测试服务提前退出'); + + chrome = childProcess.spawn(CHROME_PATH, [ + '--headless=new', + '--no-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage', + '--hide-scrollbars', + '--remote-allow-origins=*', + `--remote-debugging-port=${debugPort}`, + `--user-data-dir=${profileDir}`, + '--window-size=1467,943', + 'about:blank', + ], { stdio: ['ignore', 'ignore', 'pipe'] }); + + const targets = await waitFor(async () => { + const list = await getJson(`http://127.0.0.1:${debugPort}/json/list`); + return list.find((target) => target.type === 'page') ? list : null; + }, 20000); + const pageTarget = targets.find((target) => target.type === 'page'); + cdp = await connectCdp(pageTarget.webSocketDebuggerUrl); + await cdp.send('Page.enable'); + await cdp.send('Runtime.enable'); + await cdp.send('Network.enable'); + await cdp.send('Network.setBlockedURLs', { urls: ['https://cdnjs.cloudflare.com/*'] }); + await cdp.send('Page.addScriptToEvaluateOnNewDocument', { + source: `(() => { + class Renderer {} + window.marked = { Renderer, setOptions() {}, parse: (value) => String(value || '') }; + window.hljs = { + getLanguage: () => false, + highlight: (_value, options) => ({ value: String(options?.language || '') }), + highlightAuto: (value) => ({ value: String(value || '') }), + }; + window.mermaid = { initialize() {}, render: async () => ({ svg: '' }) }; + })();`, + }); + await setViewport(cdp, 1467, 943); + const navigation = await cdp.send('Page.navigate', { url: `http://127.0.0.1:${port}/` }); + if (navigation.errorText) throw new Error(`页面导航失败:${navigation.errorText}`); + try { + await waitFor(() => evaluate(cdp, "document.readyState === 'complete'"), 30000); + } catch (error) { + const diagnostic = await evaluate(cdp, `(() => ({ + href: location.href, + readyState: document.readyState, + title: document.title, + body: document.body?.textContent?.trim().slice(0, 240) || '', + }))()`); + throw new Error(`页面未完成加载:${JSON.stringify(diagnostic)};${error.message}`); + } + await waitFor(() => evaluate(cdp, "!!document.querySelector('#login-form')"), 10000); + await sleep(500); + await evaluate(cdp, `(() => { + document.querySelector('#login-password').value = ${JSON.stringify(PASSWORD)}; + document.querySelector('#login-form').requestSubmit(); + return true; + })()`); + try { + try { + await waitFor(() => evaluate(cdp, "document.querySelector('#login-overlay').hidden && !document.querySelector('#usage-dashboard-open').hidden"), 3000); + } catch { + await evaluate(cdp, `(() => { + document.querySelector('#login-password').value = ${JSON.stringify(PASSWORD)}; + document.querySelector('#login-form').requestSubmit(); + return true; + })()`); + await waitFor(() => evaluate(cdp, "document.querySelector('#login-overlay').hidden && !document.querySelector('#usage-dashboard-open').hidden"), 17000); + } + } catch (error) { + const diagnostic = await evaluate(cdp, `(() => ({ + loginHidden: document.querySelector('#login-overlay')?.hidden, + loginError: document.querySelector('#login-error')?.hidden ? '' : document.querySelector('#login-error')?.textContent, + appHidden: document.querySelector('#app')?.hidden, + usageHidden: document.querySelector('#usage-dashboard-open')?.hidden, + marked: typeof window.marked?.parse, + echarts: typeof window.echarts?.init, + }))()`); + throw new Error(`登录后页面未就绪:${JSON.stringify(diagnostic)};${error.message}`); + } + await evaluate(cdp, "document.querySelector('#usage-dashboard-open').click(); true"); + await waitFor(() => evaluate(cdp, "!document.querySelector('#usage-dashboard-panel').hidden"), 10000); + await waitFor(() => evaluate(cdp, "document.querySelector('#usage-dashboard-panel').dataset.state !== 'loading'"), 30000); + await sleep(320); + await evaluate(cdp, `(() => { + const from = document.querySelector('#usage-dashboard-from'); + const to = document.querySelector('#usage-dashboard-to'); + from.value = '2026-07-01'; + to.value = '2026-07-31'; + from.dispatchEvent(new Event('change', { bubbles: true })); + to.dispatchEvent(new Event('change', { bubbles: true })); + document.querySelector('#usage-dashboard-apply').click(); + return true; + })()`); + try { + await waitFor(() => evaluate(cdp, `document.querySelector('#usage-dashboard-panel').dataset.state === 'ready' + && document.querySelector('[data-usage-metric="newSessions"]').textContent !== '0' + && document.querySelectorAll('[data-usage-spark] canvas').length === 5 + && document.querySelectorAll('#usage-dashboard-trend canvas').length === 1 + && document.querySelectorAll('.usage-dashboard__donut-chart canvas').length === 3 + && document.querySelectorAll('#usage-dashboard-project-rows li').length === 4 + && !/Skill/i.test(document.querySelector('#usage-dashboard-panel').innerText)`), 30000); + } catch (error) { + const diagnostic = await evaluate(cdp, `(() => ({ + state: document.querySelector('#usage-dashboard-panel').dataset.state, + error: document.querySelector('#usage-dashboard-error').textContent, + from: document.querySelector('#usage-dashboard-from').value, + to: document.querySelector('#usage-dashboard-to').value, + applyDisabled: document.querySelector('#usage-dashboard-apply').disabled, + newSessions: document.querySelector('[data-usage-metric="newSessions"]').textContent, + status: document.querySelector('#usage-dashboard-status').textContent, + }))()`); + throw new Error(`自定义统计查询未返回预期数据:${JSON.stringify(diagnostic)};${error.message}`); + } + + await evaluate(cdp, `(() => { + const rows = document.querySelectorAll('#usage-dashboard-mcp-rows tr').length; + const more = document.querySelector('#usage-dashboard-mcp-more'); + if (rows !== 6 || !more || more.hidden || !/其余/.test(more.textContent)) { + throw new Error(JSON.stringify({ rows, moreHidden: more?.hidden, moreText: more?.textContent })); + } + more.click(); + return true; + })()`); + await waitFor(() => evaluate(cdp, `document.querySelectorAll('#usage-dashboard-mcp-rows tr').length === 10 + && document.querySelector('#usage-dashboard-mcp-more').getAttribute('aria-expanded') === 'true'`), 5000); + await evaluate(cdp, "document.querySelector('#usage-dashboard-mcp-more').click(); true"); + await waitFor(() => evaluate(cdp, "document.querySelectorAll('#usage-dashboard-mcp-rows tr').length === 6"), 5000); + + const report = { generatedAt: new Date().toISOString(), chrome: CHROME_PATH, views: [], themes: [] }; + const viewports = [ + { width: 1467, height: 943, name: 'reference' }, + { width: 1024, height: 768, name: 'desktop-narrow' }, + { width: 768, height: 1024, name: 'tablet' }, + { width: 390, height: 844, name: 'mobile' }, + ]; + await evaluate(cdp, "document.documentElement.dataset.theme = 'washi'; document.querySelector('.usage-dashboard__body').scrollTop = 0; true"); + for (const viewport of viewports) { + await setViewport(cdp, viewport.width, viewport.height); + await evaluate(cdp, "document.querySelector('.usage-dashboard__body').scrollTop = 0; true"); + await sleep(100); + await capture(cdp, `dashboard-washi-${viewport.width}x${viewport.height}.png`); + report.views.push(await collectGeometry(cdp, 'washi', viewport.width, viewport.height)); + } + + await setViewport(cdp, 1467, 943); + await evaluate(cdp, "document.querySelector('.usage-dashboard__body').scrollTop = 0; document.querySelector('.usage-dashboard__rate-action')?.click(); true"); + await waitFor(() => evaluate(cdp, "!document.querySelector('#usage-dashboard-detail').hidden"), 5000); + await sleep(220); + await capture(cdp, 'dashboard-washi-detail-1467x943.png'); + await evaluate(cdp, "document.querySelector('#usage-dashboard-detail-close').click(); document.querySelector('.usage-dashboard__body').scrollTop = 0; true"); + + await setViewport(cdp, 1440, 900); + for (const theme of THEMES) { + await evaluate(cdp, `document.documentElement.dataset.theme = ${JSON.stringify(theme)}; document.querySelector('.usage-dashboard__body').scrollTop = 0; true`); + await sleep(80); + await capture(cdp, `dashboard-theme-${theme}-1440x900.png`); + report.themes.push(await collectGeometry(cdp, theme, 1440, 900)); + } + fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2)); + process.stdout.write(`${JSON.stringify({ report: REPORT_PATH, screenshots: fs.readdirSync(OUTPUT_DIR).length }, null, 2)}\n`); + } finally { + if (cdp?.socket?.readyState === WebSocket.OPEN) cdp.socket.close(); + await stopChildProcess(chrome); + await stopChildProcess(server); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error.stack || error.message || String(error)); + process.exitCode = 1; +}); diff --git a/.planning/usage-statistics-reference-refit/visual-report.json b/.planning/usage-statistics-reference-refit/visual-report.json new file mode 100644 index 0000000..a1d9bc9 --- /dev/null +++ b/.planning/usage-statistics-reference-refit/visual-report.json @@ -0,0 +1,1838 @@ +{ + "generatedAt": "2026-08-03T14:15:13.710Z", + "chrome": "/home/hdzx/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome", + "views": [ + { + "theme": "washi", + "viewport": { + "width": 1467, + "height": 943 + }, + "dashboard": { + "x": 280, + "y": 0, + "width": 1187, + "height": 943 + }, + "header": { + "x": 280, + "y": 0, + "width": 1187, + "height": 62 + }, + "toolbar": { + "x": 280, + "y": 62, + "width": 1187, + "height": 53 + }, + "metricGrid": { + "x": 300, + "y": 131, + "width": 1147, + "height": 96 + }, + "metrics": [ + { + "x": 301, + "y": 132, + "width": 229, + "height": 94 + }, + { + "x": 530, + "y": 132, + "width": 229, + "height": 94 + }, + { + "x": 759, + "y": 132, + "width": 229, + "height": 94 + }, + { + "x": 988, + "y": 132, + "width": 229, + "height": 94 + }, + { + "x": 1217, + "y": 132, + "width": 229, + "height": 94 + } + ], + "primary": { + "x": 300, + "y": 245, + "width": 1147, + "height": 348 + }, + "trend": { + "x": 300, + "y": 246, + "width": 573.5, + "height": 346 + }, + "mcp": { + "x": 895.5, + "y": 246, + "width": 551.5, + "height": 346 + }, + "lower": { + "x": 300, + "y": 611, + "width": 1147, + "height": 228.5 + }, + "status": { + "x": 300, + "y": 612, + "width": 573.5, + "height": 226.5 + }, + "projects": { + "x": 895.5, + "y": 612, + "width": 551.5, + "height": 226.5 + }, + "sessions": { + "x": 300, + "y": 857.5, + "width": 1147, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2267, + "bodyClientHeight": 828, + "metricColumns": "229px 229px 229px 229px 229px", + "primaryColumns": "573.5px 573.5px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "color(srgb 0.977569 0.964 0.943529)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "washi", + "viewport": { + "width": 1024, + "height": 768 + }, + "dashboard": { + "x": 280, + "y": 0, + "width": 744, + "height": 768 + }, + "header": { + "x": 280, + "y": 0, + "width": 744, + "height": 62 + }, + "toolbar": { + "x": 280, + "y": 62, + "width": 744, + "height": 53 + }, + "metricGrid": { + "x": 300, + "y": 131, + "width": 704, + "height": 190 + }, + "metrics": [ + { + "x": 301, + "y": 132, + "width": 234, + "height": 94 + }, + { + "x": 535, + "y": 132, + "width": 234, + "height": 94 + }, + { + "x": 769, + "y": 132, + "width": 234, + "height": 94 + }, + { + "x": 301, + "y": 226, + "width": 234, + "height": 94 + }, + { + "x": 535, + "y": 226, + "width": 234, + "height": 94 + } + ], + "primary": { + "x": 300, + "y": 339, + "width": 704, + "height": 661.5 + }, + "trend": { + "x": 300, + "y": 340, + "width": 704, + "height": 312.5 + }, + "mcp": { + "x": 300, + "y": 652.5, + "width": 704, + "height": 347 + }, + "lower": { + "x": 300, + "y": 1018.5, + "width": 704, + "height": 456 + }, + "status": { + "x": 300, + "y": 1019.5, + "width": 704, + "height": 226.5 + }, + "projects": { + "x": 300, + "y": 1246, + "width": 704, + "height": 227.5 + }, + "sessions": { + "x": 300, + "y": 1492.4, + "width": 704, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2902, + "bodyClientHeight": 653, + "metricColumns": "234px 234px 234px", + "primaryColumns": "704px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "color(srgb 0.977569 0.964 0.943529)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "washi", + "viewport": { + "width": 768, + "height": 1024 + }, + "dashboard": { + "x": 0, + "y": 0, + "width": 768, + "height": 1024 + }, + "header": { + "x": 0, + "y": 0, + "width": 768, + "height": 62 + }, + "toolbar": { + "x": 0, + "y": 62, + "width": 768, + "height": 106 + }, + "metricGrid": { + "x": 14, + "y": 182, + "width": 740, + "height": 266 + }, + "metrics": [ + { + "x": 15, + "y": 183, + "width": 369, + "height": 88 + }, + { + "x": 384, + "y": 183, + "width": 369, + "height": 88 + }, + { + "x": 15, + "y": 271, + "width": 369, + "height": 88 + }, + { + "x": 384, + "y": 271, + "width": 369, + "height": 88 + }, + { + "x": 15, + "y": 359, + "width": 369, + "height": 88 + } + ], + "primary": { + "x": 14, + "y": 462, + "width": 740, + "height": 651.5 + }, + "trend": { + "x": 14, + "y": 463, + "width": 740, + "height": 307.5 + }, + "mcp": { + "x": 14, + "y": 770.5, + "width": 740, + "height": 342 + }, + "lower": { + "x": 14, + "y": 1127.5, + "width": 740, + "height": 439.1 + }, + "status": { + "x": 14, + "y": 1128.5, + "width": 740, + "height": 214.7 + }, + "projects": { + "x": 14, + "y": 1343.1, + "width": 740, + "height": 222.5 + }, + "sessions": { + "x": 14, + "y": 1584.6, + "width": 740, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2935, + "bodyClientHeight": 856, + "metricColumns": "369px 369px", + "primaryColumns": "740px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "color(srgb 0.977569 0.964 0.943529)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "washi", + "viewport": { + "width": 390, + "height": 844 + }, + "dashboard": { + "x": 0, + "y": 0, + "width": 390, + "height": 844 + }, + "header": { + "x": 0, + "y": 0, + "width": 390, + "height": 62 + }, + "toolbar": { + "x": 0, + "y": 62, + "width": 390, + "height": 106 + }, + "metricGrid": { + "x": 14, + "y": 182, + "width": 362, + "height": 88 + }, + "metrics": [ + { + "x": 15, + "y": 183, + "width": 218, + "height": 86 + }, + { + "x": 233, + "y": 183, + "width": 218, + "height": 86 + }, + { + "x": 451, + "y": 183, + "width": 218, + "height": 86 + }, + { + "x": 669, + "y": 183, + "width": 218, + "height": 86 + }, + { + "x": 887, + "y": 183, + "width": 218, + "height": 86 + } + ], + "primary": { + "x": 14, + "y": 284, + "width": 362, + "height": 651.5 + }, + "trend": { + "x": 14, + "y": 285, + "width": 362, + "height": 307.5 + }, + "mcp": { + "x": 14, + "y": 592.5, + "width": 362, + "height": 342 + }, + "lower": { + "x": 14, + "y": 949.5, + "width": 362, + "height": 705.9 + }, + "status": { + "x": 14, + "y": 950.5, + "width": 362, + "height": 481.4 + }, + "projects": { + "x": 14, + "y": 1431.9, + "width": 362, + "height": 222.5 + }, + "sessions": { + "x": 14, + "y": 1673.4, + "width": 362, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 3024, + "bodyClientHeight": 676, + "metricColumns": "repeat(2, minmax(0px, 1fr))", + "primaryColumns": "362px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "color(srgb 0.977569 0.964 0.943529)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + } + ], + "themes": [ + { + "theme": "washi", + "viewport": { + "width": 1440, + "height": 900 + }, + "dashboard": { + "x": 280, + "y": 0, + "width": 1160, + "height": 900 + }, + "header": { + "x": 280, + "y": 0, + "width": 1160, + "height": 62 + }, + "toolbar": { + "x": 280, + "y": 62, + "width": 1160, + "height": 53 + }, + "metricGrid": { + "x": 300, + "y": 131, + "width": 1120, + "height": 96 + }, + "metrics": [ + { + "x": 301, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 525, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 748, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 972, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 1195, + "y": 132, + "width": 224, + "height": 94 + } + ], + "primary": { + "x": 300, + "y": 245, + "width": 1120, + "height": 348 + }, + "trend": { + "x": 300, + "y": 246, + "width": 560, + "height": 346 + }, + "mcp": { + "x": 882, + "y": 246, + "width": 538, + "height": 346 + }, + "lower": { + "x": 300, + "y": 611, + "width": 1120, + "height": 228.5 + }, + "status": { + "x": 300, + "y": 612, + "width": 560, + "height": 226.5 + }, + "projects": { + "x": 882, + "y": 612, + "width": 538, + "height": 226.5 + }, + "sessions": { + "x": 300, + "y": 857.5, + "width": 1120, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2267, + "bodyClientHeight": 785, + "metricColumns": "223.594px 223.594px 223.609px 223.594px 223.609px", + "primaryColumns": "560px 560px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "color(srgb 0.977569 0.964 0.943529)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "coolvibe", + "viewport": { + "width": 1440, + "height": 900 + }, + "dashboard": { + "x": 280, + "y": 0, + "width": 1160, + "height": 900 + }, + "header": { + "x": 280, + "y": 0, + "width": 1160, + "height": 62 + }, + "toolbar": { + "x": 280, + "y": 62, + "width": 1160, + "height": 53 + }, + "metricGrid": { + "x": 300, + "y": 131, + "width": 1120, + "height": 96 + }, + "metrics": [ + { + "x": 301, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 525, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 748, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 972, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 1195, + "y": 132, + "width": 224, + "height": 94 + } + ], + "primary": { + "x": 300, + "y": 245, + "width": 1120, + "height": 348 + }, + "trend": { + "x": 300, + "y": 246, + "width": 560, + "height": 346 + }, + "mcp": { + "x": 882, + "y": 246, + "width": 538, + "height": 346 + }, + "lower": { + "x": 300, + "y": 611, + "width": 1120, + "height": 228.5 + }, + "status": { + "x": 300, + "y": 612, + "width": 560, + "height": 226.5 + }, + "projects": { + "x": 882, + "y": 612, + "width": 538, + "height": 226.5 + }, + "sessions": { + "x": 300, + "y": 857.5, + "width": 1120, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2267, + "bodyClientHeight": 785, + "metricColumns": "223.594px 223.594px 223.609px 223.594px 223.609px", + "primaryColumns": "560px 560px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "color(srgb 0.933333 0.967686 0.976706)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "editorial", + "viewport": { + "width": 1440, + "height": 900 + }, + "dashboard": { + "x": 280, + "y": 0, + "width": 1160, + "height": 900 + }, + "header": { + "x": 280, + "y": 0, + "width": 1160, + "height": 62 + }, + "toolbar": { + "x": 280, + "y": 62, + "width": 1160, + "height": 53 + }, + "metricGrid": { + "x": 300, + "y": 131, + "width": 1120, + "height": 96 + }, + "metrics": [ + { + "x": 301, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 525, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 748, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 972, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 1195, + "y": 132, + "width": 224, + "height": 94 + } + ], + "primary": { + "x": 300, + "y": 245, + "width": 1120, + "height": 348 + }, + "trend": { + "x": 300, + "y": 246, + "width": 560, + "height": 346 + }, + "mcp": { + "x": 882, + "y": 246, + "width": 538, + "height": 346 + }, + "lower": { + "x": 300, + "y": 611, + "width": 1120, + "height": 228.5 + }, + "status": { + "x": 300, + "y": 612, + "width": 560, + "height": 226.5 + }, + "projects": { + "x": 882, + "y": 612, + "width": 538, + "height": 226.5 + }, + "sessions": { + "x": 300, + "y": 857.5, + "width": 1120, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2267, + "bodyClientHeight": 785, + "metricColumns": "223.594px 223.594px 223.609px 223.594px 223.609px", + "primaryColumns": "560px 560px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "color(srgb 0.962824 0.945569 0.914039)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "sage", + "viewport": { + "width": 1440, + "height": 900 + }, + "dashboard": { + "x": 280, + "y": 0, + "width": 1160, + "height": 900 + }, + "header": { + "x": 280, + "y": 0, + "width": 1160, + "height": 62 + }, + "toolbar": { + "x": 280, + "y": 62, + "width": 1160, + "height": 53 + }, + "metricGrid": { + "x": 300, + "y": 131, + "width": 1120, + "height": 96 + }, + "metrics": [ + { + "x": 301, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 525, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 748, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 972, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 1195, + "y": 132, + "width": 224, + "height": 94 + } + ], + "primary": { + "x": 300, + "y": 245, + "width": 1120, + "height": 348 + }, + "trend": { + "x": 300, + "y": 246, + "width": 560, + "height": 346 + }, + "mcp": { + "x": 882, + "y": 246, + "width": 538, + "height": 346 + }, + "lower": { + "x": 300, + "y": 611, + "width": 1120, + "height": 228.5 + }, + "status": { + "x": 300, + "y": 612, + "width": 560, + "height": 226.5 + }, + "projects": { + "x": 882, + "y": 612, + "width": 538, + "height": 226.5 + }, + "sessions": { + "x": 300, + "y": 857.5, + "width": 1120, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2267, + "bodyClientHeight": 785, + "metricColumns": "223.594px 223.594px 223.609px 223.594px 223.609px", + "primaryColumns": "560px 560px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "color(srgb 0.959137 0.971373 0.950902)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "ink", + "viewport": { + "width": 1440, + "height": 900 + }, + "dashboard": { + "x": 280, + "y": 0, + "width": 1160, + "height": 900 + }, + "header": { + "x": 280, + "y": 0, + "width": 1160, + "height": 62 + }, + "toolbar": { + "x": 280, + "y": 62, + "width": 1160, + "height": 53 + }, + "metricGrid": { + "x": 300, + "y": 131, + "width": 1120, + "height": 96 + }, + "metrics": [ + { + "x": 301, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 525, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 748, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 972, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 1195, + "y": 132, + "width": 224, + "height": 94 + } + ], + "primary": { + "x": 300, + "y": 245, + "width": 1120, + "height": 348 + }, + "trend": { + "x": 300, + "y": 246, + "width": 560, + "height": 346 + }, + "mcp": { + "x": 882, + "y": 246, + "width": 538, + "height": 346 + }, + "lower": { + "x": 300, + "y": 611, + "width": 1120, + "height": 228.5 + }, + "status": { + "x": 300, + "y": 612, + "width": 560, + "height": 226.5 + }, + "projects": { + "x": 882, + "y": 612, + "width": 538, + "height": 226.5 + }, + "sessions": { + "x": 300, + "y": 857.5, + "width": 1120, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2267, + "bodyClientHeight": 785, + "metricColumns": "223.594px 223.594px 223.609px 223.594px 223.609px", + "primaryColumns": "560px 560px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "color(srgb 0.962824 0.967686 0.984078)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "dawn", + "viewport": { + "width": 1440, + "height": 900 + }, + "dashboard": { + "x": 280, + "y": 0, + "width": 1160, + "height": 900 + }, + "header": { + "x": 280, + "y": 0, + "width": 1160, + "height": 62 + }, + "toolbar": { + "x": 280, + "y": 62, + "width": 1160, + "height": 53 + }, + "metricGrid": { + "x": 300, + "y": 131, + "width": 1120, + "height": 96 + }, + "metrics": [ + { + "x": 301, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 525, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 748, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 972, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 1195, + "y": 132, + "width": 224, + "height": 94 + } + ], + "primary": { + "x": 300, + "y": 245, + "width": 1120, + "height": 348 + }, + "trend": { + "x": 300, + "y": 246, + "width": 560, + "height": 346 + }, + "mcp": { + "x": 882, + "y": 246, + "width": 538, + "height": 346 + }, + "lower": { + "x": 300, + "y": 611, + "width": 1120, + "height": 228.5 + }, + "status": { + "x": 300, + "y": 612, + "width": 560, + "height": 226.5 + }, + "projects": { + "x": 882, + "y": 612, + "width": 538, + "height": 226.5 + }, + "sessions": { + "x": 300, + "y": 857.5, + "width": 1120, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2267, + "bodyClientHeight": 785, + "metricColumns": "223.594px 223.594px 223.609px 223.594px 223.609px", + "primaryColumns": "560px 560px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "color(srgb 0.996 0.967686 0.950902)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "carbon", + "viewport": { + "width": 1440, + "height": 900 + }, + "dashboard": { + "x": 280, + "y": 0, + "width": 1160, + "height": 900 + }, + "header": { + "x": 280, + "y": 0, + "width": 1160, + "height": 62 + }, + "toolbar": { + "x": 280, + "y": 62, + "width": 1160, + "height": 53 + }, + "metricGrid": { + "x": 300, + "y": 131, + "width": 1120, + "height": 96 + }, + "metrics": [ + { + "x": 301, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 525, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 748, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 972, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 1195, + "y": 132, + "width": 224, + "height": 94 + } + ], + "primary": { + "x": 300, + "y": 245, + "width": 1120, + "height": 348 + }, + "trend": { + "x": 300, + "y": 246, + "width": 560, + "height": 346 + }, + "mcp": { + "x": 882, + "y": 246, + "width": 538, + "height": 346 + }, + "lower": { + "x": 300, + "y": 611, + "width": 1120, + "height": 228.5 + }, + "status": { + "x": 300, + "y": 612, + "width": 560, + "height": 226.5 + }, + "projects": { + "x": 882, + "y": 612, + "width": 538, + "height": 226.5 + }, + "sessions": { + "x": 300, + "y": 857.5, + "width": 1120, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2267, + "bodyClientHeight": 785, + "metricColumns": "223.594px 223.594px 223.609px 223.594px 223.609px", + "primaryColumns": "560px 560px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "rgb(15, 19, 20)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "nocturne", + "viewport": { + "width": 1440, + "height": 900 + }, + "dashboard": { + "x": 280, + "y": 0, + "width": 1160, + "height": 900 + }, + "header": { + "x": 280, + "y": 0, + "width": 1160, + "height": 62 + }, + "toolbar": { + "x": 280, + "y": 62, + "width": 1160, + "height": 53 + }, + "metricGrid": { + "x": 300, + "y": 131, + "width": 1120, + "height": 96 + }, + "metrics": [ + { + "x": 301, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 525, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 748, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 972, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 1195, + "y": 132, + "width": 224, + "height": 94 + } + ], + "primary": { + "x": 300, + "y": 245, + "width": 1120, + "height": 348 + }, + "trend": { + "x": 300, + "y": 246, + "width": 560, + "height": 346 + }, + "mcp": { + "x": 882, + "y": 246, + "width": 538, + "height": 346 + }, + "lower": { + "x": 300, + "y": 611, + "width": 1120, + "height": 228.5 + }, + "status": { + "x": 300, + "y": 612, + "width": 560, + "height": 226.5 + }, + "projects": { + "x": 882, + "y": 612, + "width": 538, + "height": 226.5 + }, + "sessions": { + "x": 300, + "y": 857.5, + "width": 1120, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2267, + "bodyClientHeight": 785, + "metricColumns": "223.594px 223.594px 223.609px 223.594px 223.609px", + "primaryColumns": "560px 560px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "rgb(8, 20, 23)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "cinder", + "viewport": { + "width": 1440, + "height": 900 + }, + "dashboard": { + "x": 280, + "y": 0, + "width": 1160, + "height": 900 + }, + "header": { + "x": 280, + "y": 0, + "width": 1160, + "height": 62 + }, + "toolbar": { + "x": 280, + "y": 62, + "width": 1160, + "height": 53 + }, + "metricGrid": { + "x": 300, + "y": 131, + "width": 1120, + "height": 96 + }, + "metrics": [ + { + "x": 301, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 525, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 748, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 972, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 1195, + "y": 132, + "width": 224, + "height": 94 + } + ], + "primary": { + "x": 300, + "y": 245, + "width": 1120, + "height": 348 + }, + "trend": { + "x": 300, + "y": 246, + "width": 560, + "height": 346 + }, + "mcp": { + "x": 882, + "y": 246, + "width": 538, + "height": 346 + }, + "lower": { + "x": 300, + "y": 611, + "width": 1120, + "height": 228.5 + }, + "status": { + "x": 300, + "y": 612, + "width": 560, + "height": 226.5 + }, + "projects": { + "x": 882, + "y": 612, + "width": 538, + "height": 226.5 + }, + "sessions": { + "x": 300, + "y": 857.5, + "width": 1120, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2267, + "bodyClientHeight": 785, + "metricColumns": "223.594px 223.594px 223.609px 223.594px 223.609px", + "primaryColumns": "560px 560px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "rgb(21, 17, 18)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "gilded", + "viewport": { + "width": 1440, + "height": 900 + }, + "dashboard": { + "x": 280, + "y": 0, + "width": 1160, + "height": 900 + }, + "header": { + "x": 280, + "y": 0, + "width": 1160, + "height": 62 + }, + "toolbar": { + "x": 280, + "y": 62, + "width": 1160, + "height": 53 + }, + "metricGrid": { + "x": 300, + "y": 131, + "width": 1120, + "height": 96 + }, + "metrics": [ + { + "x": 301, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 525, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 748, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 972, + "y": 132, + "width": 224, + "height": 94 + }, + { + "x": 1195, + "y": 132, + "width": 224, + "height": 94 + } + ], + "primary": { + "x": 300, + "y": 245, + "width": 1120, + "height": 348 + }, + "trend": { + "x": 300, + "y": 246, + "width": 560, + "height": 346 + }, + "mcp": { + "x": 882, + "y": 246, + "width": 538, + "height": 346 + }, + "lower": { + "x": 300, + "y": 611, + "width": 1120, + "height": 228.5 + }, + "status": { + "x": 300, + "y": 612, + "width": 560, + "height": 226.5 + }, + "projects": { + "x": 882, + "y": 612, + "width": 538, + "height": 226.5 + }, + "sessions": { + "x": 300, + "y": 857.5, + "width": 1120, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2267, + "bodyClientHeight": 785, + "metricColumns": "223.594px 223.594px 223.609px 223.594px 223.609px", + "primaryColumns": "560px 560px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "rgb(243, 234, 216)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + }, + { + "theme": "wasteland", + "viewport": { + "width": 1440, + "height": 900 + }, + "dashboard": { + "x": 316, + "y": 0, + "width": 1124, + "height": 900 + }, + "header": { + "x": 316, + "y": 0, + "width": 1124, + "height": 62 + }, + "toolbar": { + "x": 316, + "y": 62, + "width": 1124, + "height": 53 + }, + "metricGrid": { + "x": 336, + "y": 131, + "width": 1084, + "height": 96 + }, + "metrics": [ + { + "x": 337, + "y": 132, + "width": 216, + "height": 94 + }, + { + "x": 553, + "y": 132, + "width": 216, + "height": 94 + }, + { + "x": 770, + "y": 132, + "width": 216, + "height": 94 + }, + { + "x": 986, + "y": 132, + "width": 216, + "height": 94 + }, + { + "x": 1203, + "y": 132, + "width": 216, + "height": 94 + } + ], + "primary": { + "x": 336, + "y": 245, + "width": 1084, + "height": 348 + }, + "trend": { + "x": 336, + "y": 246, + "width": 542, + "height": 346 + }, + "mcp": { + "x": 900, + "y": 246, + "width": 520, + "height": 346 + }, + "lower": { + "x": 336, + "y": 611, + "width": 1084, + "height": 228.5 + }, + "status": { + "x": 336, + "y": 612, + "width": 542, + "height": 226.5 + }, + "projects": { + "x": 900, + "y": 612, + "width": 520, + "height": 226.5 + }, + "sessions": { + "x": 336, + "y": 857.5, + "width": 1084, + "height": 1490.9 + }, + "dashboardOverflowX": 0, + "bodyOverflowX": 0, + "bodyScrollHeight": 2267, + "bodyClientHeight": 785, + "metricColumns": "216.391px 216.406px 216.391px 216.406px 216.391px", + "primaryColumns": "542px 542px", + "panelBackground": "rgba(0, 0, 0, 0)", + "pageBackground": "rgb(5, 7, 7)", + "charts": { + "runtime": true, + "sparklines": 5, + "trend": 1, + "donuts": 3, + "ready": true + }, + "skillTextVisible": false, + "textOverflow": [] + } + ] +} \ No newline at end of file diff --git a/.trellis/tasks/08-03-usage-statistics-dashboard-refit/references/source-assets/manifest.json b/.trellis/tasks/08-03-usage-statistics-dashboard-refit/references/source-assets/manifest.json new file mode 100644 index 0000000..0f38b9a --- /dev/null +++ b/.trellis/tasks/08-03-usage-statistics-dashboard-refit/references/source-assets/manifest.json @@ -0,0 +1,11 @@ +{ + "source": "/home/cc-web/sessions/_attachments/cb0a0566-dc77-489b-8e04-183bbb0bd172.png", + "archived_as": "usage-statistics-dashboard-reference.png", + "role": "完整参考原型,仅用于视觉对照", + "width": 1467, + "height": 943, + "format": "PNG RGBA", + "size_bytes": 1412493, + "sha256": "ddbcc605cdcd4ea6aab943b4fa101c1e4af7e1c729fa090d1c89d847e2fdf91d", + "runtime_asset": false +} diff --git a/.trellis/tasks/08-03-usage-statistics-dashboard-refit/references/source-assets/usage-statistics-dashboard-reference.png b/.trellis/tasks/08-03-usage-statistics-dashboard-refit/references/source-assets/usage-statistics-dashboard-reference.png new file mode 100644 index 0000000..48c1980 Binary files /dev/null and b/.trellis/tasks/08-03-usage-statistics-dashboard-refit/references/source-assets/usage-statistics-dashboard-reference.png differ diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 7a07912..fb2177b 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/usage-statistics.js b/lib/usage-statistics.js index ea06b91..da1adc1 100644 --- a/lib/usage-statistics.js +++ b/lib/usage-statistics.js @@ -10,6 +10,7 @@ const DEFAULT_MAX_RANGE_DAYS = 370; const DEFAULT_RECENT_LIMIT = 50; const DEFAULT_MCP_TOOL_LIMIT = 200; const DEFAULT_SKILL_LIMIT = 100; +const DEFAULT_PROJECT_LIMIT = 100; const DEFAULT_MCP_DETAIL_LIMIT = 250; const BUILD_BATCH_SIZE = 1; const UPSERT_DEBOUNCE_MS = 300; @@ -292,6 +293,7 @@ function createTrendBucket(date) { mcpCalls: 0, mcpFailures: 0, skillMentions: 0, + projects: 0, }; } @@ -316,9 +318,11 @@ function aggregateUsageStatistics(documents, params = {}, meta = {}) { const recentLimit = clampInteger(params.recentLimit, DEFAULT_RECENT_LIMIT, 1, 100); const mcpToolLimit = clampInteger(params.mcpToolLimit, DEFAULT_MCP_TOOL_LIMIT, 1, 500); const skillLimit = clampInteger(params.skillLimit, DEFAULT_SKILL_LIMIT, 1, 500); + const projectLimit = clampInteger(params.projectLimit, DEFAULT_PROJECT_LIMIT, 1, 500); const mcpDetailLimit = clampInteger(params.mcpDetailLimit, DEFAULT_MCP_DETAIL_LIMIT, 1, 500); const dayKey = createDayKeyFormatter(range.timeZone); const trendMap = createTrendBuckets(range, dayKey); + const trendProjectKeys = new Map(Array.from(trendMap.keys(), (key) => [key, new Set()])); const overview = { newSessions: 0, messages: 0, @@ -327,10 +331,14 @@ function aggregateUsageStatistics(documents, params = {}, meta = {}) { mcpCalls: 0, mcpFailures: 0, skillMentions: 0, + projects: 0, + projectSessions: 0, + unassignedProjectSessions: 0, }; const mcpStatus = { completed: 0, failed: 0, other: 0 }; const mcpTools = new Map(); const skills = new Map(); + const projects = new Map(); const mcpRecentCalls = []; const recentSessions = []; let retainedMessages = 0; @@ -338,12 +346,15 @@ function aggregateUsageStatistics(documents, params = {}, meta = {}) { for (const doc of documents || []) { if (!doc || !doc.sessionId) continue; retainedMessages += Math.max(0, Number(doc.sourceMessageCount || 0)); + const projectCwd = cleanDisplayText(String(doc.cwd || ''), 1200).replace(/\\/g, '/').replace(/\/+$/, ''); + const projectName = cleanDisplayText(String(doc.projectName || basenameFromCwd(projectCwd)), 240); + const projectKey = projectName ? (projectCwd ? `cwd:${projectCwd}` : `name:${projectName}`) : ''; const sessionSummary = { sessionId: doc.sessionId, title: doc.title || '未命名会话', agent: doc.agent || 'codex', cwd: doc.cwd || '', - projectName: doc.projectName || '', + projectName, created: doc.created || null, lastActivity: null, newSession: false, @@ -361,7 +372,10 @@ function aggregateUsageStatistics(documents, params = {}, meta = {}) { sessionSummary.newSession = true; sessionSummary.lastActivity = new Date(createdMs).toISOString(); const bucket = trendMap.get(dayKey(createdMs)); - if (bucket) bucket.newSessions += 1; + if (bucket) { + bucket.newSessions += 1; + if (projectKey) trendProjectKeys.get(bucket.date)?.add(projectKey); + } } for (const event of Array.isArray(doc.events) ? doc.events : []) { @@ -373,6 +387,7 @@ function aggregateUsageStatistics(documents, params = {}, meta = {}) { } const bucket = trendMap.get(dayKey(timestampMs)); if (!bucket) continue; + if (projectKey) trendProjectKeys.get(bucket.date)?.add(projectKey); if (event.type === 'message') { overview.messages += 1; @@ -448,7 +463,34 @@ function aggregateUsageStatistics(documents, params = {}, meta = {}) { } } - if (sessionSummary.lastActivity) recentSessions.push(sessionSummary); + if (sessionSummary.lastActivity) { + if (projectKey) { + const current = projects.get(projectKey) || { + key: projectKey, + name: projectName, + cwd: projectCwd, + sessions: 0, + messages: 0, + mcpCalls: 0, + lastActiveAt: null, + }; + current.sessions += 1; + current.messages += sessionSummary.messages; + current.mcpCalls += sessionSummary.mcpCalls; + if (!current.lastActiveAt || sessionSummary.lastActivity > current.lastActiveAt) { + current.lastActiveAt = sessionSummary.lastActivity; + } + projects.set(projectKey, current); + overview.projectSessions += 1; + } else { + overview.unassignedProjectSessions += 1; + } + recentSessions.push(sessionSummary); + } + } + + for (const bucket of trendMap.values()) { + bucket.projects = trendProjectKeys.get(bucket.date)?.size || 0; } const mcpToolRows = Array.from(mcpTools.values()); @@ -460,6 +502,12 @@ function aggregateUsageStatistics(documents, params = {}, meta = {}) { || a.server.localeCompare(b.server) || a.tool.localeCompare(b.tool)); const skillRows = Array.from(skills.values()).sort((a, b) => b.uses - a.uses || a.name.localeCompare(b.name)); + const projectRows = Array.from(projects.values()).sort((a, b) => b.messages - a.messages + || b.mcpCalls - a.mcpCalls + || b.sessions - a.sessions + || a.name.localeCompare(b.name) + || a.key.localeCompare(b.key)); + overview.projects = projectRows.length; recentSessions.sort((a, b) => String(b.lastActivity).localeCompare(String(a.lastActivity)) || a.sessionId.localeCompare(b.sessionId)); mcpRecentCalls.sort((a, b) => b.timestamp.localeCompare(a.timestamp) || a.server.localeCompare(b.server) @@ -479,6 +527,7 @@ function aggregateUsageStatistics(documents, params = {}, meta = {}) { row.detailCallsReturned = returnedDetailCounts.get(`${row.server}\u0000${row.tool}`) || 0; } const returnedSkills = skillRows.slice(0, skillLimit); + const returnedProjects = projectRows.slice(0, projectLimit); return { schemaVersion: SCHEMA_VERSION, @@ -501,6 +550,8 @@ function aggregateUsageStatistics(documents, params = {}, meta = {}) { returnedMcpTools: returnedMcpTools.length, distinctSkills: skillRows.length, returnedSkills: returnedSkills.length, + distinctProjects: projectRows.length, + returnedProjects: returnedProjects.length, lastBuiltAt: meta.lastBuiltAt || null, mcpTimestampBasis: 'assistant_message', skillBasis: 'explicit_composer_mention', @@ -511,12 +562,14 @@ function aggregateUsageStatistics(documents, params = {}, meta = {}) { limits: { mcpTools: mcpToolLimit, skills: skillLimit, + projects: projectLimit, mcpRecentCalls: mcpDetailLimit, recentSessions: recentLimit, }, mcpTools: returnedMcpTools, mcpRecentCalls: returnedMcpRecentCalls, skills: returnedSkills, + projects: returnedProjects, recentSessions: recentSessions.slice(0, recentLimit), }; } diff --git a/package-lock.json b/package-lock.json index 44f85a1..8b8c57c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,24 @@ "name": "cc-web", "version": "1.2.11", "dependencies": { + "echarts": "5.6.0", "ws": "^8.18.0" } }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -31,6 +46,14 @@ "optional": true } } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "dependencies": { + "tslib": "2.3.0" + } } } } diff --git a/package.json b/package.json index d89c7ba..288dffa 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "regression": "node scripts/regression.js" }, "dependencies": { + "echarts": "5.6.0", "ws": "^8.18.0" } } diff --git a/public/app.js b/public/app.js index ea3eba4..b566c54 100644 --- a/public/app.js +++ b/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(); diff --git a/public/index.html b/public/index.html index a5dbb2d..ab7bcf3 100644 --- a/public/index.html +++ b/public/index.html @@ -24,7 +24,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - +
@@ -154,35 +154,44 @@基于当前保留的会话数据