feat(reports): generate card, jump to end, labelled export, TOC levels, table bands, export size; drop 字段 jargon (BUG-1092..1094)
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
64aa0ec20c
commit
d85ab9a2d2
@@ -3879,34 +3879,45 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-size: var(--type-body-sm);
|
||||
text-align: right;
|
||||
}
|
||||
.report-center-empty {
|
||||
min-height: 220px;
|
||||
/* The page's generate entry: one centred card above the archive (2026-09-29,
|
||||
product sketch). Same sheet as an empty state used to be, so a first visit
|
||||
still reads calm; the button inside is the page's one filled action. */
|
||||
.report-center-create {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto var(--space-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-8);
|
||||
padding: var(--space-8) var(--space-6);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
.report-center-empty h3 {
|
||||
.report-center-create h2 {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-title-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
.report-center-empty p {
|
||||
.report-center-create p {
|
||||
max-width: 460px;
|
||||
margin: 0;
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.55;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
/* No second big empty card under the generate card: one quiet line. */
|
||||
.report-center-empty {
|
||||
margin: 0;
|
||||
padding: var(--space-6) 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-body-sm);
|
||||
text-align: center;
|
||||
}
|
||||
.report-center-alert {
|
||||
margin: 0 0 var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
@@ -4066,7 +4077,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.personal-report-toc li[data-level="3"] { padding-left: var(--space-3); }
|
||||
/* Two levels you can tell apart without the current-item pill (2026-09-29):
|
||||
chapters are ink and semibold with air above each group; sections sit a
|
||||
full step in and one tone lighter. */
|
||||
.personal-report-toc li[data-level="2"] + li[data-level="2"],
|
||||
.personal-report-toc li[data-level="3"] + li[data-level="2"] { margin-top: var(--space-3); }
|
||||
.personal-report-toc li[data-level="2"] > a { color: var(--color-ink); font-weight: 600; }
|
||||
.personal-report-toc li[data-level="3"] { padding-left: var(--space-5); }
|
||||
.personal-report-toc a {
|
||||
display: block;
|
||||
padding: 6px var(--space-2);
|
||||
@@ -4085,6 +4102,39 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.personal-report-toc a:hover { color: var(--color-ink); }
|
||||
/* 「到底部」/「回到顶部」: one floating pill over the reader's bottom-right
|
||||
corner, same look as the chat's 跳到最新. Out of flow, so the paper never
|
||||
reflows when it flips direction. Hidden on paper via .personal-report-screen-only. */
|
||||
.report-edge-jump {
|
||||
position: fixed;
|
||||
right: var(--space-6);
|
||||
bottom: var(--space-6);
|
||||
z-index: 15;
|
||||
}
|
||||
.report-edge-jump__button {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
font: inherit;
|
||||
font-size: var(--type-body-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.report-edge-jump__button svg { width: 16px; height: 16px; flex: 0 0 auto; }
|
||||
.report-edge-jump__button:hover { background: var(--color-canvas-muted); }
|
||||
.report-edge-jump__button:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.report-edge-jump { right: var(--space-4); bottom: calc(var(--space-4) + env(safe-area-inset-bottom)); }
|
||||
}
|
||||
.personal-report-toc-drawer { display: none; }
|
||||
.personal-report-toc-desktop p {
|
||||
margin: 0 0 var(--space-2);
|
||||
@@ -4543,6 +4593,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* Report tables only, never chat bubbles (2026-09-29): a faint header band and
|
||||
zebra rows so a nine-column row can be followed across. Both are ink mixed
|
||||
into transparency, so they sit on paper and on the dark sheet alike. */
|
||||
.personal-report-table-wrap thead th,
|
||||
.personal-report-fact-tables thead th {
|
||||
background: color-mix(in srgb, var(--color-ink) 6%, transparent);
|
||||
}
|
||||
.personal-report-table-wrap tbody tr:nth-child(even),
|
||||
.personal-report-fact-tables tbody tr:nth-child(even) {
|
||||
background: color-mix(in srgb, var(--color-ink) 3%, transparent);
|
||||
}
|
||||
.personal-report-key-evidence tbody th { width: 30%; color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 500; }
|
||||
.personal-report-key-evidence tbody td { color: var(--color-ink-strong); font-size: var(--type-body-sm); }
|
||||
|
||||
@@ -4800,6 +4861,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.personal-report-chart-grid.is-single { grid-template-columns: 1fr; }
|
||||
.personal-report-chart-grid.is-single { max-width: 118mm; }
|
||||
.personal-report-table-wrap { max-width: none !important; overflow: visible !important; }
|
||||
/* Lighter on paper; the print block already forces exact colour. */
|
||||
.personal-report-table-wrap thead th,
|
||||
.personal-report-fact-tables thead th { background: color-mix(in srgb, var(--color-ink) 5%, transparent) !important; }
|
||||
.personal-report-table-wrap tbody tr:nth-child(even),
|
||||
.personal-report-fact-tables tbody tr:nth-child(even) { background: color-mix(in srgb, var(--color-ink) 2.5%, transparent) !important; }
|
||||
|
||||
.personal-report-table-wrap table,
|
||||
.personal-report-key-evidence table { width: 100% !important; min-width: 0 !important; table-layout: fixed !important; }
|
||||
.personal-report-table-wrap th,
|
||||
|
||||
@@ -200,14 +200,12 @@ export function GeneratePersonalReportButton({
|
||||
}
|
||||
|
||||
return (
|
||||
/* Sized for the 46px page header it now lives in. It used to be the
|
||||
default filled Button at full height, which read as a banner CTA
|
||||
floating in the top-right corner rather than the header's own action. */
|
||||
/* The report centre's one filled action, centred in its own card at the
|
||||
top of the page (2026-09-29). It used to be a small outline button in
|
||||
the 46px header, easy to miss beside the page name. */
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => void handleGenerate()}
|
||||
disabled={submitting}
|
||||
title="根据已保存的具体出生分钟生成完整报告;未校正会标明方向性参考"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import { Clock3, FileText, RefreshCw } from "lucide-react";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
@@ -151,14 +151,19 @@ export function PersonalReportCenter() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageShell
|
||||
title="我的报告"
|
||||
actions={<GeneratePersonalReportButton onCreated={() => void load()} />}
|
||||
>
|
||||
<SecondaryPageShell title="我的报告">
|
||||
<div className="report-center-body">
|
||||
{/* Generating is the page's first thing, in its own card, and the only
|
||||
entry: the header no longer carries a second button (2026-09-29). */}
|
||||
<section className="report-center-create" aria-labelledby="report-center-create-title">
|
||||
<FileText aria-hidden="true" className="size-7" />
|
||||
<h2 id="report-center-create-title">生成个人报告</h2>
|
||||
<p>创建后即可离开,完成时下方列表会自动更新。</p>
|
||||
<GeneratePersonalReportButton onCreated={() => void load()} />
|
||||
</section>
|
||||
<section className="report-center-section">
|
||||
<div className="report-center-section-heading">
|
||||
<h2>报告记录</h2>
|
||||
<h2>过往的报告</h2>
|
||||
<button type="button" onClick={() => void load(true)} aria-label="刷新报告列表">
|
||||
{state.phase === "loading" ? <InlineSpinner size={14} /> : <RefreshCw aria-hidden="true" />}刷新
|
||||
</button>
|
||||
@@ -169,11 +174,7 @@ export function PersonalReportCenter() {
|
||||
)}
|
||||
|
||||
{state.reports.length === 0 ? (
|
||||
<div className="report-center-empty">
|
||||
<Clock3 aria-hidden="true" className="size-7" />
|
||||
<h3>还没有个人报告</h3>
|
||||
<p>点击“生成完整报告”,创建后即可离开;完成时这里会自动更新。</p>
|
||||
</div>
|
||||
<p className="report-center-empty">还没有个人报告。</p>
|
||||
) : (
|
||||
<div className="report-center-list">
|
||||
{state.reports.map((report) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "@/lib/report-chart-block";
|
||||
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
|
||||
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
|
||||
import { ReportEdgeJump } from "@/components/personal-report/report-edge-jump";
|
||||
import {
|
||||
buildLongformOutline,
|
||||
type LongformHeading,
|
||||
@@ -104,11 +106,15 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[])
|
||||
|
||||
const LazyMarkdownSection = memo(function LazyMarkdownSection({
|
||||
section,
|
||||
forceVisible,
|
||||
}: {
|
||||
section: LongformSection;
|
||||
/** 「到底部」 mounts every section at once so the jump lands on the real end. */
|
||||
forceVisible: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [visible, setVisible] = useState(section.eager);
|
||||
const [seen, setVisible] = useState(section.eager);
|
||||
const visible = seen || forceVisible;
|
||||
useEffect(() => {
|
||||
if (visible) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
@@ -170,6 +176,10 @@ function ReportToc({ headings }: { headings: readonly LongformHeading[] }) {
|
||||
|
||||
export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
|
||||
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
// Synchronous on purpose: the caller scrolls to scrollHeight right after, and
|
||||
// that height is only final once every lazy section has committed.
|
||||
const revealAll = useCallback(() => flushSync(() => setRevealed(true)), []);
|
||||
const lead = useMemo(
|
||||
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings) : null),
|
||||
[outline],
|
||||
@@ -185,9 +195,10 @@ export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
|
||||
</section>
|
||||
) : null}
|
||||
{outline.sections.filter((section) => !section.eager).map((section) => (
|
||||
<LazyMarkdownSection key={section.id} section={section} />
|
||||
<LazyMarkdownSection forceVisible={revealed} key={section.id} section={section} />
|
||||
))}
|
||||
</article>
|
||||
<ReportEdgeJump revealAll={revealAll} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,15 +37,15 @@ export function ReportActions({
|
||||
<ArrowLeft aria-hidden="true" />返回报告中心
|
||||
</Link>
|
||||
<div className="personal-report-action-end">
|
||||
{/* Labelled, outlined and full height (2026-09-29): the icon-only ghost
|
||||
button it replaces was the one control people could not find. */}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
aria-label="导出报告"
|
||||
title="导出"
|
||||
onClick={() => setExportOpen(true)}
|
||||
>
|
||||
<Download aria-hidden="true" />
|
||||
<Download aria-hidden="true" />导出
|
||||
</Button>
|
||||
{exportOpen && <ReportExportDrawer blocks={blocks} reportDate={reportDate} onClose={() => setExportOpen(false)} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
|
||||
/** Within this many px of the end the button flips to 「回到顶部」. */
|
||||
export const REPORT_EDGE_JUMP_NEAR_END_PX = 480;
|
||||
|
||||
export type ReportEdgeDirection = "end" | "top";
|
||||
|
||||
export function reportEdgeDirection(scroller: Pick<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">): ReportEdgeDirection {
|
||||
return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= REPORT_EDGE_JUMP_NEAR_END_PX ? "top" : "end";
|
||||
}
|
||||
|
||||
/**
|
||||
* One floating control on the reader: 「到底部」 until the reader is near the
|
||||
* end, then 「回到顶部」 — never both at once. Not a scroll follower: it moves
|
||||
* only when clicked, and it owns no second scroll anchor (AGENTS §6).
|
||||
*
|
||||
* Sections below the fold render lazily, so the document is short until they
|
||||
* mount. `revealAll` must synchronously mount every section before the jump,
|
||||
* or the scroll would stop wherever the placeholders ended.
|
||||
*/
|
||||
export function ReportEdgeJump({ revealAll }: Readonly<{ revealAll: () => void }>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [direction, setDirection] = useState<ReportEdgeDirection>("end");
|
||||
|
||||
useEffect(() => {
|
||||
const scroller = ref.current?.closest<HTMLElement>(".personal-report-reader");
|
||||
if (!scroller) return;
|
||||
const update = () => setDirection(reportEdgeDirection(scroller));
|
||||
scroller.addEventListener("scroll", update, { passive: true });
|
||||
return () => scroller.removeEventListener("scroll", update);
|
||||
}, []);
|
||||
|
||||
function jump() {
|
||||
if (direction === "top") {
|
||||
ref.current?.closest<HTMLElement>(".personal-report-reader")?.scrollTo({ top: 0 });
|
||||
return;
|
||||
}
|
||||
revealAll();
|
||||
const scroller = ref.current?.closest<HTMLElement>(".personal-report-reader");
|
||||
scroller?.scrollTo({ top: scroller.scrollHeight });
|
||||
}
|
||||
|
||||
const toEnd = direction === "end";
|
||||
return (
|
||||
<div ref={ref} className="report-edge-jump personal-report-screen-only">
|
||||
<button type="button" className="report-edge-jump__button" onClick={jump}>
|
||||
{toEnd ? <ArrowDown aria-hidden="true" /> : <ArrowUp aria-hidden="true" />}
|
||||
{toEnd ? "到底部" : "回到顶部"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { detectPrintRestriction } from "@/lib/client-report-export";
|
||||
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
|
||||
import { exportFilename, joinExportBlocks, presetSelection, type ReportExportBlock, type ReportExportPreset } from "@/lib/report-export-blocks";
|
||||
import { exportByteSize, exportFilename, formatExportSize, joinExportBlocks, presetSelection, type ReportExportBlock, type ReportExportPreset } from "@/lib/report-export-blocks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const PRESETS = { common: "常用", charts: "全部分盘", tables: "仅核对表" } as const;
|
||||
@@ -23,6 +23,8 @@ export function ReportExportDrawer({ blocks, reportDate, onClose }: {
|
||||
const selected = blocks.filter(block => selection.has(block.id));
|
||||
const allSelected = selected.length === blocks.length;
|
||||
const chars = (items: readonly ReportExportBlock[]) => items.reduce((sum, block) => sum + block.chars, 0);
|
||||
const totalSize = useMemo(() => formatExportSize(exportByteSize(blocks)), [blocks]);
|
||||
const selectedSize = useMemo(() => formatExportSize(exportByteSize(blocks.filter(block => selection.has(block.id)))), [blocks, selection]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
@@ -68,7 +70,7 @@ export function ReportExportDrawer({ blocks, reportDate, onClose }: {
|
||||
<header className="report-export-header">
|
||||
<div className="report-export-heading"><h2 id="report-export-title">分块导出</h2><Button variant="ghost" onClick={onClose} aria-label="关闭分块导出">关闭</Button></div>
|
||||
<p id="report-export-description">勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。</p>
|
||||
<p className="report-export-count" aria-live="polite">共 {blocks.length} 块 · {chars(blocks)} 字 · 已选 {selected.length}/{blocks.length} · {chars(selected)} 字</p>
|
||||
<p className="report-export-count" aria-live="polite">共 {blocks.length} 块 · {chars(blocks)} 字 · {totalSize} · 已选 {selected.length}/{blocks.length} · {chars(selected)} 字 · {selected.length ? selectedSize : "0 KB"}</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>
|
||||
|
||||
@@ -109,3 +109,14 @@ export function presetSelection(preset: ReportExportPreset, blocks: readonly Rep
|
||||
export function exportFilename(reportDate: string | null | undefined, isFull: boolean): string {
|
||||
return `${personalReportMarkdownFilename(reportDate)}${isFull ? "" : "-节选"}`;
|
||||
}
|
||||
|
||||
/** UTF-8 bytes of exactly what the download would contain; not a per-character guess. */
|
||||
export function exportByteSize(blocks: readonly ReportExportBlock[]): number {
|
||||
return blocks.length ? new TextEncoder().encode(joinExportBlocks(blocks)).length : 0;
|
||||
}
|
||||
|
||||
/** 「约 186 KB」 / 「约 1.4 MB」; under 1 KB still reads 「约 1 KB」 so it never says 0. */
|
||||
export function formatExportSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `约 ${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `约 ${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user