diff --git a/AGENTS.md b/AGENTS.md index dea299c8..b9156646 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,7 +103,7 @@ - `scripts/jyotish_api_server.py` must not grow. New endpoints and features go in dedicated modules under `scripts/` and are thinly registered from the main file. Do not add new handler bodies, workflows, or feature branches to this file. The live gate is `tests/test_api_server_growth_contract.py`:**类方法数不得增长、`JyotishAPIHandler.__new__` 伪造点不得增长**;行数是粗护栏(开工实测基线 + 300)。 - `frontend/src/app/page.tsx`:`Home()` 的 `useState` / `useRef` 数不得增长。新逻辑进 `frontend/src/hooks/`、`frontend/src/lib/` 或组件;抽出去的 hook 与子组件应当持有自己的状态,`page.tsx` 只做装配。行数是粗护栏(开工实测基线 + 150),由 `frontend/tests/home-shell-growth-contract.test.ts` 执行。 -- 不得再手写第二个聊天输入框(一律 `ChatComposer`,自有草稿走 `value`)、第二套滚动跟随(一律 `useConversationScrollAnchor` + `JumpToLatestButton`)、第二套加载动画(揭幕后不得出现 spinner / 骨架 / "正在加载",流式生成中除外)。 +- 不得再手写第二个聊天输入框(一律 `ChatComposer`,自有草稿走 `value`)、第二套滚动跟随(一律 `useConversationScrollAnchor` + `JumpToLatestButton`)、第二套加载动画(揭幕后不得出现 spinner / 骨架 / "正在加载",流式生成中除外;星盘页盘位骨架盘除外,限 BUG-1016 决策)。 ## 7. 前端红线 diff --git a/BLOCKED.md b/BLOCKED.md index 9de1742a..56d5c2c9 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,29 @@ # BLOCKED +> 2026-09-24交付补记:产品已在获知缺口后授权两单组合版本push到staging;下述两单未提交/未推送描述为验证时快照,交付以实际push与远端SHA核对为准。推送不解除BUG-1020、完整构建或受控真机缺口,不代表部署通过。 + +## BUG-1020:CRLF 无 JSON 与旧导出逐字节合同冲突(2026-09-24) + +- 独立审查使用真实虚构golden只转换换行,正文下载保留22段图盘JSON,同时仍等于旧导出。根因是旧FENCE_RE仅接受语言标记后的LF,属于继承缺陷。 +- D13只放开安全SVG,没有授权改变D5;未静默修复或声称全部验收通过。建议CRLF围栏输入作有限例外后最小修复,待裁决,见 `docs/tasks/TASK-report-chapter-export-fix-20260924.md`。 + +## BUG-1016 / 1017:星盘本地修复待完整构建与真实页面验收(2026-09-24) + +- Node22.23.2组合首轮全量3717→3763,新增46全过、名称消失0、84失败名称一致;83诊断一致,1项fixture槽位偶发异常独立复跑恢复基线诊断。默认build因跨root junction失败,webpack编译/类型过但Skill symlink EPERM;Static/gzip未验证。末次depth修正后的最终重验已完成,84份失败诊断全部与基线一致,详见两单PROGRESS。 +- 单星盘树隔离Chrome153已验冷进布局、呼吸/reduced-motion、429/超时/401与重试等13/13场景;mock接口不等于受控登录E2E。iOS减弱动态、真实后端故障恢复与部署health SHA未验证,清单 `docs/testing/chart-page-skeleton-20260924.md`。 +- 未 commit / push / 部署,不改工作流、DNS、并发或共享 Docker 网络/卷。BUG 暂 blocked,验收完成后由主会话更新。 + +## 报告分块导出 BUG-1018 / 1019:本地回归与真实环境边界(2026-09-24) + +- 本轮没有受控登录账号,未借用凭据;iPhone 文件 App、微信真机下载限制、登录后的 DELETE 持久化及部署健康检查仍待真人验收。 +- ~~T5 新建 testing 被工具拒绝,同类 PROGRESS 未绕过创建,两文件尚未落盘。~~ 用户随后明确授权“由主会话补齐”,主会话已创建 `docs/testing/report-chapter-export-20260924.md` 与 `docs/tasks/PROGRESS-report-chapter-export-20260924.md`;该文档权限缺口解除。 +- 仓内React测试仅真实挂载/state/effect/handler,不模拟布局。另跑隔离Chrome153:单报告树13/13(4类实际下载、SVG、布局、焦点、hash、删除race)通过;mock后端、全新profile,不等于登录E2E/iOS/微信或真实持久化。组合树另跑星盘13/13、报告14/14通过,含合并SVG绘图fallback真实下载;证据见两单PROGRESS。 +- 最终组合(含depth末次修正)全量3763/3679pass/84fail,基线3717/3633pass/84fail;新增46全通过、无名称消失/旧状态变化,84失败名称与完整归一化诊断全部一致。tsc0,lint0error/119warning。默认build仍junction失败,webpack编译/类型过但Skill symlink EPERM(本轮先报/api/consult);Static/gzip未验。证据在组合树artifacts/combined-final-verification,当前未commit、push或部署,BUG不提前resolved。 + +## 本轮根tests主题合同既有欠账(2026-09-24) + +- 四文件定向18条:17通过、1失败。`tests/test_frontend_theme_contract.py:29`期待旧ink-secondary `#676762`,固定基线5ff43b47f已为`#5f5f59`;同一测试用基线CSS内存重放同样失败,非本轮回归。未顺修主题或弱化断言,详见两单PROGRESS。 + ## BUG-1015:次级页新建已本地修复,完整构建与真人验收待补(2026-09-23) - 本轮 `tsc --noEmit` 0 错、lint 0 error / 119 既有 warning、定向 95/95;全量基线 3708/3619pass/89fail → 最终 3717/3628pass/89fail,名称多重集合无删除、新增9全通过,失败名单不变。失败逐条诊断核对见 PROGRESS。 diff --git a/CHANGELOG.md b/CHANGELOG.md index d820fcdf..e6ecc62d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # 印度占星 Skill 更新日志 +## 2026-09-24 — 星盘等待有盘位,失败有出口(本地实现,待验收) + +- 主盘、分盘与西洋盘未到时显示空盘骨架轻缓呼吸;系统减弱动态效果时静止。其它页面和大运 / 七政表格不增加骨架。 +- 分盘失败会说明原因,服务忙与其它失败分开;再次点该页签或分盘可重新请求。 +- 客户端等太久显示明确超时句,未登录先说明再前往登录。 +- Skill 版本不 bump;尚未提交、推送或部署。真人清单见 `docs/testing/chart-page-skeleton-20260924.md`。 + +## 2026-09-24 — 报告分块下载与列表管理(本地实现,待独立验收 / 部署) + +- 阅读页「分块导出」取代旧整篇导出按钮,可勾正文、带安全 SVG 的分盘和核对表,下载一个 `.md`;常用包含全部正文,保留旧导出的逐字节内容,所有组全选另附盘 / 表。打印和原始附录下载保留。 +- 列表显示中文日期、深度和主题,完成 / 生成中 / 未完成以文字及三色区分;「分块导出」直达抽屉。每行提供删除及行内确认,失败不丢行、成功使缓存失效。 +- 不改生成管线、接口字段或数据库。Skill 版本不 bump;用户后续授权主会话补齐 PROGRESS 与真人清单,现已落盘。独立审查发现 CRLF 正文继承旧剥离器的 JSON 围栏残留,受 D5 合同冲突阻塞,未宣称全部验收通过;见 `BLOCKED.md`。 + ## 2026-09-23 — 次级页「新建对话」真正打开空会话(本地修复,待部署) - 星盘、星历、我的报告及报告详情页的「新建对话」现在进入新的空咨询,不再打开原来的会话。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index aa4e9aaf..0d8eb660 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -11155,7 +11155,7 @@ - 根因:`chart/page.tsx` `force-dynamic` 且 `await loadChartView()`,而 `loadChartView` 先串行 `/api/chart` 再并行四个后续调用。文档卸载重载期间浏览器里什么都没有。 - 修复:`/chart` 改为静态壳,客户端先画标题和返回,再取 `/api/chart-view`。进页只算主盘;分盘 / Chara / 西洋 / 七政按 tab 或非 D1 chip 按需取。超时改为 10 秒(实测主盘 0.60 秒,10 秒约 16 倍余量,避免再盯 45 秒空白)。等待句用「还没拿到」,不转圈。 - 验证:无 `view` 时外壳可见;挂起主盘不再依赖四个后续调用;源码合同禁止 `force-dynamic` / `loadChartView` 出现在 `chart/page.tsx`。 -- 防复发:`/chart` 首字节不得等待四个后续引擎调用。揭幕后填 tab 不得再上 spinner / 骨架 / 「正在加载」。 +- 防复发:`/chart` 首字节不得等待四个后续引擎调用。揭幕后填 tab 不得再上 spinner / 「正在加载」。2026-09-24 BUG-1016 产品决策仅允许星盘页 D1、非 D1 分盘、西洋盘位置复用 SVG 空盘骨架;表格区及其它页面仍禁止骨架。 - 相关记录:BUG-715、BUG-717、TASK-chart-page-blocking-open-20260915 - 复发自:无 - 修复版本:待发布 @@ -12564,7 +12564,7 @@ - 根因:三页各写各的等待布局——先渲染 46px 标题加一行短等待文案,数据到达后整块换成高正文。`SecondaryHeader` 的 `note` 未到时不渲染,标题区二次填充。`use-chart-page` 进页才 fetch,零缓存,每次都重放中间态。产品 2026-09-18 选定方案一:消灭中间态,不加 spinner,红线不动。 - 修复:三页共用 `SecondaryPageShell`(标题行高度固定、note 槽占位不塌、内容区撑满剩余视口,等待文案居中在该区)。星盘 / 星历 / 报告首屏进模块级缓存,同一会话再进直接用上次结果并后台静默刷新。侧栏三项在 `pointerenter` / `pointerdown` 预热数据。等待句仍是静态句(「这一张盘还没拿到。」等),没有 spinner / 骨架 / 「正在加载」。 - 验证:`frontend/tests/secondary-page-entry.test.ts`(三页 import 共享外壳、暖缓存第二次不经 null 等待、预取命中同一 inflight、CSS 内容区 min-height:0 且等待居中);既有 `chart-page-view` / `ephemeris-page` / sidebar 合同。浏览器量高度见 `docs/testing/secondary-page-entry-20260918.md`(无 Chrome)。 -- 防复发:次级页进入不得再各写一套等待布局。揭幕后不得加 spinner / 骨架 / 「正在加载」。缓存必须模块级,组件 unmount 不能丢掉。 +- 防复发:次级页进入不得再各写一套等待布局。揭幕后不得加 spinner / 「正在加载」;骨架禁令仅在星盘盘位被 2026-09-24 BUG-1016 决策有限推翻,其它页不变。缓存必须模块级,组件 unmount 不能丢掉。 - 关联记录:BUG-716、BUG-717、BUG-745 - 复发自:无 - 修复版本:`9ccb65b7` @@ -13490,6 +13490,30 @@ - 复发自:无;与 BUG-745 同入口但不同根因。 - 修复版本:本次提交(执行分支 `codex/secondary-new-chat-intent-20260923`,基线 `1c95eb397`);2026-09-24 产品授权推 staging 供远程 review,精确 SHA 以实际推送及远端核对为准,部署尚待核验。 +## BUG-1018 | 报告列表元信息直接显示英文深度与主题 id + +- 状态:blocked(本地针对性回归通过;受控登录与部署未验证,不预写 resolved) +- 首次发现 / 最近更新:2026-09-24。 +- 现象 / 触发:打开有报告的列表,元信息出现 standard、career 等内部 id,日期未按产品要求中文全写。 +- 根因:`rowMeta` 直接拼 `report.depth` 与 `report.themes`;已存在主题中文映射但此入口未复用,深度映射缺失。旧入口测试只锁状态 / 导出路径,没有执行元信息 formatter。 +- 修复:深度映射与主题映射同置 progress helper;rowMeta 使用中文全日期、中文深度和主题,未知 id 原样回退,非法日期显示时间未知;不改 GET 字段。 +- 验证:真实 formatter 回归覆盖 standard / career、未知 id、空主题及无效日期;真实组件挂载显示中文。定向测试、类型与独立浏览器证据由本轮进度汇总,未以源码断言冒充真人。 +- 防复发:新增 `report-row-delete.test.tsx` 的元信息执行测试;既有报告 API 日期投影、列表 cardSummary 合同保留。 +- 关联:BUG-352(日期序列化 / 失败码)、BUG-574(列表字段查询);不是这两项复发,本轮不改路由。 +- 修复版本:未提交,`codex/report-chapter-export-20260924`,固定基线 `5ff43b47f`。 + +## BUG-1019 | 报告 DELETE 接口存在但列表没有删除入口 + +- 状态:blocked(客户端实现及针对性回归通过;真实持久化 / 部署未验证) +- 首次发现 / 最近更新:2026-09-24。 +- 现象 / 触发:报告列表没有删除按钮,用户无法管理已完成、生成中或未完成记录。 +- 根因:owned DELETE API 已存在且有鉴权测试,但列表没有装配调用;旧测试只覆盖服务端删除,不检查用户可达入口。 +- 修复:每行 44px 菜单只有删除;行内确认 / 取消;same-origin DELETE,in-flight ref 阻止双发。失败保留行和短错误,成功移除并 invalidate reports cache。cache epoch 拒绝旧 GET 回写,组件 tombstone 拒绝旧快照使行复活,旧 finally 不清掉新请求。 +- 验证:真实挂载覆盖取消不请求、HTTP / 网络失败保留、重复点击仅一个 DELETE、成功回调;整个 Center 挂载覆盖成功移除及旧 GET 返回仍不复活;独立 cache race 验证 reset / 新旧请求。服务端鉴权 / owned-delete 既有 API 回归保留;不声称受控账号删除持久化已验证。 +- 防复发:`report-row-delete.test.tsx` 动态交互与 race 锁;不用 window.confirm,不新增接口 / 数据库 / GET 字段。 +- 关联:BUG-154(报告列表入口)、BUG-574(列表查询);分块导出是功能变更另记 CHANGELOG,普通投影继续遵守 BUG-999 / 1003。 +- 修复版本:未提交,`codex/report-chapter-export-20260924`,固定基线 `5ff43b47f`。BUG-1016 / 1017 由并行星盘任务预留,本分支不抢号。 + ## BUG-1014 | 输入框源码合同未跟上已删除人物的发送限制 - 状态:resolved(run 2857 的 H3 门禁通过;staging 总门禁仍因 BUG-1011 失败,尚未部署) @@ -13505,3 +13529,45 @@ - 相关记录:BUG-1000、BUG-1002;TASK-staging-gate-red-20260923.md。 - 复发自:无;源码合同落后于授权业务改动。 - 修复版本:`36a737616c67a251610662ccab76845b193993fb`(staging;未部署)。 + +## BUG-1016 | 星盘主盘等待句没有超时与登录出口,缓存异常路径留空 + +- 状态:blocked(本地动态回归、独立审查、首轮组合全量对比及隔离Chrome通过;完整构建、受控真人与部署未验收,不提前标resolved) +- 首次发现 / 最近更新:2026-09-24 +- 影响面:星盘首屏 hook、共享快照缓存、fetch JSON 读取、盘位等待渲染。 +- 现象 / 触发:请求不返回时一直显示等待句;401 跳转前仍为空;初始请求失败而缓存刚由其它路径补齐时 state 仍 null。 +- 根因:只有 null / view,没有客户端期限;unauthenticated 快照映射为 null;catch 命中缓存后早退。预取与首次请求共用在途 promise,因此超时必须由共享请求持有,不能仅约束挂载后的新 fetch。 +- 修复:共享请求(含预取和身份重试)25 秒 timeout signal;client_timeout 独立 status 对应 timedOut 文案;响应 JSON 的 abort 不误判 schema;缓存快照回填 state;401 在跳转前可见登录失败体。主盘空模型复用 VedicChartSvg,不复制几何,五 Tab 可见禁用;空盘与西洋盘共享唯一呼吸 CSS,减弱动态效果静止。 +- 验证:chart-page-hook.test.ts 实际挂载 React hook,模拟期限触发、预取复用、缓存后到、401 缓存与响应、旧身份失败;chart-page-view.test.tsx 渲染骨架、禁用 Tab、空几何和 reduced-motion。完整成绩见本轮 PROGRESS,未把 Node 生命周期渲染当作浏览器证据。 +- 防复发:BUG-716 / 966「不得骨架」条款由 2026-09-24 D1 仅限星盘盘位推翻;其它页面、表格和 spinner 禁令不动。首屏共享期限从预取起计时,旧身份完成或异常不得覆盖新缓存,读响应体同样受期限约束。 +- 相关记录:BUG-715、716、717、723、966、996;TASK-chart-page-skeleton-wait-20260924。 +- 复发自:BUG-966 的同一等待链覆盖不足;旧测试仅检验静态壳、缓存与无动画,未模拟挂起请求和后到缓存。BUG-996 的账户指纹与旧响应防线保留并追加旧异常回归。 +- 修复版本:基线 5ff43b47f 上的本地工作树 codex/chart-page-skeleton-wait-20260924,未提交 / 未推送 / 未部署。 + +## BUG-1017 | 分盘与按需层失败仍永久显示等待句 + +- 状态:blocked(本地动态回归、独立审查、首轮组合全量对比及隔离Chrome通过;完整构建、受控真人 / 部署待验收) +- 首次发现 / 最近更新:2026-09-24 +- 影响面:星盘 varga、Chara、西洋、七政按需数据与失败投影。 +- 现象 / 触发:引擎忙、按需请求失败或抛错后 pending 清除,却无失败出口;分盘一直「这一分盘还没拿到」。 +- 根因:hook 无 layerFailures,只接受顶层 ok;真实 assembleChartView 对后续层失败经 enginePayload 压为 null,外层仍 ok,连引擎 429 原因也在投影前丢失。该事实是任务书未覆盖的必要透传缺口。 +- 修复:最小可选 layerFailures 投影保留既有引擎原因,未改路由、超时或并发;客户端逐层失败,重试先清失败再 pending,成功仅合并请求层,不抹其它已完成层。BFF rate_limited 保持原类,实际引擎429保留 engine_busy。分盘/西洋等待用空盘,大运/七政仍静态;失败均撤等待图并写对应原因。返回缺层不能标已加载,旧资料层结果不能写回。 +- 验证:chart-page-hook.test.ts 真实 assemble→fetch解析→React hook 四层429、catch重试、响应体超时、BFF限流、无效429体、并发乱序、401及资料指纹过期;chart-page-view.test.tsx 分盘、西洋、七政、Chara 的失败句与等待图互斥。详见 PROGRESS。 +- 防复发:层失败不能经 null 投影丢失;只有完整成功层可进入 loaded;失败不自动重试,显式点原 Tab/chip 重试,不增加入口。BUG-715/723 的429独立档及 BUG-717 按需、并发红线保留。 +- 相关记录:BUG-715、716、717、723、966、996、1016。 +- 复发自:BUG-715/723 的失败分档未覆盖按需层投影与组件出口;旧测试仅验主盘错误和端点不可用说明,未串联真实 busy 层响应到 hook。 +- 修复版本:基线 5ff43b47f 上本地工作树,未提交 / 未推送 / 未部署。 + +## BUG-1020 | CRLF 普通报告下载保留图盘 JSON 围栏 + +- 状态:blocked(已独立复现,修复与D5旧导出逐字节合同冲突,未获有限例外授权) +- 首次发现 / 最近更新:2026-09-24。 +- 影响面:旧普通Markdown下载与新分块导出的正文组。 +- 现象 / 触发:真实引擎虚构golden仅将LF换成CRLF,正文下载残留22段图盘JSON;新分盘组仍可提取22盘。 +- 根因:report-chart-block.ts的FENCE_RE在jyotish-chart语言标记后仅匹配LF,stripReportChartBlocks共用它;旧测试验证LF剥除和CRLF字节等价,没有覆盖含真实围栏的CRLF无JSON。 +- 修复:尚未实施。建议仅支持语言标记后的CRLF,保留其余换行、SVG与投影;需先授权D5只对错误JSON残留作有限例外,D13不是该授权。 +- 验证:独立只读审查,转换golden后正文25块/残留22围栏/仍与旧导出byte-equal;相关105/105通过不能替代此未通过项。 +- 防复发:待补真实golden CRLF/混合换行无JSON回归及冻结旧LF输出字节锁;禁止只比较两个共同改过剥离器的函数而声称历史输出不变。 +- 相关记录:BUG-999/1003;TASK-report-chapter-export-fix-20260924.md。 +- 复发自:BUG-999同输出边界的未覆盖换行路径,继承剥离器缺陷,非本轮新增回归。 +- 修复版本:未修复 / 未提交 / 未部署。 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 89706e01..12d581d1 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -2,6 +2,13 @@ Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility. +## 2026-09-24 · 星盘与报告组合版本推送预检 + +- 产品在获知CRLF合同冲突及构建/真机缺口后授权push到staging。fetch成功,HEAD与origin/staging均为5ff43b47f;写入成功仍须push与ls-remote另核。 +- Python3.11.7运行pre_work_check:remote verified,适配器/碎片扫描步骤成功,focused 22通过/2失败;缺历史workbuddy镜像断言、candidate_count=4小于workspace_residue_count=105,整体fail。未伪造目录、清理他人资料或修改测试。 +- 首次调用输出重定向误落主工作目录,目标日志目录不存在、脚本未执行;随后明确进入组合树运行,日志仅存artifacts/combined-final-verification/push-pre-work-check.log。没有写主目录文件。 +- 两单最终验证与剩余事项见对应PROGRESS;允许push不等于全部验收或部署通过。 + ## 2026-09-24 · 次级页新建意图交付预检 - 产品授权推送供远程 review;fetch 与 pre_work_check 远端可见性均成功,推送仍须另核 SHA,不将远端可读等同写入成功。 diff --git a/docs/tasks/PROGRESS-chart-page-skeleton-wait-20260924.md b/docs/tasks/PROGRESS-chart-page-skeleton-wait-20260924.md new file mode 100644 index 00000000..33e1da3e --- /dev/null +++ b/docs/tasks/PROGRESS-chart-page-skeleton-wait-20260924.md @@ -0,0 +1,109 @@ +# PROGRESS · 星盘页盘位骨架与等待出口(2026-09-24) + +## 交付授权(2026-09-24) + +产品已明确要求“请你帮我push到staging”,授权将两单组合版本提交并快进推送。实际交付以push成功和远端SHA核对为准,不将推送当作完整构建、受控真机或部署通过。下述未提交状态描述的是验证时快照,BUG仍blocked,不提升main。 + +## 当前结论 + +T1–T4 已实现,通过定向、独立review与隔离真实Chrome,已串行整合到 `codex/chart-report-integration-20260924`。首轮组合全量已对照固定基线,无新增失败;完整构建、受控真机与部署仍有缺口。报告末次depth修正后的最终组合重验亦已完成,详见末节。未 commit / push / stash / 部署。BUG-1016 / 1017 暂 blocked,不以任务书要求的 resolved 字样替代真实验收。 + +- 实施基线:主会话已 fetch 并创建的 `5ff43b47f`;任务书旧基线 `8902e484` 仅作事故实证。本轮没有再切分支或改动主检出。 +- 分支 / 工作树:`codex/chart-page-skeleton-wait-20260924` / `.worktrees/chart-page-skeleton-wait-20260924`。 +- 依赖:仅建立授权的 `frontend/node_modules` junction,未安装或升级。固定 `D:/nvm/installs/v22.23.2/node.exe` v22.23.2,与主会话基线相同。 +- 已读 AGENTS / CLAUDE、frontend AGENTS / DESIGN / VOICE、任务书、本地 Next `use-client` guide;本单纯前端与现有 BFF 投影,不运行基础设施预检。骨架只是既有空几何,没有数据编码或新分类调色板;不改原盘色。 + +## 逐项实施 + +| 项 | 实现 | 本地证据 / 尚欠 | +| --- | --- | --- | +| T1 主盘出口 | 共享 fetch(含侧栏预取、资料指纹重试)持有25秒 signal;client_timeout 映射 timedOut;JSON读取中超时不误判 schema;缓存已有view回填;401先呈现失败体再跳转 | React真实生命周期与模拟fetch,含预取、缓存、过期身份、401;真人/部署尚欠 | +| T1 层出口 | Map逐层记录失败;再次点原入口清失败并pending;失败不置loaded;成功只合并请求层,保留其它已成功层;BFF rate_limited不冒充engine_busy | assemble→fetch→hook四层429、catch重试、超时、坏429体、乱序成功与身份失效 | +| T2 盘位骨架 | VedicChartSvg导出空模型并支持skeleton,强制不画数据;西洋复用原SVG空模型;ChartPageSkeleton仅供星盘盘位;初始Tab可见禁用,主盘到后解禁 | 空盘几何与标签、busy、五禁用Tab、失败替换、静态表格均有渲染合同 | +| T2 动效与边界 | 唯一CSS keyframe:2.4s ease-in-out / 0.35↔0.6,reduced-motion静止0.5;无spinner/旋转/扫光 | CSS合同;真机视觉未验 | +| T3 规则 | AGENTS §6有限例外,DESIGN §9/§15及共享壳说明同步,VOICE新增唯一timedOut句 | BUG-716/966防复发条款同时注明有限推翻;其它页不变 | +| T4 记录 | BUG-1016/1017、CHANGELOG、BLOCKED、状态板、本记录与真机清单 | 未将本地实现当上线;Skill不bump | + +## 任务书勘误与必要范围 + +1. 真实 `assembleChartView` 的后续层失败先经 `enginePayload` 变成 null,再返回 HTTP200/status=ok。单改 hook 无法恢复 429 原因。主会话确认必要范围后,给 `ChartViewOk` 增加可选 `layerFailures`,在 `chart-view-load.ts` 保留既有 engine_busy / engine_timeout / engine_bad_payload / engine_http_error 分类。未改 `/api/chart-view` 路由、引擎10秒期限、HEAVY_COMPUTE_PATHS、并发或任何Python文件。 +2. 任务书 D5 写超时→timedOut;T1要求 status=client_timeout,以 T1 的明确状态要求为准:`client_timeout` 是状态,`timedOut` 仅是文案key。不把客户端超时加进出生资料失败原因数组。 +3. 首次请求真正从共享缓存发出,故超时由 `refreshChartSnapshot` 建立而非 hook 单独建一个被已在途promise忽略的signal。预取起计时、资料指纹重试共用同一期限;过期epoch失败转当前刷新,不写回旧fallback。 +4. BFF 自身 `rate_limited` 与引擎429不是同类,前者保留既有频繁打开句;只有引擎busy或没有有效业务体的HTTP429显示busy句。JSON读到一半发生abort仍是超时。 +5. 各层响应带D1与其它未请求层的空占位,整体覆盖会在西洋成功后点D9丢西洋结果。客户端仅合并当前请求层;暖缓存进页的后台D1刷新迟到时,同身份已成功层也保留,动态乱序与暖刷新回归确保成功数据不退化。 +6. 真机清单“断网→timedOut”并非浏览器必然行为:网络立即拒绝应unavailable,持续挂起才达25秒期限;已写明两种测试,不伪造速度承诺。AbortSignal按活动时间计时,后台休眠不承诺墙钟25秒。 +7. grep `chart-skeleton-breathe` 本身会命中声明与引用两行;按意图只允许CSS一个文件、一个keyframe定义、一处动画规则,不把合法引用误当第二套动画。 + +## 历史检索与旧防线核查 + +以等待原句、`/api/chart-view`、hook、层日志关键词检索并完整阅读 BUG-715/716/717/718/723/966/996(最大号1015,本轮1016/1017未被占用)。 + +| 原防线 | 核查 | +| --- | --- | +| BUG-715/723:引擎失败分档、429独立、日志不吞 | chart-view-engine 与既有定向测试仍在;此次补后续层到UI透传,不改既有引擎分类 | +| BUG-716:静态壳、只取D1、其它层按需 | 静态route未改,engine/route合同通过;仅“不得骨架”按授权有限推翻 | +| BUG-717:不改并发、不预取西洋/七政、不写速度成本眉标 | 路径/并发未动、按需入口不变,成功和失败视图合同保留 | +| BUG-718:跳过页面无需的外部证据 | birthPayload标志未动,route合同仍通过 | +| BUG-966:模块缓存、同inflight、稳定壳 | 缓存/共享壳不搬;预取共享期限补上;星历/报告无骨架断言不改 | +| BUG-996:账户+资料指纹、旧结果不可回写、错误分档 | 原定向全过;新增旧请求异常与旧层完成不得写新身份缓存 | + +## 验证实测 + +日志均只在本树 artifacts,不纳入提交。 + +| 命令 / 范围 | 基线 | 当前 | +| --- | ---: | ---: | +| 固定5套定向:chart-page-view / secondary-page-entry / chart-profile-update-consistency / chart-view-engine / chart-view-route | 49 pass / 0 fail / 0 skip | 54 pass / 0 fail / 0 skip | +| 新增 chart-page-hook 真React生命周期 | 不存在 | 18 pass / 0 fail / 0 skip | +| 合计定向 | 49 | 72 pass / 0 fail / 0 skip | +| tsc --noEmit | 主会话持有 | 0错,exit0 | +| npm run lint | 主会话持有 | 0error / 119warning,exit0;不顺修既有warning | +| 组合首轮全量npm test | 3717 /3633pass/84fail | 3763 /3679pass/84fail;新增46全过、名称消失0、旧状态变化0 | +| 组合首轮build、Static、gzip | 默认junction失败;webpack Skill symlink EPERM | 两类失败相同;webpack编译/类型过,Static与gzip仍未验证 | +| 隔离Chrome | — | 星盘单树及组合树分别13/13通过 | +| 受控登录、iOS与staging health | 未执行 | 环境/验收缺口,见BLOCKED与真机清单 | + +使用 `node --import tsx --test`,不是新增测试运行器;日志 `artifacts/chart-targeted-baseline.log`、`chart-targeted-final.log`、`chart-tsc.log`、`chart-lint.log`。新增测试真实执行 hook 与 fetch/schema/assemble,不仅匹配源码;仍不替代浏览器布局或后端线上压测。 + +## 既有断言变更(三栏) + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| chart-page-view 冷主盘等待不得骨架 | 必须data-skeleton + aria-busy、五Tab禁用,仍无spinner/正在加载 | D1有限授权,增加反向约束 | +| chart-page-view 西洋pending不得skeleton | 必须data-skeleton,仍保留原等待句且禁止InlineSpinner | D2复用西洋空模型 | +| secondary-page-entry 星盘源码禁止skeleton | 必须ChartPageSkeleton引用,仍禁止InlineSpinner/正在加载 | 只放开星盘;星历与报告原断言不变 | + +定向日志名称实测:原49个名称无消失,新增23个全通过。所有原测试名称保留,未删除测试;主会话最终还需对全量名称多重集合与基线逐项比较。 + +## 独立复核与隔离浏览器(2026-09-24) + +- 独立只读审查未发现需阻断交付的正确性缺陷;复跑 hook/缓存/引擎/路由55条与视图17条全部通过;独立暖缓存乱序复现确认慢D1不会覆盖已成功D9。 +- 系统 Chrome 153、全新临时profile、真实 ChartPageRoute→useChartPage→fetchChartView、真实CSS,接口白名单mock消费真实assembler与虚构golden,13/13场景通过,pageerror=0。覆盖375/390/1280冷进、D9 429与原入口重试、西洋骨架、七政/大运静态等待、减弱动态、主盘/分盘超时、BFF限流及401导航。 +- 超时测试只在浏览器harness把原25000ms映射为500ms;未改业务源码。401证据为导航前DOM提交且布局可见,不保证实际快速跳转时足够读完。 +- 证据仅本地 `artifacts/chart-report-browser-20260924/chart-2026-09-24T03-24-29-908Z/results.json`。浏览器已关闭,不使用真实资料、账号或凭据。该证据不替代完整Next服务器、真实后端、登录staging、iOS真机或部署验收。 + +## 组合验收与剩余事项 + +- 已完成两个工作树串行整合:secondary-page-data同时保留chart身份逻辑与reports通用cache epoch;VedicChartSvg同时保留骨架与报告hook-free Drawing,无第二份几何。 +- 首轮84失败名称多重集合一致,83项完整诊断直接一致;appendices owner-read一项首轮fixture槽位pid ENOENT,单项复跑恢复基线duplicate migration诊断,未写成通过。Docker本轮可用,不套用上一单地址池结论。 +- 固定基线失败分类:migration symlink文本38、Skill references文本9、Windows symlink EPERM13、双盘符路径6、POSIX路径/权限/UID/install11、python3/rsync不可用4、底因未证实exit1共3。明细在基线artifacts及组合 `artifacts/combined-verification`,不提交。 +- 首轮3305非md文件起止hash相同:`e95f3f18801d10fa757e52622fa3fb850af2258fc0ff7c5667300b588f73a90c`。此后仅追加报告未知depth透传/测试断言,最终重验结果见末节。 +- 待完整构建环境补Static/gzip,按真人清单完成受控验收后再决定BUG是否resolved;没有本轮commit/push/部署,不改main。 +- 报告独立审查的CRLF/D5冲突是另单未通过项,不冒称两单全部完成验收。 + +## 最终组合快照复验(含报告depth末次修正) + +- tsc0错;lint0error/119warning;3763 tests/3679pass/84fail/skip0。相对固定基线新增46全通过、名称多重集合消失0、旧状态变化0,84失败名称及84份归一化完整诊断全部一致;首轮fixture槽位竞态未复现。 +- 开始/结束3305非md文件hash同为 `b076a2c2f40d1fdac1520fa88b38818f14847b7e4710047571a0f73052d580ce`,无代码漂移,仅文档变化。 +- 默认build仍junction失败;webpack编译/类型过,Skill symlink EPERM未解除,本轮先报/api/consult而非基线daily-starlanguage(并行worker先报路径不同,底层原因相同)。Static/gzip仍未验,不写成通过。 +- 证据:组合树 `artifacts/combined-final-verification`;首轮日志保留。 + +## 最终组合Chrome复验 + +- 组合树重新运行星盘13/13、报告14/14,pageerror/非白名单请求0;真实合并代码,不复用单树成绩。 +- 合并VedicChartSvg同时经星盘骨架与报告无原SVG fallback实际下载验证,SHA256 `c59bced71cc9433a32c09c68d5aa437f3b8de8e95f3cb9c8b88baeecc19a2a00`,两runner记录前后稳定;chart10个/report9个核心文件无漂移。 +- 组合星盘证据:主仓本地 `artifacts/chart-report-browser-20260924/chart-2026-09-24T03-59-56-966Z/results.json`;报告为 `report-2026-09-24T04-01-33-428Z/results.json`。浏览器已关闭;mock API边界、超时harness映射与无受控登录/iOS/部署的限制不变。 + +## 最终范围与根tests合同 + +独立审计未见两共享文件合并遗漏、无关源码或冲突marker;精选整合点8/8通过。根Python前端合同四文件18条:17通过、1既有失败。主题合同test_frontend_theme_contract.py:29期待旧ink-secondary #676762,基线HEAD的CSS已为#5f5f59,内存重放基线同样失败;未改CSS迎合旧合同。另三个session/daily/rectification/birth journey入口合同通过。无Python/API路由/依赖/workflow/并发改动,diff --check通过。 diff --git a/docs/tasks/PROGRESS-report-chapter-export-20260924.md b/docs/tasks/PROGRESS-report-chapter-export-20260924.md new file mode 100644 index 00000000..3a5c02fa --- /dev/null +++ b/docs/tasks/PROGRESS-report-chapter-export-20260924.md @@ -0,0 +1,145 @@ +# PROGRESS · 报告分块导出与列表管理(2026-09-24) + +## 交付授权(2026-09-24) + +产品在获知下述未通过项与环境缺口后明确要求“请你帮我push到staging”。本轮按此授权提交组合版本并快进推送staging;实际交付以push成功与远端SHA核对为准,不代表CRLF合同已获变更授权或部署验收通过。以下“未提交/未推送”均为推送前验证快照;BUG仍blocked,不提升main。 + +## 当前结论 + +T1–T4 已实现并串行整合,但**不是全部验收通过**:独立审查复现 CRLF 输入保留图盘 JSON 的旧缺陷,与 D5 逐字节合同冲突,未授权改变 D5,记为未通过。构建、受控登录与真机仍有缺口。未 commit / push / 部署。 + +- 固定执行基线 `5ff43b47f38e378918c8b6bd967228185d7f02fe`,替代任务书事故实证旧基线。 +- 实现树 `.worktrees/report-chapter-export-20260924`;最终组合树 `.worktrees/chart-report-integration-20260924`,分支 `codex/chart-report-integration-20260924`。 +- Node v22.23.2 / npm10.9.8,复用已有依赖 junction,不安装或升级;42项顶层非可选依赖与 lock 精确匹配。 +- 两份新记录最初受子代理报告写入限制,未绕过。用户随后明确选择“由主会话补齐”,本记录与 testing 清单由主会话按该授权创建。 + +## 实现与逐项验收 + +| 项 | 实现 / 证据 | 结论 | +| --- | --- | --- | +| T1 正文分块 | 唯一来源 buildLongformOutline(projectOrdinaryReportMarkdown(...));补 sourcePreamble/sourceMarkdown 无损切片,原阅读字段不变;LF golden 正文全选等于旧导出 | 主路径通过;CRLF 无 JSON 合同未通过 | +| T1 盘表 | 22个盘出现项(D9两次)+8组表;安全SVG优先复用,fallback共用 VedicChartSvgDrawing,无第二份几何或 react-dom/server;表格与页面共用显示转换 | golden与定向通过 | +| T2 抽屉 | 原生底部dialog、三组/三套餐、替换式选择、手动取消高亮、全选/清空、实际字符计数、0选禁用 | 组件与隔离Chrome通过 | +| T3 入口 | 旧整篇按钮替换为分块导出,#export ready后消费一次并清hash;打印及原始附录保留;只用已有数据/下载器 | 组件与隔离Chrome通过 | +| T4 列表 | 中文日期/深度/主题,未知字符串原样回退;三色状态;行内确认删除、防重复DELETE、失败留行、成功移除与cache epoch/tombstone防旧GET回写 | 定向通过;真实持久化未验 | +| T5 记录 | DESIGN、VOICE、CHANGELOG、BUG-1018/1019、任务书D13、本进度及真人清单 | 已补齐;BUG仍blocked | + +无新网络请求用于导出,无剪贴板,无 spinner/骨架,不改数据库、GET API字段、生成管线、report-chart-grid-rehype、workflow、DNS或main。 + +## 决策与技术勘误 + +1. 用户执行中明确“分盘导出也需要带出svg”,随后要求继续。任务书 D13 有限替代 D4/T1 禁 SVG,不追溯声称原单已授权。 +2. D5 保持不变:常用/正文全选与旧普通导出逐字节一致;**所有组全选**额外附盘/表,不与旧纯正文文件相等。 +3. golden 为25正文+22盘出现项+8表=55块;D9出现两次,不按id去重。 +4. 文件名helper返回stem,`.md`由既有下载器追加;实际字符数含Markdown/SVG标记,按Array.from计算。 +5. fallback SVG共用hook-free绘图与受限序列化,离线presentation attributes不依赖应用CSS;不放宽普通投影安全边界。 +6. 实际主题使用既有success/warning/danger/canvas token,不新增不存在的别名。 +7. 组合SVG同时保留星盘empty/skeleton与报告Drawing出口;secondary缓存同时保留星盘身份epoch、报告失效epoch及未知depth透传。 + +## 独立审查:CRLF 合同缺口(未通过) + +审查复跑105/105相关测试,但另以内存把真实虚构golden的LF转换为CRLF,发现正文下载仍有22个 `jyotish-chart` JSON围栏,同时仍与旧导出逐字节相同。已有测试通过不等于该边界安全合同通过。 + +根因:`report-chart-block.ts` 的 FENCE_RE 在语言标记后仅匹配 `\n`,不接受 `\r\n`;旧 ordinaryReportDownloadMarkdown 与新正文块共用此剥离器。这是**继承缺陷,不是本单新增回归**。新分盘提取已能识别CRLF,所以同一输入另可生成22盘块。 + +D13只授权SVG,不授权改变D5。本轮未静默修改剥离器或弱化JSON禁出断言。建议对CRLF围栏输入授权D5有限例外后,仅修语言标记后的换行匹配,冻结旧LF输出作独立回归;详见 `TASK-report-chapter-export-fix-20260924.md`。本项阻止把报告单称为全部验收通过。 + +另有非阻断观察:优先复用的旧golden引擎SVG实际为南印矩形布局,标题沿用chartAriaLabel的“北印度盘”;按D13保留原SVG,未擅改几何或原输出,图式/标题对齐待后续确认。 + +## 验证实测 + +| 项目 | 固定基线 | 本轮 | +| --- | ---: | ---: | +| 报告八套件 | 140/140 | 扩展定向205/205 | +| 报告基线测试名消失 | — | 0 | +| 报告本单新增 | — | 23(纯块12、抽屉6、删除/元信息/cache5) | +| 星盘定向 | 49/49 | 72/72 | +| 组合首轮全量 | 3717 /3633pass/84fail | 3763 /3679pass/84fail | +| 组合首轮名称多重集合 | — | 消失0,新增46全通过,既有状态变化0 | +| 组合首轮tsc | 0错 | 0错 | +| 组合首轮lint | 0error/119warning | 0error/119warning | +| 默认build | junction越Turbopack root失败 | 同类失败 | +| webpack build | 编译/类型过,Skill symlink EPERM失败 | 同类失败 | +| Static / 动态路由标记、首页gzip±2% | 未取得完整构建产物 | 未验证 | + +205与140的差额包含42条既有共享回归,不能写成本单新增65条。全量按名称**多重集合**比较,不只看汇总、不去重。 + +首轮84失败名称相同,83项归一化完整诊断直接一致;appendices owner-read用例首轮遇fixture `slot-1/pid ENOENT`,独立复跑恢复基线 `duplicate migration filename`。保留原失败日志,未将复跑写成通过。本轮Docker可用,不继承上一单地址池耗尽结论。 + +基线失败分类:migration symlink检出为文本38、Skill references链接文本9、Windows symlink EPERM13、双盘符路径6、POSIX路径/权限/UID/install11、python3/rsync不可用4、exit1且底因未证实3,共84。 + +首轮源码3305非md文件开始/结束SHA256均为 `e95f3f18801d10fa757e52622fa3fb850af2258fc0ff7c5667300b588f73a90c`。之后仅补未知depth parser与一条既有测试内断言,最终快照重验已完成,见末节;不将首轮数字冒称覆盖此末次修正。 + +证据均为本地artifacts,不提交:实现树 `report-baseline.log`、`report-final-targeted.log`、`report-tsc.log`、`report-lint.log`;固定基线树 `artifacts/chart-report-baseline-20260924`;组合树 `artifacts/combined-verification`(summary、名称/状态、完整诊断、单项复跑、源码hash)。 + +## 隔离真实浏览器 + +系统Chrome153、全新profile、真实组件/CSS/缓存函数、虚构golden和白名单mock;单报告树13/13通过,pageerror及非白名单请求0,8个核心文件hash前后相同,浏览器已关闭。覆盖套餐/手选/空选、4种真实下载、Esc/遮罩回焦、hash一次、375/390/1280布局/44px/两列/避侧栏、删除失败/重复/旧GET/缓存重挂载。 + +| 实际下载 | 字节 | +| --- | ---: | +| 常用/全部正文 | 221792 | +| 全部分盘(22 SVG) | 88288 | +| 仅核对表 | 7512 | +| 所有组全选 | 317596 | + +LF常用逐字节等旧导出,所有下载ordinaryOutputLeaks为空;无应用CSS的SVG截图可读。证据:主仓本地 `artifacts/chart-report-browser-20260924/report-2026-09-24T03-50-59-992Z/results.json`。组合树另跑14/14通过(新增真实fallback下载),详见末节。 + +仓内交互测试使用真实React reconciler/state/effect及已挂载handler,不模拟事件冒泡或布局。浏览器宿主提供Next router、mock接口,**不等于完整Next集成、真实数据库、iOS/微信、登录staging或部署验收**。 + +## 既有断言三栏 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| entry:Center含旧下载helper | #export且无旧helper/exportingReportId/exportError | D10改深链,删旧请求路径 | +| entry:download含GET URL/filename helper | 无fetch,保留ordinaryReportDownloadMarkdown/projection | 保留历史纯转换,抽屉复用唯一下载器 | +| entry:Center export error alert | 每行ReportRowDelete及错误role=alert | 导出移抽屉,删除失败行内反馈 | +| view:旧下载helper | ReportExportDrawer且无旧helper | D1/D10 | +| view:print disabled含exportBusy | exportOpen,仍保留printBusy/capability | 抽屉打开期间不同时打印 | +| longform-md:导出报告(.md) | 分块导出 | 授权入口替换,其余writer断言保留 | + +原测试名称保留;新测试无SVG要求按D13替换为安全SVG+无JSON/泄漏。未知depth追加真实readReports→rowMeta断言,不只直接喂rowMeta。 + +## 历史核查 + +执行方已检索并阅读 BUG-125/154/156/247/352/455/486/496/526/534/574/610/966、999及1000–1015相关段。 + +- BUG-999/1003:普通投影、安全SVG、内部字段过滤不改;LF golden块leak=0与正文byte lock执行。CRLF围栏另列未通过,不据旧测试预写通过。 +- BUG-154:报告入口不绑会话、生成/失败/未登录分支保留,API/entry回归通过,创建链不改。 +- BUG-352/574:日期/card_summary/列表字段投影保留,当前路由读取独立card_summary。 +- BUG-496:failure摘要fallback保留,不改writer预算/分类。 +- BUG-526:仍用loadReportCandidateRange共享路径,不增加直接校正表SELECT。 +- BUG-534:progress phase仍用isValidPersonalReportJobProgressPhase,不改job链。 +- BUG-455:reports路由pricing_configuration_unavailable映射保留。 +- BUG-966:共享壳、缓存、预热不搬;新增失效epoch回归,不增加报告等待动画。 +- BUG-125/247/156/486/610:旧入口、生成/适配、引擎路径本轮未改,不以历史部署数字充当本轮证据,旧符号不保证逐字存在。 +- BUG-1018/1019保持blocked,真实受控登录/持久化/部署未验。 + +## 剩余事项 + +1. 最终depth快照全量/类型/lint及组合Chrome已完成,见下。 +2. CRLF无JSON与D5冲突按修复单保留blocked,不擅改已确认合同。 +3. 在可完整构建环境验证四路由标记和首页gzip;iPhone、微信、受控登录持久化与health SHA按testing清单补验。 +4. 无commit/push/部署,不改main/workflow/DNS,不清共享Docker资源,不修任务外warning,不提交artifacts或依赖链接。 + +## 最终组合快照复验(含未知depth修正) + +- tsc exit0;lint0error/119warning;全量3763/3679pass/84fail/skip0。 +- 对固定基线3717/3633pass/84fail:新增46全部通过,名称多重集合消失0、旧状态变化0;84失败名称及84份归一化完整诊断全部一致。首轮fixture槽位竞态未复现,首轮日志仍保留。 +- 3305个非md文件开始/结束SHA256均为 `b076a2c2f40d1fdac1520fa88b38818f14847b7e4710047571a0f73052d580ce`;无代码漂移,期间仅文档变化。 +- 默认build仍因跨root junction失败;webpack编译/类型通过,但Skill symlink EPERM。本轮15worker中先报路由/api/consult,基线先报daily-starlanguage,不能把报错路由差异藏掉;相同底层链接权限原因,完整Static/动态标记及gzip依然未验。 +- 证据独立存放组合树 `artifacts/combined-final-verification`,没有覆盖首轮。上述自动化不消除CRLF独立审查未通过项。 + +## 最终组合Chrome复验 + +- 同一组合树重新打包并运行:星盘13/13,报告14/14,共27/27通过;pageerror与非白名单页面请求均0,两个context均已关闭。 +- 人工合并VedicChartSvg的骨架与导出路径均实际执行。报告额外用真实golden去掉原SVG来源,真实Actions/Drawer下载触发VedicChartSvgDrawing fallback;离线无CSS时12宫轮廓、行星、星座号、图例、逆行线可读,截图已查看。 +- fallback文件6095B、SHA256 `74c9e17eb97bcde581777c9dd0a2ffca80eed2ab89e4c2d0554205196921edd6`;其它四份下载hash与单树完全一致。D5证据限于该LF golden,不掩盖CRLF未通过项。 +- 合并绘图文件SHA256 `c59bced71cc9433a32c09c68d5aa437f3b8de8e95f3cb9c8b88baeecc19a2a00`;chart10个/report9个源码hash前后均稳定。 +- 主仓本地证据:`artifacts/chart-report-browser-20260924/chart-2026-09-24T03-59-56-966Z/results.json`、`report-2026-09-24T04-01-33-428Z/results.json`,后者目录含download-fallback.md及无CSS截图。仅专属artifacts,不提交;仍不等于登录staging、DB或iOS/微信真机。 + +## 最终范围与跨语言合同复核 + +独立只读审计确认共享SVG/缓存两单逻辑并存,没有API路由、Python、依赖/锁文件、workflow、部署或引擎并发配置改动,无冲突marker,diff --check通过。精选整合点8/8通过。 + +根tests四套前端源码合同实跑18条:17通过、1失败。`tests/test_frontend_theme_contract.py:29`仍期待 `--color-ink-secondary: #676762;`,基线HEAD原始CSS已是 `#5f5f59`;同一测试以内存基线CSS重放亦失败,确认既有合同欠账,未顺修。其它session management / daily and rectification / birth time journey入口合同通过。此项不混入前端npm test的84失败数字。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index ba2aafbf..c13f7103 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -170,7 +170,8 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-report-chapter-export-20260924.md` | — | **我的报告:分块导出抽屉 + 列表页中文元信息 / 状态色 / 删除**(原型 https://claude.ai/artifact/TM7SQetho1p3cDpVwj3RgE ,主按钮已由「复制」改为下载)。列表页 `rowMeta` 直接输出 `standard` / `career` 等英文 id(BUG-1018);`DELETE /api/reports/:id` 有接口无 UI(BUG-1019);阅读页只能整篇导出。**产品 09-24 拍板(两次)**:形态是部分导出——勾选章节后下载一个 .md,**不写剪贴板**;阅读页与列表页原「导出报告(.md)」按钮都删,全选 = 整篇导出(回归锁:全选文件逐字节等于旧导出);章节全列不做白名单(以 `projectOrdinaryReportMarkdown` 后的 H2 为准、`buildLongformOutline` 唯一切分器);盘 22 块 + 核对表 8 块另成两组;手机与桌面都是底部原生 `` 抽屉,桌面限宽 760 不盖侧栏、≥860 两列;套餐常用 / 全部分盘 / 仅核对表。红线:导出文本过 `ordinaryOutputLeaks()`、零新请求、只走现有 `downloadMarkdownReport`、不做 PDF / 分享 | 待领取 | — | +| `TASK-report-chapter-export-fix-20260924.md` | `PROGRESS-report-chapter-export-20260924.md` | **CRLF图盘JSON残留(BUG-1020)**:旧剥离器只匹配LF;建议D5对错误围栏残留作有限例外,D13不构成该授权。 | 待裁决(blocked,未执行) | 组合候选未提交 | +| `TASK-report-chapter-export-20260924.md` | `PROGRESS-report-chapter-export-20260924.md` | **我的报告:分块导出 + 中文元信息 / 状态色 / 行内确认删除(BUG-1018/1019)**。旧整篇按钮由抽屉取代;常用 / 全部正文保持 D5 逐字节,所有组全选另附盘和核对表。后续产品裁决 D13:分盘必须带安全 SVG,有限替代原无 SVG 约束;JSON 围栏及内部字段仍禁。原生底部 dialog、760px、70%、860 两列、零额外请求、保留打印 / 原始附录。 | 待验收(CRLF合同未通过;完整构建 / 受控真机缺口) | 已整合 `codex/chart-report-integration-20260924`,未提交 / 未部署 | | `TASK-report-density-20260922.md` | `PROGRESS-report-density-20260922.md` | **报告信息密度与原始附录(BUG-1003~1007)**:与源头仓 PL9 全量报告同资料实测:对照 847,632 字符 / 8,044 表格行,我方 309,357 / 1,478。缺口不在引擎——20 张分盘全表、KP 四表、Ashtakavarga 四类、Shadbala 六分量、Avastha、Sahams、年度都已算得出,但 `personal-report-contract.ts` 的 `REPORT_SECTION_KINDS` **没有表格类型**、`CHART_IDS` 只有 9 种,这些表在产品页报告里没有落脚字段。另测出对外原始附录泄漏 925 种 / 3,797 处工程标识符(`parameter_sensitive` 697、`cmd_full_reading` 68、`PyJHora`/`JHora` 35、`PL9` 页码 59),对照物同项为 0。**产品 2026-09-22 三点拍板**:层 1 按 8 组表 + 分盘扩到 16 张;层 2 原始附录直接给 C 端且先全显示;B 类大运族要显示。**决策记录已写明推翻 BUG-999 的两条红线**(专业参考不得作普通下载 fallback;`parameter_sensitive`/「参数敏感」命中整行剔除)——**仅限附录通道**,普通正文投影不动。硬红线:表格层服务端装配、writer 不得写表;对照物的替代大运族日期与 Shadbala 分量在源头仍未闭环(506 行 date mismatch、`production_tuning_allowed=false`),不得照搬升级。让步顺序与串行依赖(任务 3 → 任务 4 同改契约)见任务书 | **验收未通过**(2026-09-23):任务 1/2/4/5 通过;任务 3 事实表在普通报告里显示 447 个引擎键路径(P1);隐私门新增失败(任务书自身写入真实出生资料 BUG-1008 已清 HEAD,fixture 数值碰撞待登记);当时 BUG-1003~1007、PROGRESS、testing 清单缺失;F3 现已补前两项,testing 新建受工具边界阻塞仍缺。Node 22 前端 0 新增失败、`/` Static、gzip +0.007% | 实现 `bbd96d3b`(原 `f968cb21`,09-23 为清除 BUG-1008 重写历史;已在 staging,未部署);修复单 `TASK-report-density-fix-20260923.md` | | `TASK-report-density-fix-20260923.md` | `PROGRESS-report-density-20260922.md` | **报告密度验收修复单(BUG-1009/1010 + 补写 1003~1007)**:F1 事实表改成真正的表(逐组规定列,普通报告可见格 0 个引擎键 / 0 个下标 / 最多 2 位小数,`sourcePath` 保留供回查;主运年数不得填出生剩余年数);F2 按既有机制登记 fixture 的数值碰撞(已独立重跑确认是虚构输入);F3 补 Bug 历史 / PROGRESS / 真人清单并更正「未提交推送」;F4 两份清洗规则只留一份(Python 版漏第三方页码);F5 writer 写表拦截补测试与提示;F6 事实表可打印。硬红线:不得弱化隐私扫描、不得强推或重写 staging(历史清除由产品负责人决定);Sade Sati 三轮日期是原任务书错误、不在本单 | **已验收,F1–F6 全部通过**(2026-09-23,`a12f2c0d`):事实表 130 行 / 600 格 0 引擎键 0 下标、主运年数 18、隐私门转绿、两份规则逐字节一致、写表拦截变异测试红→绿;Node 22 前端 3,748 / 26(名单与基线一致)、Python 948/0。剩数据库测试 E2BIG 另开 fix2(BUG-1011) | `a12f2c0d`(门禁 run 6294 红,未部署);真人清单 `docs/testing/report-density-20260922.md` | | `TASK-report-density-fix2-20260923.md` | `PROGRESS-report-density-fix2-20260923.md` | **快照数据库测试无法启动(BUG-1011)——staging 恢复部署前的唯一阻塞**:门禁 run 2857 为 3,762 / 1 fail,唯一失败即此。测试把 428,557 字节 SQL 作为单个 psql 参数,超过 Linux 131,072 上限 → `E2BIG`。**09-23 更正做法**:不得改共享 `psql`/`psqlAs`(29 文件 522 次调用依赖 `-c` 单事务语义,`selectAsAuthenticated` 的事务内 `set_config` 会失效);新增单事务 + 标准输入 + 显式 `maxBuffer` 的专用函数,只给本测试用。修通后若真实断言失败是产品缺陷,不得改断言 | **已验收并部署 staging** | `018b2b48`;run 2858 / validate 6349:3,764/3,764、0 fail/skip,名称无删除新增2;47个DB名称全过。migrate 2859、deploy 2860 成功,health 双 SHA 已核对;本机 Docker/Windows 缺口见 PROGRESS | @@ -195,7 +196,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-chart-page-skeleton-wait-20260924.md` | — | **星盘页骨架盘等待态 + 三个「永远还没拿到」死角**:「这一张盘还没拿到。」只代表 `view === null`,是唯一等待态,失败各有句子;但客户端无超时(A)、401 跳转前(B)、分盘层失败 / 429 后 `ChartVedicTab` 只看 `!selected` 不看 pending(C)三条路都让这句永远挂着。**产品 09-24 拍板推翻 09-18 方案一(BUG-966)**:星盘页盘位用骨架盘 + 呼吸动效(复用 `VedicChartSvg` 空模型,不复制几何;`prefers-reduced-motion` 静止),范围只限星盘页会出盘轮的位置,首页 / 侧栏 / 星历 / 报告的「不放骨架」合同不动;`AGENTS.md` §6 与 `DESIGN.md` §9 / §15 追加例外。三死角出口:客户端 25s 超时 → `timedOut` 句、401 先写失败体、按层失败态 `layerFailures`(429 独立档,BUG-715/723)。BUG-1016 / 1017 | 待领取 | — | +| `TASK-chart-page-skeleton-wait-20260924.md` | `PROGRESS-chart-page-skeleton-wait-20260924.md` | **星盘页骨架盘等待态 + 三个「永远还没拿到」死角**:「这一张盘还没拿到。」只代表 `view === null`,是唯一等待态,失败各有句子;但客户端无超时(A)、401 跳转前(B)、分盘层失败 / 429 后 `ChartVedicTab` 只看 `!selected` 不看 pending(C)三条路都让这句永远挂着。**产品 09-24 拍板推翻 09-18 方案一(BUG-966)**:星盘页盘位用骨架盘 + 呼吸动效(复用 `VedicChartSvg` 空模型,不复制几何;`prefers-reduced-motion` 静止),范围只限星盘页会出盘轮的位置,首页 / 侧栏 / 星历 / 报告的「不放骨架」合同不动;`AGENTS.md` §6 与 `DESIGN.md` §9 / §15 追加例外。三死角出口:客户端 25s 超时 → `timedOut` 句、401 先写失败体、按层失败态 `layerFailures`(429 独立档,BUG-715/723)。BUG-1016 / 1017 | 待验收(独立审查、隔离Chrome、全量对比完成;完整构建及受控真机缺口) | 已整合 `codex/chart-report-integration-20260924`,未提交 / 未推送 | | `TASK-smalltalk-test-stdout-mock-20260921.md` | `PROGRESS-smalltalk-test-stdout-mock-20260921.md` | **测试基础设施(BUG-995)**:`consultation-smalltalk.test.ts` 为验证「SDK 不得把私密文本写进日志」,用 `t.mock.method(process.stdout/stderr, "write", …)` 全局接管标准输出——而 `node:test` 的 TAP 报告也走 `process.stdout`,于是运行器自己的 `# Subtest:` / `ok` 行被吞进测试的 `logs` 数组。实测四次全量:`default Mastra adapter` 四条用例只上报 4 / 2 / 1 / 1 条(`Subtest` 声明本身就少,不是 grep 锚点问题),`# tests` 因此 ±3 抖;单跑该文件 33 条静态展开只报 30 条,仅循环最后一个 `provider_error` 稳定可见。**更要紧**:注入必失败探针后,`invalid_schema` / `bad_json` 只剩一行 `not ok 1 - <文件绝对路径>`,**没有用例名、没有断言消息**(退出码仍是 1,门禁不会漏掉失败,不夸大)。定位 BUG-987 时靠的就是从门禁日志 grep `not ok` 抓用例名,红在这三条上那条路会断。**决策**:mock 改为记录后**透传给原始 `write`**(保留隐私断言,不换报告通道,不改 `npm test` 脚本);F3 把「比用例名列表 diff、不比 `# tests` 总数」写进 `frontend/AGENTS.md`。全仓只此一个文件用了这个手法(已 grep 确认)| 待验收 | `2503c019` | | `TASK-frontend-optimization-20260828.md` | `PROGRESS-frontend-optimization-20260828.md` | 前端优化九条 | 待核对 | 分支 `codex/frontend-optimization-20260828` | | `TASK-frontend-followup-20260829.md` | `PROGRESS-frontend-followup-20260829.md` | 九条收尾 | 待核对 | — | diff --git a/docs/tasks/TASK-report-chapter-export-20260924.md b/docs/tasks/TASK-report-chapter-export-20260924.md index 966174ba..2ab64d6d 100644 --- a/docs/tasks/TASK-report-chapter-export-20260924.md +++ b/docs/tasks/TASK-report-chapter-export-20260924.md @@ -44,6 +44,15 @@ - **D11 列表页**:`rowMeta` 改中文——日期全写(`2026年9月23日`)、深度映射 `REPORT_DEPTH_LABELS = {concise:"简版", standard:"标准深度", deep:"深度", research:"研究级"}`(放在 `personal-report-progress.ts` 与 `REPORT_THEME_LABELS` 并排)、主题走 `REPORT_THEME_LABELS`;状态 chip 分色(已完成 `--ok`、生成中 `--warn`、未完成 `--bad`,用 `color-mix` 12% 底);已完成行底色 `--report-paper`,其它行 `--color-surface`;行右上 `⋯` 菜单只有「删除」,点击后行内展开「确定删除这份报告? 删除 / 取消」,不用 `window.confirm`;删除成功后行移除并使列表缓存失效。仍是单列行列表,不做卡片网格(D12)。 - **D12 不做**:不做剪贴板、不做 PDF、不做分享链接、不改生成管线、不改数据库、不改 `GET /api/reports` 字段;微信内置浏览器下载限制沿用现有 `client-report-export.ts` 的检测与提示。 +### D13 · SVG 后续裁决(2026-09-24,执行中追加) + +原任务 D4 的“盘纯文本、不含 SVG”及 T1“每块无 SVG”与 D5 冲突:旧普通导出实际保留安全引擎 SVG。执行中提出该冲突后,产品明确回答“分盘导出也需要带出svg”,随后要求“请你继续”。此为后续授权,不追溯声称原单已经授权。 + +- D5 保持不变:常用 / 全部正文逐字节等于旧普通导出(包括已有安全 SVG)。 +- 新增分盘块必须包含安全 SVG,可同时保留落宫纯文本。优先复用投影后原有 SVG;无现成 SVG 时复用现有报告图盘组件,不另造几何、不增加请求。 +- 有限替代 D4 的“不含 SVG”及 T1 的无 SVG 断言;JSON 图盘围栏仍禁止导出,每块 `ordinaryOutputLeaks()` 仍必须为空,不放宽投影安全边界。 +- “所有组全选”包含附加分盘 / 核对表,不等于“全部正文”。真人清单③的逐字节对照针对常用 / 正文全选;所有组全选需另验附加内容。 + ## 硬红线 1. 导出文本必须过 `ordinaryOutputLeaks()` = 空(BUG-999 / 1003):合同测试把 golden fixture 每一块都跑一遍。 diff --git a/docs/tasks/TASK-report-chapter-export-fix-20260924.md b/docs/tasks/TASK-report-chapter-export-fix-20260924.md new file mode 100644 index 00000000..8e591ab5 --- /dev/null +++ b/docs/tasks/TASK-report-chapter-export-fix-20260924.md @@ -0,0 +1,63 @@ +# TASK · 报告导出 CRLF 围栏合同冲突(2026-09-24) + +## 状态 / 基线 + +**blocked,待D5有限例外裁决;本文件不是执行授权。** + +基线 `5ff43b47f38e378918c8b6bd967228185d7f02fe`,发现于尚未提交的 `codex/report-chapter-export-20260924`,组合候选 `codex/chart-report-integration-20260924`。原报告单完成本地实现后,独立审查发现本项;继承旧剥离器缺陷,不是新增回归。后续必须在两单组合版本之后串行执行,不覆盖在途工作。 + +## 事故实证 + +- `frontend/src/lib/report-chart-block.ts` 的 `FENCE_RE`(当前83行)在语言标记后仅允许LF;`stripReportChartBlocks`(当前202–204行)直接使用该正则。 +- 新 `report-export-blocks.ts` 的正文块与旧 `ordinaryReportDownloadMarkdown()` 都调用旧剥离器。 +- 只将真实引擎虚构fixture `frontend/tests/fixtures/report-density-fictional-reader.json` 的markdown从LF转为CRLF,构造正文块并join:正文25块,下载中残留22个 `jyotish-chart` JSON围栏;仍与旧导出逐字节相等。分盘另能正确提取22个出现项。 +- 相关105条测试通过未覆盖“含真实图盘围栏的CRLF输入无JSON”;已有CRLF字节等价测试不足以证明禁止JSON合同。 + +复现核心(在既有Node22/tsx环境运行,仅读虚构fixture,不写资料): + +```ts +const markdown = fixture.markdown.replace(/\n/g, "\r\n"); +const body = buildReportExportBlocks({ markdown }).filter(b => b.group === "body"); +const output = joinExportBlocks(body); +console.log({ + residualJsonFences: (output.match(/```jyotish-chart/g) ?? []).length, + equalsOldExport: output === ordinaryReportDownloadMarkdown(markdown), +}); +// 实测:{ residualJsonFences: 22, equalsOldExport: true } +``` + +## 根因与旧防线 + +语言标记后 `\n` 不匹配 `\r\n`;投影允许供阅读组件消费的图盘结构,普通导出还需剥除围栏。BUG-999/1003投影及安全SVG防线不应撤销,当前缺的是下载剥离器换行边界;不能把ordinaryOutputLeaks为空替代无JSON独立断言。 + +## 决策记录 + +- 原单D5明确不可让步:正文全选与**旧版本**普通导出逐字节相同。 +- 原单T1同时禁止JSON图盘围栏,CRLF输入下这两条无法同时成立。 +- 用户后续D13只允许并要求SVG,不授权保留JSON,也未授权改变D5。 +- **建议但尚未批准**:仅对CRLF图盘围栏的错误残留授权D5有限例外,新导出删除JSON;其它正文/安全SVG和LF输出不变。 +- 未获该裁决前只记录blocked,不改剥离器、不将两个共同修改后的helper比较当成旧行为等价。 + +## 硬红线 + +不标准化整篇换行,不改SVG几何/投影allowlist,不改生成/API/数据库,不新请求,不动依赖或workflow。保留单一大纲、既有下载器、全部测试名称与三栏断言记录。 + +## 任务分解(获准后) + +1. 冻结基线LF golden旧导出结果及hash;另记录CRLF旧输出与22围栏事实。 +2. 最小修改语言标记后的换行匹配为 `\r?\n`,检查边缘空白处理而非全篇replace。 +3. 新增真实golden转换CRLF/混合换行测试:所有正文无JSON围栏;原安全SVG、其它文本字节与顺序保持;ordinaryOutputLeaks为空;分盘出现数不变。 +4. LF golden必须与冻结旧版本结果逐字节相等。CRLF差异必须仅为获准移除的围栏,不得仅比较两个共用修改后helper的函数。 +5. 跑tsc/lint/全量名称多重集合与失败诊断对照、实际下载;更新D5有限例外、PROGRESS、BUG-1020、BLOCKED、testing。构建/真机缺口如实保留。 + +## 让步顺序 + +不让步安全投影或JSON禁止,不静默改D5;无授权则保持blocked。构建环境缺口可留清单,不能写成通过。 + +## 开工前置命令 + +先 `git status -sb`,再 `git fetch origin --prune`,核对两单实际交付基线;获准后创建独立worktree,不在脏树切分支/reset/stash。重读AGENTS/frontend约束及本次BUG记录,Node22下跑报告定向基线。没有真实账号不得借凭据验收。 + +## BUG编号 + +组合候选核对最大号1019,本项暂记BUG-1020。正式领取时再次核对远端;若被占用顺延,不覆写其它会话记录。 diff --git a/docs/testing/chart-page-skeleton-20260924.md b/docs/testing/chart-page-skeleton-20260924.md new file mode 100644 index 00000000..dcd18f98 --- /dev/null +++ b/docs/testing/chart-page-skeleton-20260924.md @@ -0,0 +1,21 @@ +# 星盘盘位等待态真机清单(2026-09-24) + +状态:受控登录及真机待执行。单星盘与最终组合工作树分别完成隔离真实Chrome153组件验收13/13(真实组件/CSS、mock接口、虚构golden、全新profile);这不替代本表真人和部署验收,证据见PROGRESS。仅使用产品提供的受控账户和明确虚构的出生资料;不借用他人登录态。只记录脱敏结果,不保存Cookie、姓名、出生数据或完整响应。 + +| 项目 | 操作 | 通过标准 | 结果 | +| --- | --- | --- | --- | +| 冷进与尺寸 | 375px、390px、桌面冷进 `/chart`,在受控网络中延迟主盘响应 | 五个 Tab 可见禁用;空北印盘轻缓呼吸,下面原等待句;盘到后可用且标题/侧栏不抖动,页面无横滚 | 待验 | +| 暖进 | 主盘成功后离开再进入 | 缓存立即显示,不重放骨架;后台更新不抹已完成的分盘/西洋结果 | 待验 | +| 非 D1 | 冷点 D9、D10 | 空北印盘与分盘等待句;成功移除骨架,不残留假度数/星体 | 待验 | +| 西洋 | 冷点西洋 | 复用原西洋轮廓空盘,不显示星体/星座/上升标签;返回后正常渲染 | 待验 | +| 429 与恢复 | 在受控测试环境连续点西洋/七政/D9/D10,或由测试代理对所请求层返回引擎忙响应 | busy 显示「算盘的服务正忙,稍等几秒再打开就好。」而非永久等待;再点原 Tab/chip 清失败、重新 pending 后可恢复。不要修改共享服务器并发来造故障 | 待验 | +| 两类限流 | 分别注入引擎 busy 与 BFF rate_limited | 前者 busy 句;后者「刚才打开得太勤,稍等再试。」;不混淆 | 待验 | +| 真超时 | 已下载页面脚本的标签页清星盘内存缓存,通过受控网络让 `/api/chart-view` 持续不响应 | 前台活动约25秒内显示「这张盘等了太久没有回来,再打开一次试试。」且骨架结束。浏览器休眠会暂停 AbortSignal 活跃时间,不据此承诺墙钟期限 | 待验 | +| 断网 | 已下载脚本后断网打开星盘 | 立即网络失败可显示既有 unavailable;只有持续挂起才显示 timedOut,不把断网立即拒绝误写为超时。恢复网络重新打开可重试 | 待验 | +| 未登录 | 受控退出登录后进页;另测查看时会话过期点分盘 | 先出现「请先登录后再看星盘。」再去 `/login`,不留空盘等到超时 | 待验 | +| 减弱动态效果 | 开系统减弱动态效果,再冷进或延迟 D9 | 空盘静止 opacity 0.5,等待句清楚可读;无旋转/扫光 | 待验 | +| 表格与其它页 | 点大运/七政;打开星历/报告列表 | 大运和七政只有静态等待句,失败给原因;星历/报告列表无新骨架或呼吸 | 待验 | +| 资料失效 | 请求延迟时保存新虚构出生资料,再进星盘,随后释放旧响应 | 只显示新资料对应盘,不用旧层结果覆盖新主体 | 待验 | +| 无障碍 | 键盘与读屏进入,数据挂起/成功/失败 | 骨架容器 busy,装饰盘不重复朗读;文字在容器下方;失败后无 busy,Tab 可操作 | 待验 | + +部署前还需主会话全量名称/失败比较、构建 `/` 和 `/chart` Static、首屏 gzip ±2% 与 staging health SHA;这些不是本清单未执行即可宣称通过的项目。 diff --git a/docs/testing/report-chapter-export-20260924.md b/docs/testing/report-chapter-export-20260924.md new file mode 100644 index 00000000..68c89e2d --- /dev/null +++ b/docs/testing/report-chapter-export-20260924.md @@ -0,0 +1,38 @@ +# 报告分块导出 / 列表管理真人清单(2026-09-24) + +## 状态与安全边界 + +受控登录/iPhone/微信真人验收尚未执行。仅使用受控账号与明确虚构资料,不借用登录态,不保存报告个人信息、Cookie或JWT。登记系统、浏览器版本、实际部署SHA与通过/未通过/环境缺口。 + +单报告工作树隔离Chrome153组件验收13/13,最终组合树另跑14/14(额外覆盖真实fallback SVG下载)通过:真实CSS与组件,白名单mock接口、虚构golden、全新profile,无pageerror;4种实际.md下载及SVG离线呈现通过。此证据不替代完整Next服务器、数据库持久化或真机。详情与本地证据见 `../tasks/PROGRESS-report-chapter-export-20260924.md`。 + +**已知未通过项**:CRLF输入会继承旧剥离器缺陷,正文保留图盘JSON;当前不满足“所有输入无JSON”验收。修复涉及D5有限例外,未获授权,见 `../tasks/TASK-report-chapter-export-fix-20260924.md`。不要据LF golden成功勾掉本项。 + +## 待真人逐项执行 + +- [ ] iPhone Safari ready报告点「分块导出」:底部抽屉、默认常用、头尾可见、中部滚动;计数来自实际文本(包括Markdown/SVG标记)。 +- [ ] 套餐替换选择;手动勾选取消套餐高亮;取消全选后按钮禁用。 +- [ ] 仅勾三章,下载一个文件名含 `-节选` 的 `.md`;文件App打开后内容/顺序正确,没有图盘JSON或工程字段。 +- [ ] 选「常用」即全部正文:文件名不含节选,与升级前同一报告旧导出逐字节相同,原安全SVG保留。**D5针对正文全选,不是所有组全选。** CRLF已知例外必须单独记录失败。 +- [ ] 所有组全选:正文后附分盘/核对表,比旧正文增加内容;每张分盘有SVG,重复D9两个出现项不丢,核对表子表/列/显示精度一致。 +- [ ] 仅分盘/仅核对表独立下载正常,文件名含节选。 +- [ ] 无原引擎SVG时fallback可离线打开,宫位、符号、图例可读,不依赖应用CSS或新请求。 +- [ ] 对照盘图式与标题:当前复用旧引擎SVG可能是南印矩形图式,标题沿用“北印度盘”;登记差异,不以新造几何掩盖。 +- [ ] 桌面展开/折叠侧栏、平板:抽屉不盖侧栏、最大760px/70%,860px起两列,窄屏一列,无横向溢出,控件至少44px。 +- [ ] Tab/Shift+Tab不会激活背景控件;Esc/遮罩/关闭可退出,点内容不退出,焦点回入口且不滚动正文。Chrome浏览器工具栏焦点中转不误判为背景控件获焦。 +- [ ] 列表ready行「分块导出」直达抽屉;ready后只开一次、清#export且保留query;关闭后不重开;其它状态无导出入口。 +- [ ] 列表日期、深度、主题中文;未知id原样回退;状态同时有文字和三色,ready纸色,其它canvas。 +- [ ] 每种状态菜单只有删除,取消不请求;重复确认只发一次DELETE;HTTP失败/断网留行并报短错误,重试成功移除。 +- [ ] 删除期间旧GET在途,返回后不复活;刷新/离开重进不出现。**持久化须受控账号实测,mock不能代替。** +- [ ] 成功触发下载后关闭并提示一次「已导出N块」;下载器可检测失败时保留抽屉/选择、不发成功提示。浏览器不能保证用户最终保存文件,不能把toast当磁盘持久化证明。 +- [ ] 微信真机沿用系统浏览器提示,不下载、不关闭、不误报成功;切系统浏览器后可下载。 +- [ ] 返回、打印、核对表打印前展开/打印后恢复、原始附录下载仍可用;普通导出无额外GET、服务端PDF或clipboard。 +- [ ] CRLF真实golden无JSON回归在获准修复后重验,同时保留冻结旧LF输出逐字节比较,不能仅比较两个共享修改后helper的函数。 +- [ ] 完整构建后核对 `/`、`/chart` Static,`/reports`、`/reports/[reportId]` 与基线动态标记一致;首页gzip±2%。 +- [ ] staging发布后health deployment.gitCommit等于本轮含代码提交;未发布前不填通过。 + +## 记录模板 + +| 环境 / 版本 / 部署SHA | 条目 | 通过 / 未通过 / 环境缺口 | 脱敏证据与说明 | +| --- | --- | --- | --- | +| 待填 | 待填 | 待执行 | 不放真实身份、报告内容或凭据 | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 5d222eec..9cfc151e 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -427,8 +427,8 @@ one skeleton, none of them carrying the sidebar. page name, a reserved note slot so a late birth line cannot collapse the row, up to two actions) and a `.secondary-page` body that always fills the remaining viewport. Waiting copy is centered in that cell; swapping in the - tall content does not change the shell height. No spinner, skeleton, or - 「正在加载」. First-screen data for the three pages lives in a module-level + tall content does not change the shell height. No spinner or 「正在加载」; + skeletons are limited to the chart-page plot positions (§15, 2026-09-24). First-screen data for the three pages lives in a module-level cache; a second visit paints the last result immediately and refreshes in the background. Sidebar `pointerenter` / `pointerdown` prefetches that data (`` only prefetches JS). A tab that outlived a deploy compares @@ -491,17 +491,22 @@ one skeleton, none of them carrying the sidebar. It used to be a standalone full-screen route with a `report-center-shell` root, a `report-center-topbar` holding one 「返回对话」 link, and a `report-center-hero` with a page-sized h1 — the sidebar vanished the moment you opened it, and the only way back was that link. - **Row list, not a card grid:** `.report-center-list` is one column of `.report-center-row`, hairline-separated by a 1px grid gap over a `--color-border` ground so each boundary is a single rule rather than two touching borders. A card grid costs one scan per card; past five or six reports the reader is looking for state, and a single column puts every status chip on the same x. D12. - **Row anatomy:** status chip, then the title at `--type-title-md`/500, then the meta line (creation time · depth · themes) at `--type-caption` with `tabular-nums`, then — for a ready report — its stored card summary, or for a failed one the failure reason. Actions sit right-aligned on the same row and wrap under the body at the global 767px cut. -- **Status chip:** a caption pill carrying a 5px `currentColor` dot before the label. The dot is the change: the three states used to differ only by fill, which is a weak signal at caption size. Ready reads success, generating reads action-soft, failed reads danger. There is no spinner in the chip — the list polls, it does not perform. +- **Status chip:** a caption pill with a 5px `currentColor` dot and a text label. Ready uses `--color-success`, generating `--color-warning`, failed `--color-danger`; each background mixes the same token at 12%. No spinner. Ready rows use `--report-paper`; all other rows use `--color-canvas`. - **Meta honesty:** the row says only what `GET /api/reports` returns. It carries no section or chart count, so the row does not claim one; inventing 「9 节 · 22 张盘」 on the client would be a made-up number (VOICE.md 第 2 条). If the endpoint ever projects per-section progress, it belongs on the generating row's note line. -- **Surface:** `--color-canvas-soft` floor; rows use `--color-canvas`, a warm hairline, and `--radius-lg` on the list as a whole. No drop shadow. +- **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 “导出报告(.md)” quiet beside it. The export downloads the same longform Markdown the detail page renders. 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 work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never starts a second writing flow. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate. +- **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. +- **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 -- **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 “导出报告(.md)”) 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 “旧版本报告,请重新生成”. +- **分块导出抽屉 (2026-09-24):** native modal ``, 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. +- **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 “旧版本报告,请重新生成”. - **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. @@ -675,12 +680,13 @@ or user IDs. ## 9. 等待与加载 -等待按语义只分三类。同类必须共用同一套实现;不要为新入口再写一套旋转或呼吸。 +等待按语义分为以下类别。同类必须共用同一套实现;不要为新入口再写一套旋转或呼吸。2026-09-24 决策仅为星盘页盘位增加骨架例外,首页、侧栏、星历、报告与表格区不适用。 | 类别 | 语义 | 组件 / 样式 | 时长 | `prefers-reduced-motion: reduce` | | --- | --- | --- | ---: | --- | | 整页 / 整块阻塞 | 首页揭幕前的两阶段载入(载入账户 → 准备对话)、生时评估浮层 | `AppLoadingIndicator`(轨道环 `app-loading-orbit`) | 1.4s linear | 全局 `*` 规则把循环收成一帧;不要改它的 DOM | | 行内 / 局部等待 | 出生地解析、两个会话面时间线的 live 步、兜底活动面板的 live 行、个人报告列表与详情 | `InlineSpinner`(`inline-spin`) | 0.8s linear | `animation: none`,收成静止圆点,不要半圈圆弧 | +| 盘位骨架(仅星盘页) | D1 / 非 D1 分盘 / 西洋盘尚未返回 | `VedicChartSvg skeleton` / `WesternWheelSvg skeleton`;统一 `.chart-page-skeleton` | 2.4s ease-in-out,opacity 0.35 ↔ 0.6 | 停止动画,opacity 0.5 | | 流式生成中 | 引导语打字、时间线 summary 与 live 行的文案 | `onboarding-caret` / `agent-activity-shimmer` | 700ms steps / 1.6s linear | 保持现有全局降级 | Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。 @@ -914,13 +920,16 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 ### 等待态 -星盘页是独立文档。`SecondaryPageShell` 揭幕(标题、保留的 note 槽、撑满剩余视口的内容区)可以在主盘到达之前发生;揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」,沿用 §9 星历页那一类静态句,不新造第五套加载动画。同一会话再次进入直接用模块缓存,不重放等待句。出生资料保存成功后,缓存按账户和资料指纹换键,旧盘不得留在页上冒充新的日期或时间。 +2026-09-24 产品决策有限推翻 09-18 方案一(BUG-716 / 966):`SecondaryPageShell` 标题、note 槽与撑满内容区不变,主盘未到时正常渲染 body 和禁用的 Tab 行;仅 D1、非 D1 分盘与西洋盘位置显示既有 SVG 空模型的骨架与原等待句。骨架无星体、度数、星座标签,几何不得复制;容器 `aria-busy=true`,装饰盘对读屏隐藏。中性 token、opacity 0.35 ↔ 0.6 / 2.4s ease-in-out,减弱动态效果时静止 0.5;不旋转、不扫光。大运、七政表格仍为静态句,首页、侧栏、星历、报告不适用此例外。首次请求(含预取)和按需层均有 25 秒客户端期限;失败移除骨架,再点该 Tab/chip 清失败并重试。401 先显式呈现登录句再跳转。同一会话再次进入直接用模块缓存,不重放等待句。出生资料保存成功后,缓存按账户和资料指纹换键,旧盘不得留在页上冒充新的日期或时间。 | 时刻 | 文案 | | --- | --- | -| 外壳已出、主盘未到 | 这一张盘还没拿到。 | -| 已点大运 / 西洋 / 七政,该路还没返回 | 这一栏还没拿到。 | -| 已点非 D1 分盘,分盘包还没返回 | 这一分盘还没拿到。 | +| 外壳已出、主盘未到 | 北印度空盘骨架 + 这一张盘还没拿到。 | +| 已点西洋,尚未返回 | 西洋空盘骨架 + 这一栏还没拿到。 | +| 已点大运 / 七政,尚未返回 | 静态句:这一栏还没拿到。 | +| 已点非 D1 分盘,分盘包还没返回 | 北印度空盘骨架 + 这一分盘还没拿到。 | +| 客户端超时 | 这张盘等了太久没有回来,再打开一次试试。 | +| 分盘 / 按需层失败 | 按原因显示既有失败句;不留等待句或骨架 | | 引擎 429 | 算盘的服务正忙,稍等几秒再打开就好。 | | 其它主盘计算失败 | 这张盘算不出来,我们已经记录下来了。 | | 资料读不出来 | 星盘资料这会儿读不出来,我们已经记录下来了。 | diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 8e80f9b4..94e052d3 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -18,6 +18,14 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 首页开场语只有一行,不带追问句,不用星星、月亮、陪伴一类比喻。 +## 报告分块导出与删除 + +- 入口与标题:「分块导出」。说明:「勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。」这里全选包含完整正文及另选的盘 / 表;与旧整篇逐字节相同的套餐是「常用」(全部正文)。 +- 套餐:「常用 / 全部分盘 / 仅核对表」;动作:「全选 / 取消全选」「导出选中 (N)」。成功才提示「已导出 N 块」,下载失败或微信限制不提示成功。 +- 「共 N 块 · X 字 · 已选 n/N · Y 字」来自实际可导出块及 `Array.from(text).length`;包含安全 SVG / Markdown 标记,不是估计文章字数。此为第 2 条的可直接计算文本统计,不是口算领域数字;列表页仍不杜撰章数或盘数。 +- 删除确认:「确定删除这份报告?」;按钮:「删除 / 取消」;请求中:「删除中…」;失败:「报告没能删除,请稍后重试。」失败保留原行。 +- 日期中文全写;深度「简版 / 标准深度 / 深度 / 研究级」;主题复用现有中文映射,未知 id 原样回退,不造含义。 + ## 报告里的限制说明 普通报告只留下人能读的正文、结论、行动建议和必要限制。不写字段名、状态码、评分、权重或执行账本。说不到日期就写「这次说不到具体哪一天。」依据没补上就写「有些依据还没补上,相关说法不能当成确定预测。」判断没闭合就写「有些判断还没闭合,不能写成确定结论。」不出现 `technique_truth`、`workflow_route` 这类键。 @@ -106,6 +114,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 2019 入职 | 2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 不得把选项字面写进题干。 | | 2023 感情区分 | 你有没有一段认真开始或结束的关系? | 工作这块记下了。2023 年前后,有没有一段认真开始或结束的关系? | 题干必须写出服务端给的年份;漏写年份用户看不见期间。 | +星盘客户端等待超时只写:「这张盘等了太久没有回来,再打开一次试试。」不承诺速度,不用「正在加载」。429 忙与频繁打开的限流仍各用原句,不混写。 + ## 聊天顶栏的当前星盘 顶栏人名是入口,不是资料编辑。看见的字是当前这个人的名字。读屏名称是「当前星盘:」加上这个名字。不要写「我的星盘」或「分析对象」。 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 812cb1b5..eb066cc6 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3710,14 +3710,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class background: var(--color-border); } .report-center-row { + position: relative; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-5); padding: var(--space-4) var(--space-5); + padding-right: calc(var(--space-5) + 44px); background: var(--color-canvas); } +.report-center-row.is-ready { background: var(--report-paper); } .report-center-row-main { min-width: 0; display: grid; gap: var(--space-1); } .report-center-status { width: fit-content; @@ -3740,9 +3743,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class border-radius: 50%; background: currentColor; } -.report-center-status.is-ready { background: var(--color-success-muted); color: var(--color-success); } -.report-center-status.is-generating { background: var(--color-action-soft); color: var(--color-action); } -.report-center-status.is-failed, +.report-center-status.is-ready { background: color-mix(in srgb, var(--color-success) 12%, transparent); color: var(--color-success); } +.report-center-status.is-generating { background: color-mix(in srgb, var(--color-warning) 12%, transparent); color: var(--color-warning); } +.report-center-status.is-failed { background: color-mix(in srgb, var(--color-danger) 12%, transparent); color: var(--color-danger); } .report-center-failure { background: var(--color-danger-muted); color: var(--color-danger); } /* The failure reason reads inside the row now, under the meta line, instead of being a right-hand block competing with the row's action. */ @@ -3784,7 +3787,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class justify-content: flex-end; gap: var(--space-2); } -.report-center-export-error { +.report-center-delete-error { flex-basis: 100%; color: var(--color-danger); font-size: var(--type-body-sm); @@ -3849,10 +3852,68 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .report-center-row { grid-template-columns: 1fr; gap: var(--space-3); } .report-center-row-actions { justify-content: flex-start; justify-self: start; } .report-center-failure { justify-self: start; } - .report-center-export-error { text-align: left; } + .report-center-delete-error { text-align: left; } .report-center-section-heading { align-items: center; } } +.report-center-delete { grid-column: 1 / -1; } +.report-center-menu { position: absolute; right: var(--space-2); top: var(--space-2); } +.report-center-menu summary { display: grid; place-items: center; width: 44px; min-height: 44px; cursor: pointer; list-style: none; } +.report-center-menu summary::-webkit-details-marker { display: none; } +.report-center-menu > div { background: var(--color-canvas); border: 1px solid var(--color-border); border-radius: var(--radius-md); } +.report-center-menu summary:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } +.report-center-delete-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); } +.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. */ +.report-export-drawer { + --report-export-sidebar: 0px; + position: fixed; + inset: auto 0 0 var(--report-export-sidebar); + width: min(760px, calc((100% - var(--report-export-sidebar)) * .92)); + max-width: none; + max-height: 70vh; + margin: 0 auto; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: var(--color-canvas); + color: var(--color-ink); + box-shadow: var(--shadow-elevated); + overflow: hidden; +} +[data-viewport="desktop"][data-state="expanded"] .report-export-drawer { --report-export-sidebar: var(--sidebar-width-desktop); } +[data-viewport="tablet"][data-state="expanded"] .report-export-drawer { --report-export-sidebar: var(--sidebar-width-tablet); } +[data-viewport="desktop"][data-state="collapsed"] .report-export-drawer, +[data-viewport="tablet"][data-state="collapsed"] .report-export-drawer { --report-export-sidebar: var(--sidebar-width-icon); } +.report-export-drawer::backdrop { background: var(--color-scrim); } +.report-export-panel { display: flex; flex-direction: column; max-height: 70vh; } +@supports (height: 1dvh) { + .report-export-drawer, .report-export-panel { max-height: 70dvh; } +} +.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); } +.report-export-header p { margin: var(--space-2) 0; font-size: var(--type-body-sm); color: var(--color-ink-secondary); } +.report-export-count { font-variant-numeric: tabular-nums; } +.report-export-presets { display: flex; flex-wrap: wrap; gap: var(--space-2); } +.report-export-presets [aria-pressed="true"] { background: var(--color-action-soft); color: var(--color-action); } +.report-export-list { overflow-y: auto; overscroll-behavior: contain; min-height: 0; padding: 0 var(--space-4) var(--space-4); } +.report-export-list h3 { font-size: var(--type-title-sm); margin: var(--space-3) 0 var(--space-2); } +.report-export-grid { display: grid; gap: var(--space-2); } +.report-export-block { display: flex; align-items: center; text-align: left; gap: var(--space-3); min-height: 44px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: transparent; color: var(--color-ink); font-size: var(--type-body-sm); overflow-wrap: anywhere; } +.report-export-block[aria-checked="true"] { background: var(--color-selected); border-color: var(--color-action); } +.report-export-block small { display: block; color: var(--color-ink-tertiary); font-size: var(--type-caption); } +.report-export-check { flex-shrink: 0; width: 18px; height: 18px; border: 1px solid var(--color-border-strong); border-radius: 4px; text-align: center; line-height: 16px; } +.report-export-block:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } +.report-export-drawer button { min-height: 44px; min-width: 44px; } +.report-export-footer { border-top: 1px solid var(--color-border); padding-bottom: max(var(--space-4), env(safe-area-inset-bottom)); } +.report-export-footer > button { width: 100%; } +.report-export-footer p { margin: 0 0 var(--space-2); color: var(--color-danger); font-size: var(--type-body-sm); } +@media (min-width: 860px) { .report-export-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media print { .report-export-drawer { display: none !important; } } + /* ============================================================ personal-report: unique block — editorial reader + A4 print Only /reports/[reportId] uses these semantic classes. The report owns its @@ -4827,6 +4888,21 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class gap: var(--space-1); margin-bottom: var(--space-4); } +.chart-page-waiting-plot { + width: min(100%, 400px); + min-width: 0; +} +.chart-page-skeleton { + animation: chart-skeleton-breathe 2.4s ease-in-out infinite; +} +.chart-page-skeleton svg { display: block; margin-top: 0; } +@keyframes chart-skeleton-breathe { + 0%, 100% { opacity: 0.35; } + 50% { opacity: 0.6; } +} +@media (prefers-reduced-motion: reduce) { + .chart-page-skeleton { animation: none; opacity: 0.5; } +} .chart-page-vedic-stage { position: relative; background: var(--color-canvas); diff --git a/frontend/src/components/chart-page/chart-dasha-tab.tsx b/frontend/src/components/chart-page/chart-dasha-tab.tsx index 07b958e6..78ce637f 100644 --- a/frontend/src/components/chart-page/chart-dasha-tab.tsx +++ b/frontend/src/components/chart-page/chart-dasha-tab.tsx @@ -1,4 +1,5 @@ -import type { ChartViewOk } from "@/lib/chart-view-contract"; +import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; +import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract"; import { CHART_VIEW_COPY } from "@/lib/chart-view-labels"; function Track({ @@ -49,17 +50,19 @@ function Track({ export function ChartDashaTab({ view, charaPending = false, + charaFailure, }: { view: ChartViewOk; charaPending?: boolean; + charaFailure?: ChartViewFailureStatus; }) { return (
- {charaPending ? ( + {charaPending || charaFailure ? (
-

{CHART_VIEW_COPY.waitingLayer}

+

{charaPending ? CHART_VIEW_COPY.waitingLayer : chartLayerFailureCopy(charaFailure!)}

) : ( diff --git a/frontend/src/components/chart-page/chart-page-skeleton.tsx b/frontend/src/components/chart-page/chart-page-skeleton.tsx new file mode 100644 index 00000000..ade8efed --- /dev/null +++ b/frontend/src/components/chart-page/chart-page-skeleton.tsx @@ -0,0 +1,15 @@ +import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "@/components/personal-report/vedic-chart-svg"; +import { EMPTY_WESTERN_WHEEL, WesternWheelSvg } from "./western-wheel-svg"; + +/** Only chart positions opt in; the shared page shell remains static. */ +export function ChartPageSkeleton({ copy, western = false }: { copy: string; western?: boolean }) { + return ( +
+ +

{copy}

+
+ ); +} diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index e3ecdc80..7d1d8fc8 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -6,11 +6,14 @@ import { SecondaryPageShell } from "@/components/secondary-page-shell"; import { useChartPage } from "@/hooks/use-chart-page"; import { CHART_VIEW_TABS, + type ChartViewFailureStatus, type ChartViewResponse, type ChartViewTabId, } from "@/lib/chart-view-contract"; import type { ChartViewLayer } from "@/lib/chart-view-engine"; import { CHART_VIEW_COPY } from "@/lib/chart-view-labels"; +import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; +import { ChartPageSkeleton } from "./chart-page-skeleton"; import { ChartBasicsTab } from "./chart-basics-tab"; import { ChartDashaTab } from "./chart-dasha-tab"; import { ChartQizhengTab } from "./chart-qizheng-tab"; @@ -18,11 +21,12 @@ import { ChartVedicTab } from "./chart-vedic-tab"; import { ChartWesternTab } from "./chart-western-tab"; export function ChartPageRoute() { - const { view, pendingLayers, requestLayer } = useChartPage(); + const { view, pendingLayers, layerFailures, requestLayer } = useChartPage(); return ( ); @@ -35,11 +39,13 @@ function WaitingNote({ copy }: { copy: string }) { export function ChartPageView({ view, pendingLayers, + layerFailures, onNeedLayer, initialTab = "vedic", }: { view: ChartViewResponse | null; pendingLayers?: ReadonlySet; + layerFailures?: ReadonlyMap; onNeedLayer?: (layer: ChartViewLayer) => void; initialTab?: ChartViewTabId; }) { @@ -66,9 +72,8 @@ export function ChartPageView({ - {view == null ? null : view.status !== "ok" ? ( + {view != null && view.status !== "ok" ? (

{view.message}

@@ -80,6 +85,7 @@ export function ChartPageView({ key={item.id} type="button" role="tab" + disabled={view == null} aria-selected={tab === item.id} className={tab === item.id ? "chart-page-tab is-current" : "chart-page-tab"} onClick={() => selectTab(item.id)} @@ -89,20 +95,25 @@ export function ChartPageView({ ))}
+ {view == null ? : <> {tab === "vedic" ? ( ) : null} {tab === "basics" ? : null} {tab === "dasha" ? ( - + ) : null} {tab === "western" ? ( pendingLayers?.has("western") ? ( - + + ) : layerFailures?.has("western") ? ( + ) : ( ) @@ -110,10 +121,13 @@ export function ChartPageView({ {tab === "qizheng" ? ( pendingLayers?.has("qizheng") ? ( + ) : layerFailures?.has("qizheng") ? ( + ) : ( ) ) : null} + }
)} diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index edcc34d1..6f8ceff8 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -2,6 +2,7 @@ import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg"; import type { + ChartViewFailureStatus, ChartViewAscendantRow, ChartViewOk, ChartViewPlanetRow, @@ -17,6 +18,8 @@ import { } from "@/lib/planet-glyphs"; import { formatDegreeMinutes } from "./chart-format"; import { ChartParamTable } from "./chart-param-table"; +import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; +import { ChartPageSkeleton } from "./chart-page-skeleton"; /** The same coloured symbol the chart draws, so a row and a house read as one body. */ function BodyGlyph({ name }: { name: string }) { @@ -87,9 +90,13 @@ export function ChartVedicTab({ view, vargaId, onSelectVarga, + pending = false, + failure, }: { view: ChartViewOk; vargaId: string; + pending?: boolean; + failure?: ChartViewFailureStatus; onSelectVarga: (id: string) => void; }) { const selected = view.vedic.vargas.find((item) => item.id === vargaId); @@ -117,7 +124,8 @@ export function ChartVedicTab({ })} {waiting || !varga ? ( -

{CHART_VIEW_COPY.waitingVarga}

+ !pending && failure ?

{chartLayerFailureCopy(failure)}

+ : ) : (
diff --git a/frontend/src/components/chart-page/western-wheel-svg.tsx b/frontend/src/components/chart-page/western-wheel-svg.tsx index f1c0c0c0..51ba30c2 100644 --- a/frontend/src/components/chart-page/western-wheel-svg.tsx +++ b/frontend/src/components/chart-page/western-wheel-svg.tsx @@ -18,7 +18,11 @@ function screenAngle(longitude: number, ascendant: number): number { return 180 - (longitude - ascendant); } -export function WesternWheelSvg({ western }: { western: ChartViewWesternOk }) { +type WesternWheelModel = Pick; +export const EMPTY_WESTERN_WHEEL: WesternWheelModel = { ascendantLongitude: 0, houses: [], planets: [] }; + +export function WesternWheelSvg({ western: suppliedWestern, skeleton = false }: { western: WesternWheelModel; skeleton?: boolean }) { + const western = skeleton ? EMPTY_WESTERN_WHEEL : suppliedWestern; const asc = western.ascendantLongitude; const signWedges = SIGN_ORDER.map((sign, index) => { const mid = screenAngle(index * 30 + 15, asc); @@ -30,7 +34,9 @@ export function WesternWheelSvg({ western }: { western: ChartViewWesternOk }) { width="100%" height="auto" role="img" - aria-label="回归黄道本命盘,上升在左,宫位按 Placidus" + aria-label={skeleton ? undefined : "回归黄道本命盘,上升在左,宫位按 Placidus"} + aria-hidden={skeleton || undefined} + data-skeleton={skeleton ? "true" : undefined} className="chart-page-western-svg" > @@ -43,9 +49,9 @@ export function WesternWheelSvg({ western }: { western: ChartViewWesternOk }) { return ( - + {!skeleton && {signZh(wedge.sign)} - + } ); })} @@ -71,9 +77,9 @@ export function WesternWheelSvg({ western }: { western: ChartViewWesternOk }) { ); })} - + {!skeleton && 升 - + } ); } diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index b5178e76..ad448d5e 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -13,8 +13,10 @@ import { PERSONAL_REPORT_EXPORT_LABEL, PERSONAL_REPORT_GENERATING_COPY, } from "@/lib/personal-report-longform-copy"; -import { downloadPersonalReportLongformAppendix } from "@/lib/personal-report-longform-download"; +import { REPORT_DEPTH_LABELS, reportThemeLabel } from "@/lib/personal-report-progress"; +import { ReportRowDelete } from "./report-row-delete"; import { + invalidateReportsPage, peekReportsPage, refreshReportsPage, REPORTS_WAITING_COPY, @@ -49,10 +51,8 @@ function formatDate(value: string): string { if (Number.isNaN(date.getTime())) return "时间未知"; return new Intl.DateTimeFormat("zh-CN", { year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", + month: "long", + day: "numeric", }).format(date); } @@ -62,14 +62,13 @@ function formatDate(value: string): string { * creation time, the depth and the requested themes and nothing more. Inventing * 「9 节 · 22 张盘」 on the client would be a made-up number (VOICE.md 第 2 条). */ -function rowMeta(report: ReportListItem): string { - return `${formatDate(report.createdAt)} · ${report.depth} · ${report.themes.join(" / ") || "综合主题"}`; +export function rowMeta(report: Pick & { depth: string }): string { + return `${formatDate(report.createdAt)} · ${REPORT_DEPTH_LABELS[report.depth] ?? report.depth} · ${report.themes.map(reportThemeLabel).join(" / ") || "综合主题"}`; } export function PersonalReportCenter() { const [state, setState] = useState(() => snapshotToState(peekReportsPage())); - const [exportingReportId, setExportingReportId] = useState(null); - const [exportError, setExportError] = useState<{ reportId: string; message: string } | null>(null); + const deletedIds = useRef(new Set()); const cancelled = useRef(false); const applySnapshot = useCallback((snapshot: ReportsPageSnapshot) => { @@ -78,10 +77,10 @@ export function PersonalReportCenter() { return; } if (snapshot.kind === "error") { - setState({ phase: "error", reports: snapshot.reports }); + setState({ phase: "error", reports: snapshot.reports.filter(report => !deletedIds.current.has(report.id)) }); return; } - setState({ phase: "ready", reports: snapshot.reports }); + setState({ phase: "ready", reports: snapshot.reports.filter(report => !deletedIds.current.has(report.id)) }); }, []); const load = useCallback(async (showLoading = false) => { @@ -124,21 +123,10 @@ export function PersonalReportCenter() { [state.reports], ); - const downloadOrdinaryReport = useCallback(async (report: ReportListItem) => { - setExportingReportId(report.id); - setExportError(null); - try { - await downloadPersonalReportLongformAppendix(report.id, { - reportDate: report.completedAt ?? report.createdAt, - }); - } catch (error) { - setExportError({ - reportId: report.id, - message: error instanceof Error ? error.message : "报告导出暂不可用", - }); - } finally { - setExportingReportId(null); - } + const removeReport = useCallback((id: string) => { + deletedIds.current.add(id); + invalidateReportsPage(); + setState(current => ({ ...current, reports: current.reports.filter(report => report.id !== id) })); }, []); if (state.phase === "unauthorized") { @@ -218,23 +206,12 @@ export function PersonalReportCenter() {
{report.status === "ready" ? (
- - {exportError?.reportId === report.id ? ( - - {exportError.message} - - ) : null} +
) : report.status === "generating" ? (
@@ -243,6 +220,7 @@ export function PersonalReportCenter() {
) : null} + ); })} diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index 5fdd5820..f617daea 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -479,6 +479,8 @@ export function PersonalReportPage({ reportId }: { reportId: string }) { reportId={state.reportId || reportId} reportTitle="个人报告" markdown={state.markdown} + charts={state.calculationCharts} + factTables={state.factTables} reportDate={state.createdAt} />
diff --git a/frontend/src/components/personal-report/report-actions.tsx b/frontend/src/components/personal-report/report-actions.tsx index e7462846..c2451292 100644 --- a/frontend/src/components/personal-report/report-actions.tsx +++ b/frontend/src/components/personal-report/report-actions.tsx @@ -2,7 +2,7 @@ "use client"; -import { useState, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import Link from "next/link"; @@ -14,7 +14,10 @@ import { printPersonalReport, safeReportFilename, } from "@/lib/client-report-export"; -import { downloadPersonalReportLongformAppendix } from "@/lib/personal-report-longform-download"; +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, @@ -25,7 +28,9 @@ import { RawAppendixDownload } from "./raw-appendix-download"; interface ReportActionsProps { reportId: string; reportTitle?: string; - markdown?: string; + markdown: string; + charts?: ReportDocumentV2["charts"]; + factTables?: readonly ReportFactTable[]; reportDate?: string | null; } @@ -38,15 +43,24 @@ export function ReportActions({ reportId, reportTitle, markdown, + charts, + factTables, reportDate, }: ReportActionsProps) { const [printBusy, setPrintBusy] = useState(false); - const [exportBusy, setExportBusy] = 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(null); const printSupported = useSyncExternalStore(subscribePrintCapability, isPrintSupported, () => false); async function handlePrint() { - if (printBusy || exportBusy || !printSupported) return; + if (printBusy || exportOpen || !printSupported) return; const restriction = detectPrintRestriction(); if (restriction.restricted) { setNotice(restriction.message ?? "当前浏览器无法可靠打印,请在系统浏览器中打开本页。"); @@ -61,22 +75,6 @@ export function ReportActions({ } } - async function handleExport() { - if (printBusy || exportBusy) return; - setExportBusy(true); - setNotice(null); - try { - await downloadPersonalReportLongformAppendix(reportId, { - markdown, - reportDate, - }); - } catch (error) { - setNotice(error instanceof Error ? error.message : "报告暂不可导出"); - } finally { - setExportBusy(false); - } - } - return ( diff --git a/frontend/src/components/personal-report/report-export-drawer.tsx b/frontend/src/components/personal-report/report-export-drawer.tsx new file mode 100644 index 00000000..c92ba731 --- /dev/null +++ b/frontend/src/components/personal-report/report-export-drawer.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { detectPrintRestriction } from "@/lib/client-report-export"; +import { downloadMarkdownReport } from "@/lib/consultation-report-export"; +import { exportFilename, joinExportBlocks, presetSelection, type ReportExportBlock, type ReportExportPreset } from "@/lib/report-export-blocks"; +import { Button } from "@/components/ui/button"; + +const PRESETS = { common: "常用", charts: "全部分盘", tables: "仅核对表" } as const; +const GROUPS = { body: "正文", chart: "分盘", table: "核对表" } as const; + +export function ReportExportDrawer({ blocks, reportDate, onClose }: { + blocks: readonly ReportExportBlock[]; + reportDate?: string | null; + onClose: () => void; +}) { + const dialog = useRef(null); + const [selection, setSelection] = useState(() => presetSelection("common", blocks)); + const [preset, setPreset] = useState("common"); + const [error, setError] = useState(null); + const exporting = useRef(false); + const selected = blocks.filter(block => selection.has(block.id)); + const allSelected = selected.length === blocks.length; + const chars = (items: readonly ReportExportBlock[]) => items.reduce((sum, block) => sum + block.chars, 0); + + useEffect(() => { + const element = dialog.current; + const previous = document.activeElement; + element?.showModal(); + return () => { + element?.close(); + if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true }); + }; + }, []); + + function choose(next: ReportExportPreset) { + setSelection(presetSelection(next, blocks)); + setPreset(next); + } + + function download() { + if (!selected.length || exporting.current) return; + const restriction = detectPrintRestriction(); + if (restriction.restricted) { + setError(restriction.message ?? "请在系统浏览器中打开本页。"); + return; + } + exporting.current = true; + setError(null); + try { + const body = blocks.filter(block => block.group === "body"); + const full = body.length > 0 && body.every(block => selection.has(block.id)); + downloadMarkdownReport(exportFilename(reportDate, full), joinExportBlocks(selected)); + dialog.current?.close(); + onClose(); + toast.success(`已导出 ${selected.length} 块`); + } catch (cause) { + exporting.current = false; + setError(cause instanceof Error ? cause.message : "报告暂不可导出"); + } + } + + return { event.preventDefault(); onClose(); }} + onClick={event => { if (event.target === event.currentTarget) onClose(); }}> +
+
+

分块导出

+

勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。

+

共 {blocks.length} 块 · {chars(blocks)} 字 · 已选 {selected.length}/{blocks.length} · {chars(selected)} 字

+
+ {(Object.keys(PRESETS) as ReportExportPreset[]).map(key => )} + +
+
+
+ {(Object.keys(GROUPS) as ReportExportBlock["group"][]).map(group => { + const items = blocks.filter(block => block.group === group); + return items.length ?

{GROUPS[group]}

+ {items.map(block => )} +
: null; + })} +
+
{error &&

{error}

}
+
+
; +} diff --git a/frontend/src/components/personal-report/report-fact-tables.tsx b/frontend/src/components/personal-report/report-fact-tables.tsx index ac41ea76..5685a61e 100644 --- a/frontend/src/components/personal-report/report-fact-tables.tsx +++ b/frontend/src/components/personal-report/report-fact-tables.tsx @@ -4,14 +4,7 @@ import { memo, useEffect, useRef } from "react"; import type { ReportFactTable } from "@/lib/report-fact-table-schema"; import { bindFactTablePrint } from "@/lib/report-fact-table-print"; -const STATUS_LABELS = { - multi_system_consensus: "多系统共识", - single_system_inference: "单一系统推断", - parameter_sensitive: "参数敏感", - unclosed_divisional_chart: "分盘尚未核验完整", - user_history_verification_required: "需要过往经历核对", - blocked: "暂不可用", -} as const; +import { factCellText, FACT_STATUS_LABELS } from "@/lib/report-fact-table-display"; function FactTable({ table }: { table: Pick }) { if (!table.rows.length) return null; @@ -22,7 +15,7 @@ function FactTable({ table }: { table: Pick{table.columns.map((column) => {column})} {table.rows.map((row) => ( {row.cells.map((cell, index) => ( - {cell === null ? "—" : typeof cell === "boolean" ? (cell ? "是" : "否") : typeof cell === "number" ? String(Number(cell.toFixed(2))) : cell} + {factCellText(cell)} ))} ))} @@ -42,7 +35,7 @@ export const ReportFactTables = memo(function ReportFactTables({ tables }: { tab

计算数据核对表

{tables.map((table) => (
- {table.title} · {STATUS_LABELS[table.claimStatus]} + {table.title} · {FACT_STATUS_LABELS[table.claimStatus]}

{table.note}

{table.subtables?.map((subtable) => ( diff --git a/frontend/src/components/personal-report/report-row-delete.tsx b/frontend/src/components/personal-report/report-row-delete.tsx new file mode 100644 index 00000000..26d3f243 --- /dev/null +++ b/frontend/src/components/personal-report/report-row-delete.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useId, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; + +/** Row-local confirmation; all states keep this entry, no global confirm dialog. */ +export function ReportRowDelete({ reportId, onDeleted }: { reportId: string; onDeleted: (id: string) => void }) { + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const pending = useRef(false); + const menu = useRef(null); + const trigger = useRef(null); + const id = useId(); + + async function remove() { + if (pending.current) return; + pending.current = true; + setBusy(true); + setError(null); + try { + const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}`, { method: "DELETE", credentials: "same-origin" }); + if (!response.ok) throw new Error("delete_failed"); + onDeleted(reportId); + } catch { + setError("报告没能删除,请稍后重试。"); + } finally { + pending.current = false; + setBusy(false); + } + } + + return
+
{ if (event.key === "Escape" && menu.current) { menu.current.open = false; trigger.current?.focus({ preventScroll: true }); } }}> + ⋯ +
+
+ {confirming &&
+

确定删除这份报告?

+ + + {error &&

{error}

} +
} +
; +} diff --git a/frontend/src/components/personal-report/vedic-chart-svg.tsx b/frontend/src/components/personal-report/vedic-chart-svg.tsx index ba62be8b..4284b874 100644 --- a/frontend/src/components/personal-report/vedic-chart-svg.tsx +++ b/frontend/src/components/personal-report/vedic-chart-svg.tsx @@ -66,7 +66,10 @@ const MAX_RENDERED_OCCUPANTS = 6; export type VedicChartModel = NorthIndianChartModel; +export const EMPTY_NORTH_INDIAN_CHART: NorthIndianChartModel = { houses: [] }; + interface VedicChartSvgProps { + skeleton?: boolean; chart: NorthIndianChartModel; ariaLabel: string; highlightHouseNumbers?: ReadonlySet; @@ -270,8 +273,14 @@ function ChartLegend() { ); } -export function VedicChartSvg({ chart, ariaLabel, highlightHouseNumbers }: VedicChartSvgProps) { +export function VedicChartSvg(props: VedicChartSvgProps) { const chartId = useId().replace(/:/g, ""); + return ; +} + +/** Shared, hook-free drawing for the reader and offline Markdown export. */ +export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highlightHouseNumbers, skeleton = false, chartId }: VedicChartSvgProps & { chartId: string }) { + const chart = skeleton ? EMPTY_NORTH_INDIAN_CHART : suppliedChart; const retrograde = retrogradeKeys(chart); const tokensByHouse = new Map(); for (const house of chart.houses) { @@ -293,6 +302,7 @@ export function VedicChartSvg({ chart, ariaLabel, highlightHouseNumbers }: Vedic height="auto" role="img" aria-label={ariaLabel} + data-skeleton={skeleton ? "true" : undefined} className="personal-report-chart-svg" > diff --git a/frontend/src/hooks/use-chart-page.ts b/frontend/src/hooks/use-chart-page.ts index 797f6b79..e4dd1c98 100644 --- a/frontend/src/hooks/use-chart-page.ts +++ b/frontend/src/hooks/use-chart-page.ts @@ -1,12 +1,16 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { fetchChartView } from "@/lib/chart-view-client"; -import type { ChartViewResponse } from "@/lib/chart-view-contract"; +import type { ChartViewFailureStatus, ChartViewOk, ChartViewResponse } from "@/lib/chart-view-contract"; import type { ChartViewLayer } from "@/lib/chart-view-engine"; -import { CHART_VIEW_COPY } from "@/lib/chart-view-labels"; +import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure"; +import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "@/lib/chart-view-labels"; import { + accountChartPinStillCurrent, + beginChartCacheAccountRead, peekChartPage, refreshChartPage, writeChartPage, @@ -17,35 +21,50 @@ function unavailableBody(): ChartViewResponse { return { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable }; } -function warnClientFailure(label: string, error: unknown, extra?: Record): void { - console.warn(label, { - errorName: error instanceof Error ? error.name : "Error", - message: error instanceof Error ? error.message : "unknown", - ...extra, - }); +function viewFromSnapshot(snapshot: ChartPageSnapshot | null): ChartViewResponse | null { + if (snapshot?.kind === "unauthenticated") { + return { status: "unauthenticated", billed: false, message: CHART_VIEW_COPY.unauthenticated }; + } + return snapshot?.kind === "view" ? snapshot.view : null; } -function viewFromSnapshot(snapshot: ChartPageSnapshot | null): ChartViewResponse | null { - return snapshot?.kind === "view" ? snapshot.view : null; +// Layer responses also contain D1 and placeholders for every unrequested layer. +// Only the requested layer may replace data already shown for this identity. +function mergeLayer(current: ChartViewOk, incoming: ChartViewOk, layer: ChartViewLayer): ChartViewOk { + if (current.accountId !== incoming.accountId || current.profileFingerprint !== incoming.profileFingerprint) return incoming; + const layerFailures = { ...current.layerFailures }; + delete layerFailures[layer]; + return { + ...current, + layerFailures, + ...(layer === "varga" ? { vedic: incoming.vedic } : {}), + ...(layer === "chara" ? { dasha: incoming.dasha } : {}), + ...(layer === "western" ? { western: incoming.western } : {}), + ...(layer === "qizheng" ? { qizheng: incoming.qizheng } : {}), + }; } export function useChartPage() { const [view, setView] = useState(() => viewFromSnapshot(peekChartPage())); const [pendingLayers, setPendingLayers] = useState>(new Set()); + const [layerFailures, setLayerFailures] = useState>(new Map()); const loadedLayers = useRef(new Set()); const inflightLayers = useRef(new Set()); + const mounted = useRef(false); useEffect(() => { + mounted.current = true; const cached = peekChartPage(); if (cached?.kind === "unauthenticated") { window.location.assign("/login"); - return; + return () => { mounted.current = false; }; } let cancelled = false; void refreshChartPage() .then((snapshot) => { if (cancelled) return; if (snapshot.kind === "unauthenticated") { + flushSync(() => setView(viewFromSnapshot(snapshot))); window.location.assign("/login"); return; } @@ -53,45 +72,73 @@ export function useChartPage() { }) .catch((error: unknown) => { if (cancelled) return; - warnClientFailure("chart_page_fetch_failed", error); - if (peekChartPage()?.kind === "view") return; - const fallback = unavailableBody(); - writeChartPage({ kind: "view", view: fallback }); + const status = chartClientFailureStatus(error); + console.warn("chart_page_fetch_failed", { status }); + const cachedView = viewFromSnapshot(peekChartPage()); + const fallback = cachedView ?? (status === "client_timeout" ? chartViewFailureResponse("client_timeout") : unavailableBody()); + if (!cachedView) writeChartPage({ kind: "view", view: fallback }); setView(fallback); }); return () => { cancelled = true; + mounted.current = false; }; }, []); const requestLayer = useCallback((layer: ChartViewLayer) => { if (loadedLayers.current.has(layer) || inflightLayers.current.has(layer)) return; + const generation = beginChartCacheAccountRead(); + const isCurrent = () => mounted.current && accountChartPinStillCurrent(generation); + const fail = (status: ChartViewFailureStatus) => { + setLayerFailures((current) => new Map(current).set(layer, status)); + }; inflightLayers.current.add(layer); - setPendingLayers((current) => { - if (current.has(layer)) return current; - const next = new Set(current); - next.add(layer); + setLayerFailures((current) => { + const next = new Map(current); + next.delete(layer); return next; }); - void fetchChartView({ layers: [layer] }) + setPendingLayers((current) => new Set(current).add(layer)); + void fetchChartView({ layers: [layer], signal: AbortSignal.timeout(CHART_VIEW_CLIENT_TIMEOUT_MS) }) .then((result) => { - if (result.body?.status === "ok") { - const stored = writeChartPage({ kind: "view", view: result.body }); - if (stored.kind !== "view" || stored.view.status !== "ok") { - setView(stored.kind === "view" ? stored.view : result.body); - return; - } - loadedLayers.current.add(layer); - setView(stored.view); + if (!isCurrent()) return; + if (result.httpStatus === 401 || result.body?.status === "unauthenticated") { + const snapshot = writeChartPage({ kind: "unauthenticated" }); + flushSync(() => setView(viewFromSnapshot(snapshot))); + window.location.assign("/login"); + return; } + const body = result.body; + if (!body || body.status !== "ok") { + // BFF throttling and engine saturation are different existing classes. + fail(body?.status === "rate_limited" ? "rate_limited" + : result.httpStatus === 429 ? "engine_busy" + : body?.reason ?? (body?.status === "client_timeout" ? "client_timeout" : "unavailable")); + return; + } + const failure = body.layerFailures?.[layer]; + if (failure) { fail(failure); return; } + const missing = layer === "varga" ? !VARGA_CHIP_ORDER.every((division) => body.vedic.vargas.some((varga) => varga.id === `D${division}`)) + : layer === "chara" ? body.dasha.chara.periods.length === 0 + : body[layer].status !== "ok"; + if (missing) { fail("unavailable"); return; } + const current = peekChartPage(); + const merged = current?.kind === "view" && current.view.status === "ok" + ? mergeLayer(current.view, body, layer) : body; + const stored = writeChartPage({ kind: "view", view: merged }); + if (stored.kind === "view" && stored.view.status === "ok") loadedLayers.current.add(layer); + setView(viewFromSnapshot(stored)); }) .catch((error: unknown) => { - warnClientFailure("chart_page_layer_failed", error, { layer }); + if (!isCurrent()) return; + const status = chartClientFailureStatus(error); + console.warn("chart_page_layer_failed", { status, layer }); + fail(status); }) .finally(() => { inflightLayers.current.delete(layer); + if (!mounted.current) return; setPendingLayers((current) => { - if (!current.has(layer)) return current; const next = new Set(current); next.delete(layer); return next; @@ -99,5 +146,5 @@ export function useChartPage() { }); }, []); - return { view, pendingLayers, requestLayer }; + return { view, pendingLayers, layerFailures, requestLayer }; } diff --git a/frontend/src/lib/chart-view-client.ts b/frontend/src/lib/chart-view-client.ts index 8c17b58f..6b17c492 100644 --- a/frontend/src/lib/chart-view-client.ts +++ b/frontend/src/lib/chart-view-client.ts @@ -13,10 +13,15 @@ export async function fetchChartView(input: { cache: "no-store", signal: input.signal, }); + // Authentication is known from the headers; a missing body must not keep + // a signed-out reader waiting for the JSON deadline. + if (response.status === 401) return { httpStatus: 401, body: null }; let json: unknown = null; try { json = await response.json(); } catch (error) { + // Aborting while reading the body is still a timeout, not malformed JSON. + if (input.signal?.aborted) throw input.signal.reason; const errorName = error instanceof Error ? error.name : "Error"; console.warn("chart_view_client_bad_payload", { errorName, httpStatus: response.status }); return { httpStatus: response.status, body: chartViewFailureResponse("response_schema_failure") }; diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 4cbf4ddb..dd918016 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -186,7 +186,19 @@ export const chartViewProfileSchema = z.object({ currentDashaLabel: z.string().min(1), }); +export const chartViewFailureStatusSchema = z.enum([ + ...CHART_BIRTH_FAILURE_REASONS, + "client_timeout", "unavailable", "unauthenticated", "rate_limited", +]); +export type ChartViewFailureStatus = z.infer; + export const chartViewOkSchema = z.object({ + layerFailures: z.object({ + varga: chartViewFailureStatusSchema.optional(), + chara: chartViewFailureStatusSchema.optional(), + western: chartViewFailureStatusSchema.optional(), + qizheng: chartViewFailureStatusSchema.optional(), + }).optional(), status: z.literal("ok"), billed: z.literal(false), profile: chartViewProfileSchema, @@ -214,7 +226,7 @@ const chartViewIdentitySchema = { }; export const chartViewMessageSchema = z.object({ - status: z.enum(["unauthenticated", "birth_profile_incomplete", "chart_unavailable", "rate_limited"]), + status: z.enum(["unauthenticated", "birth_profile_incomplete", "chart_unavailable", "rate_limited", "client_timeout"]), billed: z.literal(false), message: z.string().min(1), reason: z.enum(CHART_BIRTH_FAILURE_REASONS).optional(), diff --git a/frontend/src/lib/chart-view-failure.ts b/frontend/src/lib/chart-view-failure.ts index 7d5e5a91..f9f0b003 100644 --- a/frontend/src/lib/chart-view-failure.ts +++ b/frontend/src/lib/chart-view-failure.ts @@ -1,8 +1,11 @@ import { type ChartBirthFailureReason } from "./chart-birth-truth.ts"; -import type { ChartViewMessage } from "./chart-view-contract.ts"; +import type { ChartViewFailureStatus, ChartViewMessage } from "./chart-view-contract.ts"; + +type ChartFailureReason = ChartBirthFailureReason | "client_timeout"; import { CHART_VIEW_COPY } from "./chart-view-labels.ts"; -const FAILURE_COPY: Record = { +const FAILURE_COPY: Record = { + client_timeout: CHART_VIEW_COPY.timedOut, profile_query_error: CHART_VIEW_COPY.profileQueryError, profile_incomplete: CHART_VIEW_COPY.incomplete, adopted_calculation_incomplete: CHART_VIEW_COPY.adoptedIncomplete, @@ -14,7 +17,8 @@ const FAILURE_COPY: Record = { response_schema_failure: CHART_VIEW_COPY.schemaFailure, }; -const FAILURE_STATUS: Record = { +const FAILURE_STATUS: Record = { + client_timeout: "client_timeout", profile_query_error: "chart_unavailable", profile_incomplete: "birth_profile_incomplete", adopted_calculation_incomplete: "chart_unavailable", @@ -26,19 +30,30 @@ const FAILURE_STATUS: Record = {}; + const layerPayload = (layer: ChartViewLayer) => (result: EngineCallResult) => { + if (result.status !== "ok") layerFailures[layer] = engineFailureReason(result.status); + return enginePayload(result); + }; const [varga, chara, western, qizheng] = await Promise.all([ layers.has("varga") ? callEngine(input.postEngine, "/api/varga_full", { ...followUp, divisions: VARGA_CHIP_ORDER.map((division) => `D${division}`), - }).then(enginePayload) + }).then(layerPayload("varga")) : Promise.resolve(null), layers.has("chara") ? callEngine(input.postEngine, "/api/dasha/chara", { ...followUp, antardasha: true, mode: "dasha", - }).then(enginePayload) + }).then(layerPayload("chara")) : Promise.resolve(null), layers.has("western") - ? callEngine(input.postEngine, "/api/western", payload).then(enginePayload) + ? callEngine(input.postEngine, "/api/western", payload).then(layerPayload("western")) : Promise.resolve(null), layers.has("qizheng") - ? callEngine(input.postEngine, "/api/qizheng", payload).then(enginePayload) + ? callEngine(input.postEngine, "/api/qizheng", payload).then(layerPayload("qizheng")) : Promise.resolve(null), ]); @@ -184,7 +189,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ profile, asOf: input.asOf, }); - return { httpStatus: 200, body: stamp(body, input) }; + return { httpStatus: 200, body: stamp({ ...body, layerFailures }, input) }; } catch (error) { const errorName = error instanceof Error ? error.name : "Error"; const errorMessage = error instanceof Error ? error.message : "unknown"; diff --git a/frontend/src/lib/personal-report-longform-copy.ts b/frontend/src/lib/personal-report-longform-copy.ts index d301d50b..73d0452b 100644 --- a/frontend/src/lib/personal-report-longform-copy.ts +++ b/frontend/src/lib/personal-report-longform-copy.ts @@ -1,4 +1,4 @@ export const PERSONAL_REPORT_LEGACY_PLACEHOLDER = "旧版本报告,请重新生成"; export const PERSONAL_REPORT_GENERATING_COPY = "正在生成报告,大约 10–30 秒"; -export const PERSONAL_REPORT_EXPORT_LABEL = "导出报告(.md)"; +export const PERSONAL_REPORT_EXPORT_LABEL = "分块导出"; export const PERSONAL_REPORT_PRINT_LABEL = "打印"; diff --git a/frontend/src/lib/personal-report-longform-download.ts b/frontend/src/lib/personal-report-longform-download.ts index 4375f522..7b4ac69b 100644 --- a/frontend/src/lib/personal-report-longform-download.ts +++ b/frontend/src/lib/personal-report-longform-download.ts @@ -1,45 +1,7 @@ -import { downloadMarkdownReport } from "./consultation-report-export"; -import { PERSONAL_REPORT_LEGACY_PLACEHOLDER } from "./personal-report-longform-copy"; -import { personalReportMarkdownFilename } from "./personal-report-longform-outline"; import { stripReportChartBlocks } from "./report-chart-block"; import { projectOrdinaryReportMarkdown } from "./report-public-projection"; -/** - * Ordinary download reads GET /api/reports/:id and always projects. - * A stored or cached string is not trusted as already public. - */ +/** Historical ordinary export contract, retained as the full-body byte regression lock. */ export function ordinaryReportDownloadMarkdown(markdown: string): string { return stripReportChartBlocks(projectOrdinaryReportMarkdown(markdown)); } - -export async function requestPersonalReportLongformAppendix(reportId: string): Promise { - const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}`, { - method: "GET", - credentials: "same-origin", - headers: { Accept: "application/json" }, - }); - const result: unknown = await response.json().catch(() => null); - const payload = result && typeof result === "object" ? result as Record : {}; - const markdown = typeof payload.longformMarkdown === "string" ? payload.longformMarkdown : ""; - if (!response.ok || !markdown.trim()) { - throw new Error( - typeof payload.error === "string" && payload.error.trim() - ? payload.error - : PERSONAL_REPORT_LEGACY_PLACEHOLDER, - ); - } - return projectOrdinaryReportMarkdown(markdown); -} - -export async function downloadPersonalReportLongformAppendix( - reportId: string, - options: Readonly<{ markdown?: string; reportDate?: string | null }> = {}, -): Promise { - const markdown = options.markdown?.trim() - ? options.markdown - : await requestPersonalReportLongformAppendix(reportId); - downloadMarkdownReport( - personalReportMarkdownFilename(options.reportDate), - ordinaryReportDownloadMarkdown(markdown), - ); -} diff --git a/frontend/src/lib/personal-report-longform-outline.ts b/frontend/src/lib/personal-report-longform-outline.ts index 27c84a52..0d43dcf6 100644 --- a/frontend/src/lib/personal-report-longform-outline.ts +++ b/frontend/src/lib/personal-report-longform-outline.ts @@ -10,6 +10,8 @@ export type LongformSection = Readonly<{ id: string; title: string; markdown: string; + /** Lossless source slice, before eager/H3 summary extraction; export only. */ + sourceMarkdown: string; headings: readonly LongformHeading[]; eager: boolean; }>; @@ -17,6 +19,8 @@ export type LongformSection = Readonly<{ export type LongformOutline = Readonly<{ headings: readonly LongformHeading[]; leadMarkdown: string; + /** Only the source before the first H2; unlike leadMarkdown, no lifted content. */ + sourcePreamble: string; sections: readonly LongformSection[]; }>; @@ -94,6 +98,12 @@ function stripH3Block(markdown: string, pattern: RegExp): string { export function buildLongformOutline(markdown: string): LongformOutline { const used = new Map(); const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + let sourceOffset = 0; + const sourceOffsets = markdown.split("\n").map((line) => { + const start = sourceOffset; + sourceOffset += line.length + 1; + return start; + }); const headings: LongformHeading[] = []; type Block = { title: string; @@ -143,6 +153,7 @@ export function buildLongformOutline(markdown: string): LongformOutline { id: headings[block.headingIndex]?.id ?? slugifyHeading(block.title, used), title: block.title, markdown: sectionMarkdown, + sourceMarkdown: markdown.slice(sourceOffsets[block.start], sourceOffsets[end] ?? markdown.length), headings: [ headings[block.headingIndex], ...block.h3Indexes.map((item) => headings[item]), @@ -154,6 +165,7 @@ export function buildLongformOutline(markdown: string): LongformOutline { return { headings, leadMarkdown: leadParts.join("\n\n").trim(), + sourcePreamble: markdown.slice(0, blocks[0] ? sourceOffsets[blocks[0].start] : markdown.length), sections, }; } diff --git a/frontend/src/lib/personal-report-progress.ts b/frontend/src/lib/personal-report-progress.ts index f46665a2..826e31e6 100644 --- a/frontend/src/lib/personal-report-progress.ts +++ b/frontend/src/lib/personal-report-progress.ts @@ -28,6 +28,13 @@ export const REPORT_THEME_LABELS: Readonly> = { timing: "应期", }; +export const REPORT_DEPTH_LABELS: Readonly> = { + concise: "简版", + standard: "标准深度", + deep: "深度", + research: "研究级", +}; + export function reportThemeLabel(theme: string): string { return REPORT_THEME_LABELS[theme] ?? theme; } diff --git a/frontend/src/lib/report-chart-export.tsx b/frontend/src/lib/report-chart-export.tsx new file mode 100644 index 00000000..a3895950 --- /dev/null +++ b/frontend/src/lib/report-chart-export.tsx @@ -0,0 +1,49 @@ +import { Fragment, isValidElement, type ReactNode } from "react"; +import { VedicChartSvgDrawing } from "@/components/personal-report/vedic-chart-svg"; +import type { NorthIndianChartModel } from "./report-chart-block"; +import { projectOrdinaryReportMarkdown } from "./report-public-projection"; + +function escapeXml(value: unknown): string { + return String(value).replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!); +} + +/** Deliberately only serializes our static SVG React tree, never arbitrary HTML. */ +function serializeSvgNode(node: ReactNode): string { + if (node == null || typeof node === "boolean") return ""; + if (typeof node === "string" || typeof node === "number") return escapeXml(node); + if (Array.isArray(node)) return node.map(serializeSvgNode).join(""); + if (!isValidElement>(node)) return ""; + if (node.type === Fragment) return serializeSvgNode(node.props.children as ReactNode); + if (typeof node.type === "function") { + return serializeSvgNode((node.type as (props: Record) => ReactNode)(node.props)); + } + if (typeof node.type !== "string" || !/^(svg|g|polygon|text|line|defs|clipPath)$/.test(node.type)) return ""; + const attributes = Object.entries(node.props).filter(([name, value]) => name !== "children" && name !== "key" && value != null && !/^on/.test(name)).map(([name, value]) => { + const attribute = name === "className" ? "class" : name === "viewBox" ? name : name.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`); + return ` ${attribute}="${escapeXml(value)}"`; + }).join(""); + return `<${node.type}${attributes}>${serializeSvgNode(node.props.children as ReactNode)}`; +} + +/** Offline fallback for reports without the engine's already-safe SVG. Reuse the + * reader's glyph layout and geometry, not a second chart renderer. Presentation + * attributes survive Markdown viewers without the application's stylesheet. + * Clipping definitions are not in the ordinary SVG allowlist; remove them rather + * than widening that security boundary. The renderer already bounds occupants. */ +export function reportChartExportSvg(chart: NorthIndianChartModel, ariaLabel: string): string { + const rendered = serializeSvgNode(VedicChartSvgDrawing({ chart, ariaLabel, chartId: "export-chart" })) + .replace(/[\s\S]*?<\/defs>/g, "") + .replace(/ clip-path="[^"]*"/g, "") + .replace(/ { + if (classes.includes("personal-report-chart-cell")) return 'fill="none" stroke="currentColor" stroke-width="1.25"'; + if (classes.includes("personal-report-chart-retrograde")) return 'stroke="currentColor" stroke-width="1.9"'; + const size = classes.includes("personal-report-chart-legend-glyph") ? 14 + : classes.includes("personal-report-chart-glyph") ? 22 + : classes.includes("personal-report-chart-asc") || classes.includes("personal-report-chart-overflow") ? 13 + : classes.includes("personal-report-chart-legend") || classes.includes("personal-report-chart-occupant-text") ? 12 : null; + return size ? `fill="currentColor" font-size="${size}"` : ""; + }); + return projectOrdinaryReportMarkdown(rendered); +} diff --git a/frontend/src/lib/report-export-blocks.ts b/frontend/src/lib/report-export-blocks.ts new file mode 100644 index 00000000..d238debc --- /dev/null +++ b/frontend/src/lib/report-export-blocks.ts @@ -0,0 +1,111 @@ +import type { ReportDocumentV2 } from "./personal-report-contract"; +import { reportChartExportSvg } from "./report-chart-export"; +import { buildLongformOutline, personalReportMarkdownFilename } from "./personal-report-longform-outline"; +import { chartAriaLabel, parseReportChartBlock, stripReportChartBlocks, toNorthIndianChart, type ReportChartBlock } from "./report-chart-block"; +import type { ReportFactTable } from "./report-fact-table-schema"; +import { factCellText, FACT_STATUS_LABELS } from "./report-fact-table-display"; +import { projectOrdinaryReportMarkdown } from "./report-public-projection"; + +export type ReportExportBlock = Readonly<{ + id: string; + group: "body" | "chart" | "table"; + title: string; + text: string; + chars: number; +}>; +export type ReportExportPreset = "common" | "charts" | "tables"; + +export function chartBlockText(block: ReportChartBlock): string { + const chart = toNorthIndianChart(block); + return `#### ${chartAriaLabel(block.id)}\n\n${chart.houses.map(house => + `- 第 ${house.houseNumber} 宫 · ${house.sign}:${house.occupants.join("、") || "—"}`, + ).join("\n")}`; +} + +function tableCell(value: ReportFactTable["rows"][number]["cells"][number]): string { + return factCellText(value).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "
"); +} + +function tableGrid(table: Pick): string { + if (!table.rows.length) return ""; + return [ + `| ${table.columns.map(tableCell).join(" | ")} |`, + `| ${table.columns.map(() => "---").join(" | ")} |`, + ...table.rows.map(row => `| ${row.cells.map(tableCell).join(" | ")} |`), + ].join("\n"); +} + +/** Same labels, precision, missing values and subtables as ReportFactTables; no provenance paths. */ +export function factTableToMarkdown(group: ReportFactTable): string { + return projectOrdinaryReportMarkdown([ + `## ${group.title}`, + FACT_STATUS_LABELS[group.claimStatus], + group.note, + tableGrid(group), + ...(group.subtables ?? []).flatMap(table => [`### ${table.title}`, table.note, tableGrid(table)]), + ].filter(Boolean).join("\n\n")); +} + +export function buildReportExportBlocks({ markdown, charts = [], factTables = [] }: { + markdown: string; + charts?: ReportDocumentV2["charts"]; + factTables?: readonly ReportFactTable[]; +}): ReportExportBlock[] { + const projected = projectOrdinaryReportMarkdown(markdown); + const outline = buildLongformOutline(projected); + const blocks: ReportExportBlock[] = []; + function add(id: string, group: ReportExportBlock["group"], title: string, text: string) { + blocks.push({ id, group, title, text, chars: Array.from(text).length }); + } + // sourcePreamble/sourceMarkdown are the outline's lossless counterparts. The reader's + // leadMarkdown also contains lifted summaries: using it here duplicates/reorders prose. + if (outline.sourcePreamble) add("body:lead", "body", "摘要", stripReportChartBlocks(outline.sourcePreamble)); + for (const section of outline.sections) { + add(`body:${section.id}`, "body", section.title, stripReportChartBlocks(section.sourceMarkdown)); + } + const chartIds = new Set(); + for (const match of projected.matchAll(/```jyotish-chart\r?\n([\s\S]*?)```/g)) { + const block = parseReportChartBlock(match[1]); + if (!block) continue; + const occurrence = blocks.filter(item => item.group === "chart" && item.id.startsWith(`chart:${block.id}:`)).length + 1; + chartIds.add(block.id); + // Only the immediately preceding SVG belongs to this occurrence (D9 may repeat). + // The public projection has already rejected unsafe SVG; never recover raw HTML. + const preceding = projected.slice(0, match.index); + const svg = preceding.match(/(]*>[\s\S]*?<\/svg>)\s*$/i)?.[1]; + const safeSvg = svg?.slice(svg.lastIndexOf(" house.occupants.length)) continue; + chartIds.add(chart.id); + const title = chartAriaLabel(chart.id); + const text = `#### ${title}\n\n${chart.houses.map(house => + `- 第 ${house.houseNumber} 宫 · ${house.sign}:${house.occupants.join("、") || "—"}`, + ).join("\n")}`; + add(`chart:${chart.id}`, "chart", title, projectOrdinaryReportMarkdown(`${text}\n\n${reportChartExportSvg(chart, title)}`)); + } + for (const table of factTables) add(`table:${table.id}`, "table", table.title, factTableToMarkdown(table)); + return blocks; +} + +export function joinExportBlocks(blocks: readonly ReportExportBlock[]): string { + // Preserve source boundaries (including CRLF/trailing whitespace) for D5. All + // body slices are in outline order; stripping once more handles boundary blank runs. + const body = stripReportChartBlocks(blocks.filter(block => block.group === "body").map(block => block.text).join("")); + const added = blocks.filter(block => block.group !== "body").map(block => block.text).join("\n\n"); + return body && added ? `${body}\n\n${added}` : body || added; +} + +export function presetSelection(preset: ReportExportPreset, blocks: readonly ReportExportBlock[]): Set { + const group = { common: "body", charts: "chart", tables: "table" }[preset]; + return new Set(blocks.filter(block => block.group === group).map(block => block.id)); +} + +/** downloadMarkdownReport appends .md; returning an extension here would duplicate it. */ +export function exportFilename(reportDate: string | null | undefined, isFull: boolean): string { + return `${personalReportMarkdownFilename(reportDate)}${isFull ? "" : "-节选"}`; +} diff --git a/frontend/src/lib/report-fact-table-display.ts b/frontend/src/lib/report-fact-table-display.ts new file mode 100644 index 00000000..238bb30d --- /dev/null +++ b/frontend/src/lib/report-fact-table-display.ts @@ -0,0 +1,14 @@ +import type { ReportFactTable } from "./report-fact-table-schema"; + +export const FACT_STATUS_LABELS = { + multi_system_consensus: "多系统共识", + single_system_inference: "单一系统推断", + parameter_sensitive: "参数敏感", + unclosed_divisional_chart: "分盘尚未核验完整", + user_history_verification_required: "需要过往经历核对", + blocked: "暂不可用", +} as const; + +export function factCellText(cell: ReportFactTable["rows"][number]["cells"][number]): string { + return cell === null ? "—" : typeof cell === "boolean" ? (cell ? "是" : "否") : typeof cell === "number" ? String(Number(cell.toFixed(2))) : cell; +} diff --git a/frontend/src/lib/secondary-page-data.ts b/frontend/src/lib/secondary-page-data.ts index bc827cd6..504cd26d 100644 --- a/frontend/src/lib/secondary-page-data.ts +++ b/frontend/src/lib/secondary-page-data.ts @@ -1,6 +1,7 @@ import { fetchChartView } from "@/lib/chart-view-client"; import type { ChartViewResponse } from "@/lib/chart-view-contract"; -import { chartViewFailureResponse } from "@/lib/chart-view-failure"; +import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure"; +import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY } from "@/lib/chart-view-labels"; import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract"; export const REPORTS_WAITING_COPY = "报告列表还没拿到。"; @@ -18,7 +19,7 @@ export type ReportListItem = Readonly<{ id: string; reportType: string; presentationMode: string; - depth: "concise" | "standard" | "deep" | "research"; + depth: string; themes: readonly string[]; status: "generating" | "ready" | "failed"; failureCode: string | null; @@ -44,12 +45,14 @@ type MemoryCache = { function createMemoryCache(load: () => Promise): MemoryCache { let value: T | null = null; let inflight: Promise | null = null; + let epoch = 0; const refresh = (): Promise => { if (inflight) return inflight; + const requestEpoch = epoch; const request = load() .then((next) => { - value = next; + if (epoch === requestEpoch) value = next; return next; }) .finally(() => { @@ -70,6 +73,7 @@ function createMemoryCache(load: () => Promise): MemoryCache { void refresh(); }, reset: () => { + epoch += 1; value = null; inflight = null; }, @@ -105,7 +109,7 @@ export function readChartSnapshotIdentity(snapshot: ChartPageSnapshot): ChartSna } function snapshotFromChartResult(result: { httpStatus: number; body: ChartViewResponse | null }): ChartPageSnapshot { - if (result.httpStatus === 401) return { kind: "unauthenticated" }; + if (result.httpStatus === 401 || result.body?.status === "unauthenticated") return { kind: "unauthenticated" }; return { kind: "view", view: result.body ?? chartViewFailureResponse("response_schema_failure"), @@ -152,22 +156,50 @@ function chartSnapshotMatchesPin(snapshot: ChartPageSnapshot): boolean { return sameChartIdentity(identity, pinnedChartIdentity); } -async function loadChartSnapshot(started: number): Promise { - const first = snapshotFromChartResult(await fetchChartView({})); +async function loadChartSnapshot(started: number, signal: AbortSignal): Promise { + const first = snapshotFromChartResult(await fetchChartView({ signal })); if (started !== chartEpoch) return refreshChartPage(); let snapshot = first; if (pinnedChartIdentity && !chartSnapshotMatchesPin(snapshot)) { - snapshot = snapshotFromChartResult(await fetchChartView({})); + snapshot = snapshotFromChartResult(await fetchChartView({ signal })); if (started !== chartEpoch) return refreshChartPage(); } if (!chartSnapshotMatchesPin(snapshot)) return rememberChartSnapshot(pinnedChartFailure()); + const current = chartSnapshotVisible(); + // A warm page can finish a requested layer while this D1-only refresh is + // still in flight. Its unrequested placeholders must not erase that layer. + if (snapshot.kind === "view" && snapshot.view.status === "ok" + && current?.kind === "view" && current.view.status === "ok" + && sameChartIdentity(readChartSnapshotIdentity(snapshot), readChartSnapshotIdentity(current))) { + const prior = current.view; + snapshot = { kind: "view", view: { + ...snapshot.view, + ...(prior.vedic.vargas.length > 1 ? { vedic: prior.vedic } : {}), + dasha: { ...snapshot.view.dasha, ...(prior.dasha.chara.periods.length ? { chara: prior.dasha.chara } : {}) }, + ...(prior.western.status === "ok" ? { western: prior.western } : {}), + ...(prior.qizheng.status === "ok" ? { qizheng: prior.qizheng } : {}), + } }; + } return rememberChartSnapshot(snapshot); } function refreshChartSnapshot(): Promise { if (chartInflight && chartInflight.epoch === chartEpoch) return chartInflight.promise; const started = chartEpoch; - const promise = loadChartSnapshot(started).finally(() => { + // The shared request owns the deadline, including sidebar prefetch and the + // identity retry. A mounting hook cannot extend a hung prefetch's lifetime. + const signal = AbortSignal.timeout(CHART_VIEW_CLIENT_TIMEOUT_MS); + const promise = loadChartSnapshot(started, signal).catch((error: unknown) => { + if (started !== chartEpoch) return refreshChartPage(); + const cached = chartSnapshotVisible(); + if (cached) return cached; + const status = chartClientFailureStatus(error); + console.warn("chart_page_fetch_failed", { status }); + return rememberChartSnapshot({ kind: "view", view: status === "client_timeout" + ? chartViewFailureResponse("client_timeout") + : { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable }, + }); + }).finally(() => { if (chartInflight?.promise === promise) chartInflight = null; }); chartInflight = { epoch: started, promise }; @@ -220,9 +252,7 @@ export function readReports(value: unknown): ReportListItem[] { id: row.id, reportType: typeof row.reportType === "string" ? row.reportType : "personal_full", presentationMode: typeof row.presentationMode === "string" ? row.presentationMode : "default", - depth: ["concise", "standard", "deep", "research"].includes(String(row.depth)) - ? row.depth as ReportListItem["depth"] - : "standard", + depth: typeof row.depth === "string" ? row.depth : "standard", themes: Array.isArray(row.themes) ? row.themes.filter((theme): theme is string => typeof theme === "string") : [], @@ -287,6 +317,10 @@ export function writeReportsPage(snapshot: ReportsPageSnapshot): ReportsPageSnap return reportsCache.write(snapshot); } +export function invalidateReportsPage(): void { + reportsCache.reset(); +} + export function refreshReportsPage(): Promise { return reportsCache.refresh(); } diff --git a/frontend/tests/chart-page-hook.test.ts b/frontend/tests/chart-page-hook.test.ts new file mode 100644 index 00000000..f068a4b3 --- /dev/null +++ b/frontend/tests/chart-page-hook.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import React, { useLayoutEffect } from "react"; +import test from "node:test"; +import { useChartPage } from "../src/hooks/use-chart-page.ts"; +import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import type { ChartViewLayer, EngineCallResult } from "../src/lib/chart-view-engine.ts"; +import type { ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts"; +import { peekChartPage, pinChartSnapshotIdentity, prefetchSecondaryPage, resetSecondaryPageDataForTests, writeChartPage } from "../src/lib/secondary-page-data.ts"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8")); +const profile = { name: "Synthetic", date: "1990-06-15", time: "12:00", placeLabel: "Synthetic", latitude: 39.9042, longitude: 116.4074, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "raman", birthTimeStatus: "confirmed" }; +async function packet(layer?: ChartViewLayer, failure?: EngineCallResult) { + return assembleChartView({ userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1", profile, asOf: "2026-09-15", layers: layer ? [layer] : [], postEngine: async (path) => { + if (path === "/api/chart") return { status: "ok", payload: golden.chart }; + if (failure) return failure; + const payload = path === "/api/varga_full" ? golden.varga_full : path === "/api/dasha/chara" ? golden.chara : golden.western; + return { status: "ok", payload }; + } }); +} +function response(result: Awaited>) { return new Response(JSON.stringify(result.body), { status: result.httpStatus }); } +function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; } +async function harness(run: (h: ReturnType, current: () => ReturnType, assigned: string[]) => Promise) { + resetSecondaryPageDataForTests(); + const originalFetch = globalThis.fetch; + const originalTimeout = AbortSignal.timeout; + const h = createClientLifecycleHarness(); + let value!: ReturnType; + const assigned: string[] = []; + Object.assign(window, { location: { assign: (url: string) => { assert.equal(value.view?.status, "unauthenticated"); assigned.push(url); } } }); + function Probe() { const state = useChartPage(); useLayoutEffect(() => { value = state; }); return null; } + const mount = h.render.bind(h); + h.render = () => mount(React.createElement(Probe)); + try { await run(h, () => value, assigned); assert.deepEqual(h.errors, []); } + finally { await h.close(); globalThis.fetch = originalFetch; AbortSignal.timeout = originalTimeout; resetSecondaryPageDataForTests(); } +} + +test("chart first request times out with client_timeout and timedOut copy", async () => harness(async (h, state) => { + const deadline = new AbortController(); + AbortSignal.timeout = (ms) => { assert.equal(ms, CHART_VIEW_CLIENT_TIMEOUT_MS); return deadline.signal; }; + globalThis.fetch = async (_url, init) => new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true })); + await h.render(null); + assert.equal(state().view, null); + await h.update(() => deadline.abort(new DOMException("synthetic", "TimeoutError"))); + assert.equal(state().view?.status, "client_timeout"); + const view = state().view; + assert.equal(view?.status !== "ok" && view?.message, CHART_VIEW_COPY.timedOut); +})); + +test("sidebar prefetch shares the same bounded request with the mounted hook", async () => harness(async (h, state) => { + const deadline = new AbortController(); let calls = 0; + AbortSignal.timeout = (ms) => { assert.equal(ms, 25_000); return deadline.signal; }; + globalThis.fetch = async (_url, init) => { calls++; return new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal!.reason))); }; + prefetchSecondaryPage("/chart"); + await h.render(null); + await h.update(() => deadline.abort(new DOMException("synthetic", "TimeoutError"))); + assert.equal(calls, 1); assert.equal(state().view?.status, "client_timeout"); +})); + +test("chart cache populated during failed fetch is painted instead of remaining null", async () => harness(async (h, state) => { + const request = deferred(); globalThis.fetch = () => request.promise; + await h.render(null); + const result = await packet(); + writeChartPage({ kind: "view", view: result.body }); + await h.update(() => request.reject(new TypeError("synthetic network failure"))); + assert.deepEqual(state().view, result.body); +})); + +for (const cached of [false, true]) test(`chart 401 ${cached ? "cache" : "response"} paints unauthenticated before redirect`, async () => harness(async (h, state, assigned) => { + globalThis.fetch = async () => new Response("{}", { status: 401 }); + if (cached) writeChartPage({ kind: "unauthenticated" }); + await h.render(null); + assert.equal(state().view?.status, "unauthenticated"); assert.deepEqual(assigned, ["/login"]); +})); + +for (const layer of ["varga", "chara", "western", "qizheng"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => { + const initial = await packet(); + const failed = await packet(layer, { status: "busy", path: "/api/synthetic", elapsedMs: 1, httpStatus: 429 }); + globalThis.fetch = async (url) => response(String(url).includes("layers=") ? failed : initial); + await h.render(null); + await h.update(() => state().requestLayer(layer)); + assert.equal(state().layerFailures.get(layer), "engine_busy"); + assert.equal(state().pendingLayers.has(layer), false); + assert.deepEqual(state().view, initial.body); +})); + +test("layer catch fails unavailable and a retry clears failure while pending then succeeds", async () => harness(async (h, state) => { + const initial = await packet(); const good = await packet("varga"); const retry = deferred(); let calls = 0; + globalThis.fetch = async (url) => { + if (!String(url).includes("layers=")) return response(initial); + calls++; if (calls === 1) throw new TypeError("synthetic"); return retry.promise; + }; + await h.render(null); await h.update(() => state().requestLayer("varga")); + assert.equal(state().layerFailures.get("varga"), "unavailable"); + await h.update(() => { state().requestLayer("varga"); state().requestLayer("varga"); }); + assert.equal(state().layerFailures.has("varga"), false); assert.equal(state().pendingLayers.has("varga"), true); assert.equal(calls, 2); + await h.update(() => retry.resolve(response(good))); + assert.equal(state().pendingLayers.has("varga"), false); assert.equal(state().layerFailures.size, 0); + assert.ok((state().view as ChartViewOk).vedic.vargas.some((v) => v.id === "D9")); +})); + +test("BFF rate_limited stays distinct from engine_busy", async () => harness(async (h, state) => { + const initial = await packet(); globalThis.fetch = async (url) => String(url).includes("layers=") ? new Response(JSON.stringify({ status: "rate_limited", billed: false, message: CHART_VIEW_COPY.rateLimited }), { status: 429 }) : response(initial); + await h.render(null); await h.update(() => state().requestLayer("varga")); + assert.equal(state().layerFailures.get("varga"), "rate_limited"); +})); + +test("layer timeout while reading response JSON remains client_timeout", async () => harness(async (h, state) => { + const initial = await packet(); const deadline = new AbortController(); + globalThis.fetch = async (url) => String(url).includes("layers=") ? { status: 200, json: () => new Promise((_resolve, reject) => deadline.signal.addEventListener("abort", () => reject(new DOMException("synthetic", "AbortError")))) } as Response : response(initial); + await h.render(null); + AbortSignal.timeout = () => deadline.signal; + await h.update(() => state().requestLayer("varga")); + await h.update(() => deadline.abort(new DOMException("synthetic", "TimeoutError"))); + assert.equal(state().layerFailures.get("varga"), "client_timeout"); +})); + +test("out-of-order successful layers preserve previously loaded data", async () => harness(async (h, state) => { + const initial = await packet(); const varga = await packet("varga"); const western = await packet("western"); const v = deferred(); const w = deferred(); + globalThis.fetch = async (url) => String(url).includes("varga") ? v.promise : String(url).includes("western") ? w.promise : response(initial); + await h.render(null); await h.update(() => { state().requestLayer("varga"); state().requestLayer("western"); }); + await h.update(() => w.resolve(response(western))); await h.update(() => v.resolve(response(varga))); + const view = state().view as ChartViewOk; + assert.equal(view.western.status, "ok"); assert.ok(view.vedic.vargas.some((x) => x.id === "D9")); +})); + +test("late warm D1 refresh preserves the layer that completed after mounting", async () => harness(async (h, state) => { + const initial = await packet(); const western = await packet("western"); const refresh = deferred(); + writeChartPage({ kind: "view", view: initial.body }); + globalThis.fetch = async (url) => String(url).includes("layers=") ? response(western) : refresh.promise; + await h.render(null); await h.update(() => state().requestLayer("western")); + assert.equal((state().view as ChartViewOk).western.status, "ok"); + await h.update(() => refresh.resolve(response(initial))); + assert.equal((state().view as ChartViewOk).western.status, "ok"); +})); + +test("HTTP 429 without a valid body remains engine_busy", async () => harness(async (h, state) => { + const initial = await packet(); globalThis.fetch = async (url) => String(url).includes("layers=") ? new Response("busy", { status: 429 }) : response(initial); + await h.render(null); await h.update(() => state().requestLayer("western")); + assert.equal(state().layerFailures.get("western"), "engine_busy"); +})); + +test("an obsolete first fetch failure cannot overwrite a new profile snapshot", async () => harness(async (h, state) => { + const old = deferred(); const initial = await packet(); + const fresh = { ...initial, body: { ...initial.body, profileFingerprint: "fixture-v2" } }; + let calls = 0; + globalThis.fetch = async () => ++calls === 1 ? old.promise : response(fresh); + await h.render(null); + pinChartSnapshotIdentity({ accountId: "synthetic", fingerprint: "fixture-v2" }); + await h.update(() => old.reject(new DOMException("synthetic", "TimeoutError"))); + assert.equal(state().view?.profileFingerprint, "fixture-v2"); + assert.equal(peekChartPage()?.kind === "view" && peekChartPage()?.kind, "view"); + assert.equal(calls, 2); +})); + +test("layer 401 paints failure before redirect and never becomes a layer wait", async () => harness(async (h, state, assigned) => { + const initial = await packet(); globalThis.fetch = async (url) => String(url).includes("layers=") ? new Response(null, { status: 401 }) : response(initial); + await h.render(null); await h.update(() => state().requestLayer("western")); + assert.deepEqual(assigned, ["/login"]); assert.equal(state().view?.status, "unauthenticated"); + assert.equal(state().pendingLayers.size, 0); +})); + +test("profile pin invalidation ignores an older layer completion", async () => harness(async (h, state) => { + const initial = await packet(); const old = await packet("varga"); const request = deferred(); + globalThis.fetch = async (url) => String(url).includes("layers=") ? request.promise : response(initial); + await h.render(null); await h.update(() => state().requestLayer("varga")); + pinChartSnapshotIdentity({ accountId: "synthetic", fingerprint: "fixture-v2" }); + const fresh = { ...initial.body, profileFingerprint: "fixture-v2" }; + writeChartPage({ kind: "view", view: fresh }); + await h.update(() => request.resolve(response(old))); + assert.deepEqual(peekChartPage(), { kind: "view", view: fresh }); + assert.equal(state().layerFailures.size, 0); +})); diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index c947a14b..57f2cfd3 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -4,6 +4,8 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import test from "node:test"; +import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx"; +import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg.tsx"; import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx"; import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; import { assembleChartView } from "../src/lib/chart-view-load.ts"; @@ -253,7 +255,12 @@ test("the chart page shell is visible before the natal chart arrives", () => { assert.match(markup, /data-sidebar="trigger"/); assert.doesNotMatch(markup, /返回对话/); assert.match(markup, /这一张盘还没拿到/); - assert.doesNotMatch(markup, /正在加载|骨架|spinner|打开即有/i); + // 原值:等待页不得出现骨架;新值:必须有空盘骨架、禁用 Tab,仍无 spinner。 + // 原因:2026-09-24 D1 仅授权星盘盘位骨架(BUG-1016)。 + assert.match(markup, /data-skeleton="true"/); + assert.match(markup, /aria-busy="true"/); + assert.equal((markup.match(/disabled=""/g) ?? []).length, 5); + assert.doesNotMatch(markup, /正在加载|InlineSpinner|spinner|打开即有/i); assert.doesNotMatch(markup, /chart-page-eyebrow/); }); @@ -273,7 +280,54 @@ test("a pending western tab uses inline waiting copy instead of a spinner", asyn pendingLayers: new Set(["western"]), })); assert.match(markup, /这一栏还没拿到/); - assert.doesNotMatch(markup, /正在加载|InlineSpinner|skeleton/i); + // 原值:禁止 skeleton;新值:必须 data-skeleton;原因:D1/D2 授权复用西洋空盘。 + assert.match(markup, /data-skeleton="true"/); + assert.doesNotMatch(markup, /正在加载|InlineSpinner/i); +}); + +test("varga pending shows the empty shared chart and failure replaces it", async () => { + const all = await okView(); + const view = { ...all, vedic: { ...all.vedic, vargas: all.vedic.vargas.filter((v) => v.id === "D1") } }; + const render = (pending: boolean, failure?: "engine_busy" | "client_timeout") => renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "D9", pending, failure, onSelectVarga: () => {} })); + assert.match(render(true), /data-skeleton="true"/); + assert.match(render(true), /这一分盘还没拿到/); + for (const [failure, copy] of [["engine_busy", CHART_VIEW_COPY.busy], ["client_timeout", CHART_VIEW_COPY.timedOut]] as const) { + const markup = render(false, failure); + assert.ok(markup.includes(copy)); assert.doesNotMatch(markup, /data-skeleton|这一分盘还没拿到/); + } + assert.match(render(true, "engine_busy"), /data-skeleton="true"/); +}); + +test("western qizheng and chara failures are terminal copy rather than waiting", async () => { + const view = await okView(); + for (const [tab, layer] of [["western", "western"], ["qizheng", "qizheng"], ["dasha", "chara"]] as const) { + const markup = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: tab, layerFailures: new Map([[layer, "engine_busy" as const]]) })); + assert.ok(markup.includes(CHART_VIEW_COPY.busy)); assert.doesNotMatch(markup, /data-skeleton|这一栏还没拿到/); + if (tab === "dasha") assert.match(markup, /Vimshottari/); + } +}); + +test("qizheng and chara pending remain static and only western has a wheel skeleton", async () => { + const view = await okView(); + for (const [tab, layer] of [["qizheng", "qizheng"], ["dasha", "chara"]] as const) { + const markup = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: tab, pendingLayers: new Set([layer]) })); + assert.match(markup, /这一栏还没拿到/); assert.doesNotMatch(markup, /data-skeleton|InlineSpinner/); + } +}); + +test("skeleton shares north Indian geometry without planets degrees or signs", async () => { + const view = await okView(); + const markup = renderToStaticMarkup(React.createElement(VedicChartSvg, { chart: view.vedic.vargas[0].chart, ariaLabel: "test", skeleton: true })); + const empty = renderToStaticMarkup(React.createElement(VedicChartSvg, { chart: EMPTY_NORTH_INDIAN_CHART, ariaLabel: "test" })); + assert.deepEqual([...markup.matchAll(/ m[1]), [...empty.matchAll(/ m[1])); + assert.doesNotMatch(markup, / { + assert.match(cssDeclarations(".chart-page-skeleton", globalStyles), /animation: chart-skeleton-breathe 2\.4s ease-in-out infinite/); + assert.equal((globalStyles.match(/@keyframes chart-skeleton-breathe/g) ?? []).length, 1); + assert.match(globalStyles, /@keyframes chart-skeleton-breathe\s*\{\s*0%, 100% \{ opacity: 0\.35; \}\s*50% \{ opacity: 0\.6; \}/); + assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.chart-page-skeleton \{ animation: none; opacity: 0\.5; \}/); }); test("the (app) layout mounts one sidebar for home and the four secondary routes", () => { diff --git a/frontend/tests/personal-report-entry.test.ts b/frontend/tests/personal-report-entry.test.ts index b1b10f68..2f9c7b45 100644 --- a/frontend/tests/personal-report-entry.test.ts +++ b/frontend/tests/personal-report-entry.test.ts @@ -306,12 +306,13 @@ 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, /downloadPersonalReportLongformAppendix/); + assert.match(reportCenterSource, /#export/); + assert.doesNotMatch(reportCenterSource, /downloadPersonalReportLongformAppendix|exportingReportId|exportError/); assert.match(reportCenterSource, /cardSummary/); assert.doesNotMatch(longformDownloadSource, /professional-reference/); assert.match(longformDownloadSource, /projectOrdinaryReportMarkdown/); - assert.match(longformDownloadSource, /\/api\/reports\/\$\{encodeURIComponent\(reportId\)\}/); - assert.match(longformDownloadSource, /personalReportMarkdownFilename/); + assert.doesNotMatch(longformDownloadSource, /fetch\(/); + assert.match(longformDownloadSource, /ordinaryReportDownloadMarkdown/); assert.doesNotMatch(reportCenterSource, /全量数据附录/); assert.doesNotMatch(reportCenterSource, /章节/); const readyActionStart = reportCenterSource.indexOf( @@ -357,7 +358,9 @@ test("every report state keeps the affordance its row carried before", () => { /role=\{report\.status === "generating" \? "status" : undefined\}/, ); assert.match(reportCenterSource, /report\.failureSummary \?\? report\.failureCode \?\? "生成失败"/); - assert.match(reportCenterSource, /className="report-center-export-error" role="alert"/); + assert.match(reportCenterSource, / { assert.equal(PERSONAL_REPORT_WRITER_ENABLED, false); - assert.equal(PERSONAL_REPORT_EXPORT_LABEL, "导出报告(.md)"); + assert.equal(PERSONAL_REPORT_EXPORT_LABEL, "分块导出"); assert.equal(PERSONAL_REPORT_LEGACY_PLACEHOLDER, "旧版本报告,请重新生成"); assert.match(PERSONAL_REPORT_GENERATING_COPY, /10–30 秒/); }); diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts index e19d4963..d56d72f2 100644 --- a/frontend/tests/personal-report-view.test.ts +++ b/frontend/tests/personal-report-view.test.ts @@ -431,11 +431,12 @@ test("ready reports expose the browser print/PDF action with capability and hydr assert.match(actionsSource, /返回报告中心/); assert.match(actionsSource, /PERSONAL_REPORT_EXPORT_LABEL/); assert.match(actionsSource, /PERSONAL_REPORT_PRINT_LABEL/); - assert.match(actionsSource, /downloadPersonalReportLongformAppendix/); + 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 \|\| exportBusy \|\| !printSupported\}/); + assert.match(actionsSource, /disabled=\{printBusy \|\| exportOpen \|\| !printSupported\}/); }); test("the reader renders inside the app shell, in every phase", () => { diff --git a/frontend/tests/react-client-lifecycle-test-support.ts b/frontend/tests/react-client-lifecycle-test-support.ts index 05c73a91..87de03a0 100644 --- a/frontend/tests/react-client-lifecycle-test-support.ts +++ b/frontend/tests/react-client-lifecycle-test-support.ts @@ -10,9 +10,22 @@ class HostElement { readonly namespaceURI = "http://www.w3.org/1999/xhtml"; readonly childNodes: HostElement[] = []; readonly style = {}; + open = false; + get disabled() { return this.hasAttribute("disabled"); } + isConnected = true; + getAttribute(name: string) { return this.attributes.get(name) ?? null; } + hasAttribute(name: string) { return this.attributes.has(name); } + focus() { this.ownerDocument.activeElement = this; } + showModal() { this.open = true; } + close() { this.open = false; } + contains(node: HostElement | null): boolean { return node === this || this.childNodes.some(child => child.contains(node)); } + get text(): string { return this.textContent + this.childNodes.map(child => child.text).join(""); } + get props(): Record { return (this as unknown as Record>)[Object.keys(this).find(key => key.startsWith("__reactProps$")) ?? ""] ?? {}; } readonly attributes = new Map(); parentNode: HostElement | null = null; textContent = ""; + get nodeValue() { return this.textContent; } + set nodeValue(value: string) { this.textContent = value; } constructor(readonly tagName: string, readonly ownerDocument: HostDocument) {} get nodeName() { return this.tagName; } get firstChild() { return this.childNodes[0] ?? null; } @@ -39,7 +52,9 @@ class HostElement { class HostDocument { readonly nodeType = 9; - readonly activeElement = null; + activeElement: HostElement | null = null; + createTextNode(text: string) { const node = new HostElement("#text", this); node.textContent = text; return node; } + getElementById() { return null; } defaultView: unknown; addEventListener() {} removeEventListener() {} @@ -54,6 +69,9 @@ export function createClientLifecycleHarness() { const window = { document, innerWidth: 1440, + setTimeout, + clearTimeout, + getComputedStyle: () => ({ direction: "ltr" }), HTMLElement: HostElement, HTMLIFrameElement: class {}, addEventListener() {}, @@ -66,7 +84,7 @@ export function createClientLifecycleHarness() { cancelAnimationFrame(id: number) { clearTimeout(frames.get(id)); frames.delete(id); }, }; document.defaultView = window; - const globals = { window, document, IS_REACT_ACT_ENVIRONMENT: true }; + const globals = { window, self: window, document, HTMLElement: HostElement, Element: HostElement, IS_REACT_ACT_ENVIRONMENT: true }; const originals = Object.fromEntries(Object.keys(globals).map((key) => [ key, Object.getOwnPropertyDescriptor(globalThis, key), ])); @@ -74,11 +92,23 @@ export function createClientLifecycleHarness() { Object.defineProperty(globalThis, key, { value, writable: true, configurable: true }); } const errors: unknown[] = []; - const root = createRoot(document.createElement("div") as unknown as HTMLElement, { + const container = document.createElement("div"); + const root = createRoot(container as unknown as HTMLElement, { onUncaughtError: (error) => { errors.push(error); }, }); + function elements(node = container): HostElement[] { return [node, ...node.childNodes.flatMap(child => elements(child))]; } return { errors, + elements, + container, + // Invoke the mounted host's current React handler inside act. This exercises + // real component state/effects, not browser bubbling, native dialog or layout. + async event(node: HostElement, name = "onClick", extra: Record = {}) { + await act(async () => { + const handler = node.props[name] as ((event: unknown) => void) | undefined; + handler?.({ target: node, currentTarget: node, preventDefault() {}, stopPropagation() {}, nativeEvent: { preventDefault() {} }, ...extra }); + }); + }, async render(node: ReactNode) { await act(async () => { root.render(node); }); }, async update(action: () => void) { await act(async () => { action(); }); }, async idle() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); }, diff --git a/frontend/tests/report-export-blocks.test.ts b/frontend/tests/report-export-blocks.test.ts new file mode 100644 index 00000000..092570ca --- /dev/null +++ b/frontend/tests/report-export-blocks.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { buildReportExportBlocks, chartBlockText, exportFilename, factTableToMarkdown, joinExportBlocks, presetSelection } from "../src/lib/report-export-blocks"; +import { ordinaryReportDownloadMarkdown } from "../src/lib/personal-report-longform-download"; +import { buildLongformOutline } from "../src/lib/personal-report-longform-outline"; +import { ordinaryOutputLeaks, projectOrdinaryReportMarkdown } from "../src/lib/report-public-projection"; +import { assembleReportFactTables } from "../src/lib/report-fact-tables"; +import { assembleLongformCharts } from "../src/lib/personal-report-generation"; +import { parseReportChartBlock, toNorthIndianChart } from "../src/lib/report-chart-block"; + +const fixture = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-reader.json", import.meta.url), "utf8")); +const engine = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8")); +const factTables = assembleReportFactTables(engine); +const blocks = buildReportExportBlocks({ markdown: fixture.markdown, factTables }); +const body = blocks.filter(block => block.group === "body"); + +test("export golden uses the same outline order plus 22 charts and eight fact groups", () => { + const outline = buildLongformOutline(projectOrdinaryReportMarkdown(fixture.markdown)); + assert.deepEqual(body.map(block => block.id), [...(outline.sourcePreamble ? ["body:lead"] : []), ...outline.sections.map(section => `body:${section.id}`)]); + assert.equal(blocks.filter(block => block.group === "chart").length, 22); + assert.equal(blocks.filter(block => block.group === "table").length, 8); + assert.equal(blocks.length, outline.sections.length + (outline.sourcePreamble ? 1 : 0) + 30); +}); +test("every golden export block has safe SVG but no chart fence or ordinary output leak", () => { + for (const block of blocks) { + assert.doesNotMatch(block.text, /```jyotish-chart| { + const projected = projectOrdinaryReportMarkdown(fixture.markdown); + const svgs = [...projected.matchAll(/]*>[\s\S]*?<\/svg>/g)].map(match => match[0]); + const chartBlocks = blocks.filter(block => block.group === "chart"); + assert.equal(svgs.length, 22); + chartBlocks.forEach((block, index) => assert.ok(block.text.endsWith(svgs[index]), block.id)); + assert.equal(new Set(chartBlocks.map(block => block.id)).size, 22); +}); +test("fence-only and structured chart fallback reuse safe offline SVG without server rendering", () => { + const fence = /```jyotish-chart\n([\s\S]*?)```/.exec(fixture.markdown)!; + const fromFence = buildReportExportBlocks({ markdown: fence[0] }).filter(block => block.group === "chart"); + const structured = buildReportExportBlocks({ markdown: "", charts: assembleLongformCharts(engine, "ev-cover-longform").slice(0, 1) }).filter(block => block.group === "chart"); + for (const block of [...fromFence, ...structured]) { + assert.match(block.text, / { + for (const block of blocks) assert.equal(block.chars, Array.from(block.text).length, block.id); +}); +test("full golden body is byte identical to the retired ordinary export", () => { + assert.equal(joinExportBlocks(body), ordinaryReportDownloadMarkdown(fixture.markdown)); +}); +test("all-group export adds charts and tables after unchanged ordinary body", () => { + assert.ok(joinExportBlocks(blocks).startsWith(ordinaryReportDownloadMarkdown(fixture.markdown) + "\n\n####")); + assert.notEqual(joinExportBlocks(blocks), joinExportBlocks(body)); +}); +test("presets replace with exactly one group each", () => { + for (const [preset, group] of [["common", "body"], ["charts", "chart"], ["tables", "table"]] as const) { + assert.deepEqual([...presetSelection(preset, blocks)], blocks.filter(block => block.group === group).map(block => block.id)); + } +}); +test("filenames reuse old extensionless stem and add excerpt suffix only when partial", () => { + assert.equal(exportFilename("2026-09-24T12:00:00Z", true), "个人报告-2026-09-24"); + assert.equal(exportFilename("2026-09-24", false), "个人报告-2026-09-24-节选"); +}); +test("chart text matches all real golden houses and their occupants", () => { + const match = /```jyotish-chart\n([\s\S]*?)```/.exec(fixture.markdown)!; + const chart = parseReportChartBlock(match[1])!; + const text = chartBlockText(chart); + for (const house of toNorthIndianChart(chart).houses) { + assert.ok(text.includes(`第 ${house.houseNumber} 宫 · ${house.sign}:${house.occupants.join("、") || "—"}`)); + } +}); +test("fact group exports every visible subtable with original precision and no source paths", () => { + for (const table of factTables) { + const text = factTableToMarkdown(table); + for (const grid of [table, ...(table.subtables ?? [])]) { + assert.ok(text.includes(grid.title)); + if (grid.rows.length) assert.ok(text.includes(`| ${grid.columns.join(" | ")} |`)); + assert.ok(!text.includes(grid.sourcePath)); + for (const row of grid.rows) for (const cell of row.cells) if (typeof cell === "string") assert.ok(text.includes(cell.replace(/\|/g, "\\|"))); + } + } +}); +test("lossless outline slices keep D5 for lead, H3 summary, eager sections, CRLF and trailing whitespace", () => { + const chartFence = /```jyotish-chart\n[\s\S]*?```/.exec(fixture.markdown)![0]; + const variants = [ + "plain prose with no H2\n ", + "# 标题\n\n开头\n\n## 事业\n\n正文\n", + "# 标题\n\n## 事业\n\n之前\n\n### 摘要\n\n摘要正文\n\n### 建议\n\n之后\n\n## 摘要\n\n整章\n", + "## 摘要\n\n摘要正文\n\n## 事业\n\n末尾 \n\n", + `# 标题\n\n## 盘面\n\n#### D1\n\n${chartFence}\n\n## 事业\n\n正文\n`, + "## 成品阅读导航\n\n原文\n\n## 质量验收矩阵\n\n内部\n\n## 事业\n\n正文", + "\n\n", "", + ]; + for (const input of variants.flatMap(text => [text, text.replace(/\n/g, "\r\n")])) { + const selected = buildReportExportBlocks({ markdown: input }).filter(block => block.group === "body"); + assert.equal(joinExportBlocks(selected), ordinaryReportDownloadMarkdown(input)); + } +}); diff --git a/frontend/tests/report-export-drawer.test.tsx b/frontend/tests/report-export-drawer.test.tsx new file mode 100644 index 00000000..e731e382 --- /dev/null +++ b/frontend/tests/report-export-drawer.test.tsx @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React from "react"; +import { toast } from "sonner"; +import { ReportExportDrawer } from "../src/components/personal-report/report-export-drawer"; +import { ReportActions } from "../src/components/personal-report/report-actions"; +import { buildReportExportBlocks, exportFilename, joinExportBlocks } from "../src/lib/report-export-blocks"; +import { assembleReportFactTables } from "../src/lib/report-fact-tables"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support"; + +Object.assign(globalThis, { React }); +const reader = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-reader.json", import.meta.url), "utf8")); +const engine = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8")); +const blocks = buildReportExportBlocks({ markdown: reader.markdown, factTables: assembleReportFactTables(engine) }); +const source = readFileSync(new URL("../src/components/personal-report/report-export-drawer.tsx", import.meta.url), "utf8"); +const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + +type Harness = ReturnType; +const button = (h: Harness, text: string) => { const node = h.elements().find(node => node.tagName === "BUTTON" && node.text === text); assert.ok(node, text); return node; }; +const checks = (h: Harness) => h.elements().filter(node => node.getAttribute("role") === "checkbox"); + +test("drawer mounts all golden blocks, presets replace selection and manual choices clear highlight", async () => { + const h = createClientLifecycleHarness(); + try { + await h.render( {}} />); + assert.deepEqual(h.errors, []); + assert.ok(h.elements().find(node => node.tagName === "DIALOG")?.open); + assert.equal(checks(h).length, blocks.length); + assert.equal(checks(h).filter(node => node.getAttribute("aria-checked") === "true").length, blocks.filter(block => block.group === "body").length); + await h.event(button(h, "全部分盘")); + assert.equal(checks(h).filter(node => node.getAttribute("aria-checked") === "true").length, blocks.filter(block => block.group === "chart").length); + await h.event(button(h, "仅核对表")); + assert.equal(checks(h).filter(node => node.getAttribute("aria-checked") === "true").length, 8); + await h.event(checks(h)[0]); + assert.equal(button(h, "仅核对表").getAttribute("aria-pressed"), "false"); + await h.event(button(h, "全选")); + await h.event(button(h, "取消全选")); + assert.equal(button(h, "导出选中 (0)").disabled, true); + } finally { await h.close(); } +}); + +test("drawer download uses the existing Blob downloader exactly once and only then closes", async () => { + const h = createClientLifecycleHarness(); + const oldCreate = URL.createObjectURL; + const oldRevoke = URL.revokeObjectURL; + let blob: Blob | undefined; + let clicked = 0; + let closed = 0; + const successBefore = toast.success; + const successes: string[] = []; + toast.success = message => { assert.equal(closed, 1); successes.push(String(message)); return 1; }; + let filename = ""; + const document = globalThis.document; + const originalCreate = document.createElement.bind(document); + URL.createObjectURL = value => { blob = value as Blob; return "blob:test"; }; + URL.revokeObjectURL = () => {}; + Object.assign(document, { body: { append() {} } }); + document.createElement = ((tag: string) => { + if (tag !== "a") return originalCreate(tag); + return { set download(value: string) { filename = value; }, click() { clicked += 1; }, remove() {} } as unknown as HTMLAnchorElement; + }) as typeof document.createElement; + try { + await h.render( { closed += 1; }} />); + await h.event(button(h, "仅核对表")); + await h.event(button(h, "导出选中 (8)")); + assert.equal(clicked, 1); + assert.equal(closed, 1); + assert.deepEqual(successes, ["已导出 8 块"]); + assert.equal(filename, exportFilename("2026-09-24", false) + ".md"); + assert.equal(await blob?.text(), joinExportBlocks(blocks.filter(block => block.group === "table"))); + } finally { toast.success = successBefore; URL.createObjectURL = oldCreate; URL.revokeObjectURL = oldRevoke; await h.close(); } +}); + +test("download failure and WeChat restrictions leave drawer open without a success close", async () => { + const h = createClientLifecycleHarness(); + const oldCreate = URL.createObjectURL; + const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + const successBefore = toast.success; + let successes = 0; + toast.success = () => { successes += 1; return 1; }; + let closed = 0; + try { + await h.render( { closed += 1; }} />); + URL.createObjectURL = () => { throw new Error("下载失败"); }; + await h.event(button(h, `导出选中 (${blocks.filter(block => block.group === "body").length})`)); + assert.ok(h.container.text.includes("下载失败")); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { userAgent: "MicroMessenger" } }); + await h.event(button(h, `导出选中 (${blocks.filter(block => block.group === "body").length})`)); + assert.ok(h.container.text.includes("系统浏览器")); + assert.equal(closed, 0); + assert.equal(successes, 0); + assert.ok(h.elements().find(node => node.tagName === "DIALOG")?.open); + } finally { + toast.success = successBefore; + URL.createObjectURL = oldCreate; + if (navigatorDescriptor) Object.defineProperty(globalThis, "navigator", navigatorDescriptor); else Reflect.deleteProperty(globalThis, "navigator"); + await h.close(); + } +}); + +test("ready actions consume export hash once and keep print and raw appendix", 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(); + assert.deepEqual(h.errors, []); + assert.equal(replaces, 1); + 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(); } +}); + +test("mounted drawer handles Escape and backdrop but not inner clicks and restores focus", async () => { + const h = createClientLifecycleHarness(); let closes = 0; + const previous = document.createElement("button"); previous.focus(); + try { + await h.render( { closes += 1; }} />); + const dialog = h.elements().find(node => node.tagName === "DIALOG")!; + await h.event(dialog, "onClick", { target: button(h, "常用") }); assert.equal(closes, 0); + await h.event(dialog, "onCancel"); assert.equal(closes, 1); + await h.event(dialog); assert.equal(closes, 2); + await h.render(null); assert.equal(document.activeElement, previous); + } finally { await h.close(); } +}); + +test("drawer contract uses native dialog, Escape/backdrop close and only allowed responsive chrome", () => { + assert.match(source, /; +const buttons = (h: Harness, text: string) => h.elements().filter(node => node.tagName === "BUTTON" && node.text === text); +function deferred() { let resolve!: (value: T) => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } +// Explicitly fictional list metadata, not an engine-contract fixture. +const row: ReportListItem = { id: "fictional-report", reportType: "personal_full", presentationMode: "default", depth: "standard", themes: ["career"], status: "ready", failureCode: null, createdAt: "2026-09-24T12:00:00Z", completedAt: null }; + +test("report metadata localizes depth and themes while preserving unknown identifiers", () => { + assert.equal(rowMeta(row), "2026年9月24日 · 标准深度 · 事业"); + assert.equal(rowMeta({ ...row, depth: "future-depth", themes: ["future-theme"], createdAt: "invalid" }), "时间未知 · future-depth · future-theme"); + assert.match(rowMeta({ ...row, themes: [] }), /综合主题$/); + assert.match(rowMeta(readReports({ reports: [{ ...row, depth: "future-depth" }] })[0]), /future-depth/); +}); + +test("row delete is confirmed inline, cancelled without request and retains failure for retry", async () => { + const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch; + let calls = 0; let removed = 0; + globalThis.fetch = async () => { calls += 1; return new Response(null, { status: 500 }); }; + try { + await h.render( { removed += 1; }} />); + await h.event(buttons(h, "删除")[0]); + assert.match(h.container.text, /确定删除这份报告?/); + await h.event(buttons(h, "取消")[0]); + assert.equal(calls, 0); + assert.equal(document.activeElement, h.elements().find(node => node.tagName === "SUMMARY")); + await h.event(buttons(h, "删除")[0]); + await h.event(buttons(h, "删除")[1]); + await h.idle(); + assert.match(h.container.text, /报告没能删除,请稍后重试。/); + assert.equal(removed, 0); assert.equal(calls, 1); + globalThis.fetch = async () => { throw new Error("offline"); }; + await h.event(buttons(h, "删除")[1]); await h.idle(); + assert.match(h.container.text, /报告没能删除,请稍后重试。/); + assert.equal(removed, 0); + } finally { globalThis.fetch = fetchBefore; await h.close(); } +}); + +test("row delete sends one same-origin DELETE despite repeated confirmation clicks", async () => { + const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch; + const response = deferred(); let calls = 0; const removed: string[] = []; + globalThis.fetch = async (url, options) => { assert.equal(url, `/api/reports/${row.id}`); assert.equal(options?.method, "DELETE"); assert.equal(options?.credentials, "same-origin"); calls += 1; return response.promise; }; + try { + await h.render( removed.push(id)} />); + await h.event(buttons(h, "删除")[0]); + const confirm = buttons(h, "删除")[1]; + await h.event(confirm); await h.event(confirm); + assert.equal(calls, 1); assert.match(h.container.text, /删除中/); + response.resolve(new Response(null, { status: 204 })); await h.idle(); + assert.deepEqual(removed, [row.id]); + } finally { globalThis.fetch = fetchBefore; await h.close(); } +}); + +test("report cache invalidation rejects stale GET writes and stale finally keeps newer flight", async () => { + const fetchBefore = globalThis.fetch; const first = deferred(); const second = deferred(); let calls = 0; + invalidateReportsPage(); + globalThis.fetch = () => (++calls === 1 ? first.promise : second.promise); + try { + const stale = refreshReportsPage(); invalidateReportsPage(); const fresh = refreshReportsPage(); + first.resolve(Response.json({ reports: [row] })); await stale; + assert.equal(peekReportsPage(), null); + assert.equal(refreshReportsPage(), fresh); assert.equal(calls, 2); + second.resolve(Response.json({ reports: [] })); await fresh; + assert.deepEqual(peekReportsPage(), { kind: "ready", reports: [] }); + } finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); } +}); + +test("mounted report center removes deleted row and an older GET cannot resurrect it", async () => { + const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch; + const stale = deferred(); let gets = 0; + writeReportsPage({ kind: "ready", reports: [row] }); + globalThis.fetch = async (url, options) => { + if (options?.method === "DELETE") return new Response(null, { status: 204 }); + if (url === "/api/reports") { gets += 1; return stale.promise; } + return Response.json({}, { status: 401 }); + }; + try { + const router = { bfcacheId: "test", push() {}, replace() {}, refresh() {}, prefetch() {}, back() {}, forward() {} } as AppRouterInstance; + await h.render(); await h.idle(); + assert.equal(gets, 1); assert.match(h.container.text, /标准深度/); + await h.event(buttons(h, "删除")[0]); await h.event(buttons(h, "删除")[1]); await h.idle(); + assert.doesNotMatch(h.container.text, /标准深度/); assert.equal(peekReportsPage(), null); + stale.resolve(Response.json({ reports: [row] })); await h.idle(); + assert.doesNotMatch(h.container.text, /标准深度/); assert.equal(peekReportsPage(), null); + assert.deepEqual(h.errors, []); + } finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); await h.close(); } +}); diff --git a/frontend/tests/secondary-page-entry.test.ts b/frontend/tests/secondary-page-entry.test.ts index 9ac5d5db..4b587699 100644 --- a/frontend/tests/secondary-page-entry.test.ts +++ b/frontend/tests/secondary-page-entry.test.ts @@ -67,7 +67,9 @@ test("the three entry pages import the shared SecondaryPageShell", () => { assert.doesNotMatch(chartView, /