diff --git a/BLOCKED.md b/BLOCKED.md index f3f34152..fd49031e 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,12 @@ # BLOCKED +## TASK-people-ephemeris-ui:登录态真机与 Node 22 全量待验(2026-09-26) + +- 本机只有 Node v20.19.2:`mock.module` 类测试以 ERR_MODULE_NOT_FOUND 失败(基线即 61 条)。本轮全量 3915 条,失败 61 条,与基线测试名逐条一致、新增失败 0、无消失测试名;Linux Node 22 全量待协调方复跑。 +- 无受控登录账号:`/people`、`/ephemeris` 的真实数据只用本地 `next start` + 临时宿主页(虚构人物、真实引擎 golden 五要素,未提交)在 Chrome 151 里看过;登录态真机、iPhone Safari、浏览器返回手势、读屏按 `docs/testing/people-ephemeris-ui-20260926.md` 待验。 +- 任务书对照表与引擎拼写有三处不一致(`Aindra` / `Variyana` / `Dhanishtha`,表里是 Indra / Variyan / Dhanishta),按任务书只允许三组别名,这三个名字现在照写梵文,待产品裁决是否补别名。 +- 未部署;BUG-1039 保持 investigating。 + ## 三份修复单 final-r4b:自动化缺口解除与剩余边界(2026-09-25) - ~~最终组合 Linux Node22 全量、真实 DB、名称对账、Static/gzip 尚未完成。~~ **已解除这一部分**:final-r4b 前端3894/3894、PG17标准DB64/64;五个exit均0,旧名称无消失、新增失败0;lint0error/127warning,首页Static;三种gzip均在±2%内。下方各单“待最终全量”是历史快照,以整合PROGRESS补记为准。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 62965f69..b1abb233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-09-26 — 星盘档案页补上样式,星历页先写中文(待验收) + +- 星盘档案页:手机上先列表、点人进详情(「‹ 返回」和浏览器返回都回列表);桌面左列表右详情。列表行有首字头像、「本人」标签和一行「出生日期 · 排盘时间 · 城市」,当前人物高亮;详情有填报 / 排盘两格时间卡(校正过的标「已校正」)、中文岁差名和三个快捷入口。删除他人挪到编辑页底部,本人仍不能删除。 +- 星历页:月相日、月宿、瑜伽、迦那先写中文,梵文放小字(对照表以任务书为唯一来源,表里没有的名字照写梵文);吉 / 凶 / 中改成彩色小标签,卡片顶部按固定规则写一句当日总评和「N 吉 · M 凶」,不调模型。 +- 「带这天去提问」从标题栏挪到页面底部的浅色提示卡;行运每行是彩色行星符号、「行星 在 星座」、「落你的第 N 宫」和右侧度数;岁差说明按资料实际岁差写名字。 +- Skill 版本不 bump。不改接口、引擎和数据库;登录态真机、部署待验收。 + ## 2026-09-25 — 西洋盘标签不再读错星座,删对话不再跳到别人(待验收) - 西洋盘行星标签最多离开真实位置 30°,放不下时用更靠内的第三圈,不再被引到另一个星座扇区。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a656b008..09e4ca09 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13884,3 +13884,17 @@ - 防复发:BUG 记录写明这是死代码删除,不是年运算法变更。 - 相关记录:BUG-1028。 - 修复版本:`codex/round-0925-followup-20260925`,未部署。 + +## BUG-1039 | 星盘档案页 `/people` 没有任何样式 + +- 状态:investigating(已修复待部署;未在登录态真机复测) +- 首次发现 / 最近更新:2026-09-26 / 2026-09-26 +- 影响面:`/people` 星盘档案页(列表、详情、添加/编辑、删除确认)。同轮顺带改星历页可读性(非 Bug,见任务书 D5–D10)。 +- 用户现象:iPhone Safari 上名字、「本人」、另一人的名字和时间挤在一行,「添加一个人」悬在中间,详情字段是裸文字。 +- 触发条件:打开 `/people`,任何账户、任何宽度。 +- 根因:`frontend/src/components/people/people-page.tsx` 用了 `people-archive`、`people-archive-list`、`people-archive-detail`、`people-archive-row` 等类,`frontend/src/app/globals.css` 里没有一条对应规则。实现 BUG-1030 那一轮没写样式;验收只有源码断言和组件探针,没有登录态,这一页从未在真实浏览器里看过。 +- 修复:按原型补齐 `/people` 样式与布局:小于 1024px 列表 → 详情(同路由切视图,顶部「‹ 返回」,并压一条 `?person=` 历史记录让浏览器返回回到列表);1024px 及以上左列表右详情。列表行是首字头像 + 名字(本人加「本人」标签)+ 等宽一行「出生日期 · 排盘时间 · 城市」+「›」,当前人物用强调色边与浅底;底部虚线「+ 添加一个人」与「还可以保存 N 个人」。详情是头部 + 两格时间卡(有校正结果时排盘卡走成功色并标「已校正」)+ 出生日期与中文岁差名 + 三个快捷入口;本人底部灰字「本人不能删除」,他人的删除挪进编辑视图底部,删除确认与计数逻辑不变。头像颜色按 id 取元素色 token。 +- 验证:`tests/people-archive-view.test.tsx`(行结构、当前人物高亮、时间卡与已校正、本人无删除、删除只在编辑视图、组件里每个 `people-archive*` 类在 `globals.css` 都有规则、触控 44px、1023/1024 切换);本地 `next start` + 临时宿主页(虚构数据,未提交)在 Chrome 151 375 / 390 / 1280、亮 / 暗下截图,并用 CDP 验证列表 → 详情 → 浏览器返回 / 「‹ 返回」、无横向滚动、可见按钮均不低于 44px。 +- 防复发:新增「组件用到的每个 `people-archive*` 类都必须在 `globals.css` 有规则」的合同测试,这类「写了类没写样式」的回归会在测试里失败。验收流程缺口:无登录态页面只做源码断言等于没看过页面;本轮起此类页面至少用本地宿主页在真实浏览器里截一次图,写进 PROGRESS。 +- 相关记录:BUG-1030(本页由其引入)。 +- 修复版本:`codex/people-ephemeris-ui-20260926`,未部署。 diff --git a/docs/tasks/PROGRESS-people-ephemeris-ui-20260926.md b/docs/tasks/PROGRESS-people-ephemeris-ui-20260926.md new file mode 100644 index 00000000..1eb4a82b --- /dev/null +++ b/docs/tasks/PROGRESS-people-ephemeris-ui-20260926.md @@ -0,0 +1,73 @@ +# PROGRESS · 星盘档案页补样式 + 星历页可读性(2026-09-26) + +任务书:`docs/tasks/TASK-people-ephemeris-ui-20260926.md`。分支 `codex/people-ephemeris-ui-20260926`,worktree `.worktrees/people-ephemeris-ui-20260926`。执行方:Claude 子代理(产品授权直接执行)。未推送、未部署。 + +## 基线 + +- `origin/staging` = `65260ca7`(线上 `e84d6eb8` 之后只有文档提交)。 +- 基线全量(同一台机器、Node v20.19.2):3883 条,61 失败(全是 Node 20 缺 `mock.module` 等环境缺口),27 跳过。 +- BUG 编号:开工时 `docs/BUG_HISTORY.md` 最大 BUG-1037,BUG-1038 被并行单预留,本单 **BUG-1039**。 +- 并行单 `TASK-new-chat-from-people-20260926` 的文件(`people-home-links.tsx`、`chat-session-url.ts`、`home-bootstrap-run.ts`、`app/(app)/page.tsx`)一律未动。`people-page.tsx` 里「和 TA 对话」仍是 `openPerson(selected.id, "/?newChat=1")` 一行,跳转目标由那一单改,合并时只会在这一行冲突。 + +## 改了什么 + +| 任务 | 文件 | 内容 | +| --- | --- | --- | +| T1 | `frontend/src/components/people/people-page.tsx` | 拆出无状态 `PeopleArchiveView`(同文件导出,便于组件测试);`data-view` 列表 / 详情切换;手机点人 `pushState(?person=)`,`popstate` 与「‹ 返回」回列表,刷新详情地址直接打开;列表行、详情卡、时间卡、快捷入口;删除挪进编辑视图底部,确认 / 计数 / 失败句逻辑不变。 | +| T1 | `frontend/src/lib/people-archive-view.ts`(新) | 头像取色(按 id 取四个元素色)、首字、城市、行 meta、「已校正」判定、岁差中文名。 | +| T1 | `frontend/src/app/globals.css` | `people-archive*` 共 24 个类的规则;`@media (max-width: 1023px)` 只显示一栏,`@media (min-width: 1024px)` 两栏。 | +| T2 | `frontend/src/lib/panchanga-names-zh.ts`(新) | 任务书附表原样 + 三组别名;`panchangaNameZh` 查不到返回 null。 | +| T2/T3 | `frontend/src/lib/ephemeris-view.ts` | `panchangaDisplayRows`(中文主名 / 梵文副名,查不到记一次 `panchanga_name_zh_missing`)、`panchangaQualityCounts`、`panchangaComplete`、`panchangaDaySummary`(D7 纯函数)、`ephemerisAyanamsaNote`(D10)、`transitHouseLine`(D9);文案常量更新。 | +| T3/T4 | `frontend/src/components/ephemeris/ephemeris-page.tsx` | 顶栏不再有按钮;五要素总评 + 计数 + 彩色标签;行运行改版(符号、「在」、宫位副行、度°分′);底部提示卡。 | +| T3/T4 | `frontend/src/app/globals.css` | `.ephemeris-quality`(12% 同色底)、`.ephemeris-summary`、`.ephemeris-counts`、行运新行式、`.ephemeris-ask-card`;删掉行运表头和 `data-label` 堆叠;标签去字距、行距收紧;行星符号颜色复用 `.chart-page-planet-glyph` 的 `--color-planet-*` 规则。 | +| T5 | `docs/BUG_HISTORY.md`、`CHANGELOG.md`、`BLOCKED.md`、`frontend/DESIGN.md`(§15 新增「星盘档案页布局」、§16 重写)、`frontend/docs/VOICE.md`(总评句式、提示卡、档案页文案)、`docs/testing/people-ephemeris-ui-20260926.md` | 记录。 | + +## 实现取舍(验收请看) + +1. **中文名在视图层投影,不改接口。** 任务书 T2 写「`panchangaFromEngine` 输出中文主名与梵文副名」,同时红线 3 写「不改接口」。实际函数名是 `panchangaFactorsFromEngine`,它的输出就是 `/api/ephemeris` 的 `factors` 字段。为守红线,接口字段一字未改(`value` 仍是引擎名),中文主名 / 梵文副名由 `panchangaDisplayRows` 在客户端从 `panchanga-names-zh.ts` 查。「未知名记日志」因此在浏览器控制台,每个名字只记一次。 +2. **星期不出副行。** 接口里星期本来就是中文(服务端 `VARA_ZH`),引擎原名是英文 Sunday,不是梵文;附表也没有星期。原型里的「土星日」不在附表里,没加。 +3. **对照表与引擎拼写三处不一致,未擅自加别名(待裁决)。** 引擎 `scripts/muhurta.py` 输出 `Aindra`、`Variyana`、`Dhanishtha`,附表写的是 Indra、Variyan、Dhanishta。任务书只允许三组别名,所以这三个名字现在照写梵文(测试 `panchanga-names-zh.test.ts` 锁住了这一现状并注明待裁决)。真实 golden 2026-09-14 的瑜伽就是 `Aindra`。产品若同意,只需在 `PANCHANGA_NAME_ALIASES` 加三行别名、改那条测试。 +4. **D10 的两个边界句。** 资料岁差是 Lahiri 时,「和星盘页的 Lahiri 不同」是错的,改写「五要素按 Lahiri 岁差算,和星盘页相同。」;读不到资料岁差时只写「五要素按 Lahiri 岁差算。」已写进 VOICE。岁差名取接口已有的 `transits.ayanamsa`(即资料设置),名字与设置里一致,不带「(默认)」。 +5. **没有本命上升时的句子改了。** 原句「还没有本命上升,相对本命这一列空着。」里的「列」随表格一起没了,改为「还没有本命上升,先不写落在你的第几宫。」行运逆行保留为副行「· 逆行」(D9 没提,但删掉会丢事实)。 +6. **「已校正」判定** = 状态 accepted / confirmed **且**有校正案例 id。只看状态会把旧资料读回时 `readProfile` 的 `confirmed` 兜底(有时间就算 confirmed)误标成已校正。 +7. **当前人物 vs 正在看的详情。** 「当前正在看的人高亮」按顶栏当前人物(`useCurrentSubject`)上强调色;桌面右栏打开的那一行只用中性浅底 `is-open`,免得两行都是强调色。 +8. **桌面五要素仍是五格一行**(DESIGN §16 原有),标签在上、名称与标签在下;767px 以下才是原型那种「左标签 · 中名称 · 右标签」行。原型里日期条下的「星期六 · 今天」副标题、迦那副行「· 通常避开」都不在决策里,没做。 +9. 档案页「还可以保存 N 个人」「本人不能删除」按原型去掉了句末句号(VOICE 已写)。 + +## 验证 + +| 项 | 结果 | +| --- | --- | +| `./node_modules/.bin/tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error / 127 warning(与既有一致,新文件无 warning) | +| 全量 `npm test`(Node 20.19.2) | 3915 条,3827 通过,61 失败,27 跳过。基线 3883 / 61 失败。失败测试名与基线逐条比对:新增 0、消失 0;测试名无消失,新增 32 条(`panchanga-names-zh` 7、`ephemeris-readable-panchanga` 13、`people-archive-view` 12) | +| 定向 | `ephemeris-page`、`ephemeris-readable-panchanga`、`panchanga-names-zh`、`people-archive-view`、`people-archive-regressions`、`chart-library-other-profile`、`adopted-birth-date`、`settings-mvp-contract`、`secondary-page-entry`、`viewport-breakpoint-contract`、`touch-target-contract`、`dark-theme-contract`、`design-token-contract` 全绿(`ephemeris-route` 的 6 条失败是基线同名 Node 20 缺口) | +| Python 源码合同 | `tests/test_birth_time_journey_contract.py`、`test_session_management_entrypoints.py`、`test_supabase_user_data_contract.py`、`test_daily_and_rectification_entrypoints.py`:25 passed(系统 python3 + pytest 9.1.1;本 worktree 无 `.venv`) | +| `npm run build -- --webpack` | 成功;`○ /`、`○ /people`、`○ /ephemeris`、`○ /chart` 仍 Static(`/reports` 为 ƒ,本单未碰) | +| 首屏 gzip(`build-manifest.json` rootMainFiles 4 个文件,zlib 默认级别) | 131253 字节,基线 130933,+0.24%(±2% 内;本单只改组件与 CSS,不进 rootMainFiles,差值应是口径 / 构建噪声) | + +改动的既有断言(三栏也写在测试文件里): + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `tests/ephemeris-page.test.tsx`「the Lahiri note sits under panchanga…」 | `EPHEMERIS_COPY.panchangaLahiriNote`(固定句「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」) | `ephemerisAyanamsaNote("raman")`(「五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。」) | D10 改句式并要求按资料实际岁差填名;位置断言不变 | +| `tests/ephemeris-page.test.tsx`「copy stays factual…」 | 整页 markup 不得含 `/运势\|适合\|不宜\|建议\|吉祥日/` | 去掉底部提示卡那一句问句后再断同一组词,另断问句只出现一次 | D8 定稿问句「想问这一天适不适合做某件事?」是邀请提问,不是宜忌结论;其余范围不放宽 | + +## 浏览器截图(真实 Chrome 151,非登录态) + +方式:本地 `next build --webpack` + `next start`,加一个**未提交**的临时宿主页 `/ui-harness-1039`: +- `?screen=people` 挂真实 `PeoplePage`,只把 `/api/account`、`/api/chart-profiles`、`/api/sessions` 三个读请求换成虚构数据(本人「示例甲」已校正、「示例乙」Lisbon/Lahiri、「示例丙」成都),列表 → 详情 → 返回走的是组件真实的 `pushState` / `popstate`。 +- `?screen=ephemeris` 挂真实 `EphemerisView`:行运用真实引擎 golden(2026-09-15),五要素用虚构 Vishti/Purnima 组合以显示完整总评;另一张用真实 golden 五要素。 +- CDP 驱动(`node --experimental-websocket`),设备像素比 2,`prefers-color-scheme` 亮 / 暗。每个宽度同时检查:页面与 `.secondary-page` 无横向滚动、可见按钮高度均 ≥ 44px、星历顶栏没有 `.ephemeris-ask`;手机宽度检查点人后 URL 带 `?person=`、`history.back()` 与「‹ 返回」都回到 `data-view="list"`。全部通过(`ui-1039/checks.json`)。 + +截图目录 `/tmp/claude-1000/-workspace-Jyotisha/725df462-ac7f-4544-90b7-60fba1501ed7/scratchpad/ui-1039/`(不在仓库里),宽度 × 主题各一组: + +- 档案页:`people-{375,390,1280}-{light,dark}-list.png`(列表、当前人物高亮、虚线添加卡)、`people-{375,390}-{light,dark}-detail-self.png`(本人详情:已校正绿卡、Raman(默认)、三入口、「本人不能删除」)、`people-{375,390,1280}-{light,dark}-detail-other.png`(他人详情;1280 为两栏)、`people-{375,390,1280}-{light,dark}-edit-other.png`(编辑视图底部「删除这个人」)。 +- 星历:`ephemeris-{375,390,1280}-{light,dark}-top.png`(总评、计数、彩色标签、中文主名梵文副名、岁差句、行运行)、`ephemeris-{375,390,1280}-{light,dark}-bottom.png`(九十天与底部提示卡)、`ephemeris-375-light-golden-top.png`(真实 golden 五要素:白分初五 / 亢宿 / 分离 / 巴瓦)。 + +## 环境缺口(未写成通过) + +- Node 22 全量未跑(本机只有 Node 20.19.2),61 条基线失败为同一原因;需协调方在 Linux Node 22 复跑并比对名单。 +- 无 Docker:本单不动表,未跑 `test:db`。 +- 无受控登录账号:真实数据、iPhone Safari、左滑返回手势、读屏未验,见 `docs/testing/people-ephemeris-ui-20260926.md`。 +- 未部署;BUG-1039 保持 `investigating`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index dfdb66fe..8e05eca2 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -209,7 +209,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-people-ephemeris-ui-20260926.md` | — | **星盘档案页补样式 + 星历可读性改版**(原型 https://claude.ai/artifact/FmPy9xZ7dQw9u7yrW4WzTw ,产品 09-26 确认):/people 样式 0 条(BUG-1039)→ 手机列表→详情、桌面两栏;星历五要素先中文后梵文(附唯一对照表)、吉凶彩色标签、固定规则当日总评(不调模型)、标题栏大按钮移到底部提示卡。必须真实浏览器截图验收。与 BUG-1038 单并行、文件不交叉 | 待领取 | — | +| `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 实现;全量失败名单与基线一致、Static 保持、Chrome 375/390/1280 亮暗截图;对照表三处引擎拼写待裁决) | 分支 `codex/people-ephemeris-ui-20260926`,未推送 | | `TASK-round-0925-followup-20260925.md` | `PROGRESS-round-0925-followup-20260925.md` | **0925 收尾单**:西洋盘标签偏移 ≤30° + 第三圈;删除后只在当前人物里回退;人物接口故障降级为本人;P3 删旧面板、点击按最近行星、删除年运死调用 | 已验收(Claude 09-26:偏移 ≤30° 达成;删后回退按人、人物接口降级、账户单次请求、面板与死代码删除均通过;残留 0.73% 盘标签重叠为 P3) | `d08ffdc0` + 合同修复 `e84d6eb8`(d08ffdc0 的门禁 run 2901 红于一条引用已删面板文案的 Python 合同,Claude 经产品授权直接修复);已部署 run 2906,health 一致 | | `TASK-chart-western-fix-20260925.md` | `PROGRESS-chart-western-fix-20260925.md` | **西洋盘 ASC/MC 重叠**:固定锚点避让、真实引擎64盘与固定种子512盘扫描、343px盘宽命中≥44px;raw golden;文档陈旧行更正 | 已验收(重叠 0%);新 P1 标签偏移最大 115° → 收尾单 D1(产品定 ≤30° + 第三圈) | `4d801e53`(已部署,health 核对一致) | | `TASK-chart-dasha-western-redesign-20260924.md` | `PROGRESS-chart-dasha-western-redesign-20260924.md` | **大运 / 西洋盘改版**(原型 https://claude.ai/artifact/DYovEPMqD1xwNgna5xVTre ,产品 09-24 全部同意,相位线常显):大运「现在」卡 + 时间条 + 默认折叠(原 81/144 行平铺);西洋盘 520 三圈、行星避让 + 引线、宫号居宫中、ASC/MC 盘内、行星位置表、点选过滤相位。排在 chart-ephemeris-fixes T1 之后 | 已验收,1 P1(ASC/MC 标签不参与避让,约 34% 盘重叠)→ chart-western-fix | `5a2d9da9`(已部署) | diff --git a/docs/testing/people-ephemeris-ui-20260926.md b/docs/testing/people-ephemeris-ui-20260926.md new file mode 100644 index 00000000..1803e391 --- /dev/null +++ b/docs/testing/people-ephemeris-ui-20260926.md @@ -0,0 +1,34 @@ +# 真机清单 · 星盘档案页样式 + 星历可读性(2026-09-26) + +对应任务书 `docs/tasks/TASK-people-ephemeris-ui-20260926.md`,BUG-1039。部署到 staging 后,用自己的账号在 iPhone Safari 和电脑 Chrome 各走一遍。每条写「通过 / 不通过 + 截图」。 + +## 星盘档案 `/people`(手机) + +1. 打开侧栏「星盘档案」:看到一张张人物卡片,不再挤成一行。每张卡有圆形头像、名字,本人后面有灰色「本人」小标签,下面一行是「出生日期 · 排盘时间 · 城市」,右边一个「›」。 +2. 顶栏「当前星盘」是谁,谁的卡片就是橙色描边 + 浅橙底。切到别人后回到这页,高亮跟着换。 +3. 最下面是虚线框「+ 添加一个人」,下面小字「还可以保存 N 个人」。N = 5 − 现有人数。存满 5 个人时框变灰、点不动,下面写「最多保存 5 个人的星盘」。 +4. 点任意一个人:整页换成这个人的详情,顶部有「‹ 返回」。 +5. 点「‹ 返回」回到列表;再点进一个人后,用 Safari 左滑返回(或浏览器返回键),也回到列表,而不是离开这一页。 +6. 详情里:头像 + 名字 +「本人 · 出生地」(别人只写出生地);两格时间卡「填报时间」「排盘时间」。做过生时校正并采用的本人,排盘卡是绿色、写「排盘时间 · 已校正」;没校正过的人两张卡都是普通白卡。 +7. 「岁差」写「Raman(默认)」这类名字,不出现小写的 raman。 +8. 三个一样宽的按钮「和 TA 对话 / 看星盘 / 生成报告」都能点(「和 TA 对话」的去向以新建对话那一单为准)。 +9. 本人详情最下面灰字「本人不能删除」,整页没有删除按钮。 +10. 别人的详情有「和我合盘」和「编辑」。点「编辑」,表单最下面才有红色「删除这个人」;点它先显示「将同时删除 N 个对话和 M 份报告,无法恢复。」,取消后什么都不删。 +11. 页面左右不能横着拖动;所有按钮手指好点,不会点到旁边的。 + +## 星盘档案 `/people`(电脑,窗口宽于 1024) + +12. 左边列表、右边详情同时在;没有「‹ 返回」。点左边的人,右边换成他,被打开的那一行是灰底,当前人物仍是橙色。 +13. 把窗口拉窄到手机宽度,变回「先列表、点进去看详情」。 + +## 星历 `/ephemeris` + +14. 顶栏只剩「星历」和人名,**没有**右上角的「带这天去提问」大按钮。 +15. 「今日五要素」卡片第一行是一句总评,例如「满月之日,整体偏吉;迦那落在毗湿提,重要的事避开。」下面两个小标签「N 吉」「M 凶」。换几天看看,句子跟着变,且只有这几种说法:满月之日 / 新月之日 / 月亮渐盈 / 月亮渐亏 + 整体吉 / 整体偏吉 / 吉凶参半 / 整体偏凶。 +16. 五行里月相日、月宿、瑜伽、迦那先写中文(例如「白分初五」「亢宿 · 第 4 足」「分离」「巴瓦」),下面小字是梵文。遇到「Aindra」「Variyana」「Dhanishtha」这三个名字时目前只写梵文(对照表待产品裁决),记下日期。 +17. 每行右边有彩色小标签:吉是绿色、凶是红色、中是黄色。 +18. 卡片底下一行写「五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。」——如果你的岁差设置改成了 KP,这里写 KP;改成 Lahiri 时写「和星盘页相同」。 +19. 「当日行运」每行:彩色行星符号、「太阳 在 狮子座」、下面小字「落你的第 N 宫」(逆行的星再加「· 逆行」),右边是「29°33′」这样的度数。给一个还没填出生时间的人看,行运没有「落你的第几宫」,段首写「还没有本命上升,先不写落在你的第几宫。」 +20. 滑到最底下:一张浅橙色提示卡,左边「想问这一天适不适合做某件事?」,右边按钮「带这天去提问」。点它回到对话,输入框里已经填好这一天的日期。 +21. 切日期、切人物、弱网时,仍然没有转圈、骨架或「正在加载」。 +22. 深色模式下以上颜色都看得清(设置 → 外观 → 深色)。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ff6b8c3e..d561c56b 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -904,6 +904,18 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 上述为实现约束;登录页面、窄屏和慢网交互仍按真人清单待验,不以组件探针代替。 +### 星盘档案页布局(2026-09-26,BUG-1039) + +`/people` 上一轮只有类名、没有一条样式。布局按原型定稿,断点只用星盘页已有的 1024: + +- **小于 1024px:列表 → 详情。** 同一路由里用 `.people-archive[data-view]` 切换,只显示一栏。点人时压一条 `?person=` 历史记录,浏览器返回和详情顶部的「‹ 返回」走同一条路回到列表;刷新详情地址直接打开那个人。桌面点人不压历史。 +- **1024px 及以上:两栏。** 左列表(18–22rem),右详情;「‹ 返回」隐藏。右栏正在看的那一行用中性浅底(`is-open`),强调色只给当前人物。 +- **列表行**(`.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 - 人数`;到顶时换成原来的限额句并禁用。 +- **详情卡**:头部 48px 头像 + 名字 +「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期与岁差(显示「Raman(默认)」这类设置里的名字,不露小写键),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」与「编辑」;本人底部灰字「本人不能删除」。 +- **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。 +- **头像颜色**:本人用 `--color-action-strong` + `--color-on-dark`(同账户首字圆);其他人按 id 稳定地取四个元素色 `--color-element-fire / earth / air / water` 之一,字用 `--color-ink`。不新造色值。 + ### 行星表 三张 `.chart-page-planet-table`(印度盘宿度、西洋相位、七政十一曜)**每一张**都包在 `.chart-page-planet-table-wrap` 里:`min-width: 0` + `overflow-x: auto`。375px 下表格在自己的盒子里横滚,页面不横滚。`min-width: 0` 和 overflow 一样是必需的——grid item 默认按内容定宽,少了它表格会把页面撑宽而不是滚动。印度盘那张从四列扩到八列后 `min-width` 由 `26rem` 提到 `34rem`。 @@ -991,16 +1003,19 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 ## 16. 星历页 -独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `app/(secondary)` 共享外壳上(§ Secondary page shell):页名「星历」与「带这天去提问」在 46px 顶栏里,`.ephemeris-body` 是唯一滚动的东西。 +独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `app/(secondary)` 共享外壳上(§ Secondary page shell):页名「星历」在 46px 顶栏里,顶栏不放按钮;`.ephemeris-body` 是唯一滚动的东西。 -- **结构:** 日期条;今日五要素;当日行运;未来九十天。顺序固定。页尾不另写定性句。 +- **结构:** 日期条;今日五要素;当日行运;未来九十天;底部提问提示卡。顺序固定。页尾不另写定性句。 - **日期条:** 居中的 `‹ 日期 ›`——日期是主语,两枚 44px 箭头按钮是它的把手。箭头字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。 - **三段同一套卡片:** 五要素 / 行运 / 未来九十天三个 `.ephemeris-section` 各是一张卡(1px 描边 + `--radius-lg` + `--color-canvas`),卡内是行。原本三段的**内容**各自画框而**段落**不画框,于是三段看起来是三种不同的东西。框归段落,段内只有行。 - 切到未缓存的日期时,日期条和布局留下,五要素、行运立刻换成「这一天的五要素还没拿到。」「这一天的行运还没拿到。」事件区写「换座和停滞还在取。」,这时不用「还没接上」。没有 spinner、骨架或「正在加载」。请求失败时,三段都写「这一天的星历没取到,点日期再试一次。」再点这一天的日期,或在失败时点左右箭头,就重试这一天;日历仍可改去别的日期。旧日期的响应,包括进页时刷新的今天,不得盖住正在看的另一天。 -- **五要素:** `dl` 网格保留:桌面五格一行(星期、月相日、月宿含第几足、瑜伽、迦那),格子是段落卡内的浅底块,不再各自描边。767px 以下收成五行,标签左、值右。Yoga / Karana 的吉凶只照搬引擎标签(吉 / 中 / 凶),不写成建议。卡片下固定一行:「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」行运段不加这句。 -- **当日行运:** 九曜的星座、度数、相对本命宫位、顺逆。桌面表格,767px 以下每行改成带标签的堆叠。没有本命上升时,相对本命列为空,并写「还没有本命上升,相对本命这一列空着。」 +- **五要素(2026-09-26 可读性改版):** `dl` 网格保留:桌面五格一行(星期、月相日、月宿含第几足、瑜伽、迦那),格子是段落卡内的浅底块。767px 以下收成五行:左列标签固定 5.5rem、不加字距,行距收紧到 `--space-2`。 + - **先中文、后梵文。** 主行中文,副行 12px 三级色写引擎原名(`lang="sa-Latn"`)。中文名只来自 `src/lib/panchanga-names-zh.ts`,该表逐字抄自任务书附表,不自行翻译、不增删;匹配忽略大小写、空格、连字符,别名只有 Garija/Gara、Dwitiya/Dvitiya、Shashthi/Shashti。表里查不到的名字原样显示梵文、不出副行,并在控制台记一次 `panchanga_name_zh_missing`。星期接口里本来就是中文,不出副行。接口字段不变:`factor.value` 仍是引擎名,中文在视图层投影。§15 行星表「没有中文宿名表就不造一个」仍只管星盘页;星历这张表是产品在任务书里给定的。 + - **吉 / 中 / 凶是行右侧的彩色小标签**(`.ephemeris-quality`):吉 `--color-success`、凶 `--color-danger`、中 `--color-warning`,底是同色 `color-mix(… 12%, transparent)`。只照搬引擎标签,不写成建议。 + - **卡片顶部当日总评 + 计数。** 一句 `{月相短语},{整体}{Vishti 追加}。`,按固定规则拼(`panchangaDaySummary`,纯函数,不调模型):Purnima「满月之日」、Amavasya「新月之日」、其余白分「月亮渐盈」、黑分「月亮渐亏」;吉 ≥ 4「整体吉」、3「整体偏吉」、2「吉凶参半」、≤ 1「整体偏凶」,中不计入;迦那为 Vishti 时追加「;迦那落在毗湿提,重要的事避开」。下面两枚计数标签「N 吉」「M 凶」。五要素或其中任一吉凶缺项时,总评和计数都不出,只显示已有行。 + - 卡片下固定一行岁差说明,按资料实际岁差填名:「五要素按 Lahiri 岁差算,和星盘页的 {Raman / KP / True Pushya} 不同。」资料就是 Lahiri 时写「和星盘页相同」,读不到资料岁差时只写前半句。行运段不加这句。 +- **当日行运(2026-09-26):** 每行是一个 `li`:左 1.75rem 彩色行星符号(复用 `--color-planet-*`,与星盘页行星表同色、同字体栈)+「{行星} 在 {星座}」+ 副行「落你的第 N 宫」(仅 `natalRelativeAvailable` 为真时)与逆行时的「逆行」+ 右侧等宽度数(度°分′,`tabular-nums`)。桌面与手机同一行式,不再有表头和 `data-label` 堆叠。没有本命上升时副行不写宫位,段首写「还没有本命上升,先不写落在你的第几宫。」 - **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。 -- **出口:** 主按钮「带这天去提问」在顶栏,把当前日期预填进对话草稿并回到 `/`。不写「这一页是天象本身,不是对你的判断。」 +- **出口(2026-09-26 挪位):** 「带这天去提问」不在顶栏了。页面最底部一张浅色提示卡 `.ephemeris-ask-card`(`--color-action-soft` 底、`--radius-lg`、宽度与段落卡一致):左侧「想问这一天适不适合做某件事?」,右侧主按钮「带这天去提问」(44px),行为不变——把当前日期预填进对话草稿并回到 `/`。窄屏时问句自己折行,按钮不变宽。不写「这一页是天象本身,不是对你的判断。」 - **等待:** 揭幕后不得再出现 spinner / 骨架 / 「正在加载」。首屏未到时 `SecondaryPageShell` 写「这一天的星历还没拿到。」,居中在已撑满的内容区里。未登录是静态「请先登录」加去登录,不是转圈。同一会话再次进入直接用模块缓存。 -- **文案:** 不得写运势结论,不得把三套坐标系换算或叠加。不另印「这是天象本身」这类边界说明。 - +- **文案:** 不得写运势结论,不得把三套坐标系换算或叠加。当日总评只来自上面的固定规则。不另印「这是天象本身」这类边界说明。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 485310b9..cd8cf273 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -30,6 +30,21 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 没有出生资料时,标题是「还没有星盘资料」,按钮是「去填写出生资料」。资料读不出来、已采用时间缺时区、时区没有对上,正文仍用各自的原句,只共用这颗按钮。 +## 星历五要素总评与底部提示卡(2026-09-26) + +- 五要素先写中文,梵文小字在下;中文名只用 `panchanga-names-zh.ts` 那张表(任务书给定),不自己翻,查不到就照写梵文。 +- 当日总评只有一种句式:`{月相短语},{整体}{Vishti 追加}。` 月相短语是「满月之日 / 新月之日 / 月亮渐盈 / 月亮渐亏」;整体是「整体吉 / 整体偏吉 / 吉凶参半 / 整体偏凶」;迦那是 Vishti 时追加「;迦那落在毗湿提,重要的事避开」。例:「满月之日,整体偏吉;迦那落在毗湿提,重要的事避开。」规则拼句,不调模型,不加别的形容词;五要素不全就不写。 +- 计数标签写「3 吉」「2 凶」,数字与字之间一个空格。吉 / 中 / 凶标签只照搬引擎。 +- 岁差说明:「五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。」Raman 换成资料里实际的岁差名(Raman / KP / True Pushya);资料就是 Lahiri 时写「五要素按 Lahiri 岁差算,和星盘页相同。」;读不到时只写「五要素按 Lahiri 岁差算。」 +- 行运行:「太阳 在 狮子座」,副行「落你的第 2 宫」,逆行时加「· 逆行」。没有本命上升时段首写「还没有本命上升,先不写落在你的第几宫。」(原句「相对本命这一列空着」里的「列」已经没有了)。 +- 底部提示卡:左边「想问这一天适不适合做某件事?」,右边按钮「带这天去提问」。这是邀请用户问,不是页面给宜忌结论;页面其余地方仍不写「适合 / 不宜」。 + +## 星盘档案页(2026-09-26) + +- 列表行一行写「出生日期 · 排盘时间 · 城市」,本人名字后跟「本人」。添加卡片写「+ 添加一个人」,下面「还可以保存 N 个人」(不加句号);到顶仍是「最多保存 5 个人的星盘」。 +- 详情两格时间卡写「填报时间」「排盘时间」;有校正结果时排盘卡写「排盘时间 · 已校正」。岁差写设置里的名字,例如「Raman(默认)」,不写小写键。 +- 快捷入口只写「和 TA 对话 / 看星盘 / 生成报告」;他人另有「和我合盘」「编辑」。本人底部灰字「本人不能删除」(不加句号)。手机详情顶部是「‹ 返回」。 + ## 报告分块导出与删除 - 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」与导出图标;图标读屏名「导出报告」,tooltip「导出」。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。 @@ -109,7 +124,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 没有可显示内容 / 资料还在,过一会儿再打开 | 读不到资料:「星盘资料这会儿读不出来,我们已经记录下来了。」已采用的时间缺时区:「已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。」时区对不上:「出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。」结果格式不对:「星盘结果的格式对不上,我们已经记录下来了。」 | 失败要说出是哪一种。不写「过一会儿再打开」,也不用一句没头没尾的「没有可显示内容」。 | | 下面是词条式释义,不是对你个人的判断。 | (不写) | 基础信息 Tab 只放行星卡。词条本身不得写成运势,但不另印这句边界说明。 | | Agent 未完成必要的方法与计算步骤,本次不会扣点。(模型其实写过一段) | 把模型写过的那段留下,文末由服务端加一句:「这次没跑完星盘计算,上面是模型直接写的,先看着。」 | 合同没绿但写过字时不得整轮空白。这句不是模型写的。没写过字仍报未完成、不扣点。 | -| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 | +| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件和底部提问提示卡。「带这天去提问」在页面最底部的提示卡里(2026-09-26 从顶栏挪下来)。不另印定性句。 | | (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 | | 9月14日 · 生时校正 / 生时校正 · 9月14日 / 生时校正 · 09:35(创建钟点) | 生时校正 · 05:07 / 生时校正 · 05:00–05:15 / 生时校正 | 校正标题写结果:已确认优先于已采用,写 `生时校正 · HH:MM`;否则当前范围写 `生时校正 · HH:MM–HH:MM`(间隔号,不是连字符);都没有则只写「生时校正」。今日节奏仍是 `今日节奏 · M月D日`。同日不再用墙钟去重。副标题仍是最后活动时间。旧标题按结果批量改;手改过的名字不动。 | | 这么晚还醒着,{名字}。直接说说最在意的问题吧。 | 还没睡呀,{名字} | 开场只一行、不追问、不写场景;提问交给输入框占位符 | diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 67588b70..058e386b 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2179,6 +2179,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .chart-page-body, .ephemeris-body, .report-center-body, +.people-archive, .chart-page-message, .ephemeris-message, .report-center-message { @@ -2221,6 +2222,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .chart-page-body, .ephemeris-body, .report-center-body, + .people-archive, .chart-page-message, .ephemeris-message, .report-center-message, @@ -5031,14 +5033,22 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class font-weight: 600; } .chart-page-planet-abbr { color: var(--color-ink-tertiary); } -.chart-page-planet-glyph.is-sun { color: var(--color-planet-sun); } -.chart-page-planet-glyph.is-moon { color: var(--color-planet-moon); } -.chart-page-planet-glyph.is-mars { color: var(--color-planet-mars); } -.chart-page-planet-glyph.is-mercury { color: var(--color-planet-mercury); } -.chart-page-planet-glyph.is-jupiter { color: var(--color-planet-jupiter); } -.chart-page-planet-glyph.is-venus { color: var(--color-planet-venus); } -.chart-page-planet-glyph.is-saturn { color: var(--color-planet-saturn); } -.chart-page-planet-glyph.is-node { color: var(--color-planet-node); } +.chart-page-planet-glyph.is-sun, +.ephemeris-transit-glyph.is-sun { color: var(--color-planet-sun); } +.chart-page-planet-glyph.is-moon, +.ephemeris-transit-glyph.is-moon { color: var(--color-planet-moon); } +.chart-page-planet-glyph.is-mars, +.ephemeris-transit-glyph.is-mars { color: var(--color-planet-mars); } +.chart-page-planet-glyph.is-mercury, +.ephemeris-transit-glyph.is-mercury { color: var(--color-planet-mercury); } +.chart-page-planet-glyph.is-jupiter, +.ephemeris-transit-glyph.is-jupiter { color: var(--color-planet-jupiter); } +.chart-page-planet-glyph.is-venus, +.ephemeris-transit-glyph.is-venus { color: var(--color-planet-venus); } +.chart-page-planet-glyph.is-saturn, +.ephemeris-transit-glyph.is-saturn { color: var(--color-planet-saturn); } +.chart-page-planet-glyph.is-node, +.ephemeris-transit-glyph.is-node { color: var(--color-planet-node); } .chart-page-planet-glyph.is-ascendant { color: var(--color-planet-ascendant); font-weight: 700; } .chart-page-planet-table td.chart-page-planet-mid { text-align: center; } /* Figures line up column-wise instead of drifting with glyph width. */ @@ -5427,6 +5437,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .ephemeris-panchanga-item { display: grid; + align-content: start; gap: var(--space-2); margin: 0; padding: var(--space-3); @@ -5434,18 +5445,27 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class border-radius: var(--radius-sm); background: var(--color-canvas-muted); } +/* D6: a fixed-width label without the old 1.5px overline tracking. */ .ephemeris-panchanga-item dt { margin: 0; color: var(--color-ink-tertiary); - font-size: var(--type-overline); + font-size: var(--type-caption); font-weight: 500; - letter-spacing: 1.5px; } +/* Name on the left, the 吉 / 中 / 凶 tag on the right. The tag sits inside the + `dd` because a `dl` row may only hold `dt` / `dd`. */ .ephemeris-panchanga-item dd { display: grid; - gap: var(--space-1); + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2); margin: 0; } +.ephemeris-panchanga-name { + display: grid; + gap: 2px; + min-width: 0; +} .ephemeris-panchanga-item strong { color: var(--color-ink); font-size: var(--type-body-sm); @@ -5453,32 +5473,75 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .ephemeris-panchanga-item small { color: var(--color-ink-tertiary); - font-size: var(--type-caption); + font-size: var(--type-overline); + line-height: 1.4; } +/* D6 / D7: coloured tags on a 12% tint of their own hue. Tokens only; each + semantic hue already has a dark value. */ +.ephemeris-quality { + display: inline-flex; + align-items: center; + padding: 2px var(--space-2); + border-radius: 999px; + font-size: var(--type-overline); + font-weight: 500; + line-height: 1.4; + white-space: nowrap; +} +.ephemeris-quality.is-subha { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 12%, transparent); } +.ephemeris-quality.is-asubha { color: var(--color-danger); background: color-mix(in srgb, var(--color-danger) 12%, transparent); } +.ephemeris-quality.is-mixed { color: var(--color-warning); background: color-mix(in srgb, var(--color-warning) 12%, transparent); } +.ephemeris-summary { + margin: 0; + color: var(--color-ink-strong); + font-size: var(--type-body-sm); + line-height: 1.55; +} +.ephemeris-counts { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin: 0; +} +/* D9: glyph · 「行星 在 星座」 over 「落你的第 N 宫」 · degree on the right. */ .ephemeris-transits { - overflow: hidden; - border-radius: var(--radius-sm); + display: grid; + margin: 0; + padding: 0; + list-style: none; } -.ephemeris-transits-head, .ephemeris-transits-row { display: grid; - grid-template-columns: 4.5rem minmax(0, 1fr) 5rem 6.5rem 3rem; + grid-template-columns: 1.75rem minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; - padding: var(--space-3) 0; - min-height: 44px; -} -.ephemeris-transits-head { - border-bottom: 1px solid var(--color-border); - color: var(--color-ink-tertiary); - font-size: var(--type-caption); - font-weight: 500; -} -.ephemeris-transits-row { + padding: var(--space-2) 0; border-top: 1px solid var(--color-border); font-size: var(--type-body-sm); } -.ephemeris-transits-row span { min-width: 0; } +.ephemeris-transits-row:first-child { border-top: 0; } +.ephemeris-transit-glyph { + font-family: var(--font-planet-glyphs), "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", serif; + font-size: var(--type-title-sm); + font-weight: 600; + text-align: center; +} +.ephemeris-transit-main { + display: grid; + gap: 2px; + min-width: 0; + color: var(--color-ink); +} +.ephemeris-transit-main small { + color: var(--color-ink-tertiary); + font-size: var(--type-overline); +} +.ephemeris-transit-degree { + color: var(--color-ink-secondary); + font-family: var(--font-mono); + font-size: var(--type-caption); + font-variant-numeric: tabular-nums; +} .ephemeris-events { display: grid; gap: 0; @@ -5503,7 +5566,26 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink-tertiary); font-size: var(--type-caption); } -.ephemeris-ask { min-height: 44px; width: fit-content; } +/* D8: the page action moved out of the header into a quiet bottom card. */ +.ephemeris-ask-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + width: min(900px, 100%); + margin: 0 auto var(--space-6); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-lg); + background: var(--color-action-soft); +} +.ephemeris-ask-card p { + flex: 1 1 auto; + min-width: 0; + margin: 0; + color: var(--color-ink); + font-size: var(--type-body-sm); +} +.ephemeris-ask { flex: none; min-height: 44px; width: fit-content; } .ephemeris-message { display: flex; flex-direction: column; @@ -5534,31 +5616,312 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .ephemeris-panchanga-item { grid-template-columns: 5.5rem minmax(0, 1fr); align-items: center; - gap: var(--space-4); - padding: var(--space-3) 0; + gap: var(--space-3); + padding: var(--space-2) 0; border: 0; border-top: 1px solid var(--color-border); border-radius: 0; background: transparent; } .ephemeris-panchanga-item:first-child { border-top: 0; } - .ephemeris-panchanga-item dd { justify-items: end; text-align: right; } - .ephemeris-transits-head { display: none; } - .ephemeris-transits-row { - grid-template-columns: 1fr 1fr; - gap: var(--space-2) var(--space-4); - padding: var(--space-3) 0; - } - .ephemeris-transits-row span::before { - content: attr(data-label); - display: block; - color: var(--color-ink-tertiary); - font-size: var(--type-caption); - font-weight: 500; - } .ephemeris-events li { grid-template-columns: 1fr; gap: var(--space-1); } .ephemeris-section { padding: var(--space-4); } - .ephemeris-ask { width: 100%; } +} + +/* ============================================================ + People archive (/people) — TASK-people-ephemeris-ui-20260926 D1–D4. + Phone (< 1024px): list, then one person's detail in the same route + (`data-view`, plus a history entry so Back returns to the list). + Desktop (≥ 1024px, the chart page's cut): list left, detail right. + ============================================================ */ +.people-archive { + display: grid; + gap: var(--space-6); + align-items: start; + width: min(1040px, 100%); + margin-inline: auto; + padding-top: var(--space-5); +} +.people-archive-list, +.people-archive-detail { + display: grid; + gap: var(--space-3); + align-content: start; + min-width: 0; +} +.people-archive-rows { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} +.people-archive-row { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) auto; + grid-template-rows: auto auto; + column-gap: var(--space-3); + row-gap: 2px; + align-items: center; + width: 100%; + min-height: 64px; + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas); + color: var(--color-ink); + font: inherit; + text-align: left; + cursor: pointer; +} +.people-archive-row:hover { background: var(--color-canvas-muted); } +/* D2: the person the rest of the app is currently reading. */ +.people-archive-row.is-current, +.people-archive-row.is-current:hover { + border-color: var(--color-action-strong); + background: var(--color-action-soft); +} +.people-archive-row > .people-archive-avatar { grid-row: 1 / span 2; } +.people-archive-row-name { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} +.people-archive-row-name b { + overflow: hidden; + font-size: var(--type-title-sm); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} +.people-archive-tag { + flex: none; + padding: 1px var(--space-2); + border-radius: 999px; + background: var(--color-canvas-muted); + color: var(--color-ink-secondary); + font-size: var(--type-overline); + font-weight: 500; +} +.people-archive-row.is-current .people-archive-tag { background: var(--color-canvas); } +.people-archive-row-meta { + grid-column: 2; + overflow: hidden; + color: var(--color-ink-tertiary); + font-family: var(--font-mono); + font-size: var(--type-caption); + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} +.people-archive-chevron { + grid-column: 3; + grid-row: 1 / span 2; + color: var(--color-ink-tertiary); + font-size: var(--type-title-md); + line-height: 1; +} +/* D4: 本人 takes the fill-rank accent like the account initial; everyone else + an element fill picked from their id. Ink on the element fills, as on the + western wheel. */ +.people-archive-avatar { + display: grid; + place-items: center; + flex: none; + width: 40px; + height: 40px; + border-radius: 50%; + color: var(--color-ink); + font-size: var(--type-body-sm); + font-weight: 500; +} +.people-archive-avatar.is-large { + width: 48px; + height: 48px; + font-size: var(--type-title-md); +} +.people-archive-avatar.is-self { background: var(--color-action-strong); color: var(--color-on-dark); } +.people-archive-avatar.is-fire { background: var(--color-element-fire); } +.people-archive-avatar.is-earth { background: var(--color-element-earth); } +.people-archive-avatar.is-air { background: var(--color-element-air); } +.people-archive-avatar.is-water { background: var(--color-element-water); } +.people-archive-add { + width: 100%; + min-height: 48px; + margin-top: var(--space-1); + border: 1px dashed var(--color-border-strong); + border-radius: var(--radius-lg); + background: transparent; + color: var(--color-action); + font: inherit; + font-weight: 500; + cursor: pointer; +} +.people-archive-add:hover:not(:disabled) { background: var(--color-action-soft); } +.people-archive-add:disabled { color: var(--color-ink-tertiary); cursor: not-allowed; } +.people-archive-hint { + margin: 0; + color: var(--color-ink-tertiary); + font-size: var(--type-caption); + text-align: center; +} +.people-archive-back { + display: inline-flex; + align-items: center; + gap: var(--space-1); + justify-self: start; + min-height: 44px; + margin-left: calc(-1 * var(--space-2)); + padding: 0 var(--space-2); + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--color-ink-secondary); + font: inherit; + font-size: var(--type-body-sm); + cursor: pointer; +} +.people-archive-back span { font-size: var(--type-title-md); line-height: 1; } +.people-archive-card, +.people-archive-danger, +.people-archive-detail > .chart-library-form { + display: grid; + gap: var(--space-4); + margin: 0; + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas); +} +.people-archive-danger { gap: var(--space-3); } +.people-archive-head { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; +} +.people-archive-head > div { min-width: 0; } +.people-archive-head h2 { + margin: 0; + color: var(--color-ink); + font-size: var(--type-title-md); + font-weight: 500; +} +.people-archive-head p, +.people-archive-incomplete, +.people-archive-foot p { + margin: 0; + color: var(--color-ink-tertiary); + font-size: var(--type-caption); +} +.people-archive-incomplete { color: var(--color-ink-secondary); font-size: var(--type-body-sm); } +.people-archive-times { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} +.people-archive-time { + display: grid; + gap: 2px; + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} +.people-archive-time small { + color: var(--color-ink-tertiary); + font-size: var(--type-overline); + font-weight: 500; +} +.people-archive-time b { + color: var(--color-ink); + font-family: var(--font-mono); + font-size: var(--type-title-md); + font-variant-numeric: tabular-nums; + font-weight: 500; + overflow-wrap: anywhere; +} +/* D3: a rectified chart time reads as settled, in the success family. */ +.people-archive-time.is-rectified { + border-color: var(--color-success); + background: var(--color-success-muted); +} +.people-archive-time.is-rectified small { color: var(--color-success); } +.people-archive-facts { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-2) var(--space-4); + margin: 0; + font-size: var(--type-body-sm); +} +.people-archive-facts dt { color: var(--color-ink-tertiary); } +.people-archive-facts dd { + margin: 0; + color: var(--color-ink); + font-variant-numeric: tabular-nums; +} +.people-archive-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); +} +.people-archive-actions button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-1); + min-width: 0; + min-height: 60px; + padding: var(--space-2) var(--space-1); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-canvas-soft); + color: var(--color-ink); + font: inherit; + font-size: var(--type-caption); + font-weight: 500; + cursor: pointer; +} +.people-archive-actions button:hover { background: var(--color-canvas-muted); } +.people-archive-actions svg { color: var(--color-action); } +.people-archive-secondary { + min-height: 44px; + padding: 0 var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: transparent; + color: var(--color-ink-strong); + font: inherit; + font-size: var(--type-body-sm); + cursor: pointer; +} +.people-archive-secondary:hover { background: var(--color-canvas-muted); } +.people-archive-foot { display: grid; text-align: center; } +.people-archive-confirm { + display: grid; + gap: var(--space-3); +} +.people-archive-confirm p { + margin: 0; + color: var(--color-ink-strong); + font-size: var(--type-body-sm); +} + +@media (max-width: 1023px) { + .people-archive { width: min(640px, 100%); } + .people-archive[data-view="list"] .people-archive-detail, + .people-archive[data-view="detail"] .people-archive-list { display: none; } +} + +@media (min-width: 1024px) { + .people-archive { grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr); } + .people-archive-back { display: none; } + /* The open person in the right column; the accent stays with 当前人物. */ + .people-archive-row.is-open:not(.is-current) { + border-color: var(--color-border-strong); + background: var(--color-canvas-muted); + } } /* Dynamic viewport units, applied only where the engine understands them. diff --git a/frontend/src/components/ephemeris/ephemeris-page.tsx b/frontend/src/components/ephemeris/ephemeris-page.tsx index abda5eb9..29136494 100644 --- a/frontend/src/components/ephemeris/ephemeris-page.tsx +++ b/frontend/src/components/ephemeris/ephemeris-page.tsx @@ -22,14 +22,20 @@ import { import { EPHEMERIS_COPY, ephemerisAskDraft, - formatDegree, + ephemerisAyanamsaNote, formatEphemerisDate, - formatHouse, + panchangaComplete, + panchangaDaySummary, + panchangaDisplayRows, + panchangaQualityCounts, qualityLabel, shiftEphemerisDate, parseEphemerisDate, toEphemerisDate, + transitHouseLine, } from "@/lib/ephemeris-view"; +import { formatDegreeMinutes } from "@/components/chart-page/chart-format"; +import { PLANET_COLOR_CLASS, PLANET_GLYPH, planetGlyphKey } from "@/lib/planet-glyphs"; type PageState = | { phase: "ready"; payload: EphemerisOkResponse } @@ -238,19 +244,20 @@ export function EphemerisView(props: { const natalMissing = !failed && !pending && props.payload?.transits.natalRelativeNote === "missing_natal"; const eventsUnavailable = !pending && !failed && (!props.payload || props.payload.events.status === "unavailable"); const events = !pending && !failed && props.payload?.events.status === "ok" ? props.payload.events.items : []; + const natalRelativeAvailable = !failed && !pending && props.payload?.transits.natalRelativeAvailable === true; + const rows = panchangaDisplayRows(factors); + const complete = panchangaComplete(factors); + const summary = panchangaDaySummary(factors); + const counts = panchangaQualityCounts(factors); + const ayanamsaNote = ephemerisAyanamsaNote(props.payload?.transits.ayanamsa); const isToday = Boolean(props.date) && props.date === props.today; const [pickerOpen, setPickerOpen] = useState(false); const selectedDay = props.date ? parseEphemerisDate(props.date) : undefined; + // D8: the page action no longer sits in the header. It is a quiet hint card + // at the very bottom of the body (below). return ( - - {EPHEMERIS_COPY.ask} - - )} - > +
{/* `‹ 日期 ›`: the date is the subject, the two arrows are its handles. @@ -332,22 +339,37 @@ export function EphemerisView(props: {

{EPHEMERIS_COPY.panchangaHeading}

{failed ? (

{EPHEMERIS_COPY.dateFailed}

- ) : factors.length === 5 ? ( -
- {factors.map((factor) => ( -
-
{factor.label}
-
- {factor.value}{factor.detail ? ` · ${factor.detail}` : ""} - {qualityLabel(factor.quality) ? {qualityLabel(factor.quality)} : null} -
-
- ))} -
+ ) : rows.length > 0 ? ( + <> + {/* D7: fixed-rule sentence + counts; only when all five limbs came back. */} + {summary ?

{summary}

: null} + {complete ? ( +

+ {counts.good} 吉 + {counts.bad} 凶 +

+ ) : null} +
+ {rows.map((row) => ( +
+
{row.label}
+
+ + {row.primary} + {row.secondary ? {row.secondary} : null} + + {row.quality ? ( + {qualityLabel(row.quality)} + ) : null} +
+
+ ))} +
+ ) : (

{EPHEMERIS_COPY.panchangaUnavailable}

)} -

{EPHEMERIS_COPY.panchangaLahiriNote}

+

{ayanamsaNote}

@@ -356,24 +378,25 @@ export function EphemerisView(props: { {failed ? (

{EPHEMERIS_COPY.dateFailed}

) : bodies.length > 0 ? ( -
- - {bodies.map((body) => ( -
- {body.planetLabel} - {body.signLabel} - {formatDegree(body.degreeInSign)} - {formatHouse(body.natalHouse)} - {body.retrograde ? EPHEMERIS_COPY.retrograde : EPHEMERIS_COPY.direct} -
- ))} -
+
    + {bodies.map((body) => { + const glyph = planetGlyphKey(body.planet); + const house = transitHouseLine(body.natalHouse, natalRelativeAvailable); + const sub = [house, body.retrograde ? EPHEMERIS_COPY.retrograde : null].filter(Boolean).join(" · "); + return ( +
  • + + + {body.planetLabel} 在 {body.signLabel} + {sub ? {sub} : null} + + {formatDegreeMinutes(body.degreeInSign)} +
  • + ); + })} +
) : (

{EPHEMERIS_COPY.transitsUnavailable}

)} @@ -398,6 +421,13 @@ export function EphemerisView(props: { )}
+ +
); diff --git a/frontend/src/components/people/people-page.tsx b/frontend/src/components/people/people-page.tsx index be030674..cfc836fe 100644 --- a/frontend/src/components/people/people-page.tsx +++ b/frontend/src/components/people/people-page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type Dispatch, type FormEvent, type SetStateAction } from "react"; +import { FileText, MessageSquareText, Orbit } from "lucide-react"; import { effectiveBirthDate } from "@/lib/effective-birth-date"; import { ChartProfileForm } from "@/components/chart-profile-form"; @@ -14,34 +15,69 @@ import { readProfile, } from "@/lib/home-profile"; import { emptyProfile, type Profile } from "@/lib/home-types"; +import { + peopleAvatarInitial, + peopleAvatarTone, + peopleAyanamsaLabel, + peopleDisplayName, + peopleRowMeta, + peopleTimeRectified, +} from "@/lib/people-archive-view"; import { MAX_OTHER_SUBJECTS, MAX_SAVED_SUBJECTS } from "@/lib/subject-birth"; -type PersonRow = { +export type PersonRow = { readonly id: string; readonly role: "self" | "other"; readonly profile: Profile; readonly incomplete: boolean; }; -type Usage = { id: string; sessions: number; reports: number }; +export type Usage = { id: string; sessions: number; reports: number }; const DELETE_USAGE_UNAVAILABLE = "暂时查不到这个人的对话数量,稍后再试"; +const DELETE_FAILED = "删除失败,请重试"; +/** `?person=new` marks the add form; a reload or Forward onto it shows the list. */ +const NEW_PERSON_PARAM = "new"; const LIMIT_COPY = "最多保存 5 个人的星盘"; +/** D1: below this width the page is list → detail; at and above it, two columns. */ +const PEOPLE_TWO_COLUMN_QUERY = "(min-width: 1024px)"; +/** The detail view on a phone is a history entry, so the browser Back returns to the list. */ +const PEOPLE_DETAIL_PARAM = "person"; + function incompleteProfile(profile: Profile): boolean { return missingOtherProfileStep(profile) !== null; } +function twoColumnLayout(): boolean { + return typeof window.matchMedia === "function" && window.matchMedia(PEOPLE_TWO_COLUMN_QUERY).matches; +} + +function detailParam(): string | null { + return new URLSearchParams(window.location.search).get(PEOPLE_DETAIL_PARAM); +} + +/** Rewrites the current entry's `?person=` (null removes it); no new history entry. */ +function replaceDetailParam(id: string | null) { + if (detailParam() === null) return; + const url = new URL(window.location.href); + if (id === null) url.searchParams.delete(PEOPLE_DETAIL_PARAM); + else url.searchParams.set(PEOPLE_DETAIL_PARAM, id); + window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`); +} + export function PeoplePage() { const subjectId = useCurrentSubject(); const [people, setPeople] = useState([]); const [status, setStatus] = useState<"loading" | "ready" | "failed">("loading"); const [selectedId, setSelectedId] = useState("self"); + const [view, setView] = useState<"list" | "detail">("list"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(emptyProfile); const [notice, setNotice] = useState(""); const [pendingDelete, setPendingDelete] = useState(null); const [saving, setSaving] = useState(false); + const pushedDetail = useRef(false); async function load() { setStatus("loading"); @@ -71,22 +107,81 @@ export function PeoplePage() { useEffect(() => { const timer = window.setTimeout(() => { + // A reload on a phone detail URL opens that person again. + const opened = detailParam(); + if (opened && opened !== NEW_PERSON_PARAM) { + setSelectedId(opened); + setView("detail"); + } void load(); }, 0); - return () => window.clearTimeout(timer); + // Browser Back / Forward between the list and a detail entry. + const onPopState = () => { + const opened = detailParam(); + pushedDetail.current = false; + setEditing(false); + setPendingDelete(null); + if (opened && opened !== NEW_PERSON_PARAM) { + setSelectedId(opened); + setView("detail"); + } else { + setView("list"); + } + }; + window.addEventListener("popstate", onPopState); + return () => { + window.clearTimeout(timer); + window.removeEventListener("popstate", onPopState); + }; }, []); const selected = people.find((person) => person.id === selectedId) ?? people[0] ?? null; const otherCount = people.filter((person) => person.role === "other").length; const atLimit = otherCount >= MAX_OTHER_SUBJECTS; + function showDetail(id: string) { + setSelectedId(id); + setView("detail"); + if (twoColumnLayout() || pushedDetail.current) return; + const url = new URL(window.location.href); + url.searchParams.set(PEOPLE_DETAIL_PARAM, id || NEW_PERSON_PARAM); + window.history.pushState(null, "", `${url.pathname}${url.search}${url.hash}`); + pushedDetail.current = true; + } + + function showList() { + setEditing(false); + setPendingDelete(null); + if (pushedDetail.current) { + // popstate brings the list back, so Back and 「‹ 返回」 are one path. + window.history.back(); + return; + } + setView("list"); + replaceDetailParam(null); + } + + function selectPerson(id: string) { + setEditing(false); + setPendingDelete(null); + setNotice(""); + showDetail(id); + } + function startAdd() { if (atLimit) return; setDraft(emptyProfile); setNotice(""); setEditing(true); - setSelectedId(""); setPendingDelete(null); + showDetail(""); + } + + function startEdit(profile: Profile) { + setDraft(profile); + setNotice(""); + setPendingDelete(null); + setEditing(true); } async function saveOther(event: FormEvent) { @@ -110,7 +205,10 @@ export function PeoplePage() { setNotice(""); invalidateSubjectCatalog(); await load(); - if (payload?.profile?.id) setSelectedId(payload.profile.id); + if (payload?.profile?.id) { + setSelectedId(payload.profile.id); + replaceDetailParam(payload.profile.id); + } } catch { setNotice("保存失败,请重试"); } finally { @@ -142,7 +240,8 @@ export function PeoplePage() { return; } if (subjectId === selected.id) setCurrentSubject("self"); - setPendingDelete(null); + // Back to the list; on a phone that also pops the detail history entry. + showList(); setSelectedId("self"); invalidateSubjectCatalog(); await load(); @@ -163,84 +262,216 @@ export function PeoplePage() { ) : null} {status === "ready" ? ( -
-
- {people.map((person) => ( - - ))} - - {atLimit ?

{LIMIT_COPY}

:

还可以保存 {MAX_SAVED_SUBJECTS - people.length} 个人。

} -
-
- {editing ? ( - void saveOther(event)} - onCancel={() => setEditing(false)} - submitLabel={saving ? "保存中" : "保存"} - submitDisabled={saving || Boolean(missingOtherProfileStep(draft))} - notice={notice} - /> - ) : selected ? ( - <> -

{selected.profile.name.trim() || (selected.role === "self" ? "我" : "未命名资料")}

- {selected.incomplete ?

这份资料还不完整,补全后才能排盘。

: null} -

出生日期:{effectiveBirthDate(selected.profile) || "出生日期待补全"}

-

填报时间:{selected.profile.reportedTime || "还没填"}

-

排盘时间:{profileBirthTimeLabel(selected.profile)}

-

地点:{profilePlaceLabel(selected.profile)}

-

岁差:{selected.profile.ayanamsa}

-
- - - - {selected.role === "other" ? ( - - ) : null} -
- {selected.role === "other" ? ( - - ) : null} - {selected.role === "self" ?

本人不能删除。

: ( - - )} - {pendingDelete?.id === selected.id && selected.role === "other" ? ( -
-

将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告,无法恢复。

-
- - -
-
- ) : null} - {notice ?

{notice}

: null} - - ) : null} -
-
+ setEditing(false)} + onDraftChange={setDraft} + saveOther={saveOther} + askDelete={askDelete} + onCancelDelete={() => setPendingDelete(null)} + confirmDelete={confirmDelete} + openPerson={openPerson} + /> ) : null}
); } + +function PeopleAvatar(props: { readonly person: PersonRow; readonly large?: boolean }) { + const tone = peopleAvatarTone(props.person.id, props.person.role); + return ( + + ); +} + +export type PeopleArchiveViewProps = { + readonly people: readonly PersonRow[]; + readonly subjectId: string; + readonly selected: PersonRow | null; + readonly selectedId: string; + readonly view: "list" | "detail"; + readonly editing: boolean; + readonly draft: Profile; + readonly notice: string; + readonly pendingDelete: Usage | null; + readonly saving: boolean; + readonly atLimit: boolean; + readonly onSelect: (id: string) => void; + readonly onBack: () => void; + readonly onAdd: () => void; + readonly onEdit: (profile: Profile) => void; + readonly onCancelEdit: () => void; + readonly onDraftChange: Dispatch>; + readonly saveOther: (event: FormEvent) => Promise; + readonly askDelete: () => Promise; + readonly onCancelDelete: () => void; + readonly confirmDelete: () => Promise; + readonly openPerson: (id: string, href: string) => void; +}; + +/** + * D1–D4. Phone (< 1024px): `data-view` shows either the list or the detail. + * Desktop: both columns, `data-view` ignored by CSS. + */ +export function PeopleArchiveView({ + people, + subjectId, + selected, + selectedId, + view, + editing, + draft, + notice, + pendingDelete, + saving, + atLimit, + onSelect, + onBack, + onAdd, + onEdit, + onCancelEdit, + onDraftChange, + saveOther, + askDelete, + onCancelDelete, + confirmDelete, + openPerson, +}: PeopleArchiveViewProps) { + const remaining = MAX_SAVED_SUBJECTS - people.length; + const deleteNotice = notice === DELETE_USAGE_UNAVAILABLE || notice === DELETE_FAILED; + return ( +
+
+
    + {people.map((person) => { + const current = person.id === subjectId; + const open = person.id === selected?.id && !editing; + return ( +
  • + +
  • + ); + })} +
+ + {atLimit ?

{LIMIT_COPY}

:

还可以保存 {remaining} 个人

} +
+
+ + {editing ? ( + void saveOther(event)} + onCancel={onCancelEdit} + submitLabel={saving ? "保存中" : "保存"} + submitDisabled={saving || Boolean(missingOtherProfileStep(draft))} + notice={deleteNotice ? undefined : notice} + /> + ) : selected ? ( +
+
+ +
+

{peopleDisplayName(selected.profile, selected.role)}

+

{selected.role === "self" ? `本人 · ${profilePlaceLabel(selected.profile)}` : profilePlaceLabel(selected.profile)}

+
+
+ {selected.incomplete ?

这份资料还不完整,补全后才能排盘。

: null} +
+
+ 填报时间 + {selected.profile.reportedTime || "还没填"} +
+
+ {peopleTimeRectified(selected.profile) ? "排盘时间 · 已校正" : "排盘时间"} + {profileBirthTimeLabel(selected.profile)} +
+
+
+
出生日期
+
{effectiveBirthDate(selected.profile) || "出生日期待补全"}
+
岁差
+
{peopleAyanamsaLabel(selected.profile)}
+
+
+ + + +
+ {selected.role === "other" ? ( + + ) : null} +
+ {selected.role === "self" ?

本人不能删除

: ( + + )} +
+ {notice ?

{notice}

: null} +
+ ) : null} + {/* D3: deleting someone lives at the bottom of their edit view; 本人 never gets it. */} + {editing && selectedId && selected?.role === "other" ? ( +
+ + {pendingDelete?.id === selected.id && selected.role === "other" ? ( +
+

将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告,无法恢复。

+
+ + +
+
+ ) : null} + {deleteNotice ?

{notice}

: null} +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/lib/ephemeris-view.ts b/frontend/src/lib/ephemeris-view.ts index 72fead58..8f801c25 100644 --- a/frontend/src/lib/ephemeris-view.ts +++ b/frontend/src/lib/ephemeris-view.ts @@ -5,6 +5,8 @@ import type { EphemerisQuality, EphemerisTransitBody, } from "@/lib/ephemeris-contract"; +import { isAyanamsaName, type AyanamsaName } from "@/lib/ayanamsa"; +import { normalizePanchangaName, panchangaNameZh, splitTithiName } from "@/lib/panchanga-names-zh"; export const EPHEMERIS_COPY = { title: "星历", @@ -13,26 +15,25 @@ export const EPHEMERIS_COPY = { nextDay: "后一天", today: "今天", panchangaHeading: "今日五要素", - panchangaLahiriNote: "这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。", + // D10: the chart page's name is filled from the subject's own setting, not + // hard-coded; see `ephemerisAyanamsaNote`. + panchangaAyanamsaNoteDifferent: "五要素按 Lahiri 岁差算,和星盘页的 {chart} 不同。", + panchangaAyanamsaNoteSame: "五要素按 Lahiri 岁差算,和星盘页相同。", + panchangaAyanamsaNoteUnknown: "五要素按 Lahiri 岁差算。", panchangaUnavailable: "这一天的五要素还没拿到。", transitsHeading: "当日行运", transitsUnavailable: "这一天的行运还没拿到。", - natalMissing: "还没有本命上升,相对本命这一列空着。", + natalMissing: "还没有本命上升,先不写落在你的第几宫。", eventsHeading: "未来九十天", eventsPending: "换座和停滞还在取。", eventsUnavailable: "换座和停滞还没接上。这一页其余部分不受影响。", dateFailed: "这一天的星历没取到,点日期再试一次。", ask: "带这天去提问", + askPrompt: "想问这一天适不适合做某件事?", loginTitle: "请先登录", loginBody: "登录后即可查看星历。", loginAction: "去登录", - planet: "星体", - sign: "星座", - degree: "度数", - natalHouse: "相对本命", - motion: "顺逆", - direct: "顺", - retrograde: "逆", + retrograde: "逆行", } as const; export const EPHEMERIS_PLANET_ORDER = [ @@ -167,6 +168,115 @@ export function qualityLabel(quality: EphemerisQuality | null): string | null { return quality ? QUALITY_ZH[quality] : null; } +/* ── Readable panchanga (TASK-people-ephemeris-ui-20260926 D5–D7, D10) ────── + A projection over the unchanged `/api/ephemeris` payload: `factor.value` + stays the engine name on the wire (星期 was already Chinese there), and the + Chinese main line is looked up here, from `panchanga-names-zh.ts` only. */ + +export type PanchangaDisplayRow = { + readonly key: EphemerisPanchangaFactor["key"]; + readonly label: string; + /** Chinese first; the engine name when the table has no entry. */ + readonly primary: string; + /** The engine's own (Sanskrit) name under a Chinese main line; null otherwise. */ + readonly secondary: string | null; + readonly quality: EphemerisQuality | null; +}; + +const reportedMissingNames = new Set(); + +function reportMissingPanchangaName(key: string, name: string) { + const id = `${key}:${normalizePanchangaName(name)}`; + if (reportedMissingNames.has(id)) return; + reportedMissingNames.add(id); + console.warn("panchanga_name_zh_missing", { kind: key, name }); +} + +export function panchangaDisplayRows(factors: readonly EphemerisPanchangaFactor[]): PanchangaDisplayRow[] { + return factors.map((factor) => { + const zh = factor.key === "vara" ? null : panchangaNameZh(factor.key, factor.value); + if (factor.key !== "vara" && !zh) reportMissingPanchangaName(factor.key, factor.value); + const name = zh ?? factor.value; + return { + key: factor.key, + label: factor.label, + primary: factor.detail ? `${name} · ${factor.detail}` : name, + secondary: zh ? factor.value : null, + quality: factor.quality, + }; + }); +} + +export type PanchangaQualityCounts = { readonly good: number; readonly bad: number }; + +/** 吉 = subha, 凶 = asubha. 中 (mixed) counts as neither. */ +export function panchangaQualityCounts(factors: readonly EphemerisPanchangaFactor[]): PanchangaQualityCounts { + return { + good: factors.filter((factor) => factor.quality === "subha").length, + bad: factors.filter((factor) => factor.quality === "asubha").length, + }; +} + +const PANCHANGA_KEYS = ["vara", "tithi", "nakshatra", "yoga", "karana"] as const; + +/** All five limbs present, each with the engine's 吉 / 中 / 凶. */ +export function panchangaComplete(factors: readonly EphemerisPanchangaFactor[]): boolean { + return PANCHANGA_KEYS.every((key) => { + const factor = factors.find((item) => item.key === key); + return Boolean(factor && factor.value.trim() && factor.quality); + }); +} + +function moonPhasePhrase(tithi: string): string | null { + const parts = splitTithiName(tithi); + if (parts.name === "purnima") return "满月之日"; + if (parts.name === "amavasya") return "新月之日"; + if (parts.paksha === "shukla") return "月亮渐盈"; + if (parts.paksha === "krishna") return "月亮渐亏"; + return null; +} + +function overallPhrase(good: number): string { + if (good >= 4) return "整体吉"; + if (good === 3) return "整体偏吉"; + if (good === 2) return "吉凶参半"; + return "整体偏凶"; +} + +/** + * D7: one fixed-rule sentence, no model call. + * `{月相短语},{整体}{Vishti 追加}。` — null when a limb is missing. + */ +export function panchangaDaySummary(factors: readonly EphemerisPanchangaFactor[]): string | null { + if (!panchangaComplete(factors)) return null; + const tithi = factors.find((factor) => factor.key === "tithi")!.value; + const karana = factors.find((factor) => factor.key === "karana")!.value; + const phase = moonPhasePhrase(tithi); + if (!phase) return null; + const overall = overallPhrase(panchangaQualityCounts(factors).good); + const vishti = normalizePanchangaName(karana) === "vishti" ? ";迦那落在毗湿提,重要的事避开" : ""; + return `${phase},${overall}${vishti}。`; +} + +/** Same names as the ayanamsa setting (`AYANAMSA_OPTIONS`), without 「(默认)」. */ +const AYANAMSA_SHORT_NAME: Readonly> = { + raman: "Raman", + lahiri: "Lahiri", + kp: "KP", + true_pushya: "True Pushya", +}; + +/** + * D10: the five limbs are always Lahiri; the chart page uses the subject's own + * setting (`transits.ayanamsa`). Unknown setting → only the Lahiri half. + */ +export function ephemerisAyanamsaNote(chartAyanamsa: string | null | undefined): string { + const key = typeof chartAyanamsa === "string" ? chartAyanamsa.trim().toLowerCase().replace(/-/g, "_") : ""; + if (!isAyanamsaName(key)) return EPHEMERIS_COPY.panchangaAyanamsaNoteUnknown; + if (key === "lahiri") return EPHEMERIS_COPY.panchangaAyanamsaNoteSame; + return EPHEMERIS_COPY.panchangaAyanamsaNoteDifferent.replace("{chart}", AYANAMSA_SHORT_NAME[key]); +} + export function planetLabel(planet: string): string { return PLANET_ZH[planet as keyof typeof PLANET_ZH] ?? planet; } @@ -195,6 +305,11 @@ export function formatHouse(house: number | null): string { return house === null ? "—" : `第 ${house} 宫`; } +/** D9 sub-line: 「落你的第 N 宫」, only when the natal ascendant is known. */ +export function transitHouseLine(house: number | null, natalRelativeAvailable: boolean): string | null { + return natalRelativeAvailable && house !== null ? `落你的第 ${house} 宫` : null; +} + export function formatDegree(degree: number): string { return `${degree.toFixed(1)}°`; } diff --git a/frontend/src/lib/panchanga-names-zh.ts b/frontend/src/lib/panchanga-names-zh.ts new file mode 100644 index 00000000..595af7e3 --- /dev/null +++ b/frontend/src/lib/panchanga-names-zh.ts @@ -0,0 +1,186 @@ +/** + * Chinese names for the four panchanga limbs the engine returns in Sanskrit + * (Latin transliteration). TASK-people-ephemeris-ui-20260926 D5. + * + * The table below is the task brief's table, copied as written. It is the only + * source: nothing here is translated by the implementer, and nothing is added + * or dropped. A name the table does not know returns `null` and the caller + * keeps the engine name — it is never guessed. + * + * Matching ignores case, spaces and hyphens. The three spelling aliases the + * brief lists (Garija/Gara, Dwitiya/Dvitiya, Shashthi/Shashti) live in + * `PANCHANGA_NAME_ALIASES`; no other alias is accepted. + */ + +export type PanchangaNameKind = "tithi" | "nakshatra" | "yoga" | "karana"; + +/** Tithi within a fortnight. Purnima / Amavasya carry their own full label. */ +export const TITHI_ZH: Readonly> = { + Pratipada: "初一", + Dwitiya: "初二", + Tritiya: "初三", + Chaturthi: "初四", + Panchami: "初五", + Shashthi: "初六", + Saptami: "初七", + Ashtami: "初八", + Navami: "初九", + Dashami: "初十", + Ekadashi: "十一", + Dwadashi: "十二", + Trayodashi: "十三", + Chaturdashi: "十四", + Purnima: "望日(满月)", + Amavasya: "朔日(新月)", +}; + +/** Paksha prefixes: 白分 = Shukla, 黑分 = Krishna. */ +export const PAKSHA_ZH: Readonly> = { + Shukla: "白分", + Krishna: "黑分", +}; + +export const NAKSHATRA_ZH: Readonly> = { + Ashwini: "娄宿", + Bharani: "胃宿", + Krittika: "昴宿", + Rohini: "毕宿", + Mrigashira: "觜宿", + Ardra: "参宿", + Punarvasu: "井宿", + Pushya: "鬼宿", + Ashlesha: "柳宿", + Magha: "星宿", + "Purva Phalguni": "张宿", + "Uttara Phalguni": "翼宿", + Hasta: "轸宿", + Chitra: "角宿", + Swati: "亢宿", + Vishakha: "氐宿", + Anuradha: "房宿", + Jyeshtha: "心宿", + Mula: "尾宿", + "Purva Ashadha": "箕宿", + "Uttara Ashadha": "斗宿", + Shravana: "女宿", + Dhanishta: "虚宿", + Shatabhisha: "危宿", + "Purva Bhadrapada": "室宿", + "Uttara Bhadrapada": "壁宿", + Revati: "奎宿", +}; + +export const YOGA_ZH: Readonly> = { + Vishkambha: "支柱", + Priti: "喜爱", + Ayushman: "长寿", + Saubhagya: "好运", + Shobhana: "光彩", + Atiganda: "大险", + Sukarma: "善业", + Dhriti: "坚持", + Shula: "刺痛", + Ganda: "险结", + Vriddhi: "增长", + Dhruva: "恒定", + Vyaghata: "打击", + Harshana: "欢喜", + Vajra: "金刚", + Siddhi: "成就", + Vyatipata: "灾变", + Variyan: "安逸", + Parigha: "阻碍", + Shiva: "吉祥", + Siddha: "圆满", + Sadhya: "可成", + Shubha: "吉善", + Shukla: "清白", + Brahma: "梵天", + Indra: "因陀罗", + Vaidhriti: "分离", +}; + +export const KARANA_ZH: Readonly> = { + Bava: "巴瓦", + Balava: "巴拉瓦", + Kaulava: "考拉瓦", + Taitila: "泰提拉", + Garija: "加拉", + Vanija: "瓦尼贾", + Vishti: "毗湿提", + Shakuni: "沙昆尼", + Chatushpada: "四足", + Naga: "蛇", + Kimstughna: "金斯图格纳", +}; + +/** Spelling alias → table key. Only the three pairs the brief names. */ +export const PANCHANGA_NAME_ALIASES: Readonly>>> = { + tithi: { Dvitiya: "Dwitiya", Shashti: "Shashthi" }, + nakshatra: {}, + yoga: {}, + karana: { Gara: "Garija" }, +}; + +/** Case, spaces and hyphens do not count. */ +export function normalizePanchangaName(name: string): string { + return name.toLowerCase().replace(/[\s-]+/g, ""); +} + +function indexTable(table: Readonly>, aliases: Readonly>) { + const index = new Map(); + for (const [key, zh] of Object.entries(table)) index.set(normalizePanchangaName(key), zh); + for (const [alias, key] of Object.entries(aliases)) { + const zh = table[key]; + if (zh) index.set(normalizePanchangaName(alias), zh); + } + return index; +} + +const INDEX: Readonly>> = { + tithi: indexTable(TITHI_ZH, PANCHANGA_NAME_ALIASES.tithi), + nakshatra: indexTable(NAKSHATRA_ZH, PANCHANGA_NAME_ALIASES.nakshatra), + yoga: indexTable(YOGA_ZH, PANCHANGA_NAME_ALIASES.yoga), + karana: indexTable(KARANA_ZH, PANCHANGA_NAME_ALIASES.karana), +}; + +const PAKSHA_INDEX = indexTable(PAKSHA_ZH, {}); + +export type TithiParts = { + readonly paksha: "shukla" | "krishna" | null; + /** Normalized tithi name without the paksha, e.g. `purnima`. */ + readonly name: string; +}; + +/** Splits the engine's `full_name` ("Shukla Purnima") into paksha and tithi. */ +export function splitTithiName(name: string): TithiParts { + const normalized = normalizePanchangaName(name); + for (const paksha of ["shukla", "krishna"] as const) { + if (normalized.startsWith(paksha) && normalized.length > paksha.length) { + return { paksha, name: normalized.slice(paksha.length) }; + } + } + return { paksha: null, name: normalized }; +} + +function tithiZh(name: string): string | null { + const parts = splitTithiName(name); + const day = INDEX.tithi.get(parts.name); + if (!day) return null; + // Full moon and new moon are whole labels on their own: 望日(满月)/ 朔日(新月). + if (parts.name === "purnima" || parts.name === "amavasya") return day; + if (!parts.paksha) return null; + const paksha = PAKSHA_INDEX.get(parts.paksha); + return paksha ? `${paksha}${day}` : null; +} + +/** + * Chinese name from the brief's table, or `null` when the table has no entry. + * A tithi needs its paksha ("Shukla Dashami" → 白分初十) unless it is Purnima + * or Amavasya; a bare "Dashami" is not guessed into either fortnight. + */ +export function panchangaNameZh(kind: PanchangaNameKind, name: string): string | null { + if (!name.trim()) return null; + if (kind === "tithi") return tithiZh(name); + return INDEX[kind].get(normalizePanchangaName(name)) ?? null; +} diff --git a/frontend/src/lib/people-archive-view.ts b/frontend/src/lib/people-archive-view.ts new file mode 100644 index 00000000..ff7fd0b0 --- /dev/null +++ b/frontend/src/lib/people-archive-view.ts @@ -0,0 +1,76 @@ +/** + * Pure display helpers for /people (TASK-people-ephemeris-ui-20260926 D2–D4). + * No new facts: every string is read from the stored profile or an existing + * label table. + */ +import { AYANAMSA_OPTIONS, resolveAyanamsa } from "@/lib/ayanamsa"; +import { effectiveBirthDate } from "@/lib/effective-birth-date"; +import { findCity, findProvince, profileBirthTimeLabel } from "@/lib/home-profile"; +import type { Profile } from "@/lib/home-types"; + +/** D4: four existing element tokens; the person's id picks one, stably. */ +export const PEOPLE_AVATAR_TONES = ["fire", "earth", "air", "water"] as const; +export type PeopleAvatarTone = (typeof PEOPLE_AVATAR_TONES)[number] | "self"; + +export function peopleAvatarTone(id: string, role: "self" | "other"): PeopleAvatarTone { + if (role === "self") return "self"; + let hash = 0; + for (const character of id) hash = (hash * 31 + character.codePointAt(0)!) >>> 0; + return PEOPLE_AVATAR_TONES[hash % PEOPLE_AVATAR_TONES.length]; +} + +export function peopleDisplayName(profile: Profile, role: "self" | "other"): string { + return profile.name.trim() || (role === "self" ? "我" : "未命名资料"); +} + +/** 本人 shows 「我」; everyone else the first character of the name. */ +export function peopleAvatarInitial(profile: Profile, role: "self" | "other"): string { + if (role === "self") return "我"; + return Array.from(peopleDisplayName(profile, role))[0] ?? "?"; +} + +function trimCitySuffix(name: string): string { + return name.length > 2 && name.endsWith("市") ? name.slice(0, -1) : name; +} + +/** + * The city for the one-line list meta. Chinese places come from the location + * codes; a free-text label keeps its first comma segment, or the city segment + * of a 「中国 · 省 · 市 · 区」 label. Empty when the place is not complete. + */ +export function peopleCityLabel(profile: Profile): string { + const city = findCity(findProvince(profile.provinceCode), profile.cityCode); + if (city) return trimCitySuffix(city.name); + const label = profile.birthPlaceLabel.trim(); + if (!label) return ""; + if (label.includes("·")) { + const parts = label.split("·").map((part) => part.trim()).filter(Boolean); + const withoutCountry = parts[0] === "中国" ? parts.slice(1) : parts; + return trimCitySuffix(withoutCountry[1] ?? withoutCountry[0] ?? ""); + } + const [first] = label.split(/[,,]/).map((part) => part.trim()).filter(Boolean); + return first ?? ""; +} + +/** D2: 「出生日期 · 排盘时间 · 城市」, missing parts dropped. */ +export function peopleRowMeta(profile: Profile): string { + return [effectiveBirthDate(profile) ?? "", profileBirthTimeLabel(profile), peopleCityLabel(profile)] + .filter(Boolean) + .join(" · "); +} + +/** + * D3: a rectification result was adopted or confirmed. The case id is required + * too: `readProfile` falls back to `confirmed` for any legacy row that simply + * has a time, and a hospital-record time is not a 校正结果. + */ +export function peopleTimeRectified(profile: Profile): boolean { + return (profile.birthTimeStatus === "accepted" || profile.birthTimeStatus === "confirmed") + && profile.rectificationCaseId.trim() !== ""; +} + +/** D3: 「Raman(默认)」, never the stored lower-case key. */ +export function peopleAyanamsaLabel(profile: Profile): string { + const value = resolveAyanamsa(profile); + return AYANAMSA_OPTIONS.find((option) => option.value === value)?.label ?? value; +} diff --git a/frontend/tests/ephemeris-page.test.tsx b/frontend/tests/ephemeris-page.test.tsx index dbbbdbb1..3f0206a7 100644 --- a/frontend/tests/ephemeris-page.test.tsx +++ b/frontend/tests/ephemeris-page.test.tsx @@ -10,6 +10,7 @@ import { parseEphemerisOkResponse, type EphemerisOkResponse } from "../src/lib/e import { EPHEMERIS_COPY, ephemerisAskDraft, + ephemerisAyanamsaNote, panchangaFactorsFromEngine, shiftEphemerisDate, transitsFromEngine, @@ -120,12 +121,16 @@ test("unavailable events render a static note, not a blank page", () => { test("the Lahiri note sits under panchanga and not under transits", () => { const markup = render(payloadFor("2026-09-15")); const panchanga = markup.indexOf(EPHEMERIS_COPY.panchangaHeading); - const note = markup.indexOf(EPHEMERIS_COPY.panchangaLahiriNote); + // 原值:`EPHEMERIS_COPY.panchangaLahiriNote`(固定句「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」) + // 新值:`ephemerisAyanamsaNote("raman")`(「五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。」,fixture 的 transits.ayanamsa 是 raman) + // 原因:TASK-people-ephemeris-ui-20260926 D10 改句式,并要求按资料实际岁差填名、不写死;位置断言(五要素之下、行运之上)一字未改。 + const lahiriNote = ephemerisAyanamsaNote("raman"); + const note = markup.indexOf(lahiriNote); const transits = markup.indexOf(EPHEMERIS_COPY.transitsHeading); assert.ok(panchanga >= 0 && note >= 0 && transits >= 0); assert.ok(panchanga < note && note < transits); const transitsBlock = markup.slice(transits); - assert.doesNotMatch(transitsBlock, new RegExp(EPHEMERIS_COPY.panchangaLahiriNote)); + assert.doesNotMatch(transitsBlock, new RegExp(lahiriNote)); }); test("今天 is absent on today and present off it, never disabled", () => { @@ -163,7 +168,13 @@ test("copy stays factual: no fortune-telling, no spinner, ask prefills the date" assert.match(markup, new RegExp(EPHEMERIS_COPY.ask)); assert.match(markup, new RegExp(EPHEMERIS_COPY.previousDay)); assert.match(markup, new RegExp(EPHEMERIS_COPY.nextDay)); - assert.doesNotMatch(markup, /运势|适合|不宜|建议|吉祥日/); + // 原值:`assert.doesNotMatch(markup, /运势|适合|不宜|建议|吉祥日/)`,对整页 markup。 + // 新值:先去掉底部提示卡那一句问句(EPHEMERIS_COPY.askPrompt),再对其余 markup 断同一组词;另断问句只出现一次。 + // 原因:D8 产品定稿的底部提示卡原文是「想问这一天适不适合做某件事?」——这是邀请用户提问的问句,不是页面给出的 + // 宜忌结论;五要素、总评、行运、九十天事件里仍不得出现这组词,约束范围没有放宽。 + const withoutAskPrompt = markup.replace(EPHEMERIS_COPY.askPrompt, ""); + assert.equal(markup.split(EPHEMERIS_COPY.askPrompt).length - 1, 1); + assert.doesNotMatch(withoutAskPrompt, /运势|适合|不宜|建议|吉祥日/); assert.doesNotMatch(pageSource, /InlineSpinner|正在加载|skeleton/i); assert.equal(ephemerisAskDraft("2026-09-15"), "请按 2026年9月15日 这一天的天象来看。"); assert.match(pageSource, /setComposerDraft\(ephemerisAskDraft/); diff --git a/frontend/tests/ephemeris-readable-panchanga.test.tsx b/frontend/tests/ephemeris-readable-panchanga.test.tsx new file mode 100644 index 00000000..c70c232f --- /dev/null +++ b/frontend/tests/ephemeris-readable-panchanga.test.tsx @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { EphemerisView } from "../src/components/ephemeris/ephemeris-page.tsx"; +import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; +import { parseEphemerisOkResponse, type EphemerisOkResponse, type EphemerisPanchangaFactor, type EphemerisQuality } from "../src/lib/ephemeris-contract.ts"; +import { + EPHEMERIS_COPY, + ephemerisAyanamsaNote, + panchangaComplete, + panchangaDaySummary, + panchangaDisplayRows, + panchangaFactorsFromEngine, + panchangaQualityCounts, + transitHouseLine, + transitsFromEngine, +} from "../src/lib/ephemeris-view.ts"; +import { cssDeclarations } from "./css-contract-test-support.ts"; + +Object.assign(globalThis, { React }); + +const pageSource = readFileSync(new URL("../src/components/ephemeris/ephemeris-page.tsx", import.meta.url), "utf8"); +const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +const panchangaGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-panchanga-range.golden.json", import.meta.url), "utf8")); +const natalGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-natal.golden.json", import.meta.url), "utf8")); +const transit15 = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8")); + +type Q = EphemerisQuality | null; + +/** Five limbs with chosen names and qualities; labels match the engine mapper. */ +function factors(input: { + tithi?: string; + karana?: string; + qualities?: readonly [Q, Q, Q, Q, Q]; +}): EphemerisPanchangaFactor[] { + const q = input.qualities ?? ["subha", "subha", "subha", "asubha", "asubha"]; + return [ + { key: "vara", label: "星期", value: "星期六", quality: q[0], detail: null }, + { key: "tithi", label: "月相日", value: input.tithi ?? "Shukla Purnima", quality: q[1], detail: null }, + { key: "nakshatra", label: "月宿", value: "Uttara Bhadrapada", quality: q[2], detail: "第 1 足" }, + { key: "yoga", label: "瑜伽", value: "Vriddhi", quality: q[3], detail: null }, + { key: "karana", label: "迦那", value: input.karana ?? "Bava", quality: q[4], detail: null }, + ]; +} + +function withGood(good: number): readonly [Q, Q, Q, Q, Q] { + return Array.from({ length: 5 }, (_, index) => (index < good ? "subha" : "asubha")) as unknown as readonly [Q, Q, Q, Q, Q]; +} + +test("D7 moon phrase: Purnima, Amavasya, waxing and waning fortnights", () => { + assert.equal(panchangaDaySummary(factors({ tithi: "Shukla Purnima", qualities: withGood(3) })), "满月之日,整体偏吉。"); + assert.equal(panchangaDaySummary(factors({ tithi: "Krishna Amavasya", qualities: withGood(3) })), "新月之日,整体偏吉。"); + assert.equal(panchangaDaySummary(factors({ tithi: "Shukla Dashami", qualities: withGood(3) })), "月亮渐盈,整体偏吉。"); + assert.equal(panchangaDaySummary(factors({ tithi: "Krishna Dashami", qualities: withGood(3) })), "月亮渐亏,整体偏吉。"); +}); + +test("D7 overall phrase for 吉 = 0 … 5", () => { + const expected = ["整体偏凶", "整体偏凶", "吉凶参半", "整体偏吉", "整体吉", "整体吉"]; + for (let good = 0; good <= 5; good += 1) { + assert.equal( + panchangaDaySummary(factors({ tithi: "Shukla Tritiya", qualities: withGood(good) })), + `月亮渐盈,${expected[good]}。`, + `吉 = ${good}`, + ); + } +}); + +test("D7 中 counts as neither 吉 nor 凶", () => { + const mixed = factors({ qualities: ["subha", "subha", "mixed", "mixed", "asubha"] }); + assert.deepEqual(panchangaQualityCounts(mixed), { good: 2, bad: 1 }); + assert.equal(panchangaDaySummary(mixed), "满月之日,吉凶参半。"); +}); + +test("D7 Vishti appends the avoid clause, in the brief's own example sentence", () => { + assert.equal( + panchangaDaySummary(factors({ tithi: "Shukla Purnima", karana: "Vishti", qualities: ["asubha", "subha", "subha", "subha", "asubha"] })), + "满月之日,整体偏吉;迦那落在毗湿提,重要的事避开。", + ); +}); + +test("D7 no summary when a limb or its quality is missing", () => { + const full = factors({}); + assert.equal(panchangaComplete(full), true); + assert.equal(panchangaDaySummary(full.filter((factor) => factor.key !== "yoga")), null); + assert.equal(panchangaDaySummary(full.filter((factor) => factor.key !== "tithi")), null); + assert.equal(panchangaDaySummary(factors({ qualities: ["subha", "subha", null, "subha", "subha"] })), null); + assert.equal(panchangaComplete([]), false); + assert.equal(panchangaDaySummary([]), null); +}); + +test("D5 rows: Chinese first, engine name second; 星期 has no second line; unknown stays Sanskrit", () => { + const warn = console.warn; + const logged: unknown[][] = []; + console.warn = (...args: unknown[]) => { logged.push(args); }; + try { + const rows = panchangaDisplayRows(factors({ karana: "Vishti" })); + assert.deepEqual(rows.map((row) => [row.key, row.primary, row.secondary]), [ + ["vara", "星期六", null], + ["tithi", "望日(满月)", "Shukla Purnima"], + ["nakshatra", "壁宿 · 第 1 足", "Uttara Bhadrapada"], + ["yoga", "增长", "Vriddhi"], + ["karana", "毗湿提", "Vishti"], + ]); + assert.equal(logged.length, 0); + const unknown = panchangaDisplayRows([{ key: "yoga", label: "瑜伽", value: "Fictionalyoga", quality: "subha", detail: null }]); + assert.equal(unknown[0].primary, "Fictionalyoga"); + assert.equal(unknown[0].secondary, null); + assert.equal(logged.length, 1); + assert.equal(logged[0][0], "panchanga_name_zh_missing"); + // Logged once per name, not on every render. + panchangaDisplayRows([{ key: "yoga", label: "瑜伽", value: "Fictionalyoga", quality: "subha", detail: null }]); + assert.equal(logged.length, 1); + } finally { + console.warn = warn; + } +}); + +test("D5 over the real engine golden: 2026-09-15 reads in Chinese", () => { + const rows = panchangaDisplayRows(panchangaFactorsFromEngine(panchangaGolden, "2026-09-15")); + assert.deepEqual(rows.map((row) => row.primary), ["星期二", "白分初五", "亢宿 · 第 4 足", "分离", "巴瓦"]); + assert.deepEqual(rows.map((row) => row.secondary), [null, "Shukla Panchami", "Swati", "Vaidhriti", "Bava"]); +}); + +test("D10 names the subject's own ayanamsa, never a hard-coded Raman", () => { + assert.equal(ephemerisAyanamsaNote("raman"), "五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。"); + assert.equal(ephemerisAyanamsaNote("kp"), "五要素按 Lahiri 岁差算,和星盘页的 KP 不同。"); + assert.equal(ephemerisAyanamsaNote("true_pushya"), "五要素按 Lahiri 岁差算,和星盘页的 True Pushya 不同。"); + assert.equal(ephemerisAyanamsaNote("lahiri"), "五要素按 Lahiri 岁差算,和星盘页相同。"); + assert.equal(ephemerisAyanamsaNote(null), "五要素按 Lahiri 岁差算。"); +}); + +test("D9 house line only when the natal ascendant is known", () => { + assert.equal(transitHouseLine(2, true), "落你的第 2 宫"); + assert.equal(transitHouseLine(2, false), null); + assert.equal(transitHouseLine(null, true), null); +}); + +function payload(overrides: Partial<{ natal: boolean; factors: EphemerisPanchangaFactor[] }> = {}): EphemerisOkResponse { + const natal = overrides.natal ?? true; + const transits = transitsFromEngine({ transitChart: transit15, natalChart: natal ? natalGolden : null }); + const parsed = parseEphemerisOkResponse({ + status: "ok", + date: "2026-09-15", + today: "2026-09-15", + timezoneId: "Asia/Shanghai", + panchanga: { + available: true, + ayanamsaNote: "lahiri_not_account", + factors: overrides.factors ?? panchangaFactorsFromEngine(panchangaGolden, "2026-09-15"), + }, + transits: { + available: true, + natalRelativeAvailable: transits.natalRelativeAvailable, + natalRelativeNote: transits.natalRelativeAvailable ? "shown" : "missing_natal", + ayanamsa: "raman", + bodies: transits.bodies, + }, + events: { status: "unavailable" }, + }); + assert.ok(parsed); + return parsed; +} + +function render(data: EphemerisOkResponse) { + return renderToStaticMarkup(React.createElement(SidebarProvider, null, React.createElement(EphemerisView, { + date: data.date, + today: data.today, + payload: data, + onPrevious() {}, + onNext() {}, + onToday() {}, + onSelectDate() {}, + onAsk() {}, + }))); +} + +test("D6/D7 card: summary, counts and coloured tags per row", () => { + const markup = render(payload({ factors: factors({ karana: "Vishti", qualities: ["asubha", "subha", "subha", "subha", "asubha"] }) })); + assert.match(markup, /class="ephemeris-summary">满月之日,整体偏吉;迦那落在毗湿提,重要的事避开。3 吉2 凶(吉|中|凶)望日(满月)<\/strong>Shukla Purnima<\/small>/); + const summary = markup.indexOf("ephemeris-summary"); + const firstRow = markup.indexOf("ephemeris-panchanga-item"); + assert.ok(summary > 0 && summary < firstRow); +}); + +test("D8 the ask button left the header for a bottom hint card", () => { + const markup = render(payload()); + const header = markup.slice(0, markup.indexOf("secondary-page\"")); + assert.doesNotMatch(header, /ephemeris-ask|带这天去提问/); + const card = markup.indexOf("ephemeris-ask-card"); + assert.ok(card > markup.indexOf(EPHEMERIS_COPY.eventsHeading), "hint card comes after the last section"); + assert.match(markup.slice(card), new RegExp(`

${EPHEMERIS_COPY.askPrompt}

[\\s\\S]*${EPHEMERIS_COPY.ask}`)); + assert.doesNotMatch(pageSource, /actions=\{\(\s*