Files
Jyotisha/frontend/src/components/birth-time-rectification.tsx
T
Jesse_ChenandClaude Opus 5 317e9f1886
Independent Staging Quality Gate / validate (push) Successful in 10m24s
Independent Staging Quality Gate / publish (push) Successful in 19m9s
fix(rectification): 顶栏被 focus 滚出视野——面板改 clip,面板内 focus 一律 preventScroll
产品实测:生时校正答了三题后 46px 顶栏消失。devtools 取证——栅格是对的
(rows "46px 1297px"、panel y=0),但 header 自己的 rect 在 **y=-88**:
面板被程序化滚动了 88px。

根因:`overflow: hidden` 仍然是滚动容器,它只是去掉了滚动条。
BirthTimeChoiceQuestion 在每答完一题后 focus 新题的第一个选项,浏览器
为把它带进视野会滚动所有可滚动祖先,`.chat-panel` 就是其中之一——
而用户没有滚动条可以滚回来,顶栏于是永久消失。

两处都修:
- 病因:面板内 7 处程序化 focus 一律加 { preventScroll: true }。
  消息区有自己的 useConversationScrollAnchor,本来就不需要浏览器代劳。
  账户弹窗的 closeButton / returnTarget 不在此列——那是对话框焦点管理。
  同一教训 use-billing-panel.ts 已经吃过一次(那里早写了 preventScroll)。
- 结构:.chat-panel 与 .chat-app 从 overflow:hidden 改成 overflow:clip。
  clip 根本不创建滚动容器,此后任何 focus / scrollIntoView 都无法位移它。

新增 tests/chat-panel-scroll-guard.test.ts:锁住两个容器必须是 clip、
面板内不得有裸 focus(),并遍历 rectification/birth-time/chat- 全部组件,
新组件再写裸 focus 会直接打红。

测试 3372(+3),fail 仍 31 且与基线逐条一致;四个路由标记不变。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 08:56:07 +00:00

131 lines
7.4 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { BirthTimeCandidateResult } from "@/components/birth-time-candidate-result";
import {
BirthTimeChoiceQuestion,
BirthTimeQuestionProgress,
BirthTimeUnmatchedClarification,
} from "@/components/birth-time-choice-question";
import { BirthTimeLegacyRectification } from "@/components/birth-time-legacy-rectification";
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
import type { DynamicNextAction } from "@/lib/birth-time-journey-turn-protocol";
type BirthTimeRectificationProps = {
readonly journey: JourneyClientResponse;
readonly controller: BirthTimeGuidedController;
readonly externalError: string;
};
function dynamicHeading(action: DynamicNextAction): { readonly title: string; readonly badge: string } {
switch (action.kind) {
case "generate_dynamic_question": return { title: "准备下一道问题", badge: "正在生成" };
case "retry_question_generation": return { title: "换一道问题", badge: "可重试" };
case "ask_dynamic_choice": return { title: "选择更接近的情况", badge: "点击作答" };
case "clarify_unmatched_answer": return { title: "换一道更合适的问题", badge: "可选补充" };
case "score_pending": return { title: "正在缩小候选范围", badge: "评分中" };
case "retry_scoring": return { title: "评分需要重试", badge: "可恢复" };
case "present_low_result": return { title: "本次评估已结束", badge: "范围已保存" };
case "present_medium_result": return { title: "候选范围已形成", badge: "评估已结束" };
case "request_candidate_confirmation": return { title: "确认候选时间", badge: "待确认" };
case "ready": return { title: "排盘时间已更新", badge: "已完成" };
case "paused": return { title: "评估已暂停", badge: "进度已保存" };
}
}
function statusCopy(action: DynamicNextAction): string {
switch (action.kind) {
case "generate_dynamic_question": return "系统正在根据当前候选范围准备更有区分度的问题。";
case "retry_question_generation": return "这道问题暂时没有生成成功,可以原地重试。";
case "ask_dynamic_choice": return "点击最接近的选项即可,不需要再填写日期或时间。";
case "clarify_unmatched_answer": return "补充说明完全可选,也可以直接换一道题。";
case "score_pending": return "正在应用刚才的选择,完成后会自动进入下一步。";
case "retry_scoring": return "刚才的选择已经保存,可以安全重试同一评分任务。";
case "present_low_result": return "目前没有足够的新信息继续稳定缩小范围,本次评估已结束并保存当前候选范围。";
case "present_medium_result": return "已形成较窄的候选范围,本次评估已结束;它不会自动改动当前排盘时间。";
case "request_candidate_confirmation": return "只有明确确认后,候选时间才会用于当前排盘。";
case "ready": return `当前排盘使用时间已更新为 ${action.activeTime}。`;
case "paused": return "当前问题和候选范围已保存,可以稍后从这里继续。";
}
}
export function BirthTimeRectification(props: BirthTimeRectificationProps) {
const assessmentHeadingRef = useRef<HTMLHeadingElement | null>(null);
const previousTurnVersion = useRef(props.journey.turnVersion);
useEffect(() => {
const changed = previousTurnVersion.current !== props.journey.turnVersion;
previousTurnVersion.current = props.journey.turnVersion;
if (!changed || props.journey.journeyProtocol !== "dynamic-choice-v2") return;
const action = props.journey.nextAction;
if (action.kind !== "ask_dynamic_choice" && action.kind !== "clarify_unmatched_answer") {
assessmentHeadingRef.current?.focus({ preventScroll: true });
}
}, [props.journey]);
if (props.journey.journeyProtocol !== "dynamic-choice-v2") {
return <BirthTimeLegacyRectification {...props} journey={props.journey} />;
}
const action = props.journey.nextAction;
const heading = dynamicHeading(action);
const error = props.controller.error || props.externalError;
const showsProgress = action.kind !== "ask_dynamic_choice" && action.kind !== "clarify_unmatched_answer";
const childOwnsError = action.kind === "ask_dynamic_choice"
|| action.kind === "clarify_unmatched_answer";
const showsCandidate = action.kind === "present_low_result"
|| action.kind === "present_medium_result"
|| action.kind === "request_candidate_confirmation"
|| action.kind === "ready";
return (
<section className="birth-time-rectification onboarding-card" aria-labelledby="birth-time-assessment-title">
<div className="birth-time-assessment-heading">
<div><span>出生时间评估</span><h2 id="birth-time-assessment-title" ref={assessmentHeadingRef} tabIndex={-1}>{heading.title}</h2></div>
<span className="birth-time-status-badge">{heading.badge}</span>
</div>
{showsProgress && <BirthTimeQuestionProgress progress={props.journey.progress} />}
<p className="birth-time-assistant-intent" role="status">{statusCopy(action)}</p>
{action.kind === "ask_dynamic_choice" && (
<BirthTimeChoiceQuestion
key={`${props.journey.turnVersion}:${action.question.questionId}`}
error={error}
onFinish={props.controller.finish}
onPause={props.controller.pause}
onSelect={props.controller.selectOption}
pending={props.controller.pending}
progress={props.journey.progress}
question={action.question}
/>
)}
{action.kind === "clarify_unmatched_answer" && (
<BirthTimeUnmatchedClarification
error={error}
onFinish={props.controller.finish}
onPause={props.controller.pause}
onReframe={props.controller.submitUnmatchedContext}
pending={props.controller.pending}
progress={props.journey.progress}
/>
)}
{(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>
)}
{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>}
</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>}
{showsCandidate ? (
<BirthTimeCandidateResult
controller={props.controller}
error={error}
journey={props.journey}
/>
) : null}
{error && !showsCandidate && !childOwnsError ? <p className="form-error" role="alert">{error}</p> : null}
</section>
);
}