feat(reports): redesign the export sheet — edition, whole or chosen chapters, size on the button

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-30 00:44:28 +08:00
co-authored by Claude Opus 5.5
parent 94fbf37674
commit 7950ae93d3
13 changed files with 310 additions and 167 deletions
+36 -43
View File
@@ -3994,57 +3994,50 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (min-width: 860px) {
.report-export-drawer {
inset: 0;
width: min(720px, 92vw);
width: min(520px, 92vw);
max-height: 80vh;
margin: auto;
border-radius: var(--radius-lg);
}
.report-export-panel { max-height: calc(80vh - 2px); }
}
.report-export-header, .report-export-footer { padding: var(--space-4); flex-shrink: 0; }
.report-export-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
.report-export-heading h2 { margin: 0; font-size: var(--type-title-lg); }
.report-export-header p { margin: var(--space-2) 0; font-size: var(--type-body-sm); color: var(--color-ink-secondary); }
.report-export-count { font-variant-numeric: tabular-nums; }
/* 「问 AI 建议导出英文版」: one quiet band inside the header, with the switch beside it. */
.report-export-ai-hint { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin: var(--space-2) 0; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-muted); }
.report-export-ai-hint p { margin: 0 !important; color: var(--color-ink) !important; }
.report-export-ai-hint > button { flex-shrink: 0; }
.report-export-edition-note { color: var(--color-ink-tertiary) !important; font-size: var(--type-caption) !important; }
@media (max-width: 767px) { .report-export-ai-hint { flex-direction: column; align-items: stretch; } }
.report-export-presets { display: flex; flex-wrap: wrap; gap: var(--space-2); }
/* Selected preset: neutral raised fill + full ink + a visible edge. The old
--color-action-soft fill reads as a dark red-brown block in the dark theme
(#3a2620) — BUG-1078. */
.report-export-presets [aria-pressed="true"] { background: var(--color-canvas-muted); color: var(--color-ink); border-color: var(--color-ink-secondary); }
.report-export-list { overflow-y: auto; overscroll-behavior: contain; min-height: 0; padding: 0 var(--space-4) var(--space-4); }
.report-export-list h3 { font-size: var(--type-title-sm); margin: var(--space-3) 0 var(--space-2); }
.report-export-grid { display: grid; gap: var(--space-2); }
.report-export-block { display: flex; align-items: center; text-align: left; gap: var(--space-3); min-height: 44px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: transparent; color: var(--color-ink); font-size: var(--type-body-sm); overflow-wrap: anywhere; }
.report-export-block[aria-checked="true"] { background: var(--color-selected); border-color: var(--color-action); }
.report-export-block small { display: block; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
.report-export-check { flex-shrink: 0; width: 18px; height: 18px; border: 1px solid var(--color-border-strong); border-radius: 4px; text-align: center; line-height: 16px; }
.report-export-block:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
/* 导出报告 sheet (redesigned 2026-09-30): title row; the edition as a two-way
segment with one caption; two radio cards, 整篇报告 / 自选章节; the checklist
only in 自选; the size on the button. No bordered block cards, no presets. */
.report-export-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: var(--space-3) var(--space-3) var(--space-1) var(--space-5); flex-shrink: 0; }
.report-export-header h2 { margin: 0; font-size: var(--type-title-md); font-weight: 600; }
.report-export-body { display: grid; gap: var(--space-4); min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: var(--space-2) var(--space-5) var(--space-4); }
.report-export-field { display: grid; gap: var(--space-2); }
.report-export-segment { display: grid; grid-template-columns: 1fr 1fr; padding: 3px; border-radius: var(--radius-md); background: var(--color-canvas-muted); }
.report-export-segment button { border: 0; border-radius: calc(var(--radius-md) - 2px); background: transparent; color: var(--color-ink-secondary); font: inherit; font-size: var(--type-body-sm); cursor: pointer; }
.report-export-segment button[aria-checked="true"] { background: var(--color-canvas); color: var(--color-ink); font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 12%, transparent); }
.report-export-caption { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; }
.report-export-modes { display: grid; gap: var(--space-2); }
.report-export-mode { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: transparent; color: var(--color-ink); font: inherit; text-align: left; cursor: pointer; }
.report-export-mode[aria-checked="true"] { border-color: var(--color-ink-secondary); background: var(--color-canvas-muted); }
.report-export-mode strong { display: block; font-size: var(--type-body-md); font-weight: 600; }
.report-export-mode small { display: block; margin-top: 2px; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
.report-export-radio { flex-shrink: 0; width: 18px; height: 18px; border: 1.5px solid var(--color-border-strong); border-radius: 50%; }
.report-export-mode[aria-checked="true"] .report-export-radio { border: 5px solid var(--color-ink); }
.report-export-list { display: grid; gap: var(--space-1); }
.report-export-group + .report-export-group { border-top: 1px solid var(--color-border); padding-top: var(--space-1); }
.report-export-group-head { display: flex; align-items: center; }
.report-export-row { display: flex; flex: 1; align-items: center; gap: var(--space-3); width: 100%; padding: 0 var(--space-1); border: 0; background: transparent; color: var(--color-ink); font: inherit; font-size: var(--type-body-sm); text-align: left; cursor: pointer; overflow-wrap: anywhere; }
.report-export-row:not(.is-group) { padding-left: calc(var(--space-1) + 18px + var(--space-3)); }
.report-export-row:not(.is-group) .report-export-box { margin-left: calc(-18px - var(--space-3)); }
.report-export-row.is-group { font-weight: 600; }
.report-export-row small { margin-left: var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-caption); font-weight: 400; font-variant-numeric: tabular-nums; }
.report-export-box { flex-shrink: 0; display: grid; place-items: center; width: 18px; height: 18px; border: 1.5px solid var(--color-border-strong); border-radius: 4px; color: var(--color-canvas); }
.report-export-box svg { width: 13px; height: 13px; stroke-width: 3; }
.report-export-row:is([aria-checked="true"], [aria-checked="mixed"]) .report-export-box { border-color: var(--color-ink); background: var(--color-ink); }
.report-export-expand { flex-shrink: 0; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; }
.report-export-expand svg { width: 18px; height: 18px; transition: transform 140ms ease-out; }
.report-export-expand[aria-expanded="true"] svg { transform: rotate(90deg); }
.report-export-drawer button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.report-export-drawer button { min-height: 44px; min-width: 44px; }
.report-export-footer { border-top: 1px solid var(--color-border); padding-bottom: max(var(--space-4), env(safe-area-inset-bottom)); }
.report-export-footer { flex-shrink: 0; padding: var(--space-3) var(--space-5); border-top: 1px solid var(--color-border); padding-bottom: max(var(--space-4), env(safe-area-inset-bottom)); }
.report-export-footer > button { width: 100%; }
.report-export-footer p { margin: 0 0 var(--space-2); color: var(--color-danger); font-size: var(--type-body-sm); }
@media (min-width: 860px) { .report-export-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* Phones (2026-09-30): the header had grown to half the sheet — title, a two-line
description, a two-line count, wrapped presets — and the block list was
pushed out of sight. The description goes (the title and presets say it),
the count is one short line, presets scroll sideways in one row, and the
sheet may use more of the screen. */
@media (max-width: 767px) {
#report-export-description { display: none; }
.report-export-header { padding: var(--space-3) var(--space-4) var(--space-2); }
.report-export-header p { margin: var(--space-1) 0; }
.report-export-count { font-size: var(--type-caption); }
.report-export-presets { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin-inline: calc(-1 * var(--space-4)); padding-inline: var(--space-4); }
.report-export-presets [data-slot="button"] { flex: 0 0 auto; height: 40px; padding: 0 var(--space-3); }
.report-export-ai-hint { padding: var(--space-2) var(--space-3); gap: var(--space-2); }
.report-export-ai-hint p { font-size: var(--type-caption); }
}
@media print { .report-export-drawer { display: none !important; } }
/* ============================================================
@@ -5208,7 +5201,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (min-width: 860px) {
.chart-page-type-sheet {
inset: 0;
width: min(720px, 92vw);
width: min(520px, 92vw);
max-height: 80vh;
margin: auto;
border-radius: var(--radius-lg);
@@ -554,9 +554,10 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
<style media="print">{"@page { size: A4; margin: 13mm 12mm 14mm; }"}</style>
<style media="print">{REPORT_SHELL_PRINT_CSS}</style>
<ReportActions
markdown={markdown}
markdown={state.markdown}
charts={state.calculationCharts}
factTables={factTables}
factTables={state.factTables}
english={state.englishEdition === "ready" && state.markdownEn ? { markdown: state.markdownEn, factTables: state.factTablesEn } : null}
reportDate={state.createdAt}
language={shown}
englishEdition={state.englishEdition}
@@ -20,7 +20,9 @@ interface ReportActionsProps {
charts?: ReportDocumentV2["charts"];
factTables?: readonly ReportFactTable[];
reportDate?: string | null;
/** Edition currently shown; `markdown` and `factTables` are that edition. */
/** The English edition, when the report has one; `markdown` / `factTables` are always Chinese. */
english?: Readonly<{ markdown: string; factTables?: readonly ReportFactTable[] }> | null;
/** Edition currently shown; the switch highlights it and the export sheet starts on it. */
language?: ReportLanguage;
englishEdition?: EnglishEditionStatus;
onLanguageChange?: (language: ReportLanguage) => void;
@@ -31,15 +33,17 @@ export function ReportActions({
charts,
factTables,
reportDate,
english = null,
language = "zh",
englishEdition = "legacy",
onLanguageChange,
}: ReportActionsProps) {
const [exportOpen, setExportOpen] = useState(false);
const blocks = useMemo(
() => buildReportExportBlocks({ markdown, charts, factTables, language }),
[markdown, charts, factTables, language],
);
// Built only while the sheet is open; each edition labels its own blocks.
const editions = useMemo(() => (exportOpen ? {
zh: buildReportExportBlocks({ markdown, charts, factTables, language: "zh" }),
...(english ? { en: buildReportExportBlocks({ markdown: english.markdown, charts, factTables: english.factTables, language: "en" }) } : {}),
} : null), [exportOpen, markdown, charts, factTables, english]);
const canSwitch = englishEdition === "ready" && onLanguageChange !== undefined;
return (
@@ -75,14 +79,12 @@ export function ReportActions({
>
<Download aria-hidden="true" />导出
</Button>
{exportOpen && (
{editions && (
<ReportExportDrawer
key={language}
blocks={blocks}
reportDate={reportDate}
language={language}
editions={editions}
initialLanguage={language}
englishEdition={englishEdition}
onSwitchToEnglish={canSwitch ? () => onLanguageChange("en") : undefined}
reportDate={reportDate}
onClose={() => setExportOpen(false)}
/>
)}
@@ -1,35 +1,53 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { X } from "lucide-react";
import { Check, ChevronRight, Minus, X } from "lucide-react";
import { toast } from "sonner";
import { detectPrintRestriction } from "@/lib/client-report-export";
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
import { exportByteSize, exportFilename, formatExportSize, joinExportBlocks, presetSelection, type ReportExportBlock, type ReportExportPreset } from "@/lib/report-export-blocks";
import { exportByteSize, exportFilename, formatExportSize, joinExportBlocks, type ReportExportBlock } from "@/lib/report-export-blocks";
import { Button } from "@/components/ui/button";
import type { EnglishEditionStatus, ReportLanguage } from "@/lib/report-language";
const PRESETS = { common: "常用", charts: "全部分盘", tables: "仅核对表" } as const;
const GROUPS = { body: "正文", chart: "分盘", table: "核对表" } as const;
type Group = ReportExportBlock["group"];
const GROUP_LABELS: Record<Group, string> = { body: "正文", chart: "分盘", table: "核对表" };
const GROUP_UNITS: Record<Group, string> = { body: "章", chart: "张", table: "张" };
const GROUPS = Object.keys(GROUP_LABELS) as Group[];
export function ReportExportDrawer({ blocks, reportDate, onClose, language = "zh", englishEdition = "legacy", onSwitchToEnglish }: {
blocks: readonly ReportExportBlock[];
/** Export blocks for each edition the report has; Chinese always, English when it exists. */
export type ReportExportEditions = Readonly<{ zh: readonly ReportExportBlock[]; en?: readonly ReportExportBlock[] }>;
/**
* 导出报告 (redesigned 2026-09-30, product: the block-picker sheet was ugly and
* hid the list). Two steps: pick the edition, then 「整篇报告」 (one tap, the
* common case) or 「自选章节」, which only then opens a plain checklist grouped
* into 正文 / 分盘 / 核对表 with one checkbox per group. The file size appears
* once, on the export button.
*/
export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEdition = "legacy", reportDate, onClose }: {
editions: ReportExportEditions;
/** The edition the reader is showing; the sheet starts on it. */
initialLanguage?: ReportLanguage;
englishEdition?: EnglishEditionStatus;
reportDate?: string | null;
onClose: () => void;
/** Edition the blocks are in; the file is exported in it. */
language?: ReportLanguage;
englishEdition?: EnglishEditionStatus;
onSwitchToEnglish?: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [selection, setSelection] = useState(() => presetSelection("common", blocks));
const [preset, setPreset] = useState<ReportExportPreset | null>("common");
const hasEnglish = englishEdition === "ready" && Boolean(editions.en?.length);
const [language, setLanguage] = useState<ReportLanguage>(initialLanguage === "en" && hasEnglish ? "en" : "zh");
const blocks = (language === "en" ? editions.en : editions.zh) ?? editions.zh;
const [mode, setMode] = useState<"whole" | "pick">("whole");
const [selection, setSelection] = useState<ReadonlySet<string>>(() => bodyIds(blocks));
const [open, setOpen] = useState<ReadonlySet<Group>>(() => new Set<Group>(["body"]));
const [error, setError] = useState<string | null>(null);
const exporting = useRef(false);
const selected = blocks.filter(block => selection.has(block.id));
const allSelected = selected.length === blocks.length;
const totalSize = useMemo(() => formatExportSize(exportByteSize(blocks)), [blocks]);
const selectedSize = useMemo(() => formatExportSize(exportByteSize(blocks.filter(block => selection.has(block.id)))), [blocks, selection]);
const selected = useMemo(
() => (mode === "whole" ? blocks : blocks.filter(block => selection.has(block.id))),
[blocks, mode, selection],
);
const size = useMemo(() => formatExportSize(exportByteSize(selected)), [selected]);
const counts = useMemo(() => countByGroup(blocks), [blocks]);
useEffect(() => {
const element = dialog.current;
@@ -41,9 +59,22 @@ export function ReportExportDrawer({ blocks, reportDate, onClose, language = "zh
};
}, []);
function choose(next: ReportExportPreset) {
setSelection(presetSelection(next, blocks));
setPreset(next);
function switchLanguage(next: ReportLanguage) {
if (next === language) return;
const nextBlocks = (next === "en" ? editions.en : editions.zh) ?? editions.zh;
setLanguage(next);
// Block ids differ per edition; start the new edition from its chapters.
setSelection(bodyIds(nextBlocks));
}
function toggle(ids: readonly string[], on: boolean) {
setSelection(current => {
const next = new Set(current);
for (const id of ids) {
if (on) next.add(id); else next.delete(id);
}
return next;
});
}
function download() {
@@ -57,55 +88,109 @@ export function ReportExportDrawer({ blocks, reportDate, onClose, language = "zh
setError(null);
try {
const body = blocks.filter(block => block.group === "body");
const full = body.length > 0 && body.every(block => selection.has(block.id));
const full = mode === "whole" || (body.length > 0 && body.every(block => selection.has(block.id)));
downloadMarkdownReport(exportFilename(reportDate, full, language), joinExportBlocks(selected));
dialog.current?.close();
onClose();
toast.success(`已导出 ${selected.length} 块`);
toast.success("已导出");
} catch (cause) {
exporting.current = false;
setError(cause instanceof Error ? cause.message : "报告暂不可导出");
}
}
return <dialog ref={dialog} className="report-export-drawer" aria-labelledby="report-export-title" aria-describedby="report-export-description"
const summary = GROUPS.filter(group => counts[group] > 0).map(group => `${counts[group]} ${GROUP_UNITS[group]}${GROUP_LABELS[group]}`).join(" + ");
return <dialog ref={dialog} className="report-export-drawer" aria-labelledby="report-export-title"
onCancel={event => { event.preventDefault(); onClose(); }}
onClick={event => { if (event.target === event.currentTarget) onClose(); }}>
<div className="report-export-panel">
<header className="report-export-header">
<div className="report-export-heading"><h2 id="report-export-title">分块导出</h2><Button variant="ghost" size="icon" onClick={onClose} aria-label="关闭分块导出" title="关闭"><X aria-hidden="true" /></Button></div>
<p id="report-export-description">勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。{language === "en" ? "导出的是英文版。" : ""}</p>
{language === "zh" && englishEdition === "ready" && onSwitchToEnglish ? (
<div className="report-export-ai-hint">
<p>要拿去问 ChatGPT、Claude 等 AI?建议切到 English 再导出,术语与原典一致,AI 读得更准。</p>
<Button variant="outline" onClick={onSwitchToEnglish}>切到 English</Button>
<h2 id="report-export-title">导出报告</h2>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="关闭导出" title="关闭"><X aria-hidden="true" /></Button>
</header>
<div className="report-export-body">
{hasEnglish ? (
<div className="report-export-field">
<div className="report-export-segment" role="radiogroup" aria-label="导出语言">
{(["zh", "en"] as const).map(option => (
<button key={option} type="button" role="radio" aria-checked={language === option} onClick={() => switchLanguage(option)}>
{option === "zh" ? "中文" : "English"}
</button>
))}
</div>
<p className="report-export-caption">拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。</p>
</div>
) : (
<p className="report-export-caption">
{englishEdition === "unavailable" ? "这份报告的英文版没有生成成功,只能导出中文版。" : "这份报告生成于英文版上线前,重新生成即可获得中英两版。"}
</p>
)}
<div className="report-export-modes" role="radiogroup" aria-label="导出范围">
<button type="button" role="radio" aria-checked={mode === "whole"} className="report-export-mode" onClick={() => setMode("whole")}>
<span aria-hidden="true" className="report-export-radio" />
<span><strong>整篇报告</strong><small>{summary}</small></span>
</button>
<button type="button" role="radio" aria-checked={mode === "pick"} className="report-export-mode" onClick={() => setMode("pick")}>
<span aria-hidden="true" className="report-export-radio" />
<span><strong>自选章节</strong><small>只带走需要的部分</small></span>
</button>
</div>
{mode === "pick" ? (
<div className="report-export-list">
{GROUPS.map(group => {
const items = blocks.filter(block => block.group === group);
if (!items.length) return null;
const on = items.filter(block => selection.has(block.id)).length;
const expanded = open.has(group);
return <section key={group} className="report-export-group" aria-label={GROUP_LABELS[group]}>
<div className="report-export-group-head">
<button type="button" role="checkbox" aria-checked={on === 0 ? false : on === items.length ? true : "mixed"}
className="report-export-row is-group" onClick={() => toggle(items.map(block => block.id), on < items.length)}>
<CheckMark state={on === 0 ? false : on === items.length ? true : "mixed"} />
<span>{GROUP_LABELS[group]}<small>{on}/{items.length}</small></span>
</button>
<button type="button" className="report-export-expand" aria-expanded={expanded} aria-label={`${expanded ? "收起" : "展开"}${GROUP_LABELS[group]}`}
onClick={() => setOpen(current => { const next = new Set(current); if (next.has(group)) next.delete(group); else next.add(group); return next; })}>
<ChevronRight aria-hidden="true" />
</button>
</div>
{expanded ? items.map(block => (
<button key={block.id} type="button" role="checkbox" aria-checked={selection.has(block.id)} className="report-export-row"
onClick={() => toggle([block.id], !selection.has(block.id))}>
<CheckMark state={selection.has(block.id)} />
<span>{block.title}</span>
</button>
)) : null}
</section>;
})}
</div>
) : null}
{englishEdition === "legacy" ? (
<p className="report-export-edition-note">这份报告生成于英文版上线前,重新生成即可获得中英两版。</p>
) : englishEdition === "unavailable" ? (
<p className="report-export-edition-note">这份报告的英文版没有生成成功,只能导出中文版。</p>
) : null}
{/* One short line (2026-09-30): the old 「共 N 块 · X 字 · … · Y 字 · …」 ran to two
lines on a phone; each block still shows its own character count. */}
<p className="report-export-count" aria-live="polite">已选 {selected.length}/{blocks.length} 块 · {selected.length ? selectedSize : "0 KB"}(全篇{totalSize})</p>
<div className="report-export-presets" aria-label="导出套餐">
{(Object.keys(PRESETS) as ReportExportPreset[]).map(key => <Button key={key} variant="outline" aria-pressed={preset === key} onClick={() => choose(key)}>{PRESETS[key]}</Button>)}
<Button variant="ghost" onClick={() => { setPreset(null); setSelection(allSelected ? new Set() : new Set(blocks.map(block => block.id))); }}>{allSelected ? "取消全选" : "全选"}</Button>
</div>
</header>
<div className="report-export-list">
{(Object.keys(GROUPS) as ReportExportBlock["group"][]).map(group => {
const items = blocks.filter(block => block.group === group);
return items.length ? <section key={group} aria-label={GROUPS[group]}><h3>{GROUPS[group]}</h3><div className="report-export-grid">
{items.map(block => <button key={block.id} type="button" className="report-export-block" role="checkbox" aria-checked={selection.has(block.id)} onClick={() => {
setPreset(null);
setSelection(current => { const next = new Set(current); if (next.has(block.id)) next.delete(block.id); else next.add(block.id); return next; });
}}><span aria-hidden="true" className="report-export-check">{selection.has(block.id) ? "✓" : ""}</span><span>{block.title}<small>{block.chars} 字</small></span></button>)}
</div></section> : null;
})}
</div>
<footer className="report-export-footer">{error && <p role="alert">{error}</p>}<Button disabled={!selected.length} onClick={download}>导出选中 ({selected.length})</Button></footer>
<footer className="report-export-footer">
{error && <p role="alert">{error}</p>}
<Button disabled={!selected.length} onClick={download}>
{!selected.length ? "先勾选要导出的部分" : mode === "whole" ? `导出整篇 · ${size}` : `导出 ${selected.length} 项 · ${size}`}
</Button>
</footer>
</div>
</dialog>;
}
function bodyIds(blocks: readonly ReportExportBlock[]): ReadonlySet<string> {
return new Set(blocks.filter(block => block.group === "body").map(block => block.id));
}
function countByGroup(blocks: readonly ReportExportBlock[]): Record<Group, number> {
const counts: Record<Group, number> = { body: 0, chart: 0, table: 0 };
for (const block of blocks) counts[block.group] += 1;
return counts;
}
function CheckMark({ state }: { state: boolean | "mixed" }) {
return <span aria-hidden="true" className="report-export-box">
{state === "mixed" ? <Minus /> : state ? <Check /> : null}
</span>;
}