Skill: 1688aiinfra/1688-common-html-report-generator (version 0.1.0)

--- name: 1688-common-html-report-generator description: 将文本数据报告快速转换为交互式可视化HTML网页。使用 Python 代码驱动预置模板，只需编写数据配置即可自动生成专业数据看板。适用于市场分析报告、运营数据复盘、行业调研报告、销售数据汇总等场景。触发词：生成HTML报告、生成网页报告、转成可视化网页、做成数据看板、数据可视化、生成HTML看板。 --- # 数据报告可视化生成器 通过 Python 代码驱动预置 HTML 模板，将数据报告转换为交互式可视化网页。AI 编写 Python 数据配置脚本（行数随数据量伸缩，简单报告 ~100 行，复杂报告可达 300-500+ 行），脚本自动处理 HTML/CSS/JS 生成。 ## 文件结构 ``` 1688-common-html-report-generator/ ├── SKILL.md # 本文件（工作流 + 可视化思路） ├── scripts/ │ ├── generate.py # ReportBuilder 核心脚本 │ └── template.html # HTML 模板（暗色主题 + ECharts） └── references/ └── chart-bindbook.md # 图表使用手册：每个组件的适用/禁用场景 + API 签名（写代码前必读） ``` ## 工作流程 ### Step 1: 读取数据源 读取用户提供的文本/数据文件，**逐章逐节**提取： - 标题、时间、来源等元信息 - **所有**数据表格（含完整行列，不可省略行或列） - 章节结构和层级关系 - 关键数字 - 洞察/结论/趋势建议等文字段落 ### Step 2: 规划报告结构 **在写代码前，先列出章节规划清单**，逐条对照下方可视化思路指南检查。 规划清单必须满足： 1. **完整性**：原文件有 N 章就必须生成 N 个 section，不可合并或省略 2. **数据零丢失**：每张表格都必须出现在报告中（图表或 `r.table()`）；原文的文字洞察/结论用 `r.insight()` 或表格备注呈现 3. **图表多样化**：至少使用 3 种不同图表类型，禁止全篇只用 bar+pie 4. **叙事节奏**：适度使用 `r.insight()` 做章节过渡和收束，避免纯数据堆砌，但也不要每个图表后都加 insight #### 可视化思路指南（核心，必读） ##### A. 结构组织原则 1. **图表先行，表格兜底**：优先用图表呈现数据。只有维度太多（≥6列）或包含文本字段时才用表格。**禁止一个 section 只有表格没有图表**。 2. **先总后分**：章节开头用一张全景图（`size="lg"`）给整体印象，再用 `grid` 分列展示细节。 3. **grid 高度平衡**：`grid(2)` 左右体量必须相当。禁止一边大图表、另一边 3 行文字。 4. **图表与表格互补**：图表覆盖核心数值维度，表格保留图表无法展示的文本维度（品牌名、关键词等），不要重复展示相同数据。 5. **叙事过渡**：避免"图表→图表→表格→表格"的纯数据堆砌。 ##### B. 图表选型规范 每个图表组件有明确的**适用场景**和**禁用场景**，详见 [references/chart-bindbook.md](references/chart-bindbook.md)。选型时必须同时满足适用条件且不触犯禁用条件。 **选型核心原则**： 1. **量级一致**：同一 series / 同一图表中的数值量级差距不超过 10 倍 2. **有对比才用对比型图表**：雷达图至少 2 个对象，热力图至少 2×3 矩阵且无大面积 0 值 3. **连续数值才用连续色阶**：热力图格子必须是可比较的数值，不能是分类/布尔/枚举 4. **信息密度**：有两个维度时用 `dual_y` 而非两张独立图 5. **容器匹配**：`r.chart()` 仅用于 ECharts 图表，`progress_group`/`metric_grid`/`table` 禁止加 `r.chart()` ##### C. 布局约束 - **grid 最多一层，禁止嵌套**。需要并排 3 个饼图就用顶层 `grid(3)`，不要在 `grid(2)` 里再嵌 `grid(3)`。 - 一个格子内需要多个内容时，用**纵向堆叠**，不要横向再分格。 ##### D. 表格精简原则 图表化后的表格应裁剪，但不能丢数据： 1. **图表已覆盖的纯数值列**：从表格中去掉 2. **文本字段列**（品牌名、关键词、建议等）：**必须保留**，图表无法表达 3. **表格行数不可裁剪**：原文 11 行就保留 11 行 4. 裁剪后至少保留"名称+2个文本列"。若只剩 1 列，改用 insight 框 ##### E. 多平台章节组织原则 **核心思路：先对比，再展开。** 读者看完平台A再看平台B时已经忘了A的数据，所以必须先用跨平台对比图建立全局印象。 推荐结构： ``` 1. 跨平台对比（全局视角，必须有） → 全景对比图：分组柱状图/热力图/雷达图，把所有平台放一起（size="lg"） → metric_grid：各平台最突出的 1 个数字 2. 分平台细节（展开视角） → grid(3) 并列饼图（各平台渠道构成） → 各平台 dual_y 图（如：月销量柱 + 客单价线，信息密度翻倍） → 各平台详细表格（保留文本维度） ``` **禁止**的组织方式：平台A insight → 平台A图表 → 平台A表格 → 平台B insight → 平台B图表 → 平台B表格（串行罗列，无法横向对比） ### Step 3: 读取 API 文档（必须，不可跳过） **在写任何代码之前**，必须先读取完整图表使用手册：[references/chart-bindbook.md](references/chart-bindbook.md) 读取后重点确认以下易错签名： - `kpi` 的参数是 `(label, value, unit, change, down)`，**不支持 `color` 参数** - `chart_heatmap` 的 `data` 格式是 `[x, y, value]` 三元组列表 - `chart_bar` 的 `dual_y=True` 时，series 里需要 `"type": "bar"/"line"` 和 `"yAxisIndex": 1` - `chart_pie` 的 `inner_radius`/`outer_radius` 参数可选 - `progress_bar` 的参数顺序是 `(label, value, max_val, color, suffix)` - `metric` 的参数是 `(value, desc, color)`，`unit` 可选 **禁止跳过此步骤直接写代码**——本文件的 API 速查是精简版，缺少参数细节，仅靠速查表写代码会导致调用出错。 ### Step 4: 编写 Python 构建脚本 在当前工作区的 `outputs/` 目录下创建构建脚本，文件名格式为 `build_报告主题_时间戳.py`（如 `outputs/build_市场分析_20260515.py`）。脚本行数不设上限，以**完整还原数据**为第一优先级。 ```python import sys, os sys.path.insert(0, [REDACTED_SECRET]) from generate import ReportBuilder r = ReportBuilder("报告标题") r.header("大标题", "副标题", ["时间:2026-05-14", "来源:XX"]) r.nav([("核心指标", "#kpi"), ("平台对比", "#platform")]) with r.section("kpi", "01", "核心指标", "关键数据概览"): r.kpi_grid() r.kpi("销量", "4.23", "亿件", "同比+14.9%") r.kpi("均价", "89", "元", "同比-5.2%", down=True) r.kpi_end() # grid(2) 示例：左右体量对等 r.grid(2) r.card("销量对比", dot_color=r.C_TAOBAO) r.chart("barChart") r.chart_bar("barChart", categories=["抖音", "淘宝", "小红书"], series=[{"name": "销量", "data": [120, 200, 80], "color": r.C_TAOBAO}], show_label=True) r.card_end() r.card("占比分布", dot_color=r.C_DOUYIN) r.chart("pieChart") r.chart_pie("pieChart", data=[ {"value": 45, "name": "抖音", "color": r.C_DOUYIN}, {"value": 35, "name": "淘宝", "color": r.C_TAOBAO}, {"value": 20, "name": "小红书", "color": r.C_XIAOHONGSHU}, ]) r.card_end() r.grid_end() r.insight("核心发现", ["要点1", "要点2"]) r.footer("数据来源：XX平台") os.makedirs("outputs", exist_ok=True) r.render("outputs/报告.html") ``` ### Step 5: 执行并交付 ```bash python3 build_报告主题_时间戳.py open outputs/报告.html ``` ## 关键约束 - **不要手写 HTML/CSS/JS**，全部通过 `ReportBuilder` 方法生成 - **不要手写 ECharts 通用配置**（tooltip/legend/grid 已内置），不满足时用 `chart_script` - **颜色引用常量**（`r.C_DOUYIN` 等），保持跨图表一致 - **脚本行数不设上限**：数据量大时可 300-500+ 行，以完整还原为第一目标

path: references/chart-bindbook.md
content: # 图表使用手册（Chart Bindbook）

> 写代码前必读。每个组件包含：适用场景、禁用场景、API 签名。

---

## 页面骨架

```python
r = ReportBuilder("页面title")
r.header("大标题", "副标题", ["时间:2026-05-14", "来源:XX平台"])
r.nav([("核心指标", "#kpi"), ("平台对比", "#platform")])
r.footer("页脚文字")
```

```python
with r.section("章节id", "编号", "章节标题", "章节描述"):
    # 在此添加内容
```

```python
r.grid(2)       # 2/3/4 列布局
r.grid_end()
```

---

## KPI 卡片

**适用**：页面顶部展示 4-8 个核心经营指标

**禁用**：章节中间的数字展示（用 `metric_grid` 代替）

```python
r.kpi_grid()
r.kpi("标签", "数值", "单位", "变化说明", down=False)
r.kpi_end()
```

- `down=True` 表示下降（红色），`False` 为上升（绿色）
- ⚠️ 不支持 `color` 参数

---

## 卡片与图表容器

```python
r.card("卡片标题", dot_color=r.C_TAOBAO)
r.chart("chartId", size="normal")            # size="lg" 为 500px 高
r.card_end()
```

**适用**：`r.chart()` 仅用于 ECharts 图表（`chart_bar`/`chart_pie`/`chart_radar`/`chart_heatmap`/`chart_stacked_bar`/`chart_script`）

**禁用**：以下组件前面禁止加 `r.chart()`（它们自带 HTML 结构，误加会产生空白占位区）：
- `progress_group` / `progress_bar`
- `metric_grid` / `metric`
- `r.table()`
- `r.insight()`

---

## chart_bar（柱状图 / 双Y轴组合图）

### 适用场景
- 同类对象的数值大小对比或排名（如 TOP5 商品销量、各渠道 GMV）
- 一组对象同时有两个维度时，用 `dual_y=True` 组合柱+线（如销量柱 + 转化率线）

### 禁用场景
- 同一 series 的 `data` 数组中最大值/最小值 > 10 倍（小值柱子不可见）
- 不同量纲的绝对值混放（如"支付金额 79000"和"客单价 175"放在同一 series）

### API

```python
# 基础柱状图（渐变色 + 数值标签）
r.chart_bar("chartId",
    categories=["A", "B", "C"],
    series=[
        {"name": "销量", "data": [120, 200, 150], "gradient": ["#6cf7c2", "#3b82f6"]},
    ],
    show_label=True,
    yname="销量(万件)"
)

# 多系列
r.chart_bar("chartId",
    categories=["A", "B", "C"],
    series=[
        {"name": "系列A", "data": [120, 200, 150], "color": r.C_TAOBAO},
        {"name": "系列B", "data": [80, 160, 100], "color": r.C_DOUYIN},
    ],
    legend=["系列A", "系列B"]
)

# 双Y轴组合图（柱+线）
r.chart_bar("chartId",
    categories=["A", "B", "C"],
    series=[
        {"name": "销量", "data": [120, 200, 150], "gradient": ["#f76c8b", "#be185d"], "type": "bar"},
        {"name": "增长率", "data": [22, 15, 28], "color": r.C_XIAOHONGSHU, "type": "line", "yAxisIndex": 1},
    ],
    dual_y=True,
    show_label=True
)
```

---

## chart_pie（饼图 / 环形图）

### 适用场景
- 占比构成数据（各项加总 = 100% 或近似 100%）
- 渠道分布、客户类型占比、产品分类占比

### 禁用场景
- 有序数据（价格区间、时间序列）——饼图丢失顺序信息
- 超过 8 个分类——太多扇区无法辨认，改用 `chart_bar`

### API

```python
r.chart_pie("chartId",
    data=[
        {"value": 78.6, "name": "直播", "color": r.C_DOUYIN},
        {"value": 18.5, "name": "短视频", "color": r.C_ORANGE},
        {"value": 2.9, "name": "商城", "color": r.C_TAOBAO},
    ],
    inner_radius="45%",    # 可选，设置后变为环形图
    outer_radius="70%"     # 可选
)
```

---

## chart_radar（雷达图）

### 适用场景
- 2-4 个对象在 3-7 个**同量纲或已归一化维度**上的多维对比
- 数据已经是百分比或评分（0-100）等统一量纲

### 禁用场景
- 只有 1 个对象（无对比意义，只是一个多边形形状，看不出强弱）
- 各维度量纲不同且未归一化（如"销量 5000"和"转化率 3%"混放）
- 维度 < 3 个（退化为线段）

### API

```python
r.chart_radar("chartId",
    indicators=[
        {"name": "维度A", "max": 100},
        {"name": "维度B", "max": 100},
        {"name": "维度C", "max": 50},
    ],
    data=[
        {"name": "对象1", "value": [80, 90, 40], "color": r.C_DOUYIN},
        {"name": "对象2", "value": [60, 70, 45], "color": r.C_TAOBAO},
    ]
)
```

- `indicators[].max`：该维度的最大刻度值，应设为数据最大值的 1.2-1.5 倍
- ⚠️ 当前不支持数据点数值标签显示，读者通过 tooltip 悬浮查看具体数值

---

## chart_heatmap（热力图）

### 适用场景
- N 对象 × M 指标的矩阵，所有格子都是**同量纲连续数值**（如归一化评分 0-100、转化率）
- 至少 2×3 或 3×2 以上的矩阵规模

### 禁用场景
- 格子中是分类/布尔/枚举值（如"是否降权""有/无"）——热力图只适合连续数值
- 矩阵中大量格子为 0 或空（稀疏矩阵，大面积同色无信息量）
- x/y 轴维度不可量化比较（如"风险类型" + "有/无"二值）——用 `table` + 标签高亮

### API

```python
r.chart_heatmap("chartId",
    x_labels=["指标A", "指标B", "指标C"],
    y_labels=["对象1", "对象2"],
    data=[
        [0, 0, 92], [1, 0, 88], [2, 0, 78],   # [x_index, y_index, value]
        [0, 1, 95], [1, 1, 82], [2, 1, 88],
    ]
)
```

---

## chart_stacked_bar（堆积柱状图）

### 适用场景
- 有序区间的构成对比（如价格带各平台占比）
- 同一总量的内部分解（如各渠道贡献的 GMV 堆积）

### 禁用场景
- 两个独立指标的并列对比（如"投入成本" vs "成交金额"不是同一总量的组成部分）

### API

```python
r.chart_stacked_bar("chartId",
    categories=["0-50", "50-100", "100-150"],
    series=[
        {"name": "抖音", "data": [18.5, 32.4, 24.6], "color": r.C_DOUYIN},
        {"name": "淘宝", "data": [5.2, 12.8, 22.5], "color": r.C_TAOBAO},
    ]
)
```

---

## progress_bar（进度条）

### 适用场景
- 完成度、提及率、评分、渗透率等有"满格"概念的数据
- 数据组内最大值/最小值 ≤ 10 倍

### 禁用场景
- 数据组内极差 > 10 倍（小值条宽度 < 10%，内部数值文字不可见）
- 负值数据（进度条无法表达负数方向）

### max_val 设置规则
必须取数据组中最大值（或略大于最大值的整数），**禁止固定写 100**（除非数据本身就是百分比且最大值接近 100）。

### API

```python
r.progress_group()
r.progress_bar("尺码不准", 42.5, max_val=50, color=r.C_DOUYIN, suffix="高退货")
r.progress_bar("面料不符", 28.3, max_val=50, color=r.C_DOUYIN, suffix="高退货")
r.progress_bar("做工问题", 18.2, max_val=50, color=r.C_ORANGE, suffix="中等")
r.progress_group_end()
```

- `label`：左侧标签
- `value`：当前值（显示在进度条内部）
- `max_val`：最大值，决定宽度百分比
- `color`：渐变起始色
- `suffix`：右侧补充文字

---

## metric_grid（数字卡片网格）

### 适用场景
- 章节中间展示 3-6 个关键数字
- 指标间量纲各异或量级差距 > 10 倍，不适合放在同一图表中

### 禁用场景
- 超过 8 个数字（信息过载，改用表格）

### API

```python
r.metric_grid()
r.metric("32.4%", desc="抖音 50-100元占比", color=r.C_DOUYIN)
r.metric("51.1%", desc="淘宝 100-250元占比", color=r.C_TAOBAO)
r.metric_end()
```

- `value`：核心数字
- `unit`：单位（可选）
- `desc`：下方说明文字
- `color`：数字颜色

---

## table（数据表格）

### 适用场景
- 维度 ≥ 6 列或包含文本字段（品牌名、关键词、建议等）
- 图表无法表达的文本维度的补充展示

### API

```python
r.table(
    headers=["列1", "列2", "列3"],
    rows=[["数据A", "数据B", "数据C"]]
)
```

**单元格支持 HTML 透传**：
- 高亮标签：`<span class="tag tag-green">值</span>`（绿）、`tag-pink`（粉）、`tag-orange`（橙）、`tag-purple`（紫）
- 趋势色：`<span class="trend-up">↑</span>`（绿）、`<span class="trend-down">↓</span>`（红）、`<span class="trend-steady">→</span>`（黄）
- 星级：`<span class="stars">★★★☆☆</span>`

---

## insight（洞察框）

```python
r.insight("标题", ["要点 1", "要点 2", "要点 3"])
```

---

## chart_script（自定义 ECharts）

```python
r.chart_script("customChart", {
    "xAxis": {"type": "category", "data": ["A", "B"]},
    "yAxis": {"type": "value"},
    "series": [{"type": "scatter", "data": [[1, 2], [3, 4]]}]
})
```

---

## raw_html

```python
r.raw_html('<div class="custom">任意 HTML</div>')
```

---

## 颜色常量

| 变量 | 色值 | 用途 |
|------|------|------|
| `r.C_DOUYIN` | `#f76c8b` | 抖音 / 警示 / 下降 |
| `r.C_TAOBAO` | `#6c7af7` | 淘宝/天猫 / 主色 |
| `r.C_XIAOHONGSHU` | `#6cf7c2` | 小红书 / 正向 / 增长 |
| `r.C_ORANGE` | `#f7c96c` | 参考 / 中性 |
| `r.C_PURPLE` | `#c96cf7` | 辅助 |


path: scripts/generate.py
content: #!/usr/bin/env python3
"""
data-report-visualizer 自动化生成器
用法：编写一个数据描述脚本调用 ReportBuilder，执行后自动生成可视化HTML。

示例：
    from generate import ReportBuilder
    r = ReportBuilder("报告标题")
    r.header("大标题", "副标题", ["时间:2026-05-14", "来源:XX"])
    r.nav([("核心指标","#kpi"), ("平台对比","#platform")])
    with r.section("kpi", "01", "核心指标", "描述..."):
        r.kpi("销量", "4.23", "亿件", "占21.1%")
    r.render("output.html")
"""

import json
import os
import re
from pathlib import Path


def _to_js(val):
    """将Python值转为JS字面量"""
    if val is None:
        return "null"
    if isinstance(val, bool):
        return "true" if val else "false"
    if isinstance(val, (int, float)):
        return str(val)
    if isinstance(val, str):
        return json.dumps(val, ensure_ascii=False)
    if isinstance(val, (list, tuple)):
        return "[" + ", ".join(_to_js(v) for v in val) + "]"
    if isinstance(val, dict):
        items = []
        for k, v in val.items():
            key = k if re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", k) else json.dumps(k)
            items.append(f"{key}: {_to_js(v)}")
        return "{" + ", ".join(items) + "}"
    return str(val)


class _Section:
    """章节上下文"""
    def __init__(self, builder, sid, num, title, desc):
        self.builder = builder
        self.sid = sid
        self.num = num
        self.title = title
        self.desc = desc

    def __enter__(self):
        parts = [f'<div class="section" id="{self.sid}">']
        parts.append(f'<div class="section-title"><span class="num">{self.num}</span>{self.title}</div>')
        parts.append(f'<div class="section-desc">{self.desc}</div>')
        self.builder._sections_html.append("\n".join(parts))
        self.builder._in_section = True
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.builder._sections_html.append("</div>")
        self.builder._in_section = False


class ReportBuilder:
    """报告构建器"""

    # 预定义颜色
    C_DOUYIN = "#f76c8b"
    C_TAOBAO = "#6c7af7"
    C_XIAOHONGSHU = "#6cf7c2"
    C_ORANGE = "#f7c96c"
    C_PURPLE = "#c96cf7"
    COLORS = [C_DOUYIN, C_TAOBAO, C_XIAOHONGSHU, C_ORANGE, C_PURPLE]

    def __init__(self, title: str):
        self.title = title
        self._header_title = title
        self._header_sub = "数据可视化分析报告"
        self._header_meta = []
        self._nav = []
        self._sections_html = []
        self._charts = []
        self._footer = ""
        self._in_section = False

    # ---------- 页面级配置 ----------

    def header(self, title: str, subtitle: str = "", meta: list = None):
        self._header_title = title
        self._header_sub = subtitle
        self._header_meta = meta or []
        return self

    def nav(self, links: list):
        """links: [(label, href), ...]"""
        self._nav = links
        return self

    def footer(self, text: str):
        self._footer = text
        return self

    # ---------- 章节 ----------

    def section(self, sid: str, num: str, title: str, desc: str = ""):
        return _Section(self, sid, num, title, desc)

    def _ensure_section(self):
        if not self._in_section:
            raise RuntimeError("必须在 section 上下文中添加内容")

    def _add_html(self, html: str):
        self._ensure_section()
        self._sections_html.append(html)

    # ---------- KPI ----------

    def kpi_grid(self):
        self._add_html('<div class="kpi-grid">')
        return self

    def kpi_end(self):
        self._add_html('</div>')
        return self

    def kpi(self, label: str, value: str, unit: str = "", change: str = "", down: bool = False):
        grad = "linear-gradient(135deg,#f76c8b,#be185d)" if down else "linear-gradient(135deg,#6c7af7,#8b5cf6)"
        cls = "kpi-change down" if down else "kpi-change"
        change_html = f'<div class="{cls}">{change}</div>' if change else ""
        html = (
            f'<div class="kpi-card" style="--grad:{grad}">'
            f'<div class="kpi-label">{label}</div>'
            f'<div class="kpi-value">{value}<span class="kpi-unit">{unit}</span></div>'
            f'{change_html}'
            f'</div>'
        )
        self._add_html(html)
        return self

    # ---------- 布局 ----------

    def grid(self, cols: int = 2):
        self._add_html(f'<div class="grid-{cols}">')
        return self

    def grid_end(self):
        self._add_html('</div>')
        return self

    # ---------- 卡片与图表 ----------

    def card(self, title: str = "", dot_color: str = None):
        dot = f'<span class="dot" style="background:{dot_color or self.C_TAOBAO}"></span>' if dot_color != "" else ""
        t = f'<div class="card-title">{dot}{title}</div>' if title else ""
        self._add_html(f'<div class="card">{t}')
        return self

    def card_end(self):
        self._add_html('</div>')
        return self

    def chart(self, cid: str, size: str = "normal"):
        cls = "chart chart-lg" if size == "lg" else "chart"
        self._add_html(f'<div id="{cid}" class="{cls}"></div>')
        return self

    def raw_html(self, html: str):
        self._add_html(html)
        return self

    # ---------- 数据表格 ----------

    def table(self, headers: list, rows: list):
        ths = "".join(f"<th>{h}</th>" for h in headers)
        trs = ""
        for row in rows:
            tds = "".join(f"<td>{cell}</td>" for cell in row)
            trs += f"<tr>{tds}</tr>"
        html = f'<table class="data-table"><thead><tr>{ths}</tr></thead><tbody>{trs}</tbody></table>'
        self._add_html(html)
        return self

    # ---------- 进度条组件 ----------

    def progress_group(self):
        """开始一组进度条"""
        self._add_html('<div class="progress-group">')
        return self

    def progress_group_end(self):
        self._add_html('</div>')
        return self

    def progress_bar(self, label: str, value: float, max_val: float = 100,
                     color: str = None, suffix: str = ""):
        """
        渐变进度条。
        label: 左侧标签
        value: 当前值
        max_val: 最大值（用于计算宽度百分比）
        color: 渐变起始色，自动生成终止色；或传 [color1, color2] 自定义渐变
        suffix: 右侧补充文字（如"高退货""严重"等）
        """
        pct = min(value / max_val * 100, 100) if max_val > 0 else 0
        if isinstance(color, (list, tuple)) and len(color) >= 2:
            grad = f"linear-gradient(90deg,{color[0]},{color[1]})"
        elif color:
            grad = f"linear-gradient(90deg,{color},{self._darken(color)})"
        else:
            grad = f"linear-gradient(90deg,{self.C_DOUYIN},{self._darken(self.C_DOUYIN)})"
        suffix_html = f'<div class="progress-suffix">{suffix}</div>' if suffix else ""
        html = (
            f'<div class="progress-item">'
            f'<div class="progress-label">{label}</div>'
            f'<div class="progress-track">'
            f'<div class="progress-fill" style="width:{pct:.1f}%;background:{grad}">{value}%</div>'
            f'</div>'
            f'{suffix_html}'
            f'</div>'
        )
        self._add_html(html)
        return self

    @staticmethod
    def _darken(hex_color: str) -> str:
        """将十六进制颜色加深，用于渐变终止色"""
        hex_color = hex_color.lstrip("#")
        r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
        factor = 0.6
        r, g, b = int(r * factor), int(g * factor), int(b * factor)
        return f"#{r:02x}{g:02x}{b:02x}"

    # ---------- 数字卡片网格 ----------

    def metric_grid(self):
        """开始数字卡片网格"""
        self._add_html('<div class="metric-grid">')
        return self

    def metric_end(self):
        self._add_html('</div>')
        return self

    def metric(self, value: str, unit: str = "", desc: str = "", color: str = None):
        """
        单个数字卡片。
        value: 核心数字
        unit: 单位（如 %、亿元）
        desc: 下方说明文字
        color: 数字颜色，同时用于背景透明色
        """
        c = color or self.C_TAOBAO
        # 从hex提取rgb用于背景透明色
        hex_c = c.lstrip("#")
        r, g, b = int(hex_c[0:2], 16), int(hex_c[2:4], 16), int(hex_c[4:6], 16)
        bg = f"rgba({r},{g},{b},0.06)"
        unit_html = f'<span class="metric-unit">{unit}</span>' if unit else ""
        html = (
            f'<div class="metric-card" style="background:{bg}">'
            f'<div class="metric-value" style="color:{c}">{value}{unit_html}</div>'
            f'<div class="metric-desc">{desc}</div>'
            f'</div>'
        )
        self._add_html(html)
        return self

    # ---------- 洞察框 ----------

    def insight(self, title: str, bullets: list):
        lis = "".join(f"<li>{b}</li>" for b in bullets)
        html = f'<div class="insight-box"><h4>{title}</h4><ul>{lis}</ul></div>'
        self._add_html(html)
        return self

    # ---------- 图表脚本 ----------

    def chart_script(self, cid: str, option: dict):
        """注册一个ECharts图表配置（Python dict → JS object）"""
        js_opt = _to_js(option)
        code = (
            f'const {cid} = echarts.init(document.getElementById("{cid}"));\n'
            f'{cid}.setOption({js_opt});'
        )
        self._charts.append(code)
        return self

    def chart_bar(self, cid: str, categories: list, series: list, yname: str = "",
                  legend: list = None, dual_y: bool = False, size: str = "normal",
                  show_label: bool = False):
        """
        柱状图/条形图（支持双Y轴组合图）。

        series 中每个元素支持的字段：
          name, data, color, type("bar"/"line"), yAxisIndex,
          barWidth, symbolSize, lineWidth,
          gradient: [color1, color2] 渐变色（仅 bar 类型有效）
        show_label: 是否在柱子顶部显示数值标签
        """
        opt = {
            **self._common(),
            "xAxis": {"type": "category", "data": categories,
                      "axisLine": {"lineStyle": {"color": "rgba(255,255,255,0.05)"}},
                      "axisLabel": {"color": "#8b92a8", "rotate": 25 if len(categories) > 6 else 0, "fontSize": 10}},
            "yAxis": {"type": "value", "name": yname,
                      "axisLine": {"show": False}, "splitLine": {"lineStyle": {"color": "rgba(255,255,255,0.05)"}},
                      "axisLabel": {"color": "#8b92a8"}},
            "series": []
        }
        if dual_y:
            opt["yAxis"] = [
                {"type": "value", "name": yname, "axisLine": {"show": False},
                 "splitLine": {"lineStyle": {"color": "rgba(255,255,255,0.05)"}}, "axisLabel": {"color": "#8b92a8"}},
                {"type": "value", "axisLine": {"show": False}, "splitLine": {"show": False}, "axisLabel": {"color": "#8b92a8"}}
            ]
        for i, s in enumerate(series):
            item = {
                "name": s["name"],
                "type": s.get("type", "bar"),
                "data": s["data"],
                "itemStyle": {"borderRadius": [4, 4, 0, 0]},
                "barWidth": s.get("barWidth", 18)
            }
            # 渐变色支持：gradient: [topColor, bottomColor]
            if "gradient" in s and s.get("type", "bar") == "bar":
                g = s["gradient"]
                item["itemStyle"]["color"] = {
                    "type": "linear", "x": 0, "y": 0, "x2": 0, "y2": 1,
                    "colorStops": [
                        {"offset": 0, "color": g[0]},
                        {"offset": 1, "color": g[1]}
                    ]
                }
            elif "color" in s:
                item["itemStyle"]["color"] = s["color"]
            if "yAxisIndex" in s:
                item["yAxisIndex"] = s["yAxisIndex"]
            if s.get("type") == "line":
                item["symbol"] = "circle"
                item["symbolSize"] = s.get("symbolSize", 5)
                item["lineStyle"] = {"width": s.get("lineWidth", 2)}
                if "color" in s:
                    item["itemStyle"] = {"color": s["color"]}
                del item["barWidth"]
            # 数据标签
            if show_label:
                item["label"] = {"show": True, "position": "top", "color": "#8b92a8", "fontSize": 10}
            opt["series"].append(item)
        if legend:
            opt["legend"] = {"data": legend, "textStyle": {"color": "#8b92a8"}, "bottom": 0}
        return self.chart_script(cid, opt)

    def chart_pie(self, cid: str, data: list, inner_radius: str = "45%", outer_radius: str = "70%"):
        """data: [{"value":78.6,"name":"直播","color":"#f76c8b"}, ...]"""
        opt = {
            **self._common(),
            "tooltip": {"trigger": "item", "backgroundColor": "rgba(24,27,34,0.95)", "borderColor": "#232733", "textStyle": {"color": "#e0e3eb"}, "padding": 12, "borderRadius": 8, "formatter": "{b}：{d}%"},
            "legend": {"orient": "vertical", "right": 10, "top": "center", "textStyle": {"color": "#8b92a8"}},
            "series": [{
                "type": "pie",
                "radius": [inner_radius, outer_radius],
                "center": ["40%", "50%"],
                "avoidLabelOverlap": True,
                "itemStyle": {"borderRadius": 8, "borderColor": "#181b22", "borderWidth": 2},
                "label": {"show": False},
                "emphasis": {"label": {"show": False}},
                "data": [{"value": d["value"], "name": d["name"], "itemStyle": {"color": d.get("color", self.COLORS[i % len(self.COLORS)])}} for i, d in enumerate(data)]
            }]
        }
        return self.chart_script(cid, opt)

    def chart_radar(self, cid: str, indicators: list, data: list):
        """
        indicators: [{"name":"维度A","max":100}, ...]
        data: [{"name":"系列A","value":[80,90,...],"color":"#6c7af7"}, ...]
        """
        opt = {
            **self._common(),
            "legend": {"data": [d["name"] for d in data], "textStyle": {"color": "#8b92a8"}, "bottom": 0},
            "radar": {
                "indicator": indicators,
                "shape": "polygon",
                "splitNumber": 4,
                "axisName": {"color": "#8b92a8", "fontSize": 12},
                "splitLine": {"lineStyle": {"color": "rgba(255,255,255,0.05)"}},
                "splitArea": {"areaStyle": {"color": ["rgba(108,122,247,0.02)", "rgba(108,122,247,0.05)"]}},
                "axisLine": {"lineStyle": {"color": "rgba(255,255,255,0.05)"}}
            },
            "series": [{
                "type": "radar",
                "data": [{
                    "value": d["value"],
                    "name": d["name"],
                    "areaStyle": {"color": d.get("areaColor", f"rgba({d['color']},0.2)")},
                    "lineStyle": {"color": d["color"], "width": 2},
                    "itemStyle": {"color": d["color"]}
                } for d in data]
            }]
        }
        return self.chart_script(cid, opt)

    def chart_heatmap(self, cid: str, x_labels: list, y_labels: list, data: list):
        """data: [[x_index, y_index, value], ...]"""
        # 根据 x 轴标签数量和文字长度自适应布局参数
        max_x_len = max((len(str(l)) for l in x_labels), default=2)
        num_x = len(x_labels)
        rotate = 45 if num_x > 6 or max_x_len > 4 else 30
        bottom = 130 if rotate == 45 else 80
        # y 轴标签越长，左边距越大
        max_y_len = max((len(str(l)) for l in y_labels), default=2)
        left = max(100, max_y_len * 13)
        # 不使用 _common() 避免其 tooltip(trigger:"axis") 覆盖自定义 formatter
        y_labels_js = _to_js(y_labels)
        opt_body = {
            "textStyle": {"fontFamily": "PingFang SC, Microsoft YaHei, sans-serif"},
            "legend": {"show": False},
            "grid": {"left": left, "right": 30, "top": 10, "bottom": bottom},
            "xAxis": {"type": "category", "data": x_labels,
                      "axisLine": {"lineStyle": {"color": "rgba(255,255,255,0.05)"}},
                      "axisLabel": {"color": "#8b92a8", "rotate": rotate, "fontSize": 10, "interval": 0}},
            "yAxis": {"type": "category", "data": y_labels,
                      "axisLine": {"lineStyle": {"color": "rgba(255,255,255,0.05)"}},
                      "axisLabel": {"color": "#8b92a8", "fontSize": 11}},
            "visualMap": {
                "min": min(d[2] for d in data), "max": max(d[2] for d in data),
                "calculable": True, "orient": "horizontal", "left": "center", "bottom": 0,
                "inRange": {"color": ["#1e1b4b", "#312e81", "#4c1d95", "#7c3aed", "#a855f7", "#c084fc", "#d8b4fe"]},
                "textStyle": {"color": "#8b92a8"}
            },
            "series": [{
                "name": "热度", "type": "heatmap", "data": data,
                "label": {"show": True, "color": "#fff", "fontSize": 12, "fontWeight": "bold"},
                "itemStyle": {"borderColor": "#1a1a2e", "borderWidth": 2},
                "emphasis": {"itemStyle": {"shadowBlur": 10, "shadowColor": "rgba(0,0,0,0.5)"}}
            }]
        }
        js_body = _to_js(opt_body)
        # 在 { 后插入含 JS function 的 tooltip（_to_js 无法序列化 JS 函数）
        tooltip_js = (
            f'tooltip: {{trigger: "item", backgroundColor: "rgba(24,27,34,0.95)", '
            f'borderColor: "#232733", textStyle: {{color: "#e0e3eb"}}, padding: 12, borderRadius: 8, '
            f'formatter: function(p){{var yl={y_labels_js};'
            f'return "<b>"+p.name+"</b><br/>"+yl[p.data[1]]+": <b>"+p.data[2]+"</b>";}}}}, '
        )
        js_opt = "{" + tooltip_js + js_body[1:]
        code = (
            f'const {cid} = echarts.init(document.getElementById("{cid}"));\n'
            f'{cid}.setOption({js_opt});'
        )
        self._charts.append(code)
    def chart_stacked_bar(self, cid: str, categories: list, series: list):
        """series: [{"name":"系列A","data":[1,2,3],"color":"#6c7af7"}, ...]"""
        opt = {
            **self._common(),
            "legend": {"data": [s["name"] for s in series], "textStyle": {"color": "#8b92a8"}, "bottom": 0},
            "xAxis": {"type": "category", "data": categories,
                      "axisLine": {"lineStyle": {"color": "rgba(255,255,255,0.05)"}},
                      "axisLabel": {"color": "#8b92a8"}},
            "yAxis": {"type": "value", "name": "占比%",
                      "axisLine": {"show": False}, "splitLine": {"lineStyle": {"color": "rgba(255,255,255,0.05)"}},
                      "axisLabel": {"color": "#8b92a8"}},
            "series": [{
                "name": s["name"], "type": "bar", "stack": "total", "data": s["data"],
                "itemStyle": {"color": s.get("color", self.COLORS[i % len(self.COLORS)])},
                "barWidth": 40
            } for i, s in enumerate(series)]
        }
        return self.chart_script(cid, opt)

    # ---------- 内部辅助 ----------

    def _common(self):
        return {
            "textStyle": {"fontFamily": "PingFang SC, Microsoft YaHei, sans-serif"},
            "tooltip": {
                "trigger": "axis",
                "backgroundColor": "rgba(24,27,34,0.95)", "borderColor": "#232733",
                "textStyle": {"color": "#e0e3eb"}, "padding": 12, "borderRadius": 8,
                "axisPointer": {"type": "shadow", "shadowStyle": {"color": "rgba(108,122,247,0.08)"}}
            },
            "legend": {"textStyle": {"color": "#8b92a8"}, "bottom": 0},
            "grid": {"left": 60, "right": 30, "top": 40, "bottom": 50, "containLabel": True}
        }

    # ---------- 渲染 ----------

    def render(self, out_path: str):
        skill_dir = Path(__file__).parent
        tmpl_path = skill_dir / "template.html"
        tmpl = tmpl_path.read_text(encoding="utf-8")

        # 构建导航HTML
        nav_links = []
        for i, (label, href) in enumerate(self._nav):
            cls = ' class="active"' if i == 0 else ""
            nav_links.append(f'<a href="{href}"{cls}>{label}</a>')
        nav_html = "".join(nav_links)

        # 构建header meta
        meta_html = "".join(f'<span>{m}</span>' for m in self._header_meta)

        # 构建sections
        sections_html = "\n".join(self._sections_html)

        # 构建chart scripts
        charts_js = "\n".join(self._charts)
        chart_vars = [line.split("=")[0].strip().replace("const ", "") for line in self._charts if line.startswith("const ")]
        instances_str = ", ".join(chart_vars)

        # 替换占位符
        tmpl = tmpl.replace("{{TITLE}}", self.title)
        tmpl = tmpl.replace("{{HEADER_TITLE}}", self._header_title)
        tmpl = tmpl.replace("{{HEADER_SUBTITLE}}", self._header_sub)
        tmpl = tmpl.replace("{{HEADER_META}}", meta_html)
        tmpl = tmpl.replace("{{NAV_LINKS}}", nav_html)
        tmpl = tmpl.replace("{{SECTIONS}}", sections_html)
        tmpl = tmpl.replace("{{FOOTER}}", self._footer)
        tmpl = tmpl.replace("{{CHART_SCRIPTS}}", charts_js)
        tmpl = tmpl.replace("__CHART_INSTANCES__", f"[{instances_str}]")

        Path(out_path).write_text(tmpl, encoding="utf-8")
        print(f"[OK] 报告已生成: {out_path}")
        return out_path


# ---------- 便捷入口 ----------

def build(out_path: str, title: str, config_fn):
    """
    便捷入口。config_fn 接收一个 ReportBuilder 实例进行配置。
    示例：
        build("report.html", "市场数据", lambda r: (
            r.header("市场数据", "", ["2026-05-14"]),
            r.nav([("指标","#kpi")]),
            r.section("kpi","01","核心指标","").__enter__(),
            r.kpi("销量","4.23","亿件","占21.1%"),
            None
        ))
    """
    r = ReportBuilder(title)
    config_fn(r)
    return r.render(out_path)


if __name__ == "__main__":
    # 最小验证示例
    r = ReportBuilder("验证报告")
    r.header("验证报告", "模板测试", ["时间:2026-05-14"])
    r.nav([("测试", "#test")])
    with r.section("test", "01", "测试章节", "验证模板渲染"):
        r.card("测试图表", r.C_TAOBAO)
        r.chart("testChart")
        r.card_end()
        r.chart_bar("testChart",
                    categories=["A", "B", "C"],
                    series=[{"name": "数值", "data": [120, 200, 150], "color": r.C_TAOBAO}])
    r.footer("验证测试")
    r.render("template-test-py.html")


path: scripts/template.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{TITLE}}</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/echarts/5.4.3/echarts.min.js"></script>
<style>
  :root {
    --bg: #0f1117; --card: #181b22; --card-border: #232733;
    --text: #e0e3eb; --text-muted: #8b92a8;
    --accent: #6c7af7; --accent2: #f76c8b; --accent3: #6cf7c2; --accent4: #f7c96c; --accent5: #c96cf7;
  }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html { scroll-behavior: smooth; }
  body { font-family: "PingFang SC","Microsoft YaHei",sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; }
  .header { position: relative; padding: 60px 40px 40px; text-align: center; background: linear-gradient(180deg,rgba(108,122,247,0.08) 0%,transparent 100%); border-bottom: 1px solid var(--card-border); }
  .header h1 { font-size: 2.4rem; font-weight: 700; background: linear-gradient(90deg,#fff,#a5b4fc); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 12px; letter-spacing: 2px; }
  .header .subtitle { color: var(--text-muted); font-size: 1rem; margin-bottom: 8px; }
  .header .meta { color: #5b6280; font-size: 0.85rem; }
  .header .meta span { margin: 0 12px; display: inline-block; }
  .header .meta span::before { content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-right: 6px; vertical-align: middle; }
  .nav { position: sticky; top: 0; z-index: 100; background: rgba(15,17,23,0.95); backdrop-filter: blur(10px); border-bottom: 1px solid var(--card-border); display: flex; justify-content: center; gap: 8px; padding: 12px 20px; flex-wrap: wrap; }
  .nav a { color: var(--text-muted); text-decoration: none; padding: 6px 16px; border-radius: 20px; font-size: 0.85rem; transition: all 0.3s; border: 1px solid transparent; }
  .nav a:hover, .nav a.active { color: #fff; background: rgba(108,122,247,0.15); border-color: rgba(108,122,247,0.3); }
  .container { max-width: 1400px; margin: 0 auto; padding: 30px; }
  .section { margin-bottom: 50px; animation: fadeInUp 0.6s ease forwards; opacity: 0; }
  @keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
  /* 组件纵向间距：section 内相邻兄弟元素自动添加间距 */
  .section > * + * { margin-top: 20px; }
  .section > .section-title + .section-desc { margin-top: 0; }
  .section > .section-desc + * { margin-top: 0; }
  .section-title { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; display: flex; align-items: center; gap: 10px; }
  .section-title .num { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; background: linear-gradient(135deg,#6c7af7,#8b5cf6); color: #fff; font-size: 0.9rem; font-weight: 700; }
  .section-desc { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 24px; padding-left: 42px; }
  .grid-2 { display: grid; grid-template-columns: repeat(2,1fr); gap: 20px; }
  .grid-3 { display: grid; grid-template-columns: repeat(3,1fr); gap: 20px; }
  .grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 20px; }
  @media (max-width:1100px) { .grid-2,.grid-3,.grid-4 { grid-template-columns: 1fr; } }
  .card { background: var(--card); border: 1px solid var(--card-border); border-radius: 16px; padding: 24px; transition: transform 0.3s,box-shadow 0.3s; overflow: visible; position: relative; z-index: 1; }
  .card:hover { transform: translateY(-2px); box-shadow: 0 8px 32px rgba(0,0,0,0.3); z-index: 10; }
  .card-title { font-size: 1rem; font-weight: 600; margin-bottom: 16px; color: #fff; display: flex; align-items: center; gap: 8px; }
  .card-title .dot { width: 8px; height: 8px; border-radius: 50%; }
  .chart { width: 100%; height: 400px; overflow: visible; }
  .chart-lg { height: 500px; }
  .kpi-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 20px; margin-bottom: 40px; }
  @media (max-width:900px) { .kpi-grid { grid-template-columns: repeat(2,1fr); } }
  .kpi-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 16px; padding: 24px; position: relative; overflow: hidden; }
  .kpi-card::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: var(--grad); }
  .kpi-label { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 8px; }
  .kpi-value { font-size: 2rem; font-weight: 700; color: #fff; line-height: 1; }
  .kpi-unit { font-size: 0.9rem; color: var(--text-muted); margin-left: 4px; }
  .kpi-change { margin-top: 10px; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 6px; background: rgba(108,247,194,0.1); color: var(--accent3); }
  .kpi-change.down { background: rgba(247,108,139,0.1); color: var(--accent2); }
  .data-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
  .data-table th { text-align: left; padding: 12px 16px; background: rgba(108,122,247,0.08); color: #a5b4fc; font-weight: 500; border-bottom: 1px solid var(--card-border); white-space: nowrap; }
  .data-table td { padding: 10px 16px; border-bottom: 1px solid var(--card-border); color: var(--text); }
  .data-table tr:hover td { background: rgba(108,122,247,0.04); }
  .trend-up { color: #6cf7c2; } .trend-down { color: #f76c8b; } .trend-steady { color: #f7c96c; }
  .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; margin-right: 4px; }
  .tag-pink { background: rgba(247,108,139,0.15); color: #f76c8b; }
  .tag-purple { background: rgba(201,108,247,0.15); color: #c96cf7; }
  .tag-green { background: rgba(108,247,194,0.15); color: #6cf7c2; }
  .tag-orange { background: rgba(247,201,108,0.15); color: #f7c96c; }
  .insight-box { background: linear-gradient(135deg,rgba(108,122,247,0.08),rgba(139,92,246,0.05)); border: 1px solid rgba(108,122,247,0.2); border-radius: 12px; padding: 20px 24px; margin-top: 20px; }
  .insight-box h4 { color: #a5b4fc; font-size: 0.95rem; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
  .insight-box p,.insight-box li { color: var(--text-muted); font-size: 0.88rem; line-height: 1.8; }
  .insight-box ul { padding-left: 20px; }
  .pain-bar { display: flex; align-items: center; margin-bottom: 12px; gap: 12px; }
  .pain-label { width: 140px; font-size: 0.85rem; color: var(--text); flex-shrink: 0; }
  .pain-track { flex: 1; height: 28px; background: rgba(255,255,255,0.04); border-radius: 6px; overflow: hidden; position: relative; }
  .pain-fill { height: 100%; border-radius: 6px; display: flex; align-items: center; justify-content: flex-end; padding-right: 10px; font-size: 0.8rem; font-weight: 600; color: #fff; }
  .stars { color: #f7c96c; font-size: 0.9rem; letter-spacing: 2px; }
  /* Progress Bar 进度条组件 */
  .progress-group { padding: 4px 0; }
  .progress-item { display: flex; align-items: center; margin-bottom: 12px; gap: 12px; }
  .progress-label { width: 150px; font-size: 0.85rem; color: var(--text); flex-shrink: 0; }
  .progress-track { flex: 1; height: 28px; background: rgba(255,255,255,0.04); border-radius: 6px; overflow: hidden; }
  .progress-fill { height: 100%; border-radius: 6px; display: flex; align-items: center; justify-content: flex-end; padding-right: 10px; font-size: 0.8rem; font-weight: 600; color: #fff; transition: width 1s ease; }
  .progress-suffix { width: 60px; text-align: right; font-size: 0.85rem; color: var(--text-muted); flex-shrink: 0; }
  /* Metric Card 数字卡片网格 */
  .metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
  .metric-card { border-radius: 10px; padding: 16px; text-align: center; }
  .metric-value { font-size: 1.6rem; font-weight: 700; line-height: 1; }
  .metric-unit { font-size: 0.85rem; margin-left: 2px; opacity: 0.8; }
  .metric-desc { font-size: 0.8rem; color: var(--text-muted); margin-top: 6px; }
  .footer { text-align: center; padding: 40px; color: #5b6280; font-size: 0.8rem; border-top: 1px solid var(--card-border); }
  ::-webkit-scrollbar { width: 8px; }
  ::-webkit-scrollbar-track { background: var(--bg); }
  ::-webkit-scrollbar-thumb { background: #2a2f3e; border-radius: 4px; }
</style>
</head>
<body>

<header class="header">
  <h1>{{HEADER_TITLE}}</h1>
  <p class="subtitle">{{HEADER_SUBTITLE}}</p>
  <p class="meta">{{HEADER_META}}</p>
</header>

<nav class="nav">{{NAV_LINKS}}</nav>

<div class="container">
{{SECTIONS}}
</div>

<footer class="footer">{{FOOTER}}</footer>

<script>
const themeColor='#e0e3eb', mutedColor='#8b92a8', gridColor='rgba(255,255,255,0.05)';
function commonOption(){
  return {
    textStyle:{fontFamily:"PingFang SC,Microsoft YaHei,sans-serif"},
    tooltip:{backgroundColor:'rgba(24,27,34,0.95)',borderColor:'#232733',textStyle:{color:'#e0e3eb'},padding:12,borderRadius:8},
    legend:{textStyle:{color:mutedColor},bottom:0},
    grid:{left:60,right:30,top:40,bottom:50,containLabel:true}
  };
}
function resizeAll(charts){
  charts.forEach(c=>c&&c.resize());
}
window.addEventListener('resize',()=>{ resizeAll(__CHART_INSTANCES__); });
const navLinks=document.querySelectorAll('.nav a');
const sections=document.querySelectorAll('.section');
window.addEventListener('scroll',()=>{
  let current='';
  sections.forEach(s=>{if(scrollY>=s.offsetTop-120)current=s.id;});
  navLinks.forEach(a=>{a.classList.toggle('active',a.getAttribute('href')==='#'+current);});
});
{{CHART_SCRIPTS}}
</script>

</body>
</html>


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal:  | SkillSpector: suspicious (HIGH)