Compare commits

...
3 Commits
Author SHA1 Message Date
jesse-ux 1c71d4cba8 Record annual Markdown export and validation results
Independent Staging Quality Gate / validate-python (pull_request) Successful in 7m5s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m15s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-13 22:14:01 +08:00
jesse-ux e3fb1dda72 Add annual report Markdown export to the report header 2026-11-13 22:00:12 +08:00
Claude c577d51f26 docs(tasks): brief for #233 annual .md export (BUG-1869)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-13 20:47:31 +08:00
13 changed files with 526 additions and 33 deletions
+4
View File
@@ -1,5 +1,9 @@
# BLOCKED
## #233 年运 Markdown 导出的本机门禁
- 无 Docker 的前端全量失败清单与同机 staging 一致。Python 快速门的三项 Kaala / PyJHora 失败在 staging 复现。另一个 VedAstro 断言只在此次全量运行失败,两边定向复跑均通过。完整门禁留给独立验收。见 [#233 进度](docs/tasks/PROGRESS-annual-md-export-20261113.md)。
## #212 年运页面缺少方案 B 的写作事实字段
- ~~写作事实缺方案 B 展示字段,原型的年主星候选口径待定。~~ 评论 43147 已授权页面投影并确认五个候选来源。实现已完成,待独立验收。
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-11-13 — 年运报告可导出 Markdown
- 写完后从顶栏导出整篇或选取章节,计算依据可整组带走。旧报告也可导出。
- 沿用当前 Skill 版本(BUG-1869)。
## 2026-11-13 — 统一网页和报告的分隔符
- 浏览器标签、星盘、侧栏与报告页面按场景使用逗号、顿号、括号或间距。星盘出生资料卡的小字可换行。个人报告 Markdown 和后台报告正文采用同一规则。
+11
View File
@@ -25346,3 +25346,14 @@
- 验证:公开盘和合成盘复现事实响应缺字段,完整年包可取月相回归、月盘和 Tripataki。上游定向回归通过,细节见 [#212 进度](tasks/PROGRESS-annual-page-option-b-20261113.md)。
- 修复及防复发:复用完整年包生成页面字段,按引擎五个实际来源列年主星候选,接入返照日期、月相回归、补充层和双轴审计。真实快照回归覆盖两地、候选、导出和旧附录。写作证据和旧审计对照哈希一致。
- 关联记录:#212、#210、#211、BUG-1856、BUG-1857、BUG-1217。修复版本 `codex/annual-page-option-b-20261113`。
## BUG-1869 | 年运报告缺少整篇 Markdown 导出
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-13 / 2026-11-13。
- 影响面:年运报告顶栏、导出面板和已保存报告。
- 现象与触发:打开一份写完的年运报告,只能从附录导出计算依据,正文没有下载入口。
- 根因:年运页面只把附录交给 Markdown 下载器,没有按快照组织正文,也未接入共用的导出面板。
- 修复:写完后在顶栏显示共用按钮和面板。按页面顺序导出公开章节及计算依据。按旧快照现有字段导出,省略没有记录的返照块。
- 验证与防复发:覆盖写完、写作中、失败及缺节状态,检查整篇和计算依据选取、三列审计及旧报告回读。详见 [#233 进度](tasks/PROGRESS-annual-md-export-20261113.md)。
- 关联记录:#233、#212、BUG-1868、BUG-1870、BUG-1845。修复版本 `codex/annual-md-export-20261113`。
@@ -0,0 +1,19 @@
# #233 年运 Markdown 导出
- 可信任务书见评论 43227。从 staging `097d5b3a` 开工并快进到任务书提交。BUG-1869 是任务书预留编号,当前记录未占用,沿用该编号。
- 从个人报告抽出共用导出按钮。年运在 `SecondaryPageShell.actions` 调用它,并复用 `ReportExportDrawer` 的整篇和自选模式。年运只显示正文和计算依据,后者用一个复选框整组选取。旧报告中的计算依据单列审计仍可下载。
- 先导出标题、范围和返照日期,再按页面顺序导出各节。采用新时间轴的快照把时间点合在「这一年怎么走」,旧快照按原来的两个章节导出。月相回归放在正文末尾。附录按现有小节顺序使用 `annualAppendixMarkdown`,包括三列审计和全部本命瑜伽。
- 文件名取范围起始日,无起始日时取浏览器当天日期。正文只投影页面字段,计算依据沿用页面过滤。导出前应用普通报告公开内容的清洗。
- 既有测试断言未改。新增用例覆盖新旧快照、导出入口、分组选择、内容顺序和文件名。
## 验证
- 定向 36 项通过,包含个人报告导出和英文版既有用例。最终改动复跑年运导出、方案 B 和分隔符合同,9 项通过。
- 同机无 Docker 的前端全量比对 staging `097d5b3a`。本分支 3722 项,3639 通过。staging 3718 项,3635 通过。按测试名比较,新增 4 项,既有测试无缺失。两边相同的 46 项失败、37 项跳过、0 项取消。
- 390px Chrome 本地回放使用虚构报告和公开盘计算夹具。顶栏标题和按钮均可见,自选面板只显示两组,整篇下载的 Markdown 文件含正文、月相回归、审计表和外部验证,文件名与起始日一致。写作中与失败的报告不显示按钮。
- TypeScript 和生产构建通过。ESLint 0 错误,62 条既有警告。首页保持静态,首屏 gzip JS 516093 B 持平,CSS 从 90978 B 到 91054 B。
- Python 快速门退出 1,1482 项通过、2 项跳过、29 个子测试通过。三项 Kaala / PyJHora 失败在同机 staging 复现。其余一项全量失败是 `test_the_new_layer_leaves_every_existing_output_unchanged` 的 VedAstro 可选证据字段差异。本分支与 staging 定向复跑均通过,完整绿灯待独立验收。
## 需拍板
- 暂无。任务书列出了产品确认过的导出结构。原型链接在当前会话无法读取,界面按任务书文字复用现有控件。
+1
View File
@@ -35,6 +35,7 @@
| --- | --- | --- | --- | --- |
| [#234 任务书](TASK-remove-middle-dot-20261113.md) | [执行进度](PROGRESS-remove-middle-dot-20261113.md) | 用户可见分隔符(BUG-1870) | 待验收 | `codex/remove-middle-dot-20261113` |
| [#212 任务书](TASK-annual-page-option-b-20261113.md) | [执行进度](PROGRESS-annual-page-option-b-20261113.md) | 年运页面方案 B(BUG-1868) | 待验收 | `codex/annual-page-option-b-20261113` |
| [#233 任务书](TASK-annual-md-export-20261113.md) | [执行进度](PROGRESS-annual-md-export-20261113.md) | 年运报告 Markdown 导出(BUG-1869) | 待验收 | `codex/annual-md-export-20261113` |
| [#243 任务书](TASK-reading-computed-fallback-20261113.md) | [执行进度](PROGRESS-reading-computed-fallback-20261113.md) | 个人报告解读计算兜底(BUG-1877) | 待验收 | `codex/reading-computed-fallback-20261113` |
| [#242 任务书](TASK-reading-rebind-repair-20261113.md) | [执行进度](PROGRESS-reading-rebind-repair-20261113.md) | 报告解读重绑与逐节重写(BUG-1876) | 待验收 | `codex/reading-rebind-repair-20261113` |
| [#241 修订任务书](FIX-chart-choice-questions-1.md) | [执行进度](PROGRESS-chart-choice-questions-20261113.md) | 多轮选盘事件题与判边(BUG-1875) | 待验收 | `codex/chart-choice-questions-20261113` |
@@ -0,0 +1,97 @@
# TASK:年运报告导出 .md(2026-11-13)
- Gitea:#233
- 分支:`codex/annual-md-export-20261113`,从 staging `097d5b3a` 切出。#212(方案 B)和 #234(去「·」)都已合入。
- Bug 编号:BUG-1869,是预留的号。开工时核对 `docs/BUG_HISTORY.md`,已被占用就顺延,并在 PROGRESS 写明。
- 负责验收:Claude(jyotisha-99,原 jyotisha-de)
任务书没写到的取舍,不得写成"产品决定"或"裁定",一律标「需拍板」。
不得修改或删除既有断言来让测试通过。确需修改的,在 PROGRESS 里逐条写明原值、新值和原因。
## 背景
- 产品负责人 2026-11-13 要求年运报告能导出 .md。原型见 https://claude.ai/artifact/7nq8yt22n8L3KXiAFQXk4z 的「年运页导出按钮」和「年运导出面板」两张。
- 原型的要求:
- 按钮和个人报告用同一个「导出」按钮(带 Download 图标的 outline 按钮),放在年运页顶栏右侧。
- 报告写完才出现。正在写、失败或没写完时都不显示。
- 点开后直接用个人报告的导出面板 `ReportExportDrawer`:先选「整篇报告」或「自选章节」。
- 年运只有中文,不显示语言切换。
- 「自选章节」分两组:「正文」和「计算依据」。整篇的摘要写成「N 节正文,计算依据 M 项」。
- 「计算依据」整组导出,内容和页面附录一致,包括三列审计表和外部验证四行。
## 要做的
1. **按钮**
- 放进年运页 `SecondaryPageShell` 的 `actions` 位置。
- 复用 `components/personal-report/report-actions.tsx` 的按钮写法和文字(「导出」,aria-label「导出报告」)。可以抽一个共用的小组件,也可以给 `ReportActions` 加一个年运用法,二选一,在 PROGRESS 写明。不另做新样式的按钮。
2. **面板**
- 复用 `ReportExportDrawer`。
- 分组名目前写死为「正文 / 分盘 / 核对表」。加一个可选参数,让年运显示「正文 / 计算依据」。个人报告的显示和行为必须不变,用现有测试证明。
- 年运没有的分组不显示,也不计入摘要。
3. **导出内容**:新函数放在 `frontend/src/lib/annual-report-generate.ts` 或新文件 `annual-report-export.ts`。
- **正文组**:一节一块,顺序和页面一致。
- 开头一块是标题和范围:「X的这一年」、范围那一行、「这一年从哪天算到哪天」块。
- 每节导出页面上显示的文字:依据、正文、应期、记住,以及时间轴或窗口表。表格用 Markdown 表格。
- 「月相回归(Tithi Pravesh)」作为单独一块,放在正文组的最后。
- **计算依据组**:附录每个小节一块。
- 用 `annualAppendixDisplayLines` 的结果。审计表用三列 Markdown 表格。
- 本命瑜伽导出全部条目,不折叠。
- 文字直接复用 #212 现有的 `annualAppendixMarkdown` 逻辑,不要另写一套。
- **不得出现在导出里**:
- 把握等级(A–D);
- 「·」;
- MEVG / Global Web Evidence 等页面隐藏的英文行名;
- 写作提示词、模型原文、package 原始 JSON。
- **旧快照**(单列审计、没有 `page_appendix`、没有 `solarReturns` / `tithiReturns`)照样能导出,缺的块直接省略。
- **文件名**:`年运报告-<范围起始日 YYYY-MM-DD>.md`。拿不到起始日时用今天的日期。
4. **去掉 #212 加在附录里的「导出计算依据」按钮。** 原型里没有这个按钮,现在统一由顶栏的「导出」负责。`annualAppendixMarkdown` 可以留作内部函数。
## 不要碰
- `scripts/**`、`references/**`
- 冻结文件
- `frontend/src/lib/consult-preset-prompts.ts`
- 个人报告、合盘的导出内容
## 要改的文件
- `frontend/src/components/annual/annual-report-page.tsx`
- `frontend/src/components/personal-report/report-export-drawer.tsx`,只加分组名参数
- 视情况改 `report-actions.tsx`
- `frontend/src/lib/annual-report-generate.ts` 或新增 `annual-report-export.ts`
- 新测试 `frontend/tests/annual-md-export-20261113.test.tsx`
- `frontend/DESIGN.md` 年运一节加一行
- `docs/BUG_HISTORY.md`,只在末尾追加
- `docs/tasks/PROGRESS-annual-md-export-20261113.md`
## 验收标准
1. 新测试覆盖以下几点:
- 写完的报告显示「导出」,写作中和失败时不显示;
- 面板只有「正文」「计算依据」两组,没有语言切换;
- 整篇导出包含每一节、审计表和外部验证四行;
- 导出里没有 A–D 等级、「·」、MEVG 英文行名;
- 旧快照能导出;
- 文件名符合规则。
2. 个人报告导出的既有测试全部通过,不改断言。
3. 附录里不再有「导出计算依据」按钮。
4. 390px 宽度下,顶栏「导出」不挤掉标题,面板能正常打开。
5. tsc、eslint(0 错)、`npm test`(基线失败除外,在 PROGRESS 写明)、quick gate 全部通过。
## 自测命令
```
cd frontend && npx tsc --noEmit -p . && npx eslint . && npm test
PATH=<venv>/bin:$PATH PYTHONHASHSEED=0 python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
```
## 进度记录
写在 `docs/tasks/PROGRESS-annual-md-export-20261113.md`,包括:
- 选了哪种复用方式;
- 导出各块的顺序;
- 改过的断言;
- 测试结果;
- 「需拍板」。
+2 -1
View File
@@ -1400,7 +1400,8 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
## 年运报告与开始遮罩(2026-11-01,issue #153 / #154)
- **方案 B(#212,BUG-1868):** 报告标题后以浅灰块列返照起止和名义年末,跨年盘分别列。时间轴之后、计算依据之前用细边框块显示月相回归,现居地优先并标注出生地参考。计算依据沿用折叠,附录可下载 Markdown。审计用不换行可横向拖动的表格,列为「方法」「计算」「在本报告中」。本命瑜伽先显示第一条,其余在原生折叠里展开。页面不渲染把握等级。
- **方案 B(#212,BUG-1868):** 报告标题后以浅灰块列返照起止和名义年末,跨年盘分别列。时间轴之后、计算依据之前用细边框块显示月相回归,现居地优先并标注出生地参考。计算依据沿用折叠。审计用不换行可横向拖动的表格,列为「方法」「计算」「在本报告中」。本命瑜伽先显示第一条,其余在原生折叠里展开。页面不渲染把握等级。
- **年运导出(#233,BUG-1869):** 写完后顶栏右侧显示共用的描边「导出」按钮。桌面从右侧打开面板,手机从底部打开。可以导出整篇或自选正文,计算依据可整组选取。手机顶栏保留「年运」标题。
- **本命瑜伽(BUG-1857):** 年运写作材料中的瑜伽按原有顺序提供完整清单及状态词,沿用本命段落的版式和篇幅。
- **年运附录(BUG-1855):** Narayana 标为条件参考。种子未决时在口径说明中列出候选数,每支单独一行。
- **入口**:「我的报告」最上面两个横条并排(手机上下排,2026-11-03 方案 A,见 Personal report centre):「年运」横条是 `Sun` 图标方块、标题、一行「这一年每段怎么安排,几个要留意的时间点」,右边描边按钮「打开年运」(手机「打开」),直接打开 `/reports/annual`。没有单独的入口页。个人报告横条上不写份数。
+11 -1
View File
@@ -187,7 +187,17 @@
.annual-return-note p { margin: 0; }
.annual-return-note > div + div { margin-top: var(--space-3); }
.annual-return-note-detail { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.annual-appendix > button { margin-top: var(--space-3); }
@media (max-width: 767px) {
.chat-panel:has(.annual-page) .chat-header-title:has(.chat-profile-picker-trigger) > strong {
display: block;
flex: 0 0 auto;
}
.chat-panel:has(.annual-page) .chat-profile-picker-full-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
.annual-appendix section details { margin-top: var(--space-2); }
.annual-appendix { border-top: 1px solid var(--color-border); padding-top: var(--space-4); }
.annual-appendix section { margin-top: var(--space-3); }
@@ -11,6 +11,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { AppLink } from "@/components/app-link";
import { BirthPlacePicker } from "@/components/birth-place-picker";
import { ReportExportButton } from "@/components/personal-report/report-actions";
import { ReportExportDrawer } from "@/components/personal-report/report-export-drawer";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
@@ -18,8 +20,8 @@ import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
import { accountDialogHref } from "@/lib/account-dialog-intent";
import { readCurrentSubjectId, useCurrentSubjectReady, useCurrentSubjectScope } from "@/lib/current-subject";
import { dragScroll } from "@/lib/drag-scroll";
import { annualAppendixDisplayLines, annualAppendixMarkdown, type AnnualSnapshot } from "@/lib/annual-report-generate";
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
import { annualAppendixDisplayLines, type AnnualSnapshot } from "@/lib/annual-report-generate";
import { buildAnnualExportBlocks, hasAnnualTimeline } from "@/lib/annual-report-export";
import {
ANNUAL_DOMAINS,
ANNUAL_DOMAIN_TITLES,
@@ -575,9 +577,6 @@ function AnnualAppendix({ blocks }: Readonly<{ blocks: AnnualSnapshot["appendix"
return (
<details className="annual-appendix">
<summary>计算依据</summary>
<Button type="button" variant="outline" size="sm" onClick={() => downloadMarkdownReport("年运计算依据", annualAppendixMarkdown(blocks))}>
导出计算依据
</Button>
{blocks.map((block) => {
const lines = annualAppendixDisplayLines(block);
const [first, ...rest] = lines;
@@ -611,8 +610,7 @@ function AnnualAppendix({ blocks }: Readonly<{ blocks: AnnualSnapshot["appendix"
export function AnnualReportView({ snapshot, generating, failed = false }: Readonly<{ snapshot: AnnualSnapshot; generating: boolean; failed?: boolean }>) {
const nextId = generating ? snapshot.plan.find((entry) => !(entry.id in snapshot.sections))?.id ?? null : null;
// Reports written before BUG-1636 keep the two separate sections.
const timeline = snapshot.plan.some((entry) => entry.layout === "timeline")
&& snapshot.windows.every((window) => Boolean(window.phaseId));
const timeline = hasAnnualTimeline(snapshot);
return (
<article className="annual-report" aria-busy={generating}>
<header className="annual-report-head">
@@ -655,6 +653,25 @@ export function AnnualReportView({ snapshot, generating, failed = false }: Reado
);
}
export function AnnualReportExport({ report }: Readonly<{ report: Pick<ReportState, "status" | "annual"> | null }>) {
const [open, setOpen] = useState(false);
const snapshot = report?.annual;
if (report?.status !== "ready" || snapshot?.status !== "ready"
|| !snapshot.plan.every((entry) => entry.id in snapshot.sections)) return null;
return <>
<ReportExportButton onClick={() => setOpen(true)} />
{open ? (
<ReportExportDrawer
format="annual"
editions={{ zh: buildAnnualExportBlocks(snapshot) }}
reportDate={snapshot.range.span.match(/^\d{4}-\d{2}-\d{2}\b/)?.[0]}
onClose={() => setOpen(false)}
/>
) : null}
</>;
}
function Skeleton({ overview, writing = false }: Readonly<{ overview: AnnualOverview | null; writing?: boolean }>) {
return (
<article className="annual-report is-skeleton" aria-hidden="true">
@@ -783,7 +800,9 @@ export function AnnualReportPage({ initialReportId = null }: Readonly<{ initialR
return (
// Page entry (2026-11-05): the page curtain stays up until the overview (or an
// opened report) is in, so the page never appears as an empty skeleton.
<SecondaryPageShell title="年运" waiting={!subjectReady || (!overview && !overviewError) || (reportId !== null && report === null) ? "正在打开年运…" : null}>
<SecondaryPageShell title="年运"
actions={report?.status === "ready" ? <AnnualReportExport key={report.id} report={report} /> : undefined}
waiting={!subjectReady || (!overview && !overviewError) || (reportId !== null && report === null) ? "正在打开年运…" : null}>
<div className={`annual-page${showVeil ? " is-veiled" : ""}`}>
{overviewError ? <p className="annual-error" role="alert">{overviewError}</p> : null}
{writing && report ? (
@@ -33,6 +33,14 @@ interface ReportActionsProps {
placement?: "bar" | "header";
}
export function ReportExportButton({ onClick }: { onClick: () => void }) {
return (
<Button type="button" variant="outline" aria-label="导出报告" onClick={onClick}>
<Download aria-hidden="true" />导出
</Button>
);
}
export function ReportActions({
markdown,
charts,
@@ -71,14 +79,7 @@ export function ReportActions({
)}
{/* 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"
variant="outline"
aria-label="导出报告"
onClick={() => setExportOpen(true)}
>
<Download aria-hidden="true" />导出
</Button>
<ReportExportButton onClick={() => setExportOpen(true)} />
{editions && (
<ReportExportDrawer
editions={editions}
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { EnglishEditionStatus, ReportLanguage } from "@/lib/report-language";
import { annualExportFilename } from "@/lib/annual-report-export";
type Group = ReportExportBlock["group"];
const GROUPS: readonly Group[] = ["body", "chart", "table"];
@@ -25,7 +26,11 @@ const GROUP_LABELS: Readonly<Record<ReportLanguage, Record<Group, string>>> = {
en: { body: "Chapters", chart: "Charts", table: "Tables" },
};
export function exportSummary(counts: Record<Group, number>, language: ReportLanguage): string {
export function exportSummary(counts: Record<Group, number>, language: ReportLanguage, format?: "annual"): string {
if (format === "annual") {
return [counts.body ? `${counts.body} 节正文` : "", counts.table ? `计算依据 ${counts.table} 项` : ""]
.filter(Boolean).join(",");
}
const parts = language === "en"
? [[counts.body, "chapters"], [counts.chart, "charts"], [counts.table, "tables"]] as const
: [[counts.body, "章正文"], [counts.chart, "张分盘"], [counts.table, "张核对表"]] as const;
@@ -52,16 +57,17 @@ export type ReportExportEditions = Readonly<{ zh: readonly ReportExportBlock[];
* 导出报告. Desktop slides in from the right; a phone slides up from the bottom.
* Pick the edition, then 「整篇报告」 or 「自选章节」. No caption and no file size.
*/
export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEdition = "legacy", reportDate, onClose }: {
export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEdition = "legacy", reportDate, format, onClose }: {
editions: ReportExportEditions;
/** The edition the reader is showing; the sheet starts on it. */
initialLanguage?: ReportLanguage;
englishEdition?: EnglishEditionStatus;
reportDate?: string | null;
format?: "annual";
onClose: () => void;
}) {
const side = useSyncExternalStore(subscribeExportSheetSide, exportSheetSide, () => "right" as const);
const hasEnglish = englishEdition === "ready" && Boolean(editions.en?.length);
const hasEnglish = format !== "annual" && englishEdition === "ready" && Boolean(editions.en?.length);
const [language, setLanguage] = useState<ReportLanguage>(initialLanguage === "en" && hasEnglish ? "en" : "zh");
const blocks = (language === "en" ? editions.en : editions.zh) ?? editions.zh;
const [mode, setMode] = useState<"whole" | "pick">("whole");
@@ -114,7 +120,8 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
try {
const body = blocks.filter(block => block.group === "body");
const full = mode === "whole" || (body.length > 0 && body.every(block => selection.has(block.id)));
downloadMarkdownReport(exportFilename(reportDate, full, language), joinExportBlocks(selected));
const filename = format === "annual" ? annualExportFilename(reportDate) : exportFilename(reportDate, full, language);
downloadMarkdownReport(filename, joinExportBlocks(selected));
onClose();
notifySuccess("已导出");
} catch (cause) {
@@ -123,7 +130,7 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
}
}
const summary = exportSummary(counts, language);
const summary = exportSummary(counts, language, format);
return (
<Sheet open onOpenChange={(open) => { if (!open) onClose(); }}>
@@ -133,7 +140,7 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
className="report-export-drawer gap-0 overflow-hidden p-0"
aria-labelledby="report-export-title"
>
{hasEnglish ? null : <ExportEditionNotice englishEdition={englishEdition} />}
{hasEnglish || format === "annual" ? null : <ExportEditionNotice englishEdition={englishEdition} />}
<header className="report-export-header">
<div className="report-export-heading">
<h2 id="report-export-title">导出报告</h2>
@@ -183,35 +190,37 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
<div className="report-export-list">
{GROUPS.map(group => {
const items = blocks.filter(block => block.group === group);
if (!items.length) return null;
if (!items.length || (format === "annual" && group === "chart")) return null;
const shown = needle ? items.filter(block => block.title.toLowerCase().includes(needle)) : items;
if (needle && !shown.length) return null;
const on = items.filter(block => selection.has(block.id)).length;
const expanded = open.has(group);
return <section key={group} className="report-export-group" aria-label={labels[group]}>
const grouped = format === "annual" && group === "table";
const label = grouped ? "计算依据" : labels[group];
const expanded = !grouped && open.has(group);
return <section key={group} className="report-export-group" aria-label={label}>
<div className="report-export-group-head">
<label className="report-export-row is-group">
<Checkbox
aria-label={labels[group]}
aria-label={label}
checked={on === items.length}
indeterminate={on > 0 && on < items.length}
onCheckedChange={(checked) => toggle(items.map(block => block.id), checked === true)}
/>
<span>{labels[group]}<small>{on}/{items.length}</small></span>
<span>{label}<small>{on}/{items.length}</small></span>
</label>
<Button
{!grouped ? <Button
type="button"
variant="ghost"
size="icon"
className="report-export-expand"
aria-expanded={expanded}
aria-label={`${expanded ? "收起" : "展开"}${labels[group]}`}
aria-label={`${expanded ? "收起" : "展开"}${label}`}
onClick={() => setOpen(current => { const next = new Set(current); if (next.has(group)) next.delete(group); else next.add(group); return next; })}
>
<ChevronRight aria-hidden="true" />
</Button>
</Button> : null}
</div>
{expanded || needle ? shown.map(block => (
{!grouped && (expanded || needle) ? shown.map(block => (
<label key={block.id} className="report-export-row">
<Checkbox
aria-label={block.title}
+168
View File
@@ -0,0 +1,168 @@
import { annualAppendixDisplayLines, annualAppendixMarkdown, type AnnualSnapshot } from "./annual-report-generate";
import type { ReportExportBlock } from "./report-export-blocks";
import { projectOrdinaryReportMarkdown } from "./report-public-projection";
type Row = Record<string, unknown>;
type Window = AnnualSnapshot["windows"][number];
type Entry = AnnualSnapshot["plan"][number];
export function hasAnnualTimeline(snapshot: AnnualSnapshot): boolean {
return snapshot.plan.some((entry) => entry.layout === "timeline")
&& snapshot.windows.every((window) => Boolean(window.phaseId));
}
export function annualExportFilename(startDate?: string | null, today = new Date()): string {
const localDate = [today.getFullYear(), today.getMonth() + 1, today.getDate()]
.map((part) => String(part).padStart(2, "0")).join("-");
return `年运报告-${startDate && /^\d{4}-\d{2}-\d{2}$/.test(startDate) ? startDate : localDate}`;
}
function fields(value: unknown): Row {
return value && typeof value === "object" && !Array.isArray(value) ? value as Row : {};
}
function rows(value: unknown): Row[] {
return Array.isArray(value) ? value.map(fields) : [];
}
function visible(value: unknown): string {
if (typeof value !== "string") return "";
return value
.replace(/MEVG\s*\/\s*Global Web Evidence|Global Web Evidence|Real Case Calibration|\bMEVG\b/gi, "")
.replace(/(?:把握(?:等级|上限)?|置信(?:度|等级)|证据等级)\s*(?:为|[::])?\s*[A-D](?:\s*[–—-]\s*[A-D])?(?:\s*[级档])?/gu, "")
.replace(/(?:(\s*[A-D]\s*[级档]\s*)|\(\s*[A-D]\s*[级档]\s*\))/gu, "")
.replace(/\s*·\s*/g, ",")
.trim();
}
function paragraphs(value: unknown): string {
return visible(value).split(/\r?\n+/).map((line) => line.trim()).filter(Boolean).join("\n\n");
}
function labelled(label: string, value: unknown): string {
const text = paragraphs(value);
return text ? `**${label}:**${text}` : "";
}
function table(headings: readonly string[], data: readonly (readonly unknown[])[]): string {
if (!data.length) return "";
const row = (cells: readonly unknown[]) => `| ${cells.map((cell) => visible(cell)
.replaceAll("|", "\\|").replace(/\r?\n/g, " ")).join(" | ")} |`;
return [row(headings), row(headings.map(() => "---")), ...data.map(row)].join("\n");
}
function bullets(values: readonly string[]): string {
return values.map(visible).filter(Boolean).map((line) => `- ${line}`).join("\n");
}
function parts(...values: string[]): string {
return values.filter(Boolean).join("\n\n");
}
function phaseTable(snapshot: AnnualSnapshot, body: Row): string {
const written = rows(body.rows);
return table(["时间", "事情", "怎么安排"], snapshot.remaining.map((row) => {
const item = written.find((entry) => entry.row_id === row.id);
return [row.span, item?.matter, item?.arrange];
}));
}
function windowTable(items: readonly { window?: Window; item?: Row }[], timeline = false): string {
const headings = timeline
? ["时间", "主题", "可以怎么做", "为什么", "另一面", "要有的条件"]
: ["时间", "主题", "可以怎么做", "依据", "分歧与限制", "要有的条件"];
return parts(table(headings, items.map(({ window, item }) => {
const status = timeline
? ["要留意的几天", window?.position === "past" ? "已过" : window?.position === "current" ? "进行中" : ""]
: ["候选"];
return [[window?.span, ...status].filter(Boolean).join(","), item?.theme, item?.action,
item?.support, timeline && !item?.support ? "" : item?.divergence, timeline && !item?.support ? "" : item?.conditions];
})), ...items.map(({ window, item }) => {
if (!window || (timeline && !item)) return "";
const evidence = bullets(timeline ? window.basis : [...window.basis, ...window.limits]);
return evidence ? parts(`**${visible(window.span)}的盘面依据**`, evidence) : "";
}));
}
function timelineMarkdown(snapshot: AnnualSnapshot): string {
const written = rows(fields(snapshot.sections.remaining).rows);
const windowItems = rows(fields(snapshot.sections.windows).items);
const details = (windows: readonly Window[]) => windowTable(windows.map((window) => ({
window, item: windowItems.find((item) => item.window_id === window.id),
})), true);
const phaseIds = new Set(snapshot.remaining.flatMap((row) => row.phaseIds ?? [row.id]));
const earlier = details(snapshot.windows.filter((window) => !phaseIds.has(window.phaseId)));
return parts(earlier ? parts("### 已经过去的时间点", earlier) : "", ...snapshot.remaining.map((row) => {
const item = written.find((entry) => entry.row_id === row.id);
const phase = table(["时间", "事情", "怎么安排"], [[row.span, item?.matter, item?.arrange]]);
const windows = snapshot.windows.filter((window) => (row.phaseIds ?? [row.id]).includes(window.phaseId));
return parts(row.current ? "### 现在" : "", phase, details(windows));
}));
}
function sectionMarkdown(snapshot: AnnualSnapshot, entry: Entry, timeline: boolean): string {
const body = fields(snapshot.sections[entry.id]);
if (timeline && entry.kind === "remaining") return timelineMarkdown(snapshot);
if (body.skipped) return "这一节按你的选择没有写。";
switch (entry.kind) {
case "person": return paragraphs(body.text);
case "overview": return parts(labelled("依据", body.evidence), paragraphs(body.text),
bullets(rows(body.remember).map((item) => [snapshot.windows.find((row) => row.id === item.window_id)?.span,
visible(item.text)].filter(Boolean).join(" "))));
case "area": return parts(labelled("依据", body.evidence), paragraphs(body.text),
labelled("应期(两套计时)", body.timing), labelled("记住", body.remember));
case "domain": return parts(paragraphs(body.base), ...rows(body.segments).map((segment) => {
const label = visible(entry.segments?.find((item) => item.id === segment.id)?.label);
return parts(label ? `### ${label}` : "", paragraphs(segment.text));
}));
case "windows": {
const items = rows(body.items);
if (!items.length) return "这一年没有几个来源同时指向的时间点。";
return windowTable(items.map((item) => ({ window: snapshot.windows.find((row) => row.id === item.window_id), item })));
}
case "remaining": return phaseTable(snapshot, body);
case "close": return paragraphs(body.line);
}
}
function noteMarkdown(notes: AnnualSnapshot["solarReturns"]): string {
return parts(...(notes ?? []).map((note) => parts(paragraphs(note.main), paragraphs(note.detail))));
}
export function buildAnnualExportBlocks(snapshot: AnnualSnapshot): ReportExportBlock[] {
const blocks: ReportExportBlock[] = [];
function add(id: string, group: ReportExportBlock["group"], title: string, markdown: string) {
const publicMarkdown = projectOrdinaryReportMarkdown(markdown).trim();
if (!publicMarkdown) return;
// The shared downloader joins body blocks without a separator to preserve original Markdown.
const text = group === "body" ? `${publicMarkdown}\n\n` : publicMarkdown;
blocks.push({ id, group, title, text, chars: text.length });
}
const title = `${visible(snapshot.subject.name)}的这一年`;
const solar = noteMarkdown(snapshot.solarReturns);
add("body:opening", "body", title, parts(`# ${title}`,
[snapshot.range.label, snapshot.range.span].map(visible).filter(Boolean).join(","),
solar ? parts("## 这一年从哪天算到哪天", solar) : ""));
const timeline = hasAnnualTimeline(snapshot);
for (const entry of snapshot.plan) {
if (!(entry.id in snapshot.sections) || (timeline && entry.kind === "windows")) continue;
const title = visible(entry.title);
add(`body:section-${entry.id}`, "body", title, parts(`## ${title}`, sectionMarkdown(snapshot, entry, timeline)));
}
const lunar = noteMarkdown(snapshot.tithiReturns);
if (lunar) add("body:tithi", "body", "月相回归(Tithi Pravesh)", parts("## 月相回归(Tithi Pravesh)", lunar));
for (const [index, block] of (snapshot.appendix ?? []).entries()) {
const title = visible(block.title);
const audit = block.audit?.filter(({ method }) => !/^(?:MEVG\b|Global Web Evidence\b|Real Case Calibration\b)/i.test(method.trim()))
.map((row) => ({ method: visible(row.method), calculation: visible(row.calculation), usage: visible(row.usage) }));
const lines = annualAppendixDisplayLines(block)
.filter((line) => !/^(?:MEVG\b|Global Web Evidence\b|Real Case Calibration\b)/i.test(line.trim()))
.map(visible).filter(Boolean);
const appendix = annualAppendixMarkdown([{ ...block, title, lines, audit }]);
// Each subheading stays individually addressable; the drawer selects the group together.
add(`table:${block.id}`, "table", title, index === 0 ? appendix : appendix.replace(/^# 计算依据\n\n/, ""));
}
return blocks;
}
@@ -0,0 +1,148 @@
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { AnnualReportExport, AnnualReportView } from "../src/components/annual/annual-report-page";
import { annualExportFilename, buildAnnualExportBlocks } from "../src/lib/annual-report-export";
import { annualSolarReturns, annualTithiReturns, buildAppendix, type AnnualSnapshot } from "../src/lib/annual-report-generate";
import { joinExportBlocks } from "../src/lib/report-export-blocks";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
Object.assign(globalThis, { React });
const { publicFacts } = JSON.parse(readFileSync(new URL("./fixtures/annual-page-option-b-20261113.json", import.meta.url), "utf8"));
const range = {
kind: "birthday_year", key: "annual:birthday_year:2026", label: "生日年度",
span: "2026-08-04 到 2027-08-04", crossesBirthday: false, birthdays: [],
} as const;
const birth = { known: false, beforeMinutes: null, afterMinutes: null, source: null, rectification: "none" } as const;
const windowRow = {
id: "w", span: "九月初", phaseId: "r", position: "current", basis: ["盘面依据一"], limits: ["保留限制"],
systems: [], themes: [], lean: "", leanKey: "", highlightRank: null,
};
const snapshot: AnnualSnapshot = {
kind: "annual_report_snapshot", version: 1, status: "ready", subject: { name: "虚构甲", isSelf: false },
range, factsSha256: "private-fixture-marker", appendix: buildAppendix(publicFacts, range, birth),
solarReturns: annualSolarReturns(publicFacts), tithiReturns: annualTithiReturns(publicFacts),
plan: [
{ id: "person", kind: "person", title: "先看这个人", disposition: "write" },
{ id: "overview", kind: "overview", title: "这一年", disposition: "write" },
{ id: "work", kind: "area", title: "工作", disposition: "write" },
{ id: "windows", kind: "windows", title: "几个时间点", disposition: "write" },
{ id: "remaining", kind: "remaining", layout: "timeline", title: "这一年怎么走", disposition: "write" },
{ id: "close", kind: "close", title: "收尾", disposition: "write" },
],
sections: {
person: { text: "这里有两段。\n再说一句。", systemPrompt: "private-fixture-marker" },
overview: { text: "这一年的正文。", evidence: "年盘依据", remember: [{ window_id: "w", text: "提前安排" }] },
work: { evidence: "把握等级 B", text: "主业 · 先排次序。", timing: "两套时间", remember: "留出余量", grade: "A" },
windows: { items: [{ window_id: "w", theme: "安排", action: "做完手头事", support: "说明", divergence: "另一面", conditions: "有空时" }] },
remaining: { rows: [{ row_id: "r", matter: "回顾", arrange: "预留一天" }] },
close: { line: "到这里就好。" },
},
windows: [windowRow], remaining: [{ id: "r", span: "秋天", lean: "", days: 20, current: true }],
};
test("annual export is available only for a finished report and the appendix has one entry point", () => {
const render = (report: React.ComponentProps<typeof AnnualReportExport>["report"]) =>
renderToStaticMarkup(<AnnualReportExport report={report} />);
assert.match(render({ status: "ready", annual: snapshot }), /aria-label="导出报告"/);
assert.equal(render({ status: "generating", annual: snapshot }), "");
assert.equal(render({ status: "failed", annual: snapshot }), "");
assert.equal(render({ status: "ready", annual: { ...snapshot, status: "writing" } }), "");
assert.equal(render({ status: "ready", annual: { ...snapshot, sections: {} } }), "");
assert.equal(render(null), "");
assert.doesNotMatch(renderToStaticMarkup(<AnnualReportView snapshot={snapshot} generating={false} />), /导出计算依据/);
});
test("annual Markdown follows the page order, projects public text and keeps all appendix rows", () => {
const blocks = buildAnnualExportBlocks(snapshot);
assert.deepEqual(blocks.filter((block) => block.group === "body").map((block) => block.title),
["虚构甲的这一年", "先看这个人", "这一年", "工作", "这一年怎么走", "收尾", "月相回归(Tithi Pravesh)"]);
const markdown = joinExportBlocks(blocks);
for (const text of ["这一年从哪天算到哪天", "这里有两段", "年盘依据", "九月初 提前安排", "主业,先排次序",
"应期(两套计时)", "留出余量", "| 时间 | 事情 | 怎么安排 |", "预留一天", "做完手头事", "盘面依据一",
"| 方法 | 计算 | 在本报告中 |", "本地计算", "预置来源匹配", "实时联网", "真实案例校准"]) {
assert.ok(markdown.includes(text), text);
}
const yogas = snapshot.appendix.find((block) => block.id === "natal-yogas");
const yogaBlock = blocks.find((block) => block.id === "table:natal-yogas");
assert.ok(yogas && yogaBlock);
for (const line of yogas.lines) assert.ok(yogaBlock.text.includes(line.replace(/\s*·\s*/g, ",")));
assert.doesNotMatch(markdown, /·|把握等级\s*[A-D]|MEVG|Global Web Evidence|Real Case Calibration|private-fixture-marker/);
});
test("annual export supports old snapshots with separate windows and a single-column audit", () => {
const old: AnnualSnapshot = {
...snapshot, solarReturns: undefined, tithiReturns: undefined,
plan: snapshot.plan.map((entry) => ({ ...entry, layout: undefined })),
appendix: buildAppendix({
technique_audit: [
{ technique: "Tajika Aspects", status: "executed", note: "旧审计" },
{ technique: "MEVG / Global Web Evidence", status: "blocked" },
],
}, range, birth),
};
const markdown = joinExportBlocks(buildAnnualExportBlocks(old));
assert.match(markdown, /几个时间点[\s\S]*候选[\s\S]*保留限制[\s\S]*这一年怎么走/);
assert.match(markdown, /计算已执行。旧审计/);
assert.doesNotMatch(markdown, /这一年从哪天算到哪天|月相回归(Tithi Pravesh)|在本报告中|MEVG/);
assert.equal(annualExportFilename(undefined, new Date(2026, 0, 9)), "年运报告-2026-01-09");
});
test("annual drawer shows two groups and downloads the selected appendix with the range start date", async () => {
const h = createClientLifecycleHarness();
const createUrl = URL.createObjectURL;
const revokeUrl = URL.revokeObjectURL;
const create = document.createElement;
let filename = "";
let blob: Blob | undefined;
URL.createObjectURL = (value) => {
assert.ok(value instanceof Blob);
blob = value;
return "blob:annual-test";
};
URL.revokeObjectURL = () => {};
document.createElement = ((tag: string, ...rest: unknown[]) => tag === "a"
? { set download(value: string) { filename = value; }, click() {}, remove() {} }
: Reflect.apply(create, document, [tag, ...rest])) as typeof document.createElement;
try {
type Node = ReturnType<typeof h.elements>[number];
const walk = (node: Node): Node[] => [node, ...node.childNodes.flatMap((child) => walk(child as Node))];
const nodes = () => [...walk(h.container as unknown as Node), ...walk(h.document.body as unknown as Node)];
const byLabel = (label: string, role?: string) => {
const node = nodes().find((element) => element.getAttribute("aria-label") === label
&& (!role || element.getAttribute("role") === role));
assert.ok(node, label);
return node;
};
const byText = (role: string, text: string) => {
const node = nodes().find((element) => (role === "button" ? element.tagName === "BUTTON" : element.getAttribute("role") === role)
&& element.text.startsWith(text));
assert.ok(node, text);
return node;
};
await h.render(<AnnualReportExport report={{ status: "ready", annual: snapshot }} />);
await h.event(byLabel("导出报告"));
assert.match(byText("tab", "整篇报告").text, /7 节正文,计算依据 \d+ 项/);
assert.ok(!nodes().some((node) => node.getAttribute("aria-label") === "导出语言"));
await h.event(byText("tab", "自选章节"));
assert.deepEqual([...new Set(nodes().filter((node) => node.tagName === "SECTION")
.map((node) => node.getAttribute("aria-label")).filter(Boolean))], ["正文", "计算依据"]);
await h.event(byLabel("正文", "checkbox"));
await h.event(byLabel("计算依据", "checkbox"));
await h.event(byText("button", "导出 "));
assert.equal(filename, "年运报告-2026-08-04.md");
const markdown = await blob?.text();
assert.ok(markdown);
assert.match(markdown, /技法执行情况[\s\S]*真实案例校准/);
assert.doesNotMatch(markdown, /虚构甲的这一年/);
assert.deepEqual(h.errors, []);
} finally {
await h.close();
document.createElement = create;
URL.createObjectURL = createUrl;
URL.revokeObjectURL = revokeUrl;
}
});