Merge codex/chart-subject-table-20261021 (#86) into staging
# Conflicts: # CHANGELOG.md # docs/BUG_HISTORY.md
This commit is contained in:
@@ -12,6 +12,20 @@
|
||||
- 个人报告「行星主星与状态」表的上升行同样写「/」,表下面加同一句说明。英文版写英文说明。已经生成的旧报告要重新生成才变。
|
||||
- Skill 版本不变。
|
||||
|
||||
## 2026-10-22 — 报告整句表恢复换行,档案页改别人后星盘跟着更新
|
||||
|
||||
- 报告里只有行星位置这类数值表保持单行,放不下时在表自己的框里横滑。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里。
|
||||
- 星盘页和星盘档案保存别人,走同一处。档案页保存成功后,星盘页和星历页不再留着旧盘。
|
||||
- 点编辑框内侧的空白边不会关掉框,未保存的修改还在。Esc 仍然关闭。别人已经填过的出生时间,那句「保存后不能再改」只出现在时间旁边。
|
||||
- Skill 版本不变(BUG-1410 收窄,BUG-1411、BUG-1412)。
|
||||
|
||||
## 2026-10-21 — 星盘页能改出生资料,人名看得出能点,报告行星表不再从中间断开
|
||||
|
||||
- 星盘、星历、报告顶栏的人名是带箭头的圆角按钮。名单没满 20 人时,底部可以「+ 添加一个人」。
|
||||
- 星盘页标题旁只留日期、时间和地点,点这一行就能改。本人都能改;别人已经保存的出生时间仍然不能改。改了日期、时间或地点会提示:之前校正采用的时间作废,星盘按新填的时间重排。
|
||||
- 报告「行星原始位置」表按内容定列宽。星宿的英文名用浅色小字,跟在中文后面同一行。速度写到小数点后 4 位。放不下时表在自己的框里横向滑动。
|
||||
- Skill 版本不变(BUG-1410)。
|
||||
|
||||
## 2026-10-21 — 合盘时间线写出起止,删掉的模型改用默认模型
|
||||
|
||||
- 关系时间线每段标题写出起止年月。第一段从本月开始时,起点写「现在」或 Now。
|
||||
|
||||
@@ -37032,3 +37032,51 @@
|
||||
- 复发自:无。本条推翻 DESIGN.md「Selected states use `--color-canvas-muted` + `--color-ink`」这一句;BUG-1078 的主语(导出面板选中态不得用 `--color-action-soft` 深色块)保留。
|
||||
- 修复版本:分支 `codex/report-export-dialog-polish-20261021`。
|
||||
|
||||
## BUG-1410 | 报告行星表把星宿和速度从中间拆开
|
||||
|
||||
- 状态:resolved(待 staging 真机复测)
|
||||
- 首次发现:2026-10-21(产品负责人,issue #86)
|
||||
- 最近更新:2026-10-22
|
||||
- 影响面:报告阅读页 Markdown 表格,尤其是「Birth Chart 行星原始位置」
|
||||
- 用户现象:九列被平均分宽。星宿「阿湿毗尼(Ashwini)」拆成三行,速度 `11.852534` 从数字中间断开。
|
||||
- 触发条件:打开一份含行星原始位置表的报告。阅读页给 `.personal-report-table-wrap table` 用了 `table-layout: fixed`,单元格 `overflow-wrap: anywhere`。打印样式再用同一套规则盖回去。
|
||||
- 根因:固定列宽加上任意断行。速度在阅读版导出里写成 6 位小数,更长,更容易被拆开。
|
||||
- 修复:表格按内容定列宽,单词和数字不从中间断开,放不下时只在表格容器里横向滑动。星宿英文名用浅色小字,和中文同一行。速度显示并写出 4 位小数。打印不再改回固定列宽和任意断行。2026-10-22 收窄:单行和按内容撑宽只给表头含速度、Speed、度数或黄经的数值表(`.personal-report-table-numeric`)。作用说明、建议这类整句表恢复换行。打印时所有表都是页宽,单元格可以换行。第一次修复把单行套到每一张 Markdown 表上,打印时宽表跑出纸面。
|
||||
- 验证:`frontend/tests/chart-birth-editor-and-report-table.test.tsx` 锁定数值表带这个类、说明表不带,并且单行规则只挂在这个类上,打印块里没有按内容撑宽和禁止换行。`tests/test_report_chart_blank_columns.py` 的行星行速度匹配 4 位小数。测试文件曾叫 `issue-86-chart-subject-table.test.tsx`,2026-10-22 改成按内容命名。
|
||||
- 防复发:上面两条。数值表不要改回 `table-layout: fixed`。不要把单行和按内容撑宽再套到每一张报告表上,打印块里也不要。整句表可以换行。
|
||||
- 相关记录:issue #86。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/chart-subject-table-20261021`。
|
||||
|
||||
## BUG-1411 | 档案页改了别人的出生资料,星盘和星历仍是旧盘
|
||||
|
||||
- 状态:resolved(待 staging 真机复测)
|
||||
- 首次发现:2026-10-22(issue #86 第 1 次验收)
|
||||
- 最近更新:2026-10-22
|
||||
- 影响面:星盘档案页保存他人;星盘页与星历页缓存
|
||||
- 用户现象:在档案页改完别人的出生日期、时间或地点,再打开星盘页或星历页,仍是改之前的盘。
|
||||
- 触发条件:档案页编辑一个已有的他人并保存成功。星盘页就地编辑同一人会清缓存,档案页这条保存没有清。
|
||||
- 根因:保存他人写成了两份。星盘编辑框在成功后清掉星盘和星历缓存。档案页的 `saveOther` 只刷新人物名单。
|
||||
- 修复:保存他人抽成 `saveOtherProfile`。请求、409 `reported_birth_time_is_immutable` 的文案、成功后清星盘和星历缓存都在这一处。星盘编辑框和档案页都调用它。档案页因此补上缓存清理。这是顺带修好的旧缺陷。
|
||||
- 验证:`frontend/tests/chart-birth-editor-and-report-table.test.tsx` 断言两处调用同一个函数;成功后两处缓存清理被调用,409 时不调用。
|
||||
- 防复发:该测试。不要在页面里再写一份保存他人的请求。
|
||||
- 相关记录:issue #86。BUG-1410。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/chart-subject-table-20261021`。
|
||||
|
||||
## BUG-1412 | 点星盘编辑框的内边距会关掉框并丢掉没保存的修改
|
||||
|
||||
- 状态:resolved(待 staging 真机复测)
|
||||
- 首次发现:2026-10-22(issue #86 第 1 次验收)
|
||||
- 最近更新:2026-10-22
|
||||
- 影响面:星盘页就地编辑出生资料的对话框
|
||||
- 用户现象:点在编辑框内侧的空白边上,或在输入框里拖选文字后松手落在这块空白上,编辑框关掉,刚填的内容没了。
|
||||
- 触发条件:编辑框有内边距。点在内边距上时,点击目标是对话框自己。旧逻辑把这当成点在遮罩上,直接关闭。
|
||||
- 根因:关闭条件只看点击目标是不是对话框,没有看点击是否落在框的矩形外面。遮罩点击的目标也是对话框,但坐标在矩形外;内边距的坐标在矩形内。
|
||||
- 修复:只有目标是对话框且坐标在矩形外才关闭。Esc 仍关闭。
|
||||
- 验证:`frontend/tests/chart-birth-editor-and-report-table.test.tsx` 用框内坐标断言不关闭、框外坐标断言关闭。
|
||||
- 防复发:该测试。不要再只按点击目标是对话框来关闭这个编辑框。
|
||||
- 相关记录:issue #86。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/chart-subject-table-20261021`。
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# 进度:星盘页人名、就地改资料、报告行星表(issue #86)
|
||||
|
||||
规格是 Gitea issue #86,没有另写任务书。分支 `codex/chart-subject-table-20261021`,基线 `origin/staging` `bd3ce0e7b`。
|
||||
|
||||
## 改了什么
|
||||
|
||||
- 报告阅读页的 Markdown 表按内容定列宽。单词和数字不从中间断开。放不下时只在表格自己的框里横向滑动,页面不被撑开。星宿英文名用浅色小字,跟在中文后面同一行。速度显示并写出小数点后 4 位。打印不再改回固定列宽和任意断行。记入 BUG-1410。
|
||||
- 星盘、星历、报告和对话顶栏的人名是带箭头的圆角按钮。名单没满 20 人时,弹出层底部有「+ 添加一个人」,去 `/people`。
|
||||
- 星盘页标题旁只留「日期 时间 · 地点」和一支笔。整行在当前页打开编辑框。本人各项都能改。别人已经保存的出生时间锁住,旁边写「填报的出生时间保存后不能再改」。改了日期、时间或地点才出现校正作废那一句。保存后星盘和星历按新资料重算。手机上这一行换到第二行,顶栏跟着长高。
|
||||
|
||||
## 和 issue 原文不一样的地方
|
||||
|
||||
- issue 写「已有 5 人就藏起添加入口」。线上档案上限是 20 人(`MAX_SAVED_SUBJECTS`)。满 20 人才藏。没有做成 5。
|
||||
- 别人的资料保存后,除了星盘缓存,星历缓存也清掉。档案页原来只刷新人物名单。星历页和星盘页共用这个人,不清的话会继续画旧时间。
|
||||
- 已经下载到本地的旧报告原文仍可能是 6 位小数。打开阅读页时会显示成 4 位。新导出的阅读版是 4 位。
|
||||
- 别人的出生时间仍然不能改。解开数据库限制是另一张单,本单不做。
|
||||
|
||||
## 检查
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `frontend` 里 `tsc --noEmit -p .` | 退出码 0 |
|
||||
| lint | `npm run lint` | 0 个错误,122 条原有警告。这次改过的文件 0 条警告 |
|
||||
| 星盘页和本单测试 | `tsx --test tests/issue-86-chart-subject-table.test.tsx tests/chart-page-view.test.tsx` | 31 通过,0 失败 |
|
||||
| 人名按钮、报告表、打印合同 | 同一轮 `tsx --test` 里的 `chat-profile-picker`、`report-reader-polish`、`report-fact-table-print`、两份 `personal-report-markdown-view` | 通过。当时行星表打印断言写错切片,已改后再跑星盘页那 31 条 |
|
||||
| 阅读版速度 | `pytest tests/test_report_chart_blank_columns.py -q` | 通过,退出码 0 |
|
||||
| 全量 `npm test` / `next build` | 本机执行约定交给验收再跑 | 未跑 |
|
||||
|
||||
## 浏览器
|
||||
|
||||
本机没有对着已登录的星盘页点过这三处。验收时请看:人名按钮、弹出层里的添加入口、星盘页出生行和编辑框、一份带行星表的报告在窄屏下横向滑动。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 进度:星盘页就地编辑 / 报告表换行(issue #86 第 1 次打回)
|
||||
|
||||
修复单 `docs/tasks/TASK-chart-subject-table-fix-20261022.md`。分支 `codex/chart-subject-table-20261021`。开工先合入 `origin/staging` `2806bd5b75bd701e62e837fae70eda87d6d796ac`(只含文档,无冲突)。被打回的提交是 `885bd5665`。
|
||||
|
||||
## 改了什么
|
||||
|
||||
- **F1** 报告里只有表头含「速度 / Speed / 度数 / 黄经」的数值表加 `personal-report-table-numeric`,单行和按内容撑宽只挂在这个类上。作用说明、建议这类表恢复换行。打印时所有表都是页宽,单元格可以换行。打印块里不再出现按内容撑宽和禁止换行。
|
||||
- **F2** 「保存他人」收到 `frontend/src/lib/other-profile-save.ts` 的 `saveOtherProfile`。请求、409 `reported_birth_time_is_immutable` 的文案、成功后清星盘和星历缓存都在这里。星盘编辑框和档案页都调它。档案页以前只刷新名单,改完别人再看星盘仍是旧盘,记为 BUG-1411。
|
||||
- **F3** 编辑框只在点击落在框外(遮罩)时关闭。点内边距、或拖选文字后松手落在内边距上,不关闭、不丢掉未保存内容。Esc 仍关闭。记为 BUG-1412。
|
||||
- 测试文件改名为 `frontend/tests/chart-birth-editor-and-report-table.test.tsx`。仓库里没有别的测试按源码断言旧文件名;BUG-1410 的验证路径已改成新名字。
|
||||
- 「填报的出生时间保存后不能再改」只留在时间旁边那一行。表单说明里不再写。档案页编辑已经保存过出生时间的他人时,也锁住这一项,所以同一句仍然会出现,但只从这一处来。
|
||||
|
||||
## 断言改动
|
||||
|
||||
| 位置 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 本单测试里的表格 CSS | `.personal-report-table-wrap` 的单元格含禁止换行;打印表按内容撑宽,单元格也不换行 | 这两条只出现在 `.personal-report-table-numeric` 下;打印块里没有;普通表宽 100% | 整句表和打印被数值表的单行规则带走了 |
|
||||
| 同上,星宿英文名 | `.report-nakshatra-en` 自己禁止换行 | 仍是浅色小字;单行靠数值表单元格 | 禁止换行只留给数值表。数值表的格子已经不会从中间拆开英文名 |
|
||||
| 同上,锁定提示 | 渲染结果里出现「填报的出生时间保存后不能再改」即可 | 锁定时正好一次,写在时间旁边;说明、编辑框源码、档案页源码里都没有这句 | 同一句原来在说明和锁定提示各出现一次 |
|
||||
| `chart-library-other-profile.test.ts` | `/method: editingOther ? "PUT" : "POST"/` 写在人物页 | `/method: id ? "PUT" : "POST"/` 在 `saveOtherProfile` | 星盘编辑框和档案页共用这一处请求 |
|
||||
| 同上 | `catch {` 后 180 字内「保存失败,请重试」 | `catch (caught)` 后仍有这句 | 409「填报的出生时间不能再改」要从共用函数原样提示,不能一律盖成保存失败 |
|
||||
|
||||
没有删测试,也没有放宽「保存失败不得假装成功」「不写本地星盘库」这些断言。
|
||||
|
||||
## 检查
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `frontend` 里 `node_modules\.bin\tsc --noEmit -p .` | 退出码 0 |
|
||||
| lint | `npm run lint` | 0 个错误,122 条警告(与打回验收记录的基线相同)。这次改过的前端文件再跑 eslint,0 条警告 |
|
||||
| 本单测试 | `tsx --test tests/chart-birth-editor-and-report-table.test.tsx` | 8 通过,0 失败 |
|
||||
| 星盘页、档案页、报告表、CSS 合同 | 同一条 `tsx --test` 加上 `chart-page-view.test.tsx`、`people-archive-view.test.tsx`、`people-archive-regressions.test.ts`、`self-edit-people-20260928.test.tsx`、`profile-gender-ui-20260927.test.tsx`、`chart-library-other-profile.test.ts`、`settings-mvp-contract.test.ts`、`report-reader-polish.test.tsx`、`report-fact-table-print.test.ts`、`personal-report-markdown-view.test.ts`、`personal-report-longform-md.test.ts` | 105 通过,0 失败,0 取消 |
|
||||
| 阅读版速度 | `pytest tests/test_report_chart_blank_columns.py -q`(本工作树没有 `.venv`,用了 `agent-loop` 工作树的解释器) | 16 通过,退出码 0 |
|
||||
| 全量 `npm test` / `next build` | 修复单约定交给验收 | 未跑 |
|
||||
|
||||
## 偏离
|
||||
|
||||
- 整句表用自动列宽加页宽,单元格可换行,没有退回改前的固定列宽。固定列宽是行星表被从中间拆开的原因。
|
||||
- 档案页在别人已经保存过出生时间时也锁住这一项。这样「保存后不能再改」只留在时间旁边,档案页不会把这句丢掉。
|
||||
- 开工时 `BUG_HISTORY` 去重后的最大号是 1404。新增 BUG-1411(档案页缓存)和 BUG-1412(点内边距关闭)。修复单写明 #85 占用 1404~1408,合入前会重编。没有再用 1404。
|
||||
- 没有在浏览器里点已登录的星盘页、档案页和报告页。
|
||||
|
||||
## 未做
|
||||
|
||||
- 没有 push。
|
||||
- 没有跑全量 `npm test`,没有 `next build`。
|
||||
|
||||
- 合入 staging 时 Claude 把本分支的 BUG-1404~1406 改为 **BUG-1410~1412**(1404~1408 归 #85,1409 归 #90)。
|
||||
@@ -251,6 +251,7 @@
|
||||
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| issue #86(无独立任务书) | [PROGRESS](PROGRESS-chart-subject-table-20261021.md) | 星盘页人名看得出能点、出生资料就地改、报告行星表不再从中间断开(BUG-1410) | 待验收 | `codex/chart-subject-table-20261021` |
|
||||
| [TASK-chart-surface-polish-20260929](TASK-chart-surface-polish-20260929.md)([验收修复](TASK-chart-surface-polish-fix-20260929.md)) | [PROGRESS](PROGRESS-chart-surface-polish-20260929.md) | 去引擎展示、庙旺三档保留、同位仅 D9、宫位左对齐、图标解释与桌面盘宽收紧(BUG-1098 / 1099) | 本地实施完成(定向/浏览器通过;全量保留 29 项基线失败,未推送) | `codex/chart-surface-polish-20260929`;不发布、不提升 main |
|
||||
| `TASK-docs-reconcile-20260926.md` | `PROGRESS-docs-reconcile-20260926.md` | **状态板 / BUG / BLOCKED 过期对账**(纯文档):约 35 行校正任务状态过期、BUG-085/086/743/981/984 记录过期、BLOCKED latency 条目过期;逐条附证据改,不把欠真机写成通过 | 待验收 | `codex/docs-reconcile-20260926`(本地提交,未推送) |
|
||||
| `TASK-people-ephemeris-ui-20260926.md` | `PROGRESS-people-ephemeris-ui-20260926.md` | **星盘档案页补样式 + 星历可读性改版**(原型 https://claude.ai/artifact/FmPy9xZ7dQw9u7yrW4WzTw ,产品 09-26 确认):/people 样式 0 条(BUG-1039)→ 手机列表→详情、桌面两栏;星历五要素先中文后梵文(附唯一对照表)、吉凶彩色标签、固定规则当日总评(不调模型)、标题栏大按钮移到底部提示卡。必须真实浏览器截图验收。与 BUG-1038 单并行、文件不交叉 | 已验收(Claude 09-26 直接执行模式:子代理实现,Claude 独立复验 tsc/lint 0、全量 3916 条失败名单与基线逐条一致、四路由 ○、gzip +0.00%、36 张浏览器截图核对与原型一致) | `0ab061b9`(已部署,run 2910,health 一致) |
|
||||
@@ -518,6 +519,6 @@
|
||||
| `TASK-chart-dasha-yogini-20261021.md` | — | #88 星盘页大运右栏切换 Chara / Yogini,Yogini 补小运(按比例,内部核对,页面注明未经外部软件核对);Ashtottari 另立单(起运表与 PyJHora 8/12 宿不一致) | 待领取(等 #86、#87 合入) | `codex/chart-dasha-yogini-20261021` |
|
||||
| `TASK-full-data-parity-1-20261021.md` | — | #91 完整数据版补齐(一):A 段渲染(中文附录改中文源、71 小节归 6 章、导出星座名)→ B 段引擎(Yogi 点、标准大运子运、Kalachakra 简化子运、年运三年全展开、36 个月星历、解释补长);不设大小上限 | 待领取(等 #89 合入;T3 与 #90 串行) | `codex/full-data-parity-1-20261021` |
|
||||
| `TASK-goel-jaimini-strength-20261022.md` | — | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 待领取(可与 #89 并行;须在 #91 印章前合入) | `codex/goel-jaimini-strength-20261022` |
|
||||
| `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-20261021.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 待领取 | `codex/chart-subject-table-20261021` |
|
||||
| `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-fix-20261022.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 已合入 staging | `codex/chart-subject-table-20261021` |
|
||||
| `TASK-full-data-parity-3-20261022.md` | — | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 待产品确认) | 待领取(等 #91 合入) | `codex/full-data-parity-3-20261022` |
|
||||
| `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-20261021.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 待领取 | `codex/synastry-staging-fix-20261021` |
|
||||
|
||||
+5
-4
@@ -88,7 +88,7 @@ Bhava Bala 保留完整十二宫力量表;Score / Weight 等领域表头不单
|
||||
|
||||
## 报告核对层与独立附录(2026-09-23)
|
||||
|
||||
个人报告保留原 Markdown 阅读、目录隔离与 memo 边界;文后独立渲染服务端分盘和事实表。分盘复用 VedicChartSvg 与 chart-grid/chart-card,不使用 float 或负 margin。事实表默认折叠、可键盘展开,横向溢出仅在表格区域滚动,缺数据不补写。Sade Sati 明确缺三轮日期。09-24 reader-actions D2 撤下操作区「下载原始附录」入口;相关组件、路由和 helper 因删除权限阻塞暂留源码,不当作完成清理。不接普通下载 fallback、不增加 spinner。旧缓存不升级、不重算;没有结构化层就不显示占位盘。文案遵守 VOICE:不把可计算写成已验证。
|
||||
个人报告保留原 Markdown 阅读、目录隔离与 memo 边界;文后独立渲染服务端分盘和事实表。分盘复用 VedicChartSvg 与 chart-grid/chart-card,不使用 float 或负 margin。事实表默认折叠、可键盘展开,横向溢出仅在表格区域滚动,缺数据不补写。Markdown 表里,只有表头含速度、Speed、度数或黄经的数值表(`.personal-report-table-numeric`)单行并按内容撑宽,放不下时在表自己的框里横滑;作用说明、建议这类整句表在页内换行。打印时所有报告表都是页宽,单元格可以换行,纸上不横滑。Sade Sati 明确缺三轮日期。09-24 reader-actions D2 撤下操作区「下载原始附录」入口;相关组件、路由和 helper 因删除权限阻塞暂留源码,不当作完成清理。不接普通下载 fallback、不增加 spinner。旧缓存不升级、不重算;没有结构化层就不显示占位盘。文案遵守 VOICE:不把可计算写成已验证。
|
||||
|
||||
事实表用中文列名和领域行列呈现,不把引擎路径当行名。主运、分运与小运、SAV 与 BAV、年度位置与 Saham 分为同组子表;缺失的年度行星位置明确说明,不用本命盘替代。机器溯源路径保留在数据中、不渲染。打印或保存 PDF 前自动展开此核对层全部折叠组,完成或取消后恢复原状态;打印解除表格横向滚动裁剪、重复表头,宽 BAV 表仍保留十二星座列。不新增打印入口或等待动画。
|
||||
|
||||
@@ -461,10 +461,11 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
### Chat header
|
||||
|
||||
- One line, 46px on desktop. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`.
|
||||
- Ordinary consultation: the person is a quiet button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button with `preventScroll: true`; focus must not move the chat panel or transcript. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation.
|
||||
- Ordinary consultation, and the chart, ephemeris and report pages: the person is a rounded button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). Light canvas fill, a hairline, and a chevron, so the name reads as something you can press. The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button with `preventScroll: true`; focus must not move the chat panel or transcript. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation. When the archive is under 20 people, the list ends with a hairline and `+ 添加一个人`, which opens `/people`. At 20 people that row is absent.
|
||||
- Before the first message the list selects a person and updates that empty session's binding. Self is the visible default. After the first message the current person stays selected; another person offers only `新建会话并使用此人物` and does not rebind the old session. That action waits until the current request ends.
|
||||
- Loading shows the small orbit (`OrbitWaiting`, 2026-09-30), with `人物列表还在读` for screen readers only. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整` and cannot be chosen. A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner.
|
||||
- Rectification, reports, and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The consultation button stays on narrow screens and ellipsizes so the header remains one line.
|
||||
- Rectification and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The person button stays on narrow screens and ellipsizes so the header remains one line.
|
||||
- The chart page birth line is a button (`.chart-birth-line`) after the person: `日期 时间 · 地点` and a pen. The name is not repeated. The whole row opens an editor on this page. Self can change every field. Someone else's already-saved birth time stays locked. The sentence `填报的出生时间保存后不能再改` appears once, beside that field, and is not repeated in the form description. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the shared other-person write (self still goes through the account). A click on the dialog's padding, or releasing a text selection there, does not close the dialog or drop unsaved edits. Escape still closes it, and so does a click on the backdrop outside the dialog box. Below 768px that row wraps onto a second line and the chart header grows to fit it.
|
||||
- With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px.
|
||||
- On viewports ≤767px the header row is 52px plus the safe-area inset. The credit pill and the rectification 「当前盘面」 chip share one size: 40px visual height, 14px tabular type, the same radius and padding, no 64px min-width. The 44px touch target is the transparent `::before` ring.
|
||||
- Window-level scroll is reset by `ViewportScrollLock` after the on-screen keyboard closes, so the header cannot be left above the screen.
|
||||
@@ -1160,7 +1161,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
- **列表行**(`.people-archive-row`,最小 64px):40px 圆形首字头像(本人写「我」)+ 名字(本人后跟灰底「本人」标签)+ 一行等宽 `tabular-nums` 的「出生日期 · 排盘时间 · 城市」+ 行尾「›」。资料不完整时这一行写「这份资料还不完整」。**当前人物**(`is-current`,与顶栏「当前星盘」同一个人)是 `--color-action-strong` 描边 + `--color-action-soft` 底。
|
||||
- **添加**:虚线卡片「+ 添加一个人」(48px,`--color-border-strong` 虚线,文字 `--color-action`),下面小字「还可以保存 N 个人」,N 仍是 `MAX_SAVED_SUBJECTS - 人数`;到顶时换成「最多保存 20 个人的星盘」并禁用。
|
||||
- **人多时的滚动**(2026-10-17):`.people-archive-rows` 限高 `min(70vh, 640px)`,只在列表内部纵向滚动。标题旁和聊天里的人物菜单(`.chat-profile-picker-options`)限高 `min(50vh, 320px)`,同样只纵向滚动,名单放到 20 人也不把弹层撑出屏幕。两处都 `overflow-x: hidden`。
|
||||
- **详情卡**:头部 48px 头像 + 名字 +「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期、岁差(显示「Raman(默认)」这类设置里的名字,不露小写键)与性别(「女」「男」,没填写「未填」),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」;每个人底部都有「编辑」,本人在它上面加一行灰字「本人不能删除」。**本人的编辑视图(2026-09-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。
|
||||
- **详情卡**:头部 48px 头像 + 名字 +「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期、岁差(显示「Raman(默认)」这类设置里的名字,不露小写键)与性别(「女」「男」,没填写「未填」),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」;每个人底部都有「编辑」,本人在它上面加一行灰字「本人不能删除」。**本人的编辑视图(2026-09-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。他人已经保存过出生时间时,编辑表单锁住这一项,旁边只留「填报的出生时间保存后不能再改」,说明里不再重复。保存他人成功后清掉星盘页和星历页缓存(BUG-1411),再打开仍是刚保存的资料。
|
||||
- **编辑表单里的性别(选填)**(2026-09-27):放在出生地点之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。
|
||||
- **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。
|
||||
- **头像颜色**:本人用 `--color-action-strong` + `--color-on-dark`(同账户首字圆);其他人按 id 稳定地取四个元素色 `--color-element-fire / earth / air / water` 之一,字用 `--color-ink`。不新造色值。
|
||||
|
||||
@@ -239,6 +239,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
- 详情两格时间卡写「填报时间」「排盘时间」;有校正结果时排盘卡写「排盘时间 · 已校正」。岁差写设置里的名字,例如「Raman(默认)」,不写小写键。
|
||||
- 快捷入口只写「和 TA 对话 / 看星盘 / 生成报告」;他人另有「和我合盘」「编辑」。本人底部灰字「本人不能删除」(不加句号)。手机详情顶部是「‹ 返回」。
|
||||
|
||||
## 星盘页人名与就地改资料(2026-10-21)
|
||||
|
||||
- 星盘、星历、报告顶栏的人名是一颗圆角按钮,带下拉箭头。点开后,名单没到 20 人时,底部一行是「+ 添加一个人」,去星盘档案。满 20 人不出现这一行。
|
||||
- 星盘页人名旁边只写「日期 时间 · 地点」,末尾一支小笔。整行可点,就在这一页改,不跳走。本人每项都能改。别人已经填过的出生时间不能改,这句话只出现在时间旁边:「填报的出生时间保存后不能再改」。表单说明里不重复。星盘档案里编辑这样的人,也只在时间旁边写这一句。改了日期、时间或地点时写:「改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。」手机上这一行换到第二行。点编辑框内侧的空白边不会关掉框。
|
||||
- 报告里的星宿,中文名后面的英文名用浅色小字,和中文同一行。速度写到小数点后 4 位。行星位置这类数值表太宽时,在表自己的框里横滑,页面不被撑开。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里,格子里的字可以换行。
|
||||
|
||||
## 报告分块导出与删除
|
||||
|
||||
- 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」、「中文 / English」切换(只在有英文版的报告上出现)与带字的「导出」按钮(读屏名「导出报告」,2026-09-29 起不再是只有图标的按钮)。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。
|
||||
|
||||
@@ -706,8 +706,10 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
|
||||
[data-sidebar="content"] { -webkit-overflow-scrolling: touch; }
|
||||
.chat-header > div { min-width: 0; flex: 1; }
|
||||
.chat-header { padding: 0 var(--space-3); }
|
||||
.chat-header-chart { display: none; }
|
||||
.chat-header-chart:not(:has(.chart-birth-line)) { display: none; }
|
||||
button.chat-profile-picker-trigger { display: inline-flex; }
|
||||
.chat-header-title:has(.chart-birth-line) { flex-wrap: wrap; row-gap: 2px; }
|
||||
.chat-header-chart:has(.chart-birth-line) { display: flex; flex: 1 0 100%; max-width: 100%; overflow: hidden; }
|
||||
.onboarding-card { padding: 16px; }
|
||||
|
||||
.message-assistant .message-content { max-width: 100%; }
|
||||
@@ -967,9 +969,21 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
|
||||
.chat-header-title { min-width: 0; display: flex; align-items: center; gap: var(--space-3); }
|
||||
.chat-header strong { min-width: 0; overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-body-md); font-weight: 500; letter-spacing: -.2px; }
|
||||
.chat-header-chart { flex: 0 1 auto; min-width: 0; overflow: hidden; color: var(--color-ink-tertiary); font-size: var(--type-caption); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chat-profile-picker-trigger { max-width: 46%; min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; margin: 0; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-tertiary); cursor: pointer; font: inherit; font-size: var(--type-caption); line-height: 1.35; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out; }
|
||||
.chart-birth-line { min-width: 0; max-width: 100%; min-height: 44px; display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: inherit; line-height: inherit; text-align: left; }
|
||||
.chart-birth-line > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chart-birth-line svg { width: 14px; height: 14px; flex: 0 0 auto; }
|
||||
.chart-birth-dialog { width: min(560px, calc(100vw - 32px)); max-height: min(720px, calc(100vh - 48px)); margin: auto; padding: var(--space-5); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); color: var(--color-ink); box-shadow: var(--shadow-elevated); }
|
||||
.chart-birth-dialog::backdrop { background: var(--color-scrim); }
|
||||
.chart-birth-dialog .profile-form { margin-top: var(--space-3); }
|
||||
.chart-birth-edit-notice { margin: 0 0 var(--space-3); color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; }
|
||||
.birth-time-lock { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.birth-time-lock-note { margin: 0 0 var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.45; }
|
||||
.chat-profile-picker-trigger { max-width: 46%; min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; gap: 4px; margin: 0; padding: 0 10px 0 12px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-canvas-muted); color: var(--color-ink); cursor: pointer; font: inherit; font-size: var(--type-caption); line-height: 1.35; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, border-color 120ms ease-out; }
|
||||
.chat-profile-picker-trigger:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 1px; }
|
||||
.chat-profile-picker-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chat-profile-picker-chevron { width: 14px; height: 14px; flex: 0 0 auto; color: var(--color-ink-tertiary); }
|
||||
.chat-profile-picker-add { margin-top: var(--space-2); padding-top: var(--space-2); border-top: 1px solid var(--color-border); }
|
||||
.chat-profile-picker-add a { min-height: 44px; display: flex; align-items: center; padding: 0 var(--space-3); border-radius: var(--radius-md); color: var(--color-ink); font-size: var(--type-body-sm); text-decoration: none; }
|
||||
.chat-profile-picker-positioner { z-index: 30; outline: 0; }
|
||||
.chat-profile-picker-popup { width: max-content; min-width: 220px; max-width: min(320px, calc(100vw - var(--space-6))); border: 1px solid var(--color-border); padding: var(--space-3); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); }
|
||||
.chat-profile-picker-popup[data-starting-style], .chat-profile-picker-popup[data-ending-style] { opacity: 0; transform: translateY(calc(var(--space-1) * -1)); }
|
||||
@@ -2168,7 +2182,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.button-primary:not(:disabled):hover, .composer .composer-row-end > button:not(:disabled):hover { background: var(--color-action-fill-hover); }
|
||||
.model-selector-trigger:not(:disabled):hover { background: var(--color-canvas-muted); color: var(--color-ink); }
|
||||
.model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
|
||||
.chat-profile-picker-trigger:hover { background: var(--color-canvas-muted); color: var(--color-ink); }
|
||||
.chat-profile-picker-trigger:hover { background: var(--color-canvas); color: var(--color-ink); border-color: var(--color-border-strong); }
|
||||
.chat-profile-picker-add a:hover { background: var(--color-canvas-soft); }
|
||||
.chat-profile-picker-option:not(:disabled):not([aria-disabled="true"]):hover { background: var(--color-canvas-soft); color: var(--color-ink); }
|
||||
.button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); }
|
||||
.danger-primary:not(:disabled):hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); }
|
||||
@@ -2193,6 +2208,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; }
|
||||
[data-sidebar="rail"] { display: none; }
|
||||
.chat-panel { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
|
||||
.chat-panel:has(.chart-birth-line) { grid-template-rows: auto minmax(0, 1fr) auto; }
|
||||
.chat-header:has(.chart-birth-line) { height: auto; padding-bottom: var(--space-2); }
|
||||
.chart-birth-line { width: 100%; min-height: 44px; justify-content: space-between; }
|
||||
.chat-panel.is-rectification { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); }
|
||||
.chat-panel:has(.starter-balance) { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); }
|
||||
.chat-header { gap: var(--space-3); padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; }
|
||||
@@ -5125,14 +5143,28 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
line-height: 1.55;
|
||||
}
|
||||
.personal-report-key-evidence { min-width: 0; }
|
||||
.personal-report-key-evidence table,
|
||||
.personal-report-table-wrap table {
|
||||
.personal-report-key-evidence table {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
text-align: left;
|
||||
}
|
||||
/* Position tables (a header such as 速度, Speed, 度数, or 黄经) use the
|
||||
numeric class: one line, as wide as the words, scrolling inside the wrap.
|
||||
Other report tables stay within the page and wrap. Print forces every
|
||||
report table to the page width and lets cells wrap. */
|
||||
.personal-report-table-wrap table {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
text-align: left;
|
||||
}
|
||||
.personal-report-table-numeric table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
.personal-report-key-evidence th,
|
||||
.personal-report-key-evidence td,
|
||||
.personal-report-table-wrap th,
|
||||
@@ -5140,8 +5172,24 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
|
||||
vertical-align: top;
|
||||
}
|
||||
.personal-report-key-evidence th,
|
||||
.personal-report-key-evidence td { overflow-wrap: anywhere; }
|
||||
.personal-report-table-wrap th,
|
||||
.personal-report-table-wrap td {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.personal-report-table-numeric th,
|
||||
.personal-report-table-numeric td {
|
||||
overflow-wrap: normal;
|
||||
word-break: keep-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.report-nakshatra-en {
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: 0.85em;
|
||||
font-weight: 400;
|
||||
}
|
||||
.personal-report-key-evidence thead th,
|
||||
.personal-report-table-wrap thead th {
|
||||
color: var(--color-ink-secondary);
|
||||
@@ -5188,7 +5236,7 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
.personal-report-claim-status.is-success { background: var(--color-success-muted); color: var(--color-success); }
|
||||
.personal-report-claim-status.is-warning { background: var(--color-action-soft); color: var(--color-warning); }
|
||||
.personal-report-claim-status.is-danger { background: var(--color-danger-muted); color: var(--color-danger); }
|
||||
.personal-report-table-wrap { max-width: 100%; overflow-x: auto; }
|
||||
.personal-report-table-wrap { max-width: 100%; min-width: 0; overflow-x: auto; }
|
||||
.personal-report-planet-table { margin-top: var(--space-4); font-size: var(--type-caption); }
|
||||
.personal-report-planet-table th,
|
||||
.personal-report-planet-table td { padding-inline: 5px; }
|
||||
@@ -5424,17 +5472,17 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
.personal-report-chart-grid,
|
||||
.personal-report-chart-grid.is-single { grid-template-columns: 1fr; }
|
||||
.personal-report-chart-grid.is-single { max-width: 118mm; }
|
||||
.personal-report-table-wrap { max-width: none !important; overflow: visible !important; }
|
||||
.personal-report-table-wrap { max-width: 100% !important; min-width: 0 !important; overflow: visible !important; }
|
||||
/* Lighter on paper; the print block already forces exact colour. */
|
||||
.personal-report-table-wrap thead th,
|
||||
.personal-report-fact-tables thead th { background: color-mix(in srgb, var(--color-ink) 5%, transparent) !important; }
|
||||
.personal-report-table-wrap tbody tr:nth-child(even),
|
||||
.personal-report-fact-tables tbody tr:nth-child(even) { background: color-mix(in srgb, var(--color-ink) 2.5%, transparent) !important; }
|
||||
|
||||
.personal-report-table-wrap table,
|
||||
.personal-report-table-wrap table { width: 100% !important; min-width: 0 !important; table-layout: auto !important; font-size: 8pt; }
|
||||
.personal-report-key-evidence table { width: 100% !important; min-width: 0 !important; table-layout: fixed !important; }
|
||||
.personal-report-table-wrap th,
|
||||
.personal-report-table-wrap td,
|
||||
.personal-report-table-wrap td { overflow-wrap: anywhere !important; word-break: break-word !important; white-space: normal !important; }
|
||||
.personal-report-key-evidence th,
|
||||
.personal-report-key-evidence td { overflow-wrap: anywhere !important; word-break: break-word; }
|
||||
.personal-report-theme { padding: 6mm; margin-top: 4mm; }
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
type BirthTimeIntakeProps = {
|
||||
readonly value: BirthTimeDraft;
|
||||
readonly onPatch: (patch: BirthTimeDraftPatch) => void;
|
||||
/** Others cannot change a birth time that has already been saved. */
|
||||
readonly lockReportedTime?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -76,7 +78,11 @@ function BirthClockSelect({
|
||||
);
|
||||
}
|
||||
|
||||
export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) {
|
||||
export function BirthTimeIntakeFields({ value, onPatch, lockReportedTime = false }: BirthTimeIntakeProps) {
|
||||
const emitTime = (patch: BirthTimeDraftPatch) => {
|
||||
if (lockReportedTime) return;
|
||||
onPatch(patch);
|
||||
};
|
||||
const groupId = useId();
|
||||
const source = value.birthTimeSource;
|
||||
const isConfirmed = value.birthTimeStatus === "confirmed";
|
||||
@@ -127,6 +133,8 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
||||
disabled={isConfirmed}
|
||||
onChange={(date) => onPatch({ date })}
|
||||
/>
|
||||
{lockReportedTime ? <p className="birth-time-lock-note">填报的出生时间保存后不能再改</p> : null}
|
||||
<fieldset className="birth-time-lock" disabled={lockReportedTime}>
|
||||
|
||||
{source === "legacy_import" && (
|
||||
<p className="birth-time-legacy-note">
|
||||
@@ -148,7 +156,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
||||
name={`birth-time-source-${groupId}`}
|
||||
type="radio"
|
||||
value={option.value}
|
||||
onChange={() => onPatch({
|
||||
onChange={() => emitTime({
|
||||
birthTimeSource: option.value,
|
||||
birthTimeStatus: "reported",
|
||||
time: "",
|
||||
@@ -171,7 +179,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
||||
<BirthClockSelect
|
||||
key={`${source}-${value.reportedTime || value.time}`}
|
||||
value={value.reportedTime || value.time}
|
||||
onChange={(reportedTime) => onPatch({ reportedTime })}
|
||||
onChange={(reportedTime) => emitTime({ reportedTime })}
|
||||
/>
|
||||
</label>
|
||||
{source === "hospital_record" && (
|
||||
@@ -186,7 +194,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
||||
className={`birth-time-uncertainty-option ${approximateRadius === option.minutes ? "is-selected" : ""}`}
|
||||
key={option.minutes}
|
||||
type="button"
|
||||
onClick={() => onPatch(
|
||||
onClick={() => emitTime(
|
||||
option.minutes === 15 && source === "family_exact"
|
||||
? {
|
||||
uncertaintyBeforeMinutes: 0,
|
||||
@@ -217,7 +225,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
||||
ariaLabel="选择开始时间"
|
||||
hourLabel="开始小时"
|
||||
minuteLabel="开始分钟"
|
||||
onChange={(declaredWindowStart) => onPatch({ declaredWindowStart })}
|
||||
onChange={(declaredWindowStart) => emitTime({ declaredWindowStart })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -228,13 +236,13 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
||||
ariaLabel="选择结束时间"
|
||||
hourLabel="结束小时"
|
||||
minuteLabel="结束分钟"
|
||||
onChange={(declaredWindowEnd) => onPatch({ declaredWindowEnd })}
|
||||
onChange={(declaredWindowEnd) => emitTime({ declaredWindowEnd })}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="button-secondary birth-time-skip-button"
|
||||
type="button"
|
||||
onClick={() => onPatch({
|
||||
onClick={() => emitTime({
|
||||
birthTimeSource: "unknown",
|
||||
reportedTime: "",
|
||||
birthTimePeriod: "",
|
||||
@@ -255,6 +263,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
||||
<p>可以直接开始生时校正:先从你记得的经历比出大致时段。</p>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
|
||||
import { ChartProfileForm } from "@/components/chart-profile-form";
|
||||
import { OrbitWaiting } from "@/components/orbit-waiting";
|
||||
import { useSessionList } from "@/hooks/use-session-list";
|
||||
import {
|
||||
invalidateSubjectCatalog,
|
||||
loadSubjectCatalog,
|
||||
readCurrentSubjectAccountId,
|
||||
subscribeSubjectCatalog,
|
||||
} from "@/lib/current-subject";
|
||||
import { missingOtherProfileStep } from "@/lib/home-profile";
|
||||
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
|
||||
import type { ChartLibraryRecord, Profile } from "@/lib/home-types";
|
||||
import { notifyError } from "@/lib/notify";
|
||||
import { saveOtherProfile } from "@/lib/other-profile-save";
|
||||
import { reportedTimeStored } from "@/lib/reported-birth-time";
|
||||
import { invalidateEphemerisPage, pinChartSnapshotIdentity } from "@/lib/secondary-page-data";
|
||||
import { saveSelfGender, saveSelfProfile } from "@/lib/self-profile-save";
|
||||
|
||||
export { reportedTimeStored };
|
||||
|
||||
export const CHART_BIRTH_EDIT_NOTICE = "改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。";
|
||||
|
||||
/** Close only when the click lands on the backdrop, outside the dialog box. Padding is inside the box. */
|
||||
export function chartBirthDialogShouldClose(
|
||||
target: unknown,
|
||||
currentTarget: unknown,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
rect: { readonly left: number; readonly top: number; readonly right: number; readonly bottom: number },
|
||||
): boolean {
|
||||
if (target !== currentTarget) return false;
|
||||
const inside = clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
|
||||
return !inside;
|
||||
}
|
||||
|
||||
export function birthFactsChanged(before: Profile, after: Profile): boolean {
|
||||
return before.date !== after.date
|
||||
|| before.time !== after.time
|
||||
|| before.reportedTime !== after.reportedTime
|
||||
|| before.birthTimeSource !== after.birthTimeSource
|
||||
|| before.birthTimePeriod !== after.birthTimePeriod
|
||||
|| before.declaredWindowStart !== after.declaredWindowStart
|
||||
|| before.declaredWindowEnd !== after.declaredWindowEnd
|
||||
|| before.uncertaintyBeforeMinutes !== after.uncertaintyBeforeMinutes
|
||||
|| before.uncertaintyAfterMinutes !== after.uncertaintyAfterMinutes
|
||||
|| before.countryCode !== after.countryCode
|
||||
|| before.provinceCode !== after.provinceCode
|
||||
|| before.cityCode !== after.cityCode
|
||||
|| before.districtCode !== after.districtCode
|
||||
|| before.birthPlaceLabel !== after.birthPlaceLabel
|
||||
|| before.latitude !== after.latitude
|
||||
|| before.longitude !== after.longitude;
|
||||
}
|
||||
|
||||
function findRecord(library: readonly ChartLibraryRecord[], subjectId: string): ChartLibraryRecord | null {
|
||||
if (subjectId === "self") return library.find((row) => row.role === "self") ?? null;
|
||||
return library.find((row) => row.id === subjectId) ?? null;
|
||||
}
|
||||
|
||||
export function ChartBirthEditorForm({
|
||||
role,
|
||||
baseline,
|
||||
draft,
|
||||
saving,
|
||||
onChange,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
readonly role: "self" | "other";
|
||||
readonly baseline: Profile;
|
||||
readonly draft: Profile;
|
||||
readonly saving: boolean;
|
||||
readonly onChange: (profile: Profile) => void;
|
||||
readonly onCancel: () => void;
|
||||
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
const self = role === "self";
|
||||
return (
|
||||
<ChartProfileForm
|
||||
title={self ? "编辑我的资料" : "编辑人物"}
|
||||
description="称呼和出生资料。"
|
||||
value={draft}
|
||||
onChange={(next) => onChange(typeof next === "function" ? next(draft) : next)}
|
||||
nameInputId="chart-birth-edit-name"
|
||||
showAyanamsa
|
||||
showGender
|
||||
lockReportedBirthTime={!self && reportedTimeStored(baseline)}
|
||||
notice={birthFactsChanged(baseline, draft) ? CHART_BIRTH_EDIT_NOTICE : null}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
submitLabel={saving ? "保存中" : "保存"}
|
||||
submitDisabled={saving || Boolean(missingOtherProfileStep(draft))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartBirthEditor({
|
||||
subjectId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
readonly subjectId: string;
|
||||
readonly onClose: () => void;
|
||||
readonly onSaved?: () => Promise<void> | void;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const { account, setAccount } = useSessionList();
|
||||
const [library, setLibrary] = useState<ChartLibraryRecord[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [draft, setDraft] = useState<Profile | null>(null);
|
||||
const [baseline, setBaseline] = useState<Profile | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
dialog.showModal();
|
||||
return () => {
|
||||
dialog.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = () => {
|
||||
void loadSubjectCatalog()
|
||||
.then((next) => {
|
||||
if (cancelled) return;
|
||||
setLibrary(next);
|
||||
setFailed(false);
|
||||
const found = findRecord(next, subjectId);
|
||||
if (!found) return;
|
||||
setDraft((current) => current ?? { ...found.profile });
|
||||
setBaseline((current) => current ?? { ...found.profile });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
const unsubscribe = subscribeSubjectCatalog(refresh);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [subjectId, attempt]);
|
||||
|
||||
const record = library ? findRecord(library, subjectId) : null;
|
||||
|
||||
async function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!record || !draft || !baseline || missingOtherProfileStep(draft) || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (record.role === "self") {
|
||||
const saved = await saveSelfProfile(draft);
|
||||
const gender = draft.gender ?? null;
|
||||
if (gender !== (baseline.gender ?? null)) await saveSelfGender(gender);
|
||||
const profile: Profile = { ...saved.profile, gender };
|
||||
const accountId = account?.user.id || readCurrentSubjectAccountId();
|
||||
if (saved.fingerprint) pinChartSnapshotIdentity({ accountId, fingerprint: saved.fingerprint });
|
||||
clearHomeWarmSnapshot();
|
||||
invalidateEphemerisPage();
|
||||
setAccount((current) => current ? {
|
||||
...current,
|
||||
profile,
|
||||
...(saved.fingerprint ? { chartBirth: { fingerprint: saved.fingerprint } } : {}),
|
||||
} : current);
|
||||
} else {
|
||||
await saveOtherProfile(draft, { id: record.id });
|
||||
}
|
||||
invalidateSubjectCatalog();
|
||||
await onSaved?.();
|
||||
onClose();
|
||||
} catch (caught) {
|
||||
notifyError(caught instanceof Error && caught.message ? caught.message : "保存失败,请重试");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="chart-birth-dialog"
|
||||
aria-label={record?.role === "other" ? "编辑人物" : "编辑我的资料"}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (chartBirthDialogShouldClose(
|
||||
event.target,
|
||||
event.currentTarget,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.currentTarget.getBoundingClientRect(),
|
||||
)) onClose();
|
||||
}}
|
||||
>
|
||||
{failed ? (
|
||||
<p>
|
||||
人物列表暂时读不出来
|
||||
<button type="button" onClick={() => { setFailed(false); setLibrary(null); setAttempt((current) => current + 1); }}>重试</button>
|
||||
</p>
|
||||
) : library === null ? (
|
||||
<OrbitWaiting label="人物资料还在读" />
|
||||
) : !record || !draft || !baseline ? (
|
||||
<p>这份资料暂时读不出来</p>
|
||||
) : (
|
||||
<ChartBirthEditorForm
|
||||
role={record.role}
|
||||
baseline={baseline}
|
||||
draft={draft}
|
||||
saving={saving}
|
||||
onChange={setDraft}
|
||||
onCancel={onClose}
|
||||
onSubmit={(event) => void save(event)}
|
||||
/>
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
|
||||
import { BirthSkyEntry } from "@/components/birth-sky/birth-sky-entry";
|
||||
import { ChartBirthEditor } from "@/components/chart-page/chart-birth-editor";
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
import { useBirthSkyCover } from "@/hooks/use-birth-sky-cover";
|
||||
import { useChartPage } from "@/hooks/use-chart-page";
|
||||
@@ -24,7 +26,7 @@ import { ChartVedicTab } from "./chart-vedic-tab";
|
||||
import { ChartWesternTab } from "./chart-western-tab";
|
||||
|
||||
export function ChartPageRoute() {
|
||||
const { view, pendingLayers, layerFailures, requestLayer } = useChartPage();
|
||||
const { view, pendingLayers, layerFailures, requestLayer, reload } = useChartPage();
|
||||
const ok = view?.status === "ok" ? view : null;
|
||||
// 「那一刻的天空」 is prefetched as soon as the chart is ok (TASK-birth-sky-cover T3).
|
||||
const birthSky = useBirthSkyCover(ok ? ok.subjectId ?? "self" : null, ok?.profileFingerprint ?? null);
|
||||
@@ -34,6 +36,7 @@ export function ChartPageRoute() {
|
||||
pendingLayers={pendingLayers}
|
||||
layerFailures={layerFailures}
|
||||
onNeedLayer={requestLayer}
|
||||
onBirthSaved={reload}
|
||||
birthSky={birthSky.cover}
|
||||
birthSkyPending={birthSky.pending}
|
||||
/>
|
||||
@@ -76,6 +79,7 @@ export function ChartPageView({
|
||||
initialTab = "vedic",
|
||||
birthSky = null,
|
||||
birthSkyPending = false,
|
||||
onBirthSaved,
|
||||
}: {
|
||||
view: ChartViewResponse | null;
|
||||
pendingLayers?: ReadonlySet<ChartViewLayer>;
|
||||
@@ -86,9 +90,11 @@ export function ChartPageView({
|
||||
birthSky?: BirthSkyCover | null;
|
||||
/** The cover is still painting: the button already shows and opens when it lands. */
|
||||
birthSkyPending?: boolean;
|
||||
onBirthSaved?: () => Promise<void> | void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<ChartViewTabId>(initialTab);
|
||||
const [vargaId, setVargaId] = useState<ChartTypeId>("D1");
|
||||
const [editingBirth, setEditingBirth] = useState(false);
|
||||
|
||||
function selectTab(id: ChartViewTabId) {
|
||||
setTab(id);
|
||||
@@ -105,15 +111,42 @@ export function ChartPageView({
|
||||
const vedicLayer = chartTypeLayer(vargaId);
|
||||
|
||||
const birthline = view != null && view.status === "ok"
|
||||
? `${view.profile.name} · ${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}`
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="chart-birth-line"
|
||||
aria-label={`编辑出生资料,${view.profile.date} ${view.profile.time},${view.profile.placeLabel}`}
|
||||
onClick={() => setEditingBirth(true)}
|
||||
>
|
||||
<span>{`${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}`}</span>
|
||||
<Pencil aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
|
||||
async function afterBirthSaved() {
|
||||
await onBirthSaved?.();
|
||||
if (tab === "dasha") onNeedLayer?.("chara");
|
||||
if (tab === "western") onNeedLayer?.("western");
|
||||
if (tab === "vedic") {
|
||||
const layer = chartTypeLayer(vargaId);
|
||||
if (layer) onNeedLayer?.(layer);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageShell
|
||||
title="星盘"
|
||||
note={birthline}
|
||||
actions={view?.status === "ok" && (birthSky || birthSkyPending) ? <BirthSkyEntry cover={birthSky ?? null} /> : undefined}
|
||||
>
|
||||
{editingBirth && view?.status === "ok" ? (
|
||||
<ChartBirthEditor
|
||||
subjectId={view.subjectId ?? "self"}
|
||||
onClose={() => setEditingBirth(false)}
|
||||
onSaved={afterBirthSaved}
|
||||
/>
|
||||
) : null}
|
||||
{view != null && view.status !== "ok" ? (
|
||||
<section className="chart-page-message">
|
||||
{view.status === "birth_profile_incomplete" ? <h2>{CHART_VIEW_COPY.profileEntryTitle}</h2> : null}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, FormEvent, SetStateAction } from "react";
|
||||
import type { Dispatch, FormEvent, ReactNode, SetStateAction } from "react";
|
||||
import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field";
|
||||
import { ProfileFields } from "@/components/profile-fields";
|
||||
import { ProfileGenderField } from "@/components/profile-gender-field";
|
||||
@@ -21,6 +21,9 @@ export type ChartProfileFormProps = {
|
||||
readonly cancelLabel?: string;
|
||||
readonly submitLabel: string;
|
||||
readonly submitDisabled: boolean;
|
||||
/** Chart-page editor: the saved birth time of someone else cannot be changed. */
|
||||
readonly lockReportedBirthTime?: boolean;
|
||||
readonly notice?: ReactNode;
|
||||
};
|
||||
|
||||
export function ChartProfileForm({
|
||||
@@ -36,11 +39,18 @@ export function ChartProfileForm({
|
||||
cancelLabel = "取消",
|
||||
submitLabel,
|
||||
submitDisabled,
|
||||
lockReportedBirthTime = false,
|
||||
notice,
|
||||
}: ChartProfileFormProps) {
|
||||
return (
|
||||
<form className="profile-form chart-library-form" onSubmit={onSubmit}>
|
||||
<div className="section-heading"><b>{title}</b><small>{description}</small></div>
|
||||
<ProfileFields value={value} onChange={onChange} nameInputId={nameInputId} />
|
||||
<ProfileFields
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
nameInputId={nameInputId}
|
||||
lockReportedBirthTime={lockReportedBirthTime}
|
||||
/>
|
||||
{showGender ? (
|
||||
<ProfileGenderField
|
||||
idPrefix={nameInputId}
|
||||
@@ -54,6 +64,7 @@ export function ChartProfileForm({
|
||||
onChange={(ayanamsa) => onChange((current) => ({ ...current, ayanamsa }))}
|
||||
/>
|
||||
) : null}
|
||||
{notice ? <p className="chart-birth-edit-notice" role="status">{notice}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
{onCancel ? <button className="button-secondary" type="button" onClick={onCancel}>{cancelLabel}</button> : null}
|
||||
<button className="button-primary save-profile" type="submit" disabled={submitDisabled}>{submitLabel}</button>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
import { bindCurrentSubjectAccount, invalidateSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
|
||||
import { fetchAccount } from "@/lib/home-cloud-sync";
|
||||
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
|
||||
import { saveOtherProfile } from "@/lib/other-profile-save";
|
||||
import { reportedTimeStored } from "@/lib/reported-birth-time";
|
||||
import { invalidateEphemerisPage, pinChartSnapshotIdentity } from "@/lib/secondary-page-data";
|
||||
import { saveSelfGender, saveSelfProfile } from "@/lib/self-profile-save";
|
||||
import { notifyError, notifyInfo } from "@/lib/notify";
|
||||
@@ -198,25 +200,16 @@ export function PeoplePage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const editingOther = selectedId && selected?.role === "other" ? selected.id : "";
|
||||
const response = await fetch(editingOther ? `/api/chart-profiles/${editingOther}` : "/api/chart-profiles", {
|
||||
method: editingOther ? "PUT" : "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ role: "other", profile: draft }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as { error?: string; profile?: { id?: string } } | null;
|
||||
if (!response.ok) {
|
||||
notifyError(payload?.error || "保存失败,请重试");
|
||||
return;
|
||||
}
|
||||
const saved = await saveOtherProfile(draft, { id: editingOther });
|
||||
setEditing(false);
|
||||
invalidateSubjectCatalog();
|
||||
await load();
|
||||
if (payload?.profile?.id) {
|
||||
setSelectedId(payload.profile.id);
|
||||
replaceDetailParam(payload.profile.id);
|
||||
if (saved.id) {
|
||||
setSelectedId(saved.id);
|
||||
replaceDetailParam(saved.id);
|
||||
}
|
||||
} catch {
|
||||
notifyError("保存失败,请重试");
|
||||
} catch (caught) {
|
||||
notifyError(caught instanceof Error && caught.message ? caught.message : "保存失败,请重试");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -395,6 +388,9 @@ export function PeopleArchiveView({
|
||||
}: PeopleArchiveViewProps) {
|
||||
const remaining = MAX_SAVED_SUBJECTS - people.length;
|
||||
const editingSelf = editing && selectedId !== "" && selected?.role === "self";
|
||||
const lockReportedBirthTime = Boolean(
|
||||
editing && selectedId !== "" && selected && selected.role === "other" && reportedTimeStored(selected.profile),
|
||||
);
|
||||
return (
|
||||
<div className="people-archive" data-view={view}>
|
||||
<section className="people-archive-list" aria-label="星盘档案">
|
||||
@@ -437,12 +433,13 @@ export function PeopleArchiveView({
|
||||
{editing ? (
|
||||
<ChartProfileForm
|
||||
title={editingSelf ? "编辑我的资料" : selectedId ? "编辑人物" : "添加一个人"}
|
||||
description={editingSelf ? "称呼和出生资料。" : "称呼和出生资料。填报的出生时间保存后不能再改。"}
|
||||
description="称呼和出生资料。"
|
||||
value={draft}
|
||||
onChange={onDraftChange}
|
||||
nameInputId="people-archive-name"
|
||||
showAyanamsa
|
||||
showGender
|
||||
lockReportedBirthTime={lockReportedBirthTime}
|
||||
onSubmit={(event) => void (editingSelf ? saveSelf(event) : saveOther(event))}
|
||||
onCancel={onCancelEdit}
|
||||
submitLabel={saving ? "保存中" : "保存"}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { Fragment, isValidElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type LongformHeading,
|
||||
type LongformSection,
|
||||
} from "@/lib/personal-report-longform-outline";
|
||||
import { formatReportSpeedColumns, reportTableWrapClass, splitNakshatraLabel } from "@/lib/report-table-reading";
|
||||
|
||||
function flattenText(node: ReactNode): string {
|
||||
if (typeof node === "string" || typeof node === "number") return String(node);
|
||||
@@ -38,6 +39,41 @@ function reportUrlTransform(url: string): string {
|
||||
return url.startsWith("#") ? url : "";
|
||||
}
|
||||
|
||||
function tableHeaderLabels(node: ReactNode): string[] {
|
||||
const labels: string[] = [];
|
||||
const visit = (current: ReactNode) => {
|
||||
if (Array.isArray(current)) {
|
||||
current.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (!isValidElement(current)) return;
|
||||
const props = current.props as { children?: ReactNode };
|
||||
if (current.type === "th") {
|
||||
labels.push(flattenText(props.children).replace(/\s+/g, " ").trim());
|
||||
return;
|
||||
}
|
||||
visit(props.children);
|
||||
};
|
||||
visit(node);
|
||||
return labels;
|
||||
}
|
||||
|
||||
function renderNakshatraLabel(node: ReactNode): ReactNode {
|
||||
if (typeof node === "string") {
|
||||
const pieces = splitNakshatraLabel(node);
|
||||
if (pieces.length === 1 && !pieces[0].english) return node;
|
||||
return pieces.map((piece, index) => (
|
||||
piece.english
|
||||
? <span className="report-nakshatra-en" key={index}>{piece.text}</span>
|
||||
: <Fragment key={index}>{piece.text}</Fragment>
|
||||
));
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map((child, index) => <Fragment key={index}>{renderNakshatraLabel(child)}</Fragment>);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function markdownComponents(
|
||||
headings: readonly LongformHeading[],
|
||||
language: ReportLanguage,
|
||||
@@ -63,10 +99,11 @@ function markdownComponents(
|
||||
},
|
||||
img: () => null,
|
||||
table: ({ children }) => (
|
||||
<div className="markdown-table personal-report-table-wrap">
|
||||
<div className={reportTableWrapClass(tableHeaderLabels(children))}>
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
td: ({ children }) => <td>{renderNakshatraLabel(children)}</td>,
|
||||
h1: ({ children }) => {
|
||||
const title = flattenText(children).trim();
|
||||
const line = byline.shown ? null : reportTitleByline({ title, subjectName, createdAt, language });
|
||||
@@ -137,7 +174,7 @@ function renderMarkdown(
|
||||
unwrapDisallowed
|
||||
urlTransform={reportUrlTransform}
|
||||
>
|
||||
{markdown}
|
||||
{formatReportSpeedColumns(markdown)}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,17 @@ import { applyBirthTimeDraftPatch } from "@/lib/birth-time-intake-model";
|
||||
import { invalidateCandidateAfterLocationChange } from "@/lib/home-profile";
|
||||
import type { Profile } from "@/lib/home-types";
|
||||
|
||||
export function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) {
|
||||
export function ProfileFields({
|
||||
value,
|
||||
onChange,
|
||||
nameInputId,
|
||||
lockReportedBirthTime = false,
|
||||
}: {
|
||||
value: Profile;
|
||||
onChange: (profile: Profile) => void;
|
||||
nameInputId?: string;
|
||||
lockReportedBirthTime?: boolean;
|
||||
}) {
|
||||
const nameRemainingId = `${nameInputId ?? "profile-name"}-remaining`;
|
||||
return (
|
||||
<>
|
||||
@@ -28,7 +38,11 @@ export function ProfileFields({ value, onChange, nameInputId }: { value: Profile
|
||||
onChange={(event) => onChange({ ...value, name: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<BirthTimeIntakeFields value={value} onPatch={(patch) => onChange(applyBirthTimeDraftPatch(value, patch))} />
|
||||
<BirthTimeIntakeFields
|
||||
value={value}
|
||||
lockReportedTime={lockReportedBirthTime}
|
||||
onPatch={(patch) => onChange(applyBirthTimeDraftPatch(value, patch))}
|
||||
/>
|
||||
<BirthLocationFields
|
||||
value={value}
|
||||
onChange={(next) => onChange(invalidateCandidateAfterLocationChange(value, next))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Popover } from "@base-ui/react/popover";
|
||||
import { Check } from "lucide-react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -41,6 +41,8 @@ export function SubjectSwitcher({
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listId = useId();
|
||||
const rows = chatProfilePickerRows({ status, library, selectedId }).slice(0, MAX_SAVED_SUBJECTS);
|
||||
// Issue #86 said “5 people”. The live archive cap is 20 (`MAX_SAVED_SUBJECTS`).
|
||||
const showAddPerson = (status === "ready" || status === "degraded") && library.length < MAX_SAVED_SUBJECTS;
|
||||
|
||||
function closePicker() {
|
||||
setOpen(false);
|
||||
@@ -77,6 +79,7 @@ export function SubjectSwitcher({
|
||||
aria-label={`当前星盘:${label}`}
|
||||
>
|
||||
<span className="chat-profile-picker-name">{label}</span>
|
||||
<ChevronDown className="chat-profile-picker-chevron" aria-hidden="true" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Positioner className="chat-profile-picker-positioner" side="bottom" align="start" sideOffset={8} collisionPadding={12}>
|
||||
@@ -115,6 +118,11 @@ export function SubjectSwitcher({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{showAddPerson ? (
|
||||
<div className="chat-profile-picker-add">
|
||||
<a href="/people">+ 添加一个人</a>
|
||||
</div>
|
||||
) : null}
|
||||
</Popover.Popup>
|
||||
</Popover.Positioner>
|
||||
</Popover.Portal>
|
||||
|
||||
@@ -167,5 +167,20 @@ export function useChartPage() {
|
||||
});
|
||||
}, [subjectId, subjectReady]);
|
||||
|
||||
return { view: subjectReady ? view : null, pendingLayers, layerFailures, requestLayer };
|
||||
const reload = useCallback(async () => {
|
||||
loadedLayers.current.clear();
|
||||
inflightLayers.current.clear();
|
||||
setPendingLayers(new Set());
|
||||
setLayerFailures(new Map());
|
||||
const snapshot = await refreshChartPage();
|
||||
if (!mounted.current) return;
|
||||
if (snapshot.kind === "unauthenticated") {
|
||||
setView(viewFromSnapshot(snapshot));
|
||||
window.location.assign("/login");
|
||||
return;
|
||||
}
|
||||
setView(snapshot.view);
|
||||
}, []);
|
||||
|
||||
return { view: subjectReady ? view : null, pendingLayers, layerFailures, requestLayer, reload };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Profile } from "./home-types.ts";
|
||||
import { invalidateChartPage, invalidateEphemerisPage } from "./secondary-page-data.ts";
|
||||
|
||||
const IMMUTABLE_BIRTH_TIME = "填报的出生时间不能再改";
|
||||
const SAVE_FAILED = "保存失败,请重试";
|
||||
|
||||
export type SavedOtherProfile = Readonly<{
|
||||
/** Server id when the route returns one, otherwise the id that was updated. */
|
||||
id: string | null;
|
||||
}>;
|
||||
|
||||
type FetchImpl = typeof fetch;
|
||||
|
||||
export type SaveOtherProfileOptions = Readonly<{
|
||||
/** Stored person id updates that row (PUT). Omit or pass "" to create (POST). */
|
||||
id?: string;
|
||||
fetchImpl?: FetchImpl;
|
||||
/** Defaults drop the chart and ephemeris pages. Tests can pass counters. */
|
||||
invalidateChart?: () => void;
|
||||
invalidateEphemeris?: () => void;
|
||||
}>;
|
||||
|
||||
type SavePayload = {
|
||||
error?: string;
|
||||
code?: string;
|
||||
profile?: { id?: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* The one write for a person who is not the account owner.
|
||||
* The chart editor and the archive page both call this. A successful save
|
||||
* always drops the chart and ephemeris caches: the archive page used to
|
||||
* refresh the people list only, so the chart page kept the previous chart
|
||||
* (BUG-1411).
|
||||
*/
|
||||
export async function saveOtherProfile(
|
||||
profile: Profile,
|
||||
options: SaveOtherProfileOptions = {},
|
||||
): Promise<SavedOtherProfile> {
|
||||
const id = options.id?.trim() ?? "";
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const response = await fetchImpl(id ? `/api/chart-profiles/${id}` : "/api/chart-profiles", {
|
||||
method: id ? "PUT" : "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ role: "other", profile }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as SavePayload | null;
|
||||
if (!response.ok) throw new Error(failedSaveMessage(response.status, payload));
|
||||
(options.invalidateChart ?? invalidateChartPage)();
|
||||
(options.invalidateEphemeris ?? invalidateEphemerisPage)();
|
||||
const returned = payload?.profile?.id;
|
||||
return { id: typeof returned === "string" && returned !== "" ? returned : (id || null) };
|
||||
}
|
||||
|
||||
function failedSaveMessage(status: number, payload: SavePayload | null): string {
|
||||
const error = payload?.error ?? "";
|
||||
const code = payload?.code ?? "";
|
||||
if (status === 409 && (code === "reported_birth_time_is_immutable" || error.includes("reported_birth_time_is_immutable"))) {
|
||||
return IMMUTABLE_BIRTH_TIME;
|
||||
}
|
||||
return error || SAVE_FAILED;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
const NAKSHATRA_ENGLISH = /([A-Za-z][A-Za-z ]*)/g;
|
||||
|
||||
export type NakshatraPiece = { readonly english: boolean; readonly text: string };
|
||||
|
||||
/** Split `阿湿毗尼(Ashwini)` so the Latin name can sit on the same line in quieter type. */
|
||||
export function splitNakshatraLabel(text: string): readonly NakshatraPiece[] {
|
||||
const pieces: NakshatraPiece[] = [];
|
||||
let last = 0;
|
||||
for (const match of text.matchAll(NAKSHATRA_ENGLISH)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > last) pieces.push({ english: false, text: text.slice(last, index) });
|
||||
pieces.push({ english: true, text: match[0] });
|
||||
last = index + match[0].length;
|
||||
}
|
||||
if (pieces.length === 0) return [{ english: false, text }];
|
||||
if (last < text.length) pieces.push({ english: false, text: text.slice(last) });
|
||||
return pieces;
|
||||
}
|
||||
|
||||
function tableCells(line: string): string[] {
|
||||
return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
function isRuleRow(cells: readonly string[]): boolean {
|
||||
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
|
||||
}
|
||||
|
||||
/**
|
||||
* Planet-position tables (a header such as 速度 / Speed / 度数 / 黄经).
|
||||
* Those stay one line. Sentence tables (作用说明, 建议) do not match.
|
||||
*/
|
||||
const NUMERIC_REPORT_HEADER = /速度|speed|度数|degree|黄经|longitude/i;
|
||||
|
||||
export function isNumericReportTable(headers: readonly string[]): boolean {
|
||||
return headers.some((header) => NUMERIC_REPORT_HEADER.test(header));
|
||||
}
|
||||
|
||||
export function reportTableWrapClass(headers: readonly string[]): string {
|
||||
const base = "markdown-table personal-report-table-wrap";
|
||||
return isNumericReportTable(headers) ? `${base} personal-report-table-numeric` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Planet-position tables already stored in a report print speed to 6 places
|
||||
* (`11.852534`) and the cell then breaks in half. Show 4 places. New reports
|
||||
* are written with 4 places as well; this keeps older markdown in line.
|
||||
*/
|
||||
export function formatReportSpeedColumns(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
let fence = false;
|
||||
let speedIndex: number | null = null;
|
||||
const out: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("```")) {
|
||||
fence = !fence;
|
||||
speedIndex = null;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
if (fence || !line.startsWith("|")) {
|
||||
speedIndex = null;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
const cells = tableCells(line);
|
||||
if (isRuleRow(cells)) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
const header = cells.findIndex((cell) => cell === "速度" || cell === "Speed");
|
||||
if (header >= 0) {
|
||||
speedIndex = header;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
if (speedIndex == null || speedIndex >= cells.length) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
const speed = cells[speedIndex];
|
||||
if (!/^-?\d+\.\d{5,}$/.test(speed)) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
cells[speedIndex] = Number(speed).toFixed(4);
|
||||
out.push(`| ${cells.join(" | ")} |`);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Profile } from "./home-types.ts";
|
||||
|
||||
/** Someone else's reported birth time is already on file and must not be edited. */
|
||||
export function reportedTimeStored(profile: Profile): boolean {
|
||||
return profile.reportedTime.trim() !== ""
|
||||
|| profile.declaredWindowStart.trim() !== ""
|
||||
|| (profile.birthTimeSource === "legacy_import" && profile.time.trim() !== "");
|
||||
}
|
||||
@@ -265,6 +265,20 @@ export function pinChartSnapshotIdentity(identity: ChartSnapshotIdentity): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the chart snapshot so the next read follows a birth-data save. */
|
||||
export function invalidateChartPage(): void {
|
||||
chartEpoch += 1;
|
||||
chartAccountReadGeneration += 1;
|
||||
const subjectId = readCurrentSubjectId();
|
||||
pinnedChartIdentity = {
|
||||
accountId: readCurrentSubjectAccountId() || pinnedChartIdentity?.accountId || "",
|
||||
fingerprint: "",
|
||||
subjectId,
|
||||
};
|
||||
chartSlot = null;
|
||||
chartInflight = null;
|
||||
}
|
||||
|
||||
let ephemerisSubject = "";
|
||||
const ephemerisCache = createMemoryCache<EphemerisPageSnapshot>(async () => {
|
||||
const subjectId = readCurrentSubjectId();
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
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 {
|
||||
ChartBirthEditorForm,
|
||||
birthFactsChanged,
|
||||
chartBirthDialogShouldClose,
|
||||
reportedTimeStored,
|
||||
} from "../src/components/chart-page/chart-birth-editor.tsx";
|
||||
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx";
|
||||
import { emptyProfile, type Profile } from "../src/lib/home-types.ts";
|
||||
import { saveOtherProfile } from "../src/lib/other-profile-save.ts";
|
||||
import { formatReportSpeedColumns, isNumericReportTable, splitNakshatraLabel } from "../src/lib/report-table-reading.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const switcher = readFileSync(new URL("../src/components/subject-switcher.tsx", import.meta.url), "utf8");
|
||||
const chartPage = readFileSync(new URL("../src/components/chart-page/chart-page-view.tsx", import.meta.url), "utf8");
|
||||
const editor = readFileSync(new URL("../src/components/chart-page/chart-birth-editor.tsx", import.meta.url), "utf8");
|
||||
const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
|
||||
const otherSave = readFileSync(new URL("../src/lib/other-profile-save.ts", import.meta.url), "utf8");
|
||||
const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
|
||||
const readerExport = readFileSync(new URL("../../scripts/pl9_reader_export.py", import.meta.url), "utf8");
|
||||
|
||||
const sample: Profile = {
|
||||
...emptyProfile,
|
||||
name: "甲",
|
||||
date: "1990-01-01",
|
||||
time: "08:00",
|
||||
reportedTime: "08:00",
|
||||
birthTimeSource: "hospital_record",
|
||||
birthPlaceLabel: "某市",
|
||||
latitude: 31.2,
|
||||
longitude: 121.5,
|
||||
};
|
||||
|
||||
type Call = { url: string; init: RequestInit };
|
||||
|
||||
function fakeFetch(status: number, body: unknown, calls: Call[]): typeof fetch {
|
||||
return (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init: init ?? {} });
|
||||
return new Response(body == null ? "" : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function cssRules(source: string): { selector: string; body: string }[] {
|
||||
const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
const found: { selector: string; body: string }[] = [];
|
||||
for (const match of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
found.push({ selector: match[1] ?? "", body: match[2] ?? "" });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
test("speed cells with five or more decimals become four, and longitude stays", () => {
|
||||
const markdown = [
|
||||
"| 行星 | 黄经 | 速度 |",
|
||||
"| --- | --- | --- |",
|
||||
"| 月亮 | 123.456789 | 11.852534 |",
|
||||
"| 太阳 | 10.000000 | -0.012345 |",
|
||||
"",
|
||||
"```",
|
||||
"| 速度 |",
|
||||
"| 11.852534 |",
|
||||
"```",
|
||||
"",
|
||||
"| Planet | Speed |",
|
||||
"| --- | --- |",
|
||||
"| Moon | 11.852534 |",
|
||||
].join("\n");
|
||||
const formatted = formatReportSpeedColumns(markdown);
|
||||
assert.match(formatted, /\| 月亮 \| 123\.456789 \| 11\.8525 \|/);
|
||||
assert.match(formatted, /\| 太阳 \| 10\.000000 \| -0\.0123 \|/);
|
||||
assert.match(formatted, /```\n\| 速度 \|\n\| 11\.852534 \|/);
|
||||
assert.match(formatted, /\| Moon \| 11\.8525 \|/);
|
||||
});
|
||||
|
||||
test("a nakshatra keeps the Chinese name and marks the Latin name", () => {
|
||||
assert.deepEqual(splitNakshatraLabel("阿湿毗尼(Ashwini)"), [
|
||||
{ english: false, text: "阿湿毗尼" },
|
||||
{ english: true, text: "(Ashwini)" },
|
||||
]);
|
||||
assert.deepEqual(splitNakshatraLabel("Mula(根、本源)"), [
|
||||
{ english: false, text: "Mula(根、本源)" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("the person control is a button, and add-person follows the live cap of 20", () => {
|
||||
assert.match(switcher, /ChevronDown/);
|
||||
assert.match(switcher, /+ 添加一个人/);
|
||||
assert.match(switcher, /href="\/people"/);
|
||||
assert.match(switcher, /MAX_SAVED_SUBJECTS/);
|
||||
assert.doesNotMatch(switcher, /library\.length < 5/);
|
||||
assert.match(css, /\.chat-profile-picker-trigger\s*\{[^}]*min-height:\s*44px/);
|
||||
assert.match(css, /\.chat-profile-picker-trigger\s*\{[^}]*border:\s*1px solid var\(--color-border\)/);
|
||||
assert.match(css, /\.chat-profile-picker-trigger\s*\{[^}]*border-radius:\s*999px/);
|
||||
});
|
||||
|
||||
test("only numeric report tables stay on one line, and print wraps every table", () => {
|
||||
assert.equal(isNumericReportTable(["行星", "黄经", "速度"]), true);
|
||||
assert.equal(isNumericReportTable(["Planet", "Degree", "Longitude", "Speed"]), true);
|
||||
assert.equal(isNumericReportTable(["落座度数"]), true);
|
||||
assert.equal(isNumericReportTable(["瑜伽", "作用说明"]), false);
|
||||
assert.equal(isNumericReportTable(["主题", "建议", "说明"]), false);
|
||||
|
||||
const markdown = [
|
||||
"# 标题",
|
||||
"",
|
||||
"## 成品阅读导航",
|
||||
"",
|
||||
"### 行星原始位置",
|
||||
"",
|
||||
"| 行星 | 黄经 | 速度 |",
|
||||
"| --- | --- | --- |",
|
||||
"| 月亮 | 10.2500 | 13.1764 |",
|
||||
"",
|
||||
"### 作用说明",
|
||||
"",
|
||||
"| 瑜伽 | 作用说明 |",
|
||||
"| --- | --- |",
|
||||
"| 某组合 | 这一整句是给读者看的说明,应该在格子里换行。 |",
|
||||
].join("\n");
|
||||
const markup = renderToStaticMarkup(React.createElement(PersonalReportMarkdownView, { markdown }));
|
||||
const tables = [...markup.matchAll(/<div class="(markdown-table [^"]*)">([\s\S]*?)<\/table><\/div>/g)];
|
||||
assert.equal(tables.length, 2);
|
||||
const numeric = tables.find((table) => table[2]?.includes("<th>速度</th>"));
|
||||
const prose = tables.find((table) => table[2]?.includes("<th>作用说明</th>"));
|
||||
assert.ok(numeric && prose);
|
||||
assert.match(numeric[1] ?? "", /personal-report-table-wrap personal-report-table-numeric$/);
|
||||
assert.equal(prose[1], "markdown-table personal-report-table-wrap");
|
||||
|
||||
assert.match(css, /\.personal-report-table-wrap table\s*\{[^}]*width:\s*100%/);
|
||||
assert.match(css, /\.personal-report-table-wrap table\s*\{[^}]*table-layout:\s*auto/);
|
||||
assert.match(css, /\.personal-report-table-numeric table\s*\{[^}]*width:\s*max-content/);
|
||||
assert.match(css, /\.report-nakshatra-en\s*\{[^}]*color:\s*var\(--color-ink-tertiary\)/);
|
||||
assert.match(readerExport, /_num\(row\.get\('speed'\), 4\)/);
|
||||
|
||||
const regionStart = css.indexOf(".personal-report-table-wrap table {");
|
||||
const regionEnd = css.indexOf(".personal-report-chart-grid { display: grid");
|
||||
assert.ok(regionStart >= 0 && regionEnd > regionStart);
|
||||
const region = css.slice(regionStart, regionEnd);
|
||||
const locked = cssRules(region).filter((rule) => /nowrap|max-content/.test(rule.body));
|
||||
assert.ok(locked.length >= 2);
|
||||
for (const rule of locked) {
|
||||
assert.match(rule.selector, /personal-report-table-numeric/);
|
||||
}
|
||||
|
||||
const printStart = css.search(/@media print \{\s*\/\* Paper is always paper\./);
|
||||
assert.ok(printStart >= 0);
|
||||
const print = css.slice(printStart, css.indexOf("/* ---- Membership page ---- */"));
|
||||
assert.doesNotMatch(print, /nowrap/);
|
||||
assert.doesNotMatch(print, /max-content/);
|
||||
assert.match(print, /\.personal-report-table-wrap table \{ width: 100% !important/);
|
||||
assert.match(print, /white-space: normal !important/);
|
||||
});
|
||||
|
||||
test("the chart page birth line drops the name and opens an editor on this page", () => {
|
||||
assert.match(chartPage, /chart-birth-line/);
|
||||
assert.match(chartPage, /编辑出生资料/);
|
||||
assert.doesNotMatch(chartPage, /\$\{view\.profile\.name\} · \$\{view\.profile\.date\}/);
|
||||
assert.match(chartPage, /onBirthSaved/);
|
||||
});
|
||||
|
||||
test("someone else's saved birth time stays locked, and the lock sentence appears once", () => {
|
||||
assert.equal(reportedTimeStored(sample), true);
|
||||
assert.equal(reportedTimeStored({ ...emptyProfile, declaredWindowStart: "06:00" }), true);
|
||||
assert.equal(reportedTimeStored({ ...emptyProfile, birthTimeSource: "legacy_import", time: "08:00" }), true);
|
||||
assert.equal(reportedTimeStored(emptyProfile), false);
|
||||
assert.equal(birthFactsChanged(sample, { ...sample, name: "乙" }), false);
|
||||
assert.equal(birthFactsChanged(sample, { ...sample, ayanamsa: "lahiri" }), false);
|
||||
assert.equal(birthFactsChanged(sample, { ...sample, date: "1991-02-02" }), true);
|
||||
|
||||
const other = renderToStaticMarkup(
|
||||
<ChartBirthEditorForm
|
||||
role="other"
|
||||
baseline={sample}
|
||||
draft={{ ...sample, date: "1991-02-02" }}
|
||||
saving={false}
|
||||
onChange={() => {}}
|
||||
onCancel={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
assert.equal((other.match(/填报的出生时间保存后不能再改/g) ?? []).length, 1);
|
||||
assert.match(other, /birth-time-lock-note">填报的出生时间保存后不能再改/);
|
||||
assert.match(other, /改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。/);
|
||||
assert.match(other, /disabled/);
|
||||
|
||||
const unlocked = renderToStaticMarkup(
|
||||
<ChartBirthEditorForm
|
||||
role="other"
|
||||
baseline={emptyProfile}
|
||||
draft={emptyProfile}
|
||||
saving={false}
|
||||
onChange={() => {}}
|
||||
onCancel={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
assert.doesNotMatch(unlocked, /填报的出生时间保存后不能再改/);
|
||||
|
||||
const self = renderToStaticMarkup(
|
||||
<ChartBirthEditorForm
|
||||
role="self"
|
||||
baseline={sample}
|
||||
draft={sample}
|
||||
saving={false}
|
||||
onChange={() => {}}
|
||||
onCancel={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
assert.doesNotMatch(self, /填报的出生时间保存后不能再改/);
|
||||
assert.doesNotMatch(self, /改了出生时间、日期或地点/);
|
||||
|
||||
assert.equal((intake.match(/填报的出生时间保存后不能再改/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(editor, /填报的出生时间保存后不能再改/);
|
||||
assert.doesNotMatch(people, /填报的出生时间保存后不能再改/);
|
||||
assert.match(people, /reportedTimeStored\(selected\.profile\)/);
|
||||
});
|
||||
|
||||
test("a click on the dialog padding keeps the editor open, and the backdrop click closes it", () => {
|
||||
const dialog = {};
|
||||
const rect = { left: 40, top: 30, right: 400, bottom: 500 };
|
||||
assert.equal(chartBirthDialogShouldClose(dialog, dialog, 48, 36, rect), false);
|
||||
assert.equal(chartBirthDialogShouldClose(dialog, dialog, 8, 8, rect), true);
|
||||
assert.equal(chartBirthDialogShouldClose({}, dialog, 8, 8, rect), false);
|
||||
assert.match(editor, /chartBirthDialogShouldClose\(\s*event\.target/);
|
||||
assert.match(editor, /onCancel=\{\(event\) => \{\s*event\.preventDefault\(\);\s*onClose\(\);/);
|
||||
assert.doesNotMatch(editor, /if \(event\.target === event\.currentTarget\) onClose\(\)/);
|
||||
});
|
||||
|
||||
test("saving someone else is one function, and a success drops the chart and ephemeris caches", async () => {
|
||||
assert.match(editor, /saveOtherProfile\(draft, \{ id: record\.id \}\)/);
|
||||
assert.match(people, /saveOtherProfile\(draft, \{ id: editingOther \}\)/);
|
||||
assert.match(otherSave, /\(options\.invalidateChart \?\? invalidateChartPage\)\(\)/);
|
||||
assert.match(otherSave, /\(options\.invalidateEphemeris \?\? invalidateEphemerisPage\)\(\)/);
|
||||
|
||||
const calls: Call[] = [];
|
||||
let charts = 0;
|
||||
let ephemerides = 0;
|
||||
const saved = await saveOtherProfile(sample, {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
fetchImpl: fakeFetch(200, { profile: { id: "11111111-1111-4111-8111-111111111111" } }, calls),
|
||||
invalidateChart: () => { charts += 1; },
|
||||
invalidateEphemeris: () => { ephemerides += 1; },
|
||||
});
|
||||
assert.equal(saved.id, "11111111-1111-4111-8111-111111111111");
|
||||
assert.equal(calls[0]?.init.method, "PUT");
|
||||
assert.equal(charts, 1);
|
||||
assert.equal(ephemerides, 1);
|
||||
assert.match(String(calls[0]?.url), /\/api\/chart-profiles\/11111111-1111-4111-8111-111111111111$/);
|
||||
|
||||
const created: Call[] = [];
|
||||
const create = await saveOtherProfile(emptyProfile, {
|
||||
fetchImpl: fakeFetch(200, { profile: { id: "22222222-2222-4222-8222-222222222222" } }, created),
|
||||
invalidateChart: () => { charts += 1; },
|
||||
invalidateEphemeris: () => { ephemerides += 1; },
|
||||
});
|
||||
assert.equal(created[0]?.init.method, "POST");
|
||||
assert.equal(created[0]?.url, "/api/chart-profiles");
|
||||
assert.equal(create.id, "22222222-2222-4222-8222-222222222222");
|
||||
assert.equal(charts, 2);
|
||||
assert.equal(ephemerides, 2);
|
||||
|
||||
await assert.rejects(
|
||||
saveOtherProfile(sample, {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
fetchImpl: fakeFetch(409, { error: "reported_birth_time_is_immutable", code: "reported_birth_time_is_immutable" }, []),
|
||||
invalidateChart: () => { charts += 1; },
|
||||
invalidateEphemeris: () => { ephemerides += 1; },
|
||||
}),
|
||||
/填报的出生时间不能再改/,
|
||||
);
|
||||
assert.equal(charts, 2);
|
||||
assert.equal(ephemerides, 2);
|
||||
|
||||
await assert.rejects(
|
||||
saveOtherProfile(emptyProfile, {
|
||||
fetchImpl: fakeFetch(409, { error: "最多保存 20 个人的星盘", code: "subject_limit_reached" }, []),
|
||||
invalidateChart: () => { charts += 1; },
|
||||
}),
|
||||
/最多保存 20 个人的星盘/,
|
||||
);
|
||||
await assert.rejects(
|
||||
saveOtherProfile(emptyProfile, {
|
||||
fetchImpl: fakeFetch(500, null, []),
|
||||
}),
|
||||
/保存失败,请重试/,
|
||||
);
|
||||
assert.equal(charts, 2);
|
||||
});
|
||||
@@ -17,9 +17,15 @@ test("other chart save fails closed when cloud sync fails", () => {
|
||||
// `localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next))`,
|
||||
// delete copy "已从本地星盘库删除;云端同步失败". Cloud write failure is now failure.
|
||||
assert.doesNotMatch(source, /let cloudSaved = false/);
|
||||
// 原值 / 新值 / 原因:旧 helper 保存 / 人物页 POST/PUT 与 catch / 死面板退役,云失败仍不得伪成功。
|
||||
assert.match(source, /method: editingOther \? "PUT" : "POST"/);
|
||||
assert.match(source, /async function saveOther\([\s\S]{0,1700}catch \{[\s\S]{0,180}保存失败,请重试/);
|
||||
// 原值: /method: editingOther \? "PUT" : "POST"/(写在人物页 saveOther 里)
|
||||
// 新值: /method: id \? "PUT" : "POST"/(saveOtherProfile;有 id 则 PUT,否则 POST)
|
||||
// 原因: 星盘编辑框和档案页共用这一处请求,页面里不再各写一份。
|
||||
const otherSave = readFileSync(new URL("../src/lib/other-profile-save.ts", import.meta.url), "utf8");
|
||||
assert.match(otherSave, /method: id \? "PUT" : "POST"/);
|
||||
// 原值: /async function saveOther\([\s\S]{0,1700}catch \{[\s\S]{0,180}保存失败,请重试/
|
||||
// 新值: /async function saveOther\([\s\S]{0,1700}catch \(caught\) \{[\s\S]{0,280}保存失败,请重试/
|
||||
// 原因: 409「填报的出生时间不能再改」从共用函数抛出,档案页要显示这句,不能一律盖成保存失败。
|
||||
assert.match(source, /async function saveOther\([\s\S]{0,1700}catch \(caught\) \{[\s\S]{0,280}保存失败,请重试/);
|
||||
assert.doesNotMatch(source, /localStorage\.setItem\(chartLibraryStorageKey/);
|
||||
assert.doesNotMatch(source, /已保存到本地星盘库/);
|
||||
assert.match(source, /invalidateSubjectCatalog\(\);\s*await load\(\)/);
|
||||
|
||||
@@ -101,6 +101,15 @@ test("all chart types omit the engine display without removing calculation param
|
||||
assert.doesNotMatch(renderToStaticMarkup(<ChartWesternTab view={view} />), /Swiss Ephemeris|>引擎</);
|
||||
});
|
||||
|
||||
test("the birth line is the date and place, and the name is not repeated there", async () => {
|
||||
const view = await okView();
|
||||
const markup = withSidebarProvider(React.createElement(ChartPageView, { view }));
|
||||
assert.match(markup, /chart-birth-line/);
|
||||
assert.match(markup, /1990-06-15 12:00 · 北京/);
|
||||
assert.match(markup, /编辑出生资料,1990-06-15 12:00,北京/);
|
||||
assert.doesNotMatch(markup, /示例 · 1990-06-15/);
|
||||
});
|
||||
|
||||
test("the four tabs render and can each be selected", async () => {
|
||||
const view = await okView();
|
||||
for (const tab of CHART_VIEW_TABS) {
|
||||
|
||||
@@ -1037,7 +1037,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
f"| {_cell(_token(planet))} | {_cell(row.get('r_c') or row.get('retrograde') and 'R' or 'C')} | "
|
||||
f"{_cell(_token(row.get('sign')))} | {_cell(_degree(sign_degree))} | {_cell(_degree(longitude))} | "
|
||||
f"{_cell(row.get('house'))} | {_cell(row.get('nakshatra'))} | {_cell(row.get('pada') or row.get('nakshatra_pada'))} | "
|
||||
f"{_cell(_num(row.get('speed'), 6))} |"
|
||||
f"{_cell(_num(row.get('speed'), 4))} |"
|
||||
)
|
||||
lines.append('')
|
||||
lines.extend([
|
||||
|
||||
@@ -253,6 +253,11 @@ def test_birth_position_table_starts_with_the_ascendant(editions) -> None:
|
||||
assert en_rows[1][0] in {"Ascendant", "Lagna", "上升"}
|
||||
assert fields["nakshatra"] in en_rows[1][6]
|
||||
assert en_rows[1][7] == str(fields["nakshatra_pada"])
|
||||
for row in (*zh_rows[2:], *en_rows[2:]):
|
||||
speed = row[8]
|
||||
if speed == "-":
|
||||
continue
|
||||
assert re.fullmatch(r"-?\d+\.\d{4}", speed), speed
|
||||
|
||||
|
||||
def test_zh_hygiene_rejects_glued_english(editions) -> None:
|
||||
|
||||
Reference in New Issue
Block a user