星盘档案可以拖动排序,合盘开始前先确认扣点。
人物卡、菜单、编辑和删除换成组件库。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:
co-authored by
Grok (agent loop)
parent
b7ec8e78ed
commit
eee8564b7f
@@ -1,5 +1,15 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-25 — 星盘档案可以拖着排,合盘先确认扣点
|
||||
|
||||
- 星盘档案的人物卡可以拖动排序。刷新、换一台登录的设备,顺序还在。本人始终排在第一张,拖不动。
|
||||
- 每张卡的更多菜单可以编辑。别人还可以删除。删除前仍先问清会带走多少对话和报告。
|
||||
- 关系和性别是同一排标签。表单上的字和读屏名称都还在。
|
||||
- 合盘选人页不再重复标题。两个人的名字左右并排,窄屏改成上下排,中间的叉仍在。开始按钮在正中,上面不写价格。点了先确认会用掉多少点、现在还有多少点。不够就改去补点数。
|
||||
- 第一次打开合盘时,两个头像仍会靠拢。系统开了减少动态效果时,仍只淡入。
|
||||
- 扣点、价格和删人之后合盘、报告怎么处理,都没有改。价格仍从计价表读。
|
||||
- Skill 版本不变(BUG-1436)。
|
||||
|
||||
## 2026-10-25 — 星盘页的页签和盘型条换成组件库
|
||||
|
||||
- 四个页签仍是星盘、基础信息、大运、西洋盘,当前项有下划线。
|
||||
|
||||
@@ -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` 的「星盘档案」和「婚恋合盘」两节。
|
||||
@@ -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
@@ -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`。不新造色值。
|
||||
|
||||
### 行星表
|
||||
|
||||
@@ -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 对话 / 看星盘 / 生成报告」;他人另有「和我合盘」「编辑」。本人底部灰字「本人不能删除」(不加句号)。手机详情顶部是「‹ 返回」。
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, /删除这个人/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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, /可以一起聊聊/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user