feat(reports): streamline reader actions and center export dialog
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -3866,7 +3866,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.report-center-delete-confirm p { margin: 0; }
|
||||
.report-center-delete-confirm button, .report-center-menu button { min-height: 44px; }
|
||||
|
||||
/* Native modal in the report's content column, never over the navigation rail. */
|
||||
/* Native modal: narrow screens keep the content-column sheet; desktop centers in the viewport. */
|
||||
.report-export-drawer {
|
||||
--report-export-sidebar: 0px;
|
||||
position: fixed;
|
||||
@@ -3892,6 +3892,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
@supports (height: 1dvh) {
|
||||
.report-export-drawer, .report-export-panel { max-height: 70dvh; }
|
||||
}
|
||||
/* After the narrow-screen dvh upgrade so it cannot cap desktop at 70dvh. */
|
||||
@media (min-width: 860px) {
|
||||
.report-export-drawer {
|
||||
inset: 0;
|
||||
width: min(720px, 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); }
|
||||
|
||||
@@ -9,10 +9,7 @@ import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
import {
|
||||
PERSONAL_REPORT_EXPORT_LABEL,
|
||||
PERSONAL_REPORT_GENERATING_COPY,
|
||||
} from "@/lib/personal-report-longform-copy";
|
||||
import { PERSONAL_REPORT_GENERATING_COPY } from "@/lib/personal-report-longform-copy";
|
||||
import { REPORT_DEPTH_LABELS, reportThemeLabel } from "@/lib/personal-report-progress";
|
||||
import { ReportRowDelete } from "./report-row-delete";
|
||||
import {
|
||||
@@ -209,9 +206,6 @@ export function PersonalReportCenter() {
|
||||
<Button render={<AppLink href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
|
||||
查看报告
|
||||
</Button>
|
||||
<Button render={<AppLink href={`/reports/${encodeURIComponent(report.id)}#export`} />} nativeButton={false} variant="ghost">
|
||||
{PERSONAL_REPORT_EXPORT_LABEL}
|
||||
</Button>
|
||||
</div>
|
||||
) : report.status === "generating" ? (
|
||||
<div className="report-center-row-actions">
|
||||
|
||||
@@ -476,8 +476,6 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
<style media="print">{"@page { size: A4; margin: 13mm 12mm 14mm; }"}</style>
|
||||
<style media="print">{REPORT_SHELL_PRINT_CSS}</style>
|
||||
<ReportActions
|
||||
reportId={state.reportId || reportId}
|
||||
reportTitle="个人报告"
|
||||
markdown={state.markdown}
|
||||
charts={state.calculationCharts}
|
||||
factTables={state.factTables}
|
||||
|
||||
@@ -2,78 +2,33 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowLeft, FileText, Printer, TriangleAlert } from "lucide-react";
|
||||
import { ArrowLeft, Download } from "lucide-react";
|
||||
|
||||
import {
|
||||
detectPrintRestriction,
|
||||
isPrintSupported,
|
||||
printPersonalReport,
|
||||
safeReportFilename,
|
||||
} from "@/lib/client-report-export";
|
||||
import { buildReportExportBlocks } from "@/lib/report-export-blocks";
|
||||
import type { ReportDocumentV2 } from "@/lib/personal-report-contract";
|
||||
import type { ReportFactTable } from "@/lib/report-fact-table-schema";
|
||||
import { ReportExportDrawer } from "./report-export-drawer";
|
||||
import {
|
||||
PERSONAL_REPORT_EXPORT_LABEL,
|
||||
PERSONAL_REPORT_PRINT_LABEL,
|
||||
} from "@/lib/personal-report-longform-copy";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RawAppendixDownload } from "./raw-appendix-download";
|
||||
|
||||
interface ReportActionsProps {
|
||||
reportId: string;
|
||||
reportTitle?: string;
|
||||
markdown: string;
|
||||
charts?: ReportDocumentV2["charts"];
|
||||
factTables?: readonly ReportFactTable[];
|
||||
reportDate?: string | null;
|
||||
}
|
||||
|
||||
function subscribePrintCapability(onStoreChange: () => void): () => void {
|
||||
queueMicrotask(onStoreChange);
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
export function ReportActions({
|
||||
reportId,
|
||||
reportTitle,
|
||||
markdown,
|
||||
charts,
|
||||
factTables,
|
||||
reportDate,
|
||||
}: ReportActionsProps) {
|
||||
const [printBusy, setPrintBusy] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const blocks = useMemo(() => buildReportExportBlocks({ markdown, charts, factTables }), [markdown, charts, factTables]);
|
||||
useEffect(() => {
|
||||
// ReportActions mounts only in markdown-ready. Consume once, including StrictMode.
|
||||
if (window.location.hash !== "#export") return;
|
||||
window.history.replaceState(window.history.state, "", window.location.pathname + window.location.search);
|
||||
queueMicrotask(() => setExportOpen(true));
|
||||
}, []);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const printSupported = useSyncExternalStore(subscribePrintCapability, isPrintSupported, () => false);
|
||||
|
||||
async function handlePrint() {
|
||||
if (printBusy || exportOpen || !printSupported) return;
|
||||
const restriction = detectPrintRestriction();
|
||||
if (restriction.restricted) {
|
||||
setNotice(restriction.message ?? "当前浏览器无法可靠打印,请在系统浏览器中打开本页。");
|
||||
return;
|
||||
}
|
||||
setPrintBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
await printPersonalReport({ title: reportTitle?.trim() || safeReportFilename(reportId) });
|
||||
} finally {
|
||||
setPrintBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="报告操作" className="personal-report-actions personal-report-screen-only">
|
||||
@@ -82,13 +37,15 @@ export function ReportActions({
|
||||
<ArrowLeft aria-hidden="true" />返回报告中心
|
||||
</Link>
|
||||
<div className="personal-report-action-end">
|
||||
<RawAppendixDownload reportId={reportId} />
|
||||
{notice && <span role="status"><TriangleAlert aria-hidden="true" />{notice}</span>}
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => void handlePrint()} disabled={printBusy || exportOpen || !printSupported}>
|
||||
<Printer aria-hidden="true" />{printBusy ? "正在准备…" : PERSONAL_REPORT_PRINT_LABEL}
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => setExportOpen(true)} disabled={printBusy}>
|
||||
<FileText aria-hidden="true" />{PERSONAL_REPORT_EXPORT_LABEL}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="导出报告"
|
||||
title="导出"
|
||||
onClick={() => setExportOpen(true)}
|
||||
>
|
||||
<Download aria-hidden="true" />
|
||||
</Button>
|
||||
{exportOpen && <ReportExportDrawer blocks={blocks} reportDate={reportDate} onClose={() => setExportOpen(false)} />}
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
/**
|
||||
* Client-side print export for the personal report.
|
||||
*
|
||||
* Deliberately print-only: `await document.fonts.ready` then `window.print()`.
|
||||
* It never requests a server-side PDF endpoint, never launches a headless
|
||||
* browser, and never rasterizes pixels: the user's own browser and OS render
|
||||
* the PDF.
|
||||
*/
|
||||
/** Browser restrictions for the local Markdown export dialog. */
|
||||
|
||||
export interface PrintPersonalReportOptions {
|
||||
/** Title used as the browser's default PDF filename (document.title). */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** Sanitize a report id into a safe PDF filename fragment (no path/query chars). */
|
||||
export function safeReportFilename(reportId: string): string {
|
||||
const cleaned = String(reportId ?? "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
return cleaned.length > 0 ? `jyotisha-report-${cleaned}` : "jyotisha-report";
|
||||
}
|
||||
|
||||
export function isPrintSupported(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.print === "function";
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect environments whose built-in browser cannot print reliably.
|
||||
* WeChat's in-app browser is the known case; it should ask users to open the
|
||||
* page in the system browser instead of producing a broken printout.
|
||||
*/
|
||||
export function detectPrintRestriction(userAgent?: string): {
|
||||
restricted: boolean;
|
||||
message?: string;
|
||||
@@ -43,30 +13,3 @@ export function detectPrintRestriction(userAgent?: string): {
|
||||
}
|
||||
return { restricted: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for web fonts, then open the browser print dialog.
|
||||
* The page/PDF layout is computed on the user's device; nothing is uploaded.
|
||||
*/
|
||||
export async function printPersonalReport(options: PrintPersonalReportOptions = {}): Promise<void> {
|
||||
if (typeof window === "undefined" || typeof window.print !== "function") {
|
||||
return;
|
||||
}
|
||||
const fonts = typeof document !== "undefined" ? document.fonts : undefined;
|
||||
if (fonts && typeof fonts.ready?.then === "function") {
|
||||
await fonts.ready;
|
||||
}
|
||||
const previousTitle = typeof document !== "undefined" ? document.title : "";
|
||||
const requested = typeof options.title === "string" ? options.title.trim() : "";
|
||||
if (typeof document !== "undefined" && requested.length > 0) {
|
||||
// Browsers derive the default PDF filename from document.title.
|
||||
document.title = requested;
|
||||
}
|
||||
try {
|
||||
window.print();
|
||||
} finally {
|
||||
if (typeof document !== "undefined") {
|
||||
document.title = previousTitle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const PERSONAL_REPORT_LEGACY_PLACEHOLDER = "旧版本报告,请重新生成";
|
||||
export const PERSONAL_REPORT_GENERATING_COPY = "正在生成报告,大约 10–30 秒";
|
||||
export const PERSONAL_REPORT_EXPORT_LABEL = "分块导出";
|
||||
export const PERSONAL_REPORT_PRINT_LABEL = "打印";
|
||||
|
||||
Reference in New Issue
Block a user