顺手收掉死样式、旧校正入口和整页跳转。
测试文档只在需要的测试里安装。提示和圆点按项分开。清单「去做」走客户端跳转。输入框高亮在离开页面时清掉。六个没有入口的旧校正组件删掉。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
d7e8cc46c2
commit
6998dc6ead
+1
-1
@@ -653,7 +653,7 @@
|
||||
- **`Home` 要真正受益很可能得拆组件,属业务代码重构,本轮明令禁止。** 诊断结果进一步说明这条路该怎么走:为迎合编译器去改 `Home`(删 `finally`、重排 6 个被声明前引用的函数、逐个绕内部断言)是被编译器 bug 牵着走的打地鼠,每步都拿确定的正确性换不确定的收益;值得做的是按职责把 `Home` 拆小。 证据是同一文件里两个小组件(原始行 760、815)被正常编译、全项目另有 44 个函数被编译,唯独这个 2730 行、24 个 `useState`、18 个 `useEffect` 的函数被拒。任务书要求「哪处代码挡住了编译器就写进 BLOCKED.md,不要自行改写」,故 `frontend/src/**` 一行业务代码未动(仅反向验证时临时加删一行 `"use no memo"`,已还原,`git diff` 为空)。
|
||||
- **~~待决策~~ 已决策(2026-08-17):维持回滚,不为那 44 个组件保留 `reactCompiler: true`;若要翻案,前置条件是先测出那 44 个的渲染基准。** 本轮按任务 2 止损条款已回滚该配置。数据是:构建耗时无可测量变化(噪声内),`/` 首屏 JS gzip +12031 B / +2.50 %,换来 44 个函数自动记忆化,其中 `app-sidebar`(112 槽)、`sidebar-session-row`(73 槽)、`birth-date-picker`(58 槽)与三个引导 hook 都在 `/` 上渲染。这 44 个的实际运行时收益本轮未测量(无渲染基准),所以不替决策者拍板。重新开启只需在 `frontend/next.config.ts` 顶层加 `reactCompiler: true`、`experimental` 里加 `turbopackRustReactCompiler: true`。
|
||||
- **`compilationMode: "all"` 不可用,会破坏构建。** 它会编译模块作用域的普通回调,`frontend/src/components/birth-time-intake.tsx:22` 的 `Array.from({ length: 24 }, (_, index) => ...)` 被插入 `useMemoCache`,预渲染 `/` 时抛 `TypeError: Cannot read properties of null (reading 'useMemoCache')`。这是该模式本身的性质(官方标注为不安全),不是本仓库代码的缺陷,未做任何改动。
|
||||
- **顺手活一律未做,登记在此:** 其一,`frontend/src/lib/skill-package-registry.ts:523` 的 `readFileSync(currentRegistryPath, "utf8")` 触发 Turbopack 构建警告「Dynamic filesystem access causes tracing of the whole project」,会把整个项目(含 `public/`)打进 server 产物,影响部署体积;升级前后都存在,与本轮无关,未改。其二,`npx eslint` 有 4 个既有 `no-unused-vars` warning,分别在 `birth-time-candidate-result.tsx:143`、~~`birth-time-candidate-completion.ts:10,11`~~(2026-10-13 已随该文件删除)、`tests/identity-auth-factory.test.ts:48`,未改。其三,`eslint-config-next` 仍是 16.2.10、与 next 16.3.1 版本号不同步,但 eslint 实测 0 error,按「不许顺手升别的依赖」未动。
|
||||
- **顺手活一律未做,登记在此:** 其一,`frontend/src/lib/skill-package-registry.ts:523` 的 `readFileSync(currentRegistryPath, "utf8")` 触发 Turbopack 构建警告「Dynamic filesystem access causes tracing of the whole project」,会把整个项目(含 `public/`)打进 server 产物,影响部署体积;升级前后都存在,与本轮无关,未改。其二,`npx eslint` 有 4 个既有 `no-unused-vars` warning,分别在 ~~`birth-time-candidate-result.tsx:143`~~(2026-10-27 该组件已删)、~~`birth-time-candidate-completion.ts:10,11`~~(2026-10-13 已随该文件删除)、`tests/identity-auth-factory.test.ts:48`,其余未改。其三,`eslint-config-next` 仍是 16.2.10、与 next 16.3.1 版本号不同步,但 eslint 实测 0 error,按「不许顺手升别的依赖」未动。
|
||||
- **测试环境噪声(非阻塞,已自行消化):** `frontend/tests/rectification-v9-database.test.ts` 的「v9 migration applies on a fresh database and re-applies idempotently」在全量并发下偶发失败(`database migration failed`,1 !== 0),单独重跑 7/7 通过、全量重跑 1592/1592 通过。靠 Docker 起临时 Postgres,判定为资源争用型 flake,与 Next 版本无关。未改任何测试文件。
|
||||
- **文件名偏离:** 任务书要求新建 `PROGRESS.md`,但根目录已有受版本控制的 `progress.md`(1025 行)且本机文件系统大小写不敏感,写 `PROGRESS.md` 等于覆盖清单外的文件,故进度记录落在 `PROGRESS-react-compiler-20260817.md`。
|
||||
|
||||
|
||||
@@ -103,3 +103,19 @@
|
||||
断言三栏(引导页是否预载):原值是目录还没到也按空列表预载。新值是已知空列表才预载,未知不预载、步骤停在称呼。原因是未知不能当成没建完档。
|
||||
|
||||
断言三栏(输入框标签和禁用):原值认 `onboardingStep`。新值认 `shownOnboardingStep`,并在目录未到时不收起输入框。原因同上。称呼和发送的两句文案没改。
|
||||
|
||||
## 顺手项
|
||||
|
||||
没有新的产品故障,所以没有新的 BUG 编号。
|
||||
|
||||
- 测试用的文档只在需要文档的测试里安装。做服务端渲染的七个测试文件不再因为导入测试工具就装上文档。
|
||||
- 外观菜单里已经没人用的 `.theme-menu-check` 删了。选中勾仍由菜单自己画出来。
|
||||
- 校正里选了「这题跳过」时,停止按钮呈选中。
|
||||
- 六个没有入口的旧校正组件删了:引导轮、旧选项题、候选结果、证据草稿、旧校正页、更早的旧校正页。首页不再预热其中那一页。
|
||||
- 提示和星盘圆点按每一项分开,不再共用同一个键。
|
||||
- 清单「去做」在版本一致时走页面内跳转。版本对不上时仍整页打开。
|
||||
- 点第三问把输入框点亮后,离开页面会把计时器清掉。
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 上面这几处,加上首页行数和外观菜单 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test` 跑清单、校正表面、清单刷新、首屏揭幕、滚动、输入法、字数、旧引导、起步问、首页行数、外观、首屏屏、起步页、提示监听、分栏挂载 | 85 通过,0 失败,0 取消,退出码 0 |
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` |
|
||||
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` |
|
||||
| `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
|
||||
| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U6、U9、U10、U11 已提交) | `codex/ui-kit-onboarding-fix-20261026` |
|
||||
| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U6、U9、U10、U11 与顺手项已提交) | `codex/ui-kit-onboarding-fix-20261026` |
|
||||
| —(门禁修复,无新任务书) | `PROGRESS-birth-journey-contract-20261025.md` | **测试分支过不了门禁,网站停在完整数据版(二)**:BUG-1439 出生时间合同;BUG-1440 测试画布;BUG-1441 侧栏流式检查等第一次补画结束再数帧。页面不改 | 已合入 staging,等门禁 | `codex/gate-birth-journey-contract-20261025` |
|
||||
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md`;`PROGRESS-onboarding-4-motion-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022`。(四)T7 在 `codex/onboarding-4-motion-20261022` | (一)(二)(三)已在 staging;(四)本分支待验收 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
|
||||
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
|
||||
|
||||
@@ -201,6 +201,14 @@ const BillingPanel = dynamic(loadBillingPanel, {
|
||||
loading: () => <p className="secondary-page-waiting">账户信息还没拿到。</p>,
|
||||
});
|
||||
|
||||
let nudgeComposerTimer: number | undefined;
|
||||
|
||||
function clearNudgeComposerTimer() {
|
||||
if (nudgeComposerTimer === undefined) return;
|
||||
window.clearTimeout(nudgeComposerTimer);
|
||||
nudgeComposerTimer = undefined;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const sessionList = useSessionList();
|
||||
const {
|
||||
@@ -706,10 +714,7 @@ export default function Home() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [bootstrapPhase, bootstrapPrepareReady, hydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bootstrapPhase !== "prepare" || uiPreview.current) return;
|
||||
void import("@/components/birth-time-rectification");
|
||||
}, [bootstrapPhase]);
|
||||
useEffect(() => () => { clearNudgeComposerTimer(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || uiPreview.current) return;
|
||||
@@ -1075,7 +1080,11 @@ export default function Home() {
|
||||
if (!input) return;
|
||||
input.classList.add("is-starter-focus");
|
||||
input.focus({ preventScroll: true });
|
||||
window.setTimeout(() => input.classList.remove("is-starter-focus"), 700);
|
||||
clearNudgeComposerTimer();
|
||||
nudgeComposerTimer = window.setTimeout(() => {
|
||||
nudgeComposerTimer = undefined;
|
||||
input.classList.remove("is-starter-focus");
|
||||
}, 700);
|
||||
}
|
||||
|
||||
async function copyAssistantMessage(renderKey: string, text: string) {
|
||||
@@ -1270,7 +1279,7 @@ export default function Home() {
|
||||
<header className="chat-header">
|
||||
<span className="onboarding-menu-anchor">
|
||||
<SidebarTrigger placement="inset" />
|
||||
<OnboardingChartDot />
|
||||
<OnboardingChartDot playKey="home-chart" />
|
||||
</span>
|
||||
<div className="chat-header-title">
|
||||
<strong>{activeSession?.title || "新对话"}</strong>
|
||||
|
||||
@@ -1974,9 +1974,7 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
|
||||
.theme-menu { display: grid; gap: 1px; padding: var(--space-1) 0; }
|
||||
.theme-menu-label { padding: var(--space-1) var(--space-3); color: var(--color-ink-tertiary); font-size: var(--type-caption); font-weight: 500; }
|
||||
.theme-menu-item { cursor: pointer; }
|
||||
.theme-menu-check { display: inline-grid; margin-left: auto; place-items: center; color: var(--color-action); }
|
||||
.theme-menu [data-slot="dropdown-menu-radio-item-indicator"] { color: var(--color-action); }
|
||||
.theme-menu-check svg { width: 15px; height: 15px; }
|
||||
.account-menu-separator { height: 1px; margin: var(--space-2) var(--space-3); background: var(--color-border); }
|
||||
|
||||
.account-info-list { display: grid; gap: var(--space-3); margin: var(--space-5) 0 0; }
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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;
|
||||
readonly controller: BirthTimeGuidedController;
|
||||
readonly error: string;
|
||||
};
|
||||
|
||||
const confidenceLabels = {
|
||||
low: "证据不足",
|
||||
medium: "中等置信",
|
||||
high: "较高置信",
|
||||
} as const;
|
||||
|
||||
const gateLabels: Readonly<Record<string, string>> = {
|
||||
event_quality: "经历质量",
|
||||
cross_domain_coverage: "跨领域覆盖",
|
||||
required_layers: "必需计算层",
|
||||
neighbor_stability: "相邻分钟稳定性",
|
||||
leave_one_event_out: "删除单条经历复算",
|
||||
three_engine_input_parity: "三引擎同输入对照",
|
||||
public_holdout_release: "公开 AA 盲测",
|
||||
};
|
||||
|
||||
const gateStatusLabels: Readonly<Record<string, string>> = {
|
||||
pass: "通过",
|
||||
fail: "未通过",
|
||||
blocked: "尚未具备条件",
|
||||
not_evaluated: "尚未执行",
|
||||
};
|
||||
|
||||
export function BirthTimeCandidateResult({ journey, controller, error }: CandidateResultProps) {
|
||||
const result = journey.candidateResult;
|
||||
const action = journey.nextAction;
|
||||
const dynamic = journey.journeyProtocol === "dynamic-choice-v2";
|
||||
const terminalPath = guidedTerminalPath(journey);
|
||||
if (result?.eventCount === 0) {
|
||||
return (
|
||||
<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" variant="outline" onClick={controller.editBirthTimeDetails}>补充资料并重新评估</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!result && action.kind === "present_low_result") {
|
||||
return (
|
||||
<div className="birth-time-candidate-result" aria-live="polite">
|
||||
<p className="birth-time-evidence-boundary">系统不会选择或应用未经证据支持的具体分钟,<span className="phrase-nowrap">当前排盘使用时间</span>保持不变。</p>
|
||||
{terminalPath && <TerminalAction controller={controller} error={error} path={terminalPath} />}
|
||||
{!terminalPath ? <ErrorToast message={error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!result) return null;
|
||||
const winner = result.winningSegment;
|
||||
const receipt = result.techniqueReceipt;
|
||||
|
||||
return (
|
||||
<div className="birth-time-candidate-result" aria-live="polite">
|
||||
<div className="birth-time-candidate-heading">
|
||||
<b>{dynamic ? "候选范围评估结果" : "关键经历评分结果"}</b>
|
||||
<span>{confidenceLabels[result.confidence]}</span>
|
||||
</div>
|
||||
{winner ? (
|
||||
<dl className="birth-time-candidate-grid">
|
||||
<div><dt>候选时间范围</dt><dd>{winner.startTime}—{winner.endTime}</dd></div>
|
||||
<div><dt>候选代表时间</dt><dd>{winner.representativeTime}</dd></div>
|
||||
{!dynamic && <div><dt>有效经历</dt><dd>{result.eventCount} 条 / {result.domainCount} 个领域</dd></div>}
|
||||
{!dynamic && <div><dt>领先幅度</dt><dd>{result.marginPercent}%</dd></div>}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="birth-time-assessment-unavailable">候选仍然并列或缺少必要计算层,系统不会选择具体分钟。</p>
|
||||
)}
|
||||
<p className="birth-time-evidence-boundary">这是可复现的候选评分,不代表已经证明出生记录中的具体分钟。</p>
|
||||
{receipt && (
|
||||
<details className="birth-time-evidence-receipt">
|
||||
<summary>本次计算回执</summary>
|
||||
<p>已用:{[...receipt.usedDivisionalCharts, ...receipt.usedArudha, ...receipt.dashaTracks].join("、") || "无"}</p>
|
||||
<p>辅助:{receipt.auxiliaryLayers.join("、") || "无"}</p>
|
||||
<p>未完成:{receipt.missingLayers.join("、") || "无"}</p>
|
||||
{receipt.gates && (
|
||||
<ul>
|
||||
{Object.entries(receipt.gates).map(([name, gate]) => (
|
||||
<li key={name}>{gateLabels[name] ?? name}:{gateStatusLabels[gate.status] ?? gate.status}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{receipt.confirmationAllowed === false && <p>当前仅保留候选范围,分钟确认尚未开放。</p>}
|
||||
</details>
|
||||
)}
|
||||
|
||||
{action.kind === "present_low_result" && (
|
||||
<div className="birth-time-candidate-terminal" role="status">
|
||||
<p>{dynamic ? "目前没有足够的新信息继续稳定缩小范围,本次评估已结束并保存当前候选范围。" : "本轮校正已安全结束,只保留候选范围,当前排盘使用时间保持不变。"}</p>
|
||||
{terminalPath && <TerminalAction controller={controller} error={error} path={terminalPath} />}
|
||||
</div>
|
||||
)}
|
||||
{action.kind === "present_medium_result" && winner && dynamic && (
|
||||
<div className="birth-time-candidate-terminal" role="status">
|
||||
<p>已形成较窄的候选范围,本次评估已结束;它不会自动改动<span className="phrase-nowrap">当前排盘使用时间</span>。</p>
|
||||
{terminalPath && <TerminalAction controller={controller} error={error} path={terminalPath} />}
|
||||
</div>
|
||||
)}
|
||||
{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)}>
|
||||
{controller.pending ? "保存中…" : "保存候选范围"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{action.kind === "candidate_saved" && (
|
||||
<div className="birth-time-candidate-terminal" role="status">
|
||||
<p className="birth-time-success-note">候选时间范围已保存。<span className="phrase-nowrap">当前排盘使用时间</span>没有改变。</p>
|
||||
{terminalPath && <TerminalAction controller={controller} error={error} path={terminalPath} />}
|
||||
</div>
|
||||
)}
|
||||
{action.kind === "request_candidate_confirmation" && winner && (
|
||||
<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)}>
|
||||
{controller.pending ? "确认中…" : `确认使用 ${winner.representativeTime}`}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
{!terminalPath ? <ErrorToast message={error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalAction({ controller, error, path }: {
|
||||
readonly controller: BirthTimeGuidedController;
|
||||
readonly error: string;
|
||||
readonly path: NonNullable<ReturnType<typeof guidedTerminalPath>>;
|
||||
}) {
|
||||
return (
|
||||
<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" variant="outline">开始新的评估</Button>
|
||||
<ErrorToast message={error} />
|
||||
<small>会建立新的记录,当前结果仍会保留。</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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,
|
||||
choiceSelectionIntent,
|
||||
normalizeUnmatchedNote,
|
||||
rangeLabel,
|
||||
} from "@/lib/birth-time-choice-question-model";
|
||||
import type {
|
||||
DynamicJourneyProgress,
|
||||
PublicDynamicChoiceQuestion,
|
||||
} from "@/lib/birth-time-journey-turn-protocol";
|
||||
import { ErrorToast } from "@/components/error-toast";
|
||||
|
||||
type SharedQuestionProps = {
|
||||
readonly progress: DynamicJourneyProgress;
|
||||
readonly pending: boolean;
|
||||
readonly error: string;
|
||||
readonly onPause: () => void;
|
||||
readonly onFinish: () => void;
|
||||
};
|
||||
|
||||
type ChoiceQuestionProps = SharedQuestionProps & {
|
||||
readonly question: PublicDynamicChoiceQuestion;
|
||||
readonly onSelect: (optionId: string) => void;
|
||||
};
|
||||
|
||||
type ClarificationProps = SharedQuestionProps & {
|
||||
readonly onReframe: (note: string) => void;
|
||||
};
|
||||
|
||||
export function BirthTimeQuestionProgress({ progress }: {
|
||||
readonly progress: DynamicJourneyProgress;
|
||||
}) {
|
||||
return (
|
||||
<div className="birth-time-question-progress">
|
||||
<b>已完成 {progress.effectiveAnswerCount} 个有效判断</b>
|
||||
<span>当前候选范围:{rangeLabel(progress.currentRange)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) {
|
||||
const groups = choiceQuestionGroups(props.question);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const firstChoiceRef = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!props.pending && (props.progress.answeredCount > 0 || props.error)) {
|
||||
/* preventScroll: the transcript has its own anchor; letting the browser
|
||||
scroll ancestors here scrolls `.chat-panel`, an overflow:hidden box with
|
||||
no scrollbar, and the 46px header never comes back. */
|
||||
firstChoiceRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [props.error, props.pending, props.progress.answeredCount, props.question.questionId]);
|
||||
const select = (option: PublicDynamicChoiceQuestion["options"][number]) => {
|
||||
const intent = choiceSelectionIntent(option);
|
||||
setSelectedId(intent.optionId);
|
||||
props.onSelect(intent.optionId);
|
||||
};
|
||||
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const unmatchedRemainingId = "birth-time-unmatched-remaining";
|
||||
|
||||
export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
|
||||
const [note, setNote] = useState("");
|
||||
const reframeRef = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!props.pending && (props.progress.answeredCount > 0 || props.error)) {
|
||||
reframeRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [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}>
|
||||
<legend>这道题都不符合你的情况吗?</legend>
|
||||
<label className="birth-time-unmatched-note">
|
||||
<span className="character-remaining-row">
|
||||
补充一句(可选)
|
||||
<CharacterRemaining id={unmatchedRemainingId} length={note.length} maxLength={240} />
|
||||
</span>
|
||||
<textarea
|
||||
maxLength={240}
|
||||
aria-describedby={characterRemainingVisible(note.length, 240) ? unmatchedRemainingId : undefined}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder="例如:这段变化发生得更早,或情况不太一样"
|
||||
value={note}
|
||||
/>
|
||||
</label>
|
||||
<div className="birth-time-reframe-actions">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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" 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,87 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
const domainLabels = {
|
||||
education: "学业与学习环境",
|
||||
relocation: "搬迁与长期居住地",
|
||||
relationship: "重要关系",
|
||||
career: "工作与身份变化",
|
||||
finance: "收入、资产或资源变化",
|
||||
health_pressure: "健康或生活压力",
|
||||
} as const satisfies Readonly<Record<LegacyGuidedEvidenceDomain, string>>;
|
||||
|
||||
type DraftCardProps = {
|
||||
readonly draft: EvidenceDraft;
|
||||
readonly pending: boolean;
|
||||
readonly onConfirm: (precision: EvidenceDatePrecision, date: string) => void;
|
||||
readonly onSkip: () => void;
|
||||
};
|
||||
|
||||
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({
|
||||
id: props.draft.draftId,
|
||||
domain: props.draft.domain,
|
||||
precision,
|
||||
date,
|
||||
}).success;
|
||||
const inputType = precision === "year" ? "number" : precision;
|
||||
|
||||
return (
|
||||
<div className="birth-time-evidence-draft-card">
|
||||
<div className="birth-time-candidate-heading"><b>确认关键经历</b><span>待确认草稿</span></div>
|
||||
<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 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>
|
||||
<input
|
||||
aria-invalid={date.length > 0 && !isValid}
|
||||
disabled={props.pending}
|
||||
inputMode={precision === "year" ? "numeric" : undefined}
|
||||
max={precision === "year" ? String(new Date().getFullYear()) : undefined}
|
||||
min={precision === "year" ? "1900" : undefined}
|
||||
type={inputType}
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</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" 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
type BirthTimeGuideTurnProps = {
|
||||
readonly question: string;
|
||||
readonly progress: JourneyProgress;
|
||||
readonly pending: boolean;
|
||||
readonly onSubmit: (message: string) => void;
|
||||
readonly onSkip: () => void;
|
||||
readonly onPause: () => void;
|
||||
};
|
||||
|
||||
const guideRemainingId = "birth-time-guide-remaining";
|
||||
|
||||
export function BirthTimeGuideTurn(props: BirthTimeGuideTurnProps) {
|
||||
const [message, setMessage] = useState("");
|
||||
const phase = props.progress.adaptiveRound > 0
|
||||
? `自适应第 ${props.progress.adaptiveRound} / ${props.progress.maxAdaptiveRounds} 轮`
|
||||
: "基础证据";
|
||||
const showRemaining = characterRemainingVisible(message.length, 500);
|
||||
const describedBy = showRemaining
|
||||
? `birth-time-guide-hint ${guideRemainingId}`
|
||||
: "birth-time-guide-hint";
|
||||
|
||||
return (
|
||||
<div className="birth-time-guide-turn" aria-live="polite">
|
||||
<div className="birth-time-question-progress">
|
||||
<b>{phase}</b>
|
||||
<span>已确认 {props.progress.confirmedEvidenceCount} 条 / {props.progress.baselineDomainCount} 个领域</span>
|
||||
</div>
|
||||
<p className="birth-time-guide-question">{protectOnboardingPhrases(props.question)}</p>
|
||||
<label className="birth-time-guide-composer">
|
||||
<span className="character-remaining-row">
|
||||
说说这段经历
|
||||
<CharacterRemaining id={guideRemainingId} length={message.length} maxLength={500} />
|
||||
</span>
|
||||
<textarea
|
||||
aria-describedby={describedBy}
|
||||
disabled={props.pending}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.key === "Enter" && !event.shiftKey && message.trim()) {
|
||||
event.preventDefault();
|
||||
props.onSubmit(message.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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" 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BirthTimeCandidateResult } from "@/components/birth-time-candidate-result";
|
||||
import { BirthTimeEvidenceDraftCard } from "@/components/birth-time-evidence-draft-card";
|
||||
import { BirthTimeGuideTurn } from "@/components/birth-time-guide-turn";
|
||||
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
|
||||
import { assistantIntentCopy } from "@/lib/birth-time-intake-model";
|
||||
import type { LegacyJourneyClientResponse } from "@/lib/birth-time-journey-response-schema";
|
||||
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;
|
||||
readonly controller: BirthTimeGuidedController;
|
||||
readonly externalError: string;
|
||||
};
|
||||
|
||||
function actionHeading(action: NextAction): { readonly title: string; readonly badge: string } {
|
||||
switch (action.kind) {
|
||||
case "ask_baseline_evidence": return { title: "回想一条关键经历", badge: "基础证据" };
|
||||
case "ask_adaptive_evidence": return { title: "继续缩小候选范围", badge: "自适应校正" };
|
||||
case "review_evidence_draft": 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 "candidate_saved": return { title: "候选范围已保存", badge: "已保存" };
|
||||
case "request_candidate_confirmation": return { title: "确认候选时间", badge: "待确认" };
|
||||
case "ready": return { title: "排盘时间已更新", badge: "已完成" };
|
||||
case "paused": return { title: "校正已暂停", badge: "已保存" };
|
||||
}
|
||||
}
|
||||
|
||||
export function BirthTimeLegacyRectification(props: LegacyRectificationProps) {
|
||||
const action = props.journey.nextAction;
|
||||
const heading = actionHeading(action);
|
||||
const asksQuestion = action.kind === "ask_baseline_evidence" || action.kind === "ask_adaptive_evidence";
|
||||
const showsCandidate = action.kind === "present_low_result"
|
||||
|| action.kind === "present_medium_result"
|
||||
|| action.kind === "candidate_saved"
|
||||
|| action.kind === "request_candidate_confirmation"
|
||||
|| action.kind === "ready";
|
||||
const error = props.controller.error || props.externalError;
|
||||
|
||||
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">{heading.title}</h2></div>
|
||||
<span className="birth-time-status-badge">{heading.badge}</span>
|
||||
</div>
|
||||
<dl className="birth-time-range-summary">
|
||||
<div><dt>当前范围</dt><dd>{props.journey.snapshot.reportedRange.label}</dd></div>
|
||||
<div><dt>应用状态</dt><dd>{action.kind === "ready" ? `当前使用 ${action.activeTime}` : "尚未更新排盘时间"}</dd></div>
|
||||
</dl>
|
||||
<p className="birth-time-assistant-intent" role="status">{assistantIntentCopy(props.journey.snapshot.assistantIntent)}</p>
|
||||
{asksQuestion && (
|
||||
<BirthTimeGuideTurn
|
||||
key={guidedTurnIdentity(props.journey.turnVersion, action.question.questionId)}
|
||||
pending={props.controller.pending}
|
||||
progress={props.journey.progress}
|
||||
question={props.controller.question}
|
||||
onPause={props.controller.pause}
|
||||
onSkip={props.controller.skip}
|
||||
onSubmit={props.controller.submitMessage}
|
||||
/>
|
||||
)}
|
||||
{action.kind === "review_evidence_draft" && props.journey.evidenceDraft && (
|
||||
<BirthTimeEvidenceDraftCard
|
||||
key={props.journey.evidenceDraft.draftId}
|
||||
draft={props.journey.evidenceDraft}
|
||||
pending={props.controller.pending}
|
||||
onConfirm={props.controller.confirmDraft}
|
||||
onSkip={props.controller.skip}
|
||||
/>
|
||||
)}
|
||||
{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>}
|
||||
{showsCandidate ? (
|
||||
<BirthTimeCandidateResult
|
||||
controller={props.controller}
|
||||
error={error}
|
||||
journey={props.journey}
|
||||
/>
|
||||
) : null}
|
||||
{error && !showsCandidate ? <ErrorToast message={error} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
"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";
|
||||
import { ErrorToast } from "@/components/error-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
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" 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" 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>}
|
||||
{showsCandidate ? (
|
||||
<BirthTimeCandidateResult
|
||||
controller={props.controller}
|
||||
error={error}
|
||||
journey={props.journey}
|
||||
/>
|
||||
) : null}
|
||||
{error && !showsCandidate && !childOwnsError ? <ErrorToast message={error} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
|
||||
|
||||
export function OnboardingDot({ playKey = "menu" }: { readonly playKey?: string }) {
|
||||
export function OnboardingDot({ playKey }: { readonly playKey: string }) {
|
||||
return <span className="onboarding-dot" data-onboarding-blink={playKey} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export function OnboardingChartDot() {
|
||||
export function OnboardingChartDot({ playKey }: { readonly playKey: string }) {
|
||||
const journey = useOnboardingJourneyOptional();
|
||||
if (!journey?.showChartDot) return null;
|
||||
return <OnboardingDot playKey="menu" />;
|
||||
return <OnboardingDot playKey={playKey} />;
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export function OnboardingHintLayer() {
|
||||
sideOffset={8}
|
||||
className="onboarding-hint"
|
||||
data-onboarding-hint=""
|
||||
data-onboarding-pop="hint"
|
||||
data-onboarding-pop={`hint-${hint}`}
|
||||
>
|
||||
<p>{journey.hintText}</p>
|
||||
<Button variant="outline" size="sm" data-onboarding-press="" onClick={() => dismiss(hint)}>{HINT_DISMISS_LABEL}</Button>
|
||||
|
||||
@@ -25,6 +25,9 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
||||
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "skip_probe" | "">("");
|
||||
const selectedKey = props.selectedKey || localSelected;
|
||||
const answered = Boolean(selectedKey);
|
||||
// skip_probe is the stop control. The toggle's value is "stop", so the
|
||||
// pressed value has to be "stop" or the button stays aria-pressed=false.
|
||||
const pressedValue = selectedKey === "skip_probe" ? "stop" : selectedKey;
|
||||
|
||||
function select(key: ChoiceKey) {
|
||||
if (props.pending || props.disabled || selectedKey) return;
|
||||
@@ -72,7 +75,7 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
||||
<ToggleGroup
|
||||
className="contents"
|
||||
style={{ display: "contents" }}
|
||||
value={selectedKey ? [selectedKey] : []}
|
||||
value={pressedValue ? [pressedValue] : []}
|
||||
onValueChange={(next) => {
|
||||
const key = next[0];
|
||||
if (!key || key === selectedKey) return;
|
||||
|
||||
@@ -38,7 +38,7 @@ export function SecondaryHeader({ title, actions, note, besideTitle, reserveNote
|
||||
<header className="chat-header has-title">
|
||||
<span className="onboarding-menu-anchor">
|
||||
<SidebarTrigger placement="inset" />
|
||||
<OnboardingChartDot />
|
||||
<OnboardingChartDot playKey="secondary-chart" />
|
||||
</span>
|
||||
<div className="chat-header-title">
|
||||
<strong>{title}</strong>
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useSessionList } from "@/hooks/use-session-list";
|
||||
import { navigateAppPath } from "@/lib/app-navigation";
|
||||
import {
|
||||
buildChecklist,
|
||||
checklistAllDone,
|
||||
@@ -302,6 +304,7 @@ export function useOneTimeHint(id: OnboardingHintId, condition: boolean): { show
|
||||
export function OnboardingJourneyProvider({ children }: { readonly children: ReactNode }) {
|
||||
const list = useSessionList();
|
||||
const { open, openMobile, viewport, ready } = useSidebar();
|
||||
const router = useRouter();
|
||||
const sidebarOpen = ready && (viewport === "mobile" ? openMobile : open);
|
||||
const [remote, setRemote] = useState<RemoteSnapshot>(INITIAL_REMOTE);
|
||||
const [overlay, setOverlay] = useState<OnboardingState>({});
|
||||
@@ -645,18 +648,19 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
|
||||
setLive({ checklistOpen: false });
|
||||
const target = item.go;
|
||||
if (!target) return;
|
||||
const clientNavigate = (href: string) => router.push(href);
|
||||
if (target.kind === "compose") {
|
||||
if (homeRef.current) homeRef.current.focusComposer();
|
||||
else window.location.assign("/?compose=1");
|
||||
else navigateAppPath("/?compose=1", clientNavigate);
|
||||
return;
|
||||
}
|
||||
if (target.kind === "daily") {
|
||||
if (homeRef.current) homeRef.current.startDaily();
|
||||
else window.location.assign("/?daily=1");
|
||||
else navigateAppPath("/?daily=1", clientNavigate);
|
||||
return;
|
||||
}
|
||||
window.location.assign(target.href);
|
||||
}, [setLive]);
|
||||
navigateAppPath(target.href, clientNavigate);
|
||||
}, [router, setLive]);
|
||||
|
||||
const dotVisible = showChartMenuDot({ askDone: hasAnswer, chartVisited });
|
||||
const tabDot = useCallback((tabId: string) => tabHasDot(tabId, hintsSeen), [hintsSeen]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
@@ -120,41 +120,20 @@ test("a resolved mutation cannot publish over a changed case or version", () =>
|
||||
|
||||
test("ready completion is explicit and terminal low has no finish mutation", () => {
|
||||
const hookSource = readFileSync(new URL("../src/hooks/use-birth-time-guided-journey.ts", import.meta.url), "utf8");
|
||||
const candidateSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
|
||||
// 原值: 还核对 birth-time-candidate-result 里的 acknowledgeReady,并且不调用 controller.finish。
|
||||
// 新值: 只核对引导 hook 不把 ready 直接当成完成。
|
||||
// 原因: 旧候选卡片没有入口,已删除。完成仍不走 completeGuidedBirthTimeCandidate。
|
||||
assert.doesNotMatch(hookSource, /turn\.nextAction\.kind === "ready"\) onReady/);
|
||||
assert.match(candidateSource, /acknowledgeReady/);
|
||||
assert.doesNotMatch(candidateSource, /controller\.finish/);
|
||||
assert.doesNotMatch(hookSource, /completeGuidedBirthTimeCandidate/);
|
||||
assert.doesNotMatch(hookSource, /completeCandidate:/);
|
||||
});
|
||||
|
||||
test("unconfirmed terminal candidates preserve the range without offering direct adoption", () => {
|
||||
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
|
||||
const choiceQuestionSource = readFileSync(new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), "utf8");
|
||||
const rectificationSource = readFileSync(new URL("../src/components/birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
const legacyRectificationSource = readFileSync(new URL("../src/components/birth-time-legacy-rectification.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(candidateResultSource, /尚未达到采用条件/);
|
||||
assert.match(candidateResultSource, /补充资料并重新评估/);
|
||||
assert.doesNotMatch(candidateResultSource, /采用 \$\{path\.time\} 并进入对话/);
|
||||
assert.doesNotMatch(candidateResultSource, /birth-time-next-step/);
|
||||
assert.match(rectificationSource, /error=\{error\}/);
|
||||
assert.match(rectificationSource, /const childOwnsError = action\.kind === "ask_dynamic_choice"\s*\|\| action\.kind === "clarify_unmatched_answer"/);
|
||||
assert.match(rectificationSource, /error && !showsCandidate && !childOwnsError/);
|
||||
// 原值: choiceQuestionSource 里 role="alert" 恰好 1 处(选项题自己画错误)
|
||||
// 新值: 0 处;同一错误由 <ErrorToast message={props.error} /> 说一次(仍只由选项题这一处负责,childOwnsError 分工不变)
|
||||
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」
|
||||
assert.equal(choiceQuestionSource.match(/role="alert"/g)?.length ?? 0, 0);
|
||||
assert.equal(choiceQuestionSource.match(/<ErrorToast message=\{props\.error\} \/>/g)?.length, 1);
|
||||
assert.match(legacyRectificationSource, /error=\{error\}/);
|
||||
assert.match(legacyRectificationSource, /error && !showsCandidate/);
|
||||
});
|
||||
|
||||
test("terminal CJK copy stays intact while homepage candidates remain unconfirmed in v3", () => {
|
||||
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
|
||||
const pageSource = homeSurface;
|
||||
|
||||
assert.match(candidateResultSource, /候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。/);
|
||||
// 原值: 旧候选卡片写着「候选范围已保留…」。
|
||||
// 新值: 首页仍只挂对话里的校正,不写「当前使用候选时间排盘」。
|
||||
// 原因: 旧候选卡片没有入口,已删除。首页这条保证还在。
|
||||
// 原值: assert.match(pageSource, /`出生资料已保存。\$\{birthTimeConsultationOptionsCopy\(savedProfile\)\}`/)
|
||||
// 新值: 首页不再有这条保存提示(doesNotMatch);本条要守的「候选未确认时不写成当前排盘时间」由其余断言继续锁住
|
||||
// 原因: BUG-1081——这句只在无 UI 调用的 `saveProfile` 里,随 TASK-self-edit-avatar-menu-20260928 S1 删除
|
||||
|
||||
@@ -12,8 +12,6 @@ import { homeSurface as page } from "./home-surface.ts";
|
||||
|
||||
const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
|
||||
const remainingSource = readFileSync(new URL("../src/components/character-remaining.tsx", import.meta.url), "utf8");
|
||||
const guide = readFileSync(new URL("../src/components/birth-time-guide-turn.tsx", import.meta.url), "utf8");
|
||||
const choice = readFileSync(new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
test("thresholds stay quiet until the last 10 percent, floored at 8 and capped at 50", () => {
|
||||
@@ -42,7 +40,7 @@ test("the count region is polite, atomic, and only mounted inside the threshold"
|
||||
assert.match(css, /\.character-remaining\[data-limit\] \{ color: var\(--color-danger\); \}/);
|
||||
});
|
||||
|
||||
test("the four capped fields wire describedby to a count that lives in existing chrome", () => {
|
||||
test("the capped fields wire describedby to a count that lives in existing chrome", () => {
|
||||
// Former lock: `aria-describedby={showRemaining ? composerRemainingId : undefined}`. The composer
|
||||
// is now shared with the rectification surface, which passes its own count id (BUG-477).
|
||||
assert.match(composer, /remainingId = composerRemainingId/);
|
||||
@@ -54,8 +52,9 @@ test("the four capped fields wire describedby to a count that lives in existing
|
||||
assert.match(composer, /\{showRemaining \? \(\s*<CharacterRemaining id=\{remainingId\} length=\{draft\.length\} maxLength=\{maxLength\} \/>/);
|
||||
assert.doesNotMatch(page, /composer-footer/);
|
||||
assert.match(page, /aria-describedby=\{characterRemainingVisible\(value\.name\.length, 80\) \? nameRemainingId : undefined\}/);
|
||||
assert.match(guide, /aria-describedby=\{describedBy\}/);
|
||||
assert.match(choice, /aria-describedby=\{characterRemainingVisible\(note\.length, 240\) \? unmatchedRemainingId : undefined\}/);
|
||||
// 原值: 还核对旧引导轮和旧选项题的 aria-describedby。
|
||||
// 新值: 只核对仍在页面上的输入框和称呼栏。
|
||||
// 原因: 那两个旧校正组件没有入口,已删除。
|
||||
// 原值 `.composer-footer > p { display: none; }`(窄屏藏计数)/ 新值:`.composer-row` 无内容时不渲染
|
||||
// / 原因:计数不再有常驻容器,窄屏不需要再单独藏它。
|
||||
assert.match(css, /\.composer-row \{[^}]*display: flex/);
|
||||
|
||||
@@ -41,8 +41,9 @@ test("nothing inside the chat panel focuses without preventScroll", () => {
|
||||
const insidePanel = [
|
||||
"src/app/(app)/page.tsx",
|
||||
"src/hooks/use-consultation-run.ts",
|
||||
"src/components/birth-time-choice-question.tsx",
|
||||
"src/components/birth-time-rectification.tsx",
|
||||
// 原值: 名单里还有 birth-time-choice-question.tsx 和 birth-time-rectification.tsx。
|
||||
// 新值: 这两份不在名单里。
|
||||
// 原因: 旧校正入口已删,不再画在对话列里。
|
||||
"src/components/rectification-agentic-chat.tsx",
|
||||
"src/components/rectification-board.tsx",
|
||||
];
|
||||
|
||||
@@ -6,7 +6,6 @@ import { rectificationChatSurface } from "./rectification-chat-surface.ts";
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
|
||||
const rectification = rectificationChatSurface;
|
||||
const guide = readFileSync(new URL("../src/components/birth-time-guide-turn.tsx", import.meta.url), "utf8");
|
||||
|
||||
function onKeyDownBlock(source: string, marker: string) {
|
||||
const start = source.indexOf(marker);
|
||||
@@ -18,26 +17,21 @@ function onKeyDownBlock(source: string, marker: string) {
|
||||
return handler[0];
|
||||
}
|
||||
|
||||
test("all three composers ignore Enter while an IME is composing", () => {
|
||||
test("the live composers ignore Enter while an IME is composing", () => {
|
||||
// 原值: 首页、校正对话、旧引导轮三处都要忽略组字中的 Enter。
|
||||
// 新值: 首页和校正对话两处。两处仍是 Enter 发送、Shift+Enter 换行。
|
||||
// 原因: 旧引导轮组件没有入口,已删除。
|
||||
const main = page.slice(
|
||||
page.indexOf("function handleComposerKeyDown"),
|
||||
page.indexOf("\n\n if (!hydrated"),
|
||||
);
|
||||
const rectificationHandler = onKeyDownBlock(rectification, "onKeyDown={(event) => {");
|
||||
const guideHandler = onKeyDownBlock(guide, "onKeyDown={(event) => {");
|
||||
|
||||
for (const [name, handler] of [
|
||||
["main composer", main],
|
||||
["rectification composer", rectificationHandler],
|
||||
["birth-time guide", guideHandler],
|
||||
] as const) {
|
||||
assert.match(handler, /event\.nativeEvent\.isComposing/, `${name} must ignore composing Enter`);
|
||||
assert.match(handler, /event\.key === "Enter" && !event\.shiftKey/, `${name} must send on Enter`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the guide turn sends with Enter and wraps with Shift+Enter, matching the other two", () => {
|
||||
const handler = onKeyDownBlock(guide, "onKeyDown={(event) => {");
|
||||
assert.match(handler, /event\.key === "Enter" && !event\.shiftKey/);
|
||||
assert.doesNotMatch(handler, /metaKey \|\| event\.ctrlKey/);
|
||||
assert.match(guide, /Enter 发送,Shift\+Enter 换行/);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { useRef } from "react";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
@@ -205,7 +205,10 @@ test("bootstrap enters the prepare phase instead of revealing after the account
|
||||
assert.match(page, /setRectificationEntrySummarySettled\(true\);/);
|
||||
assert.match(page, /bootstrapRevealDelayMs\(Date\.now\(\), prepareStartedAt\.current, bootstrapPrepareReady\)/);
|
||||
assert.match(page, /window\.setTimeout\(\(\) => setHydrated\(true\), delay\)/);
|
||||
assert.match(page, /void import\("@\/components\/birth-time-rectification"\);/);
|
||||
// 原值: 准备阶段预热 `void import("@/components/birth-time-rectification")`。
|
||||
// 新值: 不再预热。页面不渲染这组旧校正组件。
|
||||
// 原因: 这 6 个组件没有入口,按「多余入口宁可删除」去掉。
|
||||
assert.doesNotMatch(page, /birth-time-rectification"\)/);
|
||||
});
|
||||
|
||||
test("revealed home carries no component loading state", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import test from "node:test";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { useInsertionEffect, useLayoutEffect, type ReactNode } from "react";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { type ReactNode } from "react";
|
||||
|
||||
@@ -294,6 +294,24 @@ test("checklist and hint wiring stay inside the existing shell", () => {
|
||||
assert.match(hint, /initialFocus=\{false\}/);
|
||||
assert.match(hint, /finalFocus=\{false\}/);
|
||||
assert.match(hint, /event\.key === "Escape"/);
|
||||
// 原值: 提示层一律 data-onboarding-pop="hint",星盘圆点一律 playKey="menu"。
|
||||
// 新值: 提示按 id 区分,首页和次级页的圆点各用各的键。
|
||||
// 原因: 同一个键只会闪或弹一次,后面的项不再动。
|
||||
assert.match(hint, /data-onboarding-pop=\{`hint-\$\{hint\}`\}/);
|
||||
assert.doesNotMatch(hint, /data-onboarding-pop="hint"/);
|
||||
const dot = read("../src/components/onboarding-dot.tsx");
|
||||
const secondary = read("../src/components/secondary-header.tsx");
|
||||
assert.match(page, /<OnboardingChartDot playKey="home-chart" \/>/);
|
||||
assert.match(secondary, /<OnboardingChartDot playKey="secondary-chart" \/>/);
|
||||
assert.doesNotMatch(dot, /playKey="menu"/);
|
||||
// 原值: 清单「去做」用 window.location.assign 整页打开。
|
||||
// 新值: navigateAppPath,客户端用 router.push。版本对不上时仍整页打开。
|
||||
// 原因: 同一版本里点「去做」不要整页刷新。
|
||||
assert.match(journey, /navigateAppPath\(/);
|
||||
assert.match(journey, /router\.push\(href\)/);
|
||||
assert.doesNotMatch(journey, /window\.location\.assign\("\/\?compose=1"\)/);
|
||||
assert.doesNotMatch(journey, /window\.location\.assign\("\/\?daily=1"\)/);
|
||||
assert.doesNotMatch(journey, /window\.location\.assign\(target\.href\)/);
|
||||
assert.match(journey, /export function useOneTimeHint\(/);
|
||||
assert.match(css, /\.chat-app \{[^}]*overflow:\s*clip/);
|
||||
assert.match(css, /\.chat-header \{[^}]*grid-template-columns:\s*auto minmax\(0,\s*1fr\) auto/);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import test, { describe } from "node:test";
|
||||
|
||||
import { AppRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
||||
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
|
||||
import type { DataClient } from "../src/lib/db/data-client.ts";
|
||||
import { emptyProfile, type Account, type Profile } from "../src/lib/home-types.ts";
|
||||
@@ -11,6 +13,14 @@ import { invalidateAccountFlight, resetSubjectCatalogForTests } from "../src/lib
|
||||
import { SessionListProvider } from "../src/lib/session-list-context.tsx";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
||||
|
||||
const testAppRouter = {
|
||||
back() {},
|
||||
forward() {},
|
||||
prefetch() {},
|
||||
push() {},
|
||||
refresh() {},
|
||||
replace() {},
|
||||
};
|
||||
const ACCOUNT_ID = "11111111-1111-4111-8111-111111111448";
|
||||
const OTHER_ID = "22222222-2222-4222-8222-222222222448";
|
||||
const DISMISSED_AT = "2026-10-01T00:00:00.000Z";
|
||||
@@ -194,9 +204,11 @@ describe("checklist refresh inside the shell", { concurrency: 1 }, () => {
|
||||
await harness.render(
|
||||
<SessionListProvider>
|
||||
<SidebarProvider>
|
||||
<OnboardingJourneyProvider>
|
||||
<PersonProbe />
|
||||
</OnboardingJourneyProvider>
|
||||
<AppRouterContext.Provider value={testAppRouter}>
|
||||
<OnboardingJourneyProvider>
|
||||
<PersonProbe />
|
||||
</OnboardingJourneyProvider>
|
||||
</AppRouterContext.Provider>
|
||||
</SidebarProvider>
|
||||
</SessionListProvider>,
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React,{useState,useRef} from 'react';
|
||||
|
||||
@@ -181,16 +181,10 @@ class HostDocument {
|
||||
createElementNS(_namespace: string, tag: string) { return this.createElement(tag); }
|
||||
}
|
||||
|
||||
// Base UI chooses `useLayoutEffect` or a no-op once, the first time it loads,
|
||||
// by reading `typeof document`. Tests import this harness before those
|
||||
// components so the choice sticks. Each test still installs its own document.
|
||||
if (typeof globalThis.document === "undefined") {
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
value: new HostDocument(),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
// Do not install `document` on import. Seven server-render files import this
|
||||
// harness and must still see a missing document. A test that mounts Base UI
|
||||
// imports ./install-test-document first. createClientLifecycleHarness()
|
||||
// installs the host document for that render and removes it on close().
|
||||
|
||||
export function createClientLifecycleHarness() {
|
||||
const document = new HostDocument();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* and drive the LIVE path through a fake `fetch`: stream → settle → snapshot
|
||||
* merge. They count what the reader would see, not helper return values.
|
||||
*/
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*
|
||||
* Harness copied from `rectification-dup-question-20260926.test.tsx`.
|
||||
*/
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* merge) and count what the reader sees — the body sentences, the stem once
|
||||
* with its examples, and a receipt with one row per shown step.
|
||||
*/
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
|
||||
@@ -207,6 +207,10 @@ test("the choice card confirms the tap and the board's first state shows the dec
|
||||
assert.doesNotMatch(choiceCard, /已选择/);
|
||||
assert.doesNotMatch(choiceCard, /rectification-choice-card__selected/);
|
||||
assert.match(choiceCard, /data-selected=\{selectedKey === option\.key \? "true" : "false"\}/);
|
||||
// 原值: 分组的 value 直接用 selectedKey。skip_probe 对不上停止钮的 value="stop",aria-pressed 仍是 false。
|
||||
// 新值: skip_probe 映射成 stop,停止钮按下。
|
||||
// 原因: 「这题跳过」选中时,停止钮就是被选中的那一颗。
|
||||
assert.match(choiceCard, /selectedKey === "skip_probe" \? "stop" : selectedKey/);
|
||||
assert.match(choiceCard, /rectification-choice-card__pending/);
|
||||
assert.match(choiceCard, /InlineSpinner size=\{12\}/);
|
||||
assert.doesNotMatch(choiceCard, /onMouseEnter/);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 我的报告: keyset pages of 10 with 「加载更多」 (BUG-1124) and entering the page
|
||||
// joins the sidebar's prefetch instead of discarding it (BUG-1123).
|
||||
// Fictional list metadata only (AGENTS §8).
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { StrictMode, startTransition, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
Reference in New Issue
Block a user