feat: service console auto-connect, settings panel, and batch of enhancements
- Auto-connect WebSocket on page load in service console - Settings modal for editing sgclaw_config.json (API key, base URL, model, skills dir, etc.) - UpdateConfig/ConfigUpdated protocol messages for remote config save - save_to_path() for SgClawSettings serialization - ConfigUpdated handler in sg_claw_client binary - Protocol serialization tests for new message types - HTML test assertions for auto-connect and settings UI - Additional pending changes: deterministic submit, org units, lineloss xlsx export, browser script tool, and docs 🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
422
docs/collect_lineloss_troubleshooting_guide.md
Normal file
422
docs/collect_lineloss_troubleshooting_guide.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# collect_lineloss.js 从生成到可用的完整排查记录
|
||||
|
||||
本文档记录了 `tq-lineloss-report` skill 脚本从初始生成到最终可用的全部排查过程,包括遇到的每个错误、根因分析和修复方法。可作为后续类似 skill 开发的排查模板。
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
### 架构概览
|
||||
|
||||
```
|
||||
用户输入 "兰州公司 月累计 2026-03。。。"
|
||||
│
|
||||
▼
|
||||
sgClaw Rust 进程
|
||||
├── 解析指令 → DeterministicExecutionPlan
|
||||
├── 读取 collect_lineloss.js 脚本
|
||||
├── 包装为 IIFE:(function(){ const args = {...}; <脚本内容> })()
|
||||
├── 调用 sgBrowserExcuteJsCodeByDomain(domain, wrappedJs)
|
||||
│ 注入到浏览器中匹配 domain 的页面执行
|
||||
├── 等待回调:脚本通过 callBackJsToCpp 返回 JSON 结果
|
||||
├── 解析 artifact JSON → 提取 status/rows/reasons
|
||||
└── 生成 XLSX(Rust 侧)→ 返回 outcome
|
||||
```
|
||||
|
||||
### 关键差异:原始场景 vs Skill 模式
|
||||
|
||||
| 对比项 | 原始场景 (index.html) | Skill 模式 |
|
||||
|--------|----------------------|------------|
|
||||
| 脚本注入方式 | `sgBrowserExcuteJsCode(exactURL, js)` — 精确 URL | `sgBrowserExcuteJsCodeByDomain(domain, js)` — 仅域名匹配 |
|
||||
| 执行页面 | 业务子页面 `/tqLinelossStatis/tqQualifyRateMonitor` | 可能命中父框架页 `/gsllys` |
|
||||
| `window.mac` | 有(Vue 实例,`mounted()` 中 `window.mac = this`) | 无(没有 Vue 实例) |
|
||||
| 导出 Excel | JS 调 `localhost:13313`(本地场景页可访问) | JS 无法调 `localhost:13313`(CORS 阻断) |
|
||||
| 结果回传 | Rust 只需要 `.then()` 回调结果 | 同左,但脚本是 async 函数需 `.then()` 处理 |
|
||||
|
||||
---
|
||||
|
||||
## 排查时间线
|
||||
|
||||
### 第 1 阶段:基础管道问题
|
||||
|
||||
#### 问题 1: `missing_expected_domain`
|
||||
|
||||
**现象**: `status=blocked reasons=missing_expected_domain`
|
||||
|
||||
**根因**: Rust 侧 `deterministic_submit.rs` 构造 args 时没有传 `expected_domain` 字段。`derive_expected_domain()` 从 `page_url` 提取 host 时只取了域名不含端口,但传入 args 时 key 不匹配。
|
||||
|
||||
**修复**: 确保 `deterministic_submit_args()` 正确插入 `expected_domain` 到 args Map。
|
||||
|
||||
**涉及文件**: `src/compat/deterministic_submit.rs`
|
||||
|
||||
**是否需要重新编译**: 是
|
||||
|
||||
---
|
||||
|
||||
#### 问题 2: `target_url` 缺少端口号
|
||||
|
||||
**现象**: 脚本注入失败或注入到错误页面。
|
||||
|
||||
**根因**: `target_url` 被设为 `http://20.76.57.61`(无端口),但实际业务页面在 `http://20.76.57.61:18080/gsllys/...`。`sgBrowserExcuteJsCodeByDomain` 需要能匹配到正确的标签页。
|
||||
|
||||
**修复**: 在 `deterministic_submit.rs` 中设置完整 `target_url`:
|
||||
```rust
|
||||
const LINELLOSS_TARGET_URL: &str = "http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor";
|
||||
```
|
||||
|
||||
**涉及文件**: `src/compat/deterministic_submit.rs`
|
||||
|
||||
**是否需要重新编译**: 是
|
||||
|
||||
---
|
||||
|
||||
#### 问题 3: 脚本返回 `{}` 空对象
|
||||
|
||||
**现象**: Rust 侧收到的 artifact 是 `{}`,无任何数据。
|
||||
|
||||
**根因**: `collect_lineloss.js` 的入口 `buildBrowserEntrypointResult()` 是 `async` 函数,返回 Promise。Rust 侧 `build_eval_js` 包装器原来直接调用 `_s(v)` 发送结果,但 `v` 是一个 Promise 对象,JSON.stringify 后变成 `{}`。
|
||||
|
||||
**修复**: 在 `build_eval_js`(`callback_backend.rs`)中增加 Promise 检测:
|
||||
```rust
|
||||
// 旧代码
|
||||
"_s(v);"
|
||||
|
||||
// 新代码
|
||||
"if(v&&typeof v.then==='function'){v.then(_s).catch(function(){});}else{_s(v);}"
|
||||
```
|
||||
|
||||
如果返回值是 thenable(Promise),等它 resolve 后再发送回调。
|
||||
|
||||
**涉及文件**: `src/browser/callback_backend.rs` 中 `build_eval_js` 函数
|
||||
|
||||
**是否需要重新编译**: 是
|
||||
|
||||
**教训**: 所有 browser_script skill 如果入口函数是 async(返回 Promise),都需要这个 `.then()` 处理。这是管道层的通用修复。
|
||||
|
||||
---
|
||||
|
||||
### 第 2 阶段:页面上下文问题
|
||||
|
||||
#### 问题 4: `page_context_unavailable` (mac_missing)
|
||||
|
||||
**现象**:
|
||||
```
|
||||
tq-lineloss-report 国网兰州供电公司 2026-03 status=blocked rows=0 reasons=page_context_unavailable
|
||||
```
|
||||
|
||||
**排查过程**:
|
||||
|
||||
1. 在 `validatePageContext` 中添加诊断信息:
|
||||
```javascript
|
||||
// 临时诊断代码
|
||||
const diag = 'href=' + href + '|host=' + host + '|port=' + port + '|title=' + title + '|mac=' + hasMac;
|
||||
return { ok: false, reason: 'page_context_unavailable:mac_missing|' + diag };
|
||||
```
|
||||
|
||||
2. 页面返回的诊断结果:
|
||||
```
|
||||
href=http://20.76.57.61:18080/gsllys
|
||||
host=20.76.57.61
|
||||
port=18080
|
||||
title=台区线损大数据分析模块
|
||||
mac=false
|
||||
```
|
||||
|
||||
**根因**: `sgBrowserExcuteJsCodeByDomain("20.76.57.61")` 匹配到了父框架页 `/gsllys`,而不是业务子页面。`window.mac` 是业务子页面的 Vue 实例,在 `mounted()` 中通过 `window.mac = this` 设置,父框架页没有这个实例。
|
||||
|
||||
**关键认知**: 在 Skill 模式下没有 Vue 实例,`window.mac` 检查在架构上就不适用。脚本通过 AJAX 发绝对 URL 请求,不依赖页面本地状态。
|
||||
|
||||
**修复**: 删除 `globalThis.mac` 检查,只保留 host 匹配:
|
||||
```javascript
|
||||
// 修复前
|
||||
validatePageContext(args) {
|
||||
// ... 含 mac 检查 + 诊断代码
|
||||
if (!hasMac) {
|
||||
return { ok: false, reason: 'page_context_unavailable:mac_missing|' + diag };
|
||||
}
|
||||
}
|
||||
|
||||
// 修复后
|
||||
validatePageContext(args) {
|
||||
const host = normalizeText(globalThis.location?.hostname);
|
||||
const expected = normalizeText(args.expected_domain);
|
||||
if (!host) {
|
||||
return { ok: false, reason: 'page_context_unavailable' };
|
||||
}
|
||||
if (host !== expected) {
|
||||
return { ok: false, reason: 'page_context_mismatch' };
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
```
|
||||
|
||||
**涉及文件**: `collect_lineloss.js` — `validatePageContext` 函数
|
||||
|
||||
**是否需要重新编译**: 否(JS 文件运行时读取)
|
||||
|
||||
**排查技巧**: 在 reasons 中拼接诊断信息(href/host/port/title/mac),不需要 F12 console,直接通过 Rust 侧的 summary 输出就能看到。
|
||||
|
||||
---
|
||||
|
||||
### 第 3 阶段:API 请求问题
|
||||
|
||||
#### 问题 5: `api_query_failed` — 返回 HTML 而非 JSON
|
||||
|
||||
**现象**:
|
||||
```
|
||||
status=error rows=0 reasons=api_query_failed:month_api_failed: SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
|
||||
```
|
||||
|
||||
**根因**: 后端服务检测到请求缺少 `X-Requested-With: XMLHttpRequest` 头,认为这不是 AJAX 请求,返回了 HTML 登录页面。jQuery 的 `$.ajax` 不会自动添加这个头。
|
||||
|
||||
**修复**: 在 `queryMonthData` 和 `queryWeekData` 的 `$.ajax` 调用中添加请求头:
|
||||
```javascript
|
||||
$.ajax({
|
||||
url,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
crossDomain: true,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }, // <-- 新增
|
||||
data: request,
|
||||
contentType: 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
success: resolve,
|
||||
error: (xhr, _status, err) => reject(new Error(
|
||||
`month_api_failed(${xhr.status}): ${String(err)}|body=${String(xhr.responseText || '').substring(0, 200)}`
|
||||
))
|
||||
});
|
||||
```
|
||||
|
||||
**涉及文件**: `collect_lineloss.js` — `queryMonthData` 和 `queryWeekData`
|
||||
|
||||
**是否需要重新编译**: 否
|
||||
|
||||
**排查技巧**: 在 error handler 中拼接 `xhr.responseText` 的前 200 字符到 reasons 中。如果看到 `<!DOCTYPE` 开头,说明后端返回了 HTML 而非 JSON。
|
||||
|
||||
**通用规则**: 内网 Java 后端通常依赖 `X-Requested-With: XMLHttpRequest` 来区分页面请求和 AJAX 请求。所有对内网 API 的 `$.ajax` 调用都应加上此头。
|
||||
|
||||
---
|
||||
|
||||
### 第 4 阶段:数据规范化问题
|
||||
|
||||
#### 问题 6: `row_normalization_failed` — 列名不匹配
|
||||
|
||||
**现象**:
|
||||
```
|
||||
status=error rows=0 reasons=row_normalization_failed:rawRows=12|keys=YGDL,ORG_NO,YXSL,TG_NUM...
|
||||
```
|
||||
|
||||
**根因**: 初始生成的 `MONTH_COLUMN_DEFS` 使用了猜测的列名:
|
||||
```javascript
|
||||
// 错误的列名
|
||||
['LINE_LOSS_RATE', '线损完成率(%)'],
|
||||
['PPQ', '累计供电量'],
|
||||
['UPQ', '累计售电量'],
|
||||
```
|
||||
|
||||
而 API 实际返回的列名是(参考原始场景 `index.html` 中的 `cols2`):
|
||||
```javascript
|
||||
// 正确的列名
|
||||
['ORG_NAME', '供电单位'],
|
||||
['YGDL', '累计供电量'],
|
||||
['YYDL', '累计售电量'],
|
||||
['YXSL', '线损完成率(%)'],
|
||||
['RAT_SCOPE', '线损率累计目标值'],
|
||||
['BLANK3', '目标完成率'],
|
||||
['BLANK2', '排行']
|
||||
```
|
||||
|
||||
**修复**: 按原始场景 `index.html` 中 `cols2` 的定义修正 `MONTH_COLUMN_DEFS`。
|
||||
|
||||
**排查技巧**: 在 `reasons` 中拼接 `rawRows.length` 和 `Object.keys(rawRows[0]).join(',')` 可以直接看到 API 返回了哪些字段。
|
||||
|
||||
**通用规则**: 生成 skill 脚本时,列定义必须从原始场景代码中精确复制,不能靠猜测。找 `cols1`/`cols2` 或表格渲染相关代码。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 7: `row_normalization_failed` — 数值类型不兼容
|
||||
|
||||
**现象**: 列名修正后仍报 `row_normalization_failed:rawRows=12`,12 行全部被过滤。
|
||||
|
||||
**根因**: `pickFirstNonEmpty()` 函数只识别字符串类型:
|
||||
```javascript
|
||||
function pickFirstNonEmpty(...values) {
|
||||
for (const value of values) {
|
||||
if (isNonEmptyString(value)) { // isNonEmptyString: typeof value === 'string'
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return ''; // API 返回数字 12345.67,typeof === 'number',被当作空值
|
||||
}
|
||||
```
|
||||
|
||||
API 返回的字段值是数字(如 `YGDL: 12345.67`),不是字符串。`pickFirstNonEmpty` 对数字返回 `''`,导致所有行的所有字段都为空,全部被过滤。
|
||||
|
||||
**修复**: `normalizeMonthRow` 不使用 `pickFirstNonEmpty`,改为直接处理任意类型值:
|
||||
```javascript
|
||||
// 修复前
|
||||
function normalizeMonthRow(rawRow) {
|
||||
const row = {};
|
||||
for (const key of MONTH_COLUMNS) {
|
||||
row[key] = pickFirstNonEmpty(rawRow?.[key]); // 数字类型 → ''
|
||||
}
|
||||
return MONTH_COLUMNS.every((key) => row[key] !== '') ? row : null;
|
||||
}
|
||||
|
||||
// 修复后
|
||||
function normalizeMonthRow(rawRow) {
|
||||
const row = {};
|
||||
for (const key of MONTH_COLUMNS) {
|
||||
const v = rawRow?.[key];
|
||||
row[key] = (v === null || v === undefined || v === '') ? '' : String(v).trim();
|
||||
}
|
||||
return MONTH_COLUMNS.every((key) => row[key] !== '') ? row : null;
|
||||
}
|
||||
```
|
||||
|
||||
**涉及文件**: `collect_lineloss.js` — `normalizeMonthRow`
|
||||
|
||||
**是否需要重新编译**: 否
|
||||
|
||||
**通用规则**: 内网 API 返回的 JSON 中数值字段通常是 `number` 类型而非字符串。行规范化函数必须用 `String(v)` 进行类型转换,不能依赖 `typeof === 'string'` 判断。
|
||||
|
||||
---
|
||||
|
||||
### 第 5 阶段:导出问题(架构级)
|
||||
|
||||
#### 问题 8: 导出永久挂起
|
||||
|
||||
**现象**:
|
||||
```
|
||||
tq-lineloss-report 国网兰州供电公司 2026-03 status=pl rows=12
|
||||
```
|
||||
数据采集成功(12 行),但之后永远没有返回,脚本卡死在导出步骤。
|
||||
|
||||
**排查过程**:
|
||||
|
||||
1. `exportWorkbook` 调用 `fetch('http://localhost:13313/...')` — CORS 阻断
|
||||
2. 改用 `$.ajax({ crossDomain: true })` — 同样阻断
|
||||
3. 确认这是浏览器安全模型限制,不是配置问题
|
||||
|
||||
**根因**: 脚本运行在远程页面 `http://20.76.57.61:18080` 上,浏览器禁止从远程页面向 `localhost:13313` 发起请求(同源策略 + Mixed Content)。`crossDomain: true` 只是告诉 jQuery 用跨域模式,并不能绕过浏览器安全策略。
|
||||
|
||||
原始场景的解决方式:有一个本地场景页面(`localhost` 上的 `index.html`)充当代理,先在远程页面采集数据,再通过 `postMessage` 或回调传回本地页面,由本地页面调用 `localhost:13313`。
|
||||
|
||||
Skill 模式没有本地场景页面,因此这种代理机制不存在。
|
||||
|
||||
**解决方案**: 将导出逻辑从浏览器 JS 移到 Rust 侧(方案 A2: Rust 本地生成 XLSX)。
|
||||
|
||||
**最终架构**:
|
||||
```
|
||||
JS (浏览器): 采集数据 → 返回 artifact { rows, column_defs, status }
|
||||
↓
|
||||
Rust (本地): 解析 artifact → 提取 rows + column_defs → 生成 XLSX 文件
|
||||
```
|
||||
|
||||
**具体修改**:
|
||||
|
||||
1. **JS 侧**: 删除 `exportWorkbook()`、`writeReportLog()`、`postJson()`、`buildExportPayload()` 等导出相关代码。artifact 中添加 `column_defs` 字段,export 状态设为 `deferred_to_rust`。
|
||||
|
||||
2. **Rust 侧**: 新增 `lineloss_xlsx_export.rs`,用 `zip` crate + OpenXML XML 生成 XLSX。在 `deterministic_submit.rs` 中,收到 artifact 后调用 XLSX 生成。
|
||||
|
||||
**涉及文件**:
|
||||
- `collect_lineloss.js` — 删除导出代码,添加 `column_defs`
|
||||
- `src/compat/lineloss_xlsx_export.rs` — 新增
|
||||
- `src/compat/deterministic_submit.rs` — 新增导出集成
|
||||
- `src/compat/mod.rs` — 注册新模块
|
||||
|
||||
**是否需要重新编译**: 是
|
||||
|
||||
**通用规则**: 任何从远程页面调用 `localhost` 的操作在 Skill 模式下都不可行。导出/写日志等需要访问本地服务的功能必须放到 Rust 侧实现。
|
||||
|
||||
---
|
||||
|
||||
## 排查方法论总结
|
||||
|
||||
### 1. 诊断信息注入模式
|
||||
|
||||
脚本运行在浏览器中,无法看 F12 console。唯一的信息通道是 artifact JSON 的 `reasons` 字段。
|
||||
|
||||
```javascript
|
||||
// 在 catch 块中注入详细错误
|
||||
reasons: ['api_query_failed:' + String(error?.message || error || 'unknown')]
|
||||
|
||||
// 在规范化失败时注入原始数据摘要
|
||||
reasons: ['row_normalization_failed:rawRows=' + rawRows.length + '|keys=' + Object.keys(rawRows[0]).join(',')]
|
||||
|
||||
// 在页面上下文检查中注入环境信息
|
||||
reason: 'page_context_unavailable:mac_missing|href=' + href + '|host=' + host + '|port=' + port
|
||||
```
|
||||
|
||||
Rust 侧的 summary 输出会包含这些 reasons,直接在日志中可见。
|
||||
|
||||
### 2. 逐层排查顺序
|
||||
|
||||
```
|
||||
Layer 1: 管道层(Rust)
|
||||
├── args 是否正确传入?(expected_domain, target_url, org_code 等)
|
||||
├── 脚本文件是否正确读取?
|
||||
├── async 返回值是否被正确处理?(.then() 模式)
|
||||
└── 回调是否成功返回?
|
||||
|
||||
Layer 2: 页面上下文(JS)
|
||||
├── 脚本注入到了哪个页面?(href, title)
|
||||
├── 页面是否有需要的全局变量?(window.mac 等)
|
||||
└── domain 匹配是否正确?
|
||||
|
||||
Layer 3: API 请求(JS)
|
||||
├── 请求头是否完整?(X-Requested-With)
|
||||
├── 返回格式是否正确?(JSON vs HTML)
|
||||
└── 返回状态码?
|
||||
|
||||
Layer 4: 数据处理(JS)
|
||||
├── API 返回的字段名是否匹配列定义?
|
||||
├── 字段值类型是否兼容?(number vs string)
|
||||
└── 规范化后是否有有效行?
|
||||
|
||||
Layer 5: 导出(架构)
|
||||
├── 是否涉及跨域请求?
|
||||
├── localhost 是否可达?
|
||||
└── 是否需要 Rust 侧处理?
|
||||
```
|
||||
|
||||
### 3. 修改后验证检查清单
|
||||
|
||||
- [ ] JS 文件语法检查:`node -e "require('./collect_lineloss.js')"`
|
||||
- [ ] 如果改了 Rust 代码:`cargo build` 编译通过
|
||||
- [ ] `cargo test` 全部通过(排除已知的 pre-existing failures)
|
||||
- [ ] 替换 JS 文件到部署目录
|
||||
- [ ] 如果改了 Rust:重新部署编译后的 sgclaw 二进制
|
||||
|
||||
---
|
||||
|
||||
## 最终文件清单
|
||||
|
||||
### JS 文件: `collect_lineloss.js`
|
||||
|
||||
**位置**: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js`
|
||||
|
||||
**功能**: 纯数据采集。注入到浏览器,查询线损平台 API,返回结构化 artifact。
|
||||
|
||||
**不做的事**: 不调 localhost:13313,不导出 Excel,不写 report log。
|
||||
|
||||
### Rust 文件: 修改清单
|
||||
|
||||
| 文件 | 修改内容 | 修改类型 |
|
||||
|------|---------|---------|
|
||||
| `src/browser/callback_backend.rs` | `build_eval_js` 增加 `.then()` 处理 async 返回值 | 管道层通用修复 |
|
||||
| `src/compat/deterministic_submit.rs` | 完整 `target_url`; 解析 artifact 后调 XLSX 导出 | 业务集成 |
|
||||
| `src/compat/lineloss_xlsx_export.rs` | XLSX 生成(zip + OpenXML) | 新增 |
|
||||
| `src/compat/mod.rs` | 注册 `lineloss_xlsx_export` 模块 | 新增 |
|
||||
|
||||
---
|
||||
|
||||
## 快速复用模板
|
||||
|
||||
新建类似 skill 时,直接检查以下要点:
|
||||
|
||||
1. **`build_eval_js` 是否支持 async**:入口函数如果是 `async`,确认 `callback_backend.rs` 中有 `.then()` 处理。
|
||||
2. **`validatePageContext` 不检查页面局部状态**:只检查 host,不检查 `window.mac`、`window.app` 等场景页专属变量。
|
||||
3. **API 请求必须带 `X-Requested-With: XMLHttpRequest`**:内网 Java 后端的标配。
|
||||
4. **列定义从原始场景代码精确复制**:找 `cols1`/`cols2` 或表格 `columns` 配置。
|
||||
5. **`normalizeRow` 用 `String(v)` 而非 `pickFirstNonEmpty`**:API 返回数字不是字符串。
|
||||
6. **导出不走浏览器,走 Rust 侧**:JS 返回 rows + column_defs,Rust 生成 XLSX。
|
||||
Reference in New Issue
Block a user