From 885bd5665f660cec605f17c15f3c0a4a1f85b510 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Wed, 21 Oct 2026 20:38:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(chart):=20=E6=98=9F=E7=9B=98=E9=A1=B5?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E6=94=B9=E5=87=BA=E7=94=9F=E8=B5=84=E6=96=99?= =?UTF-8?q?=EF=BC=8C=E4=BA=BA=E5=90=8D=E7=9C=8B=E5=BE=97=E5=87=BA=E8=83=BD?= =?UTF-8?q?=E7=82=B9=EF=BC=8C=E6=8A=A5=E5=91=8A=E8=A1=8C=E6=98=9F=E8=A1=A8?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8C=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 报告行星表按内容定列宽,速度显示到 4 位小数,星宿英文名不再从中间拆开。星盘页出生行就地编辑;别人已保存的出生时间仍锁住。 Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor --- CHANGELOG.md | 7 + docs/BUG_HISTORY.md | 16 ++ .../PROGRESS-chart-subject-table-20261021.md | 31 +++ docs/tasks/README.md | 1 + frontend/DESIGN.md | 5 +- frontend/docs/VOICE.md | 6 + frontend/src/app/globals.css | 60 ++++- frontend/src/components/birth-time-intake.tsx | 23 +- .../chart-page/chart-birth-editor.tsx | 222 ++++++++++++++++++ .../components/chart-page/chart-page-view.tsx | 37 ++- .../src/components/chart-profile-form.tsx | 15 +- .../personal-report-markdown-view.tsx | 22 +- frontend/src/components/profile-fields.tsx | 18 +- frontend/src/components/subject-switcher.tsx | 10 +- frontend/src/hooks/use-chart-page.ts | 17 +- frontend/src/lib/report-table-reading.ts | 74 ++++++ frontend/src/lib/secondary-page-data.ts | 14 ++ frontend/tests/chart-page-view.test.tsx | 9 + .../issue-86-chart-subject-table.test.tsx | 132 +++++++++++ scripts/pl9_reader_export.py | 2 +- tests/test_report_chart_blank_columns.py | 5 + 21 files changed, 696 insertions(+), 30 deletions(-) create mode 100644 docs/tasks/PROGRESS-chart-subject-table-20261021.md create mode 100644 frontend/src/components/chart-page/chart-birth-editor.tsx create mode 100644 frontend/src/lib/report-table-reading.ts create mode 100644 frontend/tests/issue-86-chart-subject-table.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f3643395..020d121a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-10-21 — 星盘页能改出生资料,人名看得出能点,报告行星表不再从中间断开 + +- 星盘、星历、报告顶栏的人名是带箭头的圆角按钮。名单没满 20 人时,底部可以「+ 添加一个人」。 +- 星盘页标题旁只留日期、时间和地点,点这一行就能改。本人都能改;别人已经保存的出生时间仍然不能改。改了日期、时间或地点会提示:之前校正采用的时间作废,星盘按新填的时间重排。 +- 报告「行星原始位置」表按内容定列宽。星宿的英文名用浅色小字,跟在中文后面同一行。速度写到小数点后 4 位。放不下时表在自己的框里横向滑动。 +- Skill 版本不变(BUG-1404)。 + ## 2026-10-21 — 合盘时间线写出起止,删掉的模型改用默认模型 - 关系时间线每段标题写出起止年月。第一段从本月开始时,起点写「现在」或 Now。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f8989e4b..82e52cb6 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -37016,3 +37016,19 @@ - 复发自:无。 - 修复版本:分支 `codex/synastry-staging-fix-20261021`。 +## BUG-1404 | 报告行星表把星宿和速度从中间拆开 + +- 状态:resolved(待 staging 真机复测) +- 首次发现:2026-10-21(产品负责人,issue #86) +- 最近更新:2026-10-21 +- 影响面:报告阅读页 Markdown 表格,尤其是「Birth Chart 行星原始位置」 +- 用户现象:九列被平均分宽。星宿「阿湿毗尼(Ashwini)」拆成三行,速度 `11.852534` 从数字中间断开。 +- 触发条件:打开一份含行星原始位置表的报告。阅读页给 `.personal-report-table-wrap table` 用了 `table-layout: fixed`,单元格 `overflow-wrap: anywhere`。打印样式再用同一套规则盖回去。 +- 根因:固定列宽加上任意断行。速度在阅读版导出里写成 6 位小数,更长,更容易被拆开。 +- 修复:表格按内容定列宽,单词和数字不从中间断开,放不下时只在表格容器里横向滑动。星宿英文名用浅色小字,和中文同一行。速度显示并写出 4 位小数。打印不再改回固定列宽和任意断行。 +- 验证:`frontend/tests/issue-86-chart-subject-table.test.tsx` 锁定列宽、断行、英文名和 4 位小数。`tests/test_report_chart_blank_columns.py` 的行星行速度匹配 4 位小数。 +- 防复发:上面两条。不要把报告 Markdown 表改回 `table-layout: fixed` 或 `overflow-wrap: anywhere`。 +- 相关记录:issue #86。 +- 复发自:无。 +- 修复版本:分支 `codex/chart-subject-table-20261021`。 + diff --git a/docs/tasks/PROGRESS-chart-subject-table-20261021.md b/docs/tasks/PROGRESS-chart-subject-table-20261021.md new file mode 100644 index 00000000..2c63fcf9 --- /dev/null +++ b/docs/tasks/PROGRESS-chart-subject-table-20261021.md @@ -0,0 +1,31 @@ +# 进度:星盘页人名、就地改资料、报告行星表(issue #86) + +规格是 Gitea issue #86,没有另写任务书。分支 `codex/chart-subject-table-20261021`,基线 `origin/staging` `bd3ce0e7b`。 + +## 改了什么 + +- 报告阅读页的 Markdown 表按内容定列宽。单词和数字不从中间断开。放不下时只在表格自己的框里横向滑动,页面不被撑开。星宿英文名用浅色小字,跟在中文后面同一行。速度显示并写出小数点后 4 位。打印不再改回固定列宽和任意断行。记入 BUG-1404。 +- 星盘、星历、报告和对话顶栏的人名是带箭头的圆角按钮。名单没满 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` | 本机执行约定交给验收再跑 | 未跑 | + +## 浏览器 + +本机没有对着已登录的星盘页点过这三处。验收时请看:人名按钮、弹出层里的添加入口、星盘页出生行和编辑框、一份带行星表的报告在窄屏下横向滑动。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 4b71823e..ffa6deba 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -251,6 +251,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | +| issue #86(无独立任务书) | [PROGRESS](PROGRESS-chart-subject-table-20261021.md) | 星盘页人名看得出能点、出生资料就地改、报告行星表不再从中间断开(BUG-1404) | 待验收 | `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 一致) | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index d6af4424..47abaa24 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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, with `填报的出生时间保存后不能再改` beside that field. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the archive page's write (self through the account, anyone else through their chart profile) and then redraws the chart. 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. diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index be7f7c22..0dac6aa9 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -239,6 +239,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 详情两格时间卡写「填报时间」「排盘时间」;有校正结果时排盘卡写「排盘时间 · 已校正」。岁差写设置里的名字,例如「Raman(默认)」,不写小写键。 - 快捷入口只写「和 TA 对话 / 看星盘 / 生成报告」;他人另有「和我合盘」「编辑」。本人底部灰字「本人不能删除」(不加句号)。手机详情顶部是「‹ 返回」。 +## 星盘页人名与就地改资料(2026-10-21) + +- 星盘、星历、报告顶栏的人名是一颗圆角按钮,带下拉箭头。点开后,名单没到 20 人时,底部一行是「+ 添加一个人」,去星盘档案。满 20 人不出现这一行。 +- 星盘页人名旁边只写「日期 时间 · 地点」,末尾一支小笔。整行可点,就在这一页改,不跳走。本人每项都能改。别人已经填过的出生时间不能改,旁边写「填报的出生时间保存后不能再改」。改了日期、时间或地点时写:「改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。」手机上这一行换到第二行。 +- 报告里的星宿,中文名后面的英文名用浅色小字,和中文同一行。速度写到小数点后 4 位。表太宽时在表自己的框里横滑,页面不被撑开。 + ## 报告分块导出与删除 - 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」、「中文 / English」切换(只在有英文版的报告上出现)与带字的「导出」按钮(读屏名「导出报告」,2026-09-29 起不再是只有图标的按钮)。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 7b1e3cce..357e97b8 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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,23 @@ 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; } +/* Column width follows the words. A nine-column planet row used to split + 「阿湿毗尼(Ashwini)」 and `11.8525` because every column was the same + width and cells broke anywhere. The wrap scrolls; the page does not. */ +.personal-report-table-wrap table { + width: max-content; + min-width: 100%; + border-collapse: collapse; + table-layout: auto; + text-align: left; +} .personal-report-key-evidence th, .personal-report-key-evidence td, .personal-report-table-wrap th, @@ -5140,7 +5167,20 @@ 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; - overflow-wrap: anywhere; +} +.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: normal; + word-break: keep-all; + white-space: nowrap; +} +.report-nakshatra-en { + color: var(--color-ink-tertiary); + font-size: 0.85em; + font-weight: 400; + white-space: nowrap; } .personal-report-key-evidence thead th, .personal-report-table-wrap thead th { @@ -5188,7 +5228,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 +5464,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: max-content !important; min-width: 100% !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: normal !important; word-break: keep-all !important; white-space: nowrap !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; } diff --git a/frontend/src/components/birth-time-intake.tsx b/frontend/src/components/birth-time-intake.tsx index 0b12ffe0..63ffd73a 100644 --- a/frontend/src/components/birth-time-intake.tsx +++ b/frontend/src/components/birth-time-intake.tsx @@ -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 ?

填报的出生时间保存后不能再改

: null} +
{source === "legacy_import" && (

@@ -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) onPatch({ reportedTime })} + onChange={(reportedTime) => emitTime({ reportedTime })} /> {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 })} />

); } diff --git a/frontend/src/components/chart-page/chart-birth-editor.tsx b/frontend/src/components/chart-page/chart-birth-editor.tsx new file mode 100644 index 00000000..dfb04c34 --- /dev/null +++ b/frontend/src/components/chart-page/chart-birth-editor.tsx @@ -0,0 +1,222 @@ +"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 { invalidateChartPage, invalidateEphemerisPage, pinChartSnapshotIdentity } from "@/lib/secondary-page-data"; +import { saveSelfGender, saveSelfProfile } from "@/lib/self-profile-save"; + +export const CHART_BIRTH_EDIT_NOTICE = "改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。"; + +export function reportedTimeStored(profile: Profile): boolean { + return profile.reportedTime.trim() !== "" + || profile.declaredWindowStart.trim() !== "" + || (profile.birthTimeSource === "legacy_import" && profile.time.trim() !== ""); +} + +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) => void; +}) { + const self = role === "self"; + return ( + 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; +}) { + const dialogRef = useRef(null); + const { account, setAccount } = useSessionList(); + const [library, setLibrary] = useState(null); + const [failed, setFailed] = useState(false); + const [draft, setDraft] = useState(null); + const [baseline, setBaseline] = useState(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) { + 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 { + const response = await fetch(`/api/chart-profiles/${record.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ role: "other", profile: draft }), + }); + const payload = await response.json().catch(() => null) as { error?: string } | null; + if (!response.ok) { + notifyError(payload?.error || "保存失败,请重试"); + return; + } + invalidateChartPage(); + invalidateEphemerisPage(); + } + invalidateSubjectCatalog(); + await onSaved?.(); + onClose(); + } catch (caught) { + notifyError(caught instanceof Error && caught.message ? caught.message : "保存失败,请重试"); + } finally { + setSaving(false); + } + } + + return ( + { + event.preventDefault(); + onClose(); + }} + onClick={(event) => { + if (event.target === event.currentTarget) onClose(); + }} + > + {failed ? ( +

+ 人物列表暂时读不出来 + +

+ ) : library === null ? ( + + ) : !record || !draft || !baseline ? ( +

这份资料暂时读不出来

+ ) : ( + void save(event)} + /> + )} +
+ ); +} diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index f7717f6a..e399da3f 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -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; @@ -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; }) { const [tab, setTab] = useState(initialTab); const [vargaId, setVargaId] = useState("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}` + ? ( + + ) : 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 ( : undefined} > + {editingBirth && view?.status === "ok" ? ( + setEditingBirth(false)} + onSaved={afterBirthSaved} + /> + ) : null} {view != null && view.status !== "ok" ? (
{view.status === "birth_profile_incomplete" ?

{CHART_VIEW_COPY.profileEntryTitle}

: null} diff --git a/frontend/src/components/chart-profile-form.tsx b/frontend/src/components/chart-profile-form.tsx index c920826f..78606ec7 100644 --- a/frontend/src/components/chart-profile-form.tsx +++ b/frontend/src/components/chart-profile-form.tsx @@ -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 (
{title}{description}
- + {showGender ? ( onChange((current) => ({ ...current, ayanamsa }))} /> ) : null} + {notice ?

{notice}

: null}
{onCancel ? : null} diff --git a/frontend/src/components/personal-report/personal-report-markdown-view.tsx b/frontend/src/components/personal-report/personal-report-markdown-view.tsx index 68f0abc9..94bffdce 100644 --- a/frontend/src/components/personal-report/personal-report-markdown-view.tsx +++ b/frontend/src/components/personal-report/personal-report-markdown-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; +import { Fragment, 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, splitNakshatraLabel } from "@/lib/report-table-reading"; function flattenText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") return String(node); @@ -38,6 +39,22 @@ function reportUrlTransform(url: string): string { return url.startsWith("#") ? url : ""; } +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 + ? {piece.text} + : {piece.text} + )); + } + if (Array.isArray(node)) { + return node.map((child, index) => {renderNakshatraLabel(child)}); + } + return node; +} + function markdownComponents( headings: readonly LongformHeading[], language: ReportLanguage, @@ -67,6 +84,7 @@ function markdownComponents( {children}
), + td: ({ children }) => {renderNakshatraLabel(children)}, h1: ({ children }) => { const title = flattenText(children).trim(); const line = byline.shown ? null : reportTitleByline({ title, subjectName, createdAt, language }); @@ -137,7 +155,7 @@ function renderMarkdown( unwrapDisallowed urlTransform={reportUrlTransform} > - {markdown} + {formatReportSpeedColumns(markdown)} ); } diff --git a/frontend/src/components/profile-fields.tsx b/frontend/src/components/profile-fields.tsx index 640d025a..9bfe37e9 100644 --- a/frontend/src/components/profile-fields.tsx +++ b/frontend/src/components/profile-fields.tsx @@ -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 })} /> - onChange(applyBirthTimeDraftPatch(value, patch))} /> + onChange(applyBirthTimeDraftPatch(value, patch))} + /> onChange(invalidateCandidateAfterLocationChange(value, next))} diff --git a/frontend/src/components/subject-switcher.tsx b/frontend/src/components/subject-switcher.tsx index cb7c11dc..39585ba7 100644 --- a/frontend/src/components/subject-switcher.tsx +++ b/frontend/src/components/subject-switcher.tsx @@ -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(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}`} > {label} +