feat: add project usage analytics with ECharts
170
.planning/usage-statistics-reference-refit/findings.md
Normal file
@@ -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,因此视觉夹具可覆盖三个环图的全部分段。
|
||||
39
.planning/usage-statistics-reference-refit/progress.md
Normal file
@@ -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。
|
||||
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 307 KiB |
64
.planning/usage-statistics-reference-refit/task_plan.md
Normal file
@@ -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
|
||||
502
.planning/usage-statistics-reference-refit/visual-check.js
Normal file
@@ -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;
|
||||
});
|
||||