Skill: cloudcba/chartjs-reporter (version 1.0.0)

--- name: chartjs-reporter description: > This skill should be used when the user needs to turn structured data (query results, CSV summaries, JSON records, or Python dicts/lists) into a standalone HTML visualization report powered by Chart.js. It covers generating pie charts, doughnut charts, bar charts (vertical/horizontal), line charts, mixed charts, and KPI summary cards — all embedded in a dark-themed, self-contained HTML file that opens directly in any browser. Trigger when the user says things like "生成可视化报告", "数据出图", "生成HTML图表", "把查询结果可视化", "用 Chart.js 画图", or provides tabular data and asks for a visual output. --- # chartjs-reporter — Chart.js HTML 可视化报告生成技能 ## 技能目的 将结构化数据（SQL 查询结果、CSV 摘要、Python dict/list、手动提供的表格）转换为 **自包含的 HTML 可视化报告**，内嵌 Chart.js 图表和 KPI 卡片，无需服务器， 浏览器直接打开即可查看。 ## 触发条件 以下任意一种情况触发本技能： - 用户提供了数据并要求"出图"、"可视化"、"生成报告" - 用户已有 DuckDB / SQL / pandas 查询结果，需要图表化展示 - 用户指定了图表类型（饼图、柱状图、折线图等）+ 数据 - 与 chat2duckdb 技能配合：查询完成后生成可视化报告 ## 操作步骤 ### 步骤 1：理解数据结构 收到数据后，确认以下信息： - 数据形态：数值列 / 分类列 / 时间列 - 分析目的：占比 / 趋势 / 对比 / 排名 - 期望图表类型（用户未指定时，按照「图表选型规则」自动选择） ### 步骤 2：选择图表类型 | 分析目的 | 推荐图表 | |---------|---------| | 占比 / 构成 | doughnut（≤6类）/ pie | | 趋势 / 时间序列 | line（fill: true 显示面积） | | 分类对比（≤8项） | bar（垂直） | | 分类对比（>8项或标签长） | bar（水平，indexAxis: 'y'） | | 多指标对比 | 分组 bar | | 排名 Top N | 水平 bar + 进度条 | | 关键指标摘要 | KPI 卡片（非图表） | ### 步骤 3：调用生成脚本 使用 `scripts/generate_report.py` 生成 HTML 报告： ```bash python scripts/generate_report.py \ --title "报告标题" \ --subtitle "副标题说明" \ --data '{"charts": [...], "kpis": [...]}' \ --output report.html ``` 也可以直接在 Python 中调用（适合与 chat2duckdb 配合）： ```python from scripts.generate_report import build_report html = build_report(title, subtitle, kpis, charts) with open("report.html", "w", encoding="utf-8") as f: f.write(html) ``` ### 步骤 4：数据格式规范 `kpis` 列表（可选，顶部 KPI 卡片）： ```json [ {"label": "总营收", "value": "¥1,755,905", "sub": "全年累计", "color": "green"}, {"label": "订单数", "value": "200", "sub": "5 品类", "color": "blue"} ] ``` `color` 可选值：`blue` | `green` | `yellow` | `purple` | `red` `charts` 列表（图表配置）： ```json [ { "type": "doughnut", "title": "品类营收占比", "labels": ["Food", "Electronics", "Sports"], "datasets": [{"data": [456833, 351665, 349967]}] }, { "type": "line", "title": "月度趋势", "labels": ["1月","2月","3月"], "datasets": [{"label": "营收", "data": [158495, 185560, 98369]}] } ] ``` 支持的 `type` 值：`bar` | `line` | `doughnut` | `pie` | `horizontalBar`（自动转 bar + indexAxis:y） ### 步骤 5：布局规则 - KPI 卡片行：最多 4 列，超出自动换行 - 图表区：默认 2 列网格；1 个图表时全宽；3 个图表时 3 列 - 每张图表高度固定 240px，响应式宽度 - 表格（Top N 排名）：单独一行，全宽显示 - 页脚：说明数据来源和生成时间 ### 步骤 6：输出与展示 - 输出路径默认为用户提供的路径，或 `./report_<timestamp>.html` - 生成后调用 `preview_url` 工具在浏览器中预览 - 所有依赖（Chart.js）通过 CDN 加载，无需本地安装 ## 与 chat2duckdb 配合的标准流程 ``` 1. chat2duckdb 执行 SQL 查询 → 得到 DataFrame / 字典结果 2. chartjs-reporter 将结果转换为图表配置 JSON 3. 调用 generate_report.py 生成 HTML 4. 调用 preview_url 展示报告 ``` ## 参考资源 - 核心脚本：[scripts/generate_report.py](scripts/generate_report.py) - 图表配置参考：[references/chart-config-guide.md](references/chart-config-guide.md) - 样式主题参考：[references/theme-tokens.md](references/theme-tokens.md) ## 注意事项 - 报告为**深色主题**（dark mode），背景色 `#0f172a`，适合截图展示 - 数值超过 1000 时，自动格式化为千分位（¥1,234,567） - 颜色序列已内置，无需手动指定每个数据点颜色 - Chart.js 版本固定为 4.4.0（CDN），确保稳定性

path: scripts/generate_report.py
content: #!/usr/bin/env python3
"""
chartjs-reporter: generate_report.py
将结构化数据转换为自包含的 Chart.js HTML 可视化报告。

用法：
  python generate_report.py --title "标题" --data '{"kpis":[...],"charts":[...]}' --output report.html
  或直接 import build_report 函数使用。
"""

import argparse
import json
import sys
from datetime import datetime
from pathlib import Path


# ── 默认调色板 ──────────────────────────────────────────────────────────────
PALETTE = [
    "#38bdf8", "#818cf8", "#34d399", "#fbbf24", "#fb923c",
    "#f472b6", "#a78bfa", "#4ade80", "#60a5fa", "#facc15",
]

KPI_COLORS = {
    "blue":   ("var(--accent)",  "#1e293b"),
    "green":  ("var(--green)",   "#1e293b"),
    "yellow": ("var(--yellow)",  "#1e293b"),
    "purple": ("var(--accent2)", "#1e293b"),
    "red":    ("var(--red)",     "#1e293b"),
}


# ── KPI 卡片 HTML ────────────────────────────────────────────────────────────
def _render_kpis(kpis: list) -> str:
    if not kpis:
        return ""
    cards = []
    for kpi in kpis:
        color_var, _ = KPI_COLORS.get(kpi.get("color", "blue"), KPI_COLORS["blue"])
        cards.append(f"""
      <div class="kpi">
        <div class="kpi-label">{kpi.get('label', '')}</div>
        <div class="kpi-value" style="color:{color_var}">{kpi.get('value', '')}</div>
        <div class="kpi-sub">{kpi.get('sub', '')}</div>
      </div>""")
    return f'<div class="kpi-row">{"".join(cards)}\n    </div>'


# ── 单张图表 JS 配置 ──────────────────────────────────────────────────────────
def _chart_js_config(chart: dict, idx: int) -> str:
    ctype = chart.get("type", "bar")
    labels = json.dumps(chart.get("labels", []), ensure_ascii=False)
    title = chart.get("title", f"图表{idx+1}")
    raw_datasets = chart.get("datasets", [])

    # 处理水平柱状图别名
    index_axis = ""
    if ctype == "horizontalBar":
        ctype = "bar"
        index_axis = 'indexAxis: "y",'

    # 为每个 dataset 自动注入颜色
    datasets_js_parts = []
    for i, ds in enumerate(raw_datasets):
        color = PALETTE[i % len(PALETTE)]
        label = json.dumps(ds.get("label", ""), ensure_ascii=False)
        data = json.dumps(ds.get("data", []))

        if ctype in ("doughnut", "pie"):
            # 多色背景
            bg_colors = json.dumps([PALETTE[j % len(PALETTE)] for j in range(len(ds.get("data", [])))])
            ds_str = f"""{{
          label: {label},
          data: {data},
          backgroundColor: {bg_colors},
          borderWidth: 2,
          borderColor: "#1e293b"
        }}"""
        elif ctype == "line":
            fill = str(ds.get("fill", True)).lower()
            ds_str = f"""{{
          label: {label},
          data: {data},
          borderColor: "{color}",
          backgroundColor: "{color}22",
          tension: 0.4,
          fill: {fill},
          pointRadius: 4,
          pointBackgroundColor: "{color}"
        }}"""
        else:  # bar / grouped bar
            ds_str = f"""{{
          label: {label},
          data: {data},
          backgroundColor: "{color}",
          borderRadius: 6
        }}"""
        datasets_js_parts.append(ds_str)

    datasets_js = ",\n        ".join(datasets_js_parts)

    # y轴 tick 格式（数值大于 999 时加 k）
    y_tick = ""
    if ctype not in ("doughnut", "pie"):
        y_tick = "callback: v => v >= 1000 ? (v/1000).toFixed(0)+'k' : v,"

    scales_block = ""
    if ctype not in ("doughnut", "pie"):
        scales_block = f"""
      scales: {{
        x: {{ grid: {{ color: "#1e293b" }}, ticks: {{ color: "#94a3b8" }} }},
        y: {{ grid: {{ color: "#334155" }}, ticks: {{ color: "#94a3b8", {y_tick} }} }}
      }},"""

    legend_display = "false" if len(raw_datasets) <= 1 and ctype not in ("doughnut", "pie") else "true"
    legend_pos = '"right"' if ctype in ("doughnut", "pie") else '"top"'

    return f"""new Chart(document.getElementById('chart{idx}'), {{
    type: '{ctype}',
    data: {{
      labels: {labels},
      datasets: [{datasets_js}]
    }},
    options: {{
      responsive: true,
      maintainAspectRatio: false,
      {index_axis}
      {scales_block}
      plugins: {{
        legend: {{ display: {legend_display}, position: {legend_pos}, labels: {{ color: "#94a3b8", font: {{ size: 12 }} }} }},
        title: {{ display: false }}
      }}
    }}
  }});"""


# ── 图表区 HTML ──────────────────────────────────────────────────────────────
def _render_charts(charts: list) -> str:
    if not charts:
        return ""

    n = len(charts)
    if n == 1:
        cols = "1fr"
    elif n % 3 == 0 and n >= 3:
        cols = "1fr 1fr 1fr"
    else:
        cols = "1fr 1fr"

    cards_html = []
    for i, chart in enumerate(charts):
        title = chart.get("title", f"图表{i+1}")
        cards_html.append(f"""
      <div class="chart-card">
        <h3>{title}</h3>
        <div class="chart-wrap"><canvas id="chart{i}"></canvas></div>
      </div>""")

    return f"""<div class="charts-grid" style="grid-template-columns:{cols}">
      {"".join(cards_html)}
    </div>"""


# ── 表格区 HTML ──────────────────────────────────────────────────────────────
def _render_tables(tables: list) -> str:
    if not tables:
        return ""

    tables_html = []
    for table in tables:
        title = table.get("title", "")
        columns = table.get("columns", [])
        data = table.get("data", [])
        highlight_col = table.get("highlight", "")

        # 表头
        header_cells = "".join([f"<th>{col}</th>" for col in columns])

        # 表体
        rows_html = []
        for row in data:
            cells_html = []
            for i, col in enumerate(columns):
                value = row.get(col, "")
                cell_html = f"<td>{value}</td>"
                # 高亮列添加样式
                if highlight_col and col == highlight_col:
                    # 根据数值添加颜色
                    cell_html = f'<td class="highlight-{_get_rate_level(value)}">{value}</td>'
                cells_html.append(cell_html)
            rows_html.append(f"<tr>{''.join(cells_html)}</tr>")

        tables_html.append(f"""
      <div class="table-card">
        <h3>{title}</h3>
        <table>
          <thead>
            <tr>{header_cells}</tr>
          </thead>
          <tbody>
            {"".join(rows_html)}
          </tbody>
        </table>
      </div>""")

    return f"""<div class="tables-section">
      {"".join(tables_html)}
    </div>"""


# ── 退货率级别判断 ─────────────────────────────────────────────────────────
def _get_rate_level(value: str) -> str:
    """根据退货率返回级别"""
    try:
        # 移除百分号并转为浮点数
        num = float(value.replace('%', '').replace('万', '').strip())
        if '万' in value:
            # 处理金额情况
            return 'normal'
        if num >= 90:
            return 'danger'
        elif num >= 75:
            return 'warning'
        elif num >= 65:
            return 'medium'
        else:
            return 'good'
    except:
        return 'normal'


# ── 主构建函数 ───────────────────────────────────────────────────────────────
def build_report(
    title: str,
    subtitle: str = "",
    kpis: list = None,
    charts: list = None,
    tables: list = None,
    footer: str = "",
) -> str:
    kpis = kpis or []
    charts = charts or []
    tables = tables or []
    footer = footer or f"由 chartjs-reporter 技能生成 · {datetime.now().strftime('%Y-%m-%d %H:%M')}"

    kpi_html = _render_kpis(kpis)
    chart_html = _render_charts(charts)
    table_html = _render_tables(tables)
    chart_scripts = "\n  ".join(_chart_js_config(c, i) for i, c in enumerate(charts))

    return f"""<!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://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
  <style>
    :root {{
      --bg:#0f172a; --card:#1e293b; --border:#334155;
      --accent:#38bdf8; --accent2:#818cf8;
      --green:#34d399; --yellow:#fbbf24; --red:#f87171;
      --text:#e2e8f0; --muted:#94a3b8;
    }}
    *{{box-sizing:border-box;margin:0;padding:0}}
    body{{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;padding:28px}}
    h1{{font-size:1.75rem;font-weight:700;color:var(--accent);margin-bottom:4px}}
    .subtitle{{color:var(--muted);font-size:.88rem;margin-bottom:28px}}
    /* KPI */
    .kpi-row{{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:28px}}
    .kpi{{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px 22px}}
    .kpi-label{{color:var(--muted);font-size:.75rem;letter-spacing:.05em;text-transform:uppercase;margin-bottom:6px}}
    .kpi-value{{font-size:1.85rem;font-weight:700;margin-bottom:4px}}
    .kpi-sub{{color:var(--muted);font-size:.8rem}}
    /* 图表 */
    .charts-grid{{display:grid;gap:20px;margin-bottom:24px}}
    .chart-card{{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px 22px}}
    .chart-card h3{{font-size:.85rem;color:var(--muted);font-weight:600;margin-bottom:14px;text-transform:uppercase;letter-spacing:.04em}}
    .chart-wrap{{position:relative;height:240px}}
    /* 表格 */
    .tables-section{{display:grid;gap:20px}}
    .table-card{{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px 22px}}
    .table-card h3{{font-size:.85rem;color:var(--muted);font-weight:600;margin-bottom:14px;text-transform:uppercase;letter-spacing:.04em}}
    table{{width:100%;border-collapse:collapse;font-size:.85rem}}
    thead{{border-bottom:2px solid var(--border)}}
    th{{text-align:left;padding:10px 12px;color:var(--accent);font-weight:600;white-space:nowrap}}
    tbody tr{{border-bottom:1px solid var(--border);transition:background .15s}}
    tbody tr:hover{{background:rgba(56,189,248,.08)}}
    td{{padding:9px 12px;color:var(--text);vertical-align:middle}}
    /* 高亮样式 */
    .highlight-danger{{color:var(--red);font-weight:600;background:rgba(248,113,113,.12);border-radius:4px;padding:2px 6px}}
    .highlight-warning{{color:var(--yellow);font-weight:600;background:rgba(251,191,36,.12);border-radius:4px;padding:2px 6px}}
    .highlight-medium{{color:var(--text);background:rgba(148,163,184,.12);border-radius:4px;padding:2px 6px}}
    .highlight-good{{color:var(--green);font-weight:600;background:rgba(52,211,153,.12);border-radius:4px;padding:2px 6px}}
    .highlight-normal{{color:var(--text)}}
    footer{{text-align:center;color:var(--muted);font-size:.75rem;margin-top:8px}}
  </style>
</head>
<body>
  <h1>📊 {title}</h1>
  <p class="subtitle">{subtitle}</p>

  {kpi_html}
  {chart_html}
  {table_html}

  <footer>{footer}</footer>
  <script>
  {chart_scripts}
  </script>
</body>
</html>"""


# ── CLI 入口 ─────────────────────────────────────────────────────────────────
def main():
    parser = argparse.ArgumentParser(description="生成 Chart.js HTML 可视化报告")
    parser.add_argument("--title",    required=True, help="报告标题")
    parser.add_argument("--subtitle", default="",    help="副标题")
    parser.add_argument("--data",     required=True, help='JSON 字符串，含 kpis 和 charts 字段')
    parser.add_argument("--output",   default="report.html", help="输出 HTML 文件路径")
    parser.add_argument("--footer",   default="",    help="页脚文字")
    args = parser.parse_args()

    try:
        data = json.loads(args.data)
    except json.JSONDecodeError as e:
        print(f"❌ --data 不是合法 JSON：{e}", file=sys.stderr)
        sys.exit(1)

    html = build_report(
        title=args.title,
        subtitle=args.subtitle,
        kpis=data.get("kpis", []),
        charts=data.get("charts", []),
        footer=args.footer,
    )

    out = Path(args.output)
    out.write_text(html, encoding="utf-8")
    print(f"✅ 报告已生成：{out.resolve()}")


if __name__ == "__main__":
    main()


path: references/chart-config-guide.md
content: # Chart.js 图表配置参考指南

## 支持的图表类型及配置示例

### 1. 环形图 / 饼图（doughnut / pie）

适用场景：占比分析、构成分析，分类数 ≤ 6 时使用 doughnut，> 6 时考虑 bar。

```json
{
  "type": "doughnut",
  "title": "品类营收占比",
  "labels": ["Food", "Electronics", "Sports", "Clothing", "Furniture"],
  "datasets": [
    {"data": [456833, 351665, 349967, 310014, 287427]}
  ]
}
```

### 2. 折线图（line）

适用场景：时间序列、趋势分析。

```json
{
  "type": "line",
  "title": "月度营收趋势",
  "labels": ["1月","2月","3月","4月","5月","6月"],
  "datasets": [
    {"label": "营收", "data": [158495, 185560, 98369, 158995, 230752, 144102]}
  ]
}
```

多条线（多 dataset）：
```json
{
  "type": "line",
  "title": "各渠道月度对比",
  "labels": ["1月","2月","3月"],
  "datasets": [
    {"label": "Online",      "data": [50000, 60000, 45000]},
    {"label": "Store",       "data": [30000, 35000, 28000]},
    {"label": "Distributor", "data": [70000, 80000, 65000]}
  ]
}
```

### 3. 垂直柱状图（bar）

适用场景：分类对比，标签较短，分类 ≤ 8。

```json
{
  "type": "bar",
  "title": "区域营收对比",
  "labels": ["East", "West", "North", "South", "Central"],
  "datasets": [
    {"label": "营收", "data": [419036, 428635, 331090, 357535, 219610]}
  ]
}
```

### 4. 水平柱状图（horizontalBar）

适用场景：排名展示、标签较长时使用。

```json
{
  "type": "horizontalBar",
  "title": "品类均价排名",
  "labels": ["Electronics", "Clothing", "Sports", "Furniture", "Food"],
  "datasets": [
    {"label": "均价(¥)", "data": [860, 833, 813, 811, 775]}
  ]
}
```

### 5. 分组柱状图

同一 chart type=bar，提供多个 dataset 即可自动分组：

```json
{
  "type": "bar",
  "title": "Q1-Q2 各品类对比",
  "labels": ["Food", "Electronics", "Sports"],
  "datasets": [
    {"label": "Q1", "data": [120000, 95000, 88000]},
    {"label": "Q2", "data": [145000, 110000, 102000]}
  ]
}
```

---

## KPI 卡片配置

```json
[
  {"label": "总营收",     "value": "¥1,755,905", "sub": "全年累计",      "color": "green"},
  {"label": "订单数",     "value": "200",         "sub": "5品类·25产品",  "color": "blue"},
  {"label": "平均单价",   "value": "¥815",        "sub": "中位数¥868",    "color": "yellow"},
  {"label": "平均件数/单","value": "10.7",        "sub": "最高20件",      "color": "purple"}
]
```

color 可选值：`blue` | `green` | `yellow` | `purple` | `red`

---

## 完整调用示例（与 chat2duckdb 配合）

```python
import subprocess, json, sys

# 1. 从 DuckDB 查询结果构建数据
kpis = [
    {"label": "总营收", "value": "¥1,755,905", "sub": "200单", "color": "green"},
]
charts = [
    {
        "type": "doughnut",
        "title": "品类营收占比",
        "labels": ["Food","Electronics","Sports","Clothing","Furniture"],
        "datasets": [{"data": [456833,351665,349967,310014,287427]}]
    },
    {
        "type": "line",
        "title": "月度营收趋势",
        "labels": ["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],
        "datasets": [{"label":"营收","data":[158495,185560,98369,158995,230752,144102,71695,209684,72276,119508,160265,146204]}]
    }
]
data_json = json.dumps({"kpis": kpis, "charts": charts}, ensure_ascii=False)

# 2. 调用生成脚本
subprocess.run([
    sys.executable,
    "C:/Users/wangd/.workbuddy/skills/chartjs-reporter/scripts/generate_report.py",
    "--title", "2025年销售分析报告",
    "--subtitle", "数据来源：sales_data.csv · DuckDB 分析",
    "--data", data_json,
    "--output", "output_report.html"
])
```

---

## 布局自动规则

| 图表数量 | 网格列数 |
|---------|---------|
| 1       | 全宽 1列 |
| 2       | 2列     |
| 3       | 3列     |
| 4       | 2×2     |
| 6       | 3×2     |
| 其他     | 2列     |


path: references/theme-tokens.md
content: # 主题设计规范（Theme Tokens）

## 颜色系统

| 变量名         | 值        | 用途                     |
|--------------|----------|--------------------------|
| `--bg`       | `#0f172a` | 页面背景                  |
| `--card`     | `#1e293b` | 卡片/图表背景              |
| `--border`   | `#334155` | 分割线/边框               |
| `--accent`   | `#38bdf8` | 主色调（蓝色）KPI/标题     |
| `--accent2`  | `#818cf8` | 辅色调（紫色）             |
| `--green`    | `#34d399` | 正面指标（增长/完成）       |
| `--yellow`   | `#fbbf24` | 中性指标（警告/关注）       |
| `--red`      | `#f87171` | 负面指标（下降/风险）       |
| `--text`     | `#e2e8f0` | 正文文字                  |
| `--muted`    | `#94a3b8` | 次要文字/轴标签/标题       |

## 图表调色板（PALETTE，10色循环）

```
#38bdf8  蓝
#818cf8  紫
#34d399  绿
#fbbf24  黄
#fb923c  橙
#f472b6  粉
#a78bfa  淡紫
#4ade80  浅绿
#60a5fa  天蓝
#facc15  金黄
```

## 尺寸规范

| 元素         | 规格                              |
|------------|----------------------------------|
| 图表高度     | 固定 240px（`chart-wrap`）        |
| 卡片圆角     | 12px                             |
| 卡片内边距   | 20px 22px                        |
| 网格间距     | 20px（图表）/ 16px（KPI）         |
| KPI 数值字号 | 1.85rem, font-weight: 700        |
| 图表标题字号 | 0.85rem, uppercase, color: muted |

## 字体栈

```css
font-family: 'Segoe UI', system-ui, sans-serif;
```

## 图表坐标轴风格

```js
// X 轴
grid: { color: "#1e293b" }, ticks: { color: "#94a3b8" }

// Y 轴
grid: { color: "#334155" }, ticks: { color: "#94a3b8" }
```

## 图例样式

```js
labels: { color: "#94a3b8", font: { size: 12 } }
```


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