feat(reports): streamline reader actions and center export dialog
Independent Staging Quality Gate / validate (push) Failing after 11m16s
Independent Staging Quality Gate / publish (push) Skipped

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
jesse-ux
2026-09-24 20:43:35 +08:00
co-authored by Claude Code
parent 133aca49db
commit f125fae0a0
15 changed files with 105 additions and 215 deletions
+8
View File
@@ -1,5 +1,13 @@
# BLOCKED
## TASK-report-reader-actions:删除权限与依赖验收阻塞(2026-09-24)
- 删除权限 blocked:`frontend/src/components/personal-report/raw-appendix-download.tsx`、`frontend/src/app/api/reports/[reportId]/raw-appendix/route.ts`、`frontend/src/lib/personal-report-raw-appendix.ts` 的 rm 已被拒。三个文件原样保留,不删除、清空、替换或转交他人删除;相关 raw-appendix 与 density-retry 测试保留。阅读页入口已撤下不等于服务端通道已退役,T2 与全仓零命中红线尚未完全满足,等待用户批准。
- 前置依赖验收 blocked:本树 HEAD / fetch 后 origin/staging 为 `133aca49dbef342e3ba97f38b98a481c428b962c`。协调主会话本轮只读核验:公共 staging health HTTP 200,但 `deployment.gitCommit=1420471ab13f1a1970922a0a1db1dd3db70656a1`;`/login` 200,匿名 `/api/account` 401。含代码前置尚未部署验证,不写成仅文档差异。
- 协调主会话补充:Gitea actions runs API 请求 HTTP 401,质量门禁不可核对;这不证明 CI 失败,也不证明 token 全局失效。公共 health 根层 `swisseph_available` 未返回(null),不能标为已通过。执行代理未读取或借用凭据。
- 进度文档 blocked:本执行代理的开发者指令禁止新写 report / summary / findings / analysis `.md`,因此未新建 `docs/tasks/PROGRESS-report-reader-actions-20260924.md`,不以其它工具绕过或转交其它代理写入。既有测试三栏与名称对照直接随执行结果回报;不得称 T4 文档全部齐备。
- 真人清单落盘 blocked:尝试按任务新建 `docs/testing/report-reader-actions-20260924.md` 被工具拒绝(子代理不得写 report 文件),文件不存在;未换工具重试或转交他人写入。待执行项目为:列表仅查看、操作条仅返回/导出图标、旧 hash 不自动打开;860/1024/1440px 视口居中且侧栏切换不偏移,390/859px 保持底部抽屉;长列表滚动且首尾固定;Esc/遮罩关闭与焦点回收;套餐/计数/文件名/逐字节导出与微信限制不变;Ctrl+P A4、不裁表、打印前展开与取消/保存后恢复。本轮未启动页面、没有受控登录或真机证据;Node 源码合同 / React harness 不替代这些验收。未 commit / push,不启动 reader-main。
> 2026-09-24交付补记:产品已在获知缺口后授权两单组合版本push到staging;下述两单未提交/未推送描述为验证时快照,交付以实际push与远端SHA核对为准。推送不解除BUG-1020、完整构建或受控真机缺口,不代表部署通过。
## BUG-1021 / 1022:慢网首页与账户点数本地验收边界(2026-09-24)
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-09-24 — 报告操作收敛与桌面导出居中(本地未提交,依赖验收 blocked)
- 报告列表已完成行只保留「查看报告」;阅读页仅保留返回和导出图标,不再自动消费旧 `#export` 链接。
- 桌面 ≥860px 导出对话框在整个视口居中,宽 min(720px, 92vw)、最高 80vh;窄屏维持贴底抽屉,套餐、下载内容、文件名及既有微信提示不变。
- 撤下打印和原始附录按钮,保留 Ctrl+P 排版与核对表打印前展开/结束后恢复。原始附录组件、API 路由和 helper 删除被拒,三个文件及其测试原样保留,不能宣称退役已全部完成。
- 不改导出块或下载器,不启动 reader-main;Skill 版本不 bump。未 commit / push;自动化和前置部署验收缺口见 BLOCKED。
## 2026-09-24 — 慢网首页与账户点数首开优化(待 staging 验收)
- 首页取消 8 秒硬超时:账户 / 会话、模型与咨询状态并行准备,20 秒后才进入全屏错误屏;错误屏重试局部重跑,不整页刷新,模型失败不揭幕半成品。
+2
View File
@@ -2583,6 +2583,7 @@
- 修复:为 ready 报告建立独立 `.personal-report-reader` 滚动容器;将报告重构为 answer-first 的研究备忘录版式(封面快照、核心判断、D1 与关键证据双栏、主题解读、证据附录),使用既有中文正文字体、暖象牙纸张色和细分隔线,不引入卡片阴影或渐变。SVG 逐宫应用 transform 与 clipPath,密集星体最多显示七项加 `+N 项`。打印时恢复祖先自动高度和可见 overflow,重置表格 `min-width`/wrapper overflow,启用 fixed layout 与强制换行,并允许长主题自然分页;A4 `@page` 只随 ready 报告 route 注入,不再污染全站打印。恢复“打印 / 保存为 PDF”操作并保留不支持打印浏览器的能力保护;附录改用原生 `details/summary`,即使客户端 hydration 延迟也可展开,打印时始终显示内容。
- 验证:个人报告聚焦测试覆盖 answer-first 顺序、十二宫坐标/裁切/密集数据、独立滚动边界、打印表格与分页、原生附录 disclosure、PDF 操作能力保护;浏览器在 1363×936 CSS viewport 实测报告容器 `clientHeight=936`、`scrollHeight=3466`,滚动后 `scrollTop=980` 并可到达底部,附录展开后高度增至 5219;参考图与实现图完成同画布视觉比对。聚焦 ESLint、生产构建和 `git diff --check` 作为最终门禁重新执行。
- 防复发:聊天根滚动锁与报告阅读滚动必须分层;报告回归测试不得删除 `100dvh/overflow-y:auto` 合同。任何命盘 occupants 改动必须验证至少两个不同宫位 transform、clipPath 与 12 项密集输入。打印回归必须覆盖长摘要、长主题、最小宽度表格和无滚动容器的 A4 输出;PDF 入口的隐藏/恢复必须同步更新明确的能力与 ready-state 合同。
- 2026-09-24 产品修订:`TASK-report-reader-actions-20260924` D2 明确撤下打印按钮与专用调用,不是此 Bug 复发;A4、壳层解锁、表格分页以及 beforeprint/afterprint 展开/恢复保留,浏览器 Ctrl+P 仍可用。本地候选只更新入口合同,不把受阻完整测试、真机打印或部署记为通过。
- 相关记录:BUG-126
- 复发自:无
- 修复版本:本地候选(未 push / deploy)
@@ -13329,6 +13330,7 @@
- 相关记录:BUG-999、BUG-1003、BUG-1005
- 复发自:无;为经产品授权新增的独立通道,普通下载限制仍有效。
- 修复版本:`bbd96d3b` 已合入 staging;未确认部署。
- 2026-09-24 产品修订:`TASK-report-reader-actions-20260924` D2 撤下阅读页原始附录按钮,普通导出投影与快照恢复不变;不新开 Bug。清理组件、raw-appendix 路由和 helper 的删除操作被权限系统拒绝,本轮遵从用户后续边界,三个文件及其回归测试原样保留,状态为删除权限 blocked,不能声称完整退役或部署通过。
## BUG-1005 | 个人报告没有可承载引擎事实表的结构化层
+5 -5
View File
@@ -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
+3 -2
View File
@@ -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 原样回退,不造含义。
+12 -1
View File
@@ -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 -58
View File
@@ -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 = "打印";
+7 -5
View File
@@ -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", () => {
+15 -63
View File
@@ -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/);
});
+7 -8
View File
@@ -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", () => {
+26 -9
View File
@@ -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;/);
});