From eee8564b7f43b766c5711a94541d5703fd245d99 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Sun, 25 Oct 2026 05:05:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=98=9F=E7=9B=98=E6=A1=A3=E6=A1=88=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E6=8B=96=E5=8A=A8=E6=8E=92=E5=BA=8F=EF=BC=8C=E5=90=88?= =?UTF-8?q?=E7=9B=98=E5=BC=80=E5=A7=8B=E5=89=8D=E5=85=88=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E6=89=A3=E7=82=B9=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人物卡、菜单、编辑和删除换成组件库。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) --- CHANGELOG.md | 10 + docs/BUG_HISTORY.md | 16 + .../PROGRESS-ui-kit-4-people-20261023.md | 67 ++++ docs/testing/ui-kit-migration-20261023.md | 19 ++ frontend/DESIGN.md | 16 +- frontend/docs/VOICE.md | 4 +- frontend/eslint/ui-primitive-whitelist.json | 9 +- .../src/app/api/chart-profiles/[id]/route.ts | 33 ++ frontend/src/app/api/chart-profiles/route.ts | 5 +- frontend/src/app/globals.css | 57 +++- .../src/components/chart-profile-form.tsx | 5 +- .../people/people-archive-sortable-loader.ts | 40 +++ .../people/people-archive-sortable.tsx | 98 ++++++ .../src/components/people/people-page.tsx | 318 +++++++++++++----- .../src/components/person-relation-field.tsx | 21 +- .../src/components/profile-gender-field.tsx | 53 ++- .../synastry/synastry-add-dialog.tsx | 56 +-- .../components/synastry/synastry-picker.tsx | 211 ++++++------ .../synastry/synastry-report-view.tsx | 22 +- frontend/src/lib/people-archive-order.ts | 51 +++ frontend/src/lib/subject-birth.ts | 2 +- ...025180000_chart_profiles_archive_order.sql | 9 + .../tests/chart-library-other-profile.test.ts | 5 +- .../tests/database-local-business.test.ts | 23 ++ frontend/tests/people-archive-order.test.ts | 50 +++ frontend/tests/people-archive-view.test.tsx | 72 ++-- .../tests/profile-gender-ui-20260927.test.tsx | 68 ++-- .../tests/self-edit-people-20260928.test.tsx | 14 +- frontend/tests/synastry-full-report.test.ts | 36 +- 29 files changed, 1061 insertions(+), 329 deletions(-) create mode 100644 docs/tasks/PROGRESS-ui-kit-4-people-20261023.md create mode 100644 frontend/src/components/people/people-archive-sortable-loader.ts create mode 100644 frontend/src/components/people/people-archive-sortable.tsx create mode 100644 frontend/src/lib/people-archive-order.ts create mode 100644 frontend/supabase/migrations/20261025180000_chart_profiles_archive_order.sql create mode 100644 frontend/tests/people-archive-order.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b80f6efb..959d545b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 印度占星 Skill 更新日志 +## 2026-10-25 — 星盘档案可以拖着排,合盘先确认扣点 + +- 星盘档案的人物卡可以拖动排序。刷新、换一台登录的设备,顺序还在。本人始终排在第一张,拖不动。 +- 每张卡的更多菜单可以编辑。别人还可以删除。删除前仍先问清会带走多少对话和报告。 +- 关系和性别是同一排标签。表单上的字和读屏名称都还在。 +- 合盘选人页不再重复标题。两个人的名字左右并排,窄屏改成上下排,中间的叉仍在。开始按钮在正中,上面不写价格。点了先确认会用掉多少点、现在还有多少点。不够就改去补点数。 +- 第一次打开合盘时,两个头像仍会靠拢。系统开了减少动态效果时,仍只淡入。 +- 扣点、价格和删人之后合盘、报告怎么处理,都没有改。价格仍从计价表读。 +- Skill 版本不变(BUG-1436)。 + ## 2026-10-25 — 星盘页的页签和盘型条换成组件库 - 四个页签仍是星盘、基础信息、大运、西洋盘,当前项有下划线。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index cd2200d1..5bf524ba 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`。 + diff --git a/docs/tasks/PROGRESS-ui-kit-4-people-20261023.md b/docs/tasks/PROGRESS-ui-kit-4-people-20261023.md new file mode 100644 index 00000000..2a3742bb --- /dev/null +++ b/docs/tasks/PROGRESS-ui-kit-4-people-20261023.md @@ -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 开头,不能算成另一张卡 | +| 当前人物 | 按 `
  • ` 切开 | 只按完整 `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` 的「星盘档案」和「婚恋合盘」两节。 diff --git a/docs/testing/ui-kit-migration-20261023.md b/docs/testing/ui-kit-migration-20261023.md index 8809bf61..e0425670 100644 --- a/docs/testing/ui-kit-migration-20261023.md +++ b/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。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 38e7c066..0b57ea72 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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=` 历史记录,浏览器返回和详情顶部的「‹ 返回」走同一条路回到列表;刷新详情地址直接打开那个人。桌面点人不压历史。 -- **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=` 历史记录,浏览器返回和详情顶部的「返回」走同一条路回到列表;刷新详情地址直接打开那个人。桌面点人不压历史。 +- **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`。不新造色值。 ### 行星表 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 0c004d2c..5c7d2efe 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -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 对话 / 看星盘 / 生成报告」;他人另有「和我合盘」「编辑」。本人底部灰字「本人不能删除」(不加句号)。手机详情顶部是「‹ 返回」。 diff --git a/frontend/eslint/ui-primitive-whitelist.json b/frontend/eslint/ui-primitive-whitelist.json index b7a17fa7..8f7ee5ef 100644 --- a/frontend/eslint/ui-primitive-whitelist.json +++ b/frontend/eslint/ui-primitive-whitelist.json @@ -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" ] diff --git a/frontend/src/app/api/chart-profiles/[id]/route.ts b/frontend/src/app/api/chart-profiles/[id]/route.ts index f20260bf..4b276763 100644 --- a/frontend/src/app/api/chart-profiles/[id]/route.ts +++ b/frontend/src/app/api/chart-profiles/[id]/route.ts @@ -34,6 +34,7 @@ function toClientProfile(row: Record) { 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 }); + } +} diff --git a/frontend/src/app/api/chart-profiles/route.ts b/frontend/src/app/api/chart-profiles/route.ts index 496a727b..f5ecf920 100644 --- a/frontend/src/app/api/chart-profiles/route.ts +++ b/frontend/src/app/api/chart-profiles/route.ts @@ -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) { 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)); + const listed = ((data ?? []) as ReadonlyArray>).map((row) => toClientProfile(row)); + const profiles = sortByArchiveOrder(listed, (row) => row.archive_order); return NextResponse.json({ profiles, limit: MAX_SAVED_SUBJECTS, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 8e589e70..7f3890d1 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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 { diff --git a/frontend/src/components/chart-profile-form.tsx b/frontend/src/components/chart-profile-form.tsx index c55a2f07..69a0c61b 100644 --- a/frontend/src/components/chart-profile-form.tsx +++ b/frontend/src/components/chart-profile-form.tsx @@ -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 ?

    {notice}

    : null}
    - {onCancel ? : null} - + {onCancel ? : null} +
    ); diff --git a/frontend/src/components/people/people-archive-sortable-loader.ts b/frontend/src/components/people/people-archive-sortable-loader.ts new file mode 100644 index 00000000..eefff2e8 --- /dev/null +++ b/frontend/src/components/people/people-archive-sortable-loader.ts @@ -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); +} diff --git a/frontend/src/components/people/people-archive-sortable.tsx b/frontend/src/components/people/people-archive-sortable.tsx new file mode 100644 index 00000000..111d5b34 --- /dev/null +++ b/frontend/src/components/people/people-archive-sortable.tsx @@ -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({ + 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 = ( + + ); + return ( +
    + {children(drag)} +
    + ); +} + +export function PeopleArchiveSortable({ + 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 ( + + + {rows.map((person) => ( + + {(drag) => renderPerson(person, drag)} + + ))} + + + ); +} diff --git a/frontend/src/components/people/people-page.tsx b/frontend/src/components/people/people-page.tsx index fbdc1aa2..34c497ba 100644 --- a/frontend/src/components/people/people-page.tsx +++ b/frontend/src/components/people/people-page.tsx @@ -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([]); @@ -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 } | 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" ? (

    人物列表暂时读不出来

    - +
    ) : null} {status === "ready" ? ( @@ -344,6 +417,7 @@ export function PeoplePage() { onCancelDelete={() => setPendingDelete(null)} confirmDelete={confirmDelete} openPerson={openPerson} + onReorder={(orderedIds) => void persistArchiveOrder(orderedIds)} /> ) : null} @@ -378,12 +452,67 @@ export type PeopleArchiveViewProps = { readonly onDraftChange: Dispatch>; readonly saveOther: (event: FormEvent) => Promise; readonly saveSelf: (event: FormEvent) => Promise; - readonly askDelete: () => Promise; + readonly askDelete: (personId?: string) => Promise; readonly onCancelDelete: () => void; readonly confirmDelete: () => Promise; 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 ( + + + + {name} + {person.role === "self" + ? 本人 + : {personRelationLabel(person.profile.relation)}} + + + {person.incomplete ? "这份资料还不完整" : peopleRowMeta(person.profile)} + +
  • - ); - })} - +
    + {selfPerson ? ( +
    {renderCard(selfPerson, 0, null)}
    + ) : null} + {sortable ? ( + 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) => ( +
    {renderCard(person, index + 1, null)}
    + ))} +
    {/* At the limit the card still answers a tap, with a toast (2026-09-30). */} - +

    {atLimit ? LIMIT_COPY : `还可以保存 ${remaining} 个人`}

    - + {editing ? ( - void (editingSelf ? saveSelf(event) : saveOther(event))} - onCancel={onCancelEdit} - submitLabel={saving ? "保存中" : "保存"} - submitDisabled={saving || Boolean(missingOtherProfileStep(draft))} - /> + { if (!next) onCancelEdit(); }}> + + void (editingSelf ? saveSelf(event) : saveOther(event))} + onCancel={onCancelEdit} + submitLabel={saving ? "保存中" : "保存"} + submitDisabled={saving || Boolean(missingOtherProfileStep(draft))} + /> + + ) : selected ? (
    @@ -506,43 +651,50 @@ export function PeopleArchiveView({
    {profileGenderLabel(selected.profile.gender)}
    - - + - + +
    {selected.role === "other" ? ( people.some((person) => person.role === "self" && !person.incomplete) - ? + ? :

    加上你自己后可合盘

    ) : null} {/* BUG-1081: 本人 is edited here too; only deleting stays off. */}
    {selected.role === "self" ?

    本人不能删除

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

    将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告{pendingDelete.synastry ? `和 ${pendingDelete.synastry} 份合盘` : ""},无法恢复。

    -
    - - -
    -
    - ) : null} +
    ) : null} + {selected && pendingDelete?.id === selected.id && selected.role === "other" ? ( + { if (!next) onCancelDelete(); }}> + + + 确认删除 + + 将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告{pendingDelete.synastry ? `和 ${pendingDelete.synastry} 份合盘` : ""},无法恢复。 + + + + 取消 + void confirmDelete()}>确认删除 + + + + ) : null}
    ); diff --git a/frontend/src/components/person-relation-field.tsx b/frontend/src/components/person-relation-field.tsx index cd89b80f..16fe4083 100644 --- a/frontend/src/components/person-relation-field.tsx +++ b/frontend/src/components/person-relation-field.tsx @@ -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 (
    {PERSON_RELATION_TITLE} -
    + { + const picked = next[0]; + if (PERSON_RELATIONS.some((option) => option === picked)) onChange(picked as PersonRelation); + }} + > {PERSON_RELATIONS.map((option) => { const selected = option === shown; return ( - + ); })} -
    +
    ); } diff --git a/frontend/src/components/profile-gender-field.tsx b/frontend/src/components/profile-gender-field.tsx index f453664c..b1822021 100644 --- a/frontend/src/components/profile-gender-field.tsx +++ b/frontend/src/components/profile-gender-field.tsx @@ -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 (
    {PROFILE_GENDER_TITLE} -
    + { + const choice = genderChoice(current, next); + if (choice !== undefined) onChange(choice); + }} + > {OPTIONS.map((option) => { - const selected = option.value === current; + const selected = option.value === shown; return ( - + ); })} -
    +

    {PROFILE_GENDER_HINT}

    ); diff --git a/frontend/src/components/synastry/synastry-add-dialog.tsx b/frontend/src/components/synastry/synastry-add-dialog.tsx index 17b02b40..5ffdbbd6 100644 --- a/frontend/src/components/synastry/synastry-add-dialog.tsx +++ b/frontend/src/components/synastry/synastry-add-dialog.tsx @@ -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 ( -
    -
    event.stopPropagation()} - > -

    新增星盘

    + { if (!next) props.onCancel(); }}> + + 新增星盘
    - - + +
    -
    - + + ); } diff --git a/frontend/src/components/synastry/synastry-picker.tsx b/frontend/src/components/synastry/synastry-picker.tsx index de948705..26a2e5a2 100644 --- a/frontend/src/components/synastry/synastry-picker.tsx +++ b/frontend/src/components/synastry/synastry-picker.tsx @@ -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([]); const [left, setLeft] = useState("self"); const [right, setRight] = useState(""); - const [open, setOpen] = useState(null); const [adding, setAdding] = useState(null); const [draft, setDraft] = useState(emptySynastryDraft); const [message, setMessage] = useState(""); const [busy, setBusy] = useState(false); const [creditCost, setCreditCost] = useState(null); + const [balance, setBalance] = useState(null); const [pricingNote, setPricingNote] = useState(""); const [readyCount, setReadyCount] = useState(0); const [pairGenerating, setPairGenerating] = useState(false); const [earlier, setEarlier] = useState([]); const [access, setAccess] = useState<"pending" | "open" | "closed" | "unreachable">("pending"); - const pickRef = useRef(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) { 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 = ( -
    -

    婚恋合盘

    -

    看这段感情往后的走向,和两人之间的助力与考验。

    -
    - ); + const charge = confirming && creditCost !== null && balance !== null ? synastryChargeCopy(creditCost, balance) : null; return ( <>
    - {heading} {access === "closed" ?

    合盘服务暂未开放

    : null} {access === "unreachable" ?

    合盘服务暂时连不上,请稍后再试

    : null} {access === "open" ? ( <> -
    - + 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"); }} /> - -
    - + {message ?

    {message}

    : null}
    @@ -283,6 +289,28 @@ export function SynastryPicker() { onSubmit={(event) => void addPerson(event)} /> ) : null} + {charge && creditCost !== null && balance !== null ? ( + { if (!next) setConfirming(false); }}> + + + 开始合盘 + + {charge.short + ? `需要 ${creditCost} 点,余额只有 ${balance} 点。` + : `会用掉 ${creditCost} 点,余额 ${balance} 点。`} + + + + 取消 + {charge.short ? ( + { window.location.assign(accountDialogHref("billing")); }}>去补点数 + ) : ( + { setConfirming(false); void createReport(); }}>开始 + )} + + + + ) : null} ); } @@ -301,7 +329,7 @@ export function SynastryGoNotes(props: { const again = synastryAgainNote(props.readyCount, props.creditCost, props.generating); return ( <> - {props.creditCost !== null ?

    生成一份完整合盘报告,用掉 {props.creditCost} 点。大约要几分钟。

    : props.pricingNote ?

    {props.pricingNote}

    : null} + {props.creditCost === null && props.pricingNote ?

    {props.pricingNote}

    : null} {again ?

    {again}

    : 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 (
    - {props.label} -
    - - {props.open ? ( -
      - {props.people.map((person) => { - const taken = person.id === props.otherId; - const disabled = person.incomplete || taken; - const hint = person.incomplete ? "资料未齐" : taken ? "已在另一边" : person.meta; - return ( -
    • - -
    • - ); - })} - {props.archiveFull ? ( -
    • - 档案已满(最多 {MAX_SAVED_SUBJECTS} 人) - 去星盘档案删一个 -
    • - ) : ( -
    • - -
    • - )} -
    - ) : null} -
    + {props.label} + + {props.archiveFull ? ( +

    + 档案已满(最多 {MAX_SAVED_SUBJECTS} 人) + 去星盘档案删一个 +

    + ) : ( + + )}
    ); } diff --git a/frontend/src/components/synastry/synastry-report-view.tsx b/frontend/src/components/synastry/synastry-report-view.tsx index 0d89aafc..d029326d 100644 --- a/frontend/src/components/synastry/synastry-report-view.tsx +++ b/frontend/src/components/synastry/synastry-report-view.tsx @@ -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 (
    - ‹ 婚恋合盘 +