Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc2283693d | ||
|
|
d72adb5754 | ||
|
|
dd5ae8d032 | ||
|
|
9049b5e997 | ||
|
|
052a4bf70c | ||
|
|
23f1b31787 | ||
|
|
01ec3d229b | ||
|
|
3632b0eabd | ||
|
|
dfa9acaf05 | ||
|
|
b6c43f3787 | ||
|
|
8743f61512 | ||
|
|
c1aa72fcbf | ||
|
|
1dd6203883 | ||
|
|
ac3dccbac8 | ||
|
|
00c2b4d498 |
@@ -92,6 +92,8 @@
|
||||
|
||||
这份文件是产品与代码 Bug 的长期历史入口;`docs/research/pre_work_error_ledger.md` 负责基础设施、外部引擎、碎片目录与预检风险,两者不得互相替代。
|
||||
|
||||
**修在根因上(2026-10-23 产品定)**:Fix a bug at its cause, reasoning from first principles and established best practice. Do not patch over the symptom.
|
||||
|
||||
1. 修复前必须用报错原文、接口路径、状态码、模块名和用户操作搜索历史记录,优先检查相同模块的根因与防复发措施。
|
||||
2. 若历史问题复发,必须关联原 `BUG-NNN`,说明旧测试、约束或发布门禁为何未拦住;不得把复发伪装成无关的新问题。
|
||||
3. Bug 修复必须在同一变更中更新 `docs/BUG_HISTORY.md`:补充现有记录或新增连续编号,并记录状态、现象、触发条件、根因、修复、验证、防复发、关联记录和修复版本。
|
||||
@@ -110,13 +112,17 @@
|
||||
## 7. 前端红线
|
||||
|
||||
1. `./node_modules/.bin/tsc --noEmit` 通过;`npm run lint` **0 error**(react-hooks 编译器规则会拦 effect 内同步 setState、render 写 ref 等)。
|
||||
2. `next build` 后 `/` 保持 `○ Static`;首屏 gzip 变化在 ±2% 内,超出要在进度记录里给出原因。
|
||||
2. `next build` 后 `/` 保持 `○ Static`;首屏 gzip 变化在 ±2% 内,超出要在进度记录里给出原因。动效、拖拽、可拖宽布局这类库(Motion、dnd-kit、react-resizable-panels)一律按需加载,不进首屏;±2% 不因引入它们放宽(2026-10-23 产品定)。
|
||||
3. 测试总数不得低于开工时 `origin/staging` 的实测;改任何既有断言必须写"原值 / 新值 / 原因"三栏说明,不得静默弱化。
|
||||
4. 合同测试的 fixture 必须来自真实引擎响应(golden),不得手造形状。
|
||||
5. 改 UI 的提交同时更新 `frontend/DESIGN.md`;新文案对照 `frontend/docs/VOICE.md`。
|
||||
6. 不改数据库结构的轮次不得顺带动迁移;动表的轮次必须真跑 `npm run test:db`。staging 迁移在部署之前自动应用,因此必须对当前已部署的那一版代码向后兼容:加列、加表、加索引、加触发器、加函数可以同轮;删列、删表、重命名、收紧 `NOT NULL` / `CHECK`、改类型必须拆成两轮——先加新的并部署代码,再删旧的。自托管 PostgreSQL 兼容层的 stub 测不到真实查询语法,新用到的查询写法必须 `test:db` 真查或部署后 smoke(BUG-990:未实现的 `not(col,"is",null)` 让归档列表 500 了两周)。
|
||||
7. 不得顺手升级依赖、不得顺手修不在任务书里的 warning;发现了写进 `BLOCKED.md` 或进度记录。
|
||||
8. 移动、删除或改写组件与语句前,先在 `frontend/tests/` 和 `tests/` 里 grep 该文件路径、要删的类名与语句,找出按源码文本断言的合同测试,同一轮跟着改(按第 3 条写三栏)。漏改会让门禁红而业务代码是对的,已发生多次:BUG-933 / 934 / 939 / 992 / 1014。
|
||||
9. Avoid unnecessary secondary labels and descriptions. Let the layout do the explaining.(2026-10-23 产品定)
|
||||
10. Avoid unnecessary supporting text, descriptive copy, and redundant UI microcopy.(2026-10-23 产品定)
|
||||
11. 删掉重复解释布局的文案,但保留必要的表单标签和控件名称。比如搜索框旁边已有「搜索」按钮,视觉上可以省掉重复标签,代码里的可访问名称却不能一起删。界面清爽和读屏能用不冲突。(2026-10-23 产品定)
|
||||
12. **已有依赖能正确表达的组件和行为,严禁自行设计实现**(2026-10-23 产品定)。指定依赖:组件用 shadcn/ui(`base-nova`,见 `frontend/components.json`,缺的组件用 shadcn CLI 加,不手抄改写);图标只用 Lucide;动效只用 Motion;可拖宽 / 可收起的分栏用 react-resizable-panels(经 shadcn `Resizable`);拖拽排序用 dnd-kit;跨组件状态用 Zustand。GSAP 不再使用。新增手写的按钮、弹窗、菜单、下拉、页签、抽屉、提示框由 lint 拦截;迁移完成前存量文件列在白名单里,白名单只许减少。占星专有的画法(星盘、星图、报告排版)不在此列。
|
||||
|
||||
## 8. 隐私与安全
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-23 — 星盘页本命和月亮的行星表重新占满整行
|
||||
|
||||
- 宽屏下,本命和月亮的行星表回到参数卡旁边那一整行,宿主、pada、状态不用横拖就能看见。分盘、Bhava、行运仍是整行。
|
||||
- Skill 版本不变(BUG-1421)。
|
||||
|
||||
## 2026-10-22 — 星盘大运右栏可以切到 Yogini
|
||||
|
||||
- 大运页左栏仍是 Vimshottari。右栏默认 Chara,可以切到 Yogini。点开当前大运能看到小运。Yogini 的小运下面写「小运未经外部软件核对。」
|
||||
|
||||
+64
-18038
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
# PROGRESS · 星盘页行星表整行(2026-10-23)
|
||||
|
||||
Issue #102。BUG-1421。
|
||||
|
||||
## 基线
|
||||
|
||||
- 开工时 `origin/staging` = `d72adb5754eab4d66ac3a47c4edcb12704e29815`(任务书写的 `9049b5e99` 已过期)。
|
||||
- 本文件最大编号是 BUG-1420,本单用 BUG-1421。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- 星盘页网格的第三格改成固定的整行容器。本命、月亮、分盘、Bhava、行运的表都放在里面。
|
||||
- 桌面跨栏只认这个容器。表格自己的类名不再负责跨栏。
|
||||
- 容器可以缩到比内容窄,窄屏时表仍在自己的框里横滑。
|
||||
- 表格的字、列、列宽没改。符号说明的气泡还在原来的位置。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
|
||||
| lint | `frontend` 下 `npm run lint` | 退出码 0。0 error,122 条既有 warning,本轮不改 |
|
||||
| 定向测试 | `node node_modules/tsx/dist/cli.mjs --test` 跑新结构测试,加上 `chart-page-view.test.tsx`、`chart-varga-table.test.tsx`、`chart-page-mobile-width-20260929.test.ts` | 49 条通过,0 失败 |
|
||||
| 构建 | `frontend` 下 `npm run build` | 退出码 0。路由表 `/` 为 `○ Static`。预渲染 `index.html` 引用 33 个 JS、3 个 CSS,gzip-9 合计 646,234 字节(JS 575,154,CSS 71,080)。没有用改动前的代码再构建一次,所以不写增减百分比 |
|
||||
|
||||
未跑前端全量 `npm test`(验收时跑)。没有登录,本机浏览器截图没做,留给 `docs/testing/chart-table-span-20261023.md`。
|
||||
|
||||
## 偏离
|
||||
|
||||
- 无。编号说明:本分支的 BUG-1421 与尚未合并的 #95 分支各自占用了同一个号。若 #95 先进入 staging,验收时需要给本条改号。
|
||||
@@ -0,0 +1,111 @@
|
||||
# PROGRESS:#91 A 段第 2 次打回(F1~F4)
|
||||
|
||||
- 分支:`codex/full-data-parity-1-fix2-20261022`,起点 `92b4e220`(`codex/full-data-parity-1-20261021` 顶端,已含最新 `origin/staging`)。
|
||||
- 执行:Claude 子代理,Linux,系统 `python3`(无 `.venv`),PyJHora 已屏蔽(虚构盘助手 `block_jhora()` / 临时脚本 `sys.modules["jhora"] = None`)。只用虚构盘。
|
||||
- 状态:本地完成,未推送。前端未改。
|
||||
- BUG:新增 BUG-1418(F3)、BUG-1419(F1 + F4)。开工时 `origin/staging` 最大号 1415,本分支 1417,其他 `origin/codex/*` 分支都不超过 1415。
|
||||
|
||||
## 结论
|
||||
|
||||
| 项 | 做了什么 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| F1 | 11 节改读资料包里的嵌套记录,印多列表格。连带修了 6 节把小写键名或枚举印成格子的问题(Argala、出生历法、本命 Tithi、Tajika 候选、Goel 尊贵、瑜伽类别)。没有新的白名单节 | 0、1、9、3、17、42、77、150 号盘中英两版逐节检查:白名单 4 节外,每节都有非第一列真实值,整格键名为 0,英文无汉字 |
|
||||
| F2 | 「每节有真实值」只认非第一列;一列表永远不过;只有「大运与小运交界检查」可以是一句话,其余节必须有表。新加「任何格子不得整格是键名」 | 拿修复前代码的 0 号盘输出套新规则:10 节不过,40 种键名格;第 11 节「关系口径」当时是一句固定话,由「必须有表」一条拦住 |
|
||||
| F3 | 中文组合句改由中文源给出(见下)。没有恢复回译 | 0~19 号盘与 staging 逐格比:2164 行组合格,staging 有字而本分支「未判定」= 0(修前 174);英文版汉字 0 |
|
||||
| F4 | 六章 `##`,各节 `###`,Narayana 两种口径 `####`。前端未改 | 0 号盘:H1 只剩标题 1 个;`##` 中文 44、英文 44;英文 staging 38 + 6;中文 staging 37 + 7,多出的 1 个是本分支中文附录新增的「宫主逐宫解释」(与英文版同节,第 1 轮已有),不是六章 |
|
||||
|
||||
## F1 各节数据来源
|
||||
|
||||
路径都在 `build_professional_report_reference_packet` 的输出里(顶层与 `worksheets` 下同名,渲染先读顶层)。
|
||||
|
||||
| 节 | 资料包位置 | 印出的列 |
|
||||
| --- | --- | --- |
|
||||
| Jaimini 计算细节 | `advanced_systems.jaimini.chara_karaka_7.karaka_table`、`chara_karaka_8.karaka_table_8` | 卡拉卡、七曜口径行星 / 度数、八曜口径行星 / 排序度数(`effective_degree_in_sign`)、罗睺倒数(`rahu_reverse_applied`)。上游 `jaimini_calculation_detail_markdown` 同样印七曜表 |
|
||||
| KP 计算口径 | `advanced_systems.kp.kp_profile`(`ayanamsa_display`、`requested_report_ayanamsa`、`house_system`、`cusp_source`、`timing_policy`、`status`)、`kp.house_basis`、`kp.house_occupancy_basis`、`kp.calculation_profile.node_mode`、`kp.ruling_planets` | 项目 / 内容;另一表印七个 ruling planets |
|
||||
| Tara 与 Chandra 矩阵 | `advanced_systems.nakshatra_adv.tara_chandra_combined.<行星>` | 星宿、Tara 序号(+1)、Tara(中文 `tara_cn`,英文取 `tara_name` 括号前)、Tara 吉凶、Chandra 位(`rashi_from_moon` +1)、Chandra、Chandra 吉凶、合并结果(`combined_score` 映射)。上游 `nakshatra_combined_markdown` 同列 |
|
||||
| 上升 Vargottamamsa | `strengths_and_scores.lagna_vargottamamsa`(`d1_sign`、`d9_sign`、`is_lagna_vargottamamsa`) | D1 上升星座、D9 上升星座、是否 Vargottamamsa |
|
||||
| 上升主与七宫主行运 | `timing_and_predictive_systems.transit_ll7l`(`lagna_lord`、`seventh_lord`、`reference_date`、`p5` / `p8` / `parivartana` 的 `hit` 与 `details`) | 参考日、上升主、七宫主;检查项 / 是否成立 / 明细。明细句按引擎固定格式解析成中英文,解析不了的不印 |
|
||||
| 八分法 Yoga Pinda | `strengths_and_scores.ashtakavarga.yoga_pinda.planets`、`planet_weights`、`summary` | 星座、所在星座点数、Rashi / Graha / Yoga Pinda、行星权重;最强 / 最弱行星、合计、平均。上游 `ashtakavarga_yoga_pinda_markdown` 同源 |
|
||||
| 八分法加减 | `strengths_and_scores.ashtakavarga.sodhita`(`raw_bav`、`trikona_bav`、`sodhita_bav`、`ekadhipatya_audit`)、`ashtakavarga.shodhya_pinda` | 原始 BAV 合计、三方净化后、单宫主净化后、Rashi / Graha / Shodhya Pinda;单宫主净化改动的星座对(前 / 后) |
|
||||
| 关系口径 | `strengths_and_scores.planetary_friendship`(`method`、`source_planets`、`rows`、`status`) | 保留原说明句;表印口径、计入星体、是否计入交点、行数、状态 |
|
||||
| 多参考点行运 | `timing_and_predictive_systems.transit_multi_reference`(`references`、`target_date`、`transit_analysis.<行星>.house_from_ref`) | 参考点 / 星座 / 优先级;行星、星座、度数、从四个参考点起的宫 |
|
||||
| 木土双重行运 PAC | `timing_and_predictive_systems.double_transit_pac`(`d1` / `d9` / `cl` 的 jupiter / saturn 目标、`double_transit`、`stats`、`event_house`) | 参考日、事件宫、D9 上升、月亮上升、命中数;层 / 行星 / 目标 / 相位。目标名按引擎固定格式解析(BUG-1060 / 1061 的命名),解析不了的不印 |
|
||||
| 行星聚集 | `timing_and_predictive_systems.planetary_congregation`(`natal.lagna`、`natal.house_7`、`transit.<宫>`、`hit`) | 参考日、是否有聚集;本命上升宫、本命第七宫、各行运宫的行星 |
|
||||
|
||||
连带修的 6 节:Argala 读 `advanced_systems.argala.houses.house_N`(Argala / Virodhargala 数、有效 Argala 宫、`net_result`);出生历法读 `birth_and_parameters.panchanga.raw`(Tithi、星宿、Yoga、Karana、星期、星期主星、Hora 主星及吉凶);本命 Tithi 读同一处的 `tithi`;Tajika 候选补出名称、相位、剩余度数并映射入相 / 离相;Goel 尊贵的 friend / neutral / enemy 补映射;瑜伽类别改用中英类别表。
|
||||
|
||||
**Tithi 主星**:`tithi_lord` 只出现在 `raw_module_usage_map`,公开资料包没有。该节只列出生 Tithi(名称、半月、序号、月日黄经差),并在表下写「Tithi 主星模块没有进公开报告资料包」。没有在渲染层另推主星:报告只印资料包里有的东西,是否把 `tithi_lord` 接进资料包留给 B 段 / Claude 定。
|
||||
|
||||
`_PACKET_GAPS` 仍是原来 4 节(Navamsha 小运时长、五鸟活动、Rashi Tulya Navamsa、当前多系统收敛),没有新增。
|
||||
|
||||
顺带看到、未改:`cmd_planetary_congregation` 在 `event_house = 7` 时把 `house_7` 与 `house_{event_house}` 指到同一个列表,同一颗星会被追加两次(17 号盘「月亮、月亮、水星……」)。渲染层去重显示,引擎未动,留给 Claude 判断是否另立单。
|
||||
|
||||
## F2 测试改动(三栏)
|
||||
|
||||
| 断言 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `test_every_section_has_a_real_cell_except_the_four_public_gaps` 的 `_section_has_real_cell` | 任一数据格(含第一列标签)不是「未判定」即算真实值;无表时任一行文字即算 | 只看非第一列;排除「未判定 / Not determined / — / - / 空」;无表时只认非标题的句子;另外除「大运与小运交界检查」外每节必须有表 | 第一列是标签,键名也落在第一列,旧判定让一列表和键名都过了(F1 的 11 节) |
|
||||
| `test_parity_chapters_go_through_local_conventions` 的英文探针 | 源字段 `divorce`,期望 `relationship strain` | 源字段 `Divorce risk`,期望 `relationship strain risk` | 单个小写词现在按内部键名处理、不印;探针改成读者会看到的短语,替换逻辑不变 |
|
||||
| 标题定位(`_chapter_text`、`_section_body`、`test_empty_packet…`、`test_three_fictional…`) | 章 `# `、节 `## `、Narayana `### ` | 章 `## `、节 `### `、Narayana `#### `;章名用整行匹配计数 | F4 标题降一级 |
|
||||
|
||||
新增测试:`test_every_section_has_a_real_cell_outside_the_label_column_unit`、`test_no_table_cell_is_a_raw_packet_key`(`^[a-z][a-z0-9]*(_[a-z0-9]+)*$`,0、1、9 号盘中英)、`test_six_chapters_are_reader_chapters_and_sections_sit_below_them`(只有一个 H1;`##` 数 = 去掉六章的同一份报告 + 6,且最后 6 个就是六章;`###` 数 = 69 节)、`test_chinese_yoga_combinations_are_not_blanked`,以及 `tests/test_yoga_combination_chinese.py` 三条。
|
||||
|
||||
`grep` 过 `tests/` 与 `frontend/tests/`:六章标题与层级只有 `tests/test_full_data_parity_chapters.py` 引用。
|
||||
|
||||
## F3 中文组合句来源
|
||||
|
||||
| 来源 | 改法 | 是否影响检出 |
|
||||
| --- | --- | --- |
|
||||
| `yoga_engine._eval_semantic_condition` 的 `note`(166 条) | `success()` 返回 `BiText(中文, 原 note)`;中文在新文件 `references/yoga_semantic_notes_zh.json` | 一对一映射,去重键的相等关系不变;`combination_en` 仍是原 note |
|
||||
| 无 `note` 的条件(`planet_in_house`、`planet_in_lagna`,原来印类型键) | 每种类型一句固定中文,英文孪生仍是类型键 | 同类型仍合并成一条 |
|
||||
| 只写英文 `combo_template` 的自定义规则(`PyJHora aligned source logic`,2 条规则) | 同一张表查中文,英文孪生保留原句 | 无 |
|
||||
| `yogas_doshas.calc_raj_yogas` / `calc_dhana_yogas` | 每行加 `combination_zh`(宫主、落宫、行星中文名),原 `combination` 不改 | 无 |
|
||||
| `appendix_language.chinese_combination` | 先取干净的 `combination_zh` | — |
|
||||
|
||||
没改:哪些瑜伽成立、强度、类别、评分,`references/yoga_rules.json`,`scripts/jyotish_api_server.py`。
|
||||
|
||||
检出不变的核对:0~19 号盘,staging 与本分支各取 `detected_yogas`、`yogas`、王者 / 财富行的(规则、名称、强度、类别、英文组合句)共 2250 行,集合完全相同。两张盘(1、17)里王者 / 财富行先后顺序不同:`calc_raj_yogas` 遍历 `set`(`dual_lords`、`BENEFICS`),字符串 `set` 的顺序随进程的哈希种子变(未单独复测 staging 两次)。没有改。
|
||||
|
||||
比对方法:`git worktree add --detach .worktrees/fix2-91-base origin/staging`,两边各渲染 0~19 号盘中文,按表头含「组合条件」的表逐行比(同名同序)。完成后已删除该工作树。
|
||||
|
||||
| | staging | 修前 | 修后 |
|
||||
| --- | --- | --- | --- |
|
||||
| 组合格总数(0~19 号盘) | 2164 | 2164 | 2164 |
|
||||
| staging 有字、本分支「未判定」 | — | 174 | 0 |
|
||||
| 「组合:未判定」(解释页) | 0 | — | 0 |
|
||||
| 含英文词的组合格(不计 PyJHora、Mandi 等专名) | 650 | — | 24 |
|
||||
|
||||
剩下 24 格是规则中文模板本身带的专名,例如「行星集中在特定宫位(Yupa 拴马柱)」「PyJHora aligned Bheri瑜伽: …」。
|
||||
|
||||
## 体积(0 号虚构盘,字符数)
|
||||
|
||||
| | staging | 修前(`92b4e220`) | 修后 |
|
||||
| --- | --- | --- | --- |
|
||||
| 中文 | 430,566 | 517,991 | 522,047 |
|
||||
| 英文 | — | 993,608 | 1,000,010 |
|
||||
| 中文 `##` 个数 | 37 | 107 | 44 |
|
||||
| 英文 `##` 个数 | 38 | 107 | 44 |
|
||||
|
||||
## 测试
|
||||
|
||||
解释器:系统 `python3`。PyJHora 屏蔽。
|
||||
|
||||
| 命令 | 退出码 | 用时 |
|
||||
| --- | --- | --- |
|
||||
| `python3 -m pytest tests/test_full_data_parity_chapters.py tests/test_full_data_parity_appendix.py tests/test_report_english_sweep_smoke.py tests/test_report_english_edition.py tests/test_report_full_data_fix.py tests/test_python_hygiene_budget.py tests/test_api_server_growth_contract.py tests/test_repo_privacy_markers.py tests/test_yoga_combination_chinese.py -q` | 0(120 项) | 183 秒 |
|
||||
| `python3 -m pytest tests/test_report_english_sweep.py -q`(200 张虚构盘) | 0 | 550 秒 |
|
||||
| 瑜伽、阅读页、解释源、完整数据相关的其余 27 个测试文件(`tests/` 下文件名含 yoga / report_reader / full_data / pl9 / interpretation / professional_report / full_reading_module,去掉上面已跑的) | 0(118 项) | 56 秒 |
|
||||
| `python3 scripts/run_quality_gate.py --profile quick` | 1 | 271 秒 |
|
||||
|
||||
快速门:py_compile、JSON 校验、四个审计脚本、pytest(209.8 秒)都通过;停在 `npm test`,原因是这个工作树没有 `frontend/node_modules`(`tsx` 找不到)。本单没改前端,这一步是环境缺口,不是通过。
|
||||
|
||||
隐私标记测试在本文与 BUG 记录提交后又跑了一次:退出码 0。
|
||||
|
||||
第一轮必跑列表里 `test_report_full_data_fix.py::test_yoga_effect_does_not_assert_lifespan` 红过一次:新瑜伽类别表把 `ayur` 印成 Longevity,撞上 BUG-1210 不把寿命当断语的约束。改成「体质 / Constitution」后通过(提交 `1dd620388`)。
|
||||
|
||||
## 偏离
|
||||
|
||||
- 没有推送,没有改前端、`scripts/jyotish_api_server.py`、`references/yoga_rules.json`、冻结打分文件。
|
||||
- F1 范围比 11 节多了 6 节:新测试「格子不得是键名」会抓到它们,不修就过不了 F2。
|
||||
- 中文报告 `##` 是 staging + 7,不是 + 6;多的「宫主逐宫解释」来自本分支第 1 轮的中文附录,与英文版同节。测试按「同一份报告去掉六章」计数,英文正好 staging + 6。
|
||||
- `references/yoga_semantic_notes_zh.json` 是新文件(任务允许加中文模板 / 字段)。
|
||||
@@ -37,6 +37,11 @@
|
||||
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-rectification-explicit-open-20261023.md` | — | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表 | 待执行(循环) | 分支 `codex/rectification-explicit-open-20261023` |
|
||||
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1421 | 待验收 | 分支 `codex/chart-table-span-20261023` |
|
||||
| `TASK-ui-kit-migration-20261023.md` | — | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | 待领取(循环) | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
|
||||
| `TASK-onboarding-journey-20261022.md` | — | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 起 | 待领取(循环) | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
|
||||
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
|
||||
| `TASK-rectification-narrower-delivery-20261016.md` | `PROGRESS-rectification-narrower-delivery-fix-20261017.md` | **交付区间收窄**:显示改按活着的簇分段。第 1 次打回只补两处显示漏段(取整差 8 分、缺区间整段退回)。对半出题和门槛 8 改 6 仍不做。基线与对照各 924 条,12 格包含率都过红线。BUG-1367 起,本轮原记 BUG-1370,因与婚恋合盘保留号冲突改为 BUG-1392 | 待验收 | `codex/rectification-narrower-delivery-20261016` |
|
||||
| —(子代理直接执行,无任务书) | `PROGRESS-bug-1060-d9-double-transit-20260927.md` | **双重过运 D9 目标错配(BUG-1060)**:D9 第 N 宫目标误用 D9 上升、D9 宫主目标误用 D1 度数,改为标签所写的 D9 星座;D1 / CL 层逐字节不变;数据卡 golden 重采集(一张盘跨层结论 9→10);另记 BUG-1061 跨层数字比对(未修) | 已验收(待部署) | `codex/bug-1060-d9-double-transit-20260927`(未推送) |
|
||||
| —(子代理直接执行,无任务书;产品 2026-09-27 授权) | `PROGRESS-bug-1061-cross-layer-pairing-20260927.md` | **双重过运跨层配对按名字数字比对(BUG-1061)**:改为结构化身份配对(D1 第 N 宫↔D9 第 N 宫;D1 第 N 宫主↔同一颗星的 D9 目标);各层结果与 D1/CL golden 逐字节不变;48 样本跨层条目 31→38;数据卡 golden 重采集(乔布斯 6→9、泰勒 9→10) | 已验收(待部署) | `codex/bug-1061-cross-layer-pairing-20260927`(未推送) |
|
||||
@@ -524,3 +529,4 @@
|
||||
| `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-fix-20261022.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 已合入 staging | `codex/synastry-staging-fix-20261021` |
|
||||
| `TASK-report-english-han-leak-fix-20261022.md` | `PROGRESS-report-english-han-leak-fix-20261022.md` | **#89 第 1 次打回**:改了冻结的校正打分文件 `jaimini.py`;测试的 PyJHora 屏蔽没传到子进程(测的不是生产那份报告,也是慢的原因);缺英文时留空藏住漏洞 | 已合入 staging | `codex/report-english-han-leak-20261021` |
|
||||
| `TASK-full-data-parity-1-fix-20261022.md` | `PROGRESS-full-data-parity-1-fix-20261022.md` | **#91 A 段第 1 次打回**:新六章 69 节里 16 节在三张虚构盘上整节「未判定」,其中 12 节数据在资料包里、是读错了字段;测试抓不到整节未判定;新六章绕过了报告口径替换;中文附录悄悄丢列和瑜伽条目要量出来 | 本地完成,待验收 | `codex/full-data-parity-1-20261021` |
|
||||
| — | `PROGRESS-full-data-parity-1-fix2-20261022.md` | **#91 A 段第 2 次打回**(Claude 口头修复单 F1~F4):11 节只印「未判定」和键名改读真实记录;测试只认非第一列真实值、禁止键名格;中文瑜伽组合句由中文源给出(BUG-1418);六章降为 `##`、节 `###`(BUG-1419) | 本地完成,待验收 | `codex/full-data-parity-1-fix2-20261022` |
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# TASK · 星盘页本命 / 月亮盘的行星表被挤进左栏(issue #102,2026-10-23)
|
||||
|
||||
## 基线
|
||||
|
||||
- `origin/staging`:写本单时为 `9049b5e99`。开工时以当时的 `origin/staging` 为准。
|
||||
- 执行方:Grok(Windows + Docker)。分支 `codex/chart-table-span-20261023`,只推这一个分支。
|
||||
- BUG 编号:开工时运行 `grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1`,从下一个号起(写本单时最大号是 BUG-1420)。
|
||||
|
||||
## 事故实证
|
||||
|
||||
- **现象**:桌面宽度(≥1024px)下,星盘页选「本命」或「月亮」时,行星表只占左栏,和盘一样宽(约 466px)。宿主、pada、状态三列要横向拖动才看得到;右栏在参数卡下面空着。D2–D60、Bhava、行运的表都正常。
|
||||
- **布局规则**:`frontend/src/app/globals.css` 的桌面媒体段(`@media (min-width: 1024px)`,符号 `.chart-page-planet-table-wrap, .chart-page-varga-table-block { grid-column: 1 / -1; }`)把这两个类名排成跨两栏的整行。
|
||||
- **网格结构**:`frontend/src/components/chart-page/chart-vedic-tab.tsx` 的 `VedicLayout` 网格下有三类直接子项:`.chart-page-vedic-stage`(盘)、`.chart-page-params`(参数卡)、`{children}`(表)。
|
||||
- **出问题的地方**:同文件 `NatalTable` 在 #87(`d2edf8b7a`,「上升的状态格改成「/」」)里被外包了一层 `ChartSymbolHelp`,渲染出来是 `<div class="chart-page-symbol-help">`。网格的直接子项变成了这个 div,上面的跨栏规则匹配不上,表就被自动排进了左栏第二行。
|
||||
- **为什么分盘没事**:`ChartTypeBody` 里分盘外面多了一层 `.chart-page-varga-table-block`,仍然命中跨栏规则。本命、月亮两处直接放 `NatalTable`,所以出事。
|
||||
- **违反的设计**:`frontend/DESIGN.md` 写着「桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心」。
|
||||
- **为什么没拦住**:#87 的进度记录(`docs/tasks/PROGRESS-chart-ascendant-slash-20261021.md`)写明没跑全量 `npm test`、没跑 `next build`,也没登录后看页面;而且现有测试没有一条断言 `grid-column`。
|
||||
|
||||
## 根因
|
||||
|
||||
跨栏规则是按「表格外层恰好是某个类名」写的,网格自己并不掌握「第三行是整行」这件事。所以任何人往表外面再包一层,布局就会悄悄坏掉。这一次是 #87 包了一层 `ChartSymbolHelp`,下一次可能是别的。
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 产品 2026-10-22 把本问题提成 issue #102,2026-10-23 要求先规划。
|
||||
- 按 AGENTS.md §5「修在根因上」,**不采用**「在跨栏规则里再补一个 `.chart-page-symbol-help` 类名」这种对症补丁。改为让网格自己掌握结构:`VedicLayout` 用一个固定的整行容器装 `children`,跨栏规则只认这个容器。
|
||||
- 不改表格的列数、列宽,不动 D1 的字节冻结。
|
||||
|
||||
## 硬红线
|
||||
|
||||
1. 不改表格内容、列、列宽,也不改 `ChartSymbolHelp` 的行为(「/」的说明气泡必须照常弹出,不能被裁掉)。
|
||||
2. 手机端不得回归 BUG-1083:390px 宽时页面 `scrollWidth` 必须等于 390,表格只能在自己的盒子里横滚。
|
||||
3. `chart-varga-table.test.tsx` 的 D1 字节冻结、`chart-page-mobile-width-20260929.test.ts` 的现有断言都要照常通过。若必须改,按「原值 / 新值 / 原因」三栏写。
|
||||
4. 本单只修布局。组件库迁移 K3(#107)会整体改造这一页,本单不要提前换组件。
|
||||
|
||||
## 任务
|
||||
|
||||
### T1 网格自己掌握整行
|
||||
|
||||
- `VedicLayout` 里把 `{children}` 包进一个固定容器,比如 `<div className="chart-page-vedic-tables">`。这个容器永远是网格的第三个直接子项。
|
||||
- `globals.css` 桌面段的跨栏规则改为针对这个容器(`grid-column: 1 / -1`)。原来那两个类名如果别处不再需要跨栏,就从规则里删掉;删之前先按 AGENTS.md §7 第 8 条 grep 源码文本合同测试。
|
||||
- 容器加 `min-width: 0`,保证窄屏时表格只在自己的盒子里横滚。
|
||||
- `ChartTypeBody` 里分盘、Bhava、行运的 `.chart-page-varga-table-block` 保持不变。它们现在落在新容器里面,不再负责跨栏。
|
||||
|
||||
**验收**:在 1024px、1280px 宽下,「本命」「月亮」「D9」「Bhava」「行运」五种盘型的表都占满整行,宿主、pada、状态三列不用横拖就看得到。
|
||||
|
||||
### T2 回归测试
|
||||
|
||||
- 新增测试:分别渲染 `ChartVedicTab` 的 D1、MOON、D9、BHAVA、TRANSIT,断言 `.chart-page-vedic-layout` 恰好有三个直接子项,并且第三个是 `.chart-page-vedic-tables`。
|
||||
- 在同一个测试文件里,断言 `globals.css` 的桌面媒体段包含 `.chart-page-vedic-tables { grid-column: 1 / -1; }`(或等价写法)。
|
||||
- 以后谁再往表外包东西,这两条断言就会失败。
|
||||
|
||||
### T3 文档
|
||||
|
||||
- `docs/BUG_HISTORY.md` 新增一条:回归来源 #87,根因写「跨栏按类名而非结构」,防复发写上面 T2 的两条断言。
|
||||
- `frontend/DESIGN.md` 星盘页一节补一句:「表格区由 `VedicLayout` 的整行容器承载,不靠表格自身类名跨栏。」
|
||||
- `CHANGELOG.md` 写一行。
|
||||
|
||||
## 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/chart-table-span-20261023 .worktrees/chart-table-span-20261023 origin/staging
|
||||
cd .worktrees/chart-table-span-20261023 && git status -sb
|
||||
cd frontend && npm ci && ./node_modules/.bin/tsc --noEmit && npm run lint
|
||||
./node_modules/.bin/tsx --test tests/chart-page-view.test.tsx tests/chart-varga-table.test.tsx tests/chart-page-mobile-width-20260929.test.ts # 记下基线
|
||||
```
|
||||
|
||||
## 验收分工
|
||||
|
||||
- **Grok 自跑**:
|
||||
- `tsc --noEmit`;
|
||||
- `npm run lint`;
|
||||
- 上面三个测试文件加新测试;
|
||||
- `npm run build`,记录 `/` 是否 Static 和首屏 gzip;
|
||||
- 在本机浏览器 1280px 和 390px 宽下各截一张本命盘页面的图,附在回报里。
|
||||
- **Claude 验收时跑**:
|
||||
- 前端全量 `npm test`,和基线逐条对比(#87 当时就是漏了这一步);
|
||||
- 复核截图。
|
||||
- **真机清单**:`docs/testing/chart-table-span-20261023.md`,写两步:宽屏看本命和月亮的表是不是整行;手机上页面不横向滚动。
|
||||
@@ -0,0 +1,341 @@
|
||||
# TASK · 新用户启程:先看谁的盘、首问免费、起步三问、启程清单、一次性提示与动效(2026-10-22)
|
||||
|
||||
> **2026-10-23 更新:** 红线 8 和 T7 里的「用 CSS 和 Web Animations API、复用 `lib/motion/` 底座」改为「**用 Motion**,复用 `TASK-ui-kit-migration-20261023.md` K0 做好的 Motion 预设」。界面控件一律用 shadcn 组件(AGENTS.md §7 第 12 条)。文案按 AGENTS.md §7 第 9~11 条删减,可访问名称要保留。顺序也跟着调整:#96 等迁移单 K0 合入后再开工;#99 改为依赖 K0,不再依赖 #98。
|
||||
|
||||
设计原型(可点,含两条路径与全部动效):https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U 。原型与本任务书不一致时,以本任务书为准。原型里的名字、日期、星位都是虚构示例。
|
||||
|
||||
## 基线
|
||||
|
||||
- `origin/staging` = `c1aa72fcb`(写本单时)。开工时以当时的 `origin/staging` 为准,在进度记录里写下实际基线 SHA。
|
||||
- BUG 编号起点:**BUG-1420**。开工时运行 `grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1` 核对最大号;若已被占用就顺延,并在进度记录里写明。
|
||||
- 本单要动表。产品负责人 2026-10-22 明确要求把它交给 agent 循环执行,并同意其中的动表改动。执行方按文末「循环交接」拆成四个 issue,串行完成;**下面「开工前置命令」里的分支名以「循环交接」表为准**。
|
||||
|
||||
## 事故实证(行号按符号定位,以开工时为准)
|
||||
|
||||
1. **新账户第一次提问必定被拦。**
|
||||
- 新账户点数默认是 0(`frontend/supabase/migrations/20260715000000_account_credits.sql` 中 `credits` 的默认值)。
|
||||
- 前端:`hooks/use-consultation-run.ts` 发送前判断 `account.credits <= 0 && !account.activeSubscription`,成立就调 `openRedeemPaywallOnce()` 弹兑换窗。
|
||||
- 服务端:`lib/consult/settlement.ts` 的 `reserveCredit` 按 `chat.standard` 预扣,余额不足返回 402。
|
||||
- 结果:用户建完档、看完「那一刻的天空」,第一次提问就撞上付费。
|
||||
2. **过场之后没有下一步。** `components/birth-sky/birth-sky-reveal.tsx` 播完后落到 `components/starter-home.tsx` 的空白首页,只有一行问候、输入框和「今日星语」。新用户不知道能问什么。
|
||||
3. **没有任何功能介绍。** 星盘页(四个页签)、星盘档案、婚恋合盘、个人报告、星历只出现在侧栏,没有任何地方告诉新用户它们存在。仓库里也没有引导组件或引导库。
|
||||
4. **只能先排自己的盘。** 建档只认本人:`lib/home-profile.ts` 的 `missingProfileStep()` 检查 `profiles` 行,资料不全就进不了首页。想先给伴侣、孩子看的用户必须先填自己。下列文件都依赖这个判断,T3 要逐个核对:
|
||||
- `hooks/use-profile-onboarding.ts`、`lib/home-bootstrap-run.ts`、`lib/home-warm-start.ts`、`lib/session-list-context.tsx`
|
||||
- `hooks/use-consultation-run.ts`、`hooks/use-rectification-surface.ts`、`lib/rectification-session-composer-guard.ts`
|
||||
- `components/daily-starlanguage-binder.tsx`、`components/home-onboarding-shell.tsx`、`app/(app)/page.tsx`
|
||||
5. **档案没有关系。** 人物只分 `self` / `other`:`lib/subject-birth.ts` 的 `role`,以及 `chat_sessions.chart_profile_role` 的 check 约束(`20260830010000_chat_session_chart_profile_binding.sql`)。`chart_profiles` 上没有伴侣、孩子、父母、朋友之分。
|
||||
6. **残留死代码。**
|
||||
- `app/(app)/page.tsx` 的 `onboardingJustCompleted` 从来没有被设为 `true`,所以 `lib/home-profile.ts` 的 `completedOnboardingMessage` 永远不会显示。
|
||||
- `components/home-onboarding-shell.tsx` 保留着一个走不到的 `"rectification"` 分支。
|
||||
- `frontend/DESIGN.md` §9「首页只揭幕一次」那一段仍写着首页有「生时校正卡」(BUG-1247 已经删掉)。
|
||||
7. **`profiles` 上有三列废列。** `onboarding_payload / onboarding_version / onboarding_generated_at` 在前后端都已没有引用。
|
||||
|
||||
## 根因
|
||||
|
||||
- 首登流程只做到“排好自己的盘”,没有设计“第一次拿到价值”这一步;计费又默认 0 点,两者叠加,激活在第一问就断了。
|
||||
- 建档默认用户来看自己,但很多人第一次来是想看身边的人。
|
||||
- 各功能逐个上线,入口只放进侧栏,一直没有统一的“新用户怎么认识它们”的机制。
|
||||
|
||||
## 决策记录(产品负责人 2026-10-22 拍板)
|
||||
|
||||
| # | 问题 | 决定 |
|
||||
| --- | --- | --- |
|
||||
| D1 | 新账户第一个问题要不要免费 | **免费**,每个账户一次,由服务端判定 |
|
||||
| D2 | 清单里放不放收费项(合盘 19 点、个人报告) | **放**,价格写在清单那一行 |
|
||||
| D3 | 老用户看不看得到清单 | **能看到**。勾从真实数据推出;进站时七项已满的账户整张清单不出现 |
|
||||
| D4 | 先帮别人看的用户,自己的资料要不要必填 | **不必填**。先排 TA 的盘、问 TA 的事;清单多一项「加上你自己」,加完才解锁今日星语和合盘 |
|
||||
| D5 | 关系分类做不做 | **做**:伴侣、孩子、父母、朋友、其他,外加本人。老档案的关系为空,显示「其他」,用户可改 |
|
||||
| D6 | 动效 | 产品要求加强(“动画太少了”)。按原型的动效规范做,四处超出现行规范,**只限启程流程**:① 转场、飞勾 650–700 ms,合盘靠拢约 1.2 s;② 提示和勾用回弹曲线;③ 七项全满时迸一次星点;④ 侧栏和页签上的新小点闪两次 |
|
||||
|
||||
**这份任务书推翻以下既有约束。执行方必须照改,不得以“与 VOICE / DESIGN / 既有 Bug 历史冲突”为由拒改:**
|
||||
|
||||
- `frontend/docs/VOICE.md` 规定「首页开场语只有一行,不带追问句」「入口按钮下面平时不写字」。**仅在起步三问出现时放宽**:问候下面可以加一行引导、一枚「这一次不扣点」、三个起步问题,帮别人看时再加一枚「正在看:{名字} · {关系}」。起步三问消失后,首页恢复原规则。在 VOICE.md 写明这条例外和日期。
|
||||
- `frontend/DESIGN.md` §6 规定「微交互 120 ms、弹窗 180 ms、只用一条缓动、除加载外不循环」。启程流程按 D6 放宽,DESIGN.md 新增「新用户启程」一节,写明例外和适用范围;产品其他地方不变。
|
||||
- 建档“必须先有本人资料才进首页”(`missingProfileStep` 的现行语义):改为“本人资料完整,**或**档案里至少有一位资料完整的人”,见 T3。
|
||||
- `DESIGN.md` 首次建盘过场一节「过场正常结束或被点掉后直接露出下一步,不弹任何提示」:**不推翻**。起步三问是首页内容,不是提示;过场结束那一刻仍然不弹任何浮层。
|
||||
- 兑换窗「只在第一次因点数不足发不出消息时弹」:**不变**。首问免费后,这一刻自然后移到第二问。
|
||||
|
||||
## 硬红线
|
||||
|
||||
1. 首问免费只能由**服务端**判定和记账,前端只读一个布尔值。前端改余额、跳过检查、本地记标记,都不算完成。
|
||||
2. 免费额度**原子**消耗:
|
||||
- 同一账户并发两条请求,最多一条免费。
|
||||
- 回答失败、被撤回、被停止、退了点,额度都**还在**。
|
||||
- 寒暄不消耗额度(寒暄本来就不扣点)。
|
||||
- 给谁问(本人或档案里的人)都算同一份额度。
|
||||
3. 免费只覆盖 `chat.standard` 的标准档价格。用户在首问前换了高档模型,按正常价扣或只扣差价,二选一,在进度记录里写明选了哪种、为什么。
|
||||
4. 揭幕后不得出现新的等待动画、骨架或转圈(AGENTS.md §6、DESIGN.md §9)。D6 的动效全部是对用户操作的回应,不能用来填补等待。
|
||||
5. 起步问题在揭幕那一帧就能渲染,**不得为生成它多调一次模型、多等一个接口**。
|
||||
6. 一次性提示不得和以下任何一样同时出现:天空过场、兑换窗、任何弹窗或抽屉、回答流式生成中。同一时刻最多一个提示。
|
||||
7. 遵守冻结规则:`Home()` 的 `useState` / `useRef` 数不得增长(`frontend/tests/home-shell-growth-contract.test.ts`);`scripts/jyotish_api_server.py` 的类方法数不得增长。新逻辑放进 `hooks/`、`lib/` 和组件。
|
||||
8. 不引第三方引导库或动画库。动效用 CSS 和 Web Animations API,复用 `TASK-site-motion-20261022.md` M1 的 `lib/motion/` 底座;提示用现有的 `@base-ui/react` Popover。
|
||||
9. 系统开了 `prefers-reduced-motion` 时,D6 的全部动效退成淡入或直接出现,星点和闪烁去掉。
|
||||
10. 清单的“做没做”**一律从真实数据推导**。唯一例外是“进过星盘页”(没有数据可推),见 T2。
|
||||
11. 生时校正仍只支持本人。帮别人看时,任何地方都不出现校正提议。
|
||||
12. 迁移只加不删(AGENTS.md §7.6):三列废列本单不删,关系列可为空。必须真跑 `npm run test:db`;本机没有 Docker 时,按已记录的无 Docker PG17 做法跑;仍做不了就写进 `BLOCKED.md`,不得写成通过。
|
||||
13. 测试、fixture、文档里不得出现任何真实用户资料。
|
||||
|
||||
## 任务分解
|
||||
|
||||
### T1 首问免费(服务端,动表)
|
||||
|
||||
- 新增迁移,在账户上记录首问免费是否已用(例如 `profiles.first_consult_free_used_at timestamptz`),列名由执行方定。
|
||||
- `reserve_consultation_usage`(或新 RPC)在同一事务里判断并占用免费额度:
|
||||
- 可用:本次 `credit_cost = 0`,并标记占用。
|
||||
- 撤销、退点路径(`cancel_consultation_credit` 等):把额度还回去。
|
||||
- 回答完成后才算真正用掉。
|
||||
- 账户接口新增 `firstConsultFree: boolean`。
|
||||
- 前端发送前检查改为 `credits <= 0 && !activeSubscription && !firstConsultFree` 才拦;输入框占位的 `creditsEmpty` 同样排除 `firstConsultFree`。
|
||||
- 计费台账照记一条 0 点记录,标明来源是首问免费,后台用量页能看到。
|
||||
|
||||
**验收**
|
||||
- 新账户 0 点:第一问正常发出,余额仍为 0;第二问弹兑换窗,文案沿用原来的。
|
||||
- 并发两条首问,只有一条是 0 点(DB 测试)。
|
||||
- 首问中途停止或失败后,`firstConsultFree` 仍为 `true`(DB 测试)。
|
||||
- 首问是寒暄时,`firstConsultFree` 仍为 `true`。
|
||||
- 首问选了高档模型:按红线 3 选定的规则扣点,有测试。
|
||||
- 迁移前已有完成咨询的老账户,迁移后 `firstConsultFree = false`。
|
||||
|
||||
### T2 引导状态与关系字段(动表)
|
||||
|
||||
- `profiles` 新增 `onboarding_state jsonb`:
|
||||
- 加对象类型 check;写权限只给服务端路由,照 `20260715040000_agent_onboarding_cache.sql` 的授权写法。
|
||||
- 内容只有三样:`checklistDismissedAt`、`hintsSeen: string[]`、`chartVisitedAt`。
|
||||
- 新增一条已登录接口读写 `onboarding_state`:
|
||||
- 写入只接受上面三个键。
|
||||
- `hintsSeen` 只接受 T6 表里列出的 id。
|
||||
- `chart_profiles` 新增 `relation text null`,check 取值为 `partner | child | parent | friend | other`。本人不写这一列,仍由 `profiles` 表示。
|
||||
- `lib/subject-birth.ts` 的人物类型加上 `relation`,读写接口同步。
|
||||
- 星盘档案的新增和编辑表单都加关系选择,文案是「TA 是你的」,选项为伴侣、孩子、父母、朋友、其他。
|
||||
- 人物卡在名字后显示关系小标签;本人显示「本人」;`relation` 为空时显示「其他」。
|
||||
|
||||
**验收**
|
||||
- DB 测试覆盖:`onboarding_state` 读、写、白名单拒绝、未登录 401、不能写别人的。
|
||||
- `relation` 非法值被拒;为空的老行正常读出。
|
||||
- 档案页新增一个人(选「孩子」),刷新后关系仍在。
|
||||
- 编辑一个老档案,可以补上关系。
|
||||
|
||||
### T3 建档第二步「这次先看谁的盘」,本人资料可选(D4)
|
||||
|
||||
- 建档从三步变成四步:称呼 → **看谁的盘** → 出生日期与时间 → 出生地。顶部进度条四格。
|
||||
- 第二步的助手消息:「好的,{称呼}。这次想先看谁的盘?以后随时可以再加别人。」消息下面是两个大选项:
|
||||
- 「我自己」,副文案「看看自己的性格、事业和这几年的运势」。
|
||||
- 「帮别人看」,副文案「伴侣、孩子、父母或朋友」。选中后平滑展开两样:「TA 叫什么」输入框,以及「TA 是你的」关系选择(五个选项);下面写「你自己的资料可以以后再补。」
|
||||
- 选「我自己」:后续流程和现在一致,第三步文案改为「好,先看你自己的。……」。
|
||||
- 选「帮别人看」:
|
||||
- 出生两步的资料写进 `chart_profiles` 的新行,带名字和关系;本人 `profiles` 只存称呼。
|
||||
- 第三步文案:「好,先看{名字}的。请告诉我 TA 的出生日期,以及你对 TA 出生时间知道到什么程度。家人朋友的出生时间常常记不准,选个大概时段就行。」
|
||||
- 第四步:「最后一个问题,{名字}出生在哪里?」
|
||||
- 天空过场照常,用 TA 的数据;副句写「那一刻,{名字}的上升在……」。
|
||||
- 建完后,当前人物设为 TA,首页和对话都以 TA 为当前人物。
|
||||
- `missingProfileStep()` 改为:本人资料完整,**或**档案里至少有一位资料完整的人,就算建完档。中途退出的,下次从缺的那一步接着问。
|
||||
- 逐个核对事故实证 4 列出的调用点,按“当前人物”而不是“本人”判断:
|
||||
- 今日星语:本人没有出生资料时,首页不显示「今日星语」按钮。
|
||||
- 生时校正:当前人物不是本人时,不出现校正提议(红线 11)。
|
||||
- 「和我合盘」:本人没有出生资料时,按钮位置改为文字「加上你自己后可合盘」。
|
||||
- 顺手清理事故实证 6 的死代码,并修正 DESIGN.md §9 那一段。
|
||||
|
||||
**验收**
|
||||
- 选「我自己」走完四步,进首页,行为与基线一致(截图或测试断言)。
|
||||
- 选「帮别人看」走完四步:
|
||||
- `chart_profiles` 新增一行,名字和关系正确。
|
||||
- 本人没有出生资料,照样进首页。
|
||||
- 首页、聊天顶栏的当前人物都是 TA。
|
||||
- 首页不显示今日星语;对话里不出现校正提议。
|
||||
- `missingProfileStep` 的单测覆盖:本人完整 / 只有他人完整 / 都不完整 / 中途退出。
|
||||
- 帮别人看的账户之后补上本人:今日星语出现,「和我合盘」可点。
|
||||
|
||||
### T4 起步三问(首页)
|
||||
|
||||
- 显示条件:已建完档、`firstConsultFree === true`、当前是空白首页。用掉首问后不再出现。
|
||||
- 布局:
|
||||
- 问候下面一行:本人路径写「盘排好了。先问一个问题吧。」;帮别人看写「{名字}的盘排好了。先问一个关于 TA 的问题吧。」,并加一枚「正在看:{名字} · {关系}」。
|
||||
- 然后一枚「这一次不扣点」,再是三个起步问题,排在输入框上方。
|
||||
- 三个问题由 `lib/` 里的新函数按当前人物的盘拼出来,数据取自 `lib/daily-starlanguage.ts` 已有的 `ascendantSign` / `moonSign`,不得新增请求:
|
||||
|
||||
| # | 本人 | 帮别人看 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 「上升{上}、月亮{月},我是个什么样的人」;没有上升时「月亮在{月},我是个什么样的人」;两样都没有时「从我的盘看,我是个什么样的人」 | 同样三档,「我」换成「{名字}」 |
|
||||
| 2 | 「接下来这两年,事业上该注意什么」 | 「{名字}接下来这两年,事业上该注意什么」 |
|
||||
| 3 | 「我想问一件具体的事」 | 「我想问一件关于 TA 的事」 |
|
||||
|
||||
- 点 1、2 直接发送,和用户自己打字发送走同一条路径。
|
||||
- 点 3 不发送,只让输入框亮一下并获得焦点。
|
||||
|
||||
**验收**
|
||||
- 两条路径 × 三种盘数据(全有 / 无上升 / 都无),各一条单测,文案逐字匹配上表。
|
||||
- 点 3 后不发送,输入框获得焦点。
|
||||
- 首问回答完成后回到首页,起步三问消失,首页回到原样。
|
||||
|
||||
### T5 启程清单
|
||||
|
||||
- 七项和“做过”的判定,全部从服务端或现有数据推导:
|
||||
|
||||
| id | 本人路径文案 | 帮别人看文案 | 做过 = |
|
||||
| --- | --- | --- | --- |
|
||||
| `self` | 排好自己的盘 | 排好{名字}的盘 | 已建完档(T3 口径) |
|
||||
| `ask` | 问第一个问题 | 同左 | 至少一条完整的非寒暄回答 |
|
||||
| `daily` | 打开今日星语 | 同左(本人没资料时「去做」跳到档案页) | 至少一次今日星语咨询 |
|
||||
| `chart` | 看看自己的星盘 | 看看星盘 | `onboarding_state.chartVisitedAt` 有值 |
|
||||
| `person` | 加一位家人或朋友 | **加上你自己** | 本人路径:档案 ≥ 2 人;帮别人看:本人资料完整 |
|
||||
| `syn` | 和 TA 合一次盘 · {价} 点 | 和{名字}合一次盘 · {价} 点 | 至少一份合盘报告 |
|
||||
| `report` | 生成个人报告 | 同左 | 至少一份个人报告 |
|
||||
|
||||
- 帮别人看的账户,清单顺序是 `self, ask, person, daily, chart, syn, report`。
|
||||
- 本人路径 / 帮别人看,按“本人资料是否在档案里第一个建档”判断,不另存标记。如果本人和他人都已完整,用本人路径的文案。
|
||||
- 合盘价格从计价表读(现为 19),不写死;报告行有价格就显示。
|
||||
- 功能开关关闭时对应行不显示:
|
||||
- `product.compatibility` / `account.compatibility` 关闭 → 没有 `syn`。
|
||||
- 个人报告开关关闭(`product.report_center` 与 `PERSONAL_REPORT_ENABLED`,见 `lib/personal-report-entitlement.ts`)→ 没有 `report`。
|
||||
- 入口:首页和聊天顶栏右侧一枚胶囊「启程 n/N」,带进度环。
|
||||
- 点开后,手机上是底部抽屉,桌面上是弹出层。
|
||||
- 每行未完成的有「去做」,直接跳到真功能:
|
||||
|
||||
| 项 | 「去做」跳到哪里 |
|
||||
| --- | --- |
|
||||
| `ask` | 首页,聚焦输入框 |
|
||||
| `daily` | 触发今日星语;本人没资料时跳到档案页 |
|
||||
| `chart` | `/chart` |
|
||||
| `person` | `/people` 并打开新增表单(帮别人看时打开「加上你自己」) |
|
||||
| `syn` | `/people` |
|
||||
| `report` | `/reports` |
|
||||
|
||||
- 抽屉底部「先不看了」→ 写 `checklistDismissedAt`,胶囊此后不再出现。
|
||||
- 七项全满时,抽屉最后展开一次:标题「都走过一遍了」,正文「以后有新东西,我会在用到的时候再告诉你。」。关掉后写 `checklistDismissedAt`。
|
||||
- D3:老用户照常显示,勾从数据推导;进站时已经全满的账户,胶囊和「都走过一遍了」都不出现。
|
||||
|
||||
**验收**
|
||||
- 七项判定在两条路径下各一条单测,含边界:寒暄不算 `ask`;本人一人不算 `person`;帮别人看时加了第二个他人也不算 `person`。
|
||||
- 功能开关关闭时行数变化有测试。
|
||||
- 新账户进首页时 `self` 已勾,显示「启程 1/7」。
|
||||
- 老账户 fixture 只差 `chart`、`daily` 时显示「5/7」;全满时不显示。
|
||||
- 点「先不看了」后刷新、在另一会话打开,都不再出现。
|
||||
- 手机 375 px 宽无横向滚动,胶囊不挤占输入框。
|
||||
|
||||
### T6 一次性提示
|
||||
|
||||
一律用一个通用的 `useOneTimeHint(id, condition)`(或同等实现)。id 固定,即 T2 的白名单:
|
||||
|
||||
| id | 何时出现 | 指向 | 文案 |
|
||||
| --- | --- | --- | --- |
|
||||
| `follow-up` | 第一条完整回答流式结束后 | 追问胶囊 | 想接着聊,点下面任一个就直接问。 |
|
||||
| `sidebar-chart` | `follow-up` 已看过、`chartVisitedAt` 为空,第一次打开侧栏时 | 侧栏「星盘」 | 你的盘、大运和分盘都在这里。 |
|
||||
| `tab-chart` | 第一次看到星盘页的「星盘」页签 | 页签内容上方 | 这是你的本命盘,北印度画法。最上面的菱形是第一宫,上升就在这里,往左逆时针依次是二宫到十二宫。 |
|
||||
| `tab-info` | 第一次打开「基础信息」 | 同上 | 排盘用的原始资料和每颗星的位置都在这里。出生时间不准时,这一页最上面会写明误差。 |
|
||||
| `tab-dasha` | 第一次打开「大运」 | 同上 | 一段大运管几年到十几年,主色那段是你现在走的。点开任意一段,能看到里面的小运。 |
|
||||
| `tab-west` | 第一次打开「西洋盘」 | 同上 | 同一时刻的天空,换成西洋占星的算法。两种黄道相差约 24 度,所以星座可能和印度盘不一样。 |
|
||||
| `syn-ready` | 档案里第一次有了两个人(含本人),且还没有合盘 | 「和我合盘」按钮 | 有了你们两个人,就能看你们俩的关系画像。价格写在按钮上。 |
|
||||
| `ephemeris` | 第三次打开今日星语之后,下一次打开侧栏时 | 侧栏「星历」 | 每天的天象和行运在这里,可以按日期翻。 |
|
||||
|
||||
- 当前人物不是本人时,`tab-chart` 文案里的「你的」改成「{名字}的」。
|
||||
- 侧栏「星盘」在 `ask` 完成、`chart` 未完成时带一个小圆点;手机上的汉堡按钮也带。`chart` 完成后圆点消失。
|
||||
- 星盘页四个页签,没看过的右上角带小点,看过就消失(跟随 `hintsSeen` 中 `tab-*` 是否存在)。
|
||||
- 每条提示点「知道了」、点页面任何地方或按 Esc 即消失,写入 `hintsSeen`,之后不再出现。
|
||||
- 被红线 6 挡住的提示排队,条件解除后再出,不丢。
|
||||
|
||||
**验收**
|
||||
- 每条提示的出现条件和互斥(流式中、弹窗开着、过场中都不出)各有测试。
|
||||
- 看过后刷新不再出现;四个页签各自记。
|
||||
- 提示出现时不抢焦点;Esc 可关闭。
|
||||
|
||||
### T7 动效(D6)
|
||||
|
||||
照原型「动效规范」表实现,令牌写进 DESIGN.md「新用户启程」一节:
|
||||
|
||||
| 类别 | 用在哪 | 时长 | 曲线 |
|
||||
| --- | --- | --- | --- |
|
||||
| 按下反馈 | 按钮、问题卡、选项、关系标签被按下时轻微缩小 | 120 ms | `--ease-out` |
|
||||
| 进场 | 建档消息与卡片、起步问题、追问胶囊、档案卡、目录行;依次上浮,间隔 50–80 ms | 260 ms | `--ease-out` |
|
||||
| 弹出 | 一次性提示、「这一次不扣点」、勾 | 380 ms | 回弹 `cubic-bezier(.34,1.56,.64,1)` |
|
||||
| 跟手 | 称呼从输入框飞成气泡;起步问题飞成气泡 | 380 ms | `--ease-out` |
|
||||
| 勾飞进进度环 | 清单每完成一项:从操作位置沿弧线飞向胶囊,进度环走一格,胶囊轻跳一下 | 700 ms | 弧线 |
|
||||
| 画出来 | 建档进度条;星盘格线、西洋盘圆与分隔线、大运条 | 520–900 ms | `--ease-out` |
|
||||
| 场景转换 | 出生地→天空:从点击处圆形展开;天空→首页:淡出放大,首页同时上浮 | 650–700 ms | `--ease-out` |
|
||||
| 合盘 | 两个头像从两侧靠拢,泛起一圈光,再展开关系画像 | 约 1.2 s | `--ease-out` |
|
||||
| 庆祝 | 七项全满时,进度环处迸一次星点 | 1000 ms,只一次 | 减速 |
|
||||
|
||||
- 每段动效只在元素第一次出现时播放,重绘和来回切换不重播。
|
||||
- 除回答流式中的呼吸点外,不得有任何无限循环。
|
||||
- 星点、闪烁在 `prefers-reduced-motion` 下去掉,其余退成淡入(红线 9)。
|
||||
- 天空过场本身的加强(星星先亮起再汇聚、星体逐个点亮并从上升连线、两行字依次浮出)在 `components/birth-sky/` 内实现,沿用现有的“3 秒内素材没到齐就静默跳过”。
|
||||
- 动效只在 `opacity`、`transform`、颜色、`clip-path` 上做,不触发布局。
|
||||
|
||||
**验收**
|
||||
- `prefers-reduced-motion` 下没有位移动画(测试或录屏)。
|
||||
- 本机 Chrome 性能面板录一次完整新用户流程,没有超过 50 ms 的长任务由动效引起;做不了就写环境缺口。
|
||||
- 真机清单里逐条列出动效,由产品负责人核对。
|
||||
|
||||
### T8 文档
|
||||
|
||||
- `CONTEXT.md` glossary 加:启程清单、起步问题、一次性提示、关系(人物)。
|
||||
- `frontend/DESIGN.md`:
|
||||
- 新增「新用户启程」一节,写胶囊、抽屉、提示的尺寸,T7 的动效令牌,D6 例外的范围,以及互斥规则。
|
||||
- 修正 §9 那一段。
|
||||
- `frontend/docs/VOICE.md`:写明本单的例外,收录全部新文案。
|
||||
- `CHANGELOG.md`:写一条用户可感知的变更。
|
||||
- `docs/BUG_HISTORY.md`,状态、现象、根因、修复、验证、防复发写全:
|
||||
- BUG-1420:新账户首问必撞付费,激活断点。
|
||||
- BUG-1421:首登过场后无下一步,加上死代码。
|
||||
- BUG-1422:建档只能先看本人。
|
||||
- `docs/testing/onboarding-journey-20261022.md` 真机清单:
|
||||
- 新邮箱注册,两条路径各走一遍全程。
|
||||
- 老账户看清单。
|
||||
- 手机和桌面各走一遍。
|
||||
- 开启减少动态效果再走一遍。
|
||||
- 逐条动效核对。
|
||||
|
||||
## 让步顺序
|
||||
|
||||
做不完时按这个顺序砍,越靠前越先砍:
|
||||
|
||||
1. `ephemeris` 提示
|
||||
2. 合盘靠拢动画(直接展开关系画像)
|
||||
3. 七项全满的星点
|
||||
4. 桌面版清单的弹出层(先统一用抽屉)
|
||||
5. 老用户清单(D3)可以晚一个版本上,但不得改成其他形态
|
||||
|
||||
**不可让:** T1、T2、T3、T4,以及 T5 的新用户路径。
|
||||
|
||||
## 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/onboarding-journey-20261022 .worktrees/onboarding-journey-20261022 origin/staging
|
||||
cd .worktrees/onboarding-journey-20261022
|
||||
git status -sb
|
||||
grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
|
||||
cd frontend && npm ci && ./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -8 # 记下基线测试数
|
||||
npm run build # 记下 / 是否 Static、首屏 gzip
|
||||
```
|
||||
|
||||
## 循环交接
|
||||
|
||||
- **执行方**:Grok(Windows + Docker)。每个 issue 一个分支,名字见下表;只推自己的分支。
|
||||
- **Grok 自跑**:
|
||||
- `tsc --noEmit`、`npm run lint`、相关测试文件、`npm run build`。
|
||||
- 动了数据库访问层的 issue(一、二、三)必须跑 `npm run test:db`,结果与 `tools/agent-loop/windows-known-failures.txt` 逐条比对,新增的失败一条都不许有。
|
||||
- **Claude 验收时跑**:
|
||||
- 前端全量 `npm test`,逐条对比基线。
|
||||
- 改了 `scripts/**` 或 `tests/**` 时,跑 Python 快速门。
|
||||
- 复核首屏 gzip 和冻结合同。
|
||||
- 每个 issue 在同一变更里写好自己那份 `docs/BUG_HISTORY.md`、`CHANGELOG.md`、`CONTEXT.md`、`DESIGN.md`、`VOICE.md` 改动(T8 按 issue 拆开做)。
|
||||
- 真机清单 `docs/testing/onboarding-journey-20261022.md` 由 issue 一创建,后面的 issue 往里追加。
|
||||
- 迁移只加不删。staging 部署前会自动应用迁移;上生产前,产品负责人需要先跑迁移。
|
||||
|
||||
| issue | 内容 | 分支 | 前置 |
|
||||
| --- | --- | --- | --- |
|
||||
| 新用户启程(一)数据层 | T1 首问免费 + T2 引导状态与关系字段(含档案页关系选择) | `codex/onboarding-1-data-20261022` | 无 |
|
||||
| 新用户启程(二)建档与首页 | T3 先看谁的盘 + T4 起步三问 | `codex/onboarding-2-intake-20261022` | (一)合入 |
|
||||
| 新用户启程(三)清单与提示 | T5 启程清单 + T6 一次性提示 | `codex/onboarding-3-checklist-20261022` | (二)合入 |
|
||||
| 新用户启程(四)启程动效 | T7 + DESIGN.md「新用户启程」一节 + 真机清单收尾 | `codex/onboarding-4-motion-20261022` | (三)和「全站动效(一)」都合入;复用 `lib/motion/` 底座 |
|
||||
|
||||
## 交付验收口径(Claude 验收时逐条核)
|
||||
|
||||
- `tsc --noEmit` 0 错;`npm run lint` 0 error。
|
||||
- `npm test` fail=0,且总数不低于基线;改了任何既有断言,写明「原值 / 新值 / 原因」。
|
||||
- `npm run test:db` 真跑通过,或按红线 12 写明环境缺口。
|
||||
- 改动含 `scripts/**` 或 `tests/**` 时跑 Python 快速门。
|
||||
- `next build` 后 `/` 仍是 `○ Static`;首屏 gzip 变化在 ±2% 内,超出要写原因(动效代码应按需加载,不进首屏)。
|
||||
- 冻结合同测试全过。
|
||||
- 部署后 `https://staging.jyotisha.chat/api/health` 的 `deployment.gitCommit` 等于交付 SHA。上生产前必须先跑本单的迁移。
|
||||
- 真机清单交给产品负责人,不得写成“已通过”。
|
||||
@@ -0,0 +1,134 @@
|
||||
# TASK · 说「帮我做生时纠正」只回一句「可在历史对话里接着做」(issue #103,2026-10-23)
|
||||
|
||||
## 基线
|
||||
|
||||
- 写本单时 `origin/staging` 为 `dd5ae8d03`。开工时以当时的 `origin/staging` 为准。
|
||||
- 执行方:Grok(Windows + Docker),分支 `codex/rectification-explicit-open-20261023`,只推这个分支。
|
||||
- BUG 编号:开工时用 `grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1` 核对,从下一个号起。写本单时的最大号是 BUG-1420。
|
||||
- **本单不需要动表**(理由见决策记录 D3)。
|
||||
|
||||
## 事故实证
|
||||
|
||||
**现象**:产品负责人在 staging 对话里说「请你帮我做一下生时纠正」,只收到一句「可在历史对话里接着做。」,没有卡片、链接或按钮,校正进不去。该账户的出生时间显示「已采用」。
|
||||
|
||||
**链路**(文件、行号以开工时为准,按符号定位):
|
||||
|
||||
1. **识别**:模型调用 `offer-birth-time-rectification` 并传 `explicitRequest: true`(`frontend/src/mastra/consultation-tools.ts`)。
|
||||
2. **判定**:`decideRectificationOffer`(`src/lib/rectification-in-chat-server.ts`)读取 `getRectificationEntrySummary(...).hasResumableCase`,再进入 `decideRectificationOfferWith`(`src/lib/rectification-in-chat.ts`),得到 `case_in_progress`。
|
||||
3. **「可续做」的状态集合**:`RESUMABLE_CASE_STATUSES`(`src/lib/contracts/rectification-entry.ts`)与 SQL 函数 `agentic_rectification_resumable_statuses()`(`20260812010000_agentic_rectification_v9_runtime.sql`)都包含 `candidate_accepted`。
|
||||
- 但确认门实际上永远不开(Bug 历史:`confirmation_allowed=false`,「本会话以代表性时间收口」),`candidate_accepted` 在现实里就是终点。
|
||||
- 结果:**每个采用过时间的用户都会被永久判为「有进行中的校正」**。明说要校正进不去;普通对话里的校正提议卡也再不会出现。
|
||||
4. **找那次校正的会话**:`finishExplicitRectificationRequest`(`consultation-tools.ts`)调用 `loadResumableSessionId`,实现在 `src/app/api/consult/route.ts`。它直接 `from("agentic_rectification_cases").select("session_id")` 读表。
|
||||
- 而 `20260814010000_immutable_skill_registry.sql` 写了 `revoke all on table public.agentic_rectification_cases from service_role;`,之后没有任何迁移把权限授回。`20260904010000_read_report_candidate_range.sql` 的注释也写明:直接读表的权限保持撤销,只能走 RPC。
|
||||
- 自托管环境下,admin 客户端以 `set local role service_role` 运行,所以这次查询**必然**报 permission denied。`if (error || !data) return null` 把错误吞掉,也没有日志。
|
||||
- **结果:即使是真正没做完的校正,也永远续不上,必然落到那一句话。**
|
||||
- 单测没拦住的原因:测试里的 `loadResumableSessionId` 是注入的假函数,从来没有碰过真库。
|
||||
5. **兜底**:拿到 null 后写成 `kind: "refused"` + `RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY`。`stream-agent-response.ts` 只在 `new` / `resume` 时下发 `rectificationDirectOpen`,所以前端只剩一句话。
|
||||
|
||||
## 根因
|
||||
|
||||
1. **找会话的方式错了**:绕开 RPC 直接读一张对服务角色撤销了权限的表,失败又被静默吞掉。
|
||||
2. **「进行中」的口径错了**:把一个实际上的终点状态(`candidate_accepted`)算作「进行中」,这个口径同时挡住了明说入口和提议卡。
|
||||
3. **兜底设计错了**:失败时只给一句话,没有任何能操作的出口。
|
||||
|
||||
## 决策记录
|
||||
|
||||
| # | 决定 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| D1 | **已采用(`candidate_accepted`)的校正不算「进行中」**。用户明说要校正时,直接开一次新的;普通对话里的提议卡也按同一口径恢复出现 | 产品 2026-10-22 选 C |
|
||||
| D2 | 删掉「可在历史对话里接着做」这个兜底:**说了要校正,就一定打开一个能操作的校正** | 产品 2026-10-22 |
|
||||
| D3 | **只改「入口判定」的口径,不改共享的 `RESUMABLE_CASE_STATUSES` 和 SQL 函数**。理由是那个集合还被采用后补证据、重新出卡、暂停恢复、Skill 升级(不在集合内会被当成 `case_terminal`)、改资料触发器(置 `needs_rebaseline`)、以 `session` 打开时的 resumed / readonly 判定共同使用;从里面删掉 `candidate_accepted` 会让旧会话里的采用后核对题变成只读、Skill 升级报错。按 AGENTS.md §5「修在根因上」,根因是入口口径,不是状态机。因此本单**不动表** | Claude 规划 |
|
||||
| D4 | 真正没做完的校正(`draft` / `collecting_evidence` / `candidate_ready` / `needs_rebaseline` / `paused`)仍然续做,但改为**经 RPC** 拿到会话号(现成的 `getRectificationCase(accounting, userId, caseId)`,`src/lib/rectification-agentic/v9/case-service.ts`,返回值带 `sessionId`)。RPC 失败时打日志,并改为直接新开一次。数据库早已允许同一用户并行多个校正(BUG-177 删了唯一索引,`rectification-v9-database.test.ts` 已验证) | Claude 规划 |
|
||||
|
||||
**推翻的旧决策**:
|
||||
|
||||
- BUG-1306 修复段原话「进行中的 case 不新开:查到 `agentic_rectification_cases.session_id` 就打开那次,查不到就说可以在历史对话里接着做」:本单推翻「查不到就说去历史接着做」,并改为经 RPC 查找。
|
||||
- BUG-1306 10-12 决策日志「服务端本人 / 开关 / 进行中 case / 价格判定不变」中的「进行中 case」:口径改为不含 `candidate_accepted`。
|
||||
- BUG-1248 提议卡条件「无进行中 case」:沿用,但口径同上。
|
||||
- 与 BUG-177「不得用存在 resumable Case 阻止新 Case」一致,本单是在对话入口上重申这一点。
|
||||
|
||||
## 硬红线
|
||||
|
||||
1. 不改 `RESUMABLE_CASE_STATUSES`、`agentic_rectification_resumable_statuses()` 及其它 SQL;不新增迁移。
|
||||
2. 非本人、开关关闭、没有价格这三种拒绝,行为不变。
|
||||
3. 扣点规则不变:开始答第一题时才扣。
|
||||
4. 日志不得含任何个人资料,只记 `requestId`、错误类别和 caseId 是否存在。
|
||||
5. 旧会话里已采用的那次校正,采用后核对题在**新校正还没采用前**必须照常能答。新校正采用了不同的时间之后,旧那次的核对题不得报错或弹出错误提示,按只读处理即可(`segmentSelection` 为 null 时的界面状态)。
|
||||
6. 改既有断言必须写「原值 / 新值 / 原因」三栏。按 AGENTS.md §7 第 8 条,先 grep 源码文本合同测试,同一轮里跟着改。
|
||||
|
||||
## 任务
|
||||
|
||||
### T1 入口口径:已采用不算进行中
|
||||
|
||||
- 新增一个只给入口用的判定,名称执行方定,例如 `blocksNewRectification(status)`:命中 D4 列的五种状态返回 true,`candidate_accepted` 返回 false。
|
||||
- `getRectificationEntrySummary` 返回的 `latestResumable.status` 如果是 `candidate_accepted`,入口就视为「没有进行中的校正」。
|
||||
- `decideRectificationOffer`(明说入口和普通提议卡共用)改用这个判定。
|
||||
- 已知边界:summary 只返回最近活动的一次可续做校正。如果最近一次是已采用的、更早还有一次没做完的,本单按「没有进行中的」处理,直接新开。数据库允许并行,用户在历史对话里仍能找到那次。在进度记录里写明这一点。
|
||||
|
||||
**验收**
|
||||
- 单测:最近一次是 `candidate_accepted` → 明说要校正得到 `new`,提议卡可以出现;最近一次是 `collecting_evidence` → 得到 `resume`。
|
||||
|
||||
### T2 经 RPC 找会话,删掉兜底
|
||||
|
||||
- `loadResumableSessionId` 改为调用 `getRectificationCase(accounting, userId, caseId)`,取 `sessionId`。不再直接读表。
|
||||
- `finishExplicitRectificationRequest` 的分支:
|
||||
- 有进行中的校正且拿到 `sessionId` → `kind: "resume"`;
|
||||
- 有进行中的校正,但 RPC 失败或拿不到 `sessionId` → 打日志(红线 4),然后 `kind: "new"`;
|
||||
- 没有进行中的校正 → `kind: "new"`。
|
||||
- 删除常量 `RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY`,以及 `consultation-tools.ts` 里给模型的「去历史对话接着做」说明;同步删掉 `frontend/docs/VOICE.md` 里的对应口径。
|
||||
- 清理 `case-status.ts` 里已经过期的 `MAX_RESUMABLE_CASES_PER_USER = 1`:注释说它「镜像 DB 部分唯一索引」,但索引已被 BUG-177 删除。先 grep 引用;删除前确认没有业务逻辑依赖,测试里的断言按三栏改。
|
||||
|
||||
**验收**
|
||||
- 单测:RPC 抛错 → `new`,并且有一条日志调用(不含个人资料)。
|
||||
- **真库测试**(`test:db`):用服务角色调 `loadResumableSessionId` 的新实现,能拿到一个进行中校正的 `sessionId`。旧实现在同样条件下会 permission denied,这条测试就是当初缺的防线。
|
||||
|
||||
### T3 旧会话不报错
|
||||
|
||||
- 准备一个已采用的旧校正,再开一个新校正,并让新校正采用不同的时间。然后打开旧会话:
|
||||
- 采用后核对题显示为只读;
|
||||
- 不弹错误;
|
||||
- 控制台没有未处理的异常。
|
||||
- 用交互测试或组件测试覆盖。
|
||||
|
||||
### T4 文档
|
||||
|
||||
- `docs/BUG_HISTORY.md` 新增一条:关联 BUG-1306、BUG-1248、BUG-177。根因写三条(读表权限、口径、兜底);防复发写 T2 的真库测试和 T1 的口径单测。
|
||||
- `CHANGELOG.md` 写一行用户可感知的变化。
|
||||
- `docs/testing/rectification-explicit-open-20261023.md` 真机清单:
|
||||
1. 已采用过时间的账户,在对话里说「帮我做生时纠正」,应直接进入新的校正;
|
||||
2. 一个答到一半的校正,从对话里说要校正,应接回那次;
|
||||
3. 普通对话问到时机类问题时,提议卡会出现(已采用的账户)。
|
||||
|
||||
## 让步顺序
|
||||
|
||||
1. 先砍 T3 的自动化测试,改为写进真机清单。
|
||||
2. T1、T2、真库测试都不可让。
|
||||
|
||||
## 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/rectification-explicit-open-20261023 .worktrees/rectification-explicit-open-20261023 origin/staging
|
||||
cd .worktrees/rectification-explicit-open-20261023 && git status -sb
|
||||
grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
|
||||
cd frontend && npm ci && ./node_modules/.bin/tsc --noEmit && npm run lint
|
||||
./node_modules/.bin/tsx --test tests/rectification-explicit-entry-20261012.test.ts tests/rectification-in-chat-20261007.test.ts tests/rectification-v9-contracts.test.ts # 记基线
|
||||
```
|
||||
|
||||
## 验收分工
|
||||
|
||||
| 谁 | 跑什么 |
|
||||
| --- | --- |
|
||||
| Grok 自跑 | `tsc --noEmit`;`npm run lint`;上面三个测试文件加新增测试;`npm run test:db`,与 `tools/agent-loop/windows-known-failures.txt` 逐条比对;`npm run build` |
|
||||
| Claude 验收时跑 | 前端全量 `npm test`,与基线逐条对比;复核没有新迁移、`RESUMABLE_CASE_STATUSES` 和 SQL 函数未改;复核日志不含个人资料 |
|
||||
| 环境缺口 | 真实模型会不会在用户明说时调用工具,Claude 这边没有模型凭据,留给真机清单第 1 步 |
|
||||
|
||||
现有会受影响的断言(改时按三栏写):
|
||||
|
||||
- `rectification-explicit-entry-20261012.test.ts`
|
||||
- 断言 `RESUME_ELSEWHERE_COPY` 原文(约第 129–132 行);
|
||||
- 「an in-progress case resumes that session, or says to continue in history」(约第 243 行),其中 loader 返回 null 或抛错时期望 `refused`;
|
||||
- 普通调用 note 匹配 `/历史对话/`;
|
||||
- 约第 695–698 行的源码文本合同(`openRectificationFromChat(...)` 恰好出现 1 次)。
|
||||
- `rectification-in-chat-20261007.test.ts`:`hasResumableCase` 为 yes 时期望 `case_in_progress`(约第 107–115 行),需要补「最近一次是已采用」的分支。
|
||||
- `rectification-v9-contracts.test.ts`:`MAX_RESUMABLE_CASES_PER_USER === 1`(T2 清理时)。
|
||||
@@ -0,0 +1,181 @@
|
||||
# TASK · 全站动效:换页、页签、盘型、列表、弹窗、抽屉、提示、数字(2026-10-22)
|
||||
|
||||
> **2026-10-23 更新:本单不再单独执行。** 产品推翻了下面的 D3「不引入动画库」,改为一律用 Motion(见 `TASK-ui-kit-migration-20261023.md` 的 D2)。本单的**动效规范表仍然有效**,由迁移单 K0 做成 Motion 预设;M0~M4 并入迁移单 K1~K5。issue #98、#100、#101 不再执行。
|
||||
|
||||
设计原型(可在「现在 / 改后」之间对比):https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd 。原型和本任务书不一致时,以本任务书为准。
|
||||
|
||||
## 基线
|
||||
|
||||
- 写本单时的 `origin/staging` 是 `8743f6151`。每个 issue 开工时,以当时的 `origin/staging` 为准。
|
||||
- 本单不动表、不改 workflow,所以交给 agent 循环执行,分三个 issue;拆分见文末「循环交接」。
|
||||
- 本单**排在** `TASK-onboarding-journey-20261022.md` 的 issue 一至三之后,**排在**它的 issue 四(启程动效)之前。启程动效直接复用本单 M1 的动效底座。
|
||||
- BUG 编号:开工时用 `grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1` 核对当前最大号,从下一个号开始。
|
||||
|
||||
## 现状实证
|
||||
|
||||
1. `frontend/DESIGN.md` §6「Motion & Interaction」现行规定:
|
||||
- 只有一条缓动曲线 `--ease-out`。
|
||||
- 时长分档:Micro 120 ms、Standard 180 ms、Sheet 280 ms。
|
||||
- 「Spatial: Instant」:侧栏状态变化和手机侧栏都是瞬间切换,没有过渡。
|
||||
2. 各处切换现在都是瞬间完成:
|
||||
- 侧栏导航(`components/app-sidebar.tsx`)换页、当前项高亮。
|
||||
- 星盘页签与盘型(`components/chart-page/chart-page-view.tsx`、`chart-type-picker.tsx`):盘型一换,整盘重画。
|
||||
- 星历换日期(`components/ephemeris/ephemeris-page.tsx`)。
|
||||
- 档案增删(`components/people/people-page.tsx`)。
|
||||
- 会话收藏、删除(`components/sidebar-session-row.tsx`)。
|
||||
- 报告中英切换(`components/personal-report/report-actions.tsx`)。
|
||||
3. 设置弹窗(`components/account-dialog-overlay.tsx`)只有淡入;导出抽屉(`components/personal-report/report-export-drawer.tsx`)和顶部提示(`components/ui/sonner.tsx`)用的是各自的默认过渡,没有统一。
|
||||
4. 依赖里没有动画库。`gsap` 只在 `components/chat-message-row.tsx` 里用。
|
||||
|
||||
## 决策记录(产品负责人 2026-10-22 拍板)
|
||||
|
||||
| # | 决定 |
|
||||
| --- | --- |
|
||||
| D1 | **全站动效规范按下表重写**,推翻 DESIGN.md §6 的 180 ms 上限和「Spatial: Instant」。新用户启程里那几处更长的动效(飞勾 700 ms、合盘靠拢、七项全满的星点)仍只在启程流程里用 |
|
||||
| D2 | **回弹曲线只用于「成功」和「提示」**:顶部提示、勾、一次性提示。换页、换盘、弹窗、抽屉一律用 `--ease-out` |
|
||||
| D3 | **不引入动画库**。页面切换用浏览器原生的页面过渡能力(View Transitions),其余用 CSS 和 Web Animations API;不支持的浏览器直接切换,不报错。先做半天验证(M0)再铺开 |
|
||||
|
||||
**新规范(写进 DESIGN.md §6,替换原表):**
|
||||
|
||||
| 动效 | 用在哪 | 时长 | 曲线 |
|
||||
| --- | --- | --- | --- |
|
||||
| 按下反馈 | 按钮、行、卡片 | 120 ms | ease-out |
|
||||
| 页面切换 | 侧栏换页。旧页淡出,新页按侧栏顺序从下方或上方滑入 12–18 px;标题同步换 | 进 280 ms / 出 150 ms | `--ease-out` |
|
||||
| 选中块跟随 | 侧栏当前项、分段控件 | 260 ms | `--ease-out` |
|
||||
| 页签切换 | 星盘四页签、设置三分区。下划线滑动,内容按左右方向滑入 | 260 ms | `--ease-out` |
|
||||
| 盘型切换 | 星体从旧宫位移动到新宫位,上升星座淡入淡出 | 440 ms | `--ease-out` |
|
||||
| 换人 | 头像翻面,盘格线重画,星体就位 | 300 ms + 800 ms | `--ease-out` |
|
||||
| 翻日期 | 日期上下滚动,内容左右翻页,方向跟随前后 | 260 ms | `--ease-out` |
|
||||
| 发送消息 | 用户气泡从输入框升起 | 260 ms | `--ease-out` |
|
||||
| 切换会话 | 对话内容交叉淡入,不滑动 | 200 ms | `--ease-out` |
|
||||
| 列表增删与重排 | 新项展开出现,删除项收起,其余平滑补位 | 260–280 ms | `--ease-out` |
|
||||
| 弹窗 | 从触发它的按钮处放大出现,关闭时缩回去 | 220 / 160 ms | `--ease-out` |
|
||||
| 抽屉 | 导出、手机侧栏从边缘滑入,遮罩同时淡入 | 280 / 200 ms | `--ease-out` |
|
||||
| 顶部提示 | 从顶部落下 | 320 ms | 回弹 `cubic-bezier(.34,1.56,.64,1)` |
|
||||
| 数字变化 | 点数余额上下滚动:扣点往下,充值往上 | 360 ms | `--ease-out` |
|
||||
| 中英切换 | 段落依次淡出、换文字、再淡入,间隔 40 ms | 约 320 ms | `--ease-out` |
|
||||
| 展开收起 | 大运小运、思考步骤(沿用现状) | 240 ms | `--ease-out` |
|
||||
|
||||
## 硬红线
|
||||
|
||||
1. 不新增任何动画或过渡依赖。`gsap` 不扩散到 `chat-message-row.tsx` 以外的地方。
|
||||
2. `next build` 后 `/` 仍为 `○ Static`。首屏 gzip 变化在 ±2% 以内;动效代码按需加载,不进首屏。
|
||||
3. 遵守 `prefers-reduced-motion`:位移和缩放全部去掉,只保留 ≤150 ms 的淡入淡出;数字直接变化。
|
||||
4. 动效不得挡住操作:
|
||||
- 进行中可以继续点击,新动作直接打断旧动效。
|
||||
- 不得为了等动效播完而禁用按钮或延迟路由。
|
||||
5. 动效只回应用户操作,不得用来填补等待。「一次等待一次揭幕」不变:揭幕后没有骨架、转圈、「正在加载」(星盘页盘位骨架仍按 BUG-1016 例外处理)。页面切换时目标页的数据没到,照现有规则留白,不得播一段动效来拖时间。
|
||||
6. 只对 `transform`、`opacity`、颜色、`clip-path` 做动效,不触发布局;列表补位用 FLIP(先记录旧位置,改完再从旧位置滑过去)。
|
||||
7. 遵守冻结规则:`Home()` 的 `useState` / `useRef` 数不增长。动效逻辑放在 `lib/motion/` 和各组件里。
|
||||
8. 流式回答的逐字释放、思考步骤展开等现有行为不改。
|
||||
9. 改 UI 的同一提交里同步更新 `frontend/DESIGN.md`。
|
||||
|
||||
## 任务分解
|
||||
|
||||
### M0 页面过渡验证(半天,issue 一的第一步)
|
||||
|
||||
分别试两种接法,在进度记录里写清结论:
|
||||
|
||||
- **A**:Next 16 的 `experimental.viewTransition` 加 React `<ViewTransition>`。
|
||||
- **B**:在侧栏导航处自己用 `document.startViewTransition` 包住路由跳转,等新页提交后再结束过渡。
|
||||
|
||||
每种接法在下列环境里各走一遍「对话 → 星盘 → 星历 → 档案 → 报告」:
|
||||
|
||||
- 桌面 Chrome
|
||||
- 桌面 Safari
|
||||
- iOS Safari(Grok 侧做不了就写成环境缺口,留给真机清单)
|
||||
- Android Chrome
|
||||
|
||||
每次记录:能否用、首屏 gzip 变化、有无闪烁。
|
||||
|
||||
选型规则:选能用、改动小的那种。两种都不稳定时,退回方案 C:只在主内容区做交叉淡入,不用 View Transitions。三种结果都要写进进度记录。
|
||||
|
||||
**验收**:进度记录里有一张「方案 × 浏览器」表,并写明最终选了哪种。
|
||||
|
||||
### M1 动效底座(issue 一)
|
||||
|
||||
- `globals.css` 里加动效令牌:上表各档时长,以及 `--ease-out` 和 `--ease-spring`。
|
||||
- `lib/motion/` 里放以下工具,都要能被打断,并且尊重减少动态效果:
|
||||
- `prefersReducedMotion()`
|
||||
- `animate()`:对 Web Animations API 的薄封装
|
||||
- `flipList()`:列表补位
|
||||
- `rollNumber()`:数字滚动
|
||||
- `swapContent()`:内容交叉换场
|
||||
- DESIGN.md §6 按上表重写,注明 D1 / D2 / D3 和启程流程的例外。
|
||||
|
||||
**验收**
|
||||
- `lib/motion` 有单测:减少动态效果时不产生位移;连续调用两次时,第二次会打断第一次。
|
||||
- 首屏 gzip ±2%。
|
||||
|
||||
### M2 第一批:对话与页面框架(issue 一)
|
||||
|
||||
- 页面切换:按 M0 选定的方案实现。方向按侧栏顺序:点到下面的页,新页从下方进;点到上面的页,从上方进。标题同步换。
|
||||
- 侧栏当前项的底色块滑到新位置。
|
||||
- 手机侧栏从左滑入、遮罩淡入(推翻「Spatial: Instant」)。
|
||||
- 发送消息时,用户气泡从输入框位置升起。点追问胶囊时同理,从胶囊位置升起。
|
||||
- 切换会话时,对话内容交叉淡入。
|
||||
- 点数余额在侧栏和聊天顶栏同步滚动。
|
||||
- 设置弹窗、确认框从触发按钮处放大出现,关闭时缩回去。
|
||||
- 顶部提示从顶部落下,用回弹曲线。
|
||||
|
||||
**验收**
|
||||
- 每项动效至少有一个组件测试或交互测试:确认动效被调用,且减少动态效果时不被调用。
|
||||
- 快速连点侧栏 5 次:最终停在最后点的那一页,且没有残留的旧页。
|
||||
- `tsc` / `lint` / `npm test` / `build` 都过。
|
||||
|
||||
### M3 第二批:星盘页(issue 二)
|
||||
|
||||
- 四个页签:下划线滑动,内容按左右方向滑入。
|
||||
- 盘型切换:同一颗星在新旧盘型里都存在时,从旧位置移动到新位置;新盘里才有的星淡入,消失的星淡出。上升标签交叉淡入。北印度盘和西洋轮盘都要做。
|
||||
- 换人:头像翻面,盘格线重画(stroke 描线),星体就位。
|
||||
- 大运展开小运沿用现有的展开收起,时长统一为 240 ms。
|
||||
|
||||
**验收**
|
||||
- 盘型切换后,每颗星的最终位置与切换前一致的直接渲染逐像素相同(快照测试或坐标断言)。动效不得改变数据。
|
||||
- 连续快速切换 D1 → D9 → D10,最终停在 D10 的正确位置。
|
||||
|
||||
### M4 第三批:其余页面(issue 三)
|
||||
|
||||
- 星历前一天 / 后一天:日期上下滚动,内容按方向左右翻页;点「今天」时按相对方向翻。
|
||||
- 档案加人、删人,会话收藏置顶、删除:都用 FLIP 补位。删除动效放完之后才从列表里移除,但数据请求不等动效。
|
||||
- 报告中英切换:段落依次交叉淡入。继续保持 BUG-1126 的 key 修复,切换后不得残留旧语言的节点。
|
||||
- 导出抽屉(`report-export-drawer.tsx`)从边缘滑入;手机上从底部滑入。
|
||||
|
||||
**验收**
|
||||
- BUG-1126 的回归测试仍然通过。
|
||||
- 删除会话、删除人物的接口失败时,行会回到原位,并给出现有的错误提示。
|
||||
|
||||
## 让步顺序
|
||||
|
||||
做不完时按以下顺序先砍:
|
||||
|
||||
1. 中英切换的逐段错开(改成整段一起淡入淡出)
|
||||
2. 换人时的格线重画
|
||||
3. 星历日期的上下滚动(只保留内容翻页)
|
||||
4. 数字滚动
|
||||
|
||||
页面切换、侧栏选中块、弹窗、抽屉、顶部提示、盘型切换不让。
|
||||
|
||||
## 循环交接
|
||||
|
||||
- **执行方**:Grok(Windows + Docker)。每个 issue 一个分支,名字写在下表;只推自己的分支。
|
||||
- **Grok 自跑**:`tsc --noEmit`、`npm run lint`、相关测试文件、`npm run build`(记录 `/` 是否 Static 和首屏 gzip)。
|
||||
- **Claude 验收时跑**:前端全量 `npm test`,逐条对比基线;首屏 gzip 用同一种量法复核;DESIGN.md 是否同步更新。
|
||||
- **真机清单**:`docs/testing/site-motion-20261022.md`,由 issue 一创建,后两个 issue 往里追加。清单逐条写「点哪里、应该看到什么」,交给产品负责人走。
|
||||
- 每个 issue 在同一变更里更新 `docs/BUG_HISTORY.md`(体验改进类记 `enhancement`)、`CHANGELOG.md` 和 `frontend/DESIGN.md`。
|
||||
|
||||
| issue | 内容 | 分支 | 前置 |
|
||||
| --- | --- | --- | --- |
|
||||
| 全站动效(一) | M0 + M1 + M2 | `codex/site-motion-1-20261022` | 新用户启程(三)合入 |
|
||||
| 全站动效(二) | M3 | `codex/site-motion-2-20261022` | 全站动效(一)合入 |
|
||||
| 全站动效(三) | M4 | `codex/site-motion-3-20261022` | 全站动效(二)合入 |
|
||||
|
||||
## 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b <上表分支> .worktrees/<上表分支去掉 codex/> origin/staging
|
||||
cd .worktrees/<…> && git status -sb
|
||||
grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
|
||||
cd frontend && npm ci && ./node_modules/.bin/tsc --noEmit && npm run lint && npm run build # 记下基线 gzip
|
||||
```
|
||||
@@ -0,0 +1,260 @@
|
||||
# TASK · 界面统一换成组件库:shadcn/ui · Lucide · Motion · react-resizable-panels · dnd-kit · Zustand(2026-10-23)
|
||||
|
||||
可交互原型(保持现在的 Claude 风格,全部用下列依赖实现,带「显示组件来源」开关):https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG 。原型和本任务书不一致时,以本任务书为准。**产品 2026-10-23 确认原型定稿(第 5 版)**,验收时以它为视觉参照。原型的数据是虚构的。
|
||||
|
||||
## 基线
|
||||
|
||||
- 写本单时的 `origin/staging` 是 `3632b0eab`。每个 issue 开工时,以当时的 `origin/staging` 为准。
|
||||
- BUG 编号:开工时用 `grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1` 核对最大号,从下一个号开始(写本单时是 BUG-1420)。
|
||||
- 本单拆成 8 个 issue,串行执行,见文末「循环交接」。其中 K1 和 K4 要动表(加排序列),产品已经同意。
|
||||
|
||||
## 现状实证(2026-10-23 在 staging 代码上实测)
|
||||
|
||||
1. **shadcn 装了,但几乎没用。**
|
||||
- `frontend/components.json` 已经配成 `style: "base-nova"`、`iconLibrary: "lucide"`。
|
||||
- 但 `src/components/ui/` 下只有 8 个组件:button、calendar、popover、select、sidebar、sonner、tabs、textarea。
|
||||
- 业务代码(不含后台)里有手写 `<button>` 148 处、原生 `<dialog>` 8 个、自写 `role="dialog"` 弹层 3 个,还有 4 处自己 `createPortal`。
|
||||
2. **样式全靠手写。** `src/app/globals.css` 有 7002 行、902 个自定义类、14 段 `@keyframes`。同一种控件各写一份,例如:
|
||||
- 会话行菜单 `.session-actions` / `.session-action-item`,其实就是一个手写的 DropdownMenu;
|
||||
- 账户入口 `.profile-trigger`;
|
||||
- `.new-chat`、`.report-nav-button` 等。
|
||||
3. **漏引 shadcn 的变体规则。** `globals.css` 没有 `@import "shadcn/tailwind.css"`。base-nova 组件用到的 `data-horizontal:`、`data-vertical:`、`data-active:` 等变体都定义在这个文件里,漏引时页签方向、分段按钮拼接、选中态都会错。原型第一版就踩过这个坑。
|
||||
4. **动效分散。** 有 14 段手写关键帧;`chat-message-row.tsx` 里用了一处 GSAP。
|
||||
5. **状态分散。** 首页 `Home()` 和各个 hook 里共有 171 处 `useState`、2 个 context,状态靠参数一层层往下传。
|
||||
6. **没有拖拽,也没有可拖宽的分栏。** 侧栏宽度是写死的;想看星盘只能单独开一页。
|
||||
7. 手写控件最多的文件(按钮、弹窗、菜单出现次数):
|
||||
- `email-otp-login.tsx` 18
|
||||
- `people/people-page.tsx` 12
|
||||
- `report-export-drawer.tsx` 7
|
||||
- `synastry-add-dialog.tsx` 6
|
||||
- `chart-type-picker.tsx` 6
|
||||
- `birth-time-choice-question.tsx` 6
|
||||
- `onboarding-redeem-paywall.tsx` 5
|
||||
- `birth-time-candidate-result.tsx` 5
|
||||
- `app/(app)/page.tsx` 5
|
||||
- 其余见各 issue 的文件清单。
|
||||
|
||||
## 根因
|
||||
|
||||
- 引入 shadcn 的时候只拷了几个组件,之后每个新功能都自己写控件和样式;没有规矩、也没有检查拦住手写。
|
||||
- 动效、状态、拖拽都没有定好用哪个库,于是各写各的。
|
||||
|
||||
## 决策记录(产品负责人 2026-10-23 拍板,全部同意)
|
||||
|
||||
| # | 决定 | 推翻了什么 |
|
||||
| --- | --- | --- |
|
||||
| D1 | **加 4 个依赖、去掉 1 个**:加 `motion`、`react-resizable-panels`(v4,经 shadcn `Resizable`)、`@dnd-kit/core` + `@dnd-kit/sortable` + `@dnd-kit/utilities`、`zustand`;去掉 `gsap` 和 `@gsap/react`。版本固定到发布满两周的版本 | AGENTS.md §7.7「不得顺手升级依赖」:本单是产品点名授权的新增 |
|
||||
| D2 | **动效一律用 Motion** | `TASK-site-motion-20261022.md` 的 D3「不引入动画库」,以及 `TASK-onboarding-journey-20261022.md` 红线 8 的「用 CSS 和 Web Animations API」。site-motion 的**动效规范表仍然有效**,按表里的时长和曲线做成 Motion 预设;它的 M0~M4 并入本单 |
|
||||
| D3 | **已有依赖能正确表达的组件和行为,严禁自行设计实现**,并加 lint 自动拦截 | 已写进 AGENTS.md §7 第 12 条 |
|
||||
| D4 | **首屏 ±2% 不放宽**。Motion、dnd-kit、react-resizable-panels 按需加载,不进首屏 | 已写进 AGENTS.md §7 第 2 条 |
|
||||
| D5 | **新功能:在对话旁边拉出星盘面板**,可以拖宽、可以收起 | 无 |
|
||||
| D6 | **会话和星盘档案可以拖动排序,顺序跨设备保留**。要加排序列,产品同意动表 | 无 |
|
||||
| D7 | **长相不变**:配色、字体、圆角沿用现有的 Claude 风格设计令牌,映射成 shadcn 的 CSS 变量(`--background`、`--primary` 等)。本单只换实现,不换长相 | 无 |
|
||||
| D9 | **二选一、多选一的切换一律用分段式 `Tabs`**(盘型、中英、主题等),不用描边的 `ToggleGroup`;`ToggleGroup` 只留给可多选或标签式的场景 | 无 |
|
||||
| D8 | **文案规矩**按 AGENTS.md §7 第 9~11 条执行:删掉解释布局的副标题、说明句、重复标签,可访问名称保留 | 无 |
|
||||
|
||||
## 硬红线
|
||||
|
||||
1. 不得手抄或改写 shadcn 组件。缺什么组件就用 `npx shadcn@<固定版本> add <组件名>` 加。加进来之后只许改设计令牌和类名组合,不许改组件的行为。
|
||||
2. `globals.css` 第一行之后必须 `@import "shadcn/tailwind.css";`。没有这一行,验收直接不通过。
|
||||
3. 首屏:`next build` 后 `/` 仍为 `○ Static`,首屏 gzip ±2%。Motion 用 `LazyMotion` + `m` 组件,`domAnimation` 特性包异步加载;dnd-kit、react-resizable-panels 只在用到的页面里动态引入。
|
||||
4. 揭幕后不得出现新的等待动画(AGENTS.md §6)。动效只回应用户操作;一律包在 `MotionConfig reducedMotion="user"` 里,尊重系统的减少动态效果设置。
|
||||
5. 冻结合同:`Home()` 的 `useState` / `useRef` 数只许减少(状态迁进 Zustand 后应当明显下降)。`ChatComposer` 仍然是唯一的输入框,只是改为用 shadcn `InputGroup` 实现。
|
||||
6. 不得改动业务行为和数据:同样的输入,接口请求、扣点、落库都必须和改前一致。每个 issue 都要对比改前改后的全量测试;改了既有断言要写「原值 / 新值 / 原因」三栏。
|
||||
7. 删除手写类和关键帧之前,按 AGENTS.md §7 第 8 条先 grep 源码文本合同测试,同一轮跟着改。
|
||||
8. 动表只加列(排序列可为空,有默认值),对已部署代码向后兼容;必须真跑 `npm run test:db`。
|
||||
9. 星盘画法、星图、报告排版、天空过场这些占星专有的绘制保留手写,不算违规;但其中的按钮、菜单等控件仍要换成组件。
|
||||
10. 文案按 D8 删减时,读屏名称不能跟着删:图标按钮要有 `aria-label`,表单控件要有可访问名称。
|
||||
|
||||
## 任务分解
|
||||
|
||||
### K0 底座与规矩
|
||||
|
||||
- 加 D1 列的依赖,去掉 gsap。`chat-message-row.tsx` 里的 GSAP 改用 Motion,效果不变。
|
||||
- `globals.css` 加 `@import "shadcn/tailwind.css"`。把现有设计令牌映射到 shadcn 变量,深色、浅色都要映射(D7)。
|
||||
- 用 shadcn CLI 加以下组件:
|
||||
- dialog、alert-dialog、sheet、drawer
|
||||
- dropdown-menu、tooltip、card、badge、table、separator、scroll-area、avatar
|
||||
- input、input-group、label、field、toggle、toggle-group、resizable
|
||||
- checkbox、radio-group、switch、collapsible、empty、kbd、skeleton
|
||||
- 说明:skeleton 只给星盘页 BUG-1016 那一处例外用。
|
||||
- `src/lib/motion/`:
|
||||
- 把 `TASK-site-motion-20261022.md` 动效规范表里的每一档做成 Motion 预设(时长、曲线、方向);
|
||||
- 包装好 `LazyMotion` 和 `MotionConfig`;
|
||||
- 不另写任何动画逻辑。
|
||||
- `src/stores/`:先建骨架,有会话、人物、账户、界面四个 store。状态在后面各个 issue 里逐步迁入。
|
||||
- **lint 规则**:在 `src/components/ui/**` 以外:
|
||||
- 出现 JSX 原生 `button`、`dialog`、`select`、`input type=checkbox|radio`,或 `role="dialog|menu|menuitem|tablist|tab|listbox"`,或 `createPortal`,就报错;
|
||||
- 用字符充当图标(☰ ↑ × ⋯ ✓ ▾ 等出现在 JSX 文本里)也报错。
|
||||
- **存量白名单**:迁移前已有的违规文件列进白名单,再加一条测试,断言白名单只能变短、不能变长。
|
||||
- 星盘和星图里的 SVG 绘制不受图标规则限制,规则里写明豁免哪些路径。
|
||||
|
||||
**验收**
|
||||
- `npm ls` 里能看到新依赖,看不到 gsap。
|
||||
- 往 `src/components/` 下新加一个含 `<button>` 的文件,lint 报错。
|
||||
- 首屏 gzip ±2%,`/` Static。
|
||||
- 界面截图和改前一致:K0 只加底座,不换控件。
|
||||
|
||||
### K1 外壳:侧栏、会话列表、账户菜单、设置、页面切换
|
||||
|
||||
文件:`app-sidebar.tsx`、`sidebar-session-row.tsx`、`theme-preference-menu.tsx`、`account-dialog-overlay.tsx`、`profile-panel.tsx`、`billing-panel.tsx`、`account-deletion-section.tsx`、`account-deletion-gate.tsx`、`feedback-dialog.tsx`、`subject-switcher.tsx`,以及 `globals.css` 里对应的手写类。
|
||||
|
||||
- **侧栏**:
|
||||
- 用 shadcn `Sidebar` 的菜单组件;
|
||||
- 桌面上放进 `ResizablePanelGroup`,可以拖宽(200–360 px),拖到最左收起,宽度记在本机;
|
||||
- 手机上用 `Sheet` 从左边滑出;
|
||||
- 当前页的高亮底色用 Motion 的 `layoutId` 滑动。
|
||||
- **会话行**:
|
||||
- 更多菜单用 `DropdownMenu`(收藏、重命名、转发、删除);
|
||||
- 删除前弹 `AlertDialog`;
|
||||
- 拖动排序用 dnd-kit,键盘也能排;
|
||||
- 顺序写进新加的排序列(D6,动表);收藏的会话仍然排在最前。
|
||||
- **账户菜单**:
|
||||
- 用 `DropdownMenu`,主题是 `DropdownMenuRadioGroup`;
|
||||
- 触发按钮只显示头像和名字,点数不在这里重复显示(D8)。
|
||||
- **设置弹窗**:
|
||||
- 用 `Dialog` + `Tabs`,三个分区名不变;主题选择也用分段式 `Tabs`;
|
||||
- 注销确认用 `AlertDialog`,反馈用 `Dialog`。
|
||||
- **页面切换**:Motion 的 `AnimatePresence`,方向跟随侧栏顺序(site-motion 规范表的「页面切换」档)。
|
||||
- **顶栏点数**:
|
||||
- 改成正常大小的 `Button variant="outline"`,内容是 Lucide `Coins` 图标加数字;
|
||||
- 「点」字只放在 `aria-label` 里;
|
||||
- 数字变化时上下滚动。
|
||||
- 现有的手写类 `.session-row`、`.session-actions`、`.profile-trigger`、`.new-chat`、`.report-nav-button` 等,迁移后删除,并从 lint 白名单里移出对应文件。
|
||||
|
||||
**验收**
|
||||
- 拖动会话排序后刷新、换设备,顺序都保持(DB 测试加交互测试)。
|
||||
- 快速连点侧栏 5 次,最终停在最后点的那一页,没有残留的旧页。
|
||||
- 侧栏拖到最左能收起,宽度刷新后仍然记得。
|
||||
- 手机 375 px 宽时侧栏是抽屉,页面不横向滚动。
|
||||
|
||||
### K2 对话页
|
||||
|
||||
文件:`chat-composer.tsx`、`chat-message-row.tsx`、`chat-message-actions.tsx`、`conversation-follow-ups.tsx`、`reply-down-reason.tsx`、`model-selector.tsx`、`onboarding-redeem-paywall.tsx`、`starter-home.tsx`、`app/(app)/page.tsx`、`jump-to-latest-button.tsx`。
|
||||
|
||||
- **输入框**:
|
||||
- `ChatComposer` 改用 `InputGroup` + `InputGroupTextarea`;
|
||||
- 底部 `InputGroupAddon align="block-end"` 放两样东西:左边是模型选择(`DropdownMenu`),右边是圆形发送按钮;
|
||||
- 发送按钮和输入框同在一个框里,底边对齐;
|
||||
- 模型选择从顶栏移进输入框,各模型的价格只在菜单里显示。
|
||||
- **回答操作**:赞、踩、复制、重新生成用 `Button size="icon-sm"`,每个配 `Tooltip`;「不满意原因」用 `Dialog`。
|
||||
- **追问**:用 `Button variant="outline" size="sm"`,依次浮出(Motion 的 stagger)。
|
||||
- **兑换窗**:改用 `Dialog`,文案不变。
|
||||
- **对话旁的星盘面板(D5)**:
|
||||
- 顶栏加一个 `PanelRightOpen` / `PanelRightClose` 图标按钮,配 `Tooltip`,带 `aria-label`;
|
||||
- 面板放在 `ResizablePanelGroup` 里,可以拖宽、可以收起,宽窄记在本机;
|
||||
- 面板里是当前人物的本命盘(复用星盘页的绘制组件);
|
||||
- 手机上不显示这个入口。
|
||||
- **对话状态**:当前会话、消息、流式状态迁进 Zustand 的会话 store;`Home()` 里对应的 `useState` 删掉。
|
||||
- **流式输出**:逐字释放的逻辑不改;消息进场用 Motion。
|
||||
|
||||
**验收**
|
||||
- 回归原有的对话测试:流式、停止、排队、撤回、退点、寒暄免费。
|
||||
- `Home()` 的 hook 数下降,写进进度记录。
|
||||
- 星盘面板打开、拖宽、收起、刷新后仍然记得状态。
|
||||
|
||||
### K3 星盘页
|
||||
|
||||
文件:`chart-page/*`(`chart-page-view.tsx`、`chart-type-picker.tsx`、`chart-vedic-tab.tsx`、`chart-dasha-tab.tsx`、`chart-basics-tab.tsx`、`chart-western-tab.tsx`、`chart-birth-editor.tsx`、`chart-param-table.tsx`、`chart-symbol-help.tsx`)。
|
||||
|
||||
- **四个页签**:用 `Tabs`,`variant="line"`。
|
||||
- **盘型条**(本命、D9、D10、月亮…):用分段式 `Tabs`(`TabsList` 默认变体:灰底槽,选中的一格浮起亮底),盘型多时用 `ScrollArea` 横向滚动;不用描边的 `ToggleGroup`(产品 10-23 看过,嫌丑)。「星盘类型」面板用 `Popover` 或 `Sheet`。
|
||||
- **盘与参数**:桌面上两栏放在 `ResizablePanelGroup` 里,可以拖宽;手机上上下排。
|
||||
- **行星表**:
|
||||
- 用 `Table`,**跨满整行**(继承 #102 的修复,不得回归);
|
||||
- 状态列统一用 `Badge`:落陷、敌用 `destructive`,其余用 `secondary`,没有状态就写「—」。同一列不得出现两种徽标形状。
|
||||
- **出生资料就地编辑**:用 `Popover` 或 `Dialog`,表单用 `Field` 和 `Input`。
|
||||
- **Motion**:换盘型时星体从旧宫位移到新宫位;换人时格线重画;大运展开用 `Collapsible` 加 Motion。
|
||||
- 星盘 SVG 的绘制保留手写(红线 9)。
|
||||
|
||||
**验收**
|
||||
- 切盘型后星体最终坐标和直接渲染一致(坐标断言)。
|
||||
- D1 字节冻结测试仍然通过。
|
||||
- 1024、1280、390 三种宽度下截图都正常。
|
||||
|
||||
### K4 星盘档案与婚恋合盘
|
||||
|
||||
文件:`people/people-page.tsx`、`people-home-links.tsx`、`chart-profile-form.tsx`、`profile-gender-field.tsx`、`synastry/*`(`synastry-add-dialog.tsx`、`synastry-picker.tsx`、`synastry-report-view.tsx` 等)。
|
||||
|
||||
- **人物卡**:用 `Card`,加 `CardAction`,更多菜单用 `DropdownMenu`;编辑用 `Dialog`;删除用 `AlertDialog`。
|
||||
- **拖动排序**:用 dnd-kit 的网格排序,顺序写进 `chart_profiles` 的新排序列(D6,动表);本人始终排在第一。
|
||||
- **关系选择**:用 `ToggleGroup`,表单的标签和读屏名称都要保留(D8)。
|
||||
- **合盘选人页**:
|
||||
- 去掉页内重复的标题和副标题(顶栏已经有页名);
|
||||
- 两个 `Select` 左右并排,中间一个 Lucide `X`(`aria-hidden`);
|
||||
- 下方正中放「开始合盘」按钮,图标用 Lucide `HeartHandshake`(和侧栏一致,不用 `Sparkles`);按钮上不写价格。点了弹 `AlertDialog` 确认:「会用掉 {价格} 点,余额 {余额} 点。」,按钮是「取消 / 开始」;余额不够时说「需要 {价格} 点,余额只有 {余额} 点。」,主按钮换成「去补点数」(产品 10-23 定);
|
||||
- 手机上两个选择框上下排;
|
||||
- 两个 `Select` 都有可访问名称「第一个人」「第二个人」。
|
||||
|
||||
**验收**
|
||||
- 拖动排序后刷新、换设备,顺序都保持。
|
||||
- 删人后,相关的合盘和报告照原逻辑处理。
|
||||
- 合盘按钮在桌面和手机上都居中。
|
||||
|
||||
### K5 报告、星历、登录与其余页面
|
||||
|
||||
文件:
|
||||
- `personal-report/*`:`report-actions.tsx`、`report-export-drawer.tsx`、`personal-report-center.tsx`、`report-edge-jump.tsx`、`report-row-delete.tsx`
|
||||
- `ephemeris/*`
|
||||
- `email-otp-login.tsx`、`legal/*`、`app/global-error.tsx`、`markdown-code-block.tsx`、`birth-sky/birth-sky-replay.tsx` 里的控件
|
||||
|
||||
具体改法:
|
||||
- **中英切换**:用分段式 `Tabs`(同盘型条),带 `aria-label="报告语言"`;不用描边的 `ToggleGroup`。
|
||||
- **导出**:用 `Sheet`(桌面从右侧滑出,手机从底部);删掉说明句和文件大小(D8)。
|
||||
- **星历翻日期**:用 `Button` 加 Lucide 箭头,内容按方向翻页(Motion)。卡片不得被翻页容器裁掉边框,原型第一版就出过这个问题。
|
||||
- **登录页**:输入框和按钮换成组件;验证码输入用 shadcn `InputOTP`。
|
||||
|
||||
**验收**
|
||||
- BUG-1126 回归测试通过(中英切换后不残留旧语言节点)。
|
||||
- 登录页 E2E(或交互测试)全过。
|
||||
|
||||
### K6 生时校正界面
|
||||
|
||||
文件:`birth-time-*`、`rectification-*` 系列组件(`birth-time-choice-question.tsx`、`birth-time-candidate-result.tsx`、`birth-time-rectification.tsx`、`rectification-choice-card.tsx`、`rectification-board.tsx` 等)。
|
||||
|
||||
- 只换控件和动效,**题目逻辑、计分、落库一概不动**。
|
||||
- 选项卡片改用 `ToggleGroup` 或 `RadioGroup`。
|
||||
- 排在 #103(校正入口修复)合入之后。
|
||||
|
||||
**验收**:校正的全量前端测试、Python `test_rectification_*`,改前改后逐条一致。
|
||||
|
||||
### K7 收尾
|
||||
|
||||
- 删掉 `globals.css` 里已经没人用的手写类和 `@keyframes`,进度记录里写清改前改后的行数。
|
||||
- lint 白名单清空(占星绘制的豁免除外)。
|
||||
- 重写 `frontend/DESIGN.md`:
|
||||
- 第一节改为「组件一律用 shadcn base-nova,令牌如下」,动效节改为 Motion 预设表;
|
||||
- 旧的 `CLAUDE_DESIGN.md` 改编说明降为参考。
|
||||
- 更新 `frontend/docs/VOICE.md`:按 D8 删掉已不存在的说明句。
|
||||
|
||||
## 让步顺序
|
||||
|
||||
做不完时,按下面的顺序先砍:
|
||||
|
||||
1. K7 里 `globals.css` 的彻底清理(白名单仍然只减不增)
|
||||
2. 星盘面板宽窄的本机记忆
|
||||
3. 换人时的格线重画
|
||||
4. 拖动排序的键盘操作(鼠标拖动不让)
|
||||
|
||||
D3 的 lint 规则、`shadcn/tailwind.css`、首屏体积、文案规矩、读屏名称都不能让。
|
||||
|
||||
## 循环交接
|
||||
|
||||
- **执行方**:Grok(Windows + Docker)。每个 issue 一个分支,只推自己的分支。
|
||||
- **Grok 自跑**:`tsc --noEmit`、`npm run lint`、相关测试文件、`npm run build`(记录 `/` 是否 Static 和首屏 gzip)。动表的 issue 还要跑 `npm run test:db`,与 `tools/agent-loop/windows-known-failures.txt` 逐条比对。
|
||||
- **Claude 验收时跑**:前端全量 `npm test`,逐条对比基线;复核首屏 gzip;对照原型截图检查。
|
||||
- **真机清单**:`docs/testing/ui-kit-migration-20261023.md`,由 K1 创建,后面的 issue 往里追加。
|
||||
- 每个 issue 在同一变更里更新 `docs/BUG_HISTORY.md`(体验改进类记 `enhancement`)、`CHANGELOG.md` 和 `frontend/DESIGN.md`。
|
||||
|
||||
| issue | 内容 | 分支 | 前置 |
|
||||
| --- | --- | --- | --- |
|
||||
| 组件库(K0) | 底座与规矩 | `codex/ui-kit-0-base-20261023` | #95 合入 |
|
||||
| 组件库(K1) | 外壳 | `codex/ui-kit-1-shell-20261023` | #97 合入 |
|
||||
| 组件库(K2) | 对话页 | `codex/ui-kit-2-chat-20261023` | K1 合入 |
|
||||
| 组件库(K3) | 星盘页 | `codex/ui-kit-3-chart-20261023` | K2、#102 合入 |
|
||||
| 组件库(K4) | 档案与合盘 | `codex/ui-kit-4-people-20261023` | K3 合入 |
|
||||
| 组件库(K5) | 报告、星历、登录 | `codex/ui-kit-5-pages-20261023` | K4 合入 |
|
||||
| 组件库(K6) | 生时校正界面 | `codex/ui-kit-6-rectification-20261023` | K5、#103 合入 |
|
||||
| 组件库(K7) | 收尾 | `codex/ui-kit-7-cleanup-20261023` | K6 合入 |
|
||||
|
||||
新用户启程的几单也跟着调整:#96 改为等 K0 合入后再开工,新界面直接用组件库;#99(启程动效)改为依赖 K0 的 Motion 预设,不再依赖 #98。#98、#100、#101(全站动效一、二、三)并入 K1~K5,不再单独执行。
|
||||
@@ -0,0 +1,6 @@
|
||||
# 星盘页行星表整行(2026-10-23)
|
||||
|
||||
登录后打开星盘页。不要用真实出生资料做记录,用站内已有的示例盘即可。
|
||||
|
||||
1. 把窗口拉到大约 1280 宽。依次看「本命」「月亮」「D9」「Bhava」「行运」。每张表都占满盘和参数卡下面的整行,宿主、pada、状态不用横拖就能看见。
|
||||
2. 把窗口收到大约 390 宽(或手机)。页面本身不左右滚动。表如果放不下,只在表自己的框里横滑。
|
||||
+1
-1
@@ -1136,7 +1136,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
|
||||
**不再按断点复制参数。** 原本同一组参数有三种画法:中宫叠一张 `.chart-page-center-card`(手机两行、桌面六行),盘下方再来一张 `.chart-page-params-below` 重复那六行,由 media query 三选一。同样的事实画三遍、靠断点开关,改一处要改三处。键值表让每个字段只出现一次,`.chart-page-center-card` / `-compact` / `-extra` / `.chart-page-params-below` / `.chart-page-boundary-desktop` 五个类连同它们的 767/768 开关块一并删除,由 `chart-page-view.test.tsx` 守着不许回来。
|
||||
|
||||
桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。2026-09-29 起桌面印度盘宽 440px;当晚(BUG-1100,产品「框太丑」)上限从绘图区移到外框 `.chart-page-vedic-stage`(440px + 两侧 `--space-3` 内边距 + 边框),外框贴合星图,不再是一张宽卡片中间放一张窄盘;手机端外框不设上限;西洋桌面绘图区上限 480px。低于此断点的结果盘不收紧,参数区和表格不缩。等待盘与结果盘复用同一桌面列宽与绘图容器,不再使用独立 400px 等待盘上限;印度等待绘图容器以 400/452 比例预留结果图例带,内部空 SVG 仍为 400×400,避免揭幕时外框和说明区纵向跳动,不改共享绘图。只有盘位动(2026-09-30 起为宫位依次点亮,不再整张呼吸),失败后退出,参数表不新增骨架。
|
||||
桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。表格区由 `VedicLayout` 的整行容器承载,不靠表格自身类名跨栏。2026-09-29 起桌面印度盘宽 440px;当晚(BUG-1100,产品「框太丑」)上限从绘图区移到外框 `.chart-page-vedic-stage`(440px + 两侧 `--space-3` 内边距 + 边框),外框贴合星图,不再是一张宽卡片中间放一张窄盘;手机端外框不设上限;西洋桌面绘图区上限 480px。低于此断点的结果盘不收紧,参数区和表格不缩。等待盘与结果盘复用同一桌面列宽与绘图容器,不再使用独立 400px 等待盘上限;印度等待绘图容器以 400/452 比例预留结果图例带,内部空 SVG 仍为 400×400,避免揭幕时外框和说明区纵向跳动,不改共享绘图。只有盘位动(2026-09-30 起为宫位依次点亮,不再整张呼吸),失败后退出,参数表不新增骨架。
|
||||
|
||||
**符号说明(2026-09-29;当晚改为气泡,BUG-1101)。** `/chart` 开启共享 SVG 的可选交互模式:说明是标记旁的小气泡(`.chart-symbol-bubble`,最宽 220px,`--color-canvas` 底 + 细边 + `--shadow-elevated`),默认在标记上方 8px,离外框顶不足 48px 时翻到下方,左右被夹在外框内;气泡不接收指针,不会挡住旁边的标记。电脑:鼠标悬停出现、移开消失,点击与键盘聚焦同样出现。手机:轻点标记出现,触摸产生的离开事件不关它,点盘外任意处或 3 秒后自动消失。盘下不再有「停在或轻点……」提示行和说明行;读屏器仍从一个隐藏的 `role="status"` 读到同一句话;Esc、离开、失焦可关闭,切盘/人物/数据身份变化清理旧说明。行星与其逆行短线共用一个目标;西洋 caption 和命中圆用一致的行星解析,原点击过滤相位及再次点击取消保留。报告、校正、静态导出默认不启用该模式;纯绘图层不持有 hooks,静态 SVG 白名单不扩。
|
||||
|
||||
|
||||
@@ -5808,8 +5808,9 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
@media print { .chart-page-type-sheet { display: none !important; } }
|
||||
.chart-page-precision-hint,
|
||||
.chart-page-table-note { margin: 0 0 var(--space-2); }
|
||||
/* A note + table pair is one grid item, so the table keeps its own scroll box. */
|
||||
.chart-page-varga-table-block { min-width: 0; }
|
||||
/* The table row is one grid item. A note and its table stay in one scroll box. */
|
||||
.chart-page-varga-table-block,
|
||||
.chart-page-vedic-tables { min-width: 0; }
|
||||
.chart-page-varga-table-block > .chart-page-planet-table-wrap + .chart-page-planet-table-wrap { margin-top: var(--space-4); }
|
||||
/* A body the Bhava boundaries move to another house. */
|
||||
.chart-page-planet-table tr.is-shifted > td,
|
||||
@@ -6341,10 +6342,9 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
card with empty sides around a 440px chart. 440 + the card's padding. */
|
||||
.chart-page-vedic-stage { width: 100%; max-width: calc(440px + 2 * var(--space-3) + 2px); }
|
||||
.chart-page-western-wheel { max-width: 480px; }
|
||||
/* Chart and parameters sit side by side; the planet table is a full-width
|
||||
row under both, because its columns need the whole measure. */
|
||||
.chart-page-planet-table-wrap,
|
||||
.chart-page-varga-table-block { grid-column: 1 / -1; }
|
||||
/* Chart and parameters sit side by side. The third grid item is the table
|
||||
row, so a wrapper around a table cannot drop it back into the left column. */
|
||||
.chart-page-vedic-tables { grid-column: 1 / -1; }
|
||||
.chart-page-basics-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
|
||||
@@ -350,7 +350,9 @@ function VedicLayout({ view, chart, ariaLabel, ascendantHouse, children }: {
|
||||
chart can and cannot be read for. */}
|
||||
<p className="chart-page-boundary">{view.vedic.boundary}</p>
|
||||
</div>
|
||||
{children}
|
||||
<div className="chart-page-vedic-tables">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
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 { assembleChartView } from "../src/lib/chart-view-load.ts";
|
||||
import { chartViewOkSchema, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"));
|
||||
const bhavaGolden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-bhava-golden.json", import.meta.url), "utf8"));
|
||||
const transitPacket = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
const profile = {
|
||||
name: "示例",
|
||||
date: "1990-06-15",
|
||||
time: "12:00",
|
||||
placeLabel: "北京",
|
||||
latitude: 39.9042,
|
||||
longitude: 116.4074,
|
||||
timezoneOffset: 8,
|
||||
timezoneId: "Asia/Shanghai",
|
||||
ayanamsa: "raman",
|
||||
birthTimeStatus: "confirmed",
|
||||
};
|
||||
|
||||
function directDivClasses(html: string, parentClass: string): string[] {
|
||||
const needle = `<div class="${parentClass}">`;
|
||||
const start = html.indexOf(needle);
|
||||
assert.ok(start >= 0, parentClass);
|
||||
let index = start + needle.length;
|
||||
let depth = 1;
|
||||
const classes: string[] = [];
|
||||
while (index < html.length && depth > 0) {
|
||||
const nextOpen = html.indexOf("<div", index);
|
||||
const nextClose = html.indexOf("</div>", index);
|
||||
assert.ok(nextClose >= 0);
|
||||
if (nextOpen >= 0 && nextOpen < nextClose) {
|
||||
if (depth === 1) {
|
||||
const tagEnd = html.indexOf(">", nextOpen);
|
||||
const tag = html.slice(nextOpen, tagEnd + 1);
|
||||
classes.push(tag.match(/class="([^"]*)"/)?.[1] ?? "");
|
||||
}
|
||||
depth += 1;
|
||||
index = html.indexOf(">", nextOpen) + 1;
|
||||
} else {
|
||||
depth -= 1;
|
||||
index = nextClose + "</div>".length;
|
||||
}
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
async function fullView(): Promise<ChartViewOk> {
|
||||
const result = await assembleChartView({
|
||||
userId: "user-1",
|
||||
profile,
|
||||
asOf: "2026-09-15",
|
||||
layers: ["varga", "bhava", "transit"],
|
||||
postEngine: async (path, _body, cacheScope) => {
|
||||
if (path === "/api/chart" && cacheScope?.startsWith("transit:")) return { status: "ok", payload: transitPacket };
|
||||
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
|
||||
if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full };
|
||||
if (path === "/api/bhava_chalit") return { status: "ok", payload: bhavaGolden.bhava_chalit };
|
||||
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
|
||||
},
|
||||
});
|
||||
assert.equal(result.body.status, "ok");
|
||||
return chartViewOkSchema.parse(result.body);
|
||||
}
|
||||
|
||||
test("every Vedic chart type puts its table in the layout's full-width row", async () => {
|
||||
const view = await fullView();
|
||||
for (const vargaId of ["D1", "MOON", "D9", "BHAVA", "TRANSIT"]) {
|
||||
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId, onSelectVarga: () => {} }));
|
||||
const children = directDivClasses(markup, "chart-page-vedic-layout");
|
||||
assert.deepEqual(children, [
|
||||
"chart-page-vedic-stage",
|
||||
"chart-page-params",
|
||||
"chart-page-vedic-tables",
|
||||
], vargaId);
|
||||
}
|
||||
});
|
||||
|
||||
test("the desktop chart grid spans the table row, not a table's own class", () => {
|
||||
const desktopStart = globalStyles.indexOf("@media (min-width: 1024px) {\n .chart-page-vedic-layout,");
|
||||
assert.ok(desktopStart >= 0);
|
||||
const desktopEnd = globalStyles.indexOf("\n}", desktopStart);
|
||||
const desktop = globalStyles.slice(desktopStart, desktopEnd);
|
||||
assert.match(desktop, /\.chart-page-vedic-tables \{ grid-column: 1 \/ -1; \}/);
|
||||
assert.doesNotMatch(desktop, /\.chart-page-planet-table-wrap[\s\S]*grid-column/);
|
||||
assert.doesNotMatch(desktop, /\.chart-page-varga-table-block[\s\S]*grid-column/);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"10th house occupied by lords of 10th, 2nd and 7th": "第10宫有10宫主、2宫主与7宫主",
|
||||
"10th lord conjoined with 9th lord": "10宫主与9宫主合相",
|
||||
"10th lord in 3rd house with Saturn": "10宫主与土星同在第3宫",
|
||||
"10th lord in 6th, 8th or 12th house": "10宫主在第6、8或12宫",
|
||||
"10th lord in trine from 7th house": "10宫主在第7宫起的三方宫",
|
||||
"10th lord's navamsa dispositor is exalted in 10th and lagna lord joins him": "10宫主 D9 定位星擢升落第10宫,且上升主与之相会",
|
||||
"11th lord conjoins Venus": "11宫主与金星相会",
|
||||
"11th lord in deep exaltation": "11宫主在深度擢升点",
|
||||
"12th lord occupies the 12th house (Vipareeta Raja Yoga)": "12宫主落第12宫(逆转王瑜伽)",
|
||||
"1st and 11th lords exchange": "1宫主与11宫主互换星座",
|
||||
"2nd and 10th lords exchange": "2宫主与10宫主互换星座",
|
||||
"2nd lord in 10th with malefics": "2宫主与凶星同在第10宫",
|
||||
"2nd lord in house of Saturn or Mars": "2宫主落在土星或火星守护的星座",
|
||||
"2nd lord joins Jupiter in 8th house": "2宫主与木星同在第8宫",
|
||||
"3rd house is a benefic sign aspected by benefic planets": "第3宫是吉星星座并受吉星相位",
|
||||
"3rd lord joins a benefic amsa (with or aspected by benefic)": "3宫主落吉星分度(与吉星同宫或受其相位)",
|
||||
"3rd lord joins the Moon": "3宫主与月亮相会",
|
||||
"4th and 10th lords exchange signs (Parivartana)": "4宫主与10宫主互换星座(互容)",
|
||||
"4th lord aspected by malefic": "4宫主受凶星相位",
|
||||
"4th lord exalted": "4宫主擢升",
|
||||
"5th house occupied by malefics": "第5宫有凶星",
|
||||
"5th lord aspected by another benefic or in benefic sign": "5宫主受另一吉星相位或落吉星星座",
|
||||
"5th lord conjunct malefics": "5宫主与凶星合相",
|
||||
"5th lord in 6th, 8th or 12th": "5宫主在第6、8或12宫",
|
||||
"5th lord is a natural benefic": "5宫主是天然吉星",
|
||||
"5th lord with benefic and Mars/Saturn in 5th": "5宫主与吉星同宫,且火星或土星在第5宫",
|
||||
"6th house further aspected by another malefic": "第6宫另受一颗凶星相位",
|
||||
"6th lord in conjunction with Venus": "6宫主与金星合相",
|
||||
"6th lord is a natural malefic": "6宫主是天然凶星",
|
||||
"6th lord occupies the 6th house (Vipareeta Raja Yoga)": "6宫主落第6宫(逆转王瑜伽)",
|
||||
"7 visible planets occupy exactly 3 distinct signs": "七颗可见行星恰好分布在三个星座",
|
||||
"7 visible planets occupy exactly 4 distinct signs": "七颗可见行星恰好分布在四个星座",
|
||||
"7 visible planets occupy exactly 5 distinct signs": "七颗可见行星恰好分布在五个星座",
|
||||
"7 visible planets occupy exactly 6 distinct signs": "七颗可见行星恰好分布在六个星座",
|
||||
"7 visible planets occupy exactly 7 distinct signs": "七颗可见行星恰好分布在七个星座",
|
||||
"7th lord conjunct Venus, aspected by Saturn": "7宫主与金星合相,并受土星相位",
|
||||
"7th lord exalted in 10th": "7宫主擢升落第10宫",
|
||||
"8th house is not Jupiter's own or exaltation sign": "第8宫不是木星的本座或擢升座",
|
||||
"8th lord occupies the 8th house (Vipareeta Raja Yoga)": "8宫主落第8宫(逆转王瑜伽)",
|
||||
"9th lord aspected by benefic": "9宫主受吉星相位",
|
||||
"9th lord exalted": "9宫主擢升",
|
||||
"All 7 visible planets exclusively in movable signs (Ar/Cn/Li/Cp)": "七颗可见行星只在基本星座(白羊、巨蟹、天秤、摩羯)",
|
||||
"All 7 visible planets in 7 consecutive houses starting from non-Kendra, each house occupied": "七颗可见行星从非角宫起连续占据七个宫,每宫都有星",
|
||||
"All planets exclusively in dual signs (Ge/Vi/Sg/Pi)": "所有行星都只在双体星座(双子、处女、射手、双鱼)",
|
||||
"All planets exclusively in fixed signs (Ta/Le/Sc/Aq)": "所有行星都只在固定星座(金牛、狮子、天蝎、水瓶)",
|
||||
"All planets in even houses (2,4,6,8,10,12) from Lagna": "所有行星都在上升起的偶数宫(2、4、6、8、10、12)",
|
||||
"All planets in odd houses (1,3,5,7,9,11) from Lagna": "所有行星都在上升起的奇数宫(1、3、5、7、9、11)",
|
||||
"Aspected by benefics": "受吉星相位",
|
||||
"BVR definition: TBD": "BVR 定义待补",
|
||||
"Benefic in 4th house": "吉星在第4宫",
|
||||
"Benefic in 9th house": "吉星在第9宫",
|
||||
"Benefics in Upachaya houses (3,6,10,11) from Lagna or Moon": "吉星在上升或月亮起的成长宫(3、6、10、11)",
|
||||
"Benefics in both 2nd and 12th from Lagna, not aspected by malefics": "上升两侧第2、12宫都有吉星,且不受凶星相位",
|
||||
"Born during daytime with waxing Moon": "白天出生且月亮渐盈",
|
||||
"Both 11th lord and Venus in a quadrant from lagna lord": "11宫主与金星都在上升主起的角宫",
|
||||
"Jupiter and Venus in kendras": "木星与金星在角宫",
|
||||
"Jupiter conjunct Saturn and Moon in 6th house": "木星与土星、月亮同在第6宫",
|
||||
"Jupiter in 12th house": "木星在第12宫",
|
||||
"Jupiter in 2nd or 5th house, conjoined or aspected by Mercury and Venus": "木星在第2或5宫,与水星和金星合相或受其相位",
|
||||
"Jupiter in 3rd house": "木星在第3宫",
|
||||
"Jupiter in 5th or 9th from Moon": "木星在月亮起第5或第9宫",
|
||||
"Jupiter in 5th or 9th from Venus": "木星在金星起第5或第9宫",
|
||||
"Jupiter in Lagna or aspects Lagna from watery sign": "木星在上升宫,或从水象星座相位上升宫",
|
||||
"Jupiter in own, friendly or exaltation sign": "木星在本座、友座或擢升座",
|
||||
"Jupiter is lord of 5th house (Lagna in Leo or Scorpio)": "木星守护第5宫(狮子或天蝎上升)",
|
||||
"Jupiter is weak (debilitated, combust, or enemy sign)": "木星偏弱(落陷、燃烧或在敌座)",
|
||||
"Jupiter weak or afflicted": "木星弱或受克",
|
||||
"Jupiter, Venus, Mercury and Moon all in kendras (1,4,7,10) from Lagna": "木星、金星、水星与月亮都在上升起的角宫(1、4、7、10)",
|
||||
"Lagna has benefics and not affected by malefics": "上升宫有吉星且不受凶星影响",
|
||||
"Lagna has malefics and not affected by benefics": "上升宫有凶星且不受吉星影响",
|
||||
"Lagna in a fixed sign": "上升在固定星座",
|
||||
"Lagna in fixed sign aspected by 6th lord": "上升在固定星座,并受6宫主相位",
|
||||
"Lagna in malefic sign (Ar/Sc/Cp/Aq) or Sg/Ta aspected by malefics": "上升在凶星星座(白羊、天蝎、摩羯、水瓶),或在射手、金牛并受凶星相位",
|
||||
"Lagna in watery sign with benefics OR Lagna lord is watery planet": "上升在水象星座且有吉星,或上升主是水象行星",
|
||||
"Lagna lord and 2nd lord exchange their places (Parivartana)": "上升主与2宫主互换星座(互容)",
|
||||
"Lagna lord and navamsa lord both in watery signs": "上升主与其 D9 所落星座的主星都在水象星座",
|
||||
"Lagna lord aspected by Saturn": "上升主受土星相位",
|
||||
"Lagna lord conjoined or aspected by Jupiter": "上升主与木星合相或受木星相位",
|
||||
"Lagna lord conjunct/aspected by malefics": "上升主与凶星合相或受凶星相位",
|
||||
"Lagna lord exalted in a quadrant with Jupiter's aspect": "上升主在角宫擢升,并受木星相位",
|
||||
"Lagna lord in Mercury sign with Mercury": "上升主落在水星守护的星座且与水星同宫",
|
||||
"Lagna lord in a dry sign": "上升主落在干燥星座",
|
||||
"Lagna lord is exalted": "上升主擢升",
|
||||
"Lagna lord joins Mars and Mercury in 4th or 12th": "上升主与火星、水星同在第4或12宫",
|
||||
"Lagna lord, his dispositor, the latter's dispositor in rasi and navamsa, all in kendras/trines/exaltation": "上升主、其定位星及再下一层定位星在本命盘与 D9 中都落角宫、三方宫或擢升",
|
||||
"Lagna malefic/Gulika in trine OR Gulika with Kendra/Trine lords OR L1 with Rahu/Sat/Ket": "上升宫有凶星且 Gulika 在三方宫,或 Gulika 与角宫、三方宫主同宫,或上升主与罗睺、土星、计都同宫",
|
||||
"Lord of navamsa occupied by 4th lord in 12th": "4宫主所落 D9 星座的主星在第12宫",
|
||||
"Lord of navamsa occupied by 4th lord in kendra from Lagna and Chandra Lagna": "4宫主所落 D9 星座的主星在上升与月亮上升起的角宫",
|
||||
"Lord of navamsa occupied by Moon is exalted in rasi": "月亮所落 D9 星座的主星在本命盘擢升",
|
||||
"Lord of sign occupied by lagna lord, or navamsa dispositor thereof, in kendra/trine/own/exaltation": "上升主所落星座的主星,或其 D9 定位星,在角宫、三方宫、本座或擢升座",
|
||||
"Lord of sign occupied in navamsa by 2nd/5th/11th lord, exalted and joins the 9th lord": "2/5/11宫主在 D9 所落星座的主星擢升,并与9宫主相会",
|
||||
"Lords of 10th and 6th in Lagna with 2nd lord": "10宫主、6宫主与2宫主同在上升宫",
|
||||
"Lords of 10th and 6th in Neechamsat (debilitation)": "10宫主与6宫主在 D9 落陷",
|
||||
"Lords of 4th and 10th conjoined with Saturn and Mars": "4宫主与10宫主和土星、火星合相",
|
||||
"Lords of Aroodha Lagna (A1/AL) and Aroodha Dwadasa (A12/UL) in conjunction": "Arudha 上升(A1/AL)与 Arudha 第12宫(A12/UL)的主星合相",
|
||||
"Lords of Lagna, 7th and 5th are weak": "上升主、7宫主与5宫主都弱",
|
||||
"Malefic 6th lord in Lagna, 8th or 10th": "凶性6宫主在上升宫、第8或10宫",
|
||||
"Malefics in both 2nd and 12th from Lagna, not aspected by benefics": "上升两侧第2、12宫都有凶星,且不受吉星相位",
|
||||
"Malefics join Rasi Lagna": "凶星落本命上升宫",
|
||||
"Malefics join kendras and trines": "凶星同时落入角宫与三方宫",
|
||||
"Malefics join trines": "凶星落入三方宫",
|
||||
"Malefics occupy 12th, 7th and Lagna": "凶星占据第12宫、第7宫与上升宫",
|
||||
"Malika Yoga (planets in consecutive houses) starting from 11th house": "从第11宫起的 Malika 格局(行星连续占宫)",
|
||||
"Mandi and Rahu in 3rd house": "Mandi 与罗睺同在第3宫",
|
||||
"Mandi in 2nd house": "Mandi 在第2宫",
|
||||
"Mars and Saturn in 5th house": "火星与土星同在第5宫",
|
||||
"Mars conjoins 3rd lord and Rahu/Saturn/Mandi in cruel shashtiamsas": "火星与3宫主及罗睺、土星或 Mandi 同在凶性六十分",
|
||||
"Mars in 2nd house": "火星在第2宫",
|
||||
"Mars in 3rd in Preta Puriha sashtiamsa (cruel)": "火星在第3宫,落 Preta Puriha 六十分(凶)",
|
||||
"Mars in 5/7/9, malefic moon, Saturn in 12th": "火星在第5、7或9宫,月亮受凶,土星在第12宫",
|
||||
"Mars in 7th from Sun": "火星在太阳起第7宫",
|
||||
"Mars is lord of 6th or 8th": "火星守护第6或第8宫",
|
||||
"Mars joins Saturn": "火星与土星相会",
|
||||
"Mercury 2nd from Sun": "水星在太阳起第2宫",
|
||||
"Mercury conjunct or aspected by a malefic": "水星与凶星合相或受凶星相位",
|
||||
"Mercury in 3rd house": "水星在第3宫",
|
||||
"Mercury in a quadrant": "水星在角宫",
|
||||
"Mercury is lord of both Lagna and 4th (Gemini or Virgo Lagna)": "水星同时守护上升与第4宫(双子或处女上升)",
|
||||
"Mercury, Jupiter and Venus each in kendra, trine or 2nd house": "水星、木星、金星各在角宫、三方宫或第2宫",
|
||||
"Method 1: 9th lord from 11th in 11th with Moon, aspected by 11th lord": "算法一:11宫起的9宫主与月亮同在第11宫,并受11宫主相位",
|
||||
"Method 2: 9th lord from Lagna and Moon in 11th": "算法二:上升起的9宫主与月亮同在第11宫",
|
||||
"Moon 11th from Mercury (BVR method)": "月亮在水星起第11宫(BVR 算法)",
|
||||
"Moon and Mars in conjunction in any house": "月亮与火星在同一宫合相",
|
||||
"Moon and Venus in 7th, malefics in 4th and 10th": "月亮与金星在第7宫,凶星在第4宫与第10宫",
|
||||
"Moon aspected by Lagna lord in 6th, 8th or 12th": "月亮受落在第6、8或12宫的上升主相位",
|
||||
"Moon conjunct Saturn, Mandi or Rahu": "月亮与土星、Mandi 或罗睺合相",
|
||||
"Moon in 10th from Sun": "月亮在太阳起第10宫",
|
||||
"Moon in 10th, Venus in 7th, malefics in 4th": "月亮在第10宫,金星在第7宫,凶星在第4宫",
|
||||
"Moon in 5th from Jupiter": "月亮在木星起第5宫",
|
||||
"Moon in 9th house": "月亮在第9宫",
|
||||
"Moon in Lagna": "月亮在上升宫",
|
||||
"Moon in conjunction with Saturn": "月亮与土星合相",
|
||||
"Moon is strong": "月亮有力",
|
||||
"Moon joins malefics in navamsa of Cancer or Scorpio": "月亮在巨蟹座或天蝎座 D9 与凶星同宫",
|
||||
"Navamsa Lagna owned by dry planet": "D9 上升由干燥行星守护",
|
||||
"No Jupiter aspect on Moon": "月亮不受木星相位",
|
||||
"Powerful lords of 5th and 7th join with 6th lord": "有力的5宫主与7宫主和6宫主相会",
|
||||
"PyJHora aligned source logic": "按 PyJHora 源码条件判定",
|
||||
"Rahu in 2nd house": "罗睺在第2宫",
|
||||
"Rahu in Lagna": "罗睺在上升宫",
|
||||
"Rahu in Lagna in conjunction with Moon": "罗睺在上升宫与月亮合相",
|
||||
"Raja Yoga disrupted by malefic associations or aspects": "王瑜伽因凶星同宫或相位受损",
|
||||
"Saturn aspects 5th lord OR Moon in 5th house": "土星相位5宫主,或月亮在第5宫",
|
||||
"Saturn exalted and in kendra": "土星擢升且在角宫",
|
||||
"Saturn in 12th house": "土星在第12宫",
|
||||
"Saturn in 2nd from Sun": "土星在太阳起第2宫",
|
||||
"Saturn in 5th house": "土星在第5宫",
|
||||
"Saturn in 9th house": "土星在第9宫",
|
||||
"Strong benefic in Lagna, 3rd or 5th, simultaneously aspecting 9th": "有力吉星在第1、3或5宫,同时相位第9宫",
|
||||
"Sun and Mandi in 2nd house": "太阳与 Mandi 同在第2宫",
|
||||
"Sun and Mercury in the same house (Budha-Aditya conjunction)": "太阳与水星同宫(水日合相)",
|
||||
"Sun and Saturn in 5th house": "太阳与土星同在第5宫",
|
||||
"Sun exalted and strong": "太阳擢升且有力",
|
||||
"Sun in 6th house in conjunction with a malefic": "太阳在第6宫与凶星合相",
|
||||
"Sun in 7th house": "太阳在第7宫",
|
||||
"Sun in kendra from Moon": "太阳在月亮起的角宫",
|
||||
"Sun in own, exalted or friendly sign": "太阳在本座、擢升座或友座",
|
||||
"Sun in the 10th house": "太阳在第10宫",
|
||||
"Sun joins lords of navamsa signs occupied by 6th and 9th lords": "太阳与6宫主、9宫主所落 D9 星座的主星相会",
|
||||
"Sun or Saturn with Rahu in cruel shashtiamsa": "太阳或土星与罗睺同在凶性六十分",
|
||||
"Sun strong in Leo": "太阳在狮子座有力",
|
||||
"Sun, Moon and Mars are in mutual trines (trikona) from each other": "太阳、月亮、火星彼此成三方关系",
|
||||
"Two benefics join in 7th, 9th or 10th": "两颗吉星同在第7、9或10宫",
|
||||
"Venus in own/exaltation sign (Ta/Li/Pi) and in kendra (1/4/7/10) from Lagna": "金星在本座或擢升座(金牛、天秤、双鱼)且在上升起的角宫(1、4、7、10)",
|
||||
"Venus, Saturn and Mars join Moon in 7th house": "金星、土星、火星与月亮同在第7宫",
|
||||
"Waning Moon in 5th house": "亏月在第5宫",
|
||||
"Weak Moon in 7th house": "弱月在第7宫"
|
||||
}
|
||||
@@ -647,6 +647,10 @@ def chinese_yoga_label(item: dict) -> str:
|
||||
|
||||
def chinese_combination(item: dict) -> str:
|
||||
"""Use the Chinese combination sentence. An English sentence is not translated word by word."""
|
||||
authored = str(item.get("combination_zh") or "").strip()
|
||||
if authored and re.search(r"[一-鿿]", authored) and not _has_prose_glue(authored):
|
||||
# Written in Chinese by the detector itself (yogas_doshas raj / dhana rows).
|
||||
return authored
|
||||
combination = str(item.get("combination") or "").strip()
|
||||
han = bool(re.search(r"[\u4e00-\u9fff]", combination))
|
||||
function = bool(_HAN_FUNCTION_RE.search(combination)) if combination else False
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+46
-3
@@ -162,6 +162,48 @@ STRENGTH_EN = {
|
||||
}
|
||||
|
||||
|
||||
# Chinese wording for the English ``note`` of semantic (BVR / PyJHora batch)
|
||||
# conditions. The note stays the English twin. One note maps to one Chinese
|
||||
# sentence, so the dedup key keeps the same equality classes (BUG-1418).
|
||||
_SEMANTIC_NOTES_ZH_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "references", "yoga_semantic_notes_zh.json")
|
||||
# A condition without a note used to print its type key. Keep one fixed
|
||||
# sentence per type so two matches of the same type still dedupe together.
|
||||
_SEMANTIC_TYPE_ZH = {
|
||||
"planet_in_house": "行星落在规则指定的宫位",
|
||||
"planet_in_lagna": "行星落在上升宫",
|
||||
}
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _semantic_notes_zh() -> Dict[str, str]:
|
||||
try:
|
||||
with open(_SEMANTIC_NOTES_ZH_PATH, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return {str(key): str(value) for key, value in data.items()} if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _semantic_text(cond: Dict, ctype: str):
|
||||
"""``cond.get("note", ctype)`` with Chinese text; the old value stays as ``.en``."""
|
||||
if "note" in cond:
|
||||
note = cond.get("note")
|
||||
zh = _semantic_notes_zh().get(str(note)) if note else None
|
||||
return BiText(zh, note) if zh else note
|
||||
zh = _SEMANTIC_TYPE_ZH.get(str(ctype))
|
||||
return BiText(zh, ctype) if zh else ctype
|
||||
|
||||
|
||||
def _template_text(zh: str, en) -> BiText:
|
||||
"""``BiText(zh, en)``; a rule template written only in English gets its Chinese sentence (BUG-1418)."""
|
||||
if not any("\u4e00" <= char <= "\u9fff" for char in zh):
|
||||
chinese = _semantic_notes_zh().get(zh)
|
||||
if chinese:
|
||||
return BiText(chinese, zh if en is None else en)
|
||||
return BiText(zh, en)
|
||||
|
||||
|
||||
def _combo(cond: Dict, default_zh: str, **values) -> BiText:
|
||||
"""Format the rule's combination template in Chinese (unchanged) and in English."""
|
||||
zh = cond.get("combo_template", default_zh).format(**values)
|
||||
@@ -172,7 +214,7 @@ def _combo(cond: Dict, default_zh: str, **values) -> BiText:
|
||||
en = en_template.format(**{key: (value.en if isinstance(value, BiText) else value) for key, value in values.items()})
|
||||
except (KeyError, IndexError, ValueError):
|
||||
en = None
|
||||
return BiText(zh, en)
|
||||
return _template_text(zh, en)
|
||||
|
||||
|
||||
def _get_dignity_level(planet: str, sign: str) -> str:
|
||||
@@ -1465,7 +1507,7 @@ class YogaEngine:
|
||||
combo = cond.get("combo_template", "自定义条件满足")
|
||||
if isinstance(combo, str):
|
||||
# Custom templates were never formatted; keep that, and pair the English text.
|
||||
combo = BiText(combo, cond.get("combo_template_en") if "combo_template" in cond else DEFAULT_COMBO_EN["自定义条件满足"])
|
||||
combo = _template_text(combo, cond.get("combo_template_en") if "combo_template" in cond else DEFAULT_COMBO_EN["自定义条件满足"])
|
||||
if callable(combo):
|
||||
combo = combo(ctx, bindings)
|
||||
strength = cond.get("strength", rule.get("strength", "中"))
|
||||
@@ -1788,7 +1830,8 @@ class YogaEngine:
|
||||
def _eval_semantic_condition(self, ctype, cond, ctx, rule, bindings):
|
||||
"""兼容批量抽取规则中的语义型条件。返回保守布尔匹配。"""
|
||||
def success(combo=None):
|
||||
return [{"combination": combo or cond.get("note", ctype), "strength": rule.get("strength", "中")}]
|
||||
text = combo or _semantic_text(cond, ctype)
|
||||
return [{"combination": text, "strength": rule.get("strength", "中")}]
|
||||
|
||||
def house(ref):
|
||||
if isinstance(ref, int):
|
||||
|
||||
@@ -29,6 +29,24 @@ NATURAL_BENEFICS = {'Jupiter', 'Venus', 'Mercury', 'Moon'}
|
||||
NATURAL_MALEFICS = {'Saturn', 'Mars', 'Sun', 'Rahu', 'Ketu'}
|
||||
|
||||
|
||||
# Chinese twins of the raj / dhana combination strings. ``combination`` keeps its
|
||||
# old text; the Chinese report reads ``combination_zh`` (BUG-1418).
|
||||
_PLANET_ZH = {
|
||||
'Sun': '太阳', 'Moon': '月亮', 'Mars': '火星', 'Mercury': '水星', 'Jupiter': '木星',
|
||||
'Venus': '金星', 'Saturn': '土星', 'Rahu': '罗睺', 'Ketu': '计都',
|
||||
}
|
||||
|
||||
|
||||
def _cn(planet) -> str:
|
||||
return _PLANET_ZH.get(str(planet), str(planet))
|
||||
|
||||
|
||||
def _houses_cn(houses) -> str:
|
||||
if isinstance(houses, (list, tuple)):
|
||||
return '、'.join(str(house) for house in houses)
|
||||
return str(houses)
|
||||
|
||||
|
||||
def calc_raj_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
"""
|
||||
计算 Raj Yogas(王者瑜伽)——权力、地位、社会影响力格局 v7.0
|
||||
@@ -109,6 +127,7 @@ def calc_raj_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'type': 'Raj Yoga',
|
||||
'subtype': 'dual_lordship',
|
||||
'combination': f'{lord}(H{k_houses}主+H{t_houses}主)',
|
||||
'combination_zh': f'{_cn(lord)}同时是第{_houses_cn(k_houses)}宫主(角宫)与第{_houses_cn(t_houses)}宫主(三方宫)',
|
||||
'formation_house': lord_house,
|
||||
'strength': 'strong' if lord_house in kendras else 'moderate',
|
||||
'interpretation': f'Raj Yoga——{lord}同时掌管角宫{k_houses}和三方宫{t_houses},且在{lord_house}宫,双重权力格局',
|
||||
@@ -134,6 +153,7 @@ def calc_raj_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'type': 'Raj Yoga',
|
||||
'subtype': 'conjunction',
|
||||
'combination': f'{kl}(角宫主) + {tl}(三方宫主)',
|
||||
'combination_zh': f'角宫主{_cn(kl)}与三方宫主{_cn(tl)}同宫',
|
||||
'formation_house': house_from_asc,
|
||||
'strength': 'strong' if house_from_asc in kendras else 'moderate',
|
||||
'interpretation': f'Raj Yoga——{kl}(角宫主)与{tl}(三方宫主)同宫在{SIGNS_LIST[kl_sign]},权力格局',
|
||||
@@ -146,6 +166,7 @@ def calc_raj_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'type': 'Raj Yoga',
|
||||
'subtype': 'mutual_aspect',
|
||||
'combination': f'{kl}(角宫主) ↔ {tl}(三方宫主)',
|
||||
'combination_zh': f'角宫主{_cn(kl)}与三方宫主{_cn(tl)}对宫互视',
|
||||
'interpretation': f'Raj Yoga——{kl}(角宫主)与{tl}(三方宫主)互看(对宫相位),权力格局',
|
||||
}
|
||||
results['yogas'].append(yoga)
|
||||
@@ -166,6 +187,7 @@ def calc_raj_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'type': 'Raj Yoga',
|
||||
'subtype': 'parivartana',
|
||||
'combination': f'{kl}(角宫主) ⇄ {tl}(三方宫主)',
|
||||
'combination_zh': f'角宫主{_cn(kl)}与三方宫主{_cn(tl)}互换星座',
|
||||
'interpretation': f'Raj Yoga——{kl}(角宫主)与{tl}(三方宫主)互容交换,权力格局强化',
|
||||
}
|
||||
results['yogas'].append(yoga)
|
||||
@@ -185,6 +207,7 @@ def calc_raj_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'type': 'Viparita Raja Yoga',
|
||||
'subtype': 'dusthana_in_dusthana',
|
||||
'combination': f'H{h}_Lord({lord}) in H{lord_house}',
|
||||
'combination_zh': f'{h}宫主{_cn(lord)}落第{lord_house}宫',
|
||||
'interpretation': f'Viparita Raja Yoga——{h}宫主{lord}落入{lord_house}宫(凶中凶),先苦后甜逆转格局',
|
||||
}
|
||||
results['yogas'].append(yoga)
|
||||
@@ -254,6 +277,7 @@ def calc_dhana_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'type': 'Dhana Yoga',
|
||||
'subtype': '2nd_11th_lords_strong',
|
||||
'combination': f'H2_Lord({lord_2}) in H{h2} + H11_Lord({lord_11}) in H{h11}',
|
||||
'combination_zh': f'2宫主{_cn(lord_2)}落第{h2}宫,11宫主{_cn(lord_11)}落第{h11}宫',
|
||||
'strength': 'strong' if h2 in KENDRA and h11 in KENDRA else 'moderate',
|
||||
'interpretation': f'Dhana Yoga——2宫主{lord_2}(H{h2})与11宫主{lord_11}(H{h11})均落强宫,财富积累格局',
|
||||
})
|
||||
@@ -268,6 +292,7 @@ def calc_dhana_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'type': 'Dhana Yoga',
|
||||
'subtype': '5th_9th_conjunction',
|
||||
'combination': f'H5_Lord({lord_5}) + H9_Lord({lord_9})',
|
||||
'combination_zh': f'5宫主{_cn(lord_5)}与9宫主{_cn(lord_9)}同宫',
|
||||
'strength': 'strong',
|
||||
'interpretation': f'Dhana Yoga——5宫主{lord_5}与9宫主{lord_9}同宫在{s},财富+幸运格局',
|
||||
})
|
||||
@@ -281,6 +306,7 @@ def calc_dhana_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'type': 'Dhana Yoga',
|
||||
'subtype': '5th_9th_mutual_aspect',
|
||||
'combination': f'H5_Lord({lord_5}) ↔ H9_Lord({lord_9})',
|
||||
'combination_zh': f'5宫主{_cn(lord_5)}与9宫主{_cn(lord_9)}对宫互视',
|
||||
'strength': 'moderate',
|
||||
'interpretation': f'Dhana Yoga——5宫主{lord_5}(H{h5})与9宫主{lord_9}(H{h9})互看,财富格局',
|
||||
})
|
||||
@@ -290,6 +316,7 @@ def calc_dhana_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'type': 'Dhana Yoga',
|
||||
'subtype': '5th_9th_strong_houses',
|
||||
'combination': f'H5_Lord({lord_5}) in H{h5} + H9_Lord({lord_9}) in H{h9}',
|
||||
'combination_zh': f'5宫主{_cn(lord_5)}落第{h5}宫,9宫主{_cn(lord_9)}落第{h9}宫',
|
||||
'strength': 'moderate',
|
||||
'interpretation': f'Dhana Yoga——5宫主{lord_5}与9宫主{lord_9}均落强宫,财富格局',
|
||||
})
|
||||
@@ -302,6 +329,7 @@ def calc_dhana_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'type': 'Dhana Yoga',
|
||||
'subtype': '2nd_9th_conjunction',
|
||||
'combination': f'H2_Lord({lord_2}) + H9_Lord({lord_9})',
|
||||
'combination_zh': f'2宫主{_cn(lord_2)}与9宫主{_cn(lord_9)}同宫',
|
||||
'strength': 'strong',
|
||||
'interpretation': f'Dhana Yoga——2宫主{lord_2}与9宫主{lord_9}同宫在{s},财富+幸运组合',
|
||||
})
|
||||
@@ -314,6 +342,7 @@ def calc_dhana_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'type': 'Dhana Yoga',
|
||||
'subtype': '11th_9th_conjunction',
|
||||
'combination': f'H11_Lord({lord_11}) + H9_Lord({lord_9})',
|
||||
'combination_zh': f'11宫主{_cn(lord_11)}与9宫主{_cn(lord_9)}同宫',
|
||||
'strength': 'strong',
|
||||
'interpretation': f'Dhana Yoga——11宫主{lord_11}与9宫主{lord_9}同宫在{s},收益+幸运组合',
|
||||
})
|
||||
@@ -329,6 +358,7 @@ def calc_dhana_yogas(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'type': 'Dhana Yoga',
|
||||
'subtype': f'{w_house}th_lord_benefic_conjunction',
|
||||
'combination': f'H{w_house}_Lord({wealth_lord}) + {benefic}',
|
||||
'combination_zh': f'{w_house}宫主{_cn(wealth_lord)}与{_cn(benefic)}同宫',
|
||||
'strength': 'moderate',
|
||||
'interpretation': f'Dhana Yoga——{w_house}宫主{wealth_lord}与吉星{benefic}同宫在{s},财富助力格局',
|
||||
})
|
||||
|
||||
@@ -32,19 +32,24 @@ _KAAL_EN = re.compile(r"\d+ of \d+")
|
||||
|
||||
|
||||
def _chapter_text(markdown: str, language: str) -> str:
|
||||
marker = "# 大运附表" if language == "zh" else "# Dasha Appendix"
|
||||
marker = "\n## 大运附表\n" if language == "zh" else "\n## Dasha Appendix\n"
|
||||
assert marker in markdown
|
||||
return markdown.split(marker, 1)[1]
|
||||
|
||||
|
||||
def _section_body(markdown: str, heading: str) -> str:
|
||||
marker = f"## {heading}"
|
||||
marker = f"### {heading}\n"
|
||||
start = markdown.index(marker) + len(marker)
|
||||
rest = markdown[start:]
|
||||
ends = [pos for pos in (rest.find("\n## "), rest.find("\n# ")) if pos >= 0]
|
||||
ends = [pos for pos in (rest.find("\n### "), rest.find("\n## "), rest.find("\n# ")) if pos >= 0]
|
||||
return rest[: min(ends)] if ends else rest
|
||||
|
||||
|
||||
def _heading_lines(markdown: str, level: int) -> list[str]:
|
||||
prefix = "#" * level + " "
|
||||
return [line[len(prefix):].strip() for line in markdown.splitlines() if line.startswith(prefix)]
|
||||
|
||||
|
||||
def test_chapter_titles_follow_the_task_groups() -> None:
|
||||
groups = _chapter_sections()
|
||||
assert len(groups) == len(CHAPTERS) == 6
|
||||
@@ -73,22 +78,26 @@ def test_empty_packet_chapters_are_headed_and_do_not_print_raw_keys() -> None:
|
||||
heading = title_zh if language == "zh" else title_en
|
||||
body = _section_body(text, heading)
|
||||
assert body.strip(), heading
|
||||
cursor_next = text.index(f"## {heading}", cursor)
|
||||
cursor_next = text.index(f"### {heading}\n", cursor)
|
||||
assert cursor_next >= cursor
|
||||
cursor = cursor_next
|
||||
if "|---|" in body or "| --- |" in body:
|
||||
lines = [line for line in body.splitlines() if line.startswith("|")]
|
||||
assert len(lines) >= 3, heading
|
||||
for title in FORBIDDEN_TITLES:
|
||||
assert f"### {title}" not in text
|
||||
assert f"## {title}" not in text
|
||||
assert f"# {title}" not in text
|
||||
assert _SNAKE.search(text) is None
|
||||
assert _heading_lines(text, 1) == []
|
||||
chapters = [chapter_zh if language == "zh" else chapter_en for chapter_en, chapter_zh in CHAPTERS]
|
||||
assert _heading_lines(text, 2) == chapters
|
||||
zh = render_parity_chapters({}, "zh")
|
||||
en = render_parity_chapters({}, "en")
|
||||
assert zh.index("### 来源感知口径") < zh.index("### 既有口径")
|
||||
assert "来源感知口径" not in zh.split("### 既有口径", 1)[1]
|
||||
assert en.index("### Source-aware method") < en.index("### Legacy method")
|
||||
assert "Source-aware method" not in en.split("### Legacy method", 1)[1]
|
||||
assert zh.index("#### 来源感知口径") < zh.index("#### 既有口径")
|
||||
assert "来源感知口径" not in zh.split("#### 既有口径", 1)[1]
|
||||
assert en.index("#### Source-aware method") < en.index("#### Legacy method")
|
||||
assert "Source-aware method" not in en.split("#### Legacy method", 1)[1]
|
||||
assert "印刷页 10" in zh
|
||||
assert "printed pages 10-17" in en
|
||||
for banned in ("source_locator", "profile_id", "niryaana_shoola_strength", "source_discrepancy", "sign_idx"):
|
||||
@@ -120,21 +129,21 @@ def test_three_fictional_charts_render_the_same_chapters(fictional_reports: dict
|
||||
zh_at = 0
|
||||
en_at = 0
|
||||
for title_en, title_zh in SECTIONS:
|
||||
zh_at = zh.index(f"## {title_zh}", zh_at)
|
||||
en_at = en.index(f"## {title_en}", en_at)
|
||||
zh_at = zh.index(f"### {title_zh}\n", zh_at)
|
||||
en_at = en.index(f"### {title_en}\n", en_at)
|
||||
for body, heading in ((_section_body(zh, title_zh), title_zh), (_section_body(en, title_en), title_en)):
|
||||
assert body.strip(), f"{index} {heading}"
|
||||
for chapter_en, chapter_zh in CHAPTERS:
|
||||
assert chinese.count(f"# {chapter_zh}") == 1
|
||||
assert english.count(f"# {chapter_en}") == 1
|
||||
assert _heading_lines(chinese, 2).count(chapter_zh) == 1
|
||||
assert _heading_lines(english, 2).count(chapter_en) == 1
|
||||
for title in FORBIDDEN_TITLES:
|
||||
assert title not in zh
|
||||
assert title not in en
|
||||
assert _SNAKE.search(zh) is None
|
||||
assert _SNAKE.search(en) is None
|
||||
assert "印刷页 10" in zh and "printed pages 10-17" in en
|
||||
assert "### 来源感知口径" in zh and "### 既有口径" in zh
|
||||
assert "### Source-aware method" in en and "### Legacy method" in en
|
||||
assert "#### 来源感知口径" in zh and "#### 既有口径" in zh
|
||||
assert "#### Source-aware method" in en and "#### Legacy method" in en
|
||||
assert "再小运" in _section_body(zh, "Chara 大运时长")
|
||||
assert "Pratyantardasha" in _section_body(en, "Chara Dasha Period Durations")
|
||||
assert "marriage_windows" not in _section_body(zh, "Bhrigu 近似推进序列")
|
||||
@@ -158,6 +167,10 @@ _PACKET_GAPS = {
|
||||
}
|
||||
|
||||
|
||||
# Only this section may be a single sentence: "no boundary within N days".
|
||||
_PROSE_ONLY = {"Dasha Boundary Scan"}
|
||||
|
||||
|
||||
def _table_blocks(body: str) -> list[list[list[str]]]:
|
||||
blocks = []
|
||||
current: list[str] = []
|
||||
@@ -184,17 +197,40 @@ def _table_blocks(body: str) -> list[list[list[str]]]:
|
||||
return parsed
|
||||
|
||||
|
||||
_NOT_VALUES = {"未判定", "Not determined", "—", "-", ""}
|
||||
# A whole cell that looks like a packet key (`summary`, `p5`, `house_7`).
|
||||
_RAW_KEY_CELL = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$")
|
||||
|
||||
|
||||
def _section_has_real_cell(body: str, undetermined: str) -> bool:
|
||||
"""A table must carry a value outside its first (label) column.
|
||||
|
||||
The first column holds row labels, so it never proves that the packet was
|
||||
read. A one-column table therefore never passes. A body with no table at all
|
||||
may be a plain sentence (for example "no boundary within 90 days").
|
||||
"""
|
||||
blocks = _table_blocks(body)
|
||||
if blocks:
|
||||
return any(
|
||||
cell and cell != undetermined
|
||||
cell not in _NOT_VALUES and cell != undetermined
|
||||
for rows in blocks
|
||||
for row in rows
|
||||
for cell in row
|
||||
for cell in row[1:]
|
||||
)
|
||||
prose = [line.strip() for line in body.splitlines() if line.strip()]
|
||||
return any(line and line != undetermined for line in prose)
|
||||
prose = [line.strip() for line in body.splitlines() if line.strip() and not line.startswith("#")]
|
||||
return any(line != undetermined for line in prose)
|
||||
|
||||
|
||||
def _all_cells(markdown: str) -> list[str]:
|
||||
cells = []
|
||||
for line in markdown.splitlines():
|
||||
if not line.startswith("|"):
|
||||
continue
|
||||
parts = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
||||
if parts and all(set(cell) <= set("-: ") and cell for cell in parts):
|
||||
continue
|
||||
cells.extend(parts)
|
||||
return cells
|
||||
|
||||
|
||||
def test_every_section_has_a_real_cell_except_the_four_public_gaps(fictional_reports: dict[int, tuple[str, str]]) -> None:
|
||||
@@ -212,6 +248,58 @@ def test_every_section_has_a_real_cell_except_the_four_public_gaps(fictional_rep
|
||||
assert first == _GAP_LINE[language], f"{index} {heading}"
|
||||
continue
|
||||
assert _section_has_real_cell(body, _UNDETERMINED[language]), f"{index} {heading}"
|
||||
if title_en not in _PROSE_ONLY:
|
||||
# A fixed sentence is not packet data; every other section shows a table.
|
||||
assert _table_blocks(body), f"{index} {heading}"
|
||||
|
||||
|
||||
def test_every_section_has_a_real_cell_outside_the_label_column_unit() -> None:
|
||||
one_column = "| 角色 |\n|---|\n| 未判定 |\n| summary |\n"
|
||||
assert not _section_has_real_cell(one_column, "未判定")
|
||||
labels_only = "| 项目 | 内容 |\n|---|---|\n| 状态 | 未判定 |\n| 宫位 | — |\n"
|
||||
assert not _section_has_real_cell(labels_only, "未判定")
|
||||
real = "| 项目 | 内容 |\n|---|---|\n| 宫位 | 7 |\n"
|
||||
assert _section_has_real_cell(real, "未判定")
|
||||
assert _section_has_real_cell("参考日起 90 天内没有大运或小运交界。", "未判定")
|
||||
assert not _section_has_real_cell("未判定", "未判定")
|
||||
|
||||
|
||||
def test_no_table_cell_is_a_raw_packet_key(fictional_reports: dict[int, tuple[str, str]]) -> None:
|
||||
for index, (chinese, english) in fictional_reports.items():
|
||||
for language, markdown in (("zh", chinese), ("en", english)):
|
||||
chapter = _chapter_text(markdown, language)
|
||||
keys = sorted({cell for cell in _all_cells(chapter) if _RAW_KEY_CELL.fullmatch(cell)})
|
||||
assert keys == [], f"{index} {language} {keys[:10]}"
|
||||
|
||||
|
||||
def test_six_chapters_are_reader_chapters_and_sections_sit_below_them(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The reader splits on `## ` and ignores `# `. The six chapters must be `##`."""
|
||||
import scripts.pl9_full_data_export as export
|
||||
|
||||
zh_chapters = [chapter_zh for _chapter_en, chapter_zh in CHAPTERS]
|
||||
en_chapters = [chapter_en for chapter_en, _chapter_zh in CHAPTERS]
|
||||
packet = _load_packet(0)
|
||||
full_zh = render_full_data_markdown(copy.deepcopy(packet))
|
||||
full_en = _english_edition(copy.deepcopy(packet), True).get("markdown_en") or ""
|
||||
monkeypatch.setattr(export, "_append_parity_chapters", lambda markdown, _packet, _language: markdown)
|
||||
base_zh = render_full_data_markdown(copy.deepcopy(packet))
|
||||
base_en = _english_edition(copy.deepcopy(packet), True).get("markdown_en") or ""
|
||||
assert base_en
|
||||
for full, base, chapters in ((full_zh, base_zh, zh_chapters), (full_en, base_en, en_chapters)):
|
||||
h1 = _heading_lines(full, 1)
|
||||
assert len(h1) == 1, h1
|
||||
assert full.startswith("# " + h1[0])
|
||||
assert _heading_lines(base, 1) == h1
|
||||
full_h2 = _heading_lines(full, 2)
|
||||
base_h2 = _heading_lines(base, 2)
|
||||
assert len(full_h2) == len(base_h2) + len(chapters)
|
||||
assert full_h2[: len(base_h2)] == base_h2
|
||||
assert full_h2[len(base_h2):] == chapters
|
||||
tail = full.split("\n## " + chapters[0] + "\n", 1)[1]
|
||||
assert _heading_lines(tail, 3)[: len(_chapter_sections()[0])] == [
|
||||
(dict(SECTIONS)[title] if chapters is zh_chapters else title) for title in _chapter_sections()[0]
|
||||
]
|
||||
assert len(_heading_lines(tail, 3)) == len(SECTIONS)
|
||||
|
||||
|
||||
def test_parity_chapters_go_through_local_conventions() -> None:
|
||||
@@ -236,15 +324,45 @@ def test_parity_chapters_go_through_local_conventions() -> None:
|
||||
|
||||
english_packet = copy.deepcopy(packet)
|
||||
english_packet["report_language"] = "en"
|
||||
english_packet["worksheets"]["strengths_and_scores"]["vimsopaka"]["Sun"]["category"] = "divorce"
|
||||
english_packet["worksheets"]["strengths_and_scores"]["vimsopaka"]["Sun"]["category"] = "Divorce risk"
|
||||
# A bare lower-case word is now treated as an internal key and is not
|
||||
# printed, so the probe is a reader-style phrase (fix2 F1).
|
||||
english = render_full_data_markdown(english_packet)
|
||||
en_body = _section_body(english, "Vimsopaka Contribution")
|
||||
en_replaced = _apply_local_report_conventions("divorce", language="en")
|
||||
assert en_replaced == "relationship strain"
|
||||
en_replaced = _apply_local_report_conventions("Divorce risk", language="en")
|
||||
assert en_replaced == "relationship strain risk"
|
||||
assert en_replaced in en_body
|
||||
assert "divorce" not in en_body.lower()
|
||||
|
||||
|
||||
def _combination_cells(markdown: str) -> list[str]:
|
||||
cells: list[str] = []
|
||||
column = None
|
||||
for line in markdown.splitlines():
|
||||
if not line.startswith("|"):
|
||||
column = None
|
||||
continue
|
||||
parts = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
||||
if column is None:
|
||||
column = parts.index("组合条件") if "组合条件" in parts else -1
|
||||
continue
|
||||
if column < 0 or all(set(cell) <= set("-: ") and cell for cell in parts):
|
||||
continue
|
||||
if column < len(parts):
|
||||
cells.append(parts[column])
|
||||
return cells
|
||||
|
||||
|
||||
def test_chinese_yoga_combinations_are_not_blanked(fictional_reports: dict[int, tuple[str, str]]) -> None:
|
||||
"""BUG-1418: staging printed Chinese text in these cells; none may fall back to 未判定."""
|
||||
for index, (chinese, english) in fictional_reports.items():
|
||||
cells = _combination_cells(chinese)
|
||||
assert cells, index
|
||||
assert [cell for cell in cells if cell == "未判定"] == [], index
|
||||
assert "组合:未判定" not in chinese
|
||||
assert HAN.findall(english) == []
|
||||
|
||||
|
||||
def test_chart_nine_partial_kaal_and_friendship(fictional_reports: dict[int, tuple[str, str]]) -> None:
|
||||
chinese, english = fictional_reports[9]
|
||||
assert _KAAL_ZH.search(chinese)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""BUG-1418: Chinese yoga combination text comes from a Chinese source.
|
||||
|
||||
The English note or template of a rule stays the English twin. The Chinese
|
||||
report must not print 「未判定」 for a combination that has text, and it must
|
||||
not translate English back word by word.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.modules.setdefault("jhora", None)
|
||||
|
||||
from scripts.report_render.appendix_language import _has_prose_glue, chinese_combination
|
||||
from scripts.yoga_engine import BiText, YogaEngine, _semantic_notes_zh, _semantic_text, _template_text
|
||||
from scripts.yogas_doshas import calc_dhana_yogas, calc_raj_yogas
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RULES = ROOT / "references" / "yoga_rules.json"
|
||||
HAN = re.compile(r"[一-鿿]")
|
||||
|
||||
|
||||
def _semantic_notes() -> dict[str, set[str]]:
|
||||
engine = YogaEngine(str(RULES))
|
||||
notes: dict[str, set[str]] = {}
|
||||
|
||||
def walk(node, rule_id):
|
||||
if isinstance(node, dict):
|
||||
kind = node.get("type") or node.get("op")
|
||||
if kind and kind not in ("compound_conditions", "compound") and not hasattr(engine, f"_eval_{kind}"):
|
||||
if node.get("note"):
|
||||
notes.setdefault(node["note"], set()).add(rule_id)
|
||||
for value in node.values():
|
||||
walk(value, rule_id)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
walk(value, rule_id)
|
||||
|
||||
for rule in json.loads(RULES.read_text(encoding="utf-8"))["rules"]:
|
||||
walk(rule.get("logic", {}), rule.get("id"))
|
||||
return notes
|
||||
|
||||
|
||||
def test_every_semantic_note_has_one_chinese_sentence() -> None:
|
||||
table = _semantic_notes_zh()
|
||||
notes = _semantic_notes()
|
||||
assert notes
|
||||
missing = sorted(note for note in notes if note not in table)
|
||||
assert missing == []
|
||||
chinese = [table[note] for note in notes]
|
||||
# One-to-one, so the detector's dedup key keeps the same equality classes.
|
||||
assert len(set(chinese)) == len(chinese)
|
||||
for text in table.values():
|
||||
assert HAN.search(text), text
|
||||
assert not _has_prose_glue(text), text
|
||||
assert "; " not in text
|
||||
|
||||
|
||||
def test_semantic_text_keeps_the_old_value_as_english() -> None:
|
||||
note = next(iter(_semantic_notes()))
|
||||
text = _semantic_text({"note": note}, "any_type")
|
||||
assert isinstance(text, BiText)
|
||||
assert HAN.search(str(text))
|
||||
assert text.en == note
|
||||
# No note: the old value was the type key; dedup still groups by type.
|
||||
keyed = _semantic_text({}, "planet_in_house")
|
||||
assert HAN.search(str(keyed)) and keyed.en == "planet_in_house"
|
||||
assert _semantic_text({"note": ""}, "planet_in_house") == ""
|
||||
english_template = _template_text("PyJHora aligned source logic", None)
|
||||
assert HAN.search(str(english_template))
|
||||
assert english_template.en == "PyJHora aligned source logic"
|
||||
chinese_template = _template_text("满足自定义条件", "Custom condition met")
|
||||
assert str(chinese_template) == "满足自定义条件" and chinese_template.en == "Custom condition met"
|
||||
|
||||
|
||||
def _planets(**signs: str) -> dict:
|
||||
order = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio",
|
||||
"Sagittarius", "Capricorn", "Aquarius", "Pisces"]
|
||||
asc = order.index("Aries")
|
||||
planets = {}
|
||||
for planet, sign in signs.items():
|
||||
index = order.index(sign)
|
||||
planets[planet] = {"sign": sign, "house": (index - asc) % 12 + 1, "degree": index * 30 + 10.0}
|
||||
return planets
|
||||
|
||||
|
||||
def test_raj_and_dhana_rows_carry_chinese_combination() -> None:
|
||||
# Fictional placements, Aries ascendant: Mars with Saturn, Jupiter in the 9th.
|
||||
planets = _planets(Sun="Leo", Moon="Cancer", Mars="Capricorn", Mercury="Virgo",
|
||||
Jupiter="Sagittarius", Venus="Libra", Saturn="Capricorn", Rahu="Gemini", Ketu="Sagittarius")
|
||||
houses = {"asc_sign": "Aries"}
|
||||
rows = calc_raj_yogas(planets, houses)["yogas"] + calc_dhana_yogas(planets, houses)["yogas"]
|
||||
assert rows
|
||||
for row in rows:
|
||||
text = row.get("combination_zh")
|
||||
assert text and HAN.search(text), row
|
||||
assert not _has_prose_glue(text), text
|
||||
assert not re.search(r"[A-Za-z]{3,}", text), text
|
||||
assert chinese_combination(row) == text
|
||||
english = [row for row in rows if "_Lord(" in row["combination"]]
|
||||
for row in english:
|
||||
assert chinese_combination(row) != "未判定"
|
||||
Reference in New Issue
Block a user