feat(reports): streamline reader actions and center export dialog
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+5
-5
@@ -4,7 +4,7 @@ This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped J
|
||||
|
||||
## 报告核对层与独立附录(2026-09-23)
|
||||
|
||||
个人报告保留原 Markdown 阅读、目录隔离与 memo 边界;文后独立渲染服务端分盘和事实表。分盘复用 VedicChartSvg 与 chart-grid/chart-card,不使用 float 或负 margin。事实表默认折叠、可键盘展开,横向溢出仅在表格区域滚动,缺数据不补写。Sade Sati 明确缺三轮日期。操作区增加「下载原始附录」,有独立忙态和简短错误提示,不接普通下载 fallback、不增加 spinner。旧缓存不升级、不重算;没有结构化层就不显示占位盘。文案遵守 VOICE:不把可计算写成已验证。
|
||||
个人报告保留原 Markdown 阅读、目录隔离与 memo 边界;文后独立渲染服务端分盘和事实表。分盘复用 VedicChartSvg 与 chart-grid/chart-card,不使用 float 或负 margin。事实表默认折叠、可键盘展开,横向溢出仅在表格区域滚动,缺数据不补写。Sade Sati 明确缺三轮日期。09-24 reader-actions D2 撤下操作区「下载原始附录」入口;相关组件、路由和 helper 因删除权限阻塞暂留源码,不当作完成清理。不接普通下载 fallback、不增加 spinner。旧缓存不升级、不重算;没有结构化层就不显示占位盘。文案遵守 VOICE:不把可计算写成已验证。
|
||||
|
||||
事实表用中文列名和领域行列呈现,不把引擎路径当行名。主运、分运与小运、SAV 与 BAV、年度位置与 Saham 分为同组子表;缺失的年度行星位置明确说明,不用本命盘替代。机器溯源路径保留在数据中、不渲染。打印或保存 PDF 前自动展开此核对层全部折叠组,完成或取消后恢复原状态;打印解除表格横向滚动裁剪、重复表头,宽 BAV 表仍保留十二星座列。不新增打印入口或等待动画。
|
||||
|
||||
@@ -496,17 +496,17 @@ one skeleton, none of them carrying the sidebar.
|
||||
- **Surface:** `--color-canvas-soft` floor; completed rows are paper, other rows canvas, with a warm hairline and `--radius-lg` on the list as a whole. No drop shadow.
|
||||
- **Metadata and deletion:** full Chinese date · localized depth · localized themes; unknown IDs remain readable verbatim. Every row has a 44px top-right `⋯` menu containing only 「删除」. It opens the row-local 「确定删除这份报告?」 with 删除 / 取消, never `window.confirm`. Failure stays in that row; success removes it and invalidates list cache. In-flight GET responses cannot resurrect a deleted row.
|
||||
- **Width:** 900px centered, matching long-form chat reading. Rows stack at the global 767px cut.
|
||||
- **Actions:** “生成完整报告” is the one filled action. A ready row keeps “查看报告” as the primary document action with “分块导出” quiet beside it. The latter links to `/reports/:id#export`; only the ready reader consumes and clears the hash once. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. A failed row states the reason in place and offers no second generate entry — the header already has one. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time.
|
||||
- **Actions:** “生成完整报告” is the one filled action. A ready row keeps only “查看报告”; export is selected inside the reader so the list does not duplicate that action. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. A failed row states the reason in place and offers no second generate entry — the header already has one. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time.
|
||||
- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. First-screen loading uses `SecondaryPageShell` waiting copy 「报告列表还没拿到。」 — never a spinner, skeleton, or 「正在加载」. Export is local to the ready reader's drawer and never starts a second writing flow or fetch. Download failures remain in the open drawer; only success closes it and emits a toast. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate.
|
||||
- **Accessibility:** generate, refresh, and row actions are 44px. The generating row's chip is a live region; a failed export is an alert.
|
||||
|
||||
### Personal report reader
|
||||
|
||||
- **分块导出抽屉 (2026-09-24):** native modal `<dialog>`, bottom-aligned on all screens, app colours only, `min(760px, 92% of available content width)`, at most 70vh/70dvh, offset past the expanded/collapsed sidebar. Header and single export footer stay visible; the block list scrolls, two columns at the existing 860px breakpoint. Escape, backdrop or 关闭 dismisses; focus returns without scrolling. All controls have 44px targets. No spinner, clipboard or extra fetch.
|
||||
- **分块导出对话框 (2026-09-24):** native modal `<dialog>`, bottom-aligned below 860px and centered at ≥860px, app colours only. Desktop is viewport-centered (`inset: 0; margin: auto`) regardless of sidebar state, width `min(720px, 92vw)`, max-height 80vh, with full `--radius-lg` corners. Its flex panel is capped at `calc(80vh - 2px)` to reserve the dialog border; desktop rules follow the narrow-screen dvh upgrade so 70dvh cannot override them. Below 860px the content-column offset, bottom alignment and at most 70vh/70dvh remain unchanged. Header and single export footer stay visible; the block list scrolls, two columns at the existing 860px breakpoint. Escape, backdrop or 关闭 dismisses; focus returns without scrolling. All controls have 44px targets. No spinner, clipboard or extra fetch.
|
||||
- **Export selection:** default 常用 selects every body block from the single outline's lossless source slices (lead = 摘要); 全部分盘 and 仅核对表 replace the selection. Manual toggles clear preset highlighting. 全选 / 取消全选 works across all groups; zero selected disables export. Counts are actual Unicode code points in serialized block text, including SVG markup, not approximate prose counts.
|
||||
- **Export content:** full body alone remains byte-identical to the retired ordinary Markdown export. Selected chart/table blocks append new content; selecting all groups is not byte-identical to body-only. D13 requires safe SVG in each chart, reusing the projected engine SVG or the existing hook-free Vedic drawing; offline fallback uses readable monochrome glyphs/legend and presentation attributes. No chart JSON fences or internal audit fields. Fact-table labels/precision/subtables match the reader. Full body uses the historical filename; excerpt adds `-节选`. The existing downloader appends `.md`.
|
||||
|
||||
- **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back, secondary print, primary “分块导出”, plus the existing raw appendix download) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
|
||||
- **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back and the export icon button) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
|
||||
- **TOC rail:** a persistent right-hand column built from the outline's own `##`/`###` ids — no second slugger, and nothing that touches the chart-grid rehype pass. It is sticky under the report's own action bar, highlights the section in view via `IntersectionObserver`, and marks it with `aria-current="location"` plus a 2px `--color-action` bar. Placement is explicit (`grid-column: 2`) rather than DOM-ordered, so the narrow-screen drawer can stay first in the source. Below 860px the rail is gone and what remains is the collapsed 「目录」 drawer.
|
||||
- **Paper stays paper:** the rail reads in the app palette because it is chrome. `--report-paper` / `--report-rule` / `--report-accent` are the document's own, deliberately out of step with the app accent, and nothing in this surface puts them on chrome or takes chrome colour onto the page. D11, continuing D3.
|
||||
- **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette.
|
||||
@@ -517,7 +517,7 @@ one skeleton, none of them carrying the sidebar.
|
||||
- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them.
|
||||
- **Width:** 1120px for the reader chrome; the article takes `minmax(0, 1fr)` and the TOC rail `minmax(11rem, 15rem)` to its right. The TOC collapses into a drawer below 860px.
|
||||
- **States:** loading, generating, timed-out, unauthorized, not-found, failed, invalid, network-error, markdown-ready, legacy-unavailable. Waiting uses `InlineSpinner` and one-step copy.
|
||||
- **Accessibility:** back, print, and Markdown export are 44px. Generating copy uses `role="status"`. Print remains keyboard-initiated from the chrome button. Markdown is rendered with `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`.
|
||||
- **Accessibility:** the action bar has only 返回报告中心 and a 44×44 Download icon button (`aria-label="导出报告"`, native tooltip「导出」). No raw-appendix or print button, and old `#export` links no longer auto-open a dialog. Browser Ctrl+P keeps the existing print styles and beforeprint/afterprint fact-table expansion/restoration. Generating copy uses `role="status"`. Markdown is rendered with `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`.
|
||||
|
||||
### Starter home
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
## 报告分块导出与删除
|
||||
|
||||
- 入口与标题:「分块导出」。说明:「勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。」这里全选包含完整正文及另选的盘 / 表;与旧整篇逐字节相同的套餐是「常用」(全部正文)。
|
||||
- 套餐:「常用 / 全部分盘 / 仅核对表」;动作:「全选 / 取消全选」「导出选中 (N)」。成功才提示「已导出 N 块」,下载失败或微信限制不提示成功。
|
||||
- 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」与导出图标;图标读屏名「导出报告」,tooltip「导出」。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。
|
||||
- 导出对话框标题仍为「分块导出」。说明:「勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。」这里全选包含完整正文及另选的盘 / 表;与旧整篇逐字节相同的套餐是「常用」(全部正文)。
|
||||
- 套餐:「常用 / 全部分盘 / 仅核对表」;动作:「全选 / 取消全选」「导出选中 (N)」。成功才提示「已导出 N 块」,下载失败或微信限制不提示成功。本轮 D5 保持既有微信系统浏览器提示原文,不借删除打印入口顺带改它。
|
||||
- 「共 N 块 · X 字 · 已选 n/N · Y 字」来自实际可导出块及 `Array.from(text).length`;包含安全 SVG / Markdown 标记,不是估计文章字数。此为第 2 条的可直接计算文本统计,不是口算领域数字;列表页仍不杜撰章数或盘数。
|
||||
- 删除确认:「确定删除这份报告?」;按钮:「删除 / 取消」;请求中:「删除中…」;失败:「报告没能删除,请稍后重试。」失败保留原行。
|
||||
- 日期中文全写;深度「简版 / 标准深度 / 深度 / 研究级」;主题复用现有中文映射,未知 id 原样回退,不造含义。
|
||||
|
||||
@@ -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 = "打印";
|
||||
|
||||
@@ -304,10 +304,10 @@ test("legacy consultation Markdown export is untouched and still works", () => {
|
||||
});
|
||||
|
||||
|
||||
test("ready reports expose Markdown export only in the ready branch", () => {
|
||||
assert.match(reportCenterSource, /PERSONAL_REPORT_EXPORT_LABEL/);
|
||||
assert.match(reportCenterSource, /#export/);
|
||||
test("ready reports expose only the reader action in the ready branch", () => {
|
||||
assert.doesNotMatch(reportCenterSource, /PERSONAL_REPORT_EXPORT_LABEL|#export/);
|
||||
assert.doesNotMatch(reportCenterSource, /downloadPersonalReportLongformAppendix|exportingReportId|exportError/);
|
||||
assert.match(reportCenterSource, /查看报告/);
|
||||
assert.match(reportCenterSource, /cardSummary/);
|
||||
assert.doesNotMatch(longformDownloadSource, /professional-reference/);
|
||||
assert.match(longformDownloadSource, /projectOrdinaryReportMarkdown/);
|
||||
@@ -324,8 +324,10 @@ test("ready reports expose Markdown export only in the ready branch", () => {
|
||||
);
|
||||
assert.ok(readyActionStart >= 0 && generatingActionStart > readyActionStart);
|
||||
const readyBranch = reportCenterSource.slice(readyActionStart, generatingActionStart);
|
||||
assert.match(readyBranch, /PERSONAL_REPORT_EXPORT_LABEL/);
|
||||
assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /PERSONAL_REPORT_EXPORT_LABEL/);
|
||||
assert.match(readyBranch, /查看报告/);
|
||||
assert.equal((readyBranch.match(/<Button\b/g) ?? []).length, 1, "ready row has exactly one document action");
|
||||
assert.doesNotMatch(readyBranch, /#export|分块导出/);
|
||||
assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /分块导出/);
|
||||
});
|
||||
|
||||
test("report centre is a single-column row list with a dotted status chip", () => {
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
detectPrintRestriction,
|
||||
isPrintSupported,
|
||||
printPersonalReport,
|
||||
safeReportFilename,
|
||||
} from "../src/lib/client-report-export.ts";
|
||||
import { detectPrintRestriction } from "../src/lib/client-report-export.ts";
|
||||
|
||||
const exportSource = readFileSync(
|
||||
new URL("../src/lib/client-report-export.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const actionsSource = readFileSync(
|
||||
new URL("../src/components/personal-report/report-actions.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const pageSource = readFileSync(
|
||||
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -22,61 +20,14 @@ const documentViewSource = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("safeReportFilename strips path, query and control characters", () => {
|
||||
assert.equal(safeReportFilename("../../etc/passwd?x=1&y=2"), "jyotisha-report-etc-passwd-x-1-y-2");
|
||||
assert.equal(safeReportFilename("a b<c>d|e:f"), "jyotisha-report-a-b-c-d-e-f");
|
||||
assert.equal(safeReportFilename(""), "jyotisha-report");
|
||||
assert.equal(safeReportFilename(null as unknown as string), "jyotisha-report");
|
||||
const cleaned = safeReportFilename("abcdefghijklmnopqrstuvwxyz0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ-extra");
|
||||
assert.ok(cleaned.startsWith("jyotisha-report-"));
|
||||
assert.ok(cleaned.length <= "jyotisha-report-".length + 64);
|
||||
assert.doesNotMatch(cleaned, /[^a-zA-Z0-9_-]/);
|
||||
});
|
||||
|
||||
// TASK-report-reader-actions D2 retires the print helper's four tests, not Ctrl+P.
|
||||
// Old/new assertions and test-name mapping are recorded in the task PROGRESS.
|
||||
test("detectPrintRestriction flags WeChat in-app browser and nothing else", () => {
|
||||
assert.equal(detectPrintRestriction("Mozilla/5.0 MicroMessenger/8.0.49").restricted, true);
|
||||
assert.equal(detectPrintRestriction("Mozilla/5.0 (iPhone) Safari").restricted, false);
|
||||
assert.equal(detectPrintRestriction(undefined).restricted, false);
|
||||
assert.match(detectPrintRestriction("MicroMessenger").message ?? "", /系统浏览器/);
|
||||
});
|
||||
|
||||
test("isPrintSupported requires a usable window.print", () => {
|
||||
assert.equal(isPrintSupported(), false);
|
||||
(globalThis as Record<string, unknown>).window = { print: () => undefined };
|
||||
assert.equal(isPrintSupported(), true);
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
});
|
||||
|
||||
test("printPersonalReport waits for fonts, then calls window.print exactly once", async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveFonts: (() => void) | undefined;
|
||||
const fontsReady = new Promise<void>((resolve) => {
|
||||
resolveFonts = resolve;
|
||||
});
|
||||
const fakeDocument = { fonts: { ready: fontsReady }, title: "original-title" };
|
||||
(globalThis as Record<string, unknown>).window = {
|
||||
print: () => {
|
||||
calls.push("print");
|
||||
// Browsers derive the PDF filename from document.title at print time.
|
||||
assert.equal(fakeDocument.title, "safe-filename-title");
|
||||
},
|
||||
};
|
||||
(globalThis as Record<string, unknown>).document = fakeDocument;
|
||||
const printPromise = printPersonalReport({ title: "safe-filename-title" });
|
||||
// Fonts not ready yet: print must not have fired.
|
||||
assert.deepEqual(calls, []);
|
||||
resolveFonts?.();
|
||||
await printPromise;
|
||||
assert.deepEqual(calls, ["print"]);
|
||||
// Original title restored afterwards.
|
||||
assert.equal(fakeDocument.title, "original-title");
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
delete (globalThis as Record<string, unknown>).document;
|
||||
});
|
||||
|
||||
test("printPersonalReport is a no-op without window", async () => {
|
||||
// window is already deleted; must not throw.
|
||||
await printPersonalReport({ title: "x" });
|
||||
assert.equal(detectPrintRestriction("MicroMessenger").message,
|
||||
"微信内置浏览器可能无法完整打印,请在系统浏览器中打开本页后打印。");
|
||||
});
|
||||
|
||||
test("client export never touches a server PDF pipeline", () => {
|
||||
@@ -84,15 +35,16 @@ test("client export never touches a server PDF pipeline", () => {
|
||||
assert.doesNotMatch(exportSource, /html2canvas|jsPDF|jspdf|playwright|puppeteer|chromium/i);
|
||||
assert.doesNotMatch(exportSource, /canvas|base64/i);
|
||||
assert.doesNotMatch(exportSource, /\bfetch\s*\(/);
|
||||
assert.match(exportSource, /window\.print/);
|
||||
assert.match(exportSource, /document\.fonts/);
|
||||
assert.doesNotMatch(exportSource, /window\.print|document\.fonts|document\.title/);
|
||||
assert.doesNotMatch(exportSource, /printPersonalReport|isPrintSupported|safeReportFilename/);
|
||||
});
|
||||
|
||||
|
||||
test("native print remains a secondary action on the Markdown report page", () => {
|
||||
test("the Markdown report keeps print layout support without a reader print button", () => {
|
||||
assert.match(pageSource, /longformMarkdown/);
|
||||
assert.match(pageSource, /PersonalReportMarkdownView/);
|
||||
assert.doesNotMatch(pageSource, /PersonalReportDocumentView/);
|
||||
assert.match(pageSource, /<style media="print">\{"@page \{ size: A4;/);
|
||||
assert.match(pageSource, /<style media="print">\{REPORT_SHELL_PRINT_CSS\}/);
|
||||
assert.doesNotMatch(actionsSource, /printPersonalReport|PERSONAL_REPORT_PRINT_LABEL|Printer|isPrintSupported/);
|
||||
assert.doesNotMatch(documentViewSource, /dangerouslySetInnerHTML|srcDoc|<iframe/i);
|
||||
assert.match(exportSource, /window\.print/);
|
||||
});
|
||||
|
||||
@@ -422,21 +422,20 @@ test("canonical contract rejects malformed documents", () => {
|
||||
assert.equal(safeParseReportDocument(badRefs).ok, false, "dangling evidenceRefs must fail guards");
|
||||
});
|
||||
|
||||
test("ready reports expose the browser print/PDF action with capability and hydration guards", () => {
|
||||
test("ready reports expose only the back link and accessible export icon", () => {
|
||||
const actionsSource = readFileSync(
|
||||
new URL("../src/components/personal-report/report-actions.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(actionsSource, /href="\/reports"/);
|
||||
assert.match(actionsSource, /返回报告中心/);
|
||||
assert.match(actionsSource, /PERSONAL_REPORT_EXPORT_LABEL/);
|
||||
assert.match(actionsSource, /PERSONAL_REPORT_PRINT_LABEL/);
|
||||
assert.match(actionsSource, /ReportExportDrawer/);
|
||||
assert.doesNotMatch(actionsSource, /downloadPersonalReportLongformAppendix/);
|
||||
assert.match(actionsSource, /printPersonalReport/);
|
||||
assert.match(actionsSource, /Printer/);
|
||||
assert.match(actionsSource, /useSyncExternalStore\(subscribePrintCapability, isPrintSupported, \(\) => false\)/);
|
||||
assert.match(actionsSource, /disabled=\{printBusy \|\| exportOpen \|\| !printSupported\}/);
|
||||
assert.match(actionsSource, /size="icon"/);
|
||||
assert.doesNotMatch(actionsSource, /PERSONAL_REPORT_EXPORT_LABEL|#export|reportId|reportTitle/);
|
||||
assert.match(actionsSource, /<Download aria-hidden="true" \/>/);
|
||||
assert.match(actionsSource, /aria-label="导出报告"/);
|
||||
assert.match(actionsSource, /title="导出"/);
|
||||
assert.doesNotMatch(actionsSource, /RawAppendixDownload|printPersonalReport|PERSONAL_REPORT_PRINT_LABEL|Printer|useSyncExternalStore|detectPrintRestriction/);
|
||||
});
|
||||
|
||||
test("the reader renders inside the app shell, in every phase", () => {
|
||||
|
||||
@@ -99,22 +99,25 @@ test("download failure and WeChat restrictions leave drawer open without a succe
|
||||
}
|
||||
});
|
||||
|
||||
test("ready actions consume export hash once and keep print and raw appendix", async () => {
|
||||
test("reader actions keep only the accessible export icon", async () => {
|
||||
const h = createClientLifecycleHarness();
|
||||
let replaces = 0;
|
||||
const location = { hash: "#export", pathname: "/reports/fictional", search: "?view=1" };
|
||||
Object.assign(window, { location, history: { state: { retained: true }, replaceState(state: unknown, _: string, url: string) { assert.deepEqual(state, { retained: true }); assert.equal(url, "/reports/fictional?view=1"); location.hash = ""; replaces += 1; } } });
|
||||
try {
|
||||
await h.render(<ReportActions reportId="fictional" markdown={reader.markdown} />);
|
||||
Object.assign(window, { location: { hash: "#export" }, history: { replaceState() { assert.fail("retired export hash must not be consumed"); } } });
|
||||
await h.render(<ReportActions markdown={reader.markdown} />);
|
||||
assert.ok(!h.elements().some(node => node.tagName === "DIALOG"), "old export hash does not auto-open the dialog");
|
||||
assert.equal(h.elements().filter(node => node.tagName === "BUTTON").length, 1);
|
||||
assert.ok(h.elements().some(node => node.tagName === "A" && node.getAttribute("href") === "/reports"));
|
||||
assert.deepEqual(h.errors, []);
|
||||
assert.equal(replaces, 1);
|
||||
const exportButton = h.elements().find(node => node.tagName === "BUTTON" && node.getAttribute("aria-label") === "导出报告");
|
||||
assert.ok(exportButton);
|
||||
assert.equal(exportButton?.getAttribute("title"), "导出");
|
||||
assert.ok(!h.container.text.includes("下载原始附录"));
|
||||
assert.ok(!h.container.text.includes("打印"));
|
||||
await h.event(exportButton!);
|
||||
assert.ok(h.elements().some(node => node.tagName === "DIALOG"));
|
||||
await h.event(button(h, "关闭"));
|
||||
await h.idle();
|
||||
assert.ok(!h.elements().some(node => node.tagName === "DIALOG"));
|
||||
assert.equal(replaces, 1);
|
||||
assert.ok(h.container.text.includes("下载原始附录"));
|
||||
assert.ok(h.container.text.includes("打印"));
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
@@ -140,4 +143,18 @@ test("drawer contract uses native dialog, Escape/backdrop close and only allowed
|
||||
assert.match(css, /min-width: 860px/);
|
||||
assert.doesNotMatch(css, /var\(--report-(?:paper|rule|accent)\)/);
|
||||
assert.match(css, /min-height: 44px/);
|
||||
const narrow = css.slice(0, css.indexOf("@media (min-width: 860px)"));
|
||||
assert.match(narrow, /inset: auto 0 0 var\(--report-export-sidebar\)/);
|
||||
assert.match(narrow, /width: min\(760px, calc\(\(100% - var\(--report-export-sidebar\)\) \* \.92\)\)/);
|
||||
assert.match(narrow, /margin: 0 auto/);
|
||||
assert.match(narrow, /border-radius: var\(--radius-lg\) var\(--radius-lg\) 0 0/);
|
||||
assert.match(narrow, /max-height: 70vh/);
|
||||
assert.match(narrow, /@supports \(height: 1dvh\)\s*\{\s*\.report-export-drawer, \.report-export-panel \{ max-height: 70dvh;/);
|
||||
const desktopStart = css.indexOf("@media (min-width: 860px)");
|
||||
const desktop = css.slice(desktopStart);
|
||||
assert.match(desktop, /\.report-export-drawer\s*\{\s*inset: 0;\s*width: min\(720px, 92vw\);\s*max-height: 80vh;\s*margin: auto;\s*border-radius: var\(--radius-lg\);/);
|
||||
assert.match(desktop, /\.report-export-panel \{ max-height: calc\(80vh - 2px\); \}/);
|
||||
assert.doesNotMatch(desktop, /max-height: 70(?:d)?vh|inset:.*--report-export-sidebar/);
|
||||
assert.match(css, /\.report-export-list \{[^}]*overflow-y: auto;[^}]*min-height: 0;/);
|
||||
assert.match(css, /\.report-export-header, \.report-export-footer \{[^}]*flex-shrink: 0;/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user