> ## Documentation Index
> Fetch the complete documentation index at: https://niceeval.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 编写自定义报告

> 用 defineReport、内置报告组件和双面组件编写一份同时用于 niceeval show 与 niceeval view 的自定义报告。

[查看结果](/docs/zh/tutorials/viewing-results)介绍 `niceeval show` 的终端输出和 `niceeval view` 的网页输出。本教程用相同的结果数据编写考试成绩单、代码行数表和质量 × 成本图等自定义报告。

一份报告对应一个报告文件：一棵由内置组件搭起来的树。`niceeval show` 和 `niceeval view` 打开结果、选出当前范围（Scope），交给这棵树渲染。把文件路径传给 `--report`，同一份定义在终端和网页都能渲染。自定义报告同样支持 Attempt 证据链接、`--results` 指定结果根和静态导出。

## show / view 的默认报告也是一份报告文件

`niceeval show` / `view` 不传 `--report` 时渲染的默认报告不是私有实现，而是包里自带的一个普通报告文件：报告、Attempts、追踪三个导航页，加一个不进导航、按 Attempt 定位符打开的详情页，每页都由 `niceeval/report` 公开导出的组件搭成。

这份默认报告本身以 `standard` 为名从 `niceeval/report/built-in` 导出。只想在默认报告上加站点标题、GitHub 链接或统计脚本时，用 `extends` 在它上面叠自己的外壳，页面内容一行不用写；NiceEval 升级带来的页面改进也会自动跟过来：

```tsx theme={null}
// reports/branded.tsx —— 默认报告整站 + 自己的标题和链接
import { defineReport } from "niceeval/report";
import { standard } from "niceeval/report/built-in";

export default defineReport({
  extends: standard,
  title: "Memory Evals",
  links: [{ label: "GitHub", href: "https://github.com/you/repo" }],
});
```

`niceeval/report/built-in` 是内置报告的集合，每份一个名字；今天只有 `standard`，以后新增的内置报告也从这里按名字导入。想改页面内容本身，用同一批公开组件自己搭——你的报告文件能逐字写出同样的首页，也能只留自己要的部分：

```tsx theme={null}
import { ExperimentComparison } from "niceeval/report";

<ExperimentComparison />
```

`ExperimentComparison` 就是默认首页：一行组件直接对当前 Scope 计算范围摘要、成本 × 端到端成功率散点图和实验明细表，不需要额外取数。

|       | 网页（人看）                                    | 终端（agent 和你看）                             |
| ----- | ----------------------------------------- | ----------------------------------------- |
| 官方默认  | 分组比较报告（网页面）                               | 同一分组比较报告（文本面）                             |
| 自定义报告 | `niceeval view --report reports/exam.tsx` | `niceeval show --report reports/exam.tsx` |

自定义报告分为三个层次：

1. **调整布局**：用内置组件和 `Row` / `Col` 重新排列版面，需要自己分组或过滤时写一个组合组件。
2. **换口径**：`defineMetric` 定义自己的指标，`flag()` / `label()` / `runConfig()` 定义自己的分组和坐标轴。
3. **自定义组件**：表格用 `<Table>` 自定义列，其它展示用 `defineComponent` 分别实现网页和终端渲染。

## 创建报告文件

先交代唯一的前置：报告文件是 `.tsx`，写它的项目要装 `react`（写自定义组件的 web 面还要 `@types/react`），tsconfig 里 `compilerOptions.jsx` 设为 `"react-jsx"`。裸跑 `niceeval show` / `niceeval view` 不需要这些——只有自己写报告文件才需要。

用 `defineReport` 声明一棵树。宿主打开结果目录(包括 `--results` 指定的结果根)，按默认规则选出当前 Scope,再把这份 Scope 交给树里每个组件——组件自己知道怎么从 Scope 取数,报告文件不需要手工传参:

```tsx theme={null}
// reports/exam.tsx —— 终端和网页共用一份定义
import {
  defineReport, Col, Section,
  ExperimentComparison, Scoreboard, examScore,
} from "niceeval/report";

export default defineReport(
  <Col>
    <ExperimentComparison />
    <Section title="考试成绩单">
      <Scoreboard
        rows="agent"
        questions={["security/sql-injection", "correctness/retry"]}
        score={examScore}
      />
    </Section>
  </Col>,
);
```

```bash theme={null}
niceeval show --report reports/exam.tsx    # 终端：同一棵树走文本面
niceeval view --report reports/exam.tsx    # 网页：渲染网页面，Attempt 链接进入证据页
```

`ExperimentComparison`、`Scoreboard` 这类组件省略 `input` 时都默认吃宿主选出的当前 Scope——不用在报告文件里手工取数再喂给它们。Scope 的挑选规则是：对每个实验、每道评估用例，取该实验历史运行里最新的那次判定；只按前缀重跑了一部分评估用例时，其余评估用例的判定从更早的运行补齐,不会因为一次局部重跑就整体退回某一份残缺快照。

默认挑法不合口径,或者要按子集分别展示时,不能在报告树里直接写 JavaScript——树只负责声明,取数发生在组合组件里。用 `defineComponent` 写一个组合组件,函数体里能拿到 `ctx.scope`(当前 Scope)和 `ctx.results`(结果根的完整读取面,取历史快照用它),用普通 JavaScript 加工后再传给下游组件:

```tsx theme={null}
import { ScopeSummary, Section, defineComponent } from "niceeval/report";

const ProdSummary = defineComponent((_props: {}, ctx) => (
  <Section title="生产实验">
    <ScopeSummary input={ctx.scope.filter((s) => s.experimentId.startsWith("prod/"))} />
  </Section>
));
```

命令行的范围先作用在挑选上,组件拿到的就是收窄到这个范围后的 Scope:位置参数的评估用例 id 前缀收窄 Scope 覆盖的评估用例(覆盖提醒的分母同样收窄到范围内),`--results` 把结果根换成指定目录,`--exp` 让 Scope 只留该实验。`--history` 与 `--report` 互斥——趋势在报告里用 `ctx.results` 自己组织;证据视图(`--source` / `--execution` / `--diff`)只看证据,不渲染报告。

报告树里的组件有两种数据形态。像上面这样省略 `data`、直接传计算选项(`rows`、`columns`、`questions` 这类)是 spec 形态,组件自己在渲染前取数;需要先用 JavaScript 过滤或加工时,改用组件配套的 `xxxData(scope, options)` 函数手工取数,再把结果传给 `data` prop——两种写法产出完全相同,选哪种只看要不要在取数和渲染之间插入自己的逻辑。完整的双形态契约和每个组件的字段见[报告组件](/docs/zh/reference/report-components)。

选择警告(`ScopeWarnings`)组件显示覆盖不全、快照过期、运行未完成和快照读取失败等提醒,按实验分组,组头列出实验名、问题标签和可复制的重跑命令,每条原文位于可展开区域中。默认报告的每一页都包含该组件;自定义报告需要显示提醒时,在页首添加 `<ScopeWarnings />`。

页面里的每个组件都是**双面**的:网页面是 React 渲染,终端面是字符渲染,两面吃同一份算好的数据。实体列表按 experiment → Eval → Attempt 展示事实;指标表、矩阵、条形图、成绩单、散点图、趋势图和差异表展示聚合值。完整清单见[报告组件](/docs/zh/reference/report-components)。网页面的实体、格子和点深链到 Attempt 详情,终端面印出对应的 `niceeval show <eval id>` 下钻命令。

默认报告没有特权:它的四个页面全部由公开组件搭成。需要同样的当前 Scope 比较就写 `<ExperimentComparison />`;要子集就在报告里先 `filter`,或从 CLI 用 `--exp` 收窄。

## 排版:内置排版原语

排版原语也是双面组件,同一份布局会分别渲染到网页和终端:

* **`<Col>`**:纵向依次排列——网页是块级堆叠,终端是逐块输出。
* **`<Row>`**:并排——网页是横向排布,终端是字符分栏;终端宽度不够时自动降级为纵向,不硬挤。
* **`<Grid columns={N}>` / `<Stat>`**:自由摘要格,一格一个 label-value 卡片;`columns` 是网页宽屏下最多摆几列,窄屏和终端都会自动减列,不丢格。见下文「自由摘要格」。
* **`<Section title="…">`**:带标题的块,网页是标题层级,终端是标题行加缩进;可选 `meta` 是标题行右侧的短元信息,网页同行右对齐,终端放不下时换行。
* **`<Text>`**:说明文字,网页是段落,终端是折行文本。
* **`<Table>`**:自定义列的表——网页是 `<table>`,终端是按显示宽度对齐的字符表,中文列不撕歪。
* **`<Tabs>` / `<Tab title="…">`**:一页里的并列视图,网页是可切换的 tab 条,终端按顺序全部输出——tab 是浏览状态,不是页面,内容多到终端读不动时是升级成单独页面的信号。

```tsx theme={null}
<Row>
  <Scoreboard rows="agent" questions={["security/sql-injection", "correctness/retry"]} score={examScore} />
  <MetricTable rows="agent" columns={[endToEndPassRate, costUSD]} />
</Row>
```

```text theme={null}
$ niceeval show --report reports/exam.tsx
考试成绩单                          │  agent  pass  cost
agent  总分       security  correctness │  bub    87%   $0.42
bub    86.5/100   45/50    41.5/50  │  codex  80%   $0.51
```

页面树里只放双面组件和排版原语,不放裸 HTML 标签——终端面没法渲染一个 `<div>`。要自由内容,说明文字用 `<Text>`,更自由的走下面的自定义组件。

## 按子集分组:组合组件 + filter

需要按目录前缀把 Experiment 分成几组、每组单独摆一块摘要时,不需要专门的分组组件——写一个组合组件,在 `ctx.scope` 上 `filter`,把收窄后的 Scope 交给 `ScopeSummary`:

```tsx theme={null}
// reports/groups.tsx —— 按 experiment id 前缀分组,每组一块 ScopeSummary
import { Col, ScopeSummary, Section, defineComponent, defineReport } from "niceeval/report";

const GroupBlocks = defineComponent((_props: {}, ctx) => {
  const prefixes = ["agents/codex/", "agents/claude/"];

  return (
    <Col>
      {prefixes.map((prefix) => (
        <Section key={prefix} title={prefix}>
          <ScopeSummary input={ctx.scope.filter((s) => s.experimentId.startsWith(prefix))} />
        </Section>
      ))}
    </Col>
  );
});

export default defineReport(<GroupBlocks />);
```

```text theme={null}
$ niceeval show --report reports/groups.tsx
agents/codex/
Pass rate 60% · 2 experiments · 6 evals · failed 1 · errored 1 · $1.50
```

在分组块后面加散点图或 Experiment 列表时,把同一份收窄后的 Scope 当 `input` 传给它们,或者用 `await experimentListData(scoped)` 拿到可自行过滤的数组。需要按其它维度(不是路径前缀)分组比较,用下文的 `MetricTable` 加自定义维度更直接。

## 换口径:自定义指标

实验、Eval、Attempt 的固定诊断字段由三个实体列表承接。下面的例子是另一种需求:用通用 `MetricTable` 自由换指标口径——每个指标的计算逻辑都挂在自己身上,换口径只需要换 `columns`:

```tsx theme={null}
// reports/golf.tsx —— code-golf:只比通过方案的改动行数
import {
  MetricTable, costUSD, defineMetric, defineReport, endToEndPassRate,
} from "niceeval/report";

const changedLines = defineMetric({
  name: "changed-lines",
  label: { en: "Changed lines", "zh-CN": "改动行数" },
  unit: "lines",
  better: "lower",
  where: (attempt) => attempt.result.verdict === "passed",
  async value(attempt) {
    const diff = await attempt.diff();
    if (!diff) return null;
    return Object.keys(diff.files)
      .reduce((sum, path) => sum + (diff.get(path) ?? "").split("\n").length, 0);
  },
});

export default defineReport(
  <MetricTable
    rows="agent"
    columns={[endToEndPassRate, changedLines, costUSD]}
    sort={endToEndPassRate}
  />,
);
```

```text theme={null}
$ niceeval show --report reports/golf.tsx
agent   pass rate   changed lines   cost
bub     87%         312 lines       $0.42
codex   80%         355 lines       $0.51
```

格子里的终值是**两级折叠**出来的:同一道题的多个 attempt 先折成题级值(`perEval`),再跨题折成格子值(`acrossEvals`),两级默认都是平均。分两级不是形式主义——失败的题天然比通过的题 attempt 多(重试跑满、通过即停),平铺求均值会让分数和重试策略纠缠在一起。两级都能在 `defineMetric` 的 `aggregate` 里换:`aggregate: { perEval: "max", acrossEvals: "mean" }` 就是 pass\@k(题内取最好一次,跨题取占比)。

两个面继承同一套诚实契约:排序方向随指标的 `better`,覆盖不全的格子带 `12/15` 角标(15 个 attempt 里 12 个测得了该指标),缺数据渲染成 `—` 而不是 0。指标只定义一次,两个面共用——人在网页上看到的数字,就是 agent 在 stdout 里读到的数字,判断口径永远一致。给 agent 的指引也只多一行:跑 `niceeval show --report reports/golf.tsx`,读 stdout。

`label` 可以是一份文案,也可以按语言给:`label: { en: "Changed lines", "zh-CN": "改动行数" }`——查看器界面切语言时,按语言给的 label 跟着切;只给一份就两种语言都用它。指标算出来的数字本身不分语言。

内置指标里 `endToEndPassRate` / `taskPassRate` / `executionReliability` / `costUSD` / `durationMs` / `tokens` 只读 Attempt 自带的判定、用量这些字段,任何一份结果目录都算得出。没有限定词的"成功率"使用 `endToEndPassRate`:passed 记 1,failed 和 errored 都记 0。`taskPassRate` 只在形成可信判定的样本上衡量答题质量,errored 不参与;展示它时应明确写"可判定任务通过率",不能简称成功率。要区分答题质量和执行问题,把 `endToEndPassRate`、`taskPassRate`、`executionReliability` 三列并排。`assistantTurns`(o11y 事件流里的 assistant turn 数)不一样,它读 `attempt.o11y()`——这份数据 `copySnapshots` 缺省会随行,但如果发布脚本显式给了 `artifacts` 列表又没把 `"o11y"` 写进去,它就不在发布根里(见[结果数据 API](/docs/zh/reference/results-data)的"发布"一节),指标渲染成 `—`,不是 0。自己写的指标只要读了 `o11y()` / `diff()` 这类 artifact(就像上面 `changedLines` 读 `attempt.diff()`),发布前都要过一遍同样的检查。

## 换分组:三种来源

维度决定分组——表的行、矩阵的行列、散点的点、趋势图的 x 轴。每个维度槽收三种值:

* **内置维度**:`"agent"`、`"model"`、`"experiment"`、`"eval"`、`"evalGroup"`、`"snapshot"`,结果里现成的身份字段。
* **自定义维度**:`{ name, of }`,一个纯函数吃 attempt、吐组名。定义只住在报告文件里,不用改任何 experiment 文件。
* **声明式变量**:`flag()` / `label()` / `runConfig()`,分别引用 experiment 用 `flags`、`labels` 声明的变量和顶层运行配置。

### 自定义维度:从已有数据派生分组

分组能从现有结果数据**计算**出来时用自定义维度,不要求为了展示方式去改任何 experiment 文件。比如把不同模型折成厂商,按厂商比通过率:

```tsx theme={null}
// reports/vendor.tsx
import { MetricTable, costUSD, defineReport, endToEndPassRate } from "niceeval/report";
import type { CustomDimension } from "niceeval/report";

const vendor: CustomDimension = {
  name: "vendor",
  of: (a) => (a.snapshot.model?.startsWith("gpt-") ? "OpenAI" : "Anthropic"),
};

export default defineReport(
  <MetricTable rows={vendor} columns={[endToEndPassRate, costUSD]} />,
);
```

`of` 能读 attempt 的全部已有数据:`evalId`、`experimentId`、快照与判定里的字段。它只能派生、不能补造:如果两组 experiment 的差别只体现在文件命名里(`bub-baseline.ts` / `bub-mempal.ts`),`of` 就只能去解析这个名字——命名约定一改,分组静默散掉。这种时候变量该搬回配置,往下看。

### 声明式变量:`flag()` / `label()` / `runConfig()`

画"并行 agent 数 × 模拟延迟 × 得分"这类 scaling 趋势时,图上的变量不该编码进 experiment id(`ultra-16agents-300ms`),再在报告里解析字符串抠出来——那是约定不是配置,改个命名整张图就散。变量的家是 experiment 文件里声明的字段,三种来源对应三个构造器,不猜:

* **`flag()` / `numericFlag()`** 读 `ExperimentDef.flags`——agent 和 eval 运行时也能看见的参数,比如并发数、延迟档位。
* **`label()` / `numericLabel()`** 读 `ExperimentDef.labels`——只用于报告归类的标注,运行时不可见。
* **`runConfig()` / `numericRunConfig()`** 读顶层运行配置(`model`、`reasoningEffort`、`budget`、`runs` 这类),名字点明读的是这次运行落盘的配置。

```ts theme={null}
// experiments/ultra/agents-16.ts
import { defineExperiment } from "niceeval";
import { bub } from "../../agents/bub.ts";

export default defineExperiment({
  agent: bub({ mode: "ultra" }),
  flags: { agents: 16, latencyMs: 300 },
});
```

维度槽(`series` / `rows` / `columns` / `points`)用不加 `numeric` 前缀的版本按声明值分组;数值轴(`MetricLine` 的 `x`)必须用 `numericFlag()` / `numericLabel()` / `numericRunConfig()`,因为刻度要求真实数值:

```tsx theme={null}
import { MetricLine, defineReport, endToEndPassRate, flag, numericFlag } from "niceeval/report";

const agents = flag("agents", { label: "并行 agent 数" });
const latency = numericFlag("latencyMs", { label: "Simulated latency", unit: "ms" });

export default defineReport(
  <MetricLine x={latency} series={agents} y={endToEndPassRate} />,
);
```

未声明该变量的 Experiment 在分组时归入"未配置",用作坐标轴时不绘制该点并在注脚中计数。Flags 和 labels 随快照落盘,因此历史 run 也能按当时声明的变量重新分组。

## 换形态:表格用 Table,摘要卡片用 Grid/Stat,其余自己画

内置组件未覆盖的展示分三类:表格使用排版原语 `<Table>`;label-value 的自由摘要卡片使用 `<Grid>` / `<Stat>`;通过率条形图、预算燃尽图和项目徽章这类真正需要自己画的展示,才用 `defineComponent` 编写双面组件。

### 一张表:`<Table>`

列是你定的,格子是你算好的显示值,`<Table>` 负责把网页和终端两个面都排整齐:

```tsx theme={null}
// reports/cost-board.tsx
import {
  defineReport, defineComponent, Table,
  costUSD, endToEndPassRate, metricTableData,
} from "niceeval/report";

const CostBoard = defineComponent(async (_props: {}, ctx) => {
  const board = await metricTableData(ctx.scope, {
    rows: "agent",
    columns: [endToEndPassRate, costUSD],
  });
  return (
    <Table
      columns={[
        { key: "agent", header: "Agent" },
        { key: "pass", header: "通过率", align: "right" },
        { key: "cost", header: "成本", align: "right" },
      ]}
      rows={board.rows.map((r) => ({
        key: r.key,
        cells: {
          agent: r.key,
          pass: r.cells[endToEndPassRate.name].display,
          // 缺数据交 null,组件渲染成 —;不要自己填 0
          cost: r.cells[costUSD.name].value === null ? null : r.cells[costUSD.name].display,
        },
      }))}
    />
  );
});

export default defineReport(<CostBoard />);
```

```text theme={null}
$ niceeval show --report reports/cost-board.tsx
Agent    通过率    成本
bub         87%   $0.42
codex       80%   $0.51
克劳德        —       —
```

列宽按**终端显示宽度**计算,一个汉字占 2 列。`align: "right"` 让数字列右对齐。单元格值为 `null` 时渲染 `—`,不补 0。行上包含 `locator` 时会增加 Attempt 列;网页链接进入 Attempt 证据页,终端把 locator 交给 `niceeval show`。完整字段见[报告组件](/docs/zh/reference/report-components)的"表格"一节。

### 自由摘要格:`<Grid>` / `<Stat>`

耗时、成本、参与率这类 label-value 速览数字,不用写 `defineComponent`——`<Grid>` 摆格子,`<Stat>` 摆每格的 label / 主值 / 辅助信息,两个面自动排版:

```tsx theme={null}
// reports/run-summary.tsx
import { Grid, Section, Stat, defineReport } from "niceeval/report";

export default defineReport(
  <Section title="运行速览" meta="4/4 完成">
    <Grid columns={2} variant="boxed">
      <Stat label="总耗时" value="12m 30s" />
      <Stat label="总成本" value="$1.86" />
      <Stat label="通过率" value="87%" tone="positive" />
      <Stat label="失败数" value={2} detail="见下方 Attempt 列表" tone="negative" />
    </Grid>
  </Section>,
);
```

`columns` 是宽屏下最多摆几列,窄屏和终端都会自动减列,不丢任何一格;加 `variant="boxed"` 给每格描边,省略就是默认的 `plain` 无框。`value` 收 `LocalizedText`、`number` 或 `null`——`null` 显示 `—`,数字 `0` 照常显示成 `0`,不当成缺数据;`tone`(`positive` / `negative` / `warning`,默认 `neutral`)是你自己对这个数字的判断,只给主值上色,组件不会替你从正负号猜。

`Grid` 只管排版,不读 Scope、不聚合指标,`Stat.value` 必须是你已经算好的显示值。需要保留 `12/15` 这样的覆盖率角标和证据引用时,继续用[报告组件](/docs/zh/reference/report-components)里的指标表这类数据组件;只有这种自由摘要卡片才用 `Grid` / `Stat`。

### 不是表:`defineComponent` 加文本排版函数

`defineComponent` 声明两个渲染面,和 `defineExperiment` / `defineMetric` / `defineReport` 同一个家族。终端面要自己排字符,用 `niceeval/report` 导出的这组函数——官方组件排的就是这几把尺子:

| 函数                                    | 用途                          |
| ------------------------------------- | --------------------------- |
| `stringWidth(text)`                   | 显示宽度:CJK 和全角字符记 2 列,其余记 1 列 |
| `padEnd(text, width)`                 | 按显示宽度在右侧补齐(左对齐)             |
| `padStart(text, width)`               | 按显示宽度在左侧补齐(右对齐,数字列用)        |
| `wrapText(text, width)`               | 按显示宽度折行,返回若干行               |
| `indent(block, prefix)`               | 每行加缩进                       |
| `bar(ratio, width)`                   | 字符条:`█` 填充、`░` 补齐到 `width`  |
| `columns(blocks, widths, separator?)` | 多块并排                        |

**不要用 `String.prototype.padEnd` / `padStart` 对齐终端输出。** 它们数的是 UTF-16 码元,不是显示列宽:一个汉字占 2 列,却只算 1 个码元。用它们补齐,中文一进来列就错位,而中文 agent 名和中文题目名恰恰是最常见的情形。列宽也要随内容和 `ctx.width` 算,不要硬编码一个数字。

```tsx theme={null}
// reports/passbars.tsx
import {
  defineReport, defineComponent, Col, Style, metricTableData,
  bar, padEnd, stringWidth, endToEndPassRate,
} from "niceeval/report";

interface BarRow { key: string; ratio: number | null; display: string }

const PassBars = defineComponent<{ rows: BarRow[] }>({
  web({ rows }) {
    return (
      <ul className="passbars">
        {rows.map((r) => (
          <li key={r.key}>
            <span>{r.key}</span>
            <i style={{ width: `${(r.ratio ?? 0) * 100}%` }} />
            <b>{r.ratio === null ? "—" : r.display}</b>
          </li>
        ))}
      </ul>
    );
  },
  text({ rows }, { width }) {
    const label = Math.max(...rows.map((r) => stringWidth(r.key)));  // 列宽随内容
    const barWidth = Math.min(20, width - label - 8);                // 也随可用列宽
    return rows
      .map((r) => {
        const chart = r.ratio === null ? padEnd("—", barWidth) : bar(r.ratio, barWidth);
        return `${padEnd(r.key, label)}  ${chart}  ${r.display}`;
      })
      .join("\n");
  },
});

const PassBarsSection = defineComponent(async (_props: {}, ctx) => {
  const board = await metricTableData(ctx.scope, { rows: "agent", columns: [endToEndPassRate] });
  const rows = board.rows.map((r) => ({
    key: r.key,
    ratio: r.cells[endToEndPassRate.name].value,   // 格子键锚在指标对象上,不裸写字符串
    display: r.cells[endToEndPassRate.name].display,
  }));
  return (
    <Col>
      <Style>{`.passbars li { display: flex; gap: 8px; } .passbars i { background: #4a7; height: 12px; }`}</Style>
      <PassBars rows={rows} />
    </Col>
  );
});

export default defineReport(<PassBarsSection />);
```

```text theme={null}
$ niceeval show --report reports/passbars.tsx
bub     █████████████████░░░  87%
codex   ████████████████░░░░  80%
克劳德  —                     —
```

组件的契约:

* **计算发生在拿到 `ctx` 的地方,渲染面是纯函数。** 组合组件的函数体里能 `await` 读 attempt 句柄、折数据;双面组件的 `web` 和 `text` 只认已经算好的 props,零 IO、同步——这条边界让同一棵树能被烘进静态导出。
* **渲染面接收上下文参数。** `text(props, ctx)` 的 `ctx.width` 是可用列宽(`Row` 分栏后会变窄),`ctx.attemptCommand(ref)` 生成查看命令;`web(props, ctx)` 的 `ctx.attemptHref(ref)` 生成 Attempt 证据链接。自定义组件与内置组件使用相同证据页。
* **网页面静态渲染,不 hydrate。** 宿主在计算侧把 `web` 面渲染成静态 HTML,不打包你的代码进查看器:交互用普通链接和 `<details>`,与官方组件同一条静态契约。
* **样式随树带走。** 静态导出不打包你的代码,`className` 引用的 CSS 用内置原语 `<Style>{css}</Style>` 放进页面树——web 面吐 `<style>` 标签,text 面渲染为空,上面的例子就是这么给 `.passbars` 上样式的。
* **诚实契约同样适用。** 缺数据渲染 `—` 不补 0,截断如实标注剩余数量——上面例子里 `克劳德` 没有样本就是 `—`。

## 发布:导出即静态站

发布自定义报告和发布官方查看器是同一个动作——`--out` 加上 `--report`:

```bash theme={null}
niceeval view --report reports/exam.tsx --out site
```

产物是纯静态文件。报告页是首页,transcript、trace 和代码等 Attempt 证据位于同一站点,报告中的数值可以链接到对应证据。组件不 hydrate;页面内联脚本提供表头排序、行过滤和图表悬停。浏览器禁用 JavaScript 时页面仍完整可读。CI 上没有 `.niceeval/` 时,先用 `copySnapshots` 生成经过大小检查的结果目录,再执行导出,流程见[查看结果](/docs/zh/tutorials/viewing-results#导出与静态托管)。

双面组件的网页面是普通 React 组件,`xxxData` 函数是普通 TypeScript 函数。需要在现有内部面板中复用指标表时,可以 import 组件并传入数据。计算与渲染分开部署时(例如 CI 生成 JSON、另一个应用 fetch),两侧必须使用相同 NiceEval 版本;组件数据不带版本信息,兼容性跟随包版本。

## 自定义报告的边界

一次只渲染一份报告,`--report` 收显式文件路径——没有 `reports/` 目录自动发现、没有插件注册表、没有配置文件。自定义指标和自定义组件都住在你的报告文件里,随文件一起递入,宿主不为它们长任何注册面。不传 `--report` 时渲染的就是默认报告,你的报告和它是同级实现。
