From ff75aefcdd0885abd0bf5cbfc193dd6c29b3c0e8 Mon Sep 17 00:00:00 2001
From: chendelian <116870791@qq.com>
Date: Fri, 14 Aug 2026 15:52:06 +0800
Subject: [PATCH] docs: precipitate HTML report contract for data management
---
CHANGELOG.md | 6 ++
SKILL.md | 4 +-
assets/README.md | 3 +-
assets/examples/README.md | 1 +
.../examples/render-html.action.example.json | 28 +++++++
assets/html/example-report.html | 60 +++++++++++++++
development/CONFIG.md | 2 +-
development/DATA_PATHS.md | 16 +++-
development/DEVELOPMENT.md | 2 +
development/SKILL_ACTION_RUNTIME.md | 5 +-
references/ACTIONS.md | 58 +++++++++++++-
references/CLI.md | 4 +-
references/SCHEMA.md | 5 +-
scripts/service/html_page_service.py | 77 +++++++++++++++++++
scripts/util/constants.py | 2 +-
scripts/util/runtime_paths.py | 14 +++-
tests/test_html_page_service.py | 66 ++++++++++++++++
tests/test_runtime_paths.py | 5 +-
18 files changed, 339 insertions(+), 19 deletions(-)
create mode 100644 assets/examples/render-html.action.example.json
create mode 100644 assets/html/example-report.html
create mode 100644 scripts/service/html_page_service.py
create mode 100644 tests/test_html_page_service.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a517df..0c7203b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
+## 1.0.80
+
+- 沉淀数据管理「HTML 页面报告」契约:写入 `{skill_data}/html/*.html`,重新生成 Action id 固定为 `render-html`
+- 新增 `get_html_dir()`、薄写入 helper(`html_page_service`)、示例页与 Action 示例 JSON
+- 文档:`ACTIONS.md` / `DATA_PATHS.md` / `SKILL_ACTION_RUNTIME.md` 等已区分 HTML 报告与 `export-*` / 宿主表导出
+
## 1.0.79
- 沉淀数据管理表级「默认展示列」契约:`_jiangchang_tables.default_visible_columns_json`
diff --git a/SKILL.md b/SKILL.md
index 9d5265c..3a66cd4 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -1,7 +1,7 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
-version: 1.0.79
+version: 1.0.80
author: 深圳匠厂科技有限公司
metadata:
openclaw:
@@ -37,7 +37,7 @@ allowed-tools:
- **`executionProfile`**:只决定执行方式——`sync` 当场返回结果,`async` 进**任务中心**。与 placements / 入口**无关**。心智上多数为 sync,仅长任务 / RPA 标 async;宿主省略该字段时按 sync。每个 Action **仍必须**显式写 `sync` 或 `async`。
- **`policy`(可选)**:需要按用户/角色控制数据管理按钮或行列时,在 `actions.json` 顶层启用 Data Policy,由技能 `policy` CLI 输出 Snapshot;**未启用 = 与今日一致(L0)**。详见 `references/POLICY.md`。用户/角色数据源(飞书/库/API)属技能内部,宿主不托管。
- **`cellPresentation`(可选)**:需要数据管理「某格特殊标色/强调」时启用;技能按当前数据写入呈现(语义 tone),宿主映射主题样式;**未启用 = P0 无标色**。用途不限(超时、预警、驳回等)。详见 `references/CELL_PRESENTATION.md`。
-- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;特殊业务报告才另做 `export-*`。
+- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;文件型特殊报告才另做 `export-*`(落 `output/`);**HTML 整页报告**写 `html/` 并用固定 Action id `render-html`(见 `references/ACTIONS.md`)。
- **准备 vs 执行(适用则拆)**:若技能存在「先写入/准备本地数据」与「再对外产生副作用」两段,Agent 必须能分开触发;禁止默认绑成不可拆的一步。一键无准备态的技能可跳过本条。
### 如何触发(能力类型)
diff --git a/assets/README.md b/assets/README.md
index 2106efe..38393f6 100644
--- a/assets/README.md
+++ b/assets/README.md
@@ -1,7 +1,8 @@
# assets
- `actions.json`:Skill Action manifest 最小示例(**可选**;不需要宿主入口时可删除)。每个 action **必须**显式声明 `executionProfile`(`sync` \| `async`)。默认仅 `health` / `version` / `config-path`,不放 toolbar,故无 `bind` 示例。
-- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
+- `html/example-report.html`:HTML 报告页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);渲染后须写入数据目录 `html/`,见 `references/ACTIONS.md`「HTML 页面报告」。
+- `examples/`:CLI 成功输出形状示例、以及 `render-html.action.example.json`(虚构路径与数据)。
- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**;`task-log-record.schema.json` 等)。
`skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping`;placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。
diff --git a/assets/examples/README.md b/assets/examples/README.md
index 60242da..4a57d52 100644
--- a/assets/examples/README.md
+++ b/assets/examples/README.md
@@ -4,3 +4,4 @@
- `log-get-response.json`:若技能有日志表,`log-get` 成功时单条对象的形状参考。
- `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。
- `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
+- `render-html.action.example.json`:HTML 报告「重新生成」Action 形状参考(固定 id `render-html`;见 `references/ACTIONS.md`)。
diff --git a/assets/examples/render-html.action.example.json b/assets/examples/render-html.action.example.json
new file mode 100644
index 0000000..717f90e
--- /dev/null
+++ b/assets/examples/render-html.action.example.json
@@ -0,0 +1,28 @@
+{
+ "id": "render-html",
+ "label": "生成 HTML",
+ "description": "根据业务数据生成 HTML 报告,写入技能数据目录 html/(示例:请改 bind.tables / entrypoint)。",
+ "placements": [
+ "toolbar",
+ "skill-detail",
+ "agent"
+ ],
+ "executionProfile": "sync",
+ "interaction": "direct",
+ "bind": {
+ "tables": [
+ "your_source_table"
+ ]
+ },
+ "entrypoint": {
+ "type": "cli",
+ "command": "render-html",
+ "args": []
+ },
+ "steps": [
+ {
+ "when": "afterSuccess",
+ "do": "refreshCatalog"
+ }
+ ]
+}
diff --git a/assets/html/example-report.html b/assets/html/example-report.html
new file mode 100644
index 0000000..f5a7442
--- /dev/null
+++ b/assets/html/example-report.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+ {{TITLE}}
+
+
+
+
+ {{TITLE}}
+ 报告生成于 · {{GENERATED_AT}}
+
+
+
+ {{BODY}}
+
+
+
+
diff --git a/development/CONFIG.md b/development/CONFIG.md
index ddf65c4..c77f2c3 100644
--- a/development/CONFIG.md
+++ b/development/CONFIG.md
@@ -115,7 +115,7 @@ HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调
### 数据目录子路径(输入 / 输出)
-用户可见目录只有 **`input/`(上行)** 与 **`output/`(下行)**。技能写入的业务产出默认落在 `output/`;用户放入的待处理文件落在 `input/`。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_input_dir()` / `get_output_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
+用户可见目录:文件浏览只有 **`input/`(上行)** 与 **`output/`(下行)**;另有可选 **`html/`** 供宿主扫描 HTML 报告页(见 `DATA_PATHS.md`)。技能写入的业务文件产出默认落在 `output/`;整页报告落在 `html/`;用户放入的待处理文件落在 `input/`。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_input_dir()` / `get_output_dir()` / `get_html_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
- 权威说明(开发):[`DATA_PATHS.md`](DATA_PATHS.md)
- `.env.example` 中路径类配置**留空或保持注释即用默认**;若写相对路径,必须相对 **skill 数据目录**,**禁止** `./outputs/...` 这类相对 workspace 的写法
diff --git a/development/DATA_PATHS.md b/development/DATA_PATHS.md
index d888ea0..0a72a97 100644
--- a/development/DATA_PATHS.md
+++ b/development/DATA_PATHS.md
@@ -30,13 +30,19 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
## 2. 标准子目录树
-用户可见目录(数据管理侧栏只挂这两个)按 **上行 / 下行** 划分:
+用户可见 · **文件目录**(数据管理「输入 / 输出」文件浏览器只挂这两个):
| 子目录 | 方向 | 用途 | 默认 helper |
|--------|------|------|-------------|
| `input/` | 上行 | 用户/Agent 放入的待处理材料(urls.txt、batch.json、CSV、产品表…) | `get_input_dir()` |
| `output/` | 下行 | 技能/宿主交给用户拿走的结果(视频、回单、xlsx/csv/json…) | `get_output_dir()` |
+用户可见 · **HTML 报告页**(宿主扫描挂到技能下,**不是** input/output 文件浏览器):
+
+| 子目录 | 用途 | 默认 helper |
+|--------|------|-------------|
+| `html/` | 技能写出的静态 `.html` 报告;宿主一层扫描 `*.html`,侧栏预览 / 右键重新生成 | `get_html_dir()` |
+
内部 / 可选目录(**不上侧栏**):
| 子目录 | 用途 | 默认 helper |
@@ -55,10 +61,14 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
- 用户/Agent **放进来**的文件 → `input/`(`get_input_dir()`)
- 技能/宿主 **交给用户拿走**的文件 → `output/`(`get_output_dir()`)
-- 禁止再引入业务侧栏第三目录(如旧的 `downloads` / `imports` / `exports` 三分法)
+- 可浏览的 **HTML 报告页** → `html/`(`get_html_dir()` / `service.html_page_service`);**不要**把整页报告塞进 `output/` 指望侧栏预览
+- 禁止再引入业务「文件浏览器」第三目录(如旧的 `downloads` / `imports` / `exports` 三分法);`html/` 是报告页扫描面,不是第三种文件浏览器
- 缓存、临时、RPA 录屏用 `cache` / `temp` / `videos`,不要当成用户可见的「输出目录」
-宿主约定(与本模板对齐):工具栏「导入」默认从 `input/` 取文件;「导出」默认写到 `output/`。
+宿主约定(与本模板对齐):
+
+- 工具栏「导入」默认从 `input/` 取文件;「导出」默认写到 `output/`
+- 数据管理扫描 `{skill_data}/html/*.html`(一层、非递归);`` → 侧栏显示名;「重新生成」调用 Action id **`render-html`**(见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」)
---
diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md
index 7d5f236..e95be23 100644
--- a/development/DEVELOPMENT.md
+++ b/development/DEVELOPMENT.md
@@ -586,6 +586,7 @@ cmd_stats() # 只读 sync:JSON,无 job_context
- 批量数量用 **`pickCount` / `--pick N` 参数**(任意正整数),不写死条数。
- **顺序执行、不并发**同一浏览器 Profile / account lease。
- **同步数据**与**宿主表导出**职责分离:同步不写 CSV/Excel;表导出交给数据管理。
+- **HTML 页面报告**(可选):需要侧栏预览整页时,写入 `html/` + Action `render-html`;契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」、[`DATA_PATHS.md`](DATA_PATHS.md);薄写入 helper 见 `scripts/service/html_page_service.py`。
- 详情见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md);契约样例见 `tests/samples/test_sync_contract.py.sample`、`tests/samples/test_action_core_contract.py.sample`。
### 队列 + pick(可选)
@@ -841,6 +842,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|-------------------------|----------------|--------------|
| **新建任务**(对话 Agent) | `placements` 含 `agent`;或短查询走共享 Python CLI | 自然语言能触发主流程;长任务 / RPA 须走 `run_skill_action`,禁止 bash 干等 |
| **数据管理 · 表顶栏** | `placements` 含 `toolbar`(须合法非空 `bind.tables`) | 左侧能看到本技能库表;表顶栏出现按钮;点按行为符合预期 |
+| **数据管理 · HTML 报告** | 写出 `html/*.html`;提供 Action id `render-html` | 侧栏出现报告项;预览正常;右键「重新生成」成功且时间/内容更新 |
| **数据管理 · 行级** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 操作列和/或行右键(及可选「⋯」)出现本技能行级 Action(需有主键列);点按后入参/行为与壳无关、均正确 |
| **数据管理 · 批量** | `placements` 含 `batch` | 勾选行后批量按钮可用;无勾选时禁用;批量行为正确 |
| **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发;到点或「立即运行」行为正确 |
diff --git a/development/SKILL_ACTION_RUNTIME.md b/development/SKILL_ACTION_RUNTIME.md
index 6c88aa4..3987c0b 100644
--- a/development/SKILL_ACTION_RUNTIME.md
+++ b/development/SKILL_ACTION_RUNTIME.md
@@ -164,9 +164,10 @@ cmd_stats() # 只读 sync:JSON stdout,无 job_context
|------|------|--------|
| 同步 / refresh / collect | 外部来源 → 技能本地 SQLite | 技能 domain service |
| 导出当前数据表 | 本地库 → CSV/Excel | **宿主**数据管理统一导出 |
-| 特殊业务报告 | 多表合并 / 特殊格式 / 附加计算 | 仅系统导出不足时提供 `export-*` |
+| 特殊业务报告(文件) | 多表合并 / 特殊格式 → `output/` | 仅系统导出不足时提供 `export-*` |
+| HTML 页面报告 | 写入 `{skill_data}/html/*.html` | 技能生成;宿主扫描预览;重新生成 Action id 固定为 **`render-html`** |
-同步 Action 默认写库、不写导出文件。通用幂等 / 事务 / 空结果保护见 [`../references/SCHEMA.md`](../references/SCHEMA.md)。
+同步 Action 默认写库、不写导出文件。HTML 报告契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」与 [`DATA_PATHS.md`](DATA_PATHS.md)。通用幂等 / 事务 / 空结果保护见 [`../references/SCHEMA.md`](../references/SCHEMA.md)。
---
diff --git a/references/ACTIONS.md b/references/ACTIONS.md
index 5ffd824..4a1b50c 100644
--- a/references/ACTIONS.md
+++ b/references/ACTIONS.md
@@ -490,7 +490,7 @@ HINT:
| 导入 / 重置 | sync 或 async | skill-detail, agent | 可选 |
| 单条副作用 RPA | async(建议) | agent, skill-detail | **建议** |
| 批量顺序 pick RPA | async(建议) | toolbar, cron, agent | **建议** |
-## 同步数据 vs 导出当前表 vs 特殊业务报告
+## 同步数据 vs 导出当前表 vs 特殊业务报告 vs HTML 页面
不要混用「导出」一词:
@@ -498,12 +498,64 @@ HINT:
|------|------|--------|
| **同步数据**(sync / refresh / collect) | 外部来源 → 技能本地数据库 | 技能 Action / domain service |
| **导出当前数据表** | 技能本地数据库 → CSV/Excel 等 | 数据管理页**宿主统一导出** |
-| **生成特殊业务报告** | 多表合并、特殊排版或附加计算 | 仅当系统导出无法满足时,才提供独立 `export-*` Action |
+| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的 **xlsx/csv/json 等文件** | 仅当系统导出无法满足时,才提供独立 `export-*` Action;文件落 `output/` |
+| **HTML 页面报告** | 可在数据管理侧栏预览的整页 HTML | 技能写入 `{skill_data}/html/*.html` + Action id **`render-html`**(见下节) |
同步型 Action 默认:读外部数据、写技能自己的库、**不**生成 CSV/Excel/JSON 文件、不重复实现宿主表导出。兼容旧 export 命令若需要刷新数据,必须调用同一个 sync 业务内核。通用同步语义见 [`SCHEMA.md`](SCHEMA.md) 与 [`../development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。
-## inputSchema 与参数表单
+## HTML 页面报告(数据管理预览)
+> 一句话:**技能负责生成并覆盖 `html/*.html`;宿主负责发现、预览、用 `render-html` 触发重新生成。**
+
+### 何时用
+
+- 需要**可浏览的整页报告**(KPI + 表格 + 自带样式),而不是再导出一张表
+- 与 `export-*` / 宿主表导出**并列**:HTML 走 `html/`;文件型特殊报告仍走 `output/` + `export-*`
+
+### 目录与发现(宿主契约)
+
+| 项 | 约定 |
+|----|------|
+| 路径 | `{skill_data}/html/*.html`,**只扫一层**(子目录忽略) |
+| helper | `util.runtime_paths.get_html_dir()`;写入用 `service.html_page_service.write_html_page` |
+| 侧栏名 | 取文件内 ``(缺省则用文件名) |
+| 预览 | 宿主 iframe 预览;右键:重新生成 / 浏览器打开 / 刷新 |
+| 模板资源 | 建议放 `assets/html/*.html`(安装包内),渲染后**写出**到数据目录 `html/` |
+
+### Action:`render-html`(固定 id)
+
+宿主「重新生成」**硬编码**查找 Action id `render-html`(见 `service.html_page_service.HOST_HTML_RENDER_ACTION_ID`)。技能未提供该 id → 提示「该技能未提供生成 HTML」。
+
+推荐形态:
+
+| 字段 | 建议 |
+|------|------|
+| `id` | **必须** `render-html` |
+| `executionProfile` | `sync` |
+| `interaction` | `direct` |
+| `placements` | `toolbar` + `skill-detail`(配合表顶栏与 HTML 右键);可选 `agent` |
+| `bind.tables` | 数据**来源表**(含 `toolbar` 时必填);宿主从 HTML 页重新生成时会用 bind 补齐跑表上下文 |
+| `steps` | `afterSuccess` → **`refreshCatalog`**(重扫 `htmlPages`;不要只 `reloadTable`) |
+| `entrypoint` | CLI 子命令(如 `render-html`)调用同一写入内核 |
+
+完整 JSON 形状见 [`../assets/examples/render-html.action.example.json`](../assets/examples/render-html.action.example.json)。最小页面壳见 [`../assets/html/example-report.html`](../assets/html/example-report.html)。
+
+### 实现要点
+
+1. 用 `load_html_template` + `render_html_placeholders` + `write_html_page`(原子 `*.tmp` + `os.replace`)覆盖同名文件;**不要**每次另存带时间戳的副本(除非产品明确要求历史版本)。
+2. 页头时间建议用**写出时刻**(「报告生成于」),不要误用业务表里过期的 `generated_at` 冒充「刚重新生成」。
+3. 可选:数据重算成功后调用静默写入(吞掉错误),保持侧栏报告与库一致。
+4. 权限:按业务决定谁能生成(管理员 / 角色);在 CLI/`cmd_render_html` 内强制校验。
+5. CHANGELOG / 用户说明:模板或样式升级后,提醒「请在数据管理对报告执行一次重新生成」。
+
+### 验收(复制后自测)
+
+- [ ] `html/` 下出现 `.html`,数据管理侧栏能看到报告项,预览正常
+- [ ] 表顶栏(若声明 toolbar)或报告右键「重新生成」能跑通;成功后预览刷新
+- [ ] 未登录/无权限用户被拒绝,且有可读错误
+- [ ] 成功路径带 `refreshCatalog`(或等价让侧栏/预览看到更新)
+
+## inputSchema 与参数表单
### 表单字段类型对照(须与 CRUD 对称)
Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件语义**。CRUD 侧配置见 [`SCHEMA.md`](SCHEMA.md)「表单与展示:稳定字段类型」;本表只写 Action 怎么配。
diff --git a/references/CLI.md b/references/CLI.md
index 4c9e2b5..c75952f 100644
--- a/references/CLI.md
+++ b/references/CLI.md
@@ -24,10 +24,10 @@ python {baseDir}/scripts/main.py …
| 类型 | 本技能命令(自填) | Agent |
|------|--------------------|-------|
-| **短 CLI 白名单** | (例)`health` / `version` / `logs` / `` | 可用 bash/exec + 共享 `python` |
+| **短 CLI 白名单** | (例)`health` / `version` / `logs` / `render-html` / `` | 可用 bash/exec + 共享 `python` |
| **Action 强制黑名单** | (例)`` | **禁止** bash + `process poll`;必须 `run_skill_action` |
-细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。
+细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。若提供 HTML 报告,「重新生成」须有 Action id **`render-html`**(可同时进短 CLI 白名单);见 ACTIONS.md「HTML 页面报告」。
## 最小命令
diff --git a/references/SCHEMA.md b/references/SCHEMA.md
index 040834c..7161aa6 100644
--- a/references/SCHEMA.md
+++ b/references/SCHEMA.md
@@ -114,13 +114,14 @@ metadata:
}
```
-### 同步数据 vs 导出当前数据表 vs 特殊业务报告
+### 同步数据 vs 导出当前数据表 vs 特殊业务报告 vs HTML 页面
| 能力 | 语义 | 负责方 |
|------|------|--------|
| **同步数据** | 外部来源 → 技能本地数据库 | 技能 Action / domain service |
| **导出当前数据表** | 本地库 → CSV/Excel 等 | 数据管理页**宿主统一导出** |
-| **生成特殊业务报告** | 多表合并、特殊排版或附加计算 | 仅当系统导出无法满足时,才提供独立 `export-*` |
+| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的文件 | 仅当系统导出无法满足时,才提供独立 `export-*`;落 `output/` |
+| **HTML 页面报告** | 侧栏可预览的整页 HTML | 写入 `html/` + Action id **`render-html`**(见 [`ACTIONS.md`](ACTIONS.md)) |
同步型 Action 默认只写库、不生成导出文件。兼容旧 export 命令需要刷新时,必须调用同一 sync 内核。可复用测试范式见 `tests/samples/test_sync_contract.py.sample`。
diff --git a/scripts/service/html_page_service.py b/scripts/service/html_page_service.py
new file mode 100644
index 0000000..7b0455b
--- /dev/null
+++ b/scripts/service/html_page_service.py
@@ -0,0 +1,77 @@
+"""技能 HTML 报告页:写入 `{skill_data}/html/*.html`(宿主扫描预览)。
+
+契约要点(权威说明见 `references/ACTIONS.md`「HTML 页面报告」):
+
+- 目录:仅一层 `html/*.html`(非递归)
+- 重新生成 Action id 必须为 `render-html`(宿主硬编码)
+- 成功后建议 `steps: refreshCatalog` 以重扫侧栏
+- 本模块只提供路径/模板/原子写;业务 KPI/表格由各技能自行填充
+"""
+
+from __future__ import annotations
+
+import os
+import re
+from typing import Mapping
+
+from util.logging_config import get_skill_logger
+from util.runtime_paths import get_html_dir, get_skill_root
+
+# Host Data Management looks up this exact Skill Action id for「重新生成」.
+HOST_HTML_RENDER_ACTION_ID = "render-html"
+
+_UNSAFE_NAME = re.compile(r"[\\/]|^\.\.|\.\.$")
+
+
+def _log():
+ try:
+ return get_skill_logger()
+ except RuntimeError:
+ return None
+
+
+def assert_html_filename(filename: str) -> str:
+ name = (filename or "").strip()
+ if not name.lower().endswith(".html"):
+ raise ValueError(f"html filename must end with .html: {filename!r}")
+ if _UNSAFE_NAME.search(name) or "/" in name or "\\" in name:
+ raise ValueError(f"html filename must be a basename only: {filename!r}")
+ if len(name) > 200:
+ raise ValueError(f"html filename too long: {filename!r}")
+ return name
+
+
+def html_page_path(filename: str, *, create_dir: bool = True) -> str:
+ return os.path.join(get_html_dir(create=create_dir), assert_html_filename(filename))
+
+
+def load_html_template(relative_under_skill_root: str) -> str:
+ """Load UTF-8 template from skill root (e.g. ``assets/html/example-report.html``)."""
+ rel = (relative_under_skill_root or "").strip().replace("\\", "/")
+ if not rel or rel.startswith("/") or ".." in rel.split("/"):
+ raise ValueError(f"invalid template path: {relative_under_skill_root!r}")
+ path = os.path.join(get_skill_root(), *rel.split("/"))
+ with open(path, encoding="utf-8") as f:
+ return f.read()
+
+
+def render_html_placeholders(template: str, mapping: Mapping[str, str]) -> str:
+ """Replace ``{{KEY}}`` tokens. Unknown keys are left as-is."""
+ page = template
+ for key, value in mapping.items():
+ page = page.replace("{{" + str(key) + "}}", str(value))
+ return page
+
+
+def write_html_page(filename: str, html: str) -> str:
+ """Atomically write UTF-8 HTML under ``html/``. Returns absolute path."""
+ dest = html_page_path(filename, create_dir=True)
+ tmp = dest + ".tmp"
+ text = html if isinstance(html, str) else str(html)
+ with open(tmp, "w", encoding="utf-8", newline="\n") as f:
+ f.write(text)
+ os.replace(tmp, dest)
+ log = _log()
+ if log:
+ log.info("html_page_written path=%s bytes=%s", dest, len(text.encode("utf-8")))
+ return dest
diff --git a/scripts/util/constants.py b/scripts/util/constants.py
index 117e910..4319a42 100644
--- a/scripts/util/constants.py
+++ b/scripts/util/constants.py
@@ -1,6 +1,6 @@
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
SKILL_SLUG = "your-skill-slug"
-SKILL_VERSION = "1.0.78"
+SKILL_VERSION = "1.0.80"
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
PLATFORM_KIT_MIN_VERSION = "1.2.11"
diff --git a/scripts/util/runtime_paths.py b/scripts/util/runtime_paths.py
index 9d0e5ce..f38a631 100644
--- a/scripts/util/runtime_paths.py
+++ b/scripts/util/runtime_paths.py
@@ -10,10 +10,13 @@ from util.constants import SKILL_SLUG
_SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-# 用户可见的上行/下行目录(数据管理侧栏只挂这两个)
+# 用户可见的上行/下行目录(数据管理「输入/输出」文件浏览器)
STANDARD_SUBDIR_INPUT = "input"
STANDARD_SUBDIR_OUTPUT = "output"
+# 宿主扫描的 HTML 报告页(数据管理侧栏 htmlPages,非 input/output 文件浏览器)
+STANDARD_SUBDIR_HTML = "html"
+
# 内部/可选目录(不上侧栏)
STANDARD_SUBDIR_UPLOADS = "uploads"
STANDARD_SUBDIR_CACHE = "cache"
@@ -130,12 +133,21 @@ def get_videos_dir(*, create: bool = True) -> str:
return path
+def get_html_dir(*, create: bool = True) -> str:
+ """HTML 报告页目录:宿主扫描 `{skill_data}/html/*.html`(一层、非递归)。"""
+ path = os.path.join(get_skill_data_dir(), STANDARD_SUBDIR_HTML)
+ if create:
+ os.makedirs(path, exist_ok=True)
+ return path
+
+
def list_resolved_data_paths() -> dict[str, str]:
"""供 health / config-path 输出已解析的数据子目录(不创建目录)。"""
return {
"skill_data_dir": get_skill_data_dir(),
"input_dir": get_input_dir(create=False),
"output_dir": get_output_dir(create=False),
+ "html_dir": get_html_dir(create=False),
"uploads_dir": get_uploads_dir(create=False),
"cache_dir": get_cache_dir(create=False),
"temp_dir": get_temp_dir(create=False),
diff --git a/tests/test_html_page_service.py b/tests/test_html_page_service.py
new file mode 100644
index 0000000..21b5976
--- /dev/null
+++ b/tests/test_html_page_service.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+"""HTML 报告页薄写入 helper。"""
+
+from __future__ import annotations
+
+import os
+import unittest
+
+from _support import IsolatedDataRoot
+
+from service.html_page_service import (
+ HOST_HTML_RENDER_ACTION_ID,
+ load_html_template,
+ render_html_placeholders,
+ write_html_page,
+)
+
+
+class TestHtmlPageService(unittest.TestCase):
+ def test_host_action_id_is_stable(self) -> None:
+ self.assertEqual(HOST_HTML_RENDER_ACTION_ID, "render-html")
+
+ def test_write_html_page_atomic_under_html_dir(self) -> None:
+ with IsolatedDataRoot() as tmp:
+ path = write_html_page(
+ "example-report.html",
+ "示例ok",
+ )
+ self.assertTrue(os.path.isfile(path))
+ norm = path.replace("\\", "/")
+ self.assertIn("/html/example-report.html", norm)
+ self.assertTrue(os.path.normcase(os.path.normpath(path)).startswith(
+ os.path.normcase(os.path.normpath(tmp)),
+ ))
+ self.assertFalse(os.path.isfile(path + ".tmp"))
+ with open(path, encoding="utf-8") as f:
+ self.assertIn("示例", f.read())
+
+ def test_load_and_fill_example_template(self) -> None:
+ with IsolatedDataRoot():
+ template = load_html_template("assets/html/example-report.html")
+ page = render_html_placeholders(
+ template,
+ {
+ "TITLE": "示例报告",
+ "GENERATED_AT": "2026-08-14 15:49:00",
+ "BODY": "hello
",
+ },
+ )
+ self.assertIn("示例报告", page)
+ self.assertIn("报告生成于 · 2026-08-14 15:49:00", page)
+ self.assertIn("hello
", page)
+ self.assertNotIn("{{TITLE}}", page)
+ dest = write_html_page("example-report.html", page)
+ self.assertTrue(os.path.isfile(dest))
+
+ def test_rejects_unsafe_filename(self) -> None:
+ with IsolatedDataRoot():
+ with self.assertRaises(ValueError):
+ write_html_page("../x.html", "")
+ with self.assertRaises(ValueError):
+ write_html_page("note.txt", "")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_runtime_paths.py b/tests/test_runtime_paths.py
index 0111a6d..fdea9b8 100644
--- a/tests/test_runtime_paths.py
+++ b/tests/test_runtime_paths.py
@@ -136,11 +136,14 @@ class TestRuntimePaths(unittest.TestCase):
importlib.reload(rp)
input_dir = rp.get_input_dir(create=True)
output_dir = rp.get_output_dir(create=True)
+ html_dir = rp.get_html_dir(create=True)
norm_tmp = os.path.normcase(os.path.normpath(tmp))
self.assertTrue(os.path.normcase(os.path.normpath(input_dir)).startswith(norm_tmp))
self.assertTrue(os.path.normcase(os.path.normpath(output_dir)).startswith(norm_tmp))
+ self.assertTrue(os.path.normcase(os.path.normpath(html_dir)).startswith(norm_tmp))
self.assertTrue(input_dir.replace("\\", "/").endswith("/input"))
self.assertTrue(output_dir.replace("\\", "/").endswith("/output"))
+ self.assertTrue(html_dir.replace("\\", "/").endswith("/html"))
def test_list_resolved_data_paths_keys(self) -> None:
with IsolatedDataRoot():
@@ -154,6 +157,7 @@ class TestRuntimePaths(unittest.TestCase):
"skill_data_dir",
"input_dir",
"output_dir",
+ "html_dir",
):
self.assertIn(key, paths)
self.assertTrue(paths[key])
@@ -161,6 +165,5 @@ class TestRuntimePaths(unittest.TestCase):
self.assertNotIn("imports_dir", paths)
self.assertNotIn("exports_dir", paths)
-
if __name__ == "__main__":
unittest.main()