# 仪表盘（Dashboard，.dash）

> 用 .dash 文件和 DuckDB SQL 编写 DuckLocal 仪表盘：图表、交叉筛选、交给 AI agent 处理的评论，以及 ducklocal check 校验。

Source: https://ducklocal.app/docs/zh/dashboards

**[English](../dashboards.md)**

`.dash` 文件以数据描述仪表盘：**source** 块为数据文件命名，**query** 块存放 SQL，**plot** 块绘制查询结果，**filter** 块让点击一张图就能筛选其他图。不需要编写代码，人和 AI agent 都可以编写这类文件，通过 diff 审阅，并在打开前校验。

`.dash` 文件可以根据保存的查询绘制常规图表、表格、数字和地图。纯文本格式也方便与 AI agent 协作：agent 编写或修改文件，用 `ducklocal check` 验证能否运行，你再[在图表上写评论](#review-with-comments)，提出下一步修改意见。

## 完整示例

这个仪表盘读取[教程样例文件](tutorial.md#sample-file)。把 [sales.dash](https://ducklocal.app/docs/samples/sales.dash) 下载到 `sales.csv` 所在的文件夹，再打开它。

```hcl
source "sales" {
  path = "sales.csv"
}

query "totals" {
  sql = <<SQL
    SELECT round(sum(revenue), 2) AS revenue, sum(orders) AS orders,
           count(DISTINCT region) AS regions
    FROM sales
    WHERE $channel
  SQL
}

query "by_channel" {
  sql = <<SQL
    SELECT channel, round(sum(revenue), 2) AS revenue
    FROM sales
    GROUP BY channel
    ORDER BY revenue DESC
  SQL
}

query "daily" {
  sql = <<SQL
    SELECT date, region, round(sum(revenue), 2) AS revenue
    FROM sales
    WHERE $channel
    GROUP BY date, region
    ORDER BY date
  SQL
}

plot "revenue" {
  type  = "card"
  query = query.totals
  value = revenue
  title = "Revenue"
  width = 4
}

plot "orders" {
  type  = "card"
  query = query.totals
  value = orders
  title = "Orders"
  width = 4
}

plot "regions" {
  type  = "card"
  query = query.totals
  value = regions
  title = "Regions"
  width = 4
}

plot "revenue_by_channel" {
  type  = "bar"
  query = query.by_channel
  x     = channel
  y     = revenue
  title = "Revenue by channel"
  width = 5
}

plot "daily_revenue" {
  type   = "line"
  query  = query.daily
  x      = date
  y      = revenue
  series = region
  title  = "Daily revenue, per region"
  width  = 7
}

plot "daily_table" {
  type  = "table"
  query = query.daily
  title = "The numbers behind it"
}

filter "channel" {
  plot = plot.revenue_by_channel
}
```

打开后，最上面一排是三个数字，下面柱状图和折线图并排，再下面是表格。每个 query 只执行一次，不管有多少个 plot 用它：`totals` 供给三张卡片，`daily` 同时供给折线图和表格。在 **Revenue by channel** 里点一根柱子，卡片、折线图和表格就只看这个渠道；柱状图本身保持完整，可以接着点别的柱子。

## 打开仪表盘

以下任一方式都能把 `.dash` 文件打开成仪表盘标签页，和查询标签页并列：

- 标签栏末尾的 **+**，再选 **打开 Dashboard**；
- 把文件拖到窗口上；
- 在命令行里指定：`ducklocal sales.dash`；
- 从脚本或 agent 打开到已经在运行的窗口里：`ducklocal open sales.dash`（见 [CLI 指南](cli.md#show-work-in-the-running-window)）。

plot 排在 12 列的网格上：每个 plot 占它的 [`width`](#layout) 列，从左往右排满一行，放不下的就换到下一行。拖动行之间的分隔条可以调整高度；仪表盘比标签页高时会滚动。画不出来的 plot（查询失败，或缺少某列）会在自己的格子里显示原因，其余部分照常绘制。打开的仪表盘下次启动时会恢复，最近打开过的会列在侧栏的 **仪表盘** 下。

鼠标移到 plot 上，右上角会出现操作按钮：

- **SQL** 把 plot 翻过来，显示它的查询上一次执行的 SQL（已经填好 source 和当前的筛选值，可以原样运行），附有 **复制** 和 **在查询页打开**；**图表** 再翻回去。
- 对话气泡打开这个 plot 的[评论](#review-with-comments)。
- **放大这张图** 让它占满仪表盘标签页；点 **放回看板（Esc）** 或按 **Esc** 回到网格。

SQL 视图带语法高亮。右键点击仪表盘表格的单元格，可复制该单元格、用制表符分隔的整行，或按显示顺序复制整列。

点 `table` 图表的表头可以排序：先降序，再升序，再回到查询原来的顺序。数值按数值排，`NULL` 排在最后；被[筛选](#filters)选中的行，排序后仍保持选中。

工具栏的 **查看源码** 会把标签页切换成该文件的编辑器，带语法高亮（heredoc 里的 SQL 与 SQL 编辑器同样着色）、补全和行内诊断。**保存** 或 **⌘S** 写回文件并重新绘制。如果保存的文件未通过校验，标签页会保留上一次能用的仪表盘，并在上方显示错误。其他程序修改了文件时也是如此。**返回视图** 回到图表。**重新加载** 会重读文件并重新执行所有查询。

## 用评论审阅 {#review-with-comments}

有了评论，审阅仪表盘就像审阅文档一样，还能把修改交给 AI agent。关于这套设计背后的产品思考，可阅读[《Claude Dashboards 给 DuckLocal 的启发》](https://ducklocal.app/zh/blog/claude-dashboards-ducklocal/)。

**在窗口里**，鼠标移到 plot 上，点对话气泡。仪表盘上方会弹出一张卡片：顶部引用 plot 的标题，下面是已有的讨论，再下面是输入框。写下要改的地方（比如“按收入从高到低排”），点 **评论**（或按 **⌘/Ctrl+Enter**）。每条讨论都有 **回复** 和 **解决**；解决了的讨论会变淡但仍然保留，有人回复时重新打开。有未解决讨论的 plot 会一直显示一个带数字的气泡，哪里还有事待办一眼就能看到。

**agent** 用 [`ducklocal comments`](cli.md#review-comments-on-a-dashboard) 读取并回复评论，不需要窗口：

```bash
ducklocal comments sales.dash
```

```json
{
  "file": "sales.dash",
  "comments_file": "sales.dash.comments.json",
  "open": 1,
  "threads": [
    {
      "id": "c1",
      "plot": "revenue_by_channel",
      "plot_title": "Revenue by channel",
      "plot_query": "by_channel",
      "plot_lines": [68, 75],
      "plot_missing": false,
      "resolved": false,
      "comments": [
        { "author": "user", "text": "按收入从高到低排", "at": "2026-10-10T14:03:00+08:00" }
      ]
    }
  ]
}
```

每条讨论都写明了它所在的 plot、plot 用的查询，以及 `plot` block 在文件里的起止行，agent 由此知道该改哪里。它修改规格文件，跑一遍 `ducklocal check`，然后回复：

```bash
ducklocal comments sales.dash --reply c1 --text "已按收入降序排列" --resolve
```

窗口同时盯着这两个文件。一秒之内，plot 就按改过的规格重新绘制，agent 的回复也出现在讨论里，标着 **Agent**。

评论保存在仪表盘旁边的 `名称.dash.comments.json` 里，和它的 `source` 文件一样跟着仪表盘走。它们是工作笔记，不是仪表盘的一部分：别提交进版本库，审阅完就可以删掉。

[官方 agent skill](cli.md#安装官方-skill) 教过这套流程，装了它的 agent 只要听到一句“处理 sales.dash 上的评论”就知道怎么做。

::: info 0.4.0 新增
agent 可以[提议修改](agent-workflows.md#propose)，而不是直接改文件。标签页上会出现 **审阅**，显示差异和检查结果，只有你能应用。
:::

### 看见 agent 正在做什么 {#agent-presence}

agent 对当前打开的仪表盘运行 `check`、`comments` 或 `presence` 时，角落会显示它的名字，带着它所属厂商的标志（Claude、OpenAI、Gemini、Cursor、DeepSeek），认不出的就显示首字母；点击可查看这次工作的记录。文件被修改后，改动和新增的 plot 会保持标记（**Claude 改过**、**Claude 新增**），直到你把它们放在视野里看过片刻；被删除的 plot 留下一条可手动关闭的提示。

agent 可以先运行 `ducklocal presence sales.dash --doing editing --plot revenue_by_channel`，说明接下来要改哪里。已有的 plot 会加上边框；尚不存在的名字会在仪表盘下方显示占位提示。30 秒没有活动，或显式传入 `--doing idle` 后，这次会话结束。agent 干完了、但它回复的评论还在等你回应时，它会留在角落，头像上显示等待的条数。评论会记下是哪个 agent 写的，并显示它的标志。参数见 [CLI 参考](cli.md#agent-presence)。这些标记用于展示本机活动，不会自动向 agent 派发任务。

## Block

文件由一连串 block 组成，每个 block 是类型、带引号的名字和主体：

```hcl
source "name" { ... }
query "name" { ... }
plot "name" { ... }
filter "name" { ... }
```

同一类中名字必须唯一：两个 query 不能同名，两个 plot 也不能，但 query 和 plot 之间可以同名。

### source {#source}

| 属性 | 必填 | 取值 |
| --- | --- | --- |
| `path` | 是 | 一个 `.csv`、`.tsv`、`.txt`、`.parquet`、`.json`、`.jsonl` 或 `.ndjson` 文件，或者匹配多个文件的通配符（`exports/orders-*.csv`），合起来当作一张表读 |

source 给数据文件起一个名字，供查询读取：写了 `source "sales"`，查询里就写 `FROM sales`。路径**相对于 `.dash` 文件**，而不是 DuckLocal 启动时所在的目录，所以仪表盘和数据可以一起挪；`~` 开头的路径和绝对路径也可以。

source 不会动连接：它会变成每个用到它的查询前面的一段 `WITH sales AS (…)`，所以侧栏里不会多出东西，标签页关掉也不留痕迹；同名的表只在这个仪表盘的查询里被遮住。名字必须是普通标识符（字母、数字和 `_`）。工作簿不能当 source，先在 DuckLocal 里打开它，再查询它变成的表。

### query

| 属性 | 必填 | 取值 |
| --- | --- | --- |
| `sql` | 是 | 一条只读 SQL 语句，写成 [heredoc](#heredoc) 或字符串 |

query block 只包含 `sql`。SQL 里可以用 `$name` 读取某个 [filter](#filters)。

### plot

| 属性 | 适用 | 取值 |
| --- | --- | --- |
| `type` | 全部，必填 | `"line"`、`"bar"`、`"stacked_bar"`、`"area"`、`"scatter"`、`"pie"`、`"heatmap"`、`"histogram"`、`"map"`、`"table"`、`"card"` 或 `"text"`——带引号的字符串 |
| `query` | 除 `text` 外全部必填 | 指向某个 query block 的引用：`query.by_channel` |
| `x` | 图表，必填 | 横轴上的列；`pie` 的扇区；`histogram` 要分箱的数值列。`table` 显示所有列，不需要 `x` |
| `y` | 图表，必填 | 要画的列，必须是数值类型；`pie` 各扇区的大小。`histogram` 不需要 `y` |
| `series` | `line`、`bar`、`stacked_bar`、`area`、`scatter`、`heatmap` | 按这一列的取值拆分行，每个取值一条序列；`heatmap` 的行 |
| `title` | 全部 | plot 的标题，带引号的字符串；卡片的标签 |
| `width` | 全部 | 在 12 列网格里占几列，`1`–`12`。默认 12，卡片默认 3 |
| `value` | `card` | 要显示的列，取第一行。默认第一列 |
| `note` | `card` | 数值下方的说明；带引号的字符串直接显示，裸列名则读取第一行对应列的值 |
| `tone` | `card` | `"accent"`、`"good"`、`"warning"` 或 `"critical"`，设置顶部横线与数值的颜色 |
| `format` | 图表和 `card` | 数字的含义，见[数字格式与变化](#number-formats-and-change)。`map` 和 `table` 不支持 |
| `delta` | `card` | 存放变化量的数值列，显示在数值下方 |
| `delta_format` | `card` | 变化量的格式。默认与卡片相同 |
| `down_is_good` | `card` | 设为 `true` 时，下降显示绿色、上升显示红色，适合成本、延迟、流失率 |
| `description` | 全部，**0.4.0 起** | 这张图显示什么、怎么读；鼠标移到标题旁的 ⓘ 上时显示 |
| `text` | `text` | 正文，字符串或 heredoc；`title` 与 `text` 至少写一个，不写 `query` |
| `lat`、`lng` | `map` | 纬度和经度列，单位是度。数值列的列名表明它是纬度或经度时可以不写，比如 `lat`、`latitude`、`geo_lat`，或 `lng`、`lon`、`longitude` |
| `color` | `map` | 按这一列给点着色；不写时，如果有取值不多的文本列，会自动用它 |
| `size` | `map` | 按这一数值列决定点的大小 |
| `size_scale` | `map` | `"sqrt"`（默认）或 `"log"`，决定 `size` 怎样缩放 |
| `tooltip` | `map` | 点的提示框里显示哪些列：`[name, passengers]` |

列属性指的是查询结果中的列，匹配时不区分大小写。普通列名直接写（`x = date`）；不是普通标识符的列名加引号（`y = "revenue (EUR)"`）。某种类型用不上的属性，比如折线图的 `lat`、地图的 `x`、饼图的 `series`，会被当成错误，而不是悄悄忽略。

### filter

| 属性 | 必填 | 取值 |
| --- | --- | --- |
| `plot` | 是 | 在哪张图上点选：`plot.revenue_by_channel`。只能是 `bar`、`stacked_bar` 或 `table` |
| `column` | `table` 必填 | 点选时取哪一列的值。`bar` 和 `stacked_bar` 不写时就是它的 `x` |

filter 的作用见[筛选](#filters)。

## 图表类型

| 类型 | 画出 |
| --- | --- |
| `line` | 每条序列一条线，不填充 |
| `area` | 每条序列一块填充面积 |
| `bar` | 每行一根柱子，每条序列一种颜色。带 `series` 时，每组里各序列的柱子并排。只有一条序列时，每根柱子上标出数值 |
| `stacked_bar` | 每个 `x` 一根柱子，各 `series` 堆叠：正值从零向上，负值向下，顶部和提示框里显示合计 |
| `heatmap` | `x` 横排、`series` 竖排，每个格子按 `y` 用同一种蓝色着色，值越大越深（深色模式下越浅）。查询没有返回的组合留空 |
| `histogram` | 按数值列 `x` 的区间统计行数，箱宽取整。查询要直接返回行本身，不要先分组 |
| `scatter` | 每个点一个圆点，互不相连，每条序列一种颜色 |
| `pie` | 每个 `x` 值一个扇区，大小按 `y`，从大到小；超过 8 个时最小的几个合并成"其他"。画成环形，中间显示合计；点图例项可以单独突出一个扇区，再点一次恢复 |
| `text` | 标题、说明文字，或两者兼有；不执行查询 |
| `map` | 每行一个点，位置按 `lat` 和 `lng`；见[地图](#maps) |
| `table` | 把查询结果显示为表格，列宽按内容调整；点表头排序 |
| `card` | 一个大号数字，`title` 是它的标签。按千位分组，过长的小数会舍入（`11439704.020000005` → `11,439,704.02`），放不下时缩小字号而不是截断 |

鼠标移到图表上可以查看数值，格式与坐标轴一致（显示 `4.4M`，不显示 `4400000`）。

柱状图会标出**每一个**类别。标签在柱子下面放不下时，图会横过来，标签排在左边一列。只有时间轴会抽稀标签，在不重叠的前提下尽量多标，第一个和最后一个总会标出；按 `DATE` 列画的柱状图，会把查询里没有数据的日子补成空位，一段冷清的日子看起来是个缺口，而不是直接消失。折线图、面积图和散点图的轴也这样抽稀标签。

所有图表类型的 x 轴都是分类轴：各个值按查询返回的顺序等距排开，不按数值间距排。所以用两个数值列画 `scatter`，得到的并不是真正的 x–y 散点图。

行按查询返回的顺序绘制，所以给图表供数的查询要以 `ORDER BY` 结尾。

::: info 0.4.0 新增
仪表盘里按时间画的 `line` 和 `scatter` 会按实际时间定位，间隔不均匀时看起来也不均匀，并且不管 `ORDER BY` 如何都按时间顺序画。时间为 NULL 的行不画，并有提示。查询返回的 NULL 读数会让线在那里断开。`area` 仍然等距排开，并会注明。序列按名称保持颜色；查询被截断时，每种图表都会注明。
:::

卡片显示查询的第一行。查询返回多行时，数字下面会注明；一行都没有时显示 `—`。

### 卡片与文本面板

卡片的 `note` 用来解释数值，`tone` 用颜色表达状态。同一行的卡片共享数值字号。文本面板可以添加标题或说明，不执行 SQL：

```hcl
plot "revenue_summary" {
  type  = "card"
  query = query.totals
  value = revenue
  title = "收入"
  note  = "当前选中渠道"
  tone  = "accent"
  width = 4
}

plot "reading_guide" {
  type  = "text"
  title = "如何阅读这张仪表盘"
  text  = "点击渠道，筛选每日图表和收入卡片。"
  width = 8
}
```

文本面板只接受 `type`、`title`、`text` 和 `width`。较长的说明可以写成 `text = <<TEXT`，以单独一行 `TEXT` 结束。图表、饼图和地图使用八种不同色相，并为当前主题调整明暗；多条序列会显示图例。

### 数字格式与变化 {#number-formats-and-change}

`format` 决定卡片数值、数值轴、柱子标签和提示框的显示方式：

| 取值 | 显示为 |
| --- | --- |
| `"num0"`、`"num1"`、`"num2"` | 带相应小数位的数字 |
| `"pct"`（等于 `"pct1"`）、`"pct0"`、`"pct2"` | 比例：0.421 显示为 42.1%，所以查询要返回小数，不要乘以 100 |
| `"usd"`、`"eur"`、`"gbp"`、`"cny"` | 货币，两位小数 |
| `"usd0"`、`"eur0"`、`"gbp0"`、`"cny0"`、`"jpy"` | 货币，取整 |

卡片用 `delta` 显示变化，在数值下方显示为 `▲ 12.3%` 或 `▼ $125`。向好时为绿色，否则为红色，后面的 `note` 说明是跟什么比。变化量要在卡片的查询里算好：

```hcl
plot "revenue_total" {
  type         = "card"
  query        = query.totals
  value        = revenue
  format       = "usd0"
  delta        = growth
  delta_format = "pct1"
  note         = "比上周"
}
```

### 布局 {#layout}

`width` 是 plot 在仪表盘的 12 列里占几列。plot 按写的顺序从左往右排，下一个放不下就换行，所以四张卡片（各占 `3`）排成一行数字，两张 `width = 6` 的图在下面并排。一行不满 12 列时，剩下的地方空着。全是卡片的一行起始高度较矮；全是表格的一行，起始高度刚好放下最长那张表的所有行（最多一张图的高度）；全是文本面板的一行按文字内容确定高度；其他行起始高度是一张图的高度。鼠标悬停时才显示行间的调整手柄。

### 地图 {#maps}

```hcl
plot "stations" {
  type       = "map"
  query      = query.stations
  lat        = geo_lat
  lng        = geo_lng
  color      = line
  size       = passengers
  size_scale = "log"
  tooltip    = [name, passengers]
}
```

地图把每一行画成一个点，用 Web Mercator 投影，范围自动贴合这些点，背景是标了度数的经纬网。

- **`color`**：最多五个取值各有一种颜色；取值更多时，最常见的四个各有颜色，其余共用"其他"。图例标出各颜色对应的值。不写时，如果结果里有取值不多的文本列，就按它着色。
- **`size`**：点的*面积*跟着数值走，数值翻倍，面积也翻倍。数值跨好几个数量级时用 `size_scale = "log"`。角落的图例可以读回大小对应的数值。
- **`tooltip`**：不写时，提示框先列 size 列和其他数值列，再列坐标。

点的下面可以画一层 **OpenStreetMap** 底图。它默认关闭，要在 **显示 → 在线底图（OpenStreetMap）** 里打开，因为这是 DuckLocal 唯一会自己联网的地方。发出去的只是要哪些地图瓦片，大致能看出你在看哪片区域，但不会带上任何一行数据。瓦片在磁盘上缓存一周（见[设置与应用数据](settings-and-data.md#where-things-are-stored)）；关闭或离线时，地图只画经纬网。查询结果 **图表** 标签页里的地图也受这个开关控制。

## 筛选 {#filters}

filter 让一张图可以点选，并用选中的值筛选其他查询：

```hcl
filter "channel" {
  plot = plot.revenue_by_channel     # 点一根柱子，选中它的 channel
}

filter "day" {
  plot   = plot.days                 # 表格：选中一行，取……
  column = day                       # ……这一列的值
}

query "daily" {
  sql = "SELECT date, sum(orders) AS orders FROM sales WHERE $channel AND $day GROUP BY 1"
}
```

查询里的 **`$name` 是一个条件**，写在该写条件的地方。什么都没选时它是 `TRUE`，所以仪表盘打开时和没有这个 filter 完全一样。选中一个值后，它变成 `("channel" = 'Web')`，也就是 filter 的 `column` 列等于选中的值；选中显示为 `NULL` 的单元格时，会匹配 SQL NULL 或文本 `NULL`。多个 filter 怎么组合，由查询自己的写法决定。

- **柱状图**：点一根柱子就选中它，它保持原色，其他柱子变淡。再点一次取消。
- **表格**：选中一行，点击或用方向键都可以。
- 当前生效的选择以小标签显示在所有图的上方；点标签上的 ✕ 清除这个 filter，**全部清除** 清除所有选择。
- **做选择的那张图不会被自己的 filter 筛选。** 即使选中了某个渠道，它的查询里 `$channel` 仍然是 `TRUE`；否则点一下，柱状图就只剩刚点的那根，没法再点别的。这就是交叉筛选，和 Mosaic、crossfilter 的做法一样。
- **只重跑变了的查询。** 一次选择只重跑 SQL 因此改变的查询；不读任何 filter 的查询保留原来的结果。

每个 filter 同时只保留一个值。可点选的 plot 会在标题下显示点击筛选提示；筛选导致部分分组消失时，序列仍保持原来的颜色。使用 `$channel` 的查询，必须在该条件所在的位置能访问筛选器指定的列；`ducklocal check` 还会绑定一次带选中值的查询，在第一次点击前发现缺失的列。

::: info 0.4.0 新增
点选显示为 `NULL` 的单元格时，只选中 SQL NULL；文本 `'NULL'` 也只选中它自己。可点选的图表不再在标题下显示点击筛选提示。
:::

选中的值以带引号的字面量写进 SQL，比较时由 DuckDB 转成列的类型，所以日期、数字和文本一样能用。选择在标签页开着期间一直有效，重新加载后只要 filter 还在文件里也会保留；关掉 DuckLocal 后不会记住。

## 取值

| 形式 | 示例 | 用于 |
| --- | --- | --- |
| 字符串 | `"Revenue by channel"` | `type`、`title`、`path`、`size_scale`、`tone`、字面量 `note`、`text`、带引号的列名、简短的 `sql` |
| Heredoc | `<<SQL` … `SQL` | `sql`、`text` |
| 引用 | `query.daily`、`plot.revenue_by_channel` | plot 的 `query`、filter 的 `plot` |
| 裸名字 | `channel` | 列属性 |
| 数字 | `6` | `width` |
| 列表 | `[name, "Unique IPs"]` | `tooltip` |

字符串在行尾结束，支持 `\n`、`\t`、`\"` 和 `\\`。

`#` 或 `//` 之后直到行尾都是注释。属性之间不需要分隔符；换行只是空白。

这就是全部语法：没有变量、没有函数、没有条件，除了 filter 的 `$name` 也没有别的插值。

### Heredoc {#heredoc}

heredoc 用来写跨行文本，实际上就是写 SQL：

```hcl
  sql = <<SQL
    SELECT 1
  SQL
```

- `<<` 后面的分隔符可以是任意单词（按惯例用 `SQL`），同一行里它后面不能再有别的内容。
- heredoc 在第一行内容恰好等于分隔符（允许前后有空格）的地方结束。
- 每一行文本都会去掉结束行那么多的缩进，所以 SQL 可以跟着 block 缩进，缩进本身不会带进 SQL。

## SQL 规则 {#sql-rules}

仪表盘在打开、重新加载、保存时，以及 DuckLocal 启动并恢复该标签页时都会执行查询，所以查询只能**读**：

- **允许：** `SELECT`、`WITH …`、`FROM` 开头的查询、`VALUES`、`SHOW`、`DESCRIBE`、`SUMMARIZE`、`PIVOT` 和 `UNPIVOT`。
- **拒绝：** 任何可能改动数据或文件的语句，如 `CREATE`、`INSERT`、`UPDATE`、`DELETE`、`DROP`、`COPY`、`ATTACH`、`INSTALL`、`LOAD` 等。打开别人发来的 `.dash` 文件不会改动你的数据。
- **每个 query 一条语句。**
- **嵌在别的查询里的 `PIVOT` 要写明取值。** 以 `PIVOT` 开头的查询可以让 DuckDB 自己找取值（`PIVOT orders ON channel USING sum(amount)`）；嵌在 `SELECT` 或 `WITH` 里的，要写出取值列表：`ON channel IN ('Web', 'Store')`。
- **`$name` 必须对应一个 filter block。** 只读检查时，每个 `$name` 都按 `TRUE` 处理。字符串、带引号的标识符、注释和 `$$ … $$` 字符串里的 `$` 不算。

查询在窗口自己的连接上执行，所以能看到 SQL 编辑器能看到的一切：打开的文件、挂载的数据库，以及 `TEMP` 表等连接级状态。

### SQL 里的路径

从 v0.2.2 起，仪表盘 SQL 里的相对文件路径也会从 `.dash` 所在文件夹查找。例如，从别的目录打开仪表盘时，`FROM 'sales.csv'` 可以读取仪表盘旁的 CSV。窗口和 `ducklocal check` 都如此。

对于 SQL 里直接指定的单个文件，DuckDB 仍会先检查工作目录；那里有同名文件时，可能优先读到它。[source](#source) block 会明确按仪表盘目录解析路径，可以避免这个歧义。仍可使用绝对路径或已注册的视图。

## 限制

| 限制 | 结果 |
| --- | --- |
| 每个 plot 12 条序列 | 多出的序列不画，并有提示说明原本有多少条 |
| 每条序列 1,500 个点 | 按分桶取平均压缩到上限以内 |
| 200 根柱子 | 多出的柱子不画 |
| 饼图 8 个扇区 | 最小的几个合并成一个"其他" |
| 热力图 200 列、12 行 | 多出的列和行不画 |
| 直方图 5–40 个箱 | 约为行数的平方根，箱宽是 1、2、2.5 或 5 乘以 10 的整数次幂 |
| 地图 20,000 个点 | 多出的点不画，并有提示 |

## 校验仪表盘

`ducklocal check` 不开窗口就能校验文件。把仪表盘交给别人之前，先跑一遍：

```bash
ducklocal check sales.dash
ducklocal check sales.dash --database warehouse.duckdb
ducklocal check sales.dash --summary
```

先做静态检查：语法、名字与引用、必填属性、每个 `$name` 是否有对应的 filter，以及用 DuckDB 自己的 parser 检查每个查询的 SQL。然后**真正执行每个查询**（只读，带着 source，什么都没选），核对每个 plot 用到的列是否确实是查询返回的列，包括 `y`、地图坐标和 `size` 是否为数值，以及每个 filter 的 `column` 是否是它那张图的查询返回的列。之所以要真正执行，是因为转换失败、通配符一个文件都没匹配到这类问题，要读到行时才会暴露。

- **不带 `--database`** 时在内存里执行，只读自己 `source` 文件的仪表盘这样就够了。读取了某张 source 没定义的表的查询，列在 `unresolved` 下而不算失败，它的 plot 也不核对列：它多半是写给某个数据库的。
- **带 `--database PATH`** 时，在该数据库上执行（只读打开）。查询读的是数据库里的表时，一定要带上它。

每个使用 filter 的查询，还会带一个选中值做绑定检查，不执行这个变体，以发现该查询中不存在的筛选列。有错的 block 不会阻止其余有效 block 继续检查；同一次运行会汇总规格错误和查询失败。

成功时输出 source、查询（附带结果列，以及各自返回的行数 `rows`）、filter 与 plot 的 JSON 摘要，外加 `empty`：执行了但一行都没返回的查询。这不算错误，但通常是正则、筛选条件或 join 什么都没匹配上，交出仪表盘之前逐个看一眼。`--summary` 在成功时只输出一行 JSON：数量统计，以及未解析和返回空结果的查询名。包含规格诊断时以状态 **2** 退出，一次列出所有问题，每条一行 `文件:行号: 信息`；仅有 SQL 失败、数据库或文件错误时以 **1** 退出。细节见 [CLI 指南](cli.md#校验-dashboard-规格文件)。

::: info 0.4.0 新增
`ducklocal check 文件 --restricted --allow-read 路径` 可以在受限条件下校验不是你写的仪表盘。见[限制不是你写的 SQL](agent-workflows.md#restricted)。
:::

### 常见信息

| 信息 | 解决 |
| --- | --- |
| `type is a string in quotes` | 写 `type = "line"`，而不是 `type = line` |
| `Unknown plot type: "donut"; one of line, bar, area, scatter, pie, map, table, card, text` | 使用列出的类型之一 |
| `plot block "p" requires y` | 图表类型都需要 `y`；`table`、`map`、`card` 和 `text` 不需要 |
| `plot "p" draws query "q", which the file does not define` | 补上这个 query block，或改正 `query = query.…` 里的名字 |
| `query names a query block: query = query.some_name` | `query` 属性要写引用，不是字符串 |
| `plot "p" draws y = "channel" (VARCHAR), which is not numeric` | 让 `y` 指向数值列，或改用 `type = "table"` |
| `A map plot places points by lat and lng; it takes no x` | 地图没有坐标轴：写 `lat` 和 `lng`，或者让列名来判断 |
| `width is how many of the dashboard's 12 columns the plot spans` | 写 1 到 12 之间的整数 |
| `query "q" reads $chanel, but no filter block is named "chanel"` | 改正名字，或补上这个 `filter` block |
| `filter "f" picks on plot "p", a line; a value is picked on a bar or table plot` | 让 filter 指向柱状图或表格 |
| `a dashboard query must be a single read-only statement` | 写入操作放到 SQL 编辑器里做，或者给嵌套的 `PIVOT` 用 `IN` 写明取值；见 [SQL 规则](#sql-rules) |
| `Unknown block: "card"; a card is a plot: plot "x" { type = "card" … }` | 每个面板都是 plot block，卡片和文字面板也一样 |
| `"minutes" is a SQL keyword, so as a column alias it needs AS` | 写成 `count(*) AS minutes`，或者给别名加引号 |
| `Unterminated heredoc: no line is exactly SQL` | 在单独一行写上结束分隔符 |

## 在你自己的编辑器里编辑

`ducklocal lsp` 是 `.dash` 文件的 language server，提供：输入时即时诊断；block 类型、属性、图表类型以及 `query.`、`plot.` 后面名字的补全；悬停文档；从 `query.name` 或 `plot.name` 跳到对应 block 的定义跳转。Neovim 与 VS Code 的配置见 [CLI 指南](cli.md#用-lsp-编辑-dashboard-规格文件)。
