feat(people): raise the chart archive cap to 20 people
The trigger, the shared constants, and the limit sentence now agree, and the archive and person menus scroll instead of growing past the screen. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-18 — 星盘档案最多保存 20 个人
|
||||
|
||||
- 星盘档案从最多 5 人(含本人)改为最多 20 人。第 19 个其他人可以保存,第 20 个其他人会被拒绝,提示「最多保存 20 个人的星盘」。建档仍不扣点。
|
||||
- 档案列表、标题旁的人名、聊天里的人物选择,人多时在自己的框里上下滚动,不把页面撑出屏幕。
|
||||
- 生产部署前要先跑这一轮的数据库迁移。Skill 版本不变。
|
||||
|
||||
## 2026-10-16 — 大运接口不再编造周期,Ashtottari 计入出生余额
|
||||
|
||||
- 选择一套还没接上引擎的大运时,接口会明确回复未实现,不再返回编出来的周期。已经接上的大运与完整解读使用同一套算法。Vimshottari 的算法和返回字段不变。
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 进度:星盘档案上限 20 人(2026-10-17)
|
||||
|
||||
基线 `origin/staging` = `e7bf60c1f`。分支 `codex/people-archive-limit-20-20261017`。不新增 BUG 编号。
|
||||
|
||||
星盘档案现在最多 20 人(本人 + 19 个其他人)。第 19 个其他人可以保存,第 20 个其他人被拒绝。界面上的限额句由常量拼出。人多时,档案列表和标题旁 / 聊天里的人物菜单在自己的框里上下滚动。
|
||||
|
||||
生产部署前要先跑迁移 `frontend/supabase/migrations/20261017010000_chart_profiles_limit_20.sql`。旧迁移文件没有改。建档仍不扣点。
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 数据库:第 19 个他人可存,第 20 个他人被拒;并发锁仍在新门槛上生效 | `npx tsx --test --test-concurrency=1 tests/database-chart-subject.test.ts` | 4 通过,0 失败 |
|
||||
| 同上两条在全量里 | `npm test` 中的对应子测试 | `ok`(全量编号 1643、1645、2114) |
|
||||
| 20 人列表限高并纵向滚动 | `tests/people-archive-view.test.tsx` | 通过,含新增「twenty saved people…」 |
|
||||
| 类型检查 | `npx tsc --noEmit` | 0 错误 |
|
||||
| 代码规范 | `npm run lint` | 0 个错误,123 条原有警告 |
|
||||
| 前端全量 | `npm test` | 5037 条:通过 4971,失败 66,取消 0,退出码 1 |
|
||||
| 首页构建 | `npm run build` | 成功。`/`、`/people`、`/chart`、`/ephemeris`、`/reports` 仍是静态页 |
|
||||
|
||||
66 条失败都是这台 Windows 机器上的环境问题,不是这次限额断言:
|
||||
|
||||
- 31 条:`tsx` 解析 `tailwind-merge` 失败
|
||||
- 4 条:路径被拼成重复盘符 `G:\G:\...`
|
||||
- 其余:shell 脚本退出码 9009、临时目录被占用、`docker ENAMETOOLONG`
|
||||
|
||||
没有一条失败名是档案上限、限额句或 20 人滚动。本机没有再对 `e7bf60c1f` 单独构建一次,所以没有改动前后的首屏 gzip 对照数。这次进首屏的代码只有人物菜单的上限常量,样式只加了两段限高滚动。
|
||||
|
||||
`frontend/src` 里已经没有写死的「5 个人」。测试注释里的「原值」仍保留这句,用来对照改过的断言。更早的任务书仍写着当时的 5 人决定,没有改写。
|
||||
|
||||
真人核对仍按 `docs/testing/people-archive-p1-20260924.md` 和 `docs/testing/people-ephemeris-ui-20260926.md`:满 20 人时三处列表可滚动,手机宽 390px 不出现横向滚动。
|
||||
@@ -500,7 +500,7 @@
|
||||
| `TASK-api-server-service-extract-20261013.md` | — | API 单体第一阶段:拆出不带 HTTP 的计算类,生产伪造 handler 归零 | 待领取(排队:dasha-unify) | `codex/api-server-service-extract-20261013` |
|
||||
| `TASK-data-layer-types-20261013.md` | `PROGRESS-data-layer-types-20261013.md` | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1351;任务书预留的 1343 已被更大编号越过) | 待验收 | `codex/data-layer-types-20261013` |
|
||||
| `TASK-contracts-helpers-dedupe-20261013.md` | — | 前后端契约与小工具去重第一阶段 | 待领取(排队:frontend-dead-code、consult-route-split) | `codex/contracts-helpers-dedupe-20261013` |
|
||||
| `TASK-people-archive-limit-20-20261017.md` | — | 星盘档案上限 5→20 人(触发器新迁移 + 常量 + 文案;婚恋合盘前置单) | 待领取 | `codex/people-archive-limit-20-20261017`;动表,需 test:db |
|
||||
| `TASK-people-archive-limit-20-20261017.md` | `PROGRESS-people-archive-limit-20-20261017.md` | 星盘档案上限 5→20 人(触发器新迁移 + 常量 + 文案;婚恋合盘前置单) | 待验收 | `codex/people-archive-limit-20-20261017` |
|
||||
| `TASK-marriage-match-p1-20261017.md` | #79 | 婚恋合盘引擎(搬上游 bde5d615:marriage_match + 合盘证据包;35 分七项、不分男女;第六版不扣点、不做页面)(BUG-1370 起) | 待执行(不再等 #78) | `codex/marriage-match-p1-20261017`;动表 |
|
||||
| `TASK-full-reading-module-parity-20261018.md` | #81 | full-reading 补齐上游 5 个模块(西占层文件没搬致永远 blocked、Navamsha / Niryaana 用我们自己的实现填键、Lagna Vargottamamsa / Pushkara / Punarphoo)(BUG-1375 起) | 待执行(可与 #78 并行) | `codex/full-reading-module-parity-20261018` |
|
||||
| `TASK-synastry-full-report-20261018.md` | #82 | 婚恋合盘:选人页 + 完整合盘报告(关系画像九节:结论+折叠的分析逻辑+关系时间线+技术依据摘要;英文按需;计费键 synastry.full) | 排队(等 #78、#79、#81) | `codex/synastry-full-report-20261018`;动表 |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
没有登录浏览器。下面每一步都还没在浏览器里做过。用虚构人物,不要填真实姓名、生日或坐标。
|
||||
|
||||
1. 登录后打开「星盘档案」,添加四个虚构人物。第五个添加按钮应禁用,并看到「最多保存 5 个人的星盘」。本人不能删除。
|
||||
1. 登录后打开「星盘档案」,添加十九个虚构人物(连本人一共 20 人)。第十九个他人可以保存。第二十个他人应被拒绝,添加处看到「最多保存 20 个人的星盘」。本人不能删除。满 20 人时,档案列表、标题旁的人名、聊天里的人物选择都能上下滚动,不把页面撑出屏幕。手机宽 390px 时这三处都不出现横向滚动。
|
||||
2. 在对话、星盘、星历、我的报告之间切换当前人物。四个页面和侧栏历史应一起换成这个人,不要先闪出上一个人的盘。
|
||||
3. 在已经发过消息的对话里换成另一个人。应切换当前人物,并打开一段空对话。空对话里切换应直接改绑,不新开对话。
|
||||
4. 删除一个有对话和报告的虚构人物。确认句应是「将同时删除 N 个对话和 M 份报告,无法恢复。」确认后对话和报告一起消失。若删的是当前人物,回到本人。
|
||||
@@ -33,6 +33,6 @@
|
||||
| 星语与资料更新 | 户主无分钟、他人完整;再修改他人资料 | 他人星语可加载;资料变化后指纹更新,不复用旧当天结果 | 待验 |
|
||||
| 合盘上下文 | 从他人档案点「和我合盘」 | 我为本人;该对话绑定本人,不改全局人物,题中对方正确 | 待验 |
|
||||
| 星历暖进/首败 | 同人物暖返回;冷进首请求模拟429/坏包再恢复 | 暖进无额外重复日期请求;首败可重试,不永久停在等待句 | 待验 |
|
||||
| 回填纠偏/上限 | 受控旧档案升级、两窗口同时抢最后名额 | 未验证JSON状态不冒充已校正;本人资料不变;最多四个他人 | 待验 |
|
||||
| 回填纠偏/上限 | 受控旧档案升级、两窗口同时抢最后名额 | 未验证JSON状态不冒充已校正;本人资料不变;最多十九个他人 | 待验 |
|
||||
|
||||
发布验收另核对最终组合Linux Node22全量fail0、基线测试名称无消失、标准DB、build Static/gzip和staging health部署SHA。未完成前不写已部署或resolved。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
1. 打开侧栏「星盘档案」:看到一张张人物卡片,不再挤成一行。每张卡有圆形头像、名字,本人后面有灰色「本人」小标签,下面一行是「出生日期 · 排盘时间 · 城市」,右边一个「›」。
|
||||
2. 顶栏「当前星盘」是谁,谁的卡片就是橙色描边 + 浅橙底。切到别人后回到这页,高亮跟着换。
|
||||
3. 最下面是虚线框「+ 添加一个人」,下面小字「还可以保存 N 个人」。N = 5 − 现有人数。存满 5 个人时框变灰、点不动,下面写「最多保存 5 个人的星盘」。
|
||||
3. 最下面是虚线框「+ 添加一个人」,下面小字「还可以保存 N 个人」。N = 20 − 现有人数。存满 20 个人时框变灰、点了会提示,下面写「最多保存 20 个人的星盘」。满 20 人时列表在自己的框里上下滚动,不把页面撑出屏幕。手机宽 390px 时档案列表、标题旁人名、聊天人物选择都不出现横向滚动。
|
||||
4. 点任意一个人:整页换成这个人的详情,顶部有「‹ 返回」。
|
||||
5. 点「‹ 返回」回到列表;再点进一个人后,用 Safari 左滑返回(或浏览器返回键),也回到列表,而不是离开这一页。
|
||||
6. 详情里:头像 + 名字 +「本人 · 出生地」(别人只写出生地);两格时间卡「填报时间」「排盘时间」。做过生时校正并采用的本人,排盘卡是绿色、写「排盘时间 · 已校正」;没校正过的人两张卡都是普通白卡。
|
||||
|
||||
+5
-4
@@ -812,8 +812,8 @@ The 👍 / 👎 under an answer (回复评价) is saved per conversation and mes
|
||||
- **Pane menu states:** default is transparent with secondary ink; hover is a 55% wash of `--color-canvas-muted` keeping secondary ink; current is the solid muted surface with primary ink. No accent bar, no trailing navigation chevron, and no weight change — hierarchy here comes from ink rank and surface, matching “Hierarchy inside the nav comes from ink rank, not hue”. The sidebar's 2px `--sidebar-ring` on the active session is deliberately **not** changed to match; the two surfaces read differently on purpose until that is revisited.
|
||||
- **Content width:** the 880px frame now gives the content pane a deliberate inset instead of leaving controls against the divider. Form panes (个人资料, 通用设置) cap their children at 560px and stay left aligned (`.settings-dialog-content--form > *`); list panes (星盘资料, 账户与点数) stay full-bleed within the inset so tables and card grids keep their columns. The cap sits on the children of the scroll container only — never on `.settings-modal` or `.account-settings-shell` — so it cannot make the frame resize between panes (BUG-554 / BUG-698).
|
||||
- **Personal profile:** one row of avatar editing (56px preview, eight palettes, 换一个形象) plus nickname and login email. Gender is **not** set here (2026-09-30, BUG-1119): it lives only in the people archive's edit form, for the owner and everyone else; onboarding never asks. The header uses the product-level “设置” eyebrow above the current pane title; there is no duplicate 管理星盘资料 button.
|
||||
- **Chart archive (`/people`):** a static secondary page beside 星盘 / 星历 / 我的报告. Self is the first row and cannot be deleted, but is edited here like everyone else (2026-09-28, BUG-1081). Each other row is a name plus birth facts, with no relationship field. Detail shows the reported time and the chart time separately, then place and ayanamsa. Shortcuts are 和 TA 对话, 看星盘, 生成报告, and 和我合盘. Adding a sixth person is disabled with 「最多保存 5 个人的星盘」. Delete confirms with the session and report counts before the server removes them together. There is no 「设为默认」 and no 「当前默认」 badge.
|
||||
- **Subject switcher:** the same control sits beside the title on chat, chart, ephemeris, reports, and the archive. It is not in the sidebar header. Choosing someone in a chat that already has messages switches the current person and opens an empty chat. An empty chat rebinds in place. The list shows at most five people.
|
||||
- **Chart archive (`/people`):** a static secondary page beside 星盘 / 星历 / 我的报告. Self is the first row and cannot be deleted, but is edited here like everyone else (2026-09-28, BUG-1081). Each other row is a name plus birth facts, with no relationship field. Detail shows the reported time and the chart time separately, then place and ayanamsa. Shortcuts are 和 TA 对话, 看星盘, 生成报告, and 和我合盘. Adding a twenty-first person is disabled with 「最多保存 20 个人的星盘」. Delete confirms with the session and report counts before the server removes them together. There is no 「设为默认」 and no 「当前默认」 badge.
|
||||
- **Subject switcher:** the same control sits beside the title on chat, chart, ephemeris, reports, and the archive. It is not in the sidebar header. Choosing someone in a chat that already has messages switches the current person and opens an empty chat. An empty chat rebinds in place. The list shows every saved person, up to 20, and scrolls inside the menu so it does not grow past the screen.
|
||||
- **States:** open, pane switch, list / self / other / add, saving, success, and error.
|
||||
|
||||
### Billing pane
|
||||
@@ -1140,7 +1140,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
|
||||
标题旁的人物切换仍是当前阅读/对话上下文,不替换户主资料,也不是「设为默认」。上次选择可同步预读,但账户与服务端目录绑定完成前,星盘、星历、报告和新建对话不发默认本人的首屏请求;目录暂时失败应可恢复,不假登出,不先闪别人的内容。档案增删改后切换器重新取目录;同一人物不重复广播切换。会话列表更新合并metadata,保留已加载消息、深链与分页外对话;可见列表仅属于当前人物,换账户不保留旧数据。
|
||||
|
||||
档案数量仍本人加最多四个他人,无关系字段,本人不可删除。表单添加/编辑标题必须与操作一致。删除确认只显示服务端成功取得的对话/报告数;查询失败时显示「暂时查不到这个人的对话数量,稍后再试」,禁用确认,不能显示0冒充已知。服务端成功后才移除档案并刷新目录;删除当前人物才回到本人。删除未交付报告释放预留点数,已交付/已结算不退,不用客户端猜测退款结果。
|
||||
档案数量仍本人加最多十九个他人(一共 20 人),无关系字段,本人不可删除。表单添加/编辑标题必须与操作一致。删除确认只显示服务端成功取得的对话/报告数;查询失败时显示「暂时查不到这个人的对话数量,稍后再试」,禁用确认,不能显示0冒充已知。服务端成功后才移除档案并刷新目录;删除当前人物才回到本人。删除未交付报告释放预留点数,已交付/已结算不退,不用客户端猜测退款结果。
|
||||
|
||||
「和TA对话」新会话绑定选中的人物;「和我合盘」中的我保持本人,新会话显式绑定本人但不改变全局选择。普通新建深链用已绑定的当前人物。今日星语的可用性与缓存指纹取当前人物,不能由户主是否有分钟压掉他人的卡片。星历暖进只复用同账户/同人物缓存,首次失败仍显示可重试的静态错误句,不新增spinner、骨架或「正在加载」。跨scope迟到响应不得覆盖当前视图或分页游标。
|
||||
|
||||
@@ -1153,7 +1153,8 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
- **小于 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 - 人数`;到顶时换成原来的限额句并禁用。
|
||||
- **添加**:虚线卡片「+ 添加一个人」(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)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。
|
||||
- **编辑表单里的性别(选填)**(2026-09-27):放在出生地点之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。
|
||||
- **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。
|
||||
|
||||
@@ -222,7 +222,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
## 星盘档案页(2026-09-26)
|
||||
|
||||
- 列表行一行写「出生日期 · 排盘时间 · 城市」,本人名字后跟「本人」。添加卡片写「+ 添加一个人」,下面「还可以保存 N 个人」(不加句号);到顶仍是「最多保存 5 个人的星盘」。
|
||||
- 列表行一行写「出生日期 · 排盘时间 · 城市」,本人名字后跟「本人」。添加卡片写「+ 添加一个人」,下面「还可以保存 N 个人」(不加句号);到顶仍是「最多保存 20 个人的星盘」。
|
||||
- 详情两格时间卡写「填报时间」「排盘时间」;有校正结果时排盘卡写「排盘时间 · 已校正」。岁差写设置里的名字,例如「Raman(默认)」,不写小写键。
|
||||
- 快捷入口只写「和 TA 对话 / 看星盘 / 生成报告」;他人另有「和我合盘」「编辑」。本人底部灰字「本人不能删除」(不加句号)。手机详情顶部是「‹ 返回」。
|
||||
|
||||
@@ -417,7 +417,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
账户与点数的页签只用:会员套餐 / 点数包 / 兑换码 / 订单记录。购买中按钮写「跳转收银台…」,轮询中写「等待支付结果」,不要写「正在加载」。
|
||||
|
||||
星盘档案不写关系。人数到顶时只说「最多保存 5 个人的星盘」。当前人物不是本人时,生时校正入口禁用,提示「生时校正暂时只支持本人。」删除一个人之前必须写出「将同时删除 N 个对话和 M 份报告,无法恢复。」本人不能删除,不要给删除按钮配一句绕过去的确认。
|
||||
星盘档案不写关系。人数到顶时只说「最多保存 20 个人的星盘」。当前人物不是本人时,生时校正入口禁用,提示「生时校正暂时只支持本人。」删除一个人之前必须写出「将同时删除 N 个对话和 M 份报告,无法恢复。」本人不能删除,不要给删除按钮配一句绕过去的确认。
|
||||
|
||||
余额不足时输入框占位写「余额不足,可在账户与点数中补充」,不要叫用户去会员页。
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { scheduleConsultPacketWarm } from "@/lib/consult-packet-warm";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
|
||||
const SUBJECT_LIMIT_MESSAGE = "最多保存 5 个人的星盘";
|
||||
const SUBJECT_LIMIT_MESSAGE = `最多保存 ${MAX_SAVED_SUBJECTS} 个人的星盘`;
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error && typeof error === "object" && "message" in error && typeof error.message === "string"
|
||||
|
||||
@@ -978,7 +978,7 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
|
||||
.chat-profile-picker-status p { margin: 0; }
|
||||
.chat-profile-picker-waiting { margin-top: var(--space-2); }
|
||||
.chat-profile-picker-retry { min-height: 44px; margin-top: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-action); cursor: pointer; font: inherit; font-size: var(--type-caption); }
|
||||
.chat-profile-picker-options { display: grid; gap: var(--space-1); margin: var(--space-2) 0 0; }
|
||||
.chat-profile-picker-options { display: grid; gap: var(--space-1); max-height: min(50vh, 320px); margin: var(--space-2) 0 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
|
||||
.chat-profile-picker-option { width: 100%; min-height: 44px; display: grid; grid-template-columns: minmax(0, 1fr) 20px; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; font: inherit; transition: border-color 120ms ease-out, background-color 120ms ease-out, color 120ms ease-out; }
|
||||
.chat-profile-picker-option[aria-disabled="true"] { cursor: default; color: var(--color-ink-tertiary); }
|
||||
.chat-profile-picker-option[data-selected] { border-color: var(--color-border); background: var(--color-selected); color: var(--color-ink); }
|
||||
@@ -6266,9 +6266,14 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.people-archive-rows {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
max-height: min(70vh, 640px);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
list-style: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.people-archive-row {
|
||||
display: grid;
|
||||
|
||||
@@ -44,7 +44,7 @@ const DELETE_USAGE_UNAVAILABLE = "暂时查不到这个人的对话数量,稍
|
||||
/** `?person=new` marks the add form; a reload or Forward onto it shows the list. */
|
||||
const NEW_PERSON_PARAM = "new";
|
||||
|
||||
const LIMIT_COPY = "最多保存 5 个人的星盘";
|
||||
const LIMIT_COPY = `最多保存 ${MAX_SAVED_SUBJECTS} 个人的星盘`;
|
||||
|
||||
/** D1: below this width the page is list → detail; at and above it, two columns. */
|
||||
const PEOPLE_TWO_COLUMN_QUERY = "(min-width: 1024px)";
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/chat-profile-picker-model";
|
||||
import { OrbitWaiting } from "@/components/orbit-waiting";
|
||||
import type { ChartLibraryRecord } from "@/lib/home-types";
|
||||
import { MAX_SAVED_SUBJECTS } from "@/lib/subject-birth";
|
||||
|
||||
export type SubjectSwitcherProps = {
|
||||
readonly library: readonly ChartLibraryRecord[];
|
||||
@@ -39,7 +40,7 @@ export function SubjectSwitcher({
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listId = useId();
|
||||
const rows = chatProfilePickerRows({ status, library, selectedId }).slice(0, 5);
|
||||
const rows = chatProfilePickerRows({ status, library, selectedId }).slice(0, MAX_SAVED_SUBJECTS);
|
||||
|
||||
function closePicker() {
|
||||
setOpen(false);
|
||||
|
||||
@@ -41,8 +41,8 @@ export const CHART_SUBJECT_SELECT = "id,user_id,role,name,birth_date,reported_bi
|
||||
|
||||
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,profile,updated_at";
|
||||
|
||||
export const MAX_OTHER_SUBJECTS = 4;
|
||||
export const MAX_SAVED_SUBJECTS = 5;
|
||||
export const MAX_OTHER_SUBJECTS = 19;
|
||||
export const MAX_SAVED_SUBJECTS = 20;
|
||||
|
||||
export type ResolvedSubjectBirth = Readonly<{
|
||||
subjectId: string;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Raise the chart-archive cap from 4 other people to 19.
|
||||
-- The trigger, advisory lock, and role check stay as last defined in
|
||||
-- 20260925030000_chart_subject_trust_and_limit.sql. Only the threshold changes.
|
||||
begin;
|
||||
create or replace function public.chart_profiles_guard_limit()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
others integer;
|
||||
begin
|
||||
if new.role is distinct from 'other' then
|
||||
raise exception 'chart_subject_self_mirror_retired';
|
||||
end if;
|
||||
if tg_op = 'INSERT' or new.user_id is distinct from old.user_id then
|
||||
-- The lock and count are separate statements: after a competing insert
|
||||
-- commits, READ COMMITTED sees its row before deciding the limit.
|
||||
perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(new.user_id::text, 73031));
|
||||
select count(*) into others from public.chart_profiles
|
||||
where user_id = new.user_id and role = 'other';
|
||||
if others >= 19 then
|
||||
raise exception 'subject_limit_reached';
|
||||
end if;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
commit;
|
||||
@@ -115,19 +115,29 @@ test("corrective migration clears untrusted clocks and serializes concurrent fou
|
||||
fixture.psql(correction);
|
||||
assert.equal(fixture.psql(`select birth_time_status || '|' || (active_birth_time is null and active_birth_date is null and active_birth_timezone_offset is null)::text || '|' || to_char(reported_birth_time,'HH24:MI') from public.chart_profiles where id='${ids[0]}'`), "reported|true|07:40");
|
||||
assert.equal(fixture.psql(`select row_to_json(p)::text from public.profiles p where id='${owner}'`), before);
|
||||
// 原值: 纠正迁移把函数写回「他人 >= 4 拒绝」,并发的第 4、第 5 个他人争最后一格,提交后剩 4 人。
|
||||
// 新值: 纠正迁移之后再套上 20261017 的函数(他人 >= 19 拒绝)。先补到 18 个他人,并发的第 19 个可提交,第 20 个被拒,最后剩 19。
|
||||
// 原因: 产品 2026-10-17 把档案上限改为 20 人(含本人)。锁和 role 检查没改,只把争用点挪到新门槛上。旧迁移文件本身仍是 4,不改。
|
||||
const limit20 = readFileSync(new URL("../supabase/migrations/20261017010000_chart_profiles_limit_20.sql", import.meta.url), "utf8");
|
||||
fixture.psql(limit20);
|
||||
for (let index = 0; index < 15; index += 1) {
|
||||
const payload = profileJson({ name: `虚构并发${index}` });
|
||||
fixture.psql(`insert into public.chart_profiles(user_id,role,profile) values ('${owner}','other','${payload}'::jsonb);`);
|
||||
}
|
||||
assert.equal(fixture.psql(`select count(*) from public.chart_profiles where user_id='${owner}'`), "18");
|
||||
await Promise.all([first.connect(), second.connect()]);
|
||||
await first.query("begin");
|
||||
await second.query("begin");
|
||||
await first.query("insert into public.chart_profiles(user_id,role,profile) values($1,'other',$2)", [owner, profileJson()]);
|
||||
let finished = false;
|
||||
const fifth = second.query("insert into public.chart_profiles(user_id,role,profile) values($1,'other',$2)", [owner, profileJson()])
|
||||
const twentieth = second.query("insert into public.chart_profiles(user_id,role,profile) values($1,'other',$2)", [owner, profileJson()])
|
||||
.then(() => ({ error: "" }), (error: Error) => ({ error: error.message })).finally(() => { finished = true; });
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
assert.equal(finished, false, "fifth insert waits for the same owner's transaction lock");
|
||||
assert.equal(finished, false, "the insert past nineteen others waits for the same owner's transaction lock");
|
||||
await first.query("commit");
|
||||
assert.match((await fifth).error, /subject_limit_reached/);
|
||||
assert.match((await twentieth).error, /subject_limit_reached/);
|
||||
await second.query("rollback");
|
||||
assert.equal(fixture.psql(`select count(*) from public.chart_profiles where user_id='${owner}'`), "4");
|
||||
assert.equal(fixture.psql(`select count(*) from public.chart_profiles where user_id='${owner}'`), "19");
|
||||
} finally {
|
||||
await Promise.all([first.end(), second.end()]);
|
||||
fixture.stop();
|
||||
@@ -218,12 +228,19 @@ test("chart subject columns, guards, limit and cascaded delete", {
|
||||
"jsonb is kept for rollback and is not the reason a relationship column exists",
|
||||
);
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
// 原值: 前面已有 2 个他人,循环再插入 2 个后,下一个(第 5 个他人)被拒。
|
||||
// 新值: 循环再插入 17 个,第 19 个他人可存;再插一个(第 20 个他人,连本人共 21 人)被拒,人数停在 19。
|
||||
// 原因: 产品 2026-10-17 把档案上限改为 20 人(含本人,他人最多 19)。
|
||||
for (let index = 0; index < 17; index += 1) {
|
||||
fixture.psql(`
|
||||
insert into public.chart_profiles (id, user_id, role, profile)
|
||||
values ('${randomUUID()}', '${owner}', 'other', '${profileJson({ name: `虚构${index}` })}'::jsonb);
|
||||
`);
|
||||
}
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.chart_profiles where user_id = '${owner}' and role = 'other'`),
|
||||
"19",
|
||||
);
|
||||
assert.throws(
|
||||
() => fixture.psql(`
|
||||
insert into public.chart_profiles (id, user_id, role, profile)
|
||||
@@ -231,6 +248,10 @@ test("chart subject columns, guards, limit and cascaded delete", {
|
||||
`),
|
||||
/subject_limit_reached/,
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.chart_profiles where user_id = '${owner}' and role = 'other'`),
|
||||
"19",
|
||||
);
|
||||
assert.throws(
|
||||
() => fixture.psql(`
|
||||
insert into public.chart_profiles (id, user_id, role, profile)
|
||||
|
||||
@@ -100,7 +100,10 @@ test("D2 list rows: avatar, name, 本人 tag, one monospace meta line, chevron",
|
||||
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.match(markup, /class="people-archive-hint">还可以保存 3 个人</);
|
||||
// 原值: 还可以保存 3 个人(上限 5,列表里已有本人和 1 个他人)。
|
||||
// 新值: 还可以保存 18 个人(上限 20,列表里已有本人和 1 个他人)。
|
||||
// 原因: 产品 2026-10-17 把档案上限改为 20 人。
|
||||
assert.match(markup, /class="people-archive-hint">还可以保存 18 个人</);
|
||||
});
|
||||
|
||||
test("D2 the current person carries is-current, whoever it is", () => {
|
||||
@@ -114,11 +117,11 @@ test("D2 the current person carries is-current, whoever it is", () => {
|
||||
|
||||
test("D2 at the limit the add card is disabled and the limit sentence shows", () => {
|
||||
const markup = render({ atLimit: true });
|
||||
// 原值: /class="people-archive-add" type="button" disabled=""/,限额句是红色 .form-error
|
||||
// 新值: aria-disabled="true"(灰显但仍可点,点了由 startAdd 弹 toast「最多保存 5 个人的星盘」),限额句是灰色 .people-archive-hint
|
||||
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」——提示改 toast,页面只留中性说明
|
||||
// 原值: 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"/);
|
||||
assert.match(markup, /<p class="people-archive-hint">最多保存 5 个人的星盘<\/p>/);
|
||||
assert.match(markup, /<p class="people-archive-hint">最多保存 20 个人的星盘<\/p>/);
|
||||
assert.doesNotMatch(markup, /还可以保存/);
|
||||
});
|
||||
|
||||
@@ -225,6 +228,32 @@ test("CSS: every people-archive class the component uses has a rule (BUG-1039)",
|
||||
assert.ok(used.size >= 20);
|
||||
});
|
||||
|
||||
test("twenty saved people stay in a bounded vertical list", () => {
|
||||
const many: PersonRow[] = [
|
||||
people[0],
|
||||
...Array.from({ length: 19 }, (_, index) => ({
|
||||
id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
|
||||
role: "other" as const,
|
||||
profile: { ...otherProfile, name: `示例${index}` },
|
||||
incomplete: false,
|
||||
})),
|
||||
];
|
||||
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">/);
|
||||
const rows = cssDeclarations(".people-archive-rows", css);
|
||||
assert.match(rows, /max-height:\s*min\(70vh, 640px\)/);
|
||||
assert.match(rows, /overflow-x:\s*hidden/);
|
||||
assert.match(rows, /overflow-y:\s*auto/);
|
||||
const menu = cssDeclarations(".chat-profile-picker-options", css);
|
||||
assert.match(menu, /max-height:\s*min\(50vh, 320px\)/);
|
||||
assert.match(menu, /overflow-x:\s*hidden/);
|
||||
assert.match(menu, /overflow-y:\s*auto/);
|
||||
const switcher = readFileSync(new URL("../src/components/subject-switcher.tsx", import.meta.url), "utf8");
|
||||
assert.match(switcher, /slice\(0, MAX_SAVED_SUBJECTS\)/);
|
||||
assert.doesNotMatch(switcher, /slice\(0,\s*5\)/);
|
||||
});
|
||||
|
||||
test("CSS: touch targets, list/detail switch below 1024, two columns at 1024", () => {
|
||||
const minHeight = (selector: string) => Number(cssDeclarations(selector, css).match(/min-height:\s*(\d+)px/)?.[1] ?? 0);
|
||||
assert.ok(minHeight(".people-archive-row") >= 44);
|
||||
|
||||
Reference in New Issue
Block a user