星盘档案可以拖动排序,合盘开始前先确认扣点。
Independent Staging Quality Gate / validate (push) Failing after 9m16s
Independent Staging Quality Gate / publish (push) Skipped

人物卡、菜单、编辑和删除换成组件库。chart_profiles 增加可空的 archive_order,本人始终排在第一。合盘选人页两个名字并排,开始按钮居中,点了先说出点数和余额。

自测:tsc 0;lint 0 error(122 警告,与上一张相同);定向测试通过;next build 后 / 仍是 Static;首页 JS gzip-9 639609,相对 640131 少 0.08%。test:db 79 通过、13 失败,失败名单与 windows-known-failures.txt 一致,排序列断言在通过的那一组。没有浏览器登录态。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-25 05:05:00 +08:00
co-authored by Grok (agent loop)
parent b7ec8e78ed
commit eee8564b7f
29 changed files with 1061 additions and 329 deletions
+10
View File
@@ -1,5 +1,15 @@
# 印度占星 Skill 更新日志
## 2026-10-25 — 星盘档案可以拖着排,合盘先确认扣点
- 星盘档案的人物卡可以拖动排序。刷新、换一台登录的设备,顺序还在。本人始终排在第一张,拖不动。
- 每张卡的更多菜单可以编辑。别人还可以删除。删除前仍先问清会带走多少对话和报告。
- 关系和性别是同一排标签。表单上的字和读屏名称都还在。
- 合盘选人页不再重复标题。两个人的名字左右并排,窄屏改成上下排,中间的叉仍在。开始按钮在正中,上面不写价格。点了先确认会用掉多少点、现在还有多少点。不够就改去补点数。
- 第一次打开合盘时,两个头像仍会靠拢。系统开了减少动态效果时,仍只淡入。
- 扣点、价格和删人之后合盘、报告怎么处理,都没有改。价格仍从计价表读。
- Skill 版本不变(BUG-1436)。
## 2026-10-25 — 星盘页的页签和盘型条换成组件库
- 四个页签仍是星盘、基础信息、大运、西洋盘,当前项有下划线。
+16
View File
@@ -19462,3 +19462,19 @@
- 复发自:无
- 修复版本:分支 `codex/ui-kit-3-chart-20261023`。
## BUG-1436 | 星盘档案与婚恋合盘换成组件库
- 状态:enhancement
- 首次发现:2026-10-25
- 最近更新:2026-10-25
- 影响面:星盘档案的人物卡、编辑、删除、排序;婚恋合盘的选人页和开始前的确认;合盘报告的返回和语言切换。扣点、删人后合盘和报告怎么处理,不在这次的改动里。
- 用户现象:人物卡可以拖动排序,换设备还在,本人始终在第一张。合盘选人页两个人并排,窄屏上下排,开始按钮在正中。点开始会先说出将用掉多少点、余额还有多少点。不够时改去补点数。
- 触发条件:打开星盘档案后拖动别人的卡片,或在合盘页选好两个人再点开始合盘。排序在松手后写入。确认之后才生成报告。
- 根因:不是缺陷。组件库迁移的档案与合盘这一张(任务书 K4,决策 D4、D6、D7、D8、D9)。
- 修复:人物卡、更多菜单、编辑框和删除确认改用已有的组件库零件。关系与性别改成同一排标签。`chart_profiles` 增加可空的 `archive_order`,只加列,不改已有列表的时间顺序;没排过的仍按最近更新排在后面。只改了顺序时不改档案的更新时间。本人不是档案表里的一行,始终排在可拖的那一组外面。合盘选人改成两个下拉,开始前用确认框读计价表和余额。拖动排序的零件在档案页载入后再出现,不进首页同步包。第一次打开合盘时两个头像靠拢的动效还在。
- 验证:见 `docs/tasks/PROGRESS-ui-kit-4-people-20261023.md`。没有浏览器登录态,不把拖过人物卡或点过合盘写成已经在浏览器里做过。
- 防复发:`frontend/tests/people-archive-order.test.ts` 锁住本人不能被拖走、顺序间隔、只改排序列不改更新时间,以及开始按钮居中。`frontend/tests/database-local-business.test.ts` 锁住新列可空、授权更新,且迁移不复制到旧目录。合盘合同锁住确认句、可访问名称和扣点前的确认。白名单测试锁住名单只减不增。
- 相关记录:issue #108。任务书 `docs/tasks/TASK-ui-kit-migration-20261023.md` 的 K4。会话排序列沿用 #105 的 `sidebar_order`,本张不改它的语义。第一次打开合盘的头像动效沿用 #99。
- 复发自:无
- 修复版本:分支 `codex/ui-kit-4-people-20261023`。
@@ -0,0 +1,67 @@
# PROGRESS · 组件库 K4 星盘档案与婚恋合盘(2026-10-25)
工单 #108。分支 `codex/ui-kit-4-people-20261023`。基线 `origin/staging` `b7ec8e78ed4d280b25f5180cd7302e3ec3ef085c`(#107 星盘页已在这个提交里)。只做任务书 **K4 星盘档案与婚恋合盘**。K5 及之后没做。没有重做星盘页、对话页、侧栏。
## 结论
人物卡可以拖动排序。刷新、换一台登录的设备,顺序还在。本人始终是第一张,拖不动。合盘选人页两个人并排,窄屏上下排,开始按钮在正中。点开始会先说出将用掉多少点、余额还有多少点。不够时改去补点数。
## 做了什么
- 人物卡用 `Card` 和 `CardAction`。更多菜单用 `DropdownMenu`。编辑用 `Dialog`,表单仍是原来的那一份,没有在表单里再套一层对话框。删除用 `AlertDialog`。菜单里写「删除」,确认框里仍是「删除这个人」。
- 拖动排序用 dnd-kit 的网格策略。零件在档案页载入后再出现,不进首页同步包。本人不是 `chart_profiles` 里的一行,放在可拖的那一组外面。`moveArchiveIds` 遇到 `self` 就不动。
- `chart_profiles` 只加可空整数 `archive_order`,默认空,已部署的代码还能读这张表。不删列、不改类型、不收紧非空。会话的 `sidebar_order` 没有再加,语义也没改。只改顺序时不改 `updated_at`。名单仍按最近更新取出,再在程序里排序:有数字的在前,都没有数字时保持原来的先后。新位置是 10、20、30。
- 关系用 `ToggleGroup`,不是页签。性别也是同一排标签,所以能离开白名单。空的关系在名单上仍显示「其他」,再点这一项会写入。性别再点已选中的一项不会改值,「不填」仍存空。标签和读屏名称都留着。
- 合盘选人页去掉页内标题和副标题。两个 `Select` 左右并排,中间是 Lucide 的叉,`aria-hidden`。窄屏上下排,叉仍显示并居中。开始按钮用 `HeartHandshake`,不用 `Sparkles`,上面不写价格。点了才弹确认:「会用掉 N 点,余额 M 点。」按钮是「取消 / 开始」。不够时说「需要 N 点,余额只有 M 点。」,主按钮换成「去补点数」。N、M 从计价表和账户读。计价或余额读不到时不弹空确认,只说「计费配置不可用」,也不再发出生成请求。这比原来的按钮更严:原来计价未知时仍可以点。扣点仍发生在服务端生成报告时。删人后合盘和报告仍走原来的删除接口。
- 合盘报告的返回改成图标加「婚恋合盘」。语言用页签。追问仍是原来的八节。标题里的 × 还在,第一次打开时两个头像靠拢仍按这个标题拆。减少动态效果时仍只淡入。
- `people-home-links.tsx` 没有改。首页仍只从这里拿账户深链和合盘深链。
- 白名单去掉 7 个文件,没有加长,测试里写死的基线名单没有改。
- 没有改 `.gitea/**`,没有改生产部署,没有给 `scripts/jyotish_api_server.py` 加方法,没有改 `page.tsx`。`Home()` 的 useState / useRef 没有增长。
## 断言改动(原值 / 新值 / 原因)
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| 人物卡个数 | `class="people-archive-row"` 的个数 | 完整的 `data-slot="card"` 个数,不含 `card-action` | 人物卡改用 Card。CardAction 的槽位名字以 card 开头,不能算成另一张卡 |
| 当前人物 | 按 `<li>` 切开 | 只按完整 `data-slot="card"` 切开 | 同上。本人仍是第一张 |
| 添加按钮 | `+ 添加一个人`,到顶是 `disabled` | 仍写「添加一个人」,到顶是 `aria-disabled`,没有 `disabled` | 加号改用图标。到顶仍可点,用来弹出限额提示 |
| 返回 | 按钮里是 `‹`,类名后 240 字内是「返回」 | 类名 `people-archive-back` 后 800 字内是「返回」 | 箭头改用图标,图标 SVG 比一个字符长 |
| 本人脚注 | 类名后 500 字内是「编辑」 | 1600 字内是「编辑」 | 编辑改用 Button,组件库样式写在同一个 class 里 |
| 关系选项 | 编辑态静态 HTML 里有「孩子」 | 字段渲染关系词表,词表里仍是 `child: "孩子"` | 编辑放进对话框,传送门不进静态 HTML |
| 性别 | `role="radio"` | 按钮的 `aria-pressed`;再点已选项由 `genderChoice` 决定不改 | 性别改成同一排标签 |
| 合盘副标题 | 开关未返回时静态 HTML 有副标题 | 源码和静态 HTML 都没有这句 | 顶栏已经有页名 |
| 合盘可访问名称 | 写死 `aria-label="第一个人"` | `label="第一个人"` / `label="第二个人"`,触发器 `aria-label={props.label}` | 两个选择框共用一个组件,名字仍是这两个词 |
| 开始按钮 | 下面写「生成一份完整合盘报告,用掉 N 点。」,图标是 Sparkles | 按钮上不写价格;确认句是「会用掉 N 点,余额 M 点。」和「需要 N 点,余额只有 M 点。」;图标是 HeartHandshake | 先确认再生成。价格从计价表读 |
| 报告返回 | `‹ 婚恋合盘` | 链接仍是 `/synastry`,文字是「婚恋合盘」,源码有 ChevronLeft | 去掉手写箭头。标题里的 × 还在 |
| 编辑表单接线 | `editing ? (` 紧跟表单 | 中间可以先是 Dialog,400 字内仍是表单 | 编辑改用对话框 |
| 排序列 | 只核对会话的 `sidebar_order` | 同时核对 `chart_profiles.archive_order` 可空、可更新,且不复制到旧迁移目录 | 只加一列,对已部署代码向后兼容 |
| 白名单 | 含这 7 个档案和合盘文件 | 这 7 个移出 | 它们已经改用组件库。名单只许变短 |
移出的 7 个文件:`chart-profile-form.tsx`、`people/people-page.tsx`、`person-relation-field.tsx`、`profile-gender-field.tsx`、`synastry/synastry-add-dialog.tsx`、`synastry/synastry-picker.tsx`、`synastry/synastry-report-view.tsx`。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | 仓库根 `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| 检查 | 在 `frontend` 下 `npm run lint` | 退出码 0。122 个警告,0 error。警告数和上一张记下的一样,没有为这一张新加 |
| 定向测试 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test` 下面列出的文件 | 先 180 条里 4 条失败,按上面的三栏改完断言后,失败的 4 个文件 43 条全部通过。整批重跑的其余文件在第一次里已通过 |
| 构建 | 在 `frontend` 下 `npm run build` | 退出码 0。编译 10.7s,TypeScript 55s。`/` 仍是 ○ Static。5 条既有 skill-package-registry 警告,没有改 |
| 首屏体积 | 首页 HTML 引用的 `/_next/static/**.js`,gzip-9 相加,不含 CSS | 基线 `b7ec8e78`(上一张记下的数):36 个文件,640131 字节。本张:36 个文件,缺失 0,639609 字节,少 522(-0.08%)。±2% 大约是 627329 到 652933。`dnd-kit`、`dropdown-menu`、`people-page`、`people-archive-sortable` 命中都是 0 |
| 数据库 | 在 `frontend` 下 `npm run test:db` | 92 条,通过 79,失败 13,取消 0。失败的 13 条名字与 `tools/agent-loop/windows-known-failures.txt` 逐条一致,没有多出来的。排序列断言在通过的「local PostgreSQL applies the reviewed business schema」里。退出码是 1,因为这 13 条在 Windows 上本来就会失败,不写成整套通过 |
定向测试文件:`adopted-birth-date.test.ts`、`chart-birth-editor-and-report-table.test.tsx`、`chart-library-other-profile.test.ts`、`chat-session-url.test.ts`、`consultation-entrypoint.test.ts`、`loading-motion-20260930.test.tsx`、`onboarding-checklist-20261022.test.ts`、`people-archive-order.test.ts`、`people-archive-regressions.test.ts`、`people-archive-view.test.tsx`、`profile-gender-20260927.test.ts`、`profile-gender-ui-20260927.test.tsx`、`profile-onboarding-birth-sky-reveal.test.ts`、`self-edit-people-20260928.test.tsx`、`settings-mvp-contract.test.ts`、`synastry-full-report.test.ts`、`synastry-report-surface.test.ts`、`ui-kit-whitelist.test.ts`。
没有跑前端全量 `npm test`,没有跑 Python 快速门。`tests/test_repo_privacy_markers.py` 退出码 0。`page.tsx` 和 `scripts/jyotish_api_server.py` 没有改。
## 偏离
- 决策 D7 说长相尽量不变。K4 写明人物卡、网格排序和合盘选人页的新样子,以 K4 为准。左栏仍是 18–22rem,所以桌面上卡片多半排成一列;手机上的列表最宽 640px,宽一点会排成两列。排序用的是网格策略。
- 决策 D9 说能用页签就用页签。K4 写明关系用 `ToggleGroup`。性别也用同一排标签,这样能离开白名单,而不是再做一套页签。
- 计价或余额读不到时,不再允许直接生成。原来的按钮在价格未知时仍会提交。确认框不能空着说一个编出来的价格。
- `people-home-links.tsx` 没有改界面。首页不从档案页拿拖动排序。
- 编辑对话框在静态渲染里挂不进页面。合同改为源码接线和直接渲染表单字段。删除确认的句子仍在源码里。
## 环境缺口
- 没有登录态。没有在浏览器里拖过人物卡,也没有点过开始合盘。不写成通过。可照做的步骤在 `docs/testing/ui-kit-migration-20261023.md` 的「星盘档案」和「婚恋合盘」两节。
+19
View File
@@ -41,3 +41,22 @@
5. 点顶栏的日期和地点。可以改出生资料。点框里面的空白不会把没保存的修改丢掉。按 Esc 或点外面会关上。
6. 在本命和 D9 之间来回切。星体从原来的宫位移到新的宫位,停稳后和直接打开那张盘一样。换成另一个人时,格子重画,星体不飞。系统开了减少动态效果时,只淡入,没有位移。第一次打开星盘时的那一次画线,不会因为换盘再来一遍。
7. 点开一段收着的大运,下面的小运展开。0 年的那一段不能展开。右栏先是 Chara。点 Yogini 才去取另一套。这一套失败时,再点一次 Yogini 会再要一次。
## 星盘档案
没有登录态,下面各项都还没在浏览器里拖过人物卡。上面「会话」第 3 条说档案顺序不变,那是外壳那一张的记录。这一张之后,档案可以拖动排序。
1. 打开星盘档案。本人始终是第一张。按住别人卡片上的拖动手柄,拖到另一张前面。松手后顺序变了。刷新页面,顺序还在。
2. 换一台登录同一账户的设备,再打开星盘档案。顺序和刚才一样。本人仍是第一张,拖不动。
3. 点一张卡的更多。可以编辑。别人还有删除。点删除会先说出将带走多少对话和报告,再确认。确认后,这个人相关的合盘和报告仍按原来的规则处理:没交付的释放预留点数,已经交付的不退。
4. 编辑别人时,关系是一排标签:伴侣、孩子、父母、朋友、其他。性别是女、男、不填。两项的名字读屏都能听到。
5. 把窗口改成大约 390 像素宽。列表和详情一次只看一栏。详情顶部的返回能回到列表。卡片按宽度换行,窄的时候是一列。
## 婚恋合盘
没有登录态,下面各项都还没在浏览器里点过开始合盘。
1. 打开婚恋合盘。页内不再重复标题和副标题,顶栏已经有页名。两个人的选择框左右并排,中间一个叉。把窗口改成大约 390 像素宽,两个选择框上下排,叉仍在正中。开始合盘在正中,按钮上没有价格。
2. 选好两个人,点开始合盘。先出现「会用掉 N 点,余额 M 点。」,按钮是取消和开始。N 是计价表上的价格,不是写死的数字。点开始之后才生成。这两人已经合过的次数,仍写在按钮下面。
3. 余额不够时,这句话改成「需要 N 点,余额只有 M 点。」,主按钮变成去补点数。点了去补点数的那一页。
4. 第一次打开一份合盘报告,两个人的头像仍会靠拢,再展开报告。系统开了减少动态效果时,只淡入,头像不飞。报告左上角回到婚恋合盘。语言仍是中文和 English。
+8 -8
View File
@@ -36,7 +36,7 @@
## 婚恋合盘报告(2026-10-19)
选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。这两人已经有写好的合盘、且没有一份正在生成时,点数说明下面再写已经合过几次、再生成会另扣多少点。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。报告在整宽的外层滚动,两侧空白也能滚;中间一栏只负责居中排版。合盘报告单独走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍亮「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,并保留语言。
选人页不重复标题和副标题,页名在顶栏(2026-10-25)。两个人的选择框左右并排,中间一个叉;窄屏改成上下排,叉仍在正中。各自是下拉,最多二十人。资料不全的人不能选;已经在另一边的人不能再选。下面是「新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行换成去星盘档案删人。「开始合盘」在桌面和手机上都居中,按钮上不写价格。点了先问「会用掉 N 点,余额 M 点。」,按钮是「取消 / 开始」。余额不够时说「需要 N 点,余额只有 M 点。」,主按钮换成「去补点数」。N 从计价表读,M 从账户读。计价读不到时不弹确认,只说「计费配置不可用」。这两人已经有写好的合盘、且没有一份正在生成时,按钮下面再写已经合过几次、再生成会另扣多少点。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。第一次打开时两个头像仍从两侧靠拢;减少动态效果时只淡入。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。报告在整宽的外层滚动,两侧空白也能滚;中间一栏只负责居中排版。合盘报告单独走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍亮「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,并保留语言。
## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途)
@@ -1218,15 +1218,15 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
`/people` 上一轮只有类名、没有一条样式。布局按原型定稿,断点只用星盘页已有的 1024:
- **小于 1024px:列表 → 详情。** 同一路由里用 `.people-archive[data-view]` 切换,只显示一栏。点人时压一条 `?person=<id>` 历史记录,浏览器返回和详情顶部的「‹ 返回」走同一条路回到列表;刷新详情地址直接打开那个人。桌面点人不压历史。
- **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 - 人数`;到顶时换成「最多保存 20 个人的星盘」并禁用。
- **小于 1024px:列表 → 详情。** 同一路由里用 `.people-archive[data-view]` 切换,只显示一栏。点人时压一条 `?person=<id>` 历史记录,浏览器返回和详情顶部的「返回」走同一条路回到列表;刷新详情地址直接打开那个人。桌面点人不压历史。
- **1024px 及以上:两栏。** 左列表(18–22rem),右详情;「返回」隐藏。右栏正在看的那一行用中性浅底(`is-open`),强调色只给当前人物。
- **人物卡**(2026-10-25,`.people-archive-row`,最小 64px):网格按宽度换行,每张至少 220px。左栏只有 18–22rem,桌面上通常排成一列;手机上的列表最宽 640px,宽一点会排成两列。40px 圆形首字头像(本人写「我」)+ 名字(本人后跟灰底「本人」标签;别人后跟同一枚标签,写关系,没填过写「其他」)+ 一行等宽 `tabular-nums` 的「出生日期 · 排盘时间 · 城市」+ 行尾箭头。资料不完整时这一行写「这份资料还不完整」。右上角是更多:编辑,别人还有删除。点卡片其余地方打开详情。本人始终是第一张,不在可拖的那一组里。其他人用拖动手柄排序,顺序写进 `chart_profiles.archive_order`;没排过的按最近更新排在已排过的后面。只改顺序不改更新时间。**当前人物**(`is-current`,与顶栏「当前星盘」同一个人)是 `--color-action-strong` 描边 + `--color-action-soft` 底。
- **添加**:虚线卡片「添加一个人」(48px,`--color-border-strong` 虚线,文字 `--color-action`),下面小字「还可以保存 N 个人」,N 仍是 `MAX_SAVED_SUBJECTS - 人数`;到顶时换成「最多保存 20 个人的星盘」。到顶的卡片仍可点,用来弹出原来的限额提示,不真的禁用。
- **人多时的滚动**(2026-10-17):`.people-archive-rows` 限高 `min(70vh, 640px)`,只在列表内部纵向滚动。标题旁和聊天里的人物菜单(`.chat-profile-picker-options`)限高 `min(50vh, 320px)`,同样只纵向滚动,名单放到 20 人也不把弹层撑出屏幕。两处都 `overflow-x: hidden`。
- **详情卡**:头部 48px 头像 + 名字(别人的名字后跟关系标签,没填过写「其他」;本人的标题仍只有名字)+「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期、岁差(显示「Raman(默认)」这类设置里的名字,不露小写键)与性别(「女」「男」,没填写「未填」),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」;本人出生资料还不完整时,这个位置改成一句「加上你自己后可合盘」,不能点。每个人底部都有「编辑」,本人在它上面加一行灰字「本人不能删除」。**本人的编辑视图(2026-09-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。他人已经保存过出生时间时,编辑表单锁住这一项,旁边只留「填报的出生时间保存后不能再改」,说明里不再重复。保存他人成功后清掉星盘页和星历页缓存(BUG-1411),再打开仍是刚保存的资料。
- **编辑表单里的关系**(2026-10-23):只在编辑或添加别人时出现,放在出生地点之后、性别之前。标题「TA 是你的」,五个选项:伴侣、孩子、父母、朋友、其他。复用性别那一排的尺寸和样式,不新造颜色。没点过就保持空,页面上仍显示「其他」;点了才写入。本人的编辑表单没有这一项。
- **编辑表单里的性别(选填)**(2026-09-27):放在关系之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。
- **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。
- **编辑表单里的关系**(2026-10-25):只在编辑或添加别人时出现,放在出生地点之后、性别之前。标题「TA 是你的」,五个选项:伴侣、孩子、父母、朋友、其他。同一排标签,不是页签,尺寸沿用原来的 44px,不新造颜色。没点过就保持空,页面上仍显示「其他」;点了才写入,再点当前显示的「其他」也会写入。本人的编辑表单没有这一项。
- **编辑表单里的性别(选填)**(2026-09-27;2026-10-25 改成同一排标签):放在关系之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。再点已经选中的一项不会改值。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。
- **删除**(2026-10-25):编辑视图底部仍是「删除这个人」。确认是对话框,按钮「取消 / 删除」。用量计数、查询失败句和删人之后合盘、报告怎么处理,全部沿用。菜单里的字是「删除」。
- **头像颜色**:本人用 `--color-action-strong` + `--color-on-dark`(同账户首字圆);其他人按 id 稳定地取四个元素色 `--color-element-fire / earth / air / water` 之一,字用 `--color-ink`。不新造色值。
### 行星表
+2 -2
View File
@@ -61,7 +61,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 吵完之后,怎样递出台阶才不会变成认输?
- 过日子的安排里,哪一件需要现在就说清楚?
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。这两人已经有写好的合盘、且没有一份正在生成时,点数说明下面加「这两人已经合过 N 次,再生成会另扣 X 点」,N、X 都读实际数字。正在生成时不写这句。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页只显示标题和副标题。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页不写副标题。按钮是「开始合盘」,上面不写价格。点了先确认「会用掉 N 点,余额 M 点。」,按钮是「取消 / 开始」。余额不够时说「需要 N 点,余额只有 M 点。」,主按钮是「去补点数」。N 从计价表读,M 从账户读。计价读不到时不弹确认,只说「计费配置不可用」。这两人已经有写好的合盘、且没有一份正在生成时,按钮下面加「这两人已经合过 N 次,再生成会另扣 X 点」,N、X 都读实际数字。正在生成时不写这句。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页不出现两个人的选择框,也不出现「开始合盘」。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
@@ -263,7 +263,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
## 星盘档案页(2026-09-26)
- 列表行一行写「出生日期 · 排盘时间 · 城市」,本人名字后跟「本人」。添加卡片写「+ 添加一个人」,下面「还可以保存 N 个人」(不加句号);到顶仍是「最多保存 20 个人的星盘」。
- 列表行一行写「出生日期 · 排盘时间 · 城市」,本人名字后跟「本人」。添加卡片写「添加一个人」,下面「还可以保存 N 个人」(不加句号);到顶仍是「最多保存 20 个人的星盘」。
- 详情两格时间卡写「填报时间」「排盘时间」;有校正结果时排盘卡写「排盘时间 · 已校正」。岁差写设置里的名字,例如「Raman(默认)」,不写小写键。
- 快捷入口只写「和 TA 对话 / 看星盘 / 生成报告」;他人另有「和我合盘」「编辑」。本人底部灰字「本人不能删除」(不加句号)。手机详情顶部是「‹ 返回」。
+1 -8
View File
@@ -12,7 +12,6 @@
"src/components/birth-time-intake.tsx",
"src/components/birth-time-legacy-rectification.tsx",
"src/components/birth-time-rectification.tsx",
"src/components/chart-profile-form.tsx",
"src/components/consultation-run-timeline.tsx",
"src/components/email-otp-login.tsx",
"src/components/ephemeris/ephemeris-page.tsx",
@@ -21,22 +20,16 @@
"src/components/legal/legal-consent-gate.tsx",
"src/components/legal/login-consent.tsx",
"src/components/markdown-code-block.tsx",
"src/components/people/people-page.tsx",
"src/components/person-relation-field.tsx",
"src/components/personal-report/personal-report-center.tsx",
"src/components/personal-report/personal-report-markdown-view.tsx",
"src/components/personal-report/report-actions.tsx",
"src/components/personal-report/report-edge-jump.tsx",
"src/components/personal-report/report-export-drawer.tsx",
"src/components/personal-report/report-row-delete.tsx",
"src/components/profile-gender-field.tsx",
"src/components/rectification-agentic-chat.tsx",
"src/components/rectification-board.tsx",
"src/components/rectification-choice-card.tsx",
"src/components/rectification-composer-reopen.tsx",
"src/components/rectification-range-delivery.tsx",
"src/components/rectification-segment-delivery.tsx",
"src/components/synastry/synastry-add-dialog.tsx",
"src/components/synastry/synastry-picker.tsx",
"src/components/synastry/synastry-report-view.tsx"
"src/components/rectification-segment-delivery.tsx"
]
@@ -34,6 +34,7 @@ function toClientProfile(row: Record<string, unknown>) {
role: "other",
profile: libraryProfileFromSubjectRow(row),
updated_at: row.updated_at,
archive_order: typeof row.archive_order === "number" ? row.archive_order : null,
};
}
@@ -162,3 +163,35 @@ export async function PUT(request: Request, context: RouteContext) {
return NextResponse.json({ error: errorMessage(error, "星盘更新失败") }, { status: 500 });
}
}
export async function PATCH(request: Request, context: RouteContext) {
try {
const { id } = await context.params;
if (id === "self") return NextResponse.json({ error: "本人不能排序" }, { status: 403 });
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
if (!isChartSubjectId(id)) return NextResponse.json({ error: "星盘不存在" }, { status: 404 });
const body = await request.json().catch(() => null) as { archive_order?: unknown } | null;
const order = body?.archive_order;
if (typeof order !== "number" || !Number.isInteger(order) || order < 0 || order > 1_000_000) {
return NextResponse.json({ error: "排序无效" }, { status: 400 });
}
const { data, error } = await supabase
.from("chart_profiles")
.update({ archive_order: order })
.eq("id", id)
.eq("user_id", user.id)
.eq("role", "other")
.select("id")
.maybeSingle();
if (error) throw error;
if (!data) return NextResponse.json({ error: "星盘不存在或无权更新" }, { status: 404 });
return NextResponse.json({ ok: true, archive_order: order });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: errorMessage(error, "排序没有保存") }, { status: 500 });
}
}
+4 -1
View File
@@ -4,6 +4,7 @@ import {
parseChartSubjectWrite,
subjectLimitReached,
} from "@/lib/chart-subject-write";
import { sortByArchiveOrder } from "@/lib/people-archive-order";
import { CHART_SUBJECT_API_SELECT, MAX_SAVED_SUBJECTS } from "@/lib/subject-birth";
import { scheduleConsultPacketWarm } from "@/lib/consult-packet-warm";
import { createServerSupabaseClient } from "@/lib/supabase/server";
@@ -40,6 +41,7 @@ function toClientProfile(row: Record<string, unknown>) {
profile: libraryProfileFromSubjectRow(row),
updated_at: row.updated_at,
created_at: row.created_at,
archive_order: typeof row.archive_order === "number" ? row.archive_order : null,
};
}
@@ -57,7 +59,8 @@ export async function GET() {
.order("updated_at", { ascending: false });
if (error) throw error;
const profiles = (data ?? []).map((row: unknown) => toClientProfile(row as Record<string, unknown>));
const listed = ((data ?? []) as ReadonlyArray<Record<string, unknown>>).map((row) => toClientProfile(row));
const profiles = sortByArchiveOrder(listed, (row) => row.archive_order);
return NextResponse.json({
profiles,
limit: MAX_SAVED_SUBJECTS,
+53 -4
View File
@@ -4719,10 +4719,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.synastry-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.synastry-field > span { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.synastry-times {
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 6px;
color: var(--color-ink-tertiary);
font-family: var(--font-display);
font-size: var(--type-title-lg);
}
.synastry-dd { position: relative; min-width: 0; }
@@ -4806,7 +4807,10 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
.synastry-dd-add.is-full a { margin-left: auto; color: var(--color-ink-secondary); font-size: var(--type-caption); }
.synastry-go { display: flex; flex-direction: column; gap: var(--space-2); align-items: center; text-align: center; }
.synastry-go .button-primary { min-width: 200px; }
.synastry-start { margin-inline: auto; min-width: 200px; }
.synastry-add-person { justify-content: flex-start; min-height: 44px; }
.synastry-add-note { margin: 0; display: flex; gap: var(--space-2); align-items: baseline; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
.synastry-add-note a { margin-left: auto; color: var(--color-ink-secondary); }
.synastry-cost { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
.synastry-dialog-back {
@@ -4867,7 +4871,8 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
@media (max-width: 767px) {
.synastry-pick { grid-template-columns: minmax(0, 1fr); }
.synastry-times { display: none; }
.synastry-times { justify-self: center; padding-bottom: 0; }
.synastry-go { align-items: center; }
.synastry-add-grid { grid-template-columns: 1fr; }
}
@@ -6831,6 +6836,7 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
}
.people-archive-rows {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-2);
max-height: min(70vh, 640px);
margin: 0;
@@ -6841,6 +6847,8 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
list-style: none;
-webkit-overflow-scrolling: touch;
}
.people-archive-sort-item { min-width: 0; }
.people-archive-drag { min-width: 44px; min-height: 44px; }
.people-archive-row {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
@@ -6859,7 +6867,48 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
text-align: left;
cursor: pointer;
}
.people-archive-row[data-slot="card"] {
position: relative;
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto 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);
overflow: visible;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
color: var(--color-ink);
box-shadow: none;
--card-spacing: 0px;
}
.people-archive-row:hover { background: var(--color-canvas-muted); }
.people-archive-row-hit {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
min-height: 44px;
border: 0;
background: transparent;
}
.people-archive-row [data-slot="card-action"] {
position: relative;
z-index: 1;
grid-column: 4;
grid-row: 1 / span 2;
display: flex;
align-items: center;
align-self: center;
justify-self: end;
}
.people-archive-more { position: relative; z-index: 1; min-width: 44px; min-height: 44px; }
.people-archive-dialog { width: min(640px, 100%); }
/* D2: the person the rest of the app is currently reading. */
.people-archive-row.is-current,
.people-archive-row.is-current:hover {
@@ -2,6 +2,7 @@
import type { Dispatch, FormEvent, ReactNode, SetStateAction } from "react";
import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field";
import { Button } from "@/components/ui/button";
import { ProfileFields } from "@/components/profile-fields";
import { PersonRelationField } from "@/components/person-relation-field";
import { ProfileGenderField } from "@/components/profile-gender-field";
@@ -77,8 +78,8 @@ export function ChartProfileForm({
) : null}
{notice ? <p className="chart-birth-edit-notice" role="status">{notice}</p> : null}
<div className="dialog-actions">
{onCancel ? <button className="button-secondary" type="button" onClick={onCancel}>{cancelLabel}</button> : null}
<button className="button-primary save-profile" type="submit" disabled={submitDisabled}>{submitLabel}</button>
{onCancel ? <Button className="button-secondary" type="button" variant="outline" onClick={onCancel}>{cancelLabel}</Button> : null}
<Button className="button-primary save-profile" type="submit" disabled={submitDisabled}>{submitLabel}</Button>
</div>
</form>
);
@@ -0,0 +1,40 @@
"use client";
import { useSyncExternalStore } from "react";
import type { PeopleArchiveSortable } from "@/components/people/people-archive-sortable";
type SortableModule = {
PeopleArchiveSortable: typeof PeopleArchiveSortable;
};
let loaded: SortableModule | null = null;
let started = false;
const listeners = new Set<() => void>();
function notify() {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void) {
listeners.add(listener);
if (!started) {
started = true;
void import("@/components/people/people-archive-sortable").then((mod) => {
loaded = { PeopleArchiveSortable: mod.PeopleArchiveSortable };
notify();
}).catch(() => {
started = false;
});
}
return () => listeners.delete(listener);
}
function snapshot() {
return loaded;
}
/** Client-only. The server snapshot stays null so the cards paint before dnd-kit arrives. */
export function usePeopleArchiveSortable(): SortableModule | null {
return useSyncExternalStore(subscribe, snapshot, () => null);
}
@@ -0,0 +1,98 @@
"use client";
import type { ReactNode } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { Button } from "@/components/ui/button";
import { moveArchiveIds } from "@/lib/people-archive-order";
function SortableArchiveCard<T extends { id: string }>({
person,
name,
children,
}: {
readonly person: T;
readonly name: string;
readonly children: (drag: ReactNode) => ReactNode;
}) {
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
id: person.id,
});
const drag = (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="people-archive-drag"
aria-label={`${name} 拖动排序`}
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
>
<GripVertical aria-hidden="true" />
</Button>
);
return (
<div
ref={setNodeRef}
className="people-archive-sort-item"
style={{ transform: CSS.Transform.toString(transform), transition }}
>
{children(drag)}
</div>
);
}
export function PeopleArchiveSortable<T extends { id: string }>({
people,
nameOf,
onReorder,
renderPerson,
}: {
readonly people: readonly T[];
readonly nameOf: (person: T) => string;
readonly onReorder: (orderedIds: readonly string[]) => void;
readonly renderPerson: (person: T, drag: ReactNode) => ReactNode;
}) {
const rows = people.filter((person) => person.id !== "self");
const ids = rows.map((person) => person.id);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function onDragEnd(event: DragEndEvent) {
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : "";
if (!overId || activeId === overId || activeId === "self" || overId === "self") return;
onReorder(moveArchiveIds(ids, activeId, overId));
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={ids} strategy={rectSortingStrategy}>
{rows.map((person) => (
<SortableArchiveCard key={person.id} person={person} name={nameOf(person)}>
{(drag) => renderPerson(person, drag)}
</SortableArchiveCard>
))}
</SortableContext>
</DndContext>
);
}
+235 -83
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState, type Dispatch, type FormEvent, type SetStateAction } from "react";
import { FileText, MessageSquareText, Orbit } from "lucide-react";
import { useEffect, useRef, useState, type Dispatch, type FormEvent, type ReactNode, type SetStateAction } from "react";
import { ChevronLeft, ChevronRight, FileText, MessageSquareText, MoreHorizontal, Orbit, Plus } from "lucide-react";
import { newChatHref } from "@/lib/chat-session-url";
import { effectiveBirthDate } from "@/lib/effective-birth-date";
@@ -32,14 +32,39 @@ import {
peopleRowMeta,
peopleTimeRectified,
} from "@/lib/people-archive-view";
import { archiveOrdersForIds, sortByArchiveOrder } from "@/lib/people-archive-order";
import { usePeopleArchiveSortable } from "@/components/people/people-archive-sortable-loader";
import { MAX_OTHER_SUBJECTS, MAX_SAVED_SUBJECTS } from "@/lib/subject-birth";
import { profileGenderLabel } from "@/lib/profile-gender";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardAction } from "@/components/ui/card";
import {
Dialog,
DialogContent,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export type PersonRow = {
readonly id: string;
readonly role: "self" | "other";
readonly profile: Profile;
readonly incomplete: boolean;
readonly archiveOrder?: number | null;
};
export type Usage = { id: string; sessions: number; reports: number; synastry?: number };
@@ -75,6 +100,10 @@ function replaceDetailParam(id: string | null) {
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
}
function archiveOrderOf(value: unknown): number | null {
return typeof value === "number" ? value : null;
}
export function PeoplePage() {
const subjectId = useCurrentSubject();
const [people, setPeople] = useState<PersonRow[]>([]);
@@ -97,13 +126,21 @@ export function PeoplePage() {
fetch("/api/chart-profiles", { cache: "no-store" }),
]);
if (!response.ok) throw new Error("archive_unavailable");
const payload = await response.json() as { profiles?: Array<{ id?: string; profile?: unknown }> };
const payload = await response.json() as {
profiles?: Array<{ id?: string; profile?: unknown; archive_order?: unknown }>;
};
const self = readProfile(account.profile);
const others = (payload.profiles ?? []).flatMap((row) => {
const others = sortByArchiveOrder((payload.profiles ?? []).flatMap((row) => {
if (!row.id) return [];
const profile = readProfile(row.profile);
return [{ id: row.id, role: "other" as const, profile, incomplete: incompleteProfile(profile) }];
});
return [{
id: row.id,
role: "other" as const,
profile,
incomplete: incompleteProfile(profile),
archiveOrder: archiveOrderOf(row.archive_order),
}];
}));
accountIdRef.current = account.user.id;
bindCurrentSubjectAccount(account.user.id, others.map((person) => person.id));
const nextPeople: PersonRow[] = [
@@ -189,7 +226,7 @@ export function PeoplePage() {
setEditing(false);
setPendingDelete(null);
if (pushedDetail.current) {
// popstate brings the list back, so Back and 「‹ 返回」 are one path.
// popstate brings the list back, so Back and the back control are one path.
window.history.back();
return;
}
@@ -276,16 +313,22 @@ export function PeoplePage() {
}
}
async function askDelete() {
if (!selected || selected.role !== "other") return;
async function askDelete(personId?: string) {
const id = personId || selected?.id;
const person = people.find((item) => item.id === id);
if (!person || person.role !== "other") return;
if (personId && personId !== selectedId) {
setSelectedId(person.id);
setEditing(false);
}
setPendingDelete(null);
try {
const response = await fetch(`/api/chart-profiles/${selected.id}`, { cache: "no-store" });
const response = await fetch(`/api/chart-profiles/${person.id}`, { cache: "no-store" });
const payload = await response.json().catch(() => null) as { usage?: Omit<Usage, "id"> } | null;
const usage = payload?.usage;
if (!response.ok || !usage || !Number.isInteger(usage.sessions) || usage.sessions < 0
|| !Number.isInteger(usage.reports) || usage.reports < 0) throw new Error("usage_unavailable");
setPendingDelete({ id: selected.id, ...usage });
setPendingDelete({ id: person.id, ...usage });
} catch {
notifyError(DELETE_USAGE_UNAVAILABLE);
}
@@ -306,6 +349,36 @@ export function PeoplePage() {
await load();
}
async function persistArchiveOrder(orderedIds: readonly string[]) {
const ids = orderedIds.filter((id) => id !== "self");
const previous = people;
const orders = archiveOrdersForIds(ids);
setPeople((current) => {
const selfRows = current.filter((person) => person.role === "self");
const byId = new Map(current.filter((person) => person.role === "other").map((person) => [person.id, person]));
const others = ids.flatMap((id) => {
const person = byId.get(id);
return person ? [{ ...person, archiveOrder: orders.get(id) ?? null }] : [];
});
return [...selfRows, ...others];
});
const changed = ids.filter((id) => (previous.find((person) => person.id === id)?.archiveOrder ?? null) !== (orders.get(id) ?? null));
try {
const saved = await Promise.all(changed.map(async (id) => {
const response = await fetch(`/api/chart-profiles/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ archive_order: orders.get(id) ?? 0 }),
});
return response.ok;
}));
if (saved.some((ok) => !ok)) throw new Error("order");
} catch {
setPeople(previous);
notifyError("排序没有保存");
}
}
function openPerson(id: string, href: string) {
setCurrentSubject(id);
window.location.assign(href);
@@ -317,7 +390,7 @@ export function PeoplePage() {
{status === "failed" ? (
<section className="chart-page-message">
<p>人物列表暂时读不出来</p>
<button className="button-primary" type="button" onClick={() => void load()}>重试</button>
<Button className="button-primary" type="button" onClick={() => void load()}>重试</Button>
</section>
) : null}
{status === "ready" ? (
@@ -344,6 +417,7 @@ export function PeoplePage() {
onCancelDelete={() => setPendingDelete(null)}
confirmDelete={confirmDelete}
openPerson={openPerson}
onReorder={(orderedIds) => void persistArchiveOrder(orderedIds)}
/>
) : null}
</SecondaryPageShell>
@@ -378,12 +452,67 @@ export type PeopleArchiveViewProps = {
readonly onDraftChange: Dispatch<SetStateAction<Profile>>;
readonly saveOther: (event: FormEvent<HTMLFormElement>) => Promise<void>;
readonly saveSelf: (event: FormEvent<HTMLFormElement>) => Promise<void>;
readonly askDelete: () => Promise<void>;
readonly askDelete: (personId?: string) => Promise<void>;
readonly onCancelDelete: () => void;
readonly confirmDelete: () => Promise<void>;
readonly openPerson: (id: string, href: string) => void;
readonly onReorder?: (orderedIds: readonly string[]) => void;
};
function PersonCard(props: {
readonly person: PersonRow;
readonly index: number;
readonly current: boolean;
readonly open: boolean;
readonly drag: ReactNode | null;
readonly onSelect: (id: string) => void;
readonly onEdit: (profile: Profile) => void;
readonly onDelete: (id: string) => void;
}) {
const { person } = props;
const name = peopleDisplayName(person.profile, person.role);
return (
<Card
className={`people-archive-row${props.current ? " is-current" : ""}${props.open ? " is-open" : ""}`}
aria-current={props.current ? "true" : undefined}
data-onboarding-enter={`archive-${person.id}`}
data-onboarding-index={props.index}
>
<PeopleAvatar person={person} />
<span className="people-archive-row-name">
<b>{name}</b>
{person.role === "self"
? <span className="people-archive-tag">本人</span>
: <span className="people-archive-tag">{personRelationLabel(person.profile.relation)}</span>}
</span>
<small className="people-archive-row-meta">
{person.incomplete ? "这份资料还不完整" : peopleRowMeta(person.profile)}
</small>
<ChevronRight className="people-archive-chevron" aria-hidden="true" />
<CardAction>
{props.drag}
<DropdownMenu>
<DropdownMenuTrigger className="people-archive-more" aria-label={`${name} 更多操作`}>
<MoreHorizontal aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => { props.onSelect(person.id); props.onEdit(person.profile); }}>编辑</DropdownMenuItem>
{person.role === "other" ? <DropdownMenuItem onClick={() => props.onDelete(person.id)}>删除</DropdownMenuItem> : null}
</DropdownMenuContent>
</DropdownMenu>
</CardAction>
<Button
type="button"
variant="ghost"
className="people-archive-row-hit"
aria-label={`${name} 打开档案`}
data-onboarding-press=""
onClick={() => props.onSelect(person.id)}
/>
</Card>
);
}
/**
* D1–D4. Phone (< 1024px): `data-view` shows either the list or the detail.
* Desktop: both columns, `data-view` ignored by CSS.
@@ -411,72 +540,88 @@ export function PeopleArchiveView({
onCancelDelete,
confirmDelete,
openPerson,
onReorder,
}: PeopleArchiveViewProps) {
const sortable = usePeopleArchiveSortable();
const remaining = MAX_SAVED_SUBJECTS - people.length;
const editingSelf = editing && selectedId !== "" && selected?.role === "self";
const lockReportedBirthTime = Boolean(
editing && selectedId !== "" && selected && selected.role === "other" && reportedTimeStored(selected.profile),
);
const selfPerson = people.find((person) => person.role === "self") ?? null;
const otherPeople = people.filter((person) => person.role === "other");
const cardProps = {
subjectId,
selectedId: selected?.id,
editing,
onSelect,
onEdit,
onDelete: (id: string) => void askDelete(id),
};
function renderCard(person: PersonRow, index: number, drag: ReactNode | null) {
return (
<PersonCard
person={person}
index={index}
current={person.id === cardProps.subjectId}
open={person.id === cardProps.selectedId && !cardProps.editing}
drag={drag}
onSelect={cardProps.onSelect}
onEdit={cardProps.onEdit}
onDelete={cardProps.onDelete}
/>
);
}
return (
<div className="people-archive" data-view={view}>
<section className="people-archive-list" aria-label="星盘档案">
<ul className="people-archive-rows">
{people.map((person, index) => {
const current = person.id === subjectId;
const open = person.id === selected?.id && !editing;
return (
<li key={person.id}>
<button
type="button"
className={`people-archive-row${current ? " is-current" : ""}${open ? " is-open" : ""}`}
aria-current={current ? "true" : undefined}
data-onboarding-press=""
data-onboarding-enter={`archive-${person.id}`}
data-onboarding-index={index}
onClick={() => onSelect(person.id)}
>
<PeopleAvatar person={person} />
<span className="people-archive-row-name">
<b>{peopleDisplayName(person.profile, person.role)}</b>
{person.role === "self"
? <span className="people-archive-tag">本人</span>
: <span className="people-archive-tag">{personRelationLabel(person.profile.relation)}</span>}
</span>
<small className="people-archive-row-meta">
{person.incomplete ? "这份资料还不完整" : peopleRowMeta(person.profile)}
</small>
<span className="people-archive-chevron" aria-hidden="true">›</span>
</button>
</li>
);
})}
</ul>
<div className="people-archive-rows">
{selfPerson ? (
<div className="people-archive-sort-item">{renderCard(selfPerson, 0, null)}</div>
) : null}
{sortable ? (
<sortable.PeopleArchiveSortable
people={otherPeople}
nameOf={(person) => peopleDisplayName(person.profile, person.role)}
onReorder={onReorder ?? (() => {})}
renderPerson={(person, drag) => renderCard(person, otherPeople.findIndex((item) => item.id === person.id) + 1, drag)}
/>
) : otherPeople.map((person, index) => (
<div className="people-archive-sort-item" key={person.id}>{renderCard(person, index + 1, null)}</div>
))}
</div>
{/* At the limit the card still answers a tap, with a toast (2026-09-30). */}
<button className="people-archive-add" type="button" aria-disabled={atLimit || undefined} onClick={onAdd}>
+ 添加一个人
</button>
<Button className="people-archive-add" type="button" variant="outline" aria-disabled={atLimit || undefined} onClick={onAdd}>
<Plus aria-hidden="true" /> 添加一个人
</Button>
<p className="people-archive-hint">{atLimit ? LIMIT_COPY : `还可以保存 ${remaining} 个人`}</p>
</section>
<section className="people-archive-detail" aria-label="档案详情">
<button type="button" className="people-archive-back" onClick={onBack}>
<span aria-hidden="true">‹</span> 返回
</button>
<Button type="button" variant="ghost" className="people-archive-back" onClick={onBack}>
<ChevronLeft aria-hidden="true" /> 返回
</Button>
{editing ? (
<ChartProfileForm
title={editingSelf ? "编辑我的资料" : selectedId ? "编辑人物" : "添加一个人"}
description="称呼和出生资料。"
value={draft}
onChange={onDraftChange}
nameInputId="people-archive-name"
showAyanamsa
showGender
showRelation={!editingSelf}
lockReportedBirthTime={lockReportedBirthTime}
onSubmit={(event) => void (editingSelf ? saveSelf(event) : saveOther(event))}
onCancel={onCancelEdit}
submitLabel={saving ? "保存中" : "保存"}
submitDisabled={saving || Boolean(missingOtherProfileStep(draft))}
/>
<Dialog open onOpenChange={(next) => { if (!next) onCancelEdit(); }}>
<DialogContent className="people-archive-dialog sm:max-w-xl max-h-[85vh] overflow-y-auto">
<ChartProfileForm
title={editingSelf ? "编辑我的资料" : selectedId ? "编辑人物" : "添加一个人"}
description="称呼和出生资料。"
value={draft}
onChange={onDraftChange}
nameInputId="people-archive-name"
showAyanamsa
showGender
showRelation={!editingSelf}
lockReportedBirthTime={lockReportedBirthTime}
onSubmit={(event) => void (editingSelf ? saveSelf(event) : saveOther(event))}
onCancel={onCancelEdit}
submitLabel={saving ? "保存中" : "保存"}
submitDisabled={saving || Boolean(missingOtherProfileStep(draft))}
/>
</DialogContent>
</Dialog>
) : selected ? (
<div className="people-archive-card" data-onboarding-enter={`archive-card-${selected.id}`} data-onboarding-index={0}>
<header className="people-archive-head">
@@ -506,43 +651,50 @@ export function PeopleArchiveView({
<dd>{profileGenderLabel(selected.profile.gender)}</dd>
</dl>
<div className="people-archive-actions">
<button type="button" onClick={() => openPerson(selected.id, newChatHref())}>
<Button type="button" variant="outline" onClick={() => openPerson(selected.id, newChatHref())}>
<MessageSquareText size={18} strokeWidth={1.75} aria-hidden="true" />和 TA 对话
</button>
<button type="button" onClick={() => openPerson(selected.id, "/chart")}>
</Button>
<Button type="button" variant="outline" onClick={() => openPerson(selected.id, "/chart")}>
<Orbit size={18} strokeWidth={1.75} aria-hidden="true" />看星盘
</button>
<button type="button" onClick={() => openPerson(selected.id, "/reports")}>
</Button>
<Button type="button" variant="outline" onClick={() => openPerson(selected.id, "/reports")}>
<FileText size={18} strokeWidth={1.75} aria-hidden="true" />生成报告
</button>
</Button>
</div>
{selected.role === "other" ? (
people.some((person) => person.role === "self" && !person.incomplete)
? <button type="button" className="people-archive-secondary" data-onboarding-anchor="syn-ready" onClick={() => window.location.assign(`/synastry?with=${selected.id}`)}>和我合盘</button>
? <Button type="button" variant="outline" className="people-archive-secondary" data-onboarding-anchor="syn-ready" onClick={() => window.location.assign(`/synastry?with=${selected.id}`)}>和我合盘</Button>
: <p className="people-archive-synastry-later">加上你自己后可合盘</p>
) : null}
{/* BUG-1081: 本人 is edited here too; only deleting stays off. */}
<div className="people-archive-foot">
{selected.role === "self" ? <p>本人不能删除</p> : null}
<button type="button" className="people-archive-secondary" onClick={() => onEdit(selected.profile)}>编辑</button>
<Button type="button" variant="outline" className="people-archive-secondary" onClick={() => onEdit(selected.profile)}>编辑</Button>
</div>
</div>
) : null}
{/* D3: deleting someone lives at the bottom of their edit view; 本人 never gets it. */}
{editing && selectedId && selected?.role === "other" ? (
<div className="people-archive-danger">
<button type="button" className="button-primary danger-primary" onClick={() => void askDelete()}>删除这个人</button>
{pendingDelete?.id === selected.id && selected.role === "other" ? (
<div role="alertdialog" aria-label="确认删除" className="people-archive-confirm">
<p>将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告{pendingDelete.synastry ? `和 ${pendingDelete.synastry} 份合盘` : ""},无法恢复。</p>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={onCancelDelete}>取消</button>
<button type="button" className="button-primary danger-primary" onClick={() => void confirmDelete()}>确认删除</button>
</div>
</div>
) : null}
<Button type="button" className="button-primary danger-primary" onClick={() => void askDelete()}>删除这个人</Button>
</div>
) : null}
{selected && pendingDelete?.id === selected.id && selected.role === "other" ? (
<AlertDialog open onOpenChange={(next) => { if (!next) onCancelDelete(); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告{pendingDelete.synastry ? `和 ${pendingDelete.synastry} 份合盘` : ""},无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction className="danger-primary" onClick={() => void confirmDelete()}>确认删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</section>
</div>
);
@@ -1,5 +1,6 @@
"use client";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
PERSON_RELATIONS,
PERSON_RELATION_LABELS,
@@ -25,24 +26,30 @@ export function PersonRelationField({
return (
<div className="profile-gender-field">
<span className="profile-gender-title" id={titleId}>{PERSON_RELATION_TITLE}</span>
<div className="profile-gender-options" role="radiogroup" aria-labelledby={titleId}>
<ToggleGroup
className="profile-gender-options"
aria-labelledby={titleId}
value={[shown]}
onValueChange={(next) => {
const picked = next[0];
if (PERSON_RELATIONS.some((option) => option === picked)) onChange(picked as PersonRelation);
}}
>
{PERSON_RELATIONS.map((option) => {
const selected = option === shown;
return (
<button
<ToggleGroupItem
key={option}
value={option}
className={`profile-gender-option${selected ? " is-selected" : ""}`}
type="button"
role="radio"
aria-checked={selected}
data-onboarding-press=""
onClick={() => onChange(option)}
>
{PERSON_RELATION_LABELS[option]}
</button>
</ToggleGroupItem>
);
})}
</div>
</ToggleGroup>
</div>
);
}
@@ -1,5 +1,6 @@
"use client";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
PROFILE_GENDER_HINT,
PROFILE_GENDER_LABELS,
@@ -8,12 +9,26 @@ import {
type ProfileGender,
} from "@/lib/profile-gender";
const OPTIONS: ReadonlyArray<{ readonly value: ProfileGender | null; readonly label: string }> = [
{ value: "female", label: PROFILE_GENDER_LABELS.female },
{ value: "male", label: PROFILE_GENDER_LABELS.male },
{ value: null, label: PROFILE_GENDER_UNSET_LABEL },
const UNSET = "unset";
const OPTIONS: ReadonlyArray<{ readonly value: string; readonly stored: ProfileGender | null; readonly label: string }> = [
{ value: "female", stored: "female", label: PROFILE_GENDER_LABELS.female },
{ value: "male", stored: "male", label: PROFILE_GENDER_LABELS.male },
{ value: UNSET, stored: null, label: PROFILE_GENDER_UNSET_LABEL },
];
/** Clicking the selected gender again does nothing. 不填 is stored as null. */
export function genderChoice(
current: ProfileGender | null,
next: readonly string[],
): ProfileGender | null | undefined {
const value = next[0];
if (value !== "female" && value !== "male" && value !== UNSET) return undefined;
const chosen = value === UNSET ? null : value;
if (chosen === current) return undefined;
return chosen;
}
/**
* Optional gender (TASK-consult-gender-optional-20260927): 女 / 男 / 不填.
* 不填 is stored as null. One line under the choices says what it is for.
@@ -32,29 +47,35 @@ export function ProfileGenderField({
const titleId = `${idPrefix}-gender-title`;
const hintId = `${idPrefix}-gender-hint`;
const current = value ?? null;
const shown = current ?? UNSET;
return (
<div className="profile-gender-field">
<span className="profile-gender-title" id={titleId}>{PROFILE_GENDER_TITLE}</span>
<div className="profile-gender-options" role="radiogroup" aria-labelledby={titleId} aria-describedby={hintId}>
<ToggleGroup
className="profile-gender-options"
aria-labelledby={titleId}
aria-describedby={hintId}
value={[shown]}
disabled={disabled}
onValueChange={(next) => {
const choice = genderChoice(current, next);
if (choice !== undefined) onChange(choice);
}}
>
{OPTIONS.map((option) => {
const selected = option.value === current;
const selected = option.value === shown;
return (
<button
key={option.label}
<ToggleGroupItem
key={option.value}
value={option.value}
className={`profile-gender-option${selected ? " is-selected" : ""}`}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={() => {
if (!selected) onChange(option.value);
}}
>
{option.label}
</button>
</ToggleGroupItem>
);
})}
</div>
</ToggleGroup>
<p className="profile-gender-hint" id={hintId}>{PROFILE_GENDER_HINT}</p>
</div>
);
@@ -7,6 +7,14 @@ import { chinaProvinces } from "@/lib/china-birth-place";
import { birthTimeSourceDefaults } from "@/lib/birth-time-intake-model";
import { emptyProfile, type Profile } from "@/lib/home-types";
import { PROFILE_GENDER_LABELS, parseProfileGender, type ProfileGender } from "@/lib/profile-gender";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type CityHit = {
key: string;
@@ -86,20 +94,14 @@ export function SynastryAddDialog(props: {
}
return (
<div className="synastry-dialog-back" onClick={props.onCancel}>
<div
className="synastry-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="synastry-add-title"
onClick={(event) => event.stopPropagation()}
>
<h2 id="synastry-add-title">新增星盘</h2>
<Dialog open onOpenChange={(next) => { if (!next) props.onCancel(); }}>
<DialogContent className="synastry-dialog sm:max-w-md">
<DialogTitle id="synastry-add-title">新增星盘</DialogTitle>
<form onSubmit={props.onSubmit}>
<div className="synastry-add-grid">
<label className="full" htmlFor="synastry-new-name">
<span>称呼</span>
<input
<Input
id="synastry-new-name"
required
maxLength={80}
@@ -110,14 +112,22 @@ export function SynastryAddDialog(props: {
</label>
<div className="full">
<span id="synastry-new-gender">性别</span>
<div className="synastry-seg" role="group" aria-labelledby="synastry-new-gender">
<button type="button" aria-pressed={gender === "male"} onClick={() => chooseGender("male")}>{PROFILE_GENDER_LABELS.male}</button>
<button type="button" aria-pressed={gender === "female"} onClick={() => chooseGender("female")}>{PROFILE_GENDER_LABELS.female}</button>
</div>
<ToggleGroup
className="synastry-seg"
aria-labelledby="synastry-new-gender"
value={gender ? [gender] : []}
onValueChange={(next) => {
const picked = next[0];
if (picked === "male" || picked === "female") chooseGender(picked);
}}
>
<ToggleGroupItem value="male" aria-pressed={gender === "male"}>{PROFILE_GENDER_LABELS.male}</ToggleGroupItem>
<ToggleGroupItem value="female" aria-pressed={gender === "female"}>{PROFILE_GENDER_LABELS.female}</ToggleGroupItem>
</ToggleGroup>
</div>
<label htmlFor="synastry-new-date">
<span>出生日期</span>
<input
<Input
id="synastry-new-date"
inputMode="numeric"
placeholder="1993-08-21"
@@ -128,7 +138,7 @@ export function SynastryAddDialog(props: {
</label>
<label htmlFor="synastry-new-time">
<span>出生时间</span>
<input
<Input
id="synastry-new-time"
inputMode="numeric"
placeholder="14:30"
@@ -146,7 +156,7 @@ export function SynastryAddDialog(props: {
<div className="full">
<label htmlFor="synastry-new-place">
<span>出生地</span>
<input
<Input
id="synastry-new-place"
placeholder="南京"
autoComplete="off"
@@ -172,7 +182,7 @@ export function SynastryAddDialog(props: {
<ul className="synastry-place-hits">
{hits.map((hit) => (
<li key={hit.key}>
<button type="button" onClick={() => chooseCity(hit)}>{hit.label}</button>
<Button type="button" variant="ghost" onClick={() => chooseCity(hit)}>{hit.label}</Button>
</li>
))}
</ul>
@@ -180,13 +190,13 @@ export function SynastryAddDialog(props: {
</div>
</div>
<div className="synastry-add-acts">
<button className="button-secondary" type="button" onClick={props.onCancel}>取消</button>
<button className="button-primary" type="submit" disabled={props.busy || props.submitDisabled}>
<Button className="button-secondary" type="button" variant="outline" onClick={props.onCancel}>取消</Button>
<Button className="button-primary" type="submit" disabled={props.busy || props.submitDisabled}>
{props.busy ? "保存中" : "保存并选中"}
</button>
</Button>
</div>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,10 +1,12 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { HeartHandshake, Plus, X } from "lucide-react";
import { emptySynastryDraft, SynastryAddDialog } from "@/components/synastry/synastry-add-dialog";
import { SecondaryHeader } from "@/components/secondary-header";
import { accountDialogHref } from "@/lib/account-dialog-intent";
import { effectiveBirthDate } from "@/lib/effective-birth-date";
import { fetchAccount } from "@/lib/home-cloud-sync";
import type { Profile } from "@/lib/home-types";
@@ -12,27 +14,52 @@ import { missingOtherProfileStep, readProfile } from "@/lib/home-profile";
import { peopleCityLabel, peopleDisplayName } from "@/lib/people-archive-view";
import { PROFILE_GENDER_LABELS, parseProfileGender } from "@/lib/profile-gender";
import { MAX_SAVED_SUBJECTS } from "@/lib/subject-birth";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Person = { id: string; role: "self" | "other"; name: string; meta: string; incomplete: boolean };
type Earlier = { id: string; cardSummary?: string; createdAt?: string };
type Slot = "left" | "right";
export function synastryChargeCopy(creditCost: number, balance: number): { short: boolean; text: string } {
if (balance < creditCost) {
return { short: true, text: `需要 ${creditCost} 点,余额只有 ${balance} 点。` };
}
return { short: false, text: `会用掉 ${creditCost} 点,余额 ${balance} 点。` };
}
export function SynastryPicker() {
const [people, setPeople] = useState<Person[]>([]);
const [left, setLeft] = useState("self");
const [right, setRight] = useState("");
const [open, setOpen] = useState<Slot | null>(null);
const [adding, setAdding] = useState<Slot | null>(null);
const [draft, setDraft] = useState<Profile>(emptySynastryDraft);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [creditCost, setCreditCost] = useState<number | null>(null);
const [balance, setBalance] = useState<number | null>(null);
const [pricingNote, setPricingNote] = useState("");
const [readyCount, setReadyCount] = useState(0);
const [pairGenerating, setPairGenerating] = useState(false);
const [earlier, setEarlier] = useState<Earlier[]>([]);
const [access, setAccess] = useState<"pending" | "open" | "closed" | "unreachable">("pending");
const pickRef = useRef<HTMLDivElement>(null);
const [confirming, setConfirming] = useState(false);
async function load(prefer?: { id: string; slot: Slot }) {
let priceResponse: Response;
@@ -63,6 +90,7 @@ export function SynastryPicker() {
fetchAccount(),
fetch("/api/chart-profiles", { cache: "no-store" }),
]);
setBalance(typeof account.credits === "number" ? account.credits : null);
if (!response.ok) throw new Error("archive_unavailable");
const payload = await response.json() as { profiles?: Array<{ id?: string; profile?: unknown }> };
const self = readProfile(account.profile);
@@ -130,27 +158,6 @@ export function SynastryPicker() {
};
}, [access, left, right]);
useEffect(() => {
if (!open && !adding) return;
function onKey(event: KeyboardEvent) {
if (event.key !== "Escape") return;
setOpen(null);
setAdding(null);
}
function onPointer(event: PointerEvent) {
if (adding) return;
const target = event.target;
if (target instanceof Node && pickRef.current?.contains(target)) return;
setOpen(null);
}
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onPointer);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onPointer);
};
}, [open, adding]);
async function addPerson(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!adding || missingOtherProfileStep(draft)) return;
@@ -204,54 +211,53 @@ export function SynastryPicker() {
}
}
function askStart() {
if (busy || !ready) return;
if (creditCost === null || balance === null) {
setMessage(pricingNote || "计费配置不可用");
return;
}
setConfirming(true);
}
const archiveFull = people.filter((person) => person.role === "other").length >= MAX_SAVED_SUBJECTS - 1;
const ready = Boolean(left && right && left !== right) && !personIncomplete(people, left) && !personIncomplete(people, right);
const heading = (
<div className="synastry-page-heading">
<h1>婚恋合盘</h1>
<p className="synastry-page-sub">看这段感情往后的走向,和两人之间的助力与考验。</p>
</div>
);
const charge = confirming && creditCost !== null && balance !== null ? synastryChargeCopy(creditCost, balance) : null;
return (
<>
<SecondaryHeader title="婚恋合盘" />
<div className="synastry-picker">
{heading}
{access === "closed" ? <p>合盘服务暂未开放</p> : null}
{access === "unreachable" ? <p role="status">合盘服务暂时连不上,请稍后再试</p> : null}
{access === "open" ? (
<>
<div className="synastry-pick" ref={pickRef}>
<PersonMenu
<div className="synastry-pick">
<PersonSelect
label="第一个人"
slot="left"
people={people}
value={left}
otherId={right}
open={open === "left"}
archiveFull={archiveFull}
onToggle={() => setOpen((current) => current === "left" ? null : "left")}
onChange={(id) => { setLeft(id); setOpen(null); }}
onAdd={() => { setOpen(null); setDraft(emptySynastryDraft()); setAdding("left"); }}
onChange={setLeft}
onAdd={() => { setDraft(emptySynastryDraft()); setAdding("left"); }}
/>
<div className="synastry-times" aria-hidden="true">×</div>
<PersonMenu
<X className="synastry-times" aria-hidden="true" />
<PersonSelect
label="第二个人"
slot="right"
people={people}
value={right}
otherId={left}
open={open === "right"}
archiveFull={archiveFull}
placeholder="选择一个人"
onToggle={() => setOpen((current) => current === "right" ? null : "right")}
onChange={(id) => { setRight(id); setOpen(null); }}
onAdd={() => { setOpen(null); setDraft(emptySynastryDraft()); setAdding("right"); }}
onChange={setRight}
onAdd={() => { setDraft(emptySynastryDraft()); setAdding("right"); }}
/>
</div>
<div className="synastry-go">
<button type="button" className="button-primary" onClick={() => void createReport()} disabled={busy || !ready}>开始合盘</button>
<Button type="button" className="synastry-start" onClick={askStart} disabled={busy || !ready}>
<HeartHandshake aria-hidden="true" />开始合盘
</Button>
<SynastryGoNotes creditCost={creditCost} pricingNote={pricingNote} readyCount={readyCount} generating={pairGenerating} />
{message ? <p className="synastry-cost" role="status">{message}</p> : null}
</div>
@@ -283,6 +289,28 @@ export function SynastryPicker() {
onSubmit={(event) => void addPerson(event)}
/>
) : null}
{charge && creditCost !== null && balance !== null ? (
<AlertDialog open onOpenChange={(next) => { if (!next) setConfirming(false); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>开始合盘</AlertDialogTitle>
<AlertDialogDescription>
{charge.short
? `需要 ${creditCost} 点,余额只有 ${balance} 点。`
: `会用掉 ${creditCost} 点,余额 ${balance} 点。`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
{charge.short ? (
<AlertDialogAction onClick={() => { window.location.assign(accountDialogHref("billing")); }}>去补点数</AlertDialogAction>
) : (
<AlertDialogAction onClick={() => { setConfirming(false); void createReport(); }}>开始</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</>
);
}
@@ -301,7 +329,7 @@ export function SynastryGoNotes(props: {
const again = synastryAgainNote(props.readyCount, props.creditCost, props.generating);
return (
<>
{props.creditCost !== null ? <p className="synastry-cost">生成一份完整合盘报告,用掉 {props.creditCost} 点。大约要几分钟。</p> : props.pricingNote ? <p className="synastry-cost">{props.pricingNote}</p> : null}
{props.creditCost === null && props.pricingNote ? <p className="synastry-cost">{props.pricingNote}</p> : null}
{again ? <p className="synastry-cost">{again}</p> : null}
</>
);
@@ -336,72 +364,53 @@ function historyWhen(value: string): string {
return date.getFullYear() === new Date().getFullYear() ? day : `${date.getFullYear()} 年 ${day}`;
}
function PersonMenu(props: {
function PersonSelect(props: {
label: string;
slot: Slot;
people: readonly Person[];
value: string;
otherId: string;
open: boolean;
archiveFull: boolean;
placeholder?: string;
onToggle: () => void;
onChange: (id: string) => void;
onAdd: () => void;
}) {
const selected = props.people.find((person) => person.id === props.value);
const shown = selected && !selected.incomplete ? selected : null;
const shown = selected && !selected.incomplete ? selected.name : "";
return (
<div className="synastry-field">
<span id={`synastry-${props.slot}-label`}>{props.label}</span>
<div className="synastry-dd">
<button
type="button"
className="synastry-dd-btn"
aria-haspopup="listbox"
aria-expanded={props.open}
onClick={props.onToggle}
>
{shown ? (
<span className="synastry-dd-main"><b>{shown.name}</b>{shown.meta ? <span className="ph">{shown.meta}</span> : null}</span>
) : (
<span className="ph">{props.placeholder || "选择一个人"}</span>
)}
</button>
{props.open ? (
<ul className="synastry-dd-list" role="listbox" aria-labelledby={`synastry-${props.slot}-label`}>
{props.people.map((person) => {
const taken = person.id === props.otherId;
const disabled = person.incomplete || taken;
const hint = person.incomplete ? "资料未齐" : taken ? "已在另一边" : person.meta;
return (
<li key={person.id}>
<button
type="button"
role="option"
aria-selected={person.id === props.value}
disabled={disabled}
onClick={() => props.onChange(person.id)}
>
<b>{person.name}</b>
{hint ? <small>{hint}</small> : null}
</button>
</li>
);
})}
{props.archiveFull ? (
<li className="synastry-dd-add is-full">
档案已满(最多 {MAX_SAVED_SUBJECTS} 人)
<a href="/people">去星盘档案删一个</a>
</li>
) : (
<li>
<button type="button" className="synastry-dd-add" onClick={props.onAdd}>+ 新增星盘</button>
</li>
)}
</ul>
) : null}
</div>
<span id={`synastry-${props.label}`}>{props.label}</span>
<Select
value={props.value || null}
onValueChange={(next) => { if (typeof next === "string") props.onChange(next); }}
>
<SelectTrigger aria-label={props.label}>
<SelectValue placeholder={props.placeholder || "选择一个人"}>
{shown || props.placeholder || "选择一个人"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{props.people.map((person) => {
const taken = person.id === props.otherId;
const disabled = person.incomplete || taken;
const hint = person.incomplete ? "资料未齐" : taken ? "已在另一边" : person.meta;
return (
<SelectItem key={person.id} value={person.id} disabled={disabled}>
{person.name}{hint ? ` ${hint}` : ""}
</SelectItem>
);
})}
</SelectContent>
</Select>
{props.archiveFull ? (
<p className="synastry-add-note">
档案已满(最多 {MAX_SAVED_SUBJECTS} 人)
<a href="/people">去星盘档案删一个</a>
</p>
) : (
<Button type="button" variant="ghost" className="synastry-add-person" onClick={props.onAdd}>
<Plus aria-hidden="true" />新增星盘
</Button>
)}
</div>
);
}
@@ -2,8 +2,11 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
import { SynastryWait } from "@/components/synastry/synastry-wait";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { beginSynastrySectionChat } from "@/lib/synastry-report-chat-client";
import { SECTION_TITLES, SYNASTRY_ASK_SECTION_IDS, SYNASTRY_FOOTNOTE, SYNASTRY_PROSE_IDS, TALK_TOGETHER, type SynastryAskSectionId } from "@/lib/synastry-full-sections";
import type { TechnicalBlock } from "@/lib/synastry-full-technical";
@@ -128,19 +131,22 @@ export function SynastryReportView(props: {
const body: LanguageBody | null = language === "en" && english ? english : snapshot;
if (!body) return null;
const names = snapshot.names ?? {};
const title = `${names.a || "甲"} × ${names.b || "乙"} · 婚恋合盘`;
const pairMark = "\u00d7";
const title = `${names.a || "甲"} ${pairMark} ${names.b || "乙"} · 婚恋合盘`;
return (
<article className="synastry-report" data-onboarding-synastry={props.reportId}>
<Link className="synastry-report-back" href="/synastry">‹ 婚恋合盘</Link>
<Link className="synastry-report-back" href="/synastry"><ChevronLeft aria-hidden="true" /> 婚恋合盘</Link>
<header className="synastry-report-heading">
<h1>{title}</h1>
<div className="synastry-report-meta">
<span>{savedLine(props.createdAt)}</span>
<div className="synastry-lang" role="group" aria-label="语言">
<button type="button" aria-pressed={language === "zh"} onClick={() => setLanguage("zh")}>中文</button>
<button type="button" aria-pressed={language === "en"} onClick={() => showEnglish()}>English</button>
</div>
<Tabs className="synastry-lang" value={language} onValueChange={(next) => { if (next === "en") showEnglish(); else setLanguage("zh"); }}>
<TabsList aria-label="语言">
<TabsTrigger value="zh">中文</TabsTrigger>
<TabsTrigger value="en">English</TabsTrigger>
</TabsList>
</Tabs>
</div>
</header>
{englishNote ? <p>{englishNote}</p> : null}
@@ -208,9 +214,9 @@ export function SynastryReportView(props: {
<p>对哪一段想再问清楚?去对话里接着聊,会用印度占星的语言讲背后的依据。</p>
<div className="synastry-report-ask">
{SYNASTRY_ASK_SECTION_IDS.map((id) => (
<button key={id} type="button" disabled={asking !== ""} onClick={() => void askSection(id)}>
<Button key={id} type="button" variant="outline" disabled={asking !== ""} onClick={() => void askSection(id)}>
{SECTION_TITLES[id][language]}
</button>
</Button>
))}
</div>
</nav>
+51
View File
@@ -0,0 +1,51 @@
/** Archive order for other people. Self is not a chart_profiles row and is never included. */
export function sortByArchiveOrder<T>(rows: readonly T[]): T[];
export function sortByArchiveOrder<T>(
rows: readonly T[],
orderOf: (row: T) => number | null,
): T[];
export function sortByArchiveOrder<T>(
rows: readonly T[],
orderOf?: (row: T) => number | null,
): T[] {
const read = orderOf ?? ((row: T) => defaultArchiveOrder(row));
return rows
.map((row, index) => ({ row, index }))
.sort((left, right) => {
const leftOrder = read(left.row);
const rightOrder = read(right.row);
if (leftOrder === null && rightOrder === null) return left.index - right.index;
if (leftOrder === null) return 1;
if (rightOrder === null) return -1;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return left.index - right.index;
})
.map((item) => item.row);
}
function defaultArchiveOrder(row: unknown): number | null {
if (!row || typeof row !== "object" || !("archiveOrder" in row)) return null;
const value = (row as { archiveOrder?: number | null }).archiveOrder;
return typeof value === "number" ? value : null;
}
/** Positions inside the archive grid. Gaps of 10 leave room for a later insert. */
export function archiveOrdersForIds(ids: readonly string[]): Map<string, number> {
const orders = new Map<string, number>();
ids.forEach((id, index) => {
if (id !== "self") orders.set(id, (index + 1) * 10);
});
return orders;
}
export function moveArchiveIds(ids: readonly string[], activeId: string, overId: string): string[] {
if (activeId === "self" || overId === "self") return [...ids];
const from = ids.indexOf(activeId);
const to = ids.indexOf(overId);
if (from < 0 || to < 0 || from === to) return [...ids];
const next = [...ids];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!);
return next;
}
+1 -1
View File
@@ -40,7 +40,7 @@ export const SELF_SUBJECT_ID = "self";
export const CHART_SUBJECT_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation";
export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation,profile,updated_at,created_at";
export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation,profile,updated_at,created_at,archive_order";
export const MAX_OTHER_SUBJECTS = 19;
export const MAX_SAVED_SUBJECTS = 20;
@@ -0,0 +1,9 @@
begin;
alter table public.chart_profiles
add column if not exists archive_order integer null default null;
grant update (archive_order)
on table public.chart_profiles to authenticated;
commit;
@@ -128,7 +128,10 @@ test("the chart library starts as a list and only renders a form after a view ch
assert.match(charts, /\[editing, setEditing\] = useState\(false\)/);
assert.doesNotMatch(list, /ChartProfileForm|<form/);
assert.match(list, /添加一个人/);
assert.match(charts, /editing \? \(\s*<ChartProfileForm/);
// 原值:editing ? ( 后面立刻是 ChartProfileForm
// 新值:editing ? ( 后面是 Dialog,同一段里仍有 ChartProfileForm
// 原因:K4 档案编辑改用 Dialog。星盘页已经有自己的对话框,所以不把 Dialog 放进共用表单
assert.match(charts, /editing \? \(\s*<Dialog[\s\S]{0,400}<ChartProfileForm/);
assert.match(charts, /setEditing\(true\)/);
assert.match(charts, /setEditing\(false\)/);
});
@@ -149,6 +149,29 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
`),
"YES:integer",
);
// 原值:只核对会话的 sidebar_order
// 新值:chart_profiles.archive_order 同样是可空 integer,authenticated 可以更新它
// 原因:K4 只给星盘档案加排序列。会话那一列不改语义,迁移也不复制进 frontend/db/migrations
assert.match(migration.stdout, /applied 20261025180000_chart_profiles_archive_order\.sql/);
assert.equal(
existsSync(fileURLToPath(new URL("../db/migrations/20261025180000_chart_profiles_archive_order.sql", import.meta.url))),
false,
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
assert.equal(
fixture.psql(`
select is_nullable || ':' || data_type
from information_schema.columns
where table_schema = 'public'
and table_name = 'chart_profiles'
and column_name = 'archive_order'
`),
"YES:integer",
);
assert.equal(
fixture.psql("select has_column_privilege('authenticated', 'public.chart_profiles', 'archive_order', 'UPDATE')"),
"t",
);
assert.equal(
fixture.psql(`
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { archiveOrdersForIds, moveArchiveIds, sortByArchiveOrder } from "../src/lib/people-archive-order.ts";
import { synastryChargeCopy } from "../src/components/synastry/synastry-picker.tsx";
test("archive order keeps an untouched list, then puts numbered rows first", () => {
const rows = [
{ id: "b", archiveOrder: null },
{ id: "a", archiveOrder: null },
{ id: "c", archiveOrder: 10 },
];
assert.deepEqual(sortByArchiveOrder(rows.slice(0, 2)).map((row) => row.id), ["b", "a"]);
assert.deepEqual(sortByArchiveOrder(rows).map((row) => row.id), ["c", "b", "a"]);
});
test("moving archive ids never moves self and writes gaps of 10", () => {
assert.deepEqual(moveArchiveIds(["self", "a", "b"], "self", "b"), ["self", "a", "b"]);
assert.deepEqual(moveArchiveIds(["a", "b", "c"], "c", "a"), ["c", "a", "b"]);
assert.deepEqual(moveArchiveIds(["a", "b"], "a", "missing"), ["a", "b"]);
const orders = archiveOrdersForIds(["a", "b", "self"]);
assert.equal(orders.get("a"), 10);
assert.equal(orders.get("b"), 20);
assert.equal(orders.has("self"), false);
});
test("order writes do not touch updated_at, and the start button stays centered", () => {
const route = readFileSync(new URL("../src/app/api/chart-profiles/[id]/route.ts", import.meta.url), "utf8");
const patch = route.slice(route.indexOf("export async function PATCH"));
assert.match(patch, /archive_order: order/);
assert.doesNotMatch(patch, /updated_at/);
assert.match(patch, /id === "self"/);
const list = readFileSync(new URL("../src/app/api/chart-profiles/route.ts", import.meta.url), "utf8");
assert.match(list, /sortByArchiveOrder/);
assert.match(list, /\.order\("updated_at", \{ ascending: false \}\)/);
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(css, /\.synastry-go \{[^}]*align-items:\s*center/);
assert.match(css, /\.synastry-start \{[^}]*margin-inline:\s*auto/);
assert.equal(css.includes(".synastry-times { display: none; }"), false);
assert.match(css, /\.synastry-pick \{ grid-template-columns: minmax\(0, 1fr\); \}/);
assert.match(css, /\.synastry-times \{ justify-self: center; padding-bottom: 0; \}/);
assert.match(css, /\.synastry-go \{ align-items: center; \}/);
});
test("the confirm copy reads the price and the balance", () => {
assert.deepEqual(synastryChargeCopy(19, 40), { short: false, text: "会用掉 19 点,余额 40 点。" });
assert.deepEqual(synastryChargeCopy(19, 3), { short: true, text: "需要 19 点,余额只有 3 点。" });
assert.equal(synastryChargeCopy(19, 19).short, false);
});
+43 -29
View File
@@ -93,13 +93,16 @@ const render = (overrides: Partial<PeopleArchiveViewProps> = {}) =>
test("D2 list rows: avatar, name, 本人 tag, one monospace meta line, chevron", () => {
const markup = render();
assert.equal([...markup.matchAll(/class="people-archive-row[ "]/g)].length, 2);
// 原值:两行 class="people-archive-row",行尾是字符 ›,添加按钮以「+ 添加一个人」开头
// 新值:两张 data-slot="card"(不含 card-action),行尾是 people-archive-chevron,添加按钮仍写「添加一个人」
// 原因:K4 人物卡改用 Card,箭头和加号改用 Lucide,名字、标签和日期行不变
assert.equal([...markup.matchAll(/data-slot="card"(?![a-z-])/g)].length, 2);
assert.match(markup, /<span class="people-archive-avatar is-self" aria-hidden="true">我<\/span>/);
assert.match(markup, /<b>示例甲<\/b><span class="people-archive-tag">本人<\/span>/);
assert.match(markup, /<small class="people-archive-row-meta">1990-01-02 · 04:53 · 杭州<\/small>/);
assert.match(markup, /<small class="people-archive-row-meta">1992-03-12 · 10:01 · Lisbon<\/small>/);
assert.equal([...markup.matchAll(/class="people-archive-chevron" aria-hidden="true">›</g)].length, 2);
assert.match(markup, /class="people-archive-add"[^>]*>+ 添加一个人</);
assert.equal([...markup.matchAll(/people-archive-chevron/g)].length, 2);
assert.match(markup, /people-archive-add[\s\S]{0,400}添加一个人/);
// 原值: 还可以保存 3 个人(上限 5,列表里已有本人和 1 个他人)。
// 新值: 还可以保存 18 个人(上限 20,列表里已有本人和 1 个他人)。
// 原因: 产品 2026-10-17 把档案上限改为 20 人。
@@ -117,26 +120,28 @@ test("someone else shows 其他 until a relation is saved; the form asks TA 是
selectedId: childPeople[1]!.id,
});
assert.match(detail, /<h2>示例乙<span class="people-archive-tag">孩子<\/span><\/h2>/);
const editing = render({
view: "detail",
selected: people[1],
selectedId: people[1]!.id,
editing: true,
draft: { ...otherProfile, relation: "child" },
});
assert.match(editing, /TA 是你的/);
assert.match(editing, />孩子</);
// 原值:编辑态静态 HTML 里有「TA 是你的」和「孩子」
// 新值:关系字段写「TA 是你的」,选项文案「孩子」在关系词表里,字段按这张表渲染
// 原因:K4 编辑放进 Dialog,传送门内容不进 renderToStaticMarkup;选项不再在字段里写死
assert.match(source, /showRelation=\{!editingSelf\}/);
const relationField = readFileSync(new URL("../src/components/person-relation-field.tsx", import.meta.url), "utf8");
assert.match(relationField, /TA 是你的/);
assert.match(relationField, /PERSON_RELATION_LABELS\[option\]/);
assert.match(readFileSync(new URL("../src/lib/person-relation.ts", import.meta.url), "utf8"), /child: "孩子"/);
const selfEdit = render({ view: "detail", editing: true, draft: selfProfile });
assert.doesNotMatch(selfEdit, /TA 是你的/);
});
test("D2 the current person carries is-current, whoever it is", () => {
const selfCurrent = render();
assert.match(selfCurrent, /class="people-archive-row is-current[^"]*" aria-current="true"[\s\S]*示例甲/);
// 原值:class="people-archive-row is-current" 且按 <li> 切开
// 新值:卡片的 class 里有 is-current,aria-current="true",只按完整 data-slot="card" 切开
// 原因:K4 列表从按钮行改成 Card,本人仍是第一张且不能被标成当前以外的人
assert.match(selfCurrent, /data-slot="card"(?![a-z-])[^>]*is-current[^>]*aria-current="true"[\s\S]*示例甲/);
const otherCurrent = render({ subjectId: people[1].id });
const rows = otherCurrent.split("<li>").slice(1);
const rows = otherCurrent.split(/data-slot="card"(?![a-z-])/).slice(1);
assert.doesNotMatch(rows[0], /is-current/);
assert.match(rows[1], /class="people-archive-row is-current/);
assert.match(rows[1], /is-current/);
});
test("D2 at the limit the add card is disabled and the limit sentence shows", () => {
@@ -144,7 +149,11 @@ test("D2 at the limit the add card is disabled and the limit sentence shows", ()
// 原值: aria-disabled="true",限额句「最多保存 5 个人的星盘」。
// 新值: 仍是 aria-disabled="true",限额句「最多保存 20 个人的星盘」(由 MAX_SAVED_SUBJECTS 拼出)。
// 原因: 产品 2026-10-17 把档案上限改为 20 人;2026-09-30 的 toast 交互不变。
assert.match(markup, /class="people-archive-add" type="button" aria-disabled="true"/);
// 原值:class="people-archive-add" type="button" aria-disabled="true" 写在同一个起始位置
// 新值:按钮仍带 people-archive-add 和 aria-disabled="true",没有 disabled 属性
// 原因:K4 添加按钮改用 Button,到顶时仍可点,用来弹出原来的限额提示
assert.match(markup, /people-archive-add[^>]*aria-disabled="true"|aria-disabled="true"[^>]*people-archive-add/);
assert.doesNotMatch(markup, /people-archive-add[^>]*\sdisabled=/);
assert.match(markup, /<p class="people-archive-hint">最多保存 20 个人的星盘<\/p>/);
assert.doesNotMatch(markup, /还可以保存/);
});
@@ -154,7 +163,10 @@ test("D1 the view flag drives list → detail; the back control is in the detail
const detail = render({ view: "detail" });
assert.match(detail, /class="people-archive" data-view="detail"/);
const detailColumn = detail.slice(detail.indexOf("people-archive-detail"));
assert.match(detailColumn, /class="people-archive-back"[^>]*><span aria-hidden="true">‹<\/span> 返回</);
// 原值:返回按钮里是字符 ‹,类名后 240 字内出现「返回」
// 新值:返回按钮仍有 people-archive-back,其后 800 字内出现「返回」
// 原因:K4 返回箭头改用 Lucide,图标 SVG 比原来的字符长,返回这条路不变
assert.match(detailColumn, /people-archive-back[\s\S]{0,800}返回/);
});
test("D3 detail: header, two time cards (rectified in the success family), date, readable ayanamsa, three actions", () => {
@@ -185,17 +197,16 @@ test("D3 another person's detail offers 编辑 and 和我合盘, and delete only
assert.doesNotMatch(detail, /删除这个人/);
const editing = render({ view: "detail", selected: other, selectedId: other.id, editing: true, draft: other.profile });
assert.match(editing, /编辑人物/);
const form = editing.indexOf("chart-library-form");
const danger = editing.indexOf("people-archive-danger");
// 原值:静态 HTML 里先出现表单再出现「删除这个人」,确认句也在 HTML 里
// 新值:源码里 ChartProfileForm 在 people-archive-danger 之前;确认句仍拼 sessions 和 reports
// 原因:K4 编辑用 Dialog、删除确认用 AlertDialog,传送门内容不进 renderToStaticMarkup
assert.match(source, /编辑人物/);
const form = source.indexOf("<ChartProfileForm");
const danger = source.indexOf("people-archive-danger");
assert.ok(form > 0 && danger > form, "delete sits under the edit form");
assert.match(editing.slice(danger), /删除这个人/);
const confirm = render({
view: "detail", selected: other, selectedId: other.id, editing: true, draft: other.profile,
pendingDelete: { id: other.id, sessions: 2, reports: 1 },
});
assert.match(confirm, /将同时删除 2 个对话和 1 份报告,无法恢复。/);
assert.match(editing, /删除这个人/);
assert.match(source, /将同时删除 \{pendingDelete\.sessions\} 个对话和 \{pendingDelete\.reports\} 份报告/);
assert.match(source, /无法恢复。/);
const adding = render({ view: "detail", selected: people[0], selectedId: "", editing: true });
assert.match(adding, /添加一个人/);
@@ -281,8 +292,11 @@ test("twenty saved people stay in a bounded vertical list", () => {
})),
];
const markup = render({ people: many, atLimit: true });
assert.equal([...markup.matchAll(/class="people-archive-row[ "]/g)].length, 20);
assert.match(markup, /<ul class="people-archive-rows">/);
// 原值:20 个 class="people-archive-row",容器是 <ul class="people-archive-rows">
// 新值:20 张完整 data-slot="card"(不含 card-action),容器是 div.people-archive-rows,仍限高并只纵向滚动
// 原因:K4 改成可换行的网格,方便拖动排序;二十人的滚动边界不变
assert.equal([...markup.matchAll(/data-slot="card"(?![a-z-])/g)].length, 20);
assert.match(markup, /class="people-archive-rows"/);
const rows = cssDeclarations(".people-archive-rows", css);
assert.match(rows, /max-height:\s*min\(70vh, 640px\)/);
assert.match(rows, /overflow-x:\s*hidden/);
@@ -10,7 +10,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { ChartProfileForm, type ChartProfileFormProps } from "../src/components/chart-profile-form.tsx";
import { PeopleArchiveView, type PeopleArchiveViewProps, type PersonRow } from "../src/components/people/people-page.tsx";
import { ProfileGenderField } from "../src/components/profile-gender-field.tsx";
import { genderChoice, ProfileGenderField } from "../src/components/profile-gender-field.tsx";
import { ProfilePanel } from "../src/components/profile-panel.tsx";
import { emptyProfile, type Account, type Profile } from "../src/lib/home-types.ts";
import { PROFILE_GENDER_HINT, type ProfileGender } from "../src/lib/profile-gender.ts";
@@ -64,26 +64,36 @@ function elements(node: ReactNode): ReactElement<Record<string, unknown>>[] {
return out;
}
function radios(markup: string) {
return [...markup.matchAll(/<button[^>]*role="radio"[^>]*aria-checked="(true|false)"[^>]*>([^<]*)<\/button>/g)]
.map((match) => `${match[2]}:${match[1]}`);
function pressed(markup: string) {
return [...markup.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].flatMap((match) => {
const tag = match[0];
const open = tag.slice(0, tag.indexOf(">") + 1);
const state = /aria-pressed="(true|false)"/.exec(open)?.[1];
if (!state) return [];
const text = tag.replace(/<[^>]+>/g, "").trim();
return [`${text}:${state}`];
});
}
test("the field: 女 / 男 / 不填 as one radio group, 不填 selected when nothing is stored, one hint line", () => {
test("the field: 女 / 男 / 不填 as one group, 不填 selected when nothing is stored, one hint line", () => {
const empty = renderToStaticMarkup(<ProfileGenderField idPrefix="t" value={undefined} onChange={() => {}} />);
assert.deepEqual(radios(empty), ["女:false", "男:false", "不填:true"]);
assert.match(empty, /role="radiogroup" aria-labelledby="t-gender-title" aria-describedby="t-gender-hint"/);
// 原值:role="radio" + aria-checked,空值时 ["女:false", "男:false", "不填:true"]
// 新值:ToggleGroup 的 aria-pressed,空值时仍是 不填 按下
// 原因:K4 关系与性别改用标签式 ToggleGroup,选项、空值和再点已选项不改的行为还在
assert.deepEqual(pressed(empty), ["女:false", "男:false", "不填:true"]);
assert.match(empty, /aria-labelledby="t-gender-title"/);
assert.match(empty, /aria-describedby="t-gender-hint"/);
assert.match(empty, /性别(选填)/);
assert.ok(empty.includes(PROFILE_GENDER_HINT));
assert.equal(PROFILE_GENDER_HINT, "用于婚恋解读里判断夫星 / 妻星,不填也能用。");
const female = renderToStaticMarkup(<ProfileGenderField idPrefix="t" value="female" onChange={() => {}} />);
assert.deepEqual(radios(female), ["女:true", "男:false", "不填:false"]);
assert.deepEqual(pressed(female), ["女:true", "男:false", "不填:false"]);
const chosen: Array<ProfileGender | null> = [];
const tree = elements(ProfileGenderField({ idPrefix: "t", value: "female", onChange: (value) => chosen.push(value) }));
const buttons = tree.filter((element) => element.type === "button");
for (const button of buttons) (button.props.onClick as () => void)();
assert.deepEqual(chosen, ["male", null], "clicking the selected choice again is a no-op; 不填 stores null");
assert.equal(genderChoice("female", ["female"]), undefined);
assert.equal(genderChoice("female", []), undefined);
assert.equal(genderChoice("female", ["male"]), "male");
assert.equal(genderChoice("female", ["unset"]), null);
assert.equal(genderChoice(null, ["unset"]), undefined, "clicking the selected choice again is a no-op; 不填 stores null");
});
test("people edit form: the gender field is part of the form and writes the draft (不填 → null)", () => {
@@ -94,7 +104,7 @@ test("people edit form: the gender field is part of the form and writes the draf
onChange: (next) => { draft = typeof next === "function" ? next(draft) : next; },
};
const markup = renderToStaticMarkup(<ChartProfileForm {...props} />);
assert.deepEqual(radios(markup), ["女:false", "男:true", "不填:false"]);
assert.deepEqual(pressed(markup), ["女:false", "男:true", "不填:false"]);
const field = elements(ChartProfileForm(props)).find((element) => element.type === ProfileGenderField);
assert.ok(field);
(field.props.onChange as (value: ProfileGender | null) => void)(null);
@@ -105,16 +115,17 @@ test("people edit form: the gender field is part of the form and writes the draf
const without = renderToStaticMarkup(<ChartProfileForm {...props} showGender={false} />);
assert.equal(without.includes("性别(选填)"), false);
const editing = renderToStaticMarkup(<PeopleArchiveView {...viewProps({
selected: people[1]!, selectedId: people[1]!.id, editing: true, draft: otherProfile,
})} />);
// 原值: ["女:false", "男:true", "不填:false"]
// 新值: 性别单选前面多一组「TA 是你的」(空关系显示为已选「其他」)
// 原因: 档案编辑他人时要能补关系(#95 T2);性别单选本身没改
assert.deepEqual(radios(editing), [
const editing = renderToStaticMarkup(<ChartProfileForm {...props} showRelation />);
// 原值: PeopleArchiveView 编辑态里的 role=radio,性别前多一组「TA 是你的」
// 新值: 同一张表直接渲染;页面源码把表单放进 Dialog,并传 showRelation={!editingSelf}
// 原因: K4 编辑用 Dialog,传送门不进静态 HTML;关系和性别仍是这一张表上的 ToggleGroup
assert.deepEqual(pressed(editing), [
"伴侣:false", "孩子:false", "父母:false", "朋友:false", "其他:true",
"女:false", "男:true", "不填:false",
]);
const peopleSource = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
assert.match(peopleSource, /showRelation=\{!editingSelf\}/);
assert.match(peopleSource, /showGender/);
});
test("detail card: 性别 shows the stored value, 未填 when empty; 本人 edits it in the same form as everyone", () => {
@@ -126,8 +137,17 @@ test("detail card: 性别 shows the stored value, 未填 when empty; 本人 edit
// 原因: BUG-1081 / TASK-self-edit-avatar-menu-20260928 S1——本人在星盘档案可编辑,小字指向的「设置里改」不再是唯一入口
assert.match(self, /<dt>性别<\/dt><dd>未填<\/dd>/);
assert.equal(self.includes("role=\"radio\""), false, "the detail card itself has no radios");
const editing = renderToStaticMarkup(<PeopleArchiveView {...viewProps({ editing: true, draft: selfProfile })} />);
assert.equal(editing.includes("role=\"radio\""), true, "the owner's edit form carries gender like everyone's");
assert.equal(self.includes("aria-pressed"), false, "the detail card itself has no gender choices");
const peopleSource = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
// 原值:本人编辑视图的静态 HTML 里有 role="radio"
// 新值:编辑表单在 Dialog 里,源码仍传 showGender,表单本身仍有性别
// 原因:K4 编辑改用 Dialog,传送门内容不进 renderToStaticMarkup
assert.match(peopleSource, /showGender/);
const editing = renderToStaticMarkup(<ChartProfileForm {...{
title: "编辑我的资料", description: "", value: selfProfile, nameInputId: "people-archive-name",
showGender: true, submitLabel: "保存", submitDisabled: false, onSubmit: () => {}, onChange: () => {},
}} />);
assert.equal(editing.includes("性别(选填)"), true, "the owner's edit form carries gender like everyone's");
});
test("个人资料: the owner's gender saves on its own and birth-data saves never send it", () => {
@@ -140,7 +160,7 @@ test("个人资料: the owner's gender saves on its own and birth-data saves nev
<ProfilePanel account={account} profile={{ ...selfProfile, gender: "female" }} avatarSaving={false}
persistAvatar={() => {}} />,
);
assert.deepEqual(radios(panel), []);
assert.deepEqual(pressed(panel), []);
assert.equal(panel.includes(PROFILE_GENDER_HINT), false);
const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
assert.match(people, /await saveSelfGender\(gender\)/);
@@ -66,18 +66,24 @@ function fakeFetch(status: number, body: unknown, calls: Call[]): typeof fetch {
test("本人 detail offers 编辑 and still says it cannot be deleted", () => {
const detail = render();
assert.match(detail, /class="people-archive-foot"><p>本人不能删除<\/p><button type="button" class="people-archive-secondary">编辑<\/button>/);
// 原值:脚注里的编辑按钮 class 只有 people-archive-secondary,500 字内出现「编辑」
// 新值:脚注仍先写「本人不能删除」,1600 字内出现「编辑」
// 原因:K4 编辑按钮改用 Button,组件库样式写在同一个 class 里,比原来长
assert.match(detail, /class="people-archive-foot"><p>本人不能删除<\/p>[\s\S]{0,1600}编辑/);
assert.doesNotMatch(detail, /删除这个人|确认删除/);
});
test("本人 edit view: its own title, gender like everyone, and no delete area", () => {
const editing = render({ editing: true, draft: selfProfile });
assert.match(editing, /编辑我的资料/);
// 原值:本人编辑的静态 HTML 含「编辑我的资料」和 role="radio"
// 新值:标题在源码里;静态 HTML 没有删除区。他人编辑的静态 HTML 仍有「删除这个人」
// 原因:K4 编辑表单放进 Dialog,传送门不进 renderToStaticMarkup;删除按钮留在编辑区下面
assert.match(peopleSource, /编辑我的资料/);
assert.doesNotMatch(editing, /编辑人物|填报的出生时间保存后不能再改/);
assert.equal(editing.includes("role=\"radio\""), true);
assert.match(peopleSource, /showGender/);
assert.doesNotMatch(editing, /people-archive-danger|删除这个人/);
const other = render({ editing: true, selected: people[1]!, selectedId: people[1]!.id, draft: otherProfile });
assert.match(other, /编辑人物/);
assert.match(peopleSource, /编辑人物/);
assert.match(other, /删除这个人/);
});
+29 -7
View File
@@ -166,12 +166,30 @@ test("the synastry page is a static shell and reads the flag in the browser", ()
assert.match(picker, /合盘服务暂时连不上,请稍后再试/);
assert.ok(picker.indexOf('setAccess("closed")') < picker.indexOf("fetchAccount()"));
const pending = renderToStaticMarkup(createElement(SidebarProvider, null, createElement(SynastryPicker)));
assert.match(pending, /看这段感情往后的走向,和两人之间的助力与考验。/);
// 原值:开关未返回时静态 HTML 有副标题「看这段感情往后的走向,和两人之间的助力与考验。」
// 新值:这段副标题不在页面上;未返回时仍不出现「开始合盘」和「第一个人」
// 原因:K4 去掉页内重复标题和副标题,顶栏已经有「婚恋合盘」(BUG-1403 的不闪选人区还在)
assert.doesNotMatch(pending, /看这段感情往后的走向/);
assert.doesNotMatch(pending, /开始合盘|第一个人/);
assert.match(picker, /看这段感情往后的走向,和两人之间的助力与考验。/);
assert.doesNotMatch(picker, /看这段感情往后的走向/);
// 原值:两个选择框各自写 aria-label="第一个人" / aria-label="第二个人"
// 新值:调用处是 label="第一个人" 和 label="第二个人",触发器 aria-label={props.label}
// 原因:两个 Select 共用一个选人框,可访问名称仍是这两个词
assert.match(picker, /label="第一个人"/);
assert.match(picker, /label="第二个人"/);
assert.match(picker, /aria-label=\{props\.label\}/);
assert.match(picker, /HeartHandshake/);
assert.doesNotMatch(picker, /Sparkles/);
assert.match(picker, /开始合盘/);
assert.match(picker, /+ 新增星盘/);
assert.match(picker, /生成一份完整合盘报告,用掉/);
assert.match(picker, /新增星盘/);
assert.doesNotMatch(picker, /+/);
// 原值:按钮下方写「生成一份完整合盘报告,用掉 N 点。」
// 新值:按钮上不写价格;确认句是「会用掉 N 点,余额 M 点。」,不够时是「需要 N 点,余额只有 M 点。」
// 原因:K4 点开始先确认扣点,价格从计价表读,余额从账户读
assert.match(picker, /会用掉 \$\{creditCost\} 点,余额 \$\{balance\} 点。/);
assert.match(picker, /需要 \$\{creditCost\} 点,余额只有 \$\{balance\} 点。/);
assert.match(picker, /去补点数/);
assert.match(picker, /accountDialogHref\("billing"\)/);
const again = renderToStaticMarkup(createElement(SynastryGoNotes, {
creditCost: 19,
pricingNote: "",
@@ -179,7 +197,7 @@ test("the synastry page is a static shell and reads the flag in the browser", ()
generating: false,
}));
assert.match(again, /这两人已经合过 2 次,再生成会另扣 19 点/);
assert.match(again, /生成一份完整合盘报告,用掉 19 点/);
assert.doesNotMatch(again, /生成一份完整合盘报告/);
const quiet = renderToStaticMarkup(createElement(SynastryGoNotes, {
creditCost: 19,
pricingNote: "",
@@ -238,8 +256,12 @@ test("report view keeps analysis folded and offers a section ask entry", () => {
assert.doesNotMatch(html, /<button[^>]*>共同练习<\/button>/);
assert.match(html, /所以:靠近时先确认对方听完了。/);
assert.match(html, /外部资料与真实案例核对:未进行/);
assert.match(html, /· 婚恋合盘/);
assert.match(html, /‹ 婚恋合盘/);
assert.match(html, /甲 × 乙 · 婚恋合盘/);
// 原值:返回链接文字是「‹ 婚恋合盘」
// 新值:链接仍指向 /synastry,文字是「婚恋合盘」,箭头用 ChevronLeft
// 原因:K4 去掉手写返回箭头。标题里的 × 还在,第一次打开时两个头像靠拢仍按这个标题拆
assert.match(html, /href="\/synastry"/);
assert.match(readFileSync(new URL("../src/components/synastry/synastry-report-view.tsx", import.meta.url), "utf8"), /ChevronLeft/);
assert.match(html, /已保存,再次打开不扣点/);
assert.match(html, /可以一起聊聊/);
});