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:
co-authored by
Claude Opus 5.5
parent
94fbf37674
commit
7950ae93d3
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user