# DataDashboard 数据格式与上传

这份文档只讲两件事：**数据该长什么样**、**怎么传上来**。
整份丢给模型，它就能生成本站直接吃得下的数据。

在线地址 `https://datadashboard-cqd.pages.dev/AI.md`，跟代码一起部署。
`scripts/aimd-test.mjs` 会把下面每个 JSON 例子真的跑一遍服务端校验，
并把文中的常量和上限逐条和代码比对 —— 文档和实现是被钉住的，不是靠「放在同一个仓库里」。

数据有两种形态，二选一：

| 形态     | 什么时候用                       | 站点怎么显示                       |
| -------- | -------------------------------- | ---------------------------------- |
| **JSON** | 有结构的数据：汇总、表格、图表   | 渲染成卡片 / 表格 / 折线 / 条形    |
| **HTML** | 你自己已经排好版的整页 HTML      | 原样显示，不套版式                 |

---

## 30 秒上手

```bash
export DD="https://datadashboard-cqd.pages.dev"
export DD_TOKEN="<后台「上传 Token」页签发的 token，明文只显示一次>"

# 有 token 但不知道项目 ID？先问一句（顺带回当前生效的上限）：
curl -s "$DD/api/v1/projects" -H "Authorization: Bearer $DD_TOKEN"

cat > data.json <<'EOF'
{
  "title": { "zh": "八月对账", "en": "August" },
  "summary": [
    { "label": { "zh": "总盈亏", "en": "PnL" }, "value": -11.88, "unit": "USDT" },
    { "label": { "zh": "笔数", "en": "Trades" }, "value": 2 }
  ],
  "notes": [ { "zh": "手续费已扣除", "en": "Fees deducted" } ]
}
EOF

curl -X PUT "$DD/api/v1/projects/<项目ID>/data" \
  -H "Authorization: Bearer $DD_TOKEN" \
  -H "content-type: application/json" \
  --data-binary @data.json
```

成功回这个，刷新看板就能看到：

```json
{ "ok": true, "projectId": "hibachi", "storedAt": "2026-08-24T09:12:03.441Z",
  "bytes": 412, "sections": 2, "format": "keyed" }
```

`sections` 是真的存下来的区块数 —— 拿它对一下自己写了几个内容字段，是一次免费的自检
（上面 `summary` + `notes` 两个字段 → 2 个区块）。
`storedAt` 是服务端盖的时间，和你自己写的 `updatedAt` 是两回事，两个都保留。

项目要先在管理台建好，token 要绑在一个有「上传」权限、并且绑定了这个项目的账号上。
`POST` 和 `PUT` 一样收，其它方法回 405。

---

# 一、JSON 格式

**按业务含义写字段，不用写 `kind`，不用包 `sections`。**
管理台编辑框和 API 上传收的是同一种，转换代码只有一份（`public/lib/keyed.js`），
所以管理台预览长什么样，脚本传上去就是什么样。

```json
{
  "title": { "zh": "八月对账", "en": "August Reconciliation" },
  "subtitle": "截至 8-24 00:00 UTC",
  "summary": [
    { "label": { "zh": "总盈亏", "en": "PnL" }, "value": -11.88, "unit": "USDT" },
    { "label": { "zh": "胜率", "en": "Win rate" }, "value": 0.62, "type": "percent" }
  ],
  "charts": [
    { "label": "净值", "unit": "USDT",
      "points": [ { "x": "2026-08-23", "y": 100 }, { "x": "2026-08-24", "y": 112.5 } ] }
  ],
  "bars": { "手续费": 4.5, "滑点": 0.06 },
  "list": {
    "title": { "zh": "明细", "en": "Details" },
    "columns": [
      { "key": "acct", "label": { "zh": "账号", "en": "Account" } },
      { "key": "pnl",  "label": { "zh": "盈亏", "en": "PnL" } }
    ],
    "rows": [
      { "acct": "A-1", "pnl": -6.84 },
      { "acct": "A-2", "pnl": -5.04 }
    ]
  },
  "groups": [ { "title": "账号1", "items": { "余额": 29.02, "保证金": 3 } } ],
  "notes": [ { "zh": "手续费已扣除", "en": "Fees deducted" } ]
}
```

## 顶层字段

| 字段        | 转成    | 说明                                                   |
| ----------- | ------- | ------------------------------------------------------ |
| `title`     | 标题    | 字符串或 `{zh,en}`，不超过 200 字符。不写就用项目名    |
| `subtitle`  | 副标题  | 同上，一般写数据口径或截止时间                         |
| `summary`   | `cards` | 顶部汇总卡片                                           |
| `charts`    | `line`  | 折线图，一张或一组                                     |
| `bars`      | `bars`  | 条形图                                                 |
| `list`      | `table` | 一张表格                                               |
| `lists`     | `table` | 多张表格。和 `list` 可同时给，`list` 排前面            |
| `groups`    | `kv`    | 分组键值明细                                           |
| `notes`     | `text`  | 说明段落                                               |
| `blocks`    | 原样    | 逃生口：自己写区块、自己排顺序（见文末）               |
| `alerts`    | 告警条  | 每项 `{level, text, detail}` 或一个字符串              |
| `template`  | 版式    | `free` / `summary-table` / `kpi-charts`                |
| `updatedAt` | —       | 你自己的业务时间，原样保留                             |

内容字段（`summary` / `charts` / `bars` / `list` / `groups` / `notes` / `blocks`）
**至少要有一个**，否则报「没有可显示的内容」。最多 200 个区块。

**列类型不用写**，由整列的值推断，要覆盖再写 `type`（见「列类型」）。

**区块顺序是固定的**，和你写字段的先后无关：

```text
summary → charts → bars → list / lists → groups → notes → blocks
```

因为 JSON 对象的键序在多数语言里不保证（生成端 dump 一次就可能重排）。
要换顺序就换 `template`，或者全部写进 `blocks`。

**文本字段**（`title` / `label` / `notes` 里的每项…）都接受两种写法：
字符串 `"明细"`，或双语对象 `{"zh":"明细","en":"Details"}`。
双语对象里可以只写一种，缺的会回退到另一种。

**颜色**只接受白名单里的名字，不接受 `#rrggbb` 和 `rgb()`：
`fee` `slip` `good` `bad` `accent` `warn` `dim` 以及 `a`–`f`。
写别的会退回主题色（主题有四套，含高对比，硬编码颜色必然在某套下看不清）。

**不认识的顶层字段不会让上传失败**，但管理台预览会把它们列出来 —— 那些内容不显示。
这一条是防「把 `summary` 写成 `summaries`」：那会让整块静默消失。

## 各字段怎么写

### summary — 顶部汇总卡片

三种写法都收：

```json
{ "title": "汇总三写法A",
  "summary": [ { "label": { "zh": "总盈亏" }, "value": -11.88, "unit": "USDT", "note": "已扣手续费" } ] }
```

```json
{ "title": "汇总三写法B", "summary": [ ["总盈亏", -11.88, "USDT"], ["笔数", 2] ] }
```

```json
{ "title": "汇总三写法C", "summary": { "总盈亏": -11.88, "笔数": 2 } }
```

每项可写 `label` `value` `unit` `note` `type` `digits`。

### list / lists — 表格

```json
{ "title": "表格例子",
  "list": {
    "title": "明细",
    "columns": [
      { "key": "sym", "label": "合约" },
      { "key": "pnl", "label": "盈亏", "type": "number", "digits": 4, "signed": true, "colored": true, "total": true }
    ],
    "rows": [ { "sym": "BTC/USDT-P", "pnl": -6.84 }, { "sym": "ETH/USDT-P", "pnl": -5.04 } ]
  } }
```

- `columns` 可以不写：从 `rows` 的键推出来，顺序按第一次出现的顺序。
- `rows` 用对象数组（推荐），也收二维数组（第一行当表头）。
- `totals` 写 `false` 关掉合计行，或写对象只覆盖某几列。
- 多张表用 `lists: [ … ]`。

单元格可以旁挂一个 `<键>_pill` 字段给它加个色标，比如 `{"dir":"多","dir_pill":"long"}`。
可用的值：`good` `bad` `warn` `info` `ok` `err` `long` `short`。写别的值得到一个无色标签。

### 列类型

**默认不用写。** 数字／整数／百分比／小数位由整列的值推断，要覆盖再写 `type`：

| `type`    | 显示成                                   |
| --------- | ---------------------------------------- |
| `int`     | 整数，带千分位                           |
| `number`  | 小数，`digits` 定位数，`signed` 带正负号 |
| `percent` | 百分比 —— **值给 0–1 的小数**            |
| `date`    | 日期                                     |
| `time`    | 日期时间                                 |
| `bool`    | ✓ / ✗                                    |
| `string`  | 原样文本，不右对齐、不参与合计           |

### charts — 折线

```json
{ "title": "折线例子",
  "charts": [ { "label": "账号1", "unit": "USDT", "digits": 2,
                "points": [ { "x": "2026-08-23", "y": 100 }, { "x": "2026-08-24T12:00:00Z", "y": 112.5 } ] } ] }
```

`x` 收 **ISO 日期字符串、秒级时间戳、毫秒时间戳**，服务端统一换算。
`y` 只收数字。个别坏点跳过，一个都认不出来才报错（通常是字段名写错了）。
也可以直接给一串点：`"charts": [ {"x":…,"y":…}, … ]`。

### bars — 条形

```json
{ "title": "条形例子",
  "bars": { "title": "成本对比", "unit": "USDT", "stacked": true,
            "series": [ { "label": "手续费", "color": "fee" }, { "label": "滑点", "color": "slip" } ],
            "rows": [ { "label": "账号1", "values": [4.5, 0.8] }, { "label": "账号2", "values": [3.1, 0.4] } ] } }
```

只有一组数时可以简写成扁平映射：`"bars": { "手续费": 4.5, "滑点": 0.06 }`。

### groups — 分组键值明细

```json
{ "title": "分组例子",
  "groups": [ { "title": "账号1", "subtitle": "0x1234",
                "items": { "余额": 29.0188, "保证金": 3 } } ] }
```

`items` 和 `summary` 收的写法完全一样（对象数组 / 二维数组 / 扁平映射）。

### notes — 说明段落

```json
{ "title": "说明例子", "notes": [ { "zh": "手续费已扣除", "en": "Fees deducted" }, "第二段" ] }
```

### alerts — 告警条

渲染在所有内容之前。

```json
{ "title": "告警例子", "summary": { "余额": 1 },
  "alerts": [ "日终对账未完成", { "level": "bad", "text": "账号 3 已停止", "detail": "可选详情" } ] }
```

`level` 是 `warn`（默认）/ `bad` / `info`。
服务端对 `alerts` 只校验一件事：**它是不是数组**。里面的 `null` 和缺 `text` 的元素
存得进去但渲染时被跳过 —— 这是唯一一处「上传成功但可能一条都不显示」的字段。

## 字段同义词

写顺手的那个就行：

- `title` = `name` = `标题`
- `subtitle` = `sub` = `副标题`
- `summary` = `preview` = `cards` = `header` = `kpi` = `overview` = `汇总` = `预览`
- `charts` = `chart` = `lines` = `line` = `trend` = `trends` = `折线` = `趋势`
- `bars` = `bar` = `compare` = `comparison` = `条形` = `对比`
- `list` = `table` = `detail` = `details` = `列表` = `明细`
- `lists` = `tables` = `列表组`
- `groups` = `group` = `kv` = `kvs` = `分组`
- `notes` = `note` = `text` = `remark` = `remarks` = `备注` = `说明`
- `blocks` = `sections` = `raw` = `区块`
- `rows` = `data` = `items` = `records` = `行` = `数据`
- `columns` = `cols` = `fields` = `列` = `字段`
- `totals` = `total` = `合计`

只在块内部有意义的名字（`points` `series` `items` `unit` `values` `key` `label`）
写在顶层会被报成「不认识的字段」—— 顶层一个 `"points": […]` 什么都不显示。

---

# 二、HTML 格式

**自己排好版的整页 HTML，站点原样显示。** 不套模板、不做转换、不重排。
适合「已经有一份生成好的报表页面」的情况。

两种传法。JSON 包一层：

```json
{ "kind": "html", "title": "八月对账", "html": "<!doctype html><meta charset=\"utf-8\"><h2>八月对账</h2><table><tr><td>A-1</td><td>-6.84</td></tr></table>" }
```

或者直接把 HTML 当请求体发，**`content-type` 写 `text/html`**：

```bash
curl -X PUT "$DD/api/v1/projects/<项目ID>/data" \
  -H "Authorization: Bearer $DD_TOKEN" \
  -H "content-type: text/html; charset=utf-8" \
  --data-binary @report.html
```

这时 `title` 自动用项目名。成功回 `"format": "html"`：

```json
{ "ok": true, "projectId": "hibachi", "storedAt": "2026-08-24T09:12:03.441Z",
  "bytes": 8213, "sections": 0, "format": "html", "htmlBytes": 8102 }
```

字段：`kind` 必须是 `"html"`；`title` 必填（字符串或 `{zh,en}`，≤200 字符）；
`html` 必填，非空字符串；`subtitle` 可选。

## 生成 HTML 前必须知道的三件事

页面是放在**沙箱 iframe** 里显示的（`sandbox="allow-scripts"`，没有 `allow-same-origin`）。
这是安全边界：上传的 HTML 可以带任意脚本，直接插进主文档会把站点的登录会话交出去。
代价是三条硬限制，不知道的话生成出来的页面在本地打开好好的，传上去是一片空白：

1. **外链脚本和 CDN 加载不了。** 站点 CSP 会拦掉 `<script src="https://cdn…">`。
   要用图表就自己画（内联 SVG / Canvas），或者干脆用 JSON 格式让站点渲染。
   内联 `<script>` 和内联 `<style>` 可以跑。
2. **拿不到 cookie、localStorage、父页面。** 沙箱里 `document.cookie` 直接抛
   `SecurityError`。别写依赖这些的逻辑。
3. **上限 2 MB**（`html` 字段本身）。图片用 `data:` URI 内嵌很容易撞到这个上限，
   大图放外链 `https://` 地址。

高度由站点自动适配（会往你的 HTML 末尾追加一小段上报脚本），不用自己设 `height`。
`<!doctype html>`、`<meta charset="utf-8">` 都建议写上，整页结构照常写。

## 两种形态可以互换

同一个项目今天传 JSON、明天传 HTML 都行 —— 存在同一个位置，历史和回滚照旧，
切换后不会留上一种形态的残留字段。

---

# 三、上传方式

数据都一样，区别只在谁来验、有没有历史。

**1. API。** `PUT /api/v1/projects/{id}/data`，见下。

**2. 管理台粘贴。** 登录后台 → 选项目 → 编辑数据，三个入口：

| 入口       | 收什么                        |
| ---------- | ----------------------------- |
| 字段 JSON  | 上面第一部分那种 JSON         |
| 表格       | CSV / TSV 文本，或 `.xlsx` 文件 |
| HTML       | 整页 HTML 源码                |

粘完先「校验」，给出预览和解析统计（几张卡、几条折线、几张表、多少行；HTML 则是
一个和线上完全一致的预览），确认了再上传。拖 `.html` 文件进去会自动切到 HTML 模式。

**3. Cloudflare 控制台直接拖 R2。** 往 `p/<项目ID>/latest.json` 拖文件，网站立刻生效 ——
R2 是唯一真相源。但**这条路要的是服务端整理后的结构**（顶层字段 + `sections` 数组，
或者 `{kind,title,html}`）：没有服务端参与就没人替你整理。它也不校验、不写历史，
格式错了页面就是空的。除非在恢复一份从 `GET .../data` 下载下来的数据，否则走前两条。

R2 里一个项目长这样：

```text
p/<项目ID>/latest.json                              当前数据，页面读的就是它
p/<项目ID>/history/2026-08-24T09-12-03-441Z.json    每次上传留一份
```

历史文件名里的 `:` 和 `.` 换成了 `-`（有些 S3 兼容工具处理冒号不方便）。
前两种方式都**先写历史再写 `latest`** —— 万一中途失败，`latest` 还是上一个完整版本。

## 认证

三种凭证，各管一段：

| 凭证            | 怎么拿                                 | 用在哪                          | 有效期     |
| --------------- | -------------------------------------- | ------------------------------- | ---------- |
| API token       | 后台「上传 Token」页签发，明文只回一次 | `Authorization: Bearer <token>`，**写** | 可设过期，可吊销 |
| 访客会话 cookie | `POST /api/v1/login`                   | **读**数据、看板                | 12 小时    |
| 管理会话 cookie | `POST /api/admin/login`                | 管理台                          | 2 小时     |

token 只能写它绑定的项目，写别的项目回 **404 而不是 403** —— 403 等于承认
「这个项目存在，只是你没权限」，那就把项目 ID 变成了可枚举的东西。

## 接口

脚本要用的只有这四个。所有响应都是 `application/json; charset=utf-8`，
成功 `{"ok":true, …}`，失败 `{"ok":false,"error":"一句中文"}`。

### GET /api/v1/projects — 这把 token 能写哪些项目，当前上限是多少

**只有 token、不知道项目 ID 的时候先打这个。**

```bash
curl -s "$DD/api/v1/projects" -H "Authorization: Bearer $DD_TOKEN"
```

```json
{
  "ok": true, "username": "bot", "label": "对账脚本", "caps": ["write"],
  "projects": [
    { "id": "hibachi", "name": "Hibachi 账号看板",
      "template": "summary-table", "hasData": true,
      "updatedAt": "2026-08-24T09:12:03.441Z" }
  ],
  "spec": {
    "format": "keyed",
    "templates": ["free", "summary-table", "kpi-charts"],
    "sectionKinds": ["cards", "table", "bars", "line", "kv", "text"],
    "maxUploadBytes": 10485760, "maxSections": 200, "maxTitleLen": 200,
    "html": { "kinds": ["json", "raw"], "maxBytes": 2097152,
              "sandboxed": true, "externalScripts": false },
    "docs": "/AI.md"
  }
}
```

- `spec` 是当前部署真实生效的限制 —— 硬编码在客户端里的数字会过期，这里的不会。
  生成 HTML 前读一眼 `spec.html`：`externalScripts: false` 就是上面第 1 条。
- `template` 是项目设置里定的，**它压过数据里写的 `template`**。项目没设时是 `null`。
- `hasData: false` 的项目 `GET .../data` 回 404，那是「还没上传过」。

### PUT / POST /api/v1/projects/{id}/data — 上传

请求体：JSON 格式（`content-type: application/json`）、
或裸 HTML（`content-type: text/html`）。

- 请求体上限 **10 MB**；HTML 内容上限 **2 MB**。
- 每次上传都留一份历史，可在管理台回滚。
- token 出问题时状态码分两种：**401** 是「这串东西不认识」（打错了、少复制了一截），
  **403** 是「认识，但不给用」（被吊销、已过期、账号被停用）。
  脚本里区分这两个比重试有用。
- 不限流 —— 它认的是 token，不是可穷举的口令。

### GET /api/v1/projects/{id}/data — 读取

要访客会话 cookie，**不接受 Bearer token**（token 是写凭证）。

```bash
curl -c jar.txt -X POST "$DD/api/v1/login" \
  -H "content-type: application/json" \
  -d '{"username":"<用户名>","password":"<口令>"}'

curl -b jar.txt "$DD/api/v1/projects/my-project/data"
```

回的是存下来的字节原样（附一个 `x-stored-at` 响应头）。JSON 形态回的是整理后的结构：
顶层字段照原样，内容整理成了 `sections` 数组；HTML 形态回的就是 `{kind,title,html}`。
项目还没有数据回 404。

**读回来的可以直接改改再传回去**，不用换字段名 —— `sections` 和 `blocks`
是同义词，上传接口都认。恢复一份旧数据就是这么做的。

### POST /api/v1/login — 登录

```json
{ "username": "alice", "password": "<口令>" }
```

成功带 `Set-Cookie`，响应体里有能力位和项目列表。失败一律 401「用户名或密码错误」。
登录类接口有爆破锁定：同一用户名连错 5 次、同一 IP 连错 20 次，锁 15 分钟，
回 **429** 并带 `retryAfter`（**还剩多少秒**）—— 要重试就读这个值，别解析中文文案。

---

# 四、常见错误

| 码  | 意思             | 常见原因                                                          |
| --- | ---------------- | ----------------------------------------------------------------- |
| 400 | 请求有问题       | JSON 语法错、`content-type` 不对、字段不合规                      |
| 401 | 没有有效凭证     | 缺 `Authorization` 头、token 无效、cookie 过期、口令错            |
| 403 | 有凭证但不让做   | 账号停用 / 过期 / 缺能力位 / token 被吊销                         |
| 404 | 找不到或没权限   | 项目 ID 错、该账号没绑这个项目、项目还没有数据                    |
| 405 | 方法不对         | 对上传地址发 DELETE 之类                                          |
| 413 | 请求体太大       | 超过 10 MB，或 HTML 超过 2 MB                                     |
| 429 | 被限流锁着       | 看 `retryAfter`                                                   |

| 文案                                       | 原因                                                          |
| ------------------------------------------ | ------------------------------------------------------------- |
| `title 必填，须是字符串或 {zh,en} 对象`    | 忘了写 `title`，或写成了数组／数字                            |
| `没有可显示的内容：至少要有 …`             | 一个内容字段都没有（只有 `title` 不算）                       |
| `最外层要是一个 JSON 对象`                 | 传了 `null`、数组或裸标量                                     |
| `sections 必须是数组（可以为空数组）`      | 写了 `sections` 但它不是数组。这个名字等同于 `blocks`         |
| `content-type 必须是 application/json`     | 传 HTML 要写 `text/html`                                      |
| `kind 必须是 "html"`                       | HTML 形态的 `kind` 拼错了                                     |
| `html 必须是字符串（整页 HTML 源码）`      | `html` 写成了对象或数组                                       |
| `html 是空的，没有可显示的内容`            | `html` 是空串或只有空白                                       |
| `第 N 个列表缺少 rows`                     | 那个列表对象里没有 `rows`                                     |
| `rows[N] 必须是对象`                       | 一堆对象里混进了一个数组或数字                                |
| `columns[N] 缺少 key`                      | `columns` 里有一项只写了 `label`                              |
| `第 N 张折线图没有可用的点`                | 点里的 `x` / `y` 字段名写错了，一个都没认出来                 |
| `bars 缺少 rows`                           | 既没写 `rows`，值又不全是数字                                 |
| `项目不存在或该账号未绑定此项目`           | 项目 ID 错，或这个 token 的账号没绑它。两种都是 404           |
| `template 无效（可用：…）`                 | 模板 id 打错，报错里会列出可用的                              |

**不报错但结果不对**的几种：

| 现象                       | 原因                                                                    |
| -------------------------- | ----------------------------------------------------------------------- |
| 整块内容不见了             | 顶层字段名拼错（`summaries`、`tabel`）—— 管理台预览会列在「不认识的字段」里 |
| 有一列没显示出来           | 写了 `columns`，但那个键不在里面                                        |
| 数字没右对齐、没参与合计   | 这一列混了非数字文本。空值写 `null` 或留空，不要写 `-`、`N/A`           |
| 告警条一条都没显示         | `alerts` 里的元素是 `null` 或没有 `text`                                |
| 颜色没生效                 | `color` 写了白名单外的值，退回主题色了                                  |
| 版式不是模板说的样子       | 项目设置里的模板优先于数据里的 `template`                               |
| 折线只剩一行图例，没有曲线 | 在 `blocks` 里写 `line`，把 `x` 写成了日期字符串。那儿必须是数字毫秒，用 `charts` 字段就没这个问题 |
| 折线是一条直线             | `x` 全部落在了同一个值上，检查时间字段的写法                            |
| HTML 页面空白 / 图表没画出来 | 用了外链脚本或 CDN，被 CSP 拦了。见「生成 HTML 前必须知道的三件事」    |

---

# 附：blocks 逃生口

**只在需要自己排区块顺序、或者要恢复一份读回来的数据时用它。** 平时写业务字段就够了。

`blocks` 里的区块按你写的顺序原样排，每项要有 `kind`，取值只能是
`cards` / `table` / `bars` / `line` / `kv` / `text`。内部字段不做校验。

| kind    | 装什么       | 主要字段                                                                     |
| ------- | ------------ | ---------------------------------------------------------------------------- |
| `cards` | 顶部汇总卡片 | `items[]`：`label` `value` `unit` `note` `type` `digits`                      |
| `table` | 表格         | `columns[]`、`rows[]`、`totals`、`searchable` `sortable` `exportable`         |
| `line`  | 折线         | `charts[]`：`label` `subtitle` `unit` `digits` `color` `points[]`             |
| `bars`  | 条形         | `rows[]`、`series[]`、`stacked` `unit` `digits`                               |
| `kv`    | 分组键值明细 | `groups[]`：`title` `subtitle` `items[]`                                      |
| `text`  | 说明段落     | `paragraphs[]`                                                               |

```json
{
  "title": "自排顺序",
  "blocks": [
    { "kind": "text", "paragraphs": ["这段被排到了最前面"] },
    { "kind": "cards", "items": [ { "label": "余额", "value": 29.0188, "type": "number", "digits": 4 } ] }
  ]
}
```

**`blocks` 里 `line` 的 `points[].x` 必须是数字毫秒**，`"2026-08-24"` 这种字符串不行 ——
渲染走 `Number(x)`，ISO 字符串变 `NaN` 后那个点被静默丢掉，剩不到两个点就只画出
一行图例，没有曲线。**上传会成功，页面也不报错，图就是没了。**
用 `charts` 字段就没这个问题（它收 ISO 日期）—— 这是 `blocks` 唯一比业务字段难用的地方。

---

# 附：给模型的提示词

**JSON：**

> 把下面的数据整理成 DataDashboard 的 JSON（规范见 https://datadashboard-cqd.pages.dev/AI.md ）：
> 顶层用 `title` / `subtitle` / `summary` / `charts` / `bars` / `list` / `groups` / `notes`，只写用得上的；
> 所有标题写成 `{"zh":…,"en":…}` 双语对象；
> `list.rows` 用对象数组，键名用英文短词，中文名放在 `columns[].label` 里；
> 折线的点写 `{"x":"2026-08-24","y":123.4}`，x 用 ISO 日期；
> 金额保留两位小数，比率用 0–1 的小数并把列的 `type` 写成 `percent`；
> `color` 只能用 `fee` `slip` `good` `bad` `accent` `warn` `dim` `a`–`f`；
> 不要写 `kind`，不要包 `sections`，不要自己算合计行。
> 只输出 JSON，不要加解释文字和代码块标记。

**HTML：**

> 把下面的数据做成一个整页 HTML 报表，会显示在 DataDashboard 上
> （规范见 https://datadashboard-cqd.pages.dev/AI.md ）：
> 写完整的 `<!doctype html>` 和 `<meta charset="utf-8">`；
> 样式全部内联写在 `<style>` 里，**不要引用任何外链脚本或 CDN**（会被 CSP 拦掉）；
> 需要图表就用内联 SVG 自己画；
> 不要用 cookie、localStorage 或访问父页面（在沙箱里会抛错）；
> 不要设固定 `height`，高度由站点自动适配；
> 整页控制在 2 MB 以内，大图用外链 `https://` 地址而不是 `data:` 内嵌。
> 只输出 HTML。
