校正的选项卡片换成组件库(#110)。
Independent Staging Quality Gate / validate (push) Failing after 9m5s
Independent Staging Quality Gate / publish (push) Skipped

题目、计分、扣点和保存没有改。明确说要校正的人仍然直接开始,没做完的仍然接着做。选项改用单选标签,确定程度改用圆点,其余按钮改用已有组件。动效只淡入。

自测:tsc 通过;frontend lint 0 error(122 条原有警告);校正前端测试 2291 条,通过 2270、失败 21,与改前名单一致;Python test_rectification_*.py 26 个文件退出码 0;next build 后 / 仍是 Static;首屏 JS gzip-9 644683,相对 1516ad56 记下的 642174 增加 0.39%。隐私标记测试通过。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-25 06:29:38 +08:00
co-authored by Grok (agent loop)
parent 1516ad56ec
commit 728ead8844
22 changed files with 400 additions and 155 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-10-25 — 生时校正的选项卡片换成组件库
- 校正里的选项仍是圆角卡片。点中之后是浅底描边。确定程度仍是三个圆点,大概范围仍是一排标签。
- 盘面的关闭仍是一枚叉。重新打开、采用和其余确认都换成了同一套按钮。
- 题目出现时只淡入。系统开了减少动态效果时同样只淡入,没有位移,也没有来回循环。
- 题目、计分、扣点和保存都没有改。明确说要校正的人仍然直接开始,没做完的仍然接着做。
- Skill 版本不变(BUG-1438)。
## 2026-10-25 — 报告、星历和登录页换成组件库
- 报告的中文和 English 是一排分段。切换之后,页面上不留下上一种语言的正文。
+16
View File
@@ -19494,4 +19494,20 @@
- 复发自:无
- 修复版本:分支 `codex/ui-kit-5-pages-20261023`。
## BUG-1438 | 生时校正界面换成组件库
- 状态:enhancement
- 首次发现:2026-10-25
- 最近更新:2026-10-25
- 影响面:校正里的选项卡片、确定程度、大概范围、经历草稿的精度、盘面的关闭和重新打开、采用按钮。题目怎么出、怎么计分、怎么落库、怎么扣点,都不在这次的改动里。校正入口也不在这次:明确要求校正的人仍然直接开始,没做完的仍然接着做。
- 用户现象:选项仍是原来的圆角卡片,点中后浅底描边。确定程度仍是三个圆点。范围仍是一排标签。盘面关闭仍是一枚叉。题目出现时,如果动效已经载入,只淡入;系统开了减少动态效果时同样只淡入,没有位移,也没有循环。
- 触发条件:打开一次生时校正,或在建档时填写出生时间的确定程度。
- 根因:不是缺陷。组件库迁移的生时校正这一张(任务书 K6,决策 D4、D7、D9)。
- 修复:选项卡片和大概范围改用单选标签组。确定程度改用单选圆点。其余确认、关闭、重新打开和采用改用已有按钮。经历草稿的精度改用已有下拉,可见文字仍是「记得的精度」。动效用已有的展开预设,只淡入。校正对话仍在首页里,没有为了体积把整段校正拆出首页。盘面探头仍挂在顶栏原来的位置,没有改成弹窗。
- 验证:见 `docs/tasks/PROGRESS-ui-kit-6-rectification-20261023.md`。没有登录态,不把浏览器里走完一次校正写成已经做过。
- 防复发:选项卡片的合同仍锁住选中态、不出现「已选择」、不在卡片上抢焦点。白名单测试锁住名单只减不增。入口行为仍由 #103 的合同锁住,本张不改那条路径。
- 相关记录:issue #110。任务书 `docs/tasks/TASK-ui-kit-migration-20261023.md` 的 K6。校正入口沿用 #103,不另开编号。
- 复发自:无
- 修复版本:分支 `codex/ui-kit-6-rectification-20261023`。
@@ -0,0 +1,71 @@
# PROGRESS · 组件库 K6 生时校正界面(2026-10-25)
工单 #110。分支 `codex/ui-kit-6-rectification-20261023`。基线 `origin/staging` `1516ad56ec66c5e80ef2a1f0ef4d82152b163699`(#109 报告、星历、登录和 #103 校正入口都在这个提交里)。只做任务书 **K6 生时校正界面**。K7 没收尾。没有重做校正入口、报告、登录、星盘页。
## 结论
校正的选项卡片换成了组件库的单选标签。题目、计分、扣点和保存都没改。明确说要校正的人仍然直接开始,没做完的仍然接着做。
## 做了什么
- 选项卡片(引导题和对话里的判断卡)改用 `ToggleGroup`,一次只选一个。原来的圆角、浅底描边、题干和选项原文都留着。不写「已选择」,卡片自己也不抢焦点。
- 「你对这个时间有多确定?」改用 `RadioGroup`,仍是三个圆点。「选一个最接近的范围」改用 `ToggleGroup`。
- 其余确认、暂停、重试、关闭、重新打开、采用,改用已有的 `Button`。关闭盘面仍是 Lucide 的叉,读屏名仍是「关闭盘面」。没有用字符当图标。
- 经历草稿的精度改用已有下拉。可见文字仍是「记得的精度」,触发按钮用 `aria-labelledby` 指到这句,方便读屏。对照 `frontend/docs/VOICE.md`:没有新文案,没有改题目。
- 题目出现时,若 Motion 在这次挂载之前已经载入,用已有的 `disclosure` 预设只淡入。减少动态效果时时长用预设里的 0.15 秒淡入,没有位移。没有新增循环。动效还没载入时不包一层,避免已经在屏幕上的内容再闪一次。
- 盘面探头仍用原来的 `createPortal` 挂到顶栏,没有改成弹窗。`rectification-agentic-chat.tsx` 因此仍留在白名单里。
- 白名单去掉 12 个校正文件,没有加长。测试里写死的基线名单没有改。
- 没有改 `page.tsx`,没有把整段校正拆出首页。`Home()` 的 useState / useRef 没有增长。没有给 `scripts/jyotish_api_server.py` 加方法。没有改 `.gitea/**`,没有改生产部署,没有改数据库,没有升级依赖。
移出白名单的 12 个文件:`birth-time-candidate-result.tsx`、`birth-time-choice-question.tsx`、`birth-time-evidence-draft-card.tsx`、`birth-time-guide-turn.tsx`、`birth-time-intake.tsx`、`birth-time-legacy-rectification.tsx`、`birth-time-rectification.tsx`、`rectification-board.tsx`、`rectification-choice-card.tsx`、`rectification-composer-reopen.tsx`、`rectification-range-delivery.tsx`、`rectification-segment-delivery.tsx`。
## 断言改动(原值 / 新值 / 原因)
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `frontend/tests/rectification-agentic-entry.test.ts` 持久化选择题的选项 class | `class="birth-time-choice-option is-primary"` 整段恰好 4 次 | 同一串作为 class 的一部分出现 4 次 | K6 选项改用 ToggleGroup,组件库会合并上自己的类。选项文案和读屏名称不变 |
选中态源码 `data-selected={selectedKey === option.key ? "true" : "false"}` 没有改。卡片仍不得出现 `Check`、「已选择」和 `.focus(`。
## 自测
改前先在同一批测试上记过一笔。Windows 上本来就有 21 条失败。改完再跑,失败名单没有变长,通过的没有变少。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | 仓库根 `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`,233 个 `rectification-*` / `birth-time-*` 测试文件加 `ui-kit-whitelist.test.ts` | 2291 条。通过 2270,失败 21,取消 0。与改前一致 |
| Python | `.venv\Scripts\python.exe -m pytest`,`tests/test_rectification_*.py` 共 26 个文件 | 退出码 0,进度到 100% |
| 构建 | 在 `frontend` 下 `npm run build` | 退出码 0。编译 10.3s,TypeScript 30.5s。79 个静态页。`/` 仍是 ○ Static。5 条既有 skill-package-registry 警告,没有改 |
| 首屏体积 | 首页 `index.html` 引用的 `/_next/static/**.js`,gzip-9 相加,不含 CSS | 基线是 #109 在 `1516ad56` 记下的 35 个文件、642174 字节。本张 37 个文件,缺失 0,644683 字节,多 2509(+0.39%)。±2% 大约是 629331 到 655017。没有把校正拆出首页 |
改前就失败、改完仍是这 21 条(没有新增):
1. public code never imports the internal dynamic choice contract(路径被拼成 `G:\G:\...`)
2. candidate scores stay out of the specified client ownership boundary(同上)
3. A · typed answer to a pending choice(BUG-1045,`getComputedStyle`)
4. A · collect denial with a next choice(BUG-1045)
5. B · failed choice submit 409(BUG-1046,`window is not defined`)
6. B · failed choice submit network error(BUG-1046)
7. B · typed answer that never reaches the server
8. D3 · live focus moves to the latest message(BUG-635)
9. D3 · persisted question with no asked turn
10. D3 · standalone block fallback
11. D4 · willContinue
12. D4 · send() settle
13. C · superseded focus(BUG-1135)
14. C · client guard(BUG-1135)
15. C · stem left in the body(BUG-1135)
16. Skill bump 10.0.32 keeps Cases pinned(BUG-621)
17. D3 · first stage line
18. D3 · turn.rejected
19. opening on the real surface · body only
20. opening on the real surface · body + stem
21. agent pins the dedicated rectification skill and its fixed version
没有跑前端全量 `npm test`,没有跑 Python 快速门,没有跑 `test:db`。没有为了让测试通过去改计分或落库。
## 环境缺口
- 没有登录态,没有模型凭据。没有在浏览器里走完一次校正。不写成通过。可照做的步骤在 `docs/testing/ui-kit-migration-20261023.md` 的「生时校正」一节。
+11
View File
@@ -87,3 +87,14 @@
2. 走到填写六位验证码时,是六格分开的输入。恢复码仍是一条输入框。
3. 注册账号、忘记密码、返回登录仍是这几个字。没勾同意就继续,仍会提示先阅读并勾选。
4. 打开那一刻的天空。星星仍会汇聚到定格。左上关闭、右上分享仍在。没有重画星星。
## 生时校正
没有登录态,没有在浏览器里走完一次校正,也没有模型凭据。下面这些步骤留给有账号的人照做。
1. 在对话里明确说要校正出生时间。界面直接开始一次新的校正,不再先出一张卡让你点。已经开始、还没做完的,接着上次做,不另开一轮。
2. 题目仍是原来的句子。选项是圆角卡片,点中后变成浅底描边。读屏能读到题干和选项原文,没有多出来的「已选择」。
3. 建档或改出生时间时,「你对这个时间有多确定?」仍是三个圆点。选了大概时间之后,「选一个最接近的范围」仍是一排标签。
4. 校正进行中可以打开当前盘面,再关掉。关闭是一枚叉,读屏名是「关闭盘面」。窄屏上的探头仍在顶栏,不是弹窗。
5. 系统开了减少动态效果时,新出现的题目只淡入,选项不飞,也没有一直转的动画。
6. 做完或停下来之后,已经记下的范围还在。扣点和保存跟换皮之前一样。
+2
View File
@@ -49,6 +49,8 @@
不新增等待动画;打开校正沿用既有一次揭幕。
**选项和按钮换成组件库(2026-10-25,K6)**:选项卡片仍是原来的圆角卡片,点中后浅底描边,读屏仍读题干和选项原文,不另写「已选择」。确定程度仍是三个圆点。大概范围仍是一排标签。盘面关闭仍是一枚叉,探头仍挂在顶栏原来的位置,不是弹窗。其余确认、重新打开和采用用组件库按钮,样子沿用原来的实心、描边和文字按钮。题目出现时,若动效已经载入,只淡入;减少动态效果时同样只淡入,没有位移,也没有循环。题目、计分、扣点和保存没有改。明确要求校正仍直接开始,没做完的接着做。
**明确要求则直接打开(2026-10-12,BUG-1306)**:用户这一轮明说要做校正时,不再先出卡再点。回答只留一句过渡,界面沿用既有一次揭幕直接打开校正面。没有新的按钮、输入框、等待动画或 Home 状态。推得不准时的主动提议仍用 `.rectification-offer-card`。做不了时只显示一句话,不静默,也不在对话里假装校正。开案还没成功时,同一张提议卡仍是普通提议(不说「已经开始」,按钮仍是「开始核对出生时间」)。开案成功并绑定来源后才换成已开始的文案。打开失败时按钮仍可点。
## 用量页分段计时(2026-10-05,在途)
+1 -13
View File
@@ -3,21 +3,9 @@
"src/components/ayanamsa-preference-field.tsx",
"src/components/birth-place-picker.tsx",
"src/components/birth-sky/birth-sky-converge.tsx",
"src/components/birth-time-candidate-result.tsx",
"src/components/birth-time-choice-question.tsx",
"src/components/birth-time-evidence-draft-card.tsx",
"src/components/birth-time-guide-turn.tsx",
"src/components/birth-time-intake.tsx",
"src/components/birth-time-legacy-rectification.tsx",
"src/components/birth-time-rectification.tsx",
"src/components/consultation-run-timeline.tsx",
"src/components/home-bootstrap-error.tsx",
"src/components/home-onboarding-shell.tsx",
"src/components/personal-report/personal-report-markdown-view.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/rectification-agentic-chat.tsx"
]
+6 -5
View File
@@ -5,14 +5,15 @@
.birth-time-choice-question legend { width: 100%; margin: 0 0 var(--space-3); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; line-height: 1.55; text-wrap: pretty; word-break: auto-phrase; }
.birth-time-primary-choices { display: grid; gap: var(--space-2); }
.birth-time-special-choices, .birth-time-reframe-actions, .birth-time-journey-controls { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.birth-time-choice-option { min-height: 52px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.5; text-align: left; text-wrap: pretty; word-break: auto-phrase; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; }
.birth-time-choice-option { min-height: 52px; height: auto; justify-content: flex-start; white-space: normal; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; font-size: var(--type-body-sm); font-weight: 400; line-height: 1.5; text-align: left; text-wrap: pretty; word-break: auto-phrase; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; }
.birth-time-choice-option.is-primary { width: 100%; padding: var(--space-3) var(--space-4); }
.birth-time-choice-option.is-secondary { min-height: 44px; flex: 1 1 0; padding: var(--space-2) var(--space-4); color: var(--color-ink-secondary); text-align: center; }
.birth-time-choice-option:hover:not(:disabled) { border-color: var(--color-border-strong); background: var(--color-canvas-soft); }
.birth-time-choice-option.is-secondary { min-height: 44px; flex: 1 1 0; justify-content: center; padding: var(--space-2) var(--space-4); color: var(--color-ink-secondary); text-align: center; }
.birth-time-choice-option:hover:not(:disabled) { border-color: var(--color-border-strong); background: var(--color-canvas-soft); color: var(--color-ink); }
.birth-time-choice-option.is-secondary:hover:not(:disabled) { color: var(--color-ink-secondary); }
.birth-time-choice-option:active:not(:disabled) { transform: scale(.96); }
.birth-time-choice-option[data-selected="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-ink); }
.birth-time-choice-option:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
.birth-time-choice-option[data-selected="true"]:focus-visible { outline: none; }
.birth-time-choice-option:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; box-shadow: none; border-color: var(--color-border); }
.birth-time-choice-option[data-selected="true"]:focus-visible { outline: none; box-shadow: none; border-color: var(--color-action); }
@media (forced-colors: active) {
.birth-time-choice-option[data-selected="true"]:focus-visible { outline: 2px solid Highlight; outline-offset: -2px; }
}
+22 -5
View File
@@ -1261,14 +1261,17 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
.birth-time-source-option { min-height: 64px; display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; }
.birth-time-source-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); }
.birth-time-source-option input { width: 16px; height: 16px; margin: 0; accent-color: var(--color-action); }
.birth-time-source-option [data-slot="radio-group-item"] { width: 16px; height: 16px; margin: 0; accent-color: var(--color-action); }
.birth-time-source-option > span { display: grid; gap: 3px; }
.birth-time-source-option b { color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 600; }
.birth-time-source-option small, .birth-time-detail-note, .birth-time-legacy-note { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; }
.birth-time-uncertainty-fieldset { margin: var(--space-3) 0 0; padding: 0; border: 0; }
.birth-time-uncertainty-fieldset legend { margin-bottom: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; }
.birth-time-uncertainty-list { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.birth-time-uncertainty-option { min-height: 44px; padding: 0 var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); font-size: var(--type-body-sm); cursor: pointer; }
.birth-time-uncertainty-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); }
.birth-time-uncertainty-list { display: flex; flex-wrap: wrap; gap: var(--space-2); width: 100%; }
.birth-time-uncertainty-option { min-height: 44px; height: auto; justify-content: center; white-space: normal; padding: 0 var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 400; cursor: pointer; }
.birth-time-uncertainty-option:hover { background: var(--color-canvas); color: var(--color-ink); }
.birth-time-uncertainty-option.is-selected,
.birth-time-uncertainty-option.is-selected:hover { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-ink); }
.birth-time-detail-grid { display: grid; grid-template-columns: minmax(0, 180px) minmax(0, 1fr); align-items: end; gap: var(--space-3); }
.birth-time-detail-grid > label { min-width: 0; }
.birth-time-detail-note { padding-bottom: 12px; }
@@ -1327,8 +1330,9 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
.birth-time-guide-composer textarea { min-height: var(--space-24); resize: vertical; line-height: 1.55; }
.birth-time-guide-hint, .birth-time-scoring-status p, .birth-time-candidate-terminal p, .birth-time-success-note { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; text-wrap: pretty; word-break: auto-phrase; }
.birth-time-guided-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
.birth-time-guided-action { min-height: 44px; padding-inline: var(--space-4); font-size: var(--type-body-sm); }
.birth-time-guided-action { min-height: 44px; height: auto; padding-inline: var(--space-4); font-size: var(--type-body-sm); white-space: normal; }
.button-text.birth-time-guided-action { border: 0; background: transparent; color: var(--color-action); cursor: pointer; }
.button-text.birth-time-guided-action:hover { background: transparent; color: var(--color-action); }
.birth-time-draft-domain { margin: 0; padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); }
.birth-time-draft-domain > div { display: grid; gap: var(--space-1); }
.birth-time-draft-domain dt { color: var(--color-ink-tertiary); font-size: var(--type-overline); }
@@ -2893,6 +2897,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.birth-time-source-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); box-shadow: inset 0 0 0 1px var(--color-action); }
.birth-time-source-option input { appearance: none; align-self: center; justify-self: center; position: static; box-sizing: border-box; width: 16px; min-width: 16px; max-width: 16px; height: 16px; min-height: 16px; max-height: 16px; margin: 0; overflow: visible; clip: auto; clip-path: none; border: 1.5px solid var(--color-border-strong); border-radius: 50%; background: var(--color-canvas); }
.birth-time-source-option input:checked { border-color: var(--color-action); background: var(--color-action-strong); box-shadow: inset 0 0 0 4px var(--color-action-soft); }
.birth-time-source-option [data-slot="radio-group-item"] { appearance: none; align-self: center; justify-self: center; position: static; box-sizing: border-box; width: 16px; min-width: 16px; max-width: 16px; height: 16px; min-height: 16px; max-height: 16px; margin: 0; overflow: visible; clip: auto; clip-path: none; border: 1.5px solid var(--color-border-strong); border-radius: 50%; background: var(--color-canvas); box-shadow: none; }
.birth-time-source-option [data-slot="radio-group-item"][data-checked] { border-color: var(--color-action); background: var(--color-action-strong); box-shadow: inset 0 0 0 4px var(--color-action-soft); }
.birth-time-source-option [data-slot="radio-group-item"]:focus-visible { outline: none; box-shadow: none; }
.birth-time-source-option [data-slot="radio-group-indicator"] { display: none; }
.birth-time-source-option > span { gap: 4px; }
.birth-time-source-option b { font-size: var(--type-body-sm); line-height: 1.35; }
.birth-time-source-option small { font-size: var(--type-caption); line-height: 1.5; }
@@ -2906,7 +2914,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
line-height: 1.5;
}
.birth-time-window-details .birth-time-skip-button { grid-column: 1 / -1; justify-self: start; min-height: 44px; padding-inline: 13px; border-color: transparent; background: transparent; color: var(--color-action); font-size: var(--type-caption); }
.birth-time-window-details .birth-time-skip-button { grid-column: 1 / -1; justify-self: start; min-height: 44px; height: auto; padding-inline: 13px; border-color: transparent; background: transparent; color: var(--color-action); font-size: var(--type-caption); font-weight: 400; white-space: normal; }
.birth-time-window-details .birth-time-skip-button:hover { background: color-mix(in srgb, var(--color-action-soft) 72%, transparent); }
.birth-time-detail-grid > label { gap: var(--space-2); }
.birth-time-detail-grid > label > span { color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; }
@@ -3500,6 +3508,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
justify-content: space-between;
gap: var(--space-3);
width: 100%;
height: auto;
min-height: 44px;
margin: 0 0 var(--space-2);
padding: var(--space-2) var(--space-4);
@@ -3507,7 +3516,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
border-radius: var(--radius-md);
background: var(--color-canvas-soft);
color: var(--color-ink);
font-weight: 400;
text-align: start;
white-space: normal;
cursor: pointer;
}
.rectification-board-peek strong { font-size: var(--type-body-sm); }
@@ -3805,6 +3816,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.rectification-range-delivery__accept {
justify-self: stretch;
height: auto;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--color-border);
@@ -3813,8 +3825,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
background: var(--color-canvas);
font: inherit;
font-weight: 600;
white-space: normal;
cursor: pointer;
}
.rectification-range-delivery__accept:hover {
background: var(--color-canvas);
color: var(--color-action);
}
.rectification-range-delivery__accept[aria-pressed="true"] {
border-color: color-mix(in srgb, var(--color-action) 55%, var(--color-border));
}
@@ -4,6 +4,7 @@ import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
import { guidedTerminalPath } from "@/lib/birth-time-guided-terminal";
import { ErrorToast } from "@/components/error-toast";
import { Button } from "@/components/ui/button";
type CandidateResultProps = {
readonly journey: JourneyClientResponse;
@@ -44,7 +45,7 @@ export function BirthTimeCandidateResult({ journey, controller, error }: Candida
<div className="birth-time-candidate-result" aria-live="polite">
<p className="birth-time-assessment-unavailable">尚未进入分钟计算:还没有可评分的关键经历资料。</p>
<p className="birth-time-evidence-boundary">当前范围仅为填报范围,不是校正结果;请补充跨领域、可注明年月的重大事件后重新评估。</p>
{terminalPath && <button className="button-secondary birth-time-guided-action" disabled={controller.pending} type="button" onClick={controller.editBirthTimeDetails}>补充资料并重新评估</button>}
{terminalPath && <Button className="button-secondary birth-time-guided-action" disabled={controller.pending} type="button" variant="outline" onClick={controller.editBirthTimeDetails}>补充资料并重新评估</Button>}
</div>
);
}
@@ -110,9 +111,9 @@ export function BirthTimeCandidateResult({ journey, controller, error }: Candida
{action.kind === "present_medium_result" && winner && !dynamic && (
<div className="birth-time-candidate-terminal">
<p>可以保存候选时间范围,代表时间不会<span className="phrase-nowrap">用于</span>排盘。</p>
<button className="button-primary birth-time-guided-action" disabled={controller.pending} type="button" onClick={() => controller.saveCandidate(result.resultId)}>
<Button className="button-primary birth-time-guided-action" disabled={controller.pending} type="button" onClick={() => controller.saveCandidate(result.resultId)}>
{controller.pending ? "保存中…" : "保存候选范围"}
</button>
</Button>
</div>
)}
{action.kind === "candidate_saved" && (
@@ -125,15 +126,15 @@ export function BirthTimeCandidateResult({ journey, controller, error }: Candida
<div className="birth-time-confirmation-panel">
<b>确认<span className="phrase-nowrap">当前排盘使用时间</span></b>
<p><span className="phrase-nowrap">候选时间</span>为 {winner.representativeTime}。确认后它会成为<span className="phrase-nowrap">当前排盘使用时间</span>,<span className="phrase-nowrap">原始填报时间</span>仍会保留。</p>
<button className="button-primary birth-time-guided-action" disabled={controller.pending} type="button" onClick={() => controller.confirmCandidate(result.resultId, winner.representativeTime)}>
<Button className="button-primary birth-time-guided-action" disabled={controller.pending} type="button" onClick={() => controller.confirmCandidate(result.resultId, winner.representativeTime)}>
{controller.pending ? "确认中…" : `确认使用 ${winner.representativeTime}`}
</button>
</Button>
</div>
)}
{action.kind === "ready" && (
<div className="birth-time-candidate-terminal" role="status">
<p className="birth-time-success-note"><span className="phrase-nowrap">当前排盘使用时间</span>已更新为 {action.activeTime},<span className="phrase-nowrap">原始填报时间</span>仍已保留。</p>
<button className="button-primary birth-time-guided-action" disabled={controller.pending} type="button" onClick={controller.acknowledgeReady}>继续使用此排盘</button>
<Button className="button-primary birth-time-guided-action" disabled={controller.pending} type="button" onClick={controller.acknowledgeReady}>继续使用此排盘</Button>
</div>
)}
{!terminalPath ? <ErrorToast message={error} /> : null}
@@ -150,7 +151,7 @@ function TerminalAction({ controller, error, path }: {
<div className="birth-time-new-assessment">
<b>尚未达到采用条件</b>
<p>候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。</p>
<button className="button-secondary birth-time-guided-action" disabled={controller.pending} onClick={controller.editBirthTimeDetails} type="button">开始新的评估</button>
<Button className="button-secondary birth-time-guided-action" disabled={controller.pending} onClick={controller.editBirthTimeDetails} type="button" variant="outline">开始新的评估</Button>
<ErrorToast message={error} />
<small>会建立新的记录,当前结果仍会保留。</small>
</div>
@@ -2,6 +2,9 @@
import { useEffect, useRef, useState } from "react";
import { CharacterRemaining } from "@/components/character-remaining";
import { RectificationFade } from "@/components/rectification-fade";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { characterRemainingVisible } from "@/lib/character-remaining";
import {
choiceQuestionGroups,
@@ -62,41 +65,55 @@ export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) {
};
return (
<RectificationFade>
<div className="birth-time-choice-surface" aria-busy={props.pending}>
<BirthTimeQuestionProgress progress={props.progress} />
<fieldset className="birth-time-choice-question" disabled={props.pending}>
<legend>{props.question.prompt}</legend>
<div className="birth-time-primary-choices">
{groups.primary.map((option, index) => (
<button
className="birth-time-choice-option is-primary"
data-selected={selectedId === option.optionId}
key={option.optionId}
onClick={() => select(option)}
ref={index === 0 ? firstChoiceRef : undefined}
type="button"
>
{option.label}
</button>
))}
</div>
<div className="birth-time-special-choices">
{[groups.unknown, groups.unmatched].map((option) => (
<button
className="birth-time-choice-option is-secondary"
data-selected={selectedId === option.optionId}
key={option.optionId}
onClick={() => select(option)}
type="button"
>
{option.label}
</button>
))}
</div>
<ToggleGroup
className="contents"
style={{ display: "contents" }}
value={selectedId ? [selectedId] : []}
onValueChange={(next) => {
const id = next[0];
if (!id || id === selectedId) return;
const option = [...groups.primary, groups.unknown, groups.unmatched].find((item) => item.optionId === id);
if (option) select(option);
}}
>
<div className="birth-time-primary-choices">
{groups.primary.map((option, index) => (
<ToggleGroupItem
className="birth-time-choice-option is-primary"
data-selected={selectedId === option.optionId}
key={option.optionId}
ref={index === 0 ? firstChoiceRef : undefined}
type="button"
value={option.optionId}
>
{option.label}
</ToggleGroupItem>
))}
</div>
<div className="birth-time-special-choices">
{[groups.unknown, groups.unmatched].map((option) => (
<ToggleGroupItem
className="birth-time-choice-option is-secondary"
data-selected={selectedId === option.optionId}
key={option.optionId}
type="button"
value={option.optionId}
>
{option.label}
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
</fieldset>
{props.pending && <p className="birth-time-choice-pending" role="status">正在缩小候选范围…</p>}
<JourneyControls {...props} />
</div>
</RectificationFade>
);
}
@@ -111,6 +128,7 @@ export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
}
}, [props.error, props.pending, props.progress.answeredCount]);
return (
<RectificationFade>
<div className="birth-time-choice-surface" aria-busy={props.pending}>
<BirthTimeQuestionProgress progress={props.progress} />
<fieldset className="birth-time-choice-question" disabled={props.pending}>
@@ -129,13 +147,14 @@ export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
/>
</label>
<div className="birth-time-reframe-actions">
<button className="button-secondary birth-time-guided-action" onClick={() => props.onReframe("")} ref={reframeRef} type="button">换一道题</button>
<button className="button-primary birth-time-guided-action" onClick={() => props.onReframe(normalizeUnmatchedNote(note))} type="button">提交补充并换题</button>
<Button className="button-secondary birth-time-guided-action" onClick={() => props.onReframe("")} ref={reframeRef} type="button" variant="outline">换一道题</Button>
<Button className="button-primary birth-time-guided-action" onClick={() => props.onReframe(normalizeUnmatchedNote(note))} type="button">提交补充并换题</Button>
</div>
</fieldset>
{props.pending && <p className="birth-time-choice-pending" role="status">正在换一道更合适的问题…</p>}
<JourneyControls {...props} />
</div>
</RectificationFade>
);
}
@@ -143,8 +162,8 @@ function JourneyControls(props: SharedQuestionProps) {
return (
<>
<div className="birth-time-journey-controls">
<button className="button-text birth-time-guided-action" disabled={props.pending} onClick={props.onPause} type="button">暂停,稍后继续</button>
<button className="button-text birth-time-guided-action" disabled={props.pending} onClick={props.onFinish} type="button">结束并保存当前范围</button>
<Button className="button-text birth-time-guided-action" disabled={props.pending} onClick={props.onPause} type="button" variant="ghost">暂停,稍后继续</Button>
<Button className="button-text birth-time-guided-action" disabled={props.pending} onClick={props.onFinish} type="button" variant="ghost">结束并保存当前范围</Button>
</div>
<ErrorToast message={props.error} />
</>
@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { lifeEventSchema } from "@/lib/birth-time-evidence";
import type { EvidenceDraft } from "@/lib/birth-time-journey-turn";
import type { EvidenceDatePrecision, LegacyGuidedEvidenceDomain } from "@/lib/birth-time-question-planner";
@@ -21,12 +23,8 @@ type DraftCardProps = {
readonly onSkip: () => void;
};
function parsePrecision(value: string): EvidenceDatePrecision {
if (value === "year" || value === "month" || value === "day") return value;
throw new TypeError("Unsupported evidence date precision");
}
export function BirthTimeEvidenceDraftCard(props: DraftCardProps) {
const precisionLabelId = useId();
const [precision, setPrecision] = useState<EvidenceDatePrecision>(props.draft.precision ?? "year");
const [date, setDate] = useState(props.draft.date ?? "");
const isValid = lifeEventSchema.safeParse({
@@ -43,12 +41,25 @@ export function BirthTimeEvidenceDraftCard(props: DraftCardProps) {
<dl className="birth-time-draft-domain"><div><dt>经历领域</dt><dd>{domainLabels[props.draft.domain]}</dd></div></dl>
<div className="birth-time-draft-fields">
<label>
<span>记得的精度</span>
<select disabled={props.pending} value={precision} onChange={(event) => { setPrecision(parsePrecision(event.target.value)); setDate(""); }}>
<option value="year">只记得年份</option>
<option value="month">记得月份</option>
<option value="day">记得具体日期</option>
</select>
<span id={precisionLabelId}>记得的精度</span>
<Select
disabled={props.pending}
value={precision}
onValueChange={(next) => {
if (next !== "year" && next !== "month" && next !== "day") return;
setPrecision(next);
setDate("");
}}
>
<SelectTrigger aria-labelledby={precisionLabelId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="year">只记得年份</SelectItem>
<SelectItem value="month">记得月份</SelectItem>
<SelectItem value="day">记得具体日期</SelectItem>
</SelectContent>
</Select>
</label>
<label>
<span>发生时间</span>
@@ -66,10 +77,10 @@ export function BirthTimeEvidenceDraftCard(props: DraftCardProps) {
</div>
{!isValid && <p className="form-error" role="alert">请填写与所选精度一致的有效日期;<span className="phrase-nowrap">不会自动补猜缺失时间</span>。</p>}
<div className="birth-time-guided-actions">
<button className="button-secondary birth-time-guided-action" disabled={props.pending} type="button" onClick={props.onSkip}>放弃这条并跳过</button>
<button className="button-primary birth-time-guided-action" disabled={props.pending || !isValid} type="button" onClick={() => props.onConfirm(precision, date)}>
<Button className="button-secondary birth-time-guided-action" disabled={props.pending} type="button" variant="outline" onClick={props.onSkip}>放弃这条并跳过</Button>
<Button className="button-primary birth-time-guided-action" disabled={props.pending || !isValid} type="button" onClick={() => props.onConfirm(precision, date)}>
{props.pending ? "确认中…" : "确认并用于校正"}
</button>
</Button>
</div>
</div>
);
@@ -2,6 +2,7 @@
import { useState } from "react";
import { CharacterRemaining } from "@/components/character-remaining";
import { Button } from "@/components/ui/button";
import { characterRemainingVisible } from "@/lib/character-remaining";
import type { JourneyProgress } from "@/lib/birth-time-journey-turn";
import { protectOnboardingPhrases } from "@/lib/onboarding-copy";
@@ -57,11 +58,11 @@ export function BirthTimeGuideTurn(props: BirthTimeGuideTurnProps) {
</label>
<p id="birth-time-guide-hint" className="birth-time-guide-hint">Enter 发送,Shift+Enter 换行。说出大概年份也可以,不需要为了校正而猜测。</p>
<div className="birth-time-guided-actions">
<button className="button-text birth-time-guided-action" disabled={props.pending} type="button" onClick={props.onPause}>暂停,稍后继续</button>
<button className="button-secondary birth-time-guided-action" disabled={props.pending} type="button" onClick={props.onSkip}>不记得,跳过</button>
<button className="button-primary birth-time-guided-action" disabled={props.pending || !message.trim()} type="button" onClick={() => props.onSubmit(message.trim())}>
<Button className="button-text birth-time-guided-action" disabled={props.pending} type="button" variant="ghost" onClick={props.onPause}>暂停,稍后继续</Button>
<Button className="button-secondary birth-time-guided-action" disabled={props.pending} type="button" variant="outline" onClick={props.onSkip}>不记得,跳过</Button>
<Button className="button-primary birth-time-guided-action" disabled={props.pending || !message.trim()} type="button" onClick={() => props.onSubmit(message.trim())}>
{props.pending ? "整理中…" : "整理为经历草稿"}
</button>
</Button>
</div>
</div>
);
+53 -35
View File
@@ -2,7 +2,10 @@
import { useId, useState } from "react";
import { BirthDatePicker } from "@/components/birth-date-picker";
import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent";
import {
approximateUncertaintyOptions,
@@ -83,7 +86,8 @@ export function BirthTimeIntakeFields({ value, onPatch, lockReportedTime = false
if (lockReportedTime) return;
onPatch(patch);
};
const groupId = useId();
const sourceLegendId = useId();
const uncertaintyLegendId = useId();
const source = value.birthTimeSource;
const isConfirmed = value.birthTimeStatus === "confirmed";
const displayState = birthTimeDisplayState(value);
@@ -143,33 +147,36 @@ export function BirthTimeIntakeFields({ value, onPatch, lockReportedTime = false
)}
{!isConfirmed && <fieldset className="birth-time-source-fieldset">
<legend>你对这个时间有多确定?</legend>
<legend id={sourceLegendId}>你对这个时间有多确定?</legend>
<p className="birth-time-source-intro">只用在这里问一次。校正会按你选的范围去比,不再追问你有多确定。</p>
<div className="birth-time-source-list">
<RadioGroup
aria-labelledby={sourceLegendId}
className="birth-time-source-list"
value={selectedSourceOption}
onValueChange={(next) => {
const option = birthTimeSourceOptions.find((item) => item.value === next);
if (!option) return;
emitTime({
birthTimeSource: option.value,
birthTimeStatus: "reported",
time: "",
...birthTimeSourceDefaults[option.value],
});
}}
>
{birthTimeSourceOptions.map((option) => (
<label
className={`birth-time-source-option ${option.value === selectedSourceOption ? "is-selected" : ""}`}
key={option.value}
>
<input
checked={option.value === selectedSourceOption}
name={`birth-time-source-${groupId}`}
type="radio"
value={option.value}
onChange={() => emitTime({
birthTimeSource: option.value,
birthTimeStatus: "reported",
time: "",
...birthTimeSourceDefaults[option.value],
})}
/>
<RadioGroupItem value={option.value} />
<span>
<b>{option.label}</b>
<small>{option.hint}</small>
</span>
</label>
))}
</div>
</RadioGroup>
</fieldset>}
{usesClockTime && (
@@ -187,28 +194,38 @@ export function BirthTimeIntakeFields({ value, onPatch, lockReportedTime = false
)}
{(source === "approximate" || source === "family_exact") && (
<fieldset className="birth-time-uncertainty-fieldset">
<legend>选一个最接近的范围</legend>
<div className="birth-time-uncertainty-list">
<legend id={uncertaintyLegendId}>选一个最接近的范围</legend>
<ToggleGroup
aria-labelledby={uncertaintyLegendId}
className="birth-time-uncertainty-list"
value={approximateRadius == null ? [] : [String(approximateRadius)]}
onValueChange={(next) => {
const minutes = Number(next[0]);
const option = approximateUncertaintyOptions.find((item) => item.minutes === minutes);
if (!option) return;
emitTime(
option.minutes === 15 && source === "family_exact"
? {
uncertaintyBeforeMinutes: 0,
uncertaintyAfterMinutes: 0,
}
: {
birthTimeSource: "approximate",
uncertaintyBeforeMinutes: option.minutes,
uncertaintyAfterMinutes: option.minutes,
},
);
}}
>
{approximateUncertaintyOptions.map((option) => (
<button
<ToggleGroupItem
className={`birth-time-uncertainty-option ${approximateRadius === option.minutes ? "is-selected" : ""}`}
key={option.minutes}
type="button"
onClick={() => emitTime(
option.minutes === 15 && source === "family_exact"
? {
uncertaintyBeforeMinutes: 0,
uncertaintyAfterMinutes: 0,
}
: {
birthTimeSource: "approximate",
uncertaintyBeforeMinutes: option.minutes,
uncertaintyAfterMinutes: option.minutes,
},
)}
>{option.label}</button>
value={String(option.minutes)}
>{option.label}</ToggleGroupItem>
))}
</div>
</ToggleGroup>
</fieldset>
)}
</div>
@@ -239,9 +256,10 @@ export function BirthTimeIntakeFields({ value, onPatch, lockReportedTime = false
onChange={(declaredWindowEnd) => emitTime({ declaredWindowEnd })}
/>
</label>
<button
<Button
className="button-secondary birth-time-skip-button"
type="button"
variant="outline"
onClick={() => emitTime({
birthTimeSource: "unknown",
reportedTime: "",
@@ -254,7 +272,7 @@ export function BirthTimeIntakeFields({ value, onPatch, lockReportedTime = false
birthTimeStatus: "reported",
time: "",
})}
>完全不清楚,跳过出生时间</button>
>完全不清楚,跳过出生时间</Button>
</div>
)}
@@ -9,6 +9,7 @@ import type { LegacyJourneyClientResponse } from "@/lib/birth-time-journey-respo
import { guidedTurnIdentity } from "@/lib/birth-time-guided-turn-identity";
import type { NextAction } from "@/lib/birth-time-journey-turn";
import { ErrorToast } from "@/components/error-toast";
import { Button } from "@/components/ui/button";
type LegacyRectificationProps = {
readonly journey: LegacyJourneyClientResponse;
@@ -75,8 +76,8 @@ export function BirthTimeLegacyRectification(props: LegacyRectificationProps) {
/>
)}
{action.kind === "score_pending" && <p className="birth-time-scoring-status" role="status">正在使用已确认的关键经历评分…</p>}
{action.kind === "retry_scoring" && <button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.retryScoring} type="button">重新评分</button>}
{action.kind === "paused" && <button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.resume} type="button">继续校正</button>}
{action.kind === "retry_scoring" && <Button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.retryScoring} type="button">重新评分</Button>}
{action.kind === "paused" && <Button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.resume} type="button">继续校正</Button>}
{showsCandidate ? (
<BirthTimeCandidateResult
controller={props.controller}
@@ -12,6 +12,7 @@ import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-jo
import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
import type { DynamicNextAction } from "@/lib/birth-time-journey-turn-protocol";
import { ErrorToast } from "@/components/error-toast";
import { Button } from "@/components/ui/button";
type BirthTimeRectificationProps = {
readonly journey: JourneyClientResponse;
@@ -108,16 +109,16 @@ export function BirthTimeRectification(props: BirthTimeRectificationProps) {
/>
)}
{(action.kind === "generate_dynamic_question" || action.kind === "retry_question_generation") && error && (
<button className="button-secondary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.retryQuestionGeneration} type="button">重试当前问题</button>
<Button className="button-secondary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.retryQuestionGeneration} type="button" variant="outline">重试当前问题</Button>
)}
{action.kind === "score_pending" && (
<div className="birth-time-scoring-status" aria-live="polite">
<b>正在缩小候选范围…</b>
{props.controller.pollRecoverable && <button className="button-secondary birth-time-guided-action" onClick={props.controller.retryScoring} type="button">重新检查评分状态</button>}
{props.controller.pollRecoverable && <Button className="button-secondary birth-time-guided-action" onClick={props.controller.retryScoring} type="button" variant="outline">重新检查评分状态</Button>}
</div>
)}
{action.kind === "retry_scoring" && <button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.retryScoring} type="button">重新评分</button>}
{action.kind === "paused" && <button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.resume} type="button">继续本次评估</button>}
{action.kind === "retry_scoring" && <Button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.retryScoring} type="button">重新评分</Button>}
{action.kind === "paused" && <Button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.resume} type="button">继续本次评估</Button>}
{showsCandidate ? (
<BirthTimeCandidateResult
controller={props.controller}
@@ -2,6 +2,7 @@
import { X } from "lucide-react";
import { useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
groupWindowTransitions,
houseTableToNorthIndianChart,
@@ -99,14 +100,15 @@ function RectificationBoardBody({
? <span className="rectification-board__clock is-declared">{emptyCopy.clock}</span>
: null}
{compact && (
<button
<Button
className="rectification-board__close"
type="button"
variant="ghost"
aria-label="关闭盘面"
onClick={onClose}
>
<X aria-hidden="true" strokeWidth={2} />
</button>
</Button>
)}
</div>
{stage ? <p className="rectification-board__stage">{engineMeaningToDisplayCopy(stage)}</p> : null}
@@ -338,9 +340,10 @@ export function RectificationBoardPeek({
}>) {
const peek = rectificationBoardPeekCopy(result, declaredTime, birthTimeSource);
return (
<button
<Button
className="rectification-board-peek"
type="button"
variant="ghost"
aria-label={peek.detail ? `${peek.title},${peek.detail}` : peek.title}
aria-expanded={expanded}
aria-controls={boardId}
@@ -348,6 +351,6 @@ export function RectificationBoardPeek({
>
<strong>{peek.title}</strong>
<span>{peek.detail}</span>
</button>
</Button>
);
}
@@ -2,6 +2,8 @@
import { useState } from "react";
import { InlineSpinner } from "@/components/inline-spinner";
import { RectificationFade } from "@/components/rectification-fade";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
CHOICE_STOP_LABEL,
type RectificationChoiceCard as ChoiceCard,
@@ -40,6 +42,7 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
const hidePrompt = embedded || props.hidePrompt === true;
return (
<RectificationFade>
<section
className={`rectification-choice-card${embedded ? " is-embedded" : ""}${props.pending ? " is-pending" : ""}${answered ? " is-answered" : ""}`}
aria-label={props.card.scoring ? "校正判断" : "盘外核对"}
@@ -66,30 +69,46 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
<p>{props.card.why_user}</p>
</details>
) : null}
<div className="birth-time-primary-choices">
{props.card.options.map((option) => (
<button
key={option.key}
type="button"
className="birth-time-choice-option is-primary"
data-selected={selectedKey === option.key ? "true" : "false"}
onClick={() => select(option.key)}
>
<strong>{option.key}.</strong> {option.label}
</button>
))}
{props.card.stop_label !== CHOICE_STOP_LABEL ? (
<button
type="button"
className="birth-time-choice-option is-primary"
data-selected={selectedKey === "stop" || selectedKey === "skip_probe" ? "true" : "false"}
onClick={stop}
>
{props.card.stop_label}
</button>
) : null}
</div>
<ToggleGroup
className="contents"
style={{ display: "contents" }}
value={selectedKey ? [selectedKey] : []}
onValueChange={(next) => {
const key = next[0];
if (!key || key === selectedKey) return;
if (key === "stop") {
stop();
return;
}
if (props.card.options.some((option) => option.key === key)) select(key as ChoiceKey);
}}
>
<div className="birth-time-primary-choices">
{props.card.options.map((option) => (
<ToggleGroupItem
key={option.key}
type="button"
value={option.key}
className="birth-time-choice-option is-primary"
data-selected={selectedKey === option.key ? "true" : "false"}
>
<strong>{option.key}.</strong> {option.label}
</ToggleGroupItem>
))}
{props.card.stop_label !== CHOICE_STOP_LABEL ? (
<ToggleGroupItem
type="button"
value="stop"
className="birth-time-choice-option is-primary"
data-selected={selectedKey === "stop" || selectedKey === "skip_probe" ? "true" : "false"}
>
{props.card.stop_label}
</ToggleGroupItem>
) : null}
</div>
</ToggleGroup>
</fieldset>
</section>
</RectificationFade>
);
}
@@ -1,3 +1,4 @@
import { Button } from "@/components/ui/button";
import { RECTIFICATION_REOPEN_LABEL } from "@/lib/rectification-session-composer-guard";
export function RectificationComposerReopen({
@@ -10,9 +11,9 @@ export function RectificationComposerReopen({
return (
<div className="error-message" role="alert">
<p>{message}</p>
<button type="button" className="button-secondary" onClick={onReopen}>
<Button type="button" className="button-secondary" variant="outline" onClick={onReopen}>
{RECTIFICATION_REOPEN_LABEL}
</button>
</Button>
</div>
);
}
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { loadDomAnimation, loadMotionReact, peekMotionReact, type MotionReact } from "@/lib/motion/load-react";
import { motionPresets } from "@/lib/motion/presets";
function useMotionReact(): MotionReact | null {
const [mod, setMod] = useState<MotionReact | null>(() => peekMotionReact());
useEffect(() => {
if (mod) return;
let live = true;
void loadMotionReact().then((next) => {
if (live) setMod(next);
}, () => undefined);
return () => {
live = false;
};
}, [mod]);
return mod;
}
/**
* Fade once, and only when Motion was already loaded before this mount.
* Otherwise return the children unchanged so a late chunk does not wrap
* content that is already on screen. Reduced motion stays a short fade.
*/
export function RectificationFade({ children }: { readonly children: ReactNode }) {
const [readyAtMount] = useState(() => peekMotionReact() !== null);
const motion = useMotionReact();
if (!readyAtMount || motion === null) return children;
return <FadeIn motion={motion}>{children}</FadeIn>;
}
function FadeIn({ motion, children }: { readonly motion: MotionReact; readonly children: ReactNode }) {
const reduced = motion.useReducedMotion();
const preset = motionPresets.disclosure;
const duration = reduced ? preset.reduced.duration : preset.duration;
return (
<motion.LazyMotion features={loadDomAnimation} strict>
<motion.m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration, ease: [...preset.ease] }}
>
{children}
</motion.m.div>
</motion.LazyMotion>
);
}
@@ -17,6 +17,7 @@ import {
import { RectificationVerificationReport } from "@/components/rectification-verification-report";
import { RectificationSegmentDelivery } from "@/components/rectification-segment-delivery";
import { sessionOutcomeAllowsAdopt } from "@/lib/rectification-agentic/core/rectification-decision";
import { Button } from "@/components/ui/button";
import { deliveryWindowFromReceipt, protectVargaReport } from "@/lib/rectification-agentic/v9/delivery-window";
@@ -159,8 +160,9 @@ export function RectificationRangeDelivery({
)}
{adoptAllowed && !adoptLocked ? (
<>
<button
<Button
type="button"
variant="outline"
className="rectification-range-delivery__accept"
aria-pressed={adopted}
disabled={busy || adopted}
@@ -173,7 +175,7 @@ export function RectificationRangeDelivery({
: recordConflict
? SWITCH_TO_RECTIFIED_LABEL
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
</button>
</Button>
{recordConflict && !adopted ? (
<p className="rectification-range-delivery__switch-note">
{switchToRectifiedConfirmCopy(recordConflict.reported_time, column.time)}
@@ -7,6 +7,7 @@ import { SWITCH_TO_RECTIFIED_LABEL } from "@/lib/rectification-agentic/birth-tim
import { RectificationVerificationReport } from "@/components/rectification-verification-report";
import { protectVargaReport } from "@/lib/rectification-agentic/v9/delivery-window";
import { unknownProductDeliveryCopy } from "@/lib/rectification-agentic/v9/product-delivery";
import { Button } from "@/components/ui/button";
export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, selection, resultId, busy, accepting, onAccept, adoptLocked, markdown, candidateDate, canAdopt = false }: Readonly<{
summary: SegmentSummary | null;
@@ -57,12 +58,12 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
{showAdopt && adoption && candidateId ? (
<>
<p className="rectification-range-delivery__muted">采用后按 {adoption.date ? `${adoption.date} ` : ""}{adoption.time} 排盘;原填报资料保留。</p>
<button type="button" className="rectification-range-delivery__accept" disabled={busy || adopted}
<Button type="button" variant="outline" className="rectification-range-delivery__accept" disabled={busy || adopted}
aria-pressed={adopted} onClick={() => onAccept(candidateId)}>
{accepting ? RECTIFICATION_USER_COPY.rangeDeliveryAdopting : adopted ? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
: readopt && priorAdoption ? `改用 ${readopt}`
: delivery.record_conflict ? SWITCH_TO_RECTIFIED_LABEL : `改用 ${charts.map((chart) => chart.chart).join(" + ")} 盘解读`}
</button>
</Button>
{readopt && priorAdoption && !adopted ? (
<p className="rectification-range-delivery__switch-note">现在按 {priorAdoption.time} 排盘;改用后按 {readopt} 排盘,原填报资料保留。</p>
) : null}
@@ -786,7 +786,10 @@ test("time-selection cards use server adoption state and stay mutually exclusive
}));
assert.match(persistedHtml, /data-testid="persisted-question"/);
assert.match(persistedHtml, /class="rectification-choice-card is-embedded"/);
assert.equal(persistedHtml.match(/class="birth-time-choice-option is-primary"/g)?.length, 4);
// 原值: class="birth-time-choice-option is-primary" 整段 class 恰好出现 4 次。
// 新值: 同一串作为 class 的一部分出现 4 次。
// 原因: K6 选项卡片改用 ToggleGroup,组件库会合并上自己的类。选项文案和读屏名称不变。
assert.equal(persistedHtml.match(/birth-time-choice-option is-primary/g)?.length, 4);
assert.doesNotMatch(messageLoop, /rectification-question-slot/);
assert.doesNotMatch(persistedHtml, /rectification-question-slot/);
assert.doesNotMatch(messageEntry, /rectification-question-slot/);