feat: complete verifiable birth-time rectification flow
This commit is contained in:
@@ -16,6 +16,23 @@ const confidenceLabels = {
|
||||
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;
|
||||
@@ -66,7 +83,14 @@ export function BirthTimeCandidateResult({ journey, controller, error }: Candida
|
||||
<p>已用:{[...receipt.usedDivisionalCharts, ...receipt.usedArudha, ...receipt.dashaTracks].join("、") || "无"}</p>
|
||||
<p>辅助:{receipt.auxiliaryLayers.join("、") || "无"}</p>
|
||||
<p>未完成:{receipt.missingLayers.join("、") || "无"}</p>
|
||||
{receipt.hardBlockers.length > 0 && <p>阻止确认:{receipt.hardBlockers.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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { lifeEventSchema } from "@/lib/birth-time-journey";
|
||||
import type { LifeEvent } from "@/lib/birth-time-evidence";
|
||||
|
||||
type EventDraft = {
|
||||
readonly rowId: string;
|
||||
readonly eventId: string;
|
||||
readonly domain: LifeEvent["domain"] | "";
|
||||
readonly precision: LifeEvent["precision"];
|
||||
readonly date: string;
|
||||
};
|
||||
|
||||
type BirthTimeLifeEventsProps = {
|
||||
readonly initialEvents: readonly LifeEvent[];
|
||||
readonly pending: boolean;
|
||||
readonly onSubmit: (events: readonly LifeEvent[]) => void;
|
||||
};
|
||||
|
||||
const domainOptions = [
|
||||
{ value: "education", label: "学业或学习方向" },
|
||||
{ value: "relocation", label: "搬家、离乡或长期异地" },
|
||||
{ value: "relationship", label: "重要关系节点" },
|
||||
{ value: "career", label: "工作或身份变化" },
|
||||
{ value: "health_pressure", label: "健康、事故或低谷" },
|
||||
] as const;
|
||||
|
||||
const initialDomains = ["education", "career", "relationship"] as const;
|
||||
|
||||
function initialDrafts(events: readonly LifeEvent[]): readonly EventDraft[] {
|
||||
if (events.length > 0) {
|
||||
return events.map((event, index) => ({
|
||||
rowId: `stored-${index}`,
|
||||
eventId: event.id,
|
||||
domain: event.domain,
|
||||
precision: event.precision,
|
||||
date: event.date,
|
||||
}));
|
||||
}
|
||||
return initialDomains.map((domain, index) => ({
|
||||
rowId: `initial-${index}`,
|
||||
eventId: "",
|
||||
domain,
|
||||
precision: "month",
|
||||
date: "",
|
||||
}));
|
||||
}
|
||||
|
||||
function dateInputType(precision: LifeEvent["precision"]) {
|
||||
switch (precision) {
|
||||
case "year": return "number";
|
||||
case "month": return "month";
|
||||
case "day": return "date";
|
||||
}
|
||||
}
|
||||
|
||||
function parseDomain(value: string): EventDraft["domain"] {
|
||||
switch (value) {
|
||||
case "":
|
||||
case "education":
|
||||
case "relocation":
|
||||
case "relationship":
|
||||
case "career":
|
||||
case "health_pressure":
|
||||
return value;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function parsePrecision(value: string): EventDraft["precision"] {
|
||||
switch (value) {
|
||||
case "year":
|
||||
case "month":
|
||||
case "day":
|
||||
return value;
|
||||
default:
|
||||
return "month";
|
||||
}
|
||||
}
|
||||
|
||||
export function BirthTimeLifeEvents({
|
||||
initialEvents,
|
||||
pending,
|
||||
onSubmit,
|
||||
}: BirthTimeLifeEventsProps) {
|
||||
const [drafts, setDrafts] = useState(() => initialDrafts(initialEvents));
|
||||
const [error, setError] = useState("");
|
||||
const nextRowId = useRef(0);
|
||||
|
||||
function patchDraft(rowId: string, patch: Partial<EventDraft>) {
|
||||
setDrafts((current) => current.map((draft) => (
|
||||
draft.rowId === rowId ? { ...draft, ...patch } : draft
|
||||
)));
|
||||
}
|
||||
|
||||
function addDraft() {
|
||||
setDrafts((current) => current.length >= 6 ? current : [...current, {
|
||||
rowId: `added-${nextRowId.current++}`,
|
||||
eventId: "",
|
||||
domain: "",
|
||||
precision: "month",
|
||||
date: "",
|
||||
}]);
|
||||
}
|
||||
|
||||
function removeDraft(rowId: string) {
|
||||
setDrafts((current) => current.length <= 3
|
||||
? current
|
||||
: current.filter((draft) => draft.rowId !== rowId));
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const parsed = drafts.map((draft) => lifeEventSchema.safeParse({
|
||||
id: draft.eventId || crypto.randomUUID(),
|
||||
domain: draft.domain,
|
||||
precision: draft.precision,
|
||||
date: draft.date,
|
||||
}));
|
||||
if (parsed.some((item) => !item.success)) {
|
||||
setError("请为每条经历选择类型,并填写与精度一致的日期。");
|
||||
return;
|
||||
}
|
||||
const events = parsed.flatMap((item) => item.success ? [item.data] : []);
|
||||
if (new Set(events.map((event) => event.domain)).size < 2) {
|
||||
setError("请至少选择两个不同领域的经历,以便区分候选时间。");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
onSubmit(events);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="birth-time-life-events">
|
||||
<div className="birth-time-question-progress">
|
||||
<b>关键经历日期</b>
|
||||
<span>{drafts.length} / 6</span>
|
||||
</div>
|
||||
<p className="birth-time-evidence-note">
|
||||
请填写至少三条、覆盖两个领域的经历。只使用日期和类型评分,不会从描述中猜测。
|
||||
</p>
|
||||
<div className="birth-time-event-list">
|
||||
{drafts.map((draft, index) => (
|
||||
<fieldset className="birth-time-event-row" key={draft.rowId}>
|
||||
<legend>经历 {index + 1}</legend>
|
||||
<label>
|
||||
<span>经历类型</span>
|
||||
<select
|
||||
required
|
||||
value={draft.domain}
|
||||
onChange={(event) => patchDraft(draft.rowId, { domain: parseDomain(event.target.value) })}
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
{domainOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>记得的精度</span>
|
||||
<select
|
||||
value={draft.precision}
|
||||
onChange={(event) => patchDraft(draft.rowId, {
|
||||
precision: parsePrecision(event.target.value),
|
||||
date: "",
|
||||
})}
|
||||
>
|
||||
<option value="year">只记得年份</option>
|
||||
<option value="month">记得月份</option>
|
||||
<option value="day">记得具体日期</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>发生时间</span>
|
||||
<input
|
||||
required
|
||||
inputMode={draft.precision === "year" ? "numeric" : undefined}
|
||||
min={draft.precision === "year" ? "1900" : undefined}
|
||||
max={draft.precision === "year" ? String(new Date().getFullYear()) : undefined}
|
||||
type={dateInputType(draft.precision)}
|
||||
value={draft.date}
|
||||
onChange={(event) => patchDraft(draft.rowId, { date: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{drafts.length > 3 && (
|
||||
<button className="button-text birth-time-event-remove" type="button" onClick={() => removeDraft(draft.rowId)}>
|
||||
移除这条
|
||||
</button>
|
||||
)}
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
<div className="birth-time-evidence-actions">
|
||||
<button className="button-secondary" disabled={pending || drafts.length >= 6} type="button" onClick={addDraft}>
|
||||
添加经历
|
||||
</button>
|
||||
<button className="button-primary" disabled={pending} type="button" onClick={submit}>
|
||||
{pending ? "正在比较候选…" : "比较候选时间"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type BirthTimeSoftNoticeProps = Readonly<{
|
||||
message: string;
|
||||
onDismiss: () => void;
|
||||
durationMs?: number;
|
||||
}>;
|
||||
|
||||
export function BirthTimeSoftNotice({
|
||||
message,
|
||||
onDismiss,
|
||||
durationMs = 4_500,
|
||||
}: BirthTimeSoftNoticeProps) {
|
||||
useEffect(() => {
|
||||
if (!message) return;
|
||||
let toastId: string | number | undefined;
|
||||
let timeout: number | undefined;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
toastId = toast("出生时间尚未校正", {
|
||||
description: message,
|
||||
duration: durationMs,
|
||||
});
|
||||
timeout = window.setTimeout(onDismiss, durationMs);
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
if (timeout !== undefined) window.clearTimeout(timeout);
|
||||
if (toastId !== undefined) toast.dismiss(toastId);
|
||||
};
|
||||
}, [durationMs, message, onDismiss]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from "react";
|
||||
import { ChatMessageContent } from "./chat-message-content.tsx";
|
||||
import {
|
||||
@@ -20,6 +19,7 @@ type EvidenceDomain = NonNullable<
|
||||
const domainLabels = {
|
||||
career: "事业与身份",
|
||||
education: "学业与学习",
|
||||
finance: "收入与资产",
|
||||
relocation: "搬迁与居住地",
|
||||
relationship: "重要关系",
|
||||
family: "家庭变化",
|
||||
@@ -42,6 +42,8 @@ function CandidateSummary({ turn }: { readonly turn: ConversationalRectification
|
||||
const confirmed = candidate.status === "confirmed" && turn.status === "completed";
|
||||
const status = confirmed
|
||||
? "已明确确认"
|
||||
: turn.status === "completed"
|
||||
? "范围已保存 · 分钟未确认"
|
||||
: candidate.status === "ready_for_confirmation"
|
||||
? "待确认 · 未验证"
|
||||
: "待验证 · 未确认";
|
||||
@@ -71,94 +73,25 @@ function CandidateSummary({ turn }: { readonly turn: ConversationalRectification
|
||||
);
|
||||
}
|
||||
|
||||
function TechnicalReceipt({ turn }: { readonly turn: ConversationalRectificationTurn }) {
|
||||
const receipt = turn.technicalReceipt;
|
||||
return (
|
||||
<details className="conversational-technical-receipt">
|
||||
<summary>本轮技术回执</summary>
|
||||
<dl>
|
||||
<div><dt>计算版本</dt><dd><code>{receipt.calculationVersion}</code></dd></div>
|
||||
<div><dt>稳定层</dt><dd>{receipt.stableLayers.join("、") || "无"}</dd></div>
|
||||
<div><dt>分钟敏感层</dt><dd>{receipt.sensitiveLayers.join("、") || "无"}</dd></div>
|
||||
<div><dt>候选差异引用</dt><dd>{receipt.candidateDifferenceRefs.join("、") || "无"}</dd></div>
|
||||
</dl>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationalRectificationSurface({
|
||||
controller,
|
||||
pendingConsultationQuestion,
|
||||
continuationPending = false,
|
||||
onContinueOriginalQuestion,
|
||||
}: SurfaceProps) {
|
||||
const [abandonArmedFor, setAbandonArmedFor] = useState<string | null>(null);
|
||||
const [localAnnouncement, setLocalAnnouncement] = useState<Readonly<{
|
||||
identity: string;
|
||||
message: string;
|
||||
}> | null>(null);
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
const abandonTrigger = useRef<HTMLButtonElement>(null);
|
||||
const abandonCancel = useRef<HTMLButtonElement>(null);
|
||||
const abandonConfirm = useRef<HTMLButtonElement>(null);
|
||||
const terminalStatus = useRef<HTMLDivElement>(null);
|
||||
const restoreAbandonFocus = useRef(false);
|
||||
const focusTerminalForCase = useRef<string | null>(null);
|
||||
const [eventYear, setEventYear] = useState("");
|
||||
const [eventMonth, setEventMonth] = useState("");
|
||||
const currentYear = new Date().getFullYear();
|
||||
const eventYears = Array.from({ length: 101 }, (_, index) => currentYear - index);
|
||||
const turn = controller.turn;
|
||||
const pendingQuestion = turn?.status === "completed"
|
||||
? turn.pendingConsultationQuestion
|
||||
: turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
|
||||
const abandonIdentity = turn
|
||||
? `${turn.caseId}:${turn.turnVersion}:${turn.status}`
|
||||
: null;
|
||||
const canAbandon = Boolean(
|
||||
turn?.actions.includes("abandon")
|
||||
&& turn.status !== "abandoned"
|
||||
&& turn.status !== "completed",
|
||||
);
|
||||
const abandonArmed = canAbandon && abandonArmedFor === abandonIdentity;
|
||||
const statusAnnouncement = localAnnouncement?.identity === abandonIdentity
|
||||
? localAnnouncement.message
|
||||
: "";
|
||||
|
||||
useEffect(() => {
|
||||
if (abandonArmed) {
|
||||
abandonCancel.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (restoreAbandonFocus.current) {
|
||||
restoreAbandonFocus.current = false;
|
||||
abandonTrigger.current?.focus();
|
||||
}
|
||||
}, [abandonArmed]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestedCase = focusTerminalForCase.current;
|
||||
if (!requestedCase) return;
|
||||
if (!turn || turn.caseId !== requestedCase) {
|
||||
focusTerminalForCase.current = null;
|
||||
return;
|
||||
}
|
||||
if (turn.status === "abandoned") {
|
||||
focusTerminalForCase.current = null;
|
||||
terminalStatus.current?.focus();
|
||||
}
|
||||
}, [turn]);
|
||||
|
||||
if (!turn) {
|
||||
return (
|
||||
<section className="conversational-rectification" aria-busy={controller.pending} aria-label="生时校正对话">
|
||||
<div className="conversational-empty-state" aria-live="polite">
|
||||
<p>系统会先说明候选边界,再邀请你提供已经发生的真实经历。</p>
|
||||
<button
|
||||
className="button-primary"
|
||||
disabled={controller.pending}
|
||||
type="button"
|
||||
onClick={() => safely(controller.start(pendingQuestion))}
|
||||
>
|
||||
{controller.pending ? "正在建立校正记录…" : "开始生时校正"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="conversational-empty-state" aria-live="polite" role="status">正在建立校正记录…</p>
|
||||
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
|
||||
</section>
|
||||
);
|
||||
@@ -166,46 +99,21 @@ export function ConversationalRectificationSurface({
|
||||
|
||||
const canAnswer = turn.actions.includes("answer") && turn.status !== "abandoned" && turn.status !== "completed";
|
||||
const requestedDomains = turn.evidenceRequest?.domains ?? [];
|
||||
const submit = () => {
|
||||
if (canAnswer && controller.draft.trim() && !controller.pending) safely(controller.answer());
|
||||
const submit = async () => {
|
||||
if (!canAnswer || !controller.draft.trim() || controller.pending) return;
|
||||
const dateLabel = eventYear
|
||||
? `${eventYear} 年${eventMonth ? ` ${Number(eventMonth)} 月` : ""}`
|
||||
: "时间不确定";
|
||||
const answer = `发生时间:${dateLabel}\n事件详情:${controller.draft.trim()}`;
|
||||
try {
|
||||
await controller.answer(undefined, answer);
|
||||
setEventYear("");
|
||||
setEventMonth("");
|
||||
} catch {
|
||||
// The controller owns the visible request error and keeps the draft available for retry.
|
||||
}
|
||||
};
|
||||
const focusComposer = () => composer.current?.focus();
|
||||
const continueLocally = () => {
|
||||
if (abandonIdentity) {
|
||||
setLocalAnnouncement({
|
||||
identity: abandonIdentity,
|
||||
message: "现在可以继续填写真实经历,输入框已就绪;发送后才会推进校正进度。",
|
||||
});
|
||||
}
|
||||
focusComposer();
|
||||
};
|
||||
const closeAbandonDialog = () => {
|
||||
restoreAbandonFocus.current = true;
|
||||
setAbandonArmedFor(null);
|
||||
};
|
||||
const handleAbandonDialogKey = (event: ReactKeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeAbandonDialog();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
if (event.shiftKey && document.activeElement === abandonCancel.current) {
|
||||
event.preventDefault();
|
||||
abandonConfirm.current?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === abandonConfirm.current) {
|
||||
event.preventDefault();
|
||||
abandonCancel.current?.focus();
|
||||
}
|
||||
};
|
||||
const confirmAbandon = () => {
|
||||
focusTerminalForCase.current = turn.caseId;
|
||||
void controller.abandon().catch(() => {
|
||||
if (focusTerminalForCase.current === turn.caseId) {
|
||||
focusTerminalForCase.current = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -213,13 +121,17 @@ export function ConversationalRectificationSurface({
|
||||
aria-busy={controller.pending}
|
||||
aria-label="生时校正对话"
|
||||
>
|
||||
<CandidateSummary turn={turn} />
|
||||
|
||||
<article className="conversational-narrative" aria-label="校正分析">
|
||||
<ChatMessageContent text={turn.narrative} />
|
||||
<div className="conversational-narrative-body">
|
||||
<ChatMessageContent text={turn.narrative} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{requestedDomains.length > 0 && (
|
||||
<fieldset className="conversational-domain-picker">
|
||||
<legend>这轮想先补充哪个领域?</legend>
|
||||
<legend className="conversational-domain-question">这轮想先补充哪个领域?</legend>
|
||||
<div>
|
||||
{requestedDomains.map((domain) => (
|
||||
<button
|
||||
@@ -240,11 +152,11 @@ export function ConversationalRectificationSurface({
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<form
|
||||
{canAnswer && <form
|
||||
className="conversational-composer"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
{controller.correctionTarget && (
|
||||
@@ -267,13 +179,44 @@ export function ConversationalRectificationSurface({
|
||||
{controller.correctionTarget ? "填写更正后的真实经历" : "补充真实经历"}
|
||||
<span>大概年份也可以;不确定的部分请直接说不确定。</span>
|
||||
</label>
|
||||
<div className="conversational-event-date" role="group" aria-label="经历发生时间">
|
||||
<label>
|
||||
年份
|
||||
<select
|
||||
aria-label="经历发生年份"
|
||||
disabled={controller.pending || !canAnswer}
|
||||
value={eventYear}
|
||||
onChange={(event) => {
|
||||
setEventYear(event.target.value);
|
||||
if (!event.target.value) setEventMonth("");
|
||||
}}
|
||||
>
|
||||
<option value="">不确定</option>
|
||||
{eventYears.map((year) => <option key={year} value={year}>{year} 年</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
月份(可选)
|
||||
<select
|
||||
aria-label="经历发生月份"
|
||||
disabled={controller.pending || !canAnswer || !eventYear}
|
||||
value={eventMonth}
|
||||
onChange={(event) => setEventMonth(event.target.value)}
|
||||
>
|
||||
<option value="">不确定</option>
|
||||
{Array.from({ length: 12 }, (_, index) => index + 1).map((month) => (
|
||||
<option key={month} value={month}>{month} 月</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
id="conversational-rectification-answer"
|
||||
disabled={controller.pending || !canAnswer}
|
||||
maxLength={4_000}
|
||||
placeholder={controller.correctionTarget
|
||||
? "例如:其实是 2020 年 11 月离职"
|
||||
: "请描述一件已经发生的事,并尽量写明年份或月份"}
|
||||
: "请描述一件已经发生的具体事件"}
|
||||
ref={composer}
|
||||
rows={4}
|
||||
value={controller.draft}
|
||||
@@ -281,7 +224,7 @@ export function ConversationalRectificationSurface({
|
||||
onKeyDown={(event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -295,7 +238,7 @@ export function ConversationalRectificationSurface({
|
||||
{controller.pending ? "正在核对…" : "发送这段经历"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>}
|
||||
|
||||
{turn.evidenceRecap.length > 0 && (
|
||||
<section className="conversational-evidence-recap" aria-labelledby="conversational-evidence-title">
|
||||
@@ -328,9 +271,6 @@ export function ConversationalRectificationSurface({
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CandidateSummary turn={turn} />
|
||||
<TechnicalReceipt turn={turn} />
|
||||
|
||||
{turn.actions.includes("confirm")
|
||||
&& turn.candidate.status === "ready_for_confirmation"
|
||||
&& turn.candidate.representativeTime && (
|
||||
@@ -351,17 +291,15 @@ export function ConversationalRectificationSurface({
|
||||
)}
|
||||
|
||||
<div
|
||||
aria-label={turn.status === "abandoned" ? "生时校正终态" : undefined}
|
||||
aria-live="polite"
|
||||
className="conversational-status"
|
||||
ref={terminalStatus}
|
||||
tabIndex={turn.status === "abandoned" ? -1 : undefined}
|
||||
>
|
||||
{turn.status === "paused" && <p>校正已暂停,输入与现有证据都已保留。</p>}
|
||||
{turn.status === "abandoned" && <p>本次校正已放弃,候选时间没有应用。</p>}
|
||||
{turn.status === "completed" && turn.candidate.status === "confirmed"
|
||||
&& <p>候选时间已经过你的明确确认。</p>}
|
||||
{statusAnnouncement && <p>{statusAnnouncement}</p>}
|
||||
{turn.status === "completed" && turn.candidate.status !== "confirmed"
|
||||
&& <p>本次校正已结束并保存候选范围;没有把候选代表时间设为当前排盘时间。</p>}
|
||||
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -378,85 +316,13 @@ export function ConversationalRectificationSurface({
|
||||
>
|
||||
{continuationPending
|
||||
? "正在继续回答原问题…"
|
||||
: "使用新确认时间继续回答原问题"}
|
||||
: turn.candidate.status === "confirmed"
|
||||
? "使用新确认时间继续回答原问题"
|
||||
: "返回原对话并继续回答"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer className="conversational-session-actions">
|
||||
{turn.status === "paused" ? (
|
||||
<button
|
||||
className="button-secondary"
|
||||
disabled={controller.pending}
|
||||
type="button"
|
||||
onClick={continueLocally}
|
||||
>
|
||||
继续校正
|
||||
</button>
|
||||
) : turn.actions.includes("pause") ? (
|
||||
<button
|
||||
className="button-secondary"
|
||||
disabled={controller.pending}
|
||||
type="button"
|
||||
onClick={() => safely(controller.pause())}
|
||||
>
|
||||
暂停,稍后继续
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{canAbandon && !abandonArmed && (
|
||||
<button
|
||||
className="conversational-abandon"
|
||||
disabled={controller.pending}
|
||||
ref={abandonTrigger}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLocalAnnouncement(null);
|
||||
setAbandonArmedFor(abandonIdentity);
|
||||
}}
|
||||
>
|
||||
放弃本次校正
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{abandonArmed && (
|
||||
<div className="conversational-abandon-scrim">
|
||||
<section
|
||||
aria-describedby="conversational-abandon-description"
|
||||
aria-labelledby="conversational-abandon-title"
|
||||
aria-modal="true"
|
||||
className="conversational-abandon-confirmation"
|
||||
onKeyDown={handleAbandonDialogKey}
|
||||
role="alertdialog"
|
||||
>
|
||||
<h3 id="conversational-abandon-title">确认放弃本次校正?</h3>
|
||||
<p id="conversational-abandon-description">
|
||||
放弃后会保留审计记录,但不会应用任何候选时间。
|
||||
</p>
|
||||
<div>
|
||||
<button
|
||||
className="button-secondary"
|
||||
disabled={controller.pending}
|
||||
ref={abandonCancel}
|
||||
type="button"
|
||||
onClick={closeAbandonDialog}
|
||||
>
|
||||
返回校正
|
||||
</button>
|
||||
<button
|
||||
className="conversational-abandon is-confirm"
|
||||
disabled={controller.pending}
|
||||
ref={abandonConfirm}
|
||||
type="button"
|
||||
onClick={confirmAbandon}
|
||||
>
|
||||
确认放弃且不应用候选
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,12 +91,16 @@ export function SidebarProvider({
|
||||
const open = controlledOpen ?? uncontrolledOpen;
|
||||
const isMobile = viewport === "mobile";
|
||||
|
||||
const setOpen = useCallback((nextOpen: boolean) => {
|
||||
const commitOpen = useCallback((nextOpen: boolean) => {
|
||||
userChangedDesktopState.current = true;
|
||||
if (!isControlled) setUncontrolledOpen(nextOpen);
|
||||
onOpenChange?.(nextOpen);
|
||||
}, [isControlled, onOpenChange]);
|
||||
|
||||
const setOpen = useCallback((nextOpen: boolean) => {
|
||||
commitOpen(nextOpen);
|
||||
}, [commitOpen]);
|
||||
|
||||
const setOpenMobile = useCallback((nextOpen: boolean) => {
|
||||
if (openMobileRef.current === nextOpen) return;
|
||||
openMobileRef.current = nextOpen;
|
||||
@@ -121,7 +125,7 @@ export function SidebarProvider({
|
||||
if (shouldHandleSidebarShortcut(event)) {
|
||||
event.preventDefault();
|
||||
if (isMobile) setOpenMobile(!openMobile);
|
||||
else setOpen(!open);
|
||||
else commitOpen(!open);
|
||||
}
|
||||
if (event.key === "Escape" && isMobile && openMobile && !escapeBlocked) {
|
||||
event.preventDefault();
|
||||
@@ -131,7 +135,7 @@ export function SidebarProvider({
|
||||
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, [escapeBlocked, isMobile, open, openMobile, setOpen, setOpenMobile]);
|
||||
}, [commitOpen, escapeBlocked, isMobile, open, openMobile, setOpenMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openMobile && !wasMobileOpen.current) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
function Toaster(props: ToasterProps) {
|
||||
return (
|
||||
<Sonner
|
||||
position="bottom-right"
|
||||
closeButton={false}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "group toast",
|
||||
title: "toast-title",
|
||||
description: "toast-description",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, type KeyboardEvent } from "react";
|
||||
import { keepFocusWithin } from "@/lib/focus-trap";
|
||||
|
||||
type UnverifiedBirthTimeChoiceProps = Readonly<{
|
||||
canUseUnverifiedTime: boolean;
|
||||
unverifiedTime?: string | null;
|
||||
pending?: boolean;
|
||||
onUseUnverifiedTime: () => void;
|
||||
onContinueGenerally: () => void;
|
||||
onRectifyFirst: () => void;
|
||||
onCancel: () => void;
|
||||
}>;
|
||||
|
||||
export function UnverifiedBirthTimeChoice({
|
||||
canUseUnverifiedTime,
|
||||
unverifiedTime,
|
||||
pending = false,
|
||||
onUseUnverifiedTime,
|
||||
onContinueGenerally,
|
||||
onRectifyFirst,
|
||||
onCancel,
|
||||
}: UnverifiedBirthTimeChoiceProps) {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const dialog = useRef<HTMLElement>(null);
|
||||
const initialAction = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => initialAction.current?.focus());
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
function handleDialogKeyDown(event: KeyboardEvent<HTMLElement>) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (!pending) onCancel();
|
||||
return;
|
||||
}
|
||||
const container = dialog.current;
|
||||
if (container) keepFocusWithin(event.nativeEvent, container);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="unverified-birth-time-choice-scrim">
|
||||
<section
|
||||
aria-busy={pending}
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
aria-live="polite"
|
||||
aria-modal="true"
|
||||
className="onboarding-card birth-time-transition-card unverified-birth-time-choice"
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
ref={dialog}
|
||||
role="alertdialog"
|
||||
>
|
||||
<div className="onboarding-card-heading">
|
||||
<b id={titleId}>出生时间还没有完成校正</b>
|
||||
<small>这不会阻止你继续使用 Jyotisha</small>
|
||||
</div>
|
||||
<p id={descriptionId}>
|
||||
{canUseUnverifiedTime
|
||||
? "你可以只在当前聊天临时使用填报时间,也可以先完成生时校正再问。新建聊天后会再次温和提醒。"
|
||||
: "你目前没有可直接使用的具体分钟,系统不会替你猜一个时间。可以先校正,或改问不依赖出生分钟的一般问题。"}
|
||||
</p>
|
||||
<div className="onboarding-card-actions">
|
||||
{canUseUnverifiedTime ? (
|
||||
<button
|
||||
className="button-secondary"
|
||||
disabled={pending}
|
||||
ref={initialAction}
|
||||
type="button"
|
||||
onClick={onUseUnverifiedTime}
|
||||
>
|
||||
{unverifiedTime
|
||||
? `先用 ${unverifiedTime}(未校正)询问`
|
||||
: "先用未校正时间询问"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="button-secondary"
|
||||
disabled={pending}
|
||||
ref={initialAction}
|
||||
type="button"
|
||||
onClick={onContinueGenerally}
|
||||
>
|
||||
继续不依赖出生分钟的一般咨询
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="button-primary"
|
||||
disabled={pending}
|
||||
type="button"
|
||||
onClick={onRectifyFirst}
|
||||
>
|
||||
{pending ? "正在进入生时校正…" : "先校正再询问"}
|
||||
</button>
|
||||
<button
|
||||
className="button-secondary"
|
||||
disabled={pending}
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
返回修改问题
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user