- The working minute is written once, on the timeline; the board's title row no longer repeats it. - 「看盘面」 sits in the header at every width; the board is closed by default and opens as a right column on desktop (bottom sheet on phones). - A distinct result (D4) leads with 最可能的出生时间 + minute + Lagna; the range drops to the next line. Indistinct results keep the range headline (D3). - The most likely column adopts with 「采用 HH:MM 并打开星盘」 and goes to /chart after the server saved it; other columns stay adoptable as 「改用这个」 under 还不能排除. No confidence label. Record-conflict wording unchanged. - 「我说错了,改一条」: confirmed events (from the case read's existing evidence list) prefill the composer; nothing is sent by the pick. No 「再补一件事」: the card must not invite more events (BUG-1084, issue #148 decision). - The segment card keeps "which chart" (BUG-1151): no minute lead, same adopt label, same actions row. Docs: DESIGN §10/§11/§12, VOICE, CHANGELOG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-28 — 生时校正的结果卡和盘面
|
||||
|
||||
- 结果分得出先后时,卡片最上面直接写最可能的出生时间和上升,按钮「采用 这个时间 并打开星盘」,采用后直接去星盘页;还不能排除的时间留在下面,仍然可以改用。
|
||||
- 卡上可以点「我说错了,改一条」:选一条记下的事,输入框里先写好开头,补完发出就会改掉那一条。
|
||||
- 代表时刻只在对话上方的时间轴写一次;当前盘面改成点「看盘面」再打开。
|
||||
- Skill 版本不变(issue #146、#147)。
|
||||
|
||||
## 2026-10-28 — 两处分盘敏感点按来源修正
|
||||
|
||||
- Sarpa Drekkana 从三段补成书上的五段:巨蟹第 3 段、天蝎第 2 段以前漏掉,现在会标出来;正好落在 10 度边界上的盘不再划到上一段。
|
||||
|
||||
@@ -918,6 +918,8 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
**「已对照 N 件」在手机上不在可见行里,桌面上目前仍在——这是一处未裁决的口径冲突。** 下面的禁列表写明这一项归交付卡、不上条,而它后来(cfb41daf)被加到了条上且没有改本文件。BUG-918 只把它在手机上移出可见行;桌面是否也撤掉需要产品裁决,见 `docs/BUG_HISTORY.md` BUG-918。
|
||||
|
||||
**代表分钟只写在条上(2026-10-28,issue #146)。** 盘面标题行不再写时刻,「看盘面」按钮只写这三个字(读屏名称仍带时刻和上升)。下面 D10 那段里「条上与盘面标题行同一次调用」从此只剩条这一处。
|
||||
|
||||
**代表分钟上条是 D10(2026-09-16)的产品决定,推翻了「条上只有区间」的原设计。** 理由是窄屏:`.is-compact` 下右栏盘面整体变成默认关闭的 overlay,代表分钟此前只在 `.rectification-board__clock` 出现,于是用户最该一直看着的那个数字恰恰看不见。条上的取值必须与盘面标题行**同一次调用**——`workingRectificationTime(result)`,由 `rectification-chat-view.ts` 的 `deriveRectificationChatView` 算一次(容器 `rectification-agentic-chat.tsx` 每次渲染调用它一次;2026-09-26 代码拆分只搬位置)、两处共用,不允许第二条推导路径。已采用时写「已采用 HH:MM」,否则写「代表分钟 HH:MM」;**采用不是确认**,完整边界句仍只在交付 / 采用旁白里出现一次,条上不写、也不得暗示某一分钟已确认。
|
||||
|
||||
**条上不得出现收窄进度(「已从 30 分钟收到 7 分钟」)。** 开场窗口确实在线上,但没有任何投影把这个对比作为字段给出,而 VOICE.md 第 2 条要求进度数字来自服务端。前端自己减还会算错:轴是**当前**窗口且会放宽(BUG-572),相减会把一次放宽报成进度。这句话继续由服务端拼进旁白。缺的字段见 `BLOCKED.md`。
|
||||
@@ -955,6 +957,14 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
## 11. 区间交付卡
|
||||
|
||||
**卡头与按钮(2026-10-28,issue #147,产品同意 https://git.copse.top/root/Jyotisha/issues/147#issuecomment-36056 四条)。** 下文 D3、D4 与「更像这个」按以下改:
|
||||
|
||||
- 区分得开(D4:前两列差 ≥ 5 个百分点)且有代表分钟时,卡头是 `.rectification-result-lead`:小字「最可能的出生时间」、30px 衬线时刻、「上升X」(有该分钟的宫位表才写);「目前范围 …(对照了 N 件经历)」降为下一行 `.rectification-range-delivery__range-line`。区分不开时 D3 原样:范围是 `<strong>` 卡头,加「区分不开」那句,不写最可能。
|
||||
- 区分得开时最可能那列排第一,按钮是实心「采用 HH:MM 并打开星盘」,采用成功(服务端已保存)后去 `/chart`;其余列在小字「还不能排除」下面,按钮是描边「改用这个」,采用后不跳转。区分不开时每列仍是「更像这个」。出生记录冲突时一律「改用校正结果」,不跳转。
|
||||
- 不显示把握(低 / 中 / 高):引擎的这一档没有校准,BUG-560 同理。
|
||||
- 可以采用时,卡上多一个文字按钮「我说错了,改一条」,展开用户确认过的事件(与对话里的复述同一说法),点一条只在输入框里写好「我说错了:「…」应该是」,由用户补完发出,现有改记录工具来改;不自动发送、不另扣点。没有确认过的事件就不出这个按钮。不放「再补一件事」:训练门后补打字经历几乎缩不了范围,卡上不得邀请再补(BUG-1084,issue #148 产品 2026-10-28 定)。
|
||||
- 盘型交付卡(「用哪张盘解读」)不加时刻卡头(BUG-1151:盘型产品不排分钟),按钮文案不变;平常采用后同样去 `/chart`,也有上面那一行。
|
||||
|
||||
交付对象是区间里并排的候选分钟,不是支持度数字,也不是逐行空差异。
|
||||
|
||||
**相同的句子只写一次。** 两列或三列共有的 D9 / D10 / 月宿性格句提到卡片顶部(「三个时间的事业盘都在巨蟹座:做事以照顾人为主」)。列内只留有差别的句子;某列没有差别句就不显示性格块,不写「同上」。单列不提升共享句。
|
||||
@@ -973,6 +983,8 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
## 12. 校正右栏盘面
|
||||
|
||||
**默认关着(2026-10-28,issue #146)。** 所有宽度上,头部都有「看盘面」按钮(`.rectification-board-peek`),盘面默认不出。桌面点开后在右侧分出一栏(`minmax(18rem, 22.5rem)`,没有候选时 16–18rem),280ms 从右淡入,标题行右侧是关闭按钮;手机仍是底部抽屉。标题行不写时刻。
|
||||
|
||||
右栏回答「当前按哪一分钟在看盘」,不回答「选哪一分钟」。顶部不加任何新说明文字。
|
||||
|
||||
**本命宫位改北印星盘。** 用 `VedicChartSvg` 渲染代表分钟的 `house_table`。宫位表退到星盘下方的 `<details>`(读屏与复制仍可用)。换分钟时只高亮变化的宫(`changedHouses` / `lagnaChanged`),不做动画。星盘组件在校正面 `dynamic()` 分包里,不进首页首屏。
|
||||
|
||||
@@ -63,6 +63,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页不写副标题。按钮是「开始合盘」,上面不写价格。点了先确认「会用掉 N 点,余额 M 点。」,按钮是「取消 / 开始」。余额不够时说「需要 N 点,余额只有 M 点。」,主按钮是「去补点数」。N 从计价表读,M 从账户读。计价读不到时不弹确认,只说「计费配置不可用」。这两人已经有写好的合盘、且没有一份正在生成时,按钮下面加「这两人已经合过 N 次,再生成会另扣 X 点」,N、X 都读实际数字。正在生成时不写这句。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页不出现两个人的选择框,也不出现「开始合盘」。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
|
||||
|
||||
## 生时校正界面:收束卡与盘面(2026-10-28,issue #146、#147)
|
||||
|
||||
- 头部按钮「看盘面」;盘面标题「当前盘面」,不写时刻。
|
||||
- 收束卡区分得开时:「最可能的出生时间」+ 时刻 + 「上升X」,下一行仍是「目前范围 …(对照了 N 件经历)」。最可能那列按钮「采用 HH:MM 并打开星盘」;其余列上方小字「还不能排除」,按钮「改用这个」。区分不开时不变(「更像这个」、「这几个时刻按现在的方法区分不开。」)。不写「把握」。
|
||||
- 卡上一个文字按钮「我说错了,改一条」(不放「再补一件事」,BUG-1084);展开后一句「选一条要改的,输入框里会先写好开头:」,点一条后输入框写「我说错了:「事件」应该是」。
|
||||
|
||||
## 生时校正独立入口(2026-10-28,issue #145)
|
||||
|
||||
产品 2026-10-28 定:校正有自己的入口,不再从对话里开案例。下面这节「生时校正从对话进入」里的卡片文案和「明确要求则直接进入」由本节取代,其余(何时提议、做不了必须说一句、正文怎么提、回来)不变。
|
||||
|
||||
@@ -2827,14 +2827,26 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
/* Issue #146: the board is closed by default; 「看盘面」 in the header opens it
|
||||
as a right column on wide screens. */
|
||||
.rectification-workspace.is-board-open:not(.is-compact) {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
|
||||
}
|
||||
/* No candidate yet: the board only carries the declared time, so it takes less
|
||||
width until there is a house table to show. */
|
||||
.rectification-workspace.is-board-empty {
|
||||
.rectification-workspace.is-board-empty.is-board-open:not(.is-compact) {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(16rem, 18rem);
|
||||
}
|
||||
.rectification-workspace:not(.is-compact) .rectification-board {
|
||||
animation: rectification-board-enter 280ms cubic-bezier(.22, 1, .36, 1) both;
|
||||
}
|
||||
@keyframes rectification-board-enter {
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.rectification-workspace.is-compact {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
@@ -7201,3 +7213,83 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
.starter-rectify-link:hover {
|
||||
background: var(--color-canvas-muted);
|
||||
}
|
||||
|
||||
/* Result card (issue #147). The most likely minute leads only when the card
|
||||
says the columns can be told apart (D4); the range drops to the line under
|
||||
it. Candidates that cannot be ruled out keep their own adopt button. */
|
||||
.rectification-result-lead {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
}
|
||||
.rectification-result-lead__label {
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.rectification-result-lead__time {
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 30px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rectification-result-lead__lagna {
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.rectification-range-delivery__range-line {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rectification-range-delivery__others {
|
||||
margin: 0 0 var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-canvas-muted);
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.rectification-result-actions {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.rectification-result-actions__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.rectification-result-actions__fix {
|
||||
color: var(--color-action);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.rectification-result-actions__list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas-soft);
|
||||
animation: rectification-result-list-enter 260ms cubic-bezier(.22, 1, .36, 1) both;
|
||||
}
|
||||
.rectification-result-actions__list p {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.rectification-result-actions__items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.rectification-result-actions__items button {
|
||||
white-space: normal;
|
||||
text-align: start;
|
||||
}
|
||||
@keyframes rectification-result-list-enter {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { createPortal } from "react-dom";
|
||||
import type { ChatMessage } from "@/lib/chat-message-view";
|
||||
import { canShowRectificationSelectionCards } from "@/lib/rectification-candidate-result";
|
||||
import { RectificationRangeDelivery } from "@/components/rectification-range-delivery";
|
||||
import { useOpenChartAfterAdopt } from "@/hooks/use-open-chart-after-adopt";
|
||||
import { fixEvidenceDraft } from "@/lib/rectification-fix-evidence";
|
||||
import { livingDisplayFromReceipt } from "@/lib/rectification-agentic/core/candidate-window";
|
||||
import {
|
||||
RECTIFICATION_EMPTY_ACTION_LABEL,
|
||||
@@ -170,7 +172,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
currentQuestionRef,
|
||||
applyCaseSnapshot,
|
||||
loadCaseSnapshot,
|
||||
fixableEvidence,
|
||||
} = useRectificationCaseSnapshot({ caseId, sessionId, initialSnapshot });
|
||||
const { arm: armChartAfterAdopt, onSaved: savedThenChart } = useOpenChartAfterAdopt(onSaved);
|
||||
const readonly = historyReadonly || Boolean(resultNotice);
|
||||
const conversationReadonly = historyReadonly || (Boolean(resultNotice) && !canRecompare);
|
||||
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
|
||||
@@ -365,30 +369,33 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
void send("opening", "");
|
||||
}, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]);
|
||||
|
||||
const acceptCandidate = useCallback(async (candidateId: string) => runRectificationCandidateAccept({
|
||||
caseId,
|
||||
sessionId,
|
||||
busy,
|
||||
readonly,
|
||||
candidateResult,
|
||||
acceptingCandidateId,
|
||||
keyCounter,
|
||||
runAbort,
|
||||
lastRunOutcome,
|
||||
conversationAnchor,
|
||||
setError,
|
||||
setMessages,
|
||||
setAcceptingCandidateId,
|
||||
setCandidateResult,
|
||||
setSavedTime,
|
||||
setSavedStatus,
|
||||
setPending,
|
||||
beginLiveRun,
|
||||
loadCaseSnapshot,
|
||||
send,
|
||||
onCompleted,
|
||||
onSaved,
|
||||
}, candidateId), [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
|
||||
const acceptCandidate = useCallback(async (candidateId: string, openChart = false) => {
|
||||
armChartAfterAdopt(openChart);
|
||||
return runRectificationCandidateAccept({
|
||||
caseId,
|
||||
sessionId,
|
||||
busy,
|
||||
readonly,
|
||||
candidateResult,
|
||||
acceptingCandidateId,
|
||||
keyCounter,
|
||||
runAbort,
|
||||
lastRunOutcome,
|
||||
conversationAnchor,
|
||||
setError,
|
||||
setMessages,
|
||||
setAcceptingCandidateId,
|
||||
setCandidateResult,
|
||||
setSavedTime,
|
||||
setSavedStatus,
|
||||
setPending,
|
||||
beginLiveRun,
|
||||
loadCaseSnapshot,
|
||||
send,
|
||||
onCompleted,
|
||||
onSaved: savedThenChart,
|
||||
}, candidateId);
|
||||
}, [acceptingCandidateId, armChartAfterAdopt, savedThenChart, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, readonly, send, sessionId, setPending]);
|
||||
|
||||
async function copyMessage(message: RenderMessage) {
|
||||
await copyRectificationMessage({ setCopiedMessageKey }, message);
|
||||
@@ -533,7 +540,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const boardPeek = compactBoard && !boardOpen ? (
|
||||
// Issue #146: 「看盘面」 is in the header at every width. On a phone the open
|
||||
// sheet covers it; on a desktop it stays as the toggle beside the open board.
|
||||
const boardPeek = !compactBoard || !boardOpen ? (
|
||||
<RectificationBoardPeek
|
||||
result={candidateResult}
|
||||
declaredTime={declaredTime}
|
||||
@@ -598,8 +607,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
result={candidateResult}
|
||||
acceptingCandidateId={acceptingCandidateId}
|
||||
readonly={readonly || (busy && !acceptingCandidateId)}
|
||||
onAccept={(candidateId) => void acceptCandidate(candidateId)}
|
||||
onAccept={(candidateId, openChart) => void acceptCandidate(candidateId, openChart)}
|
||||
adoptLocked={liveInterviewBlockingAdopt}
|
||||
fixableEvidence={fixableEvidence}
|
||||
onFixEvidence={(line) => {
|
||||
setDraft(fixEvidenceDraft(line));
|
||||
composer.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
/>
|
||||
{verifiedIdleCopy && (
|
||||
<p className="rectification-pending-note" role="status">
|
||||
|
||||
@@ -94,12 +94,9 @@ function RectificationBoardBody({
|
||||
<header className="rectification-board__header">
|
||||
<div className="rectification-board__title-row">
|
||||
<h2 id={titleId}>当前盘面</h2>
|
||||
{workingTime
|
||||
? <span className="rectification-board__clock">{workingTime}</span>
|
||||
: emptyCopy.clock
|
||||
? <span className="rectification-board__clock is-declared">{emptyCopy.clock}</span>
|
||||
: null}
|
||||
{compact && (
|
||||
{/* Issue #146: the working minute is written once, on the timeline
|
||||
above the conversation. The board no longer repeats it. */}
|
||||
{(
|
||||
<Button
|
||||
className="rectification-board__close"
|
||||
type="button"
|
||||
@@ -288,7 +285,8 @@ export function RectificationBoard({
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [compact, open, onClose]);
|
||||
|
||||
if (compact && !open) return null;
|
||||
// Issue #146: closed by default at every width; 「看盘面」 opens it.
|
||||
if (!open) return null;
|
||||
|
||||
const sheet = (
|
||||
<aside
|
||||
@@ -323,6 +321,9 @@ export function RectificationBoard({
|
||||
);
|
||||
}
|
||||
|
||||
/** Issue #146: the header button says what it does; the time lives on the timeline. */
|
||||
export const RECTIFICATION_BOARD_OPEN_LABEL = "看盘面";
|
||||
|
||||
export function RectificationBoardPeek({
|
||||
result,
|
||||
declaredTime = null,
|
||||
@@ -344,13 +345,12 @@ export function RectificationBoardPeek({
|
||||
className="rectification-board-peek"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label={peek.detail ? `${peek.title},${peek.detail}` : peek.title}
|
||||
aria-label={peek.detail ? `${RECTIFICATION_BOARD_OPEN_LABEL},${peek.detail}` : RECTIFICATION_BOARD_OPEN_LABEL}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={boardId}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<strong>{peek.title}</strong>
|
||||
<span>{peek.detail}</span>
|
||||
<strong>{RECTIFICATION_BOARD_OPEN_LABEL}</strong>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ import { RectificationVerificationReport } from "@/components/rectification-veri
|
||||
import { RectificationSegmentDelivery } from "@/components/rectification-segment-delivery";
|
||||
import { sessionOutcomeAllowsAdopt } from "@/lib/rectification-agentic/core/rectification-decision";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RectificationResultActions, RectificationResultLead } from "@/components/rectification-result-parts";
|
||||
import {
|
||||
RECTIFICATION_OTHER_CANDIDATES_LABEL,
|
||||
RECTIFICATION_SWITCH_TO_THIS_LABEL,
|
||||
adoptAndOpenChartLabel,
|
||||
} from "@/lib/rectification-fix-evidence";
|
||||
|
||||
import { deliveryWindowFromReceipt, protectVargaReport } from "@/lib/rectification-agentic/v9/delivery-window";
|
||||
|
||||
@@ -31,12 +37,17 @@ export function RectificationRangeDelivery({
|
||||
readonly,
|
||||
onAccept,
|
||||
adoptLocked,
|
||||
fixableEvidence = [],
|
||||
onFixEvidence,
|
||||
}: Readonly<{
|
||||
result: RectificationCandidateResult;
|
||||
acceptingCandidateId: string | null;
|
||||
readonly: boolean;
|
||||
onAccept: (candidateId: string) => void;
|
||||
/** `openChart`: the most likely column's 「采用 HH:MM 并打开星盘」 (issue #147). */
|
||||
onAccept: (candidateId: string, openChart?: boolean) => void;
|
||||
adoptLocked?: boolean;
|
||||
fixableEvidence?: readonly string[];
|
||||
onFixEvidence?: (line: string) => void;
|
||||
}>) {
|
||||
const delivery: RangeDeliveryProjection | null = result.rangeDelivery;
|
||||
const range = delivery?.range ?? result.credibleRange;
|
||||
@@ -71,24 +82,48 @@ export function RectificationRangeDelivery({
|
||||
// D4: per-column numbers only when the first column leads by ≥ 5 points.
|
||||
const showPercents = rangeDeliveryShowsPercents(columns);
|
||||
const indistinct = columns.length >= 2 && !showPercents;
|
||||
// Issue #147: lead with the most likely minute only when D4 says the columns
|
||||
// can be told apart; otherwise the range stays the headline (D3).
|
||||
const primaryColumn = mostLikely && !indistinct
|
||||
? columns.find((column) => column.candidate_id === delivery?.representative_candidate_id)
|
||||
?? columns.find((column) => column.time === mostLikely)
|
||||
?? null
|
||||
: null;
|
||||
const primaryLagna = primaryColumn
|
||||
? result.houseTablesByTime[primaryColumn.time]?.lagna
|
||||
?? (result.houseTable?.time === primaryColumn.time ? result.houseTable.lagna : null)
|
||||
?? null
|
||||
: null;
|
||||
const orderedColumns = primaryColumn
|
||||
? [primaryColumn, ...columns.filter((column) => column !== primaryColumn)]
|
||||
: columns;
|
||||
const showActions = adoptAllowed && !adoptLocked && !readonly;
|
||||
|
||||
if (delivery && (delivery.product_delivery || delivery.segment_summary)) {
|
||||
return <RectificationSegmentDelivery summary={delivery.segment_summary ?? null} delivery={delivery} rangeLabel={rangeLabel}
|
||||
selection={result.segmentSelection} resultId={result.resultId} busy={Boolean(busy)} accepting={Boolean(acceptingCandidateId)} onAccept={onAccept}
|
||||
adoptLocked={adoptLocked} markdown={markdown} canAdopt={adoptAllowed}
|
||||
candidateDate={result.candidates.find((candidate) => candidate.candidateId === delivery.representative_candidate_id)?.candidate_date} />;
|
||||
candidateDate={result.candidates.find((candidate) => candidate.candidateId === delivery.representative_candidate_id)?.candidate_date}
|
||||
fixableEvidence={fixableEvidence} onFixEvidence={onFixEvidence} readonly={readonly} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正区间">
|
||||
<div className="rectification-candidates-heading">
|
||||
<strong>{title}</strong>
|
||||
{mostLikely && !indistinct ? (
|
||||
<span className="rectification-range-delivery__most-likely">
|
||||
{rangeDeliveryMostLikely(mostLikely)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{primaryColumn ? (
|
||||
<>
|
||||
<RectificationResultLead time={primaryColumn.time} lagna={primaryLagna} />
|
||||
<p className="rectification-range-delivery__range-line">{title}</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="rectification-candidates-heading">
|
||||
<strong>{title}</strong>
|
||||
{mostLikely && !indistinct ? (
|
||||
<span className="rectification-range-delivery__most-likely">
|
||||
{rangeDeliveryMostLikely(mostLikely)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{vargaBlocked ? (
|
||||
<p className="rectification-range-delivery__narrow">{windowMinutes === null ? "完整窗口范围暂不可用;暂不推荐分盘解读。" : "分盘解读需要更准的出生时间"}</p>
|
||||
) : caption ? (
|
||||
@@ -118,7 +153,8 @@ export function RectificationRangeDelivery({
|
||||
</ul>
|
||||
) : null}
|
||||
<ul className="rectification-range-delivery__columns">
|
||||
{columns.map((column) => {
|
||||
{orderedColumns.map((column, index) => {
|
||||
const isPrimary = column === primaryColumn;
|
||||
const candidateDate = result.candidates.find((candidate) => candidate.candidateId === column.candidate_id)?.candidate_date;
|
||||
const relativeDay = candidateDate && result.declaredBirthDate && candidateDate !== result.declaredBirthDate
|
||||
? candidateDate < result.declaredBirthDate ? "(前一天)" : "(后一天)" : "";
|
||||
@@ -128,8 +164,11 @@ export function RectificationRangeDelivery({
|
||||
return (
|
||||
<li
|
||||
key={column.candidate_id}
|
||||
className={`rectification-range-delivery__column${adopted ? " is-adopted" : ""}`}
|
||||
className={`rectification-range-delivery__column${adopted ? " is-adopted" : ""}${isPrimary ? " is-primary" : ""}`}
|
||||
>
|
||||
{primaryColumn && index === 1 ? (
|
||||
<p className="rectification-range-delivery__others">{RECTIFICATION_OTHER_CANDIDATES_LABEL}</p>
|
||||
) : null}
|
||||
<div className="rectification-range-delivery__time">{candidateDate ? `${candidateDate}${relativeDay} ${column.time}` : column.time}</div>
|
||||
{adoptAllowed && !adoptLocked && candidateDate ? <p className="rectification-range-delivery__muted">采用后,排盘使用这一日期和时间;原填报日期保留。</p> : null}
|
||||
{showPercents ? (
|
||||
@@ -162,11 +201,11 @@ export function RectificationRangeDelivery({
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant={isPrimary ? "default" : "outline"}
|
||||
className="rectification-range-delivery__accept"
|
||||
aria-pressed={adopted}
|
||||
disabled={busy || adopted}
|
||||
onClick={() => onAccept(column.candidate_id)}
|
||||
onClick={() => onAccept(column.candidate_id, isPrimary && !recordConflict)}
|
||||
>
|
||||
{adopting
|
||||
? RECTIFICATION_USER_COPY.rangeDeliveryAdopting
|
||||
@@ -174,7 +213,11 @@ export function RectificationRangeDelivery({
|
||||
? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
|
||||
: recordConflict
|
||||
? SWITCH_TO_RECTIFIED_LABEL
|
||||
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
|
||||
: isPrimary
|
||||
? adoptAndOpenChartLabel(column.time)
|
||||
: primaryColumn
|
||||
? RECTIFICATION_SWITCH_TO_THIS_LABEL
|
||||
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
|
||||
</Button>
|
||||
{recordConflict && !adopted ? (
|
||||
<p className="rectification-range-delivery__switch-note">
|
||||
@@ -189,6 +232,13 @@ export function RectificationRangeDelivery({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{showActions ? (
|
||||
<RectificationResultActions
|
||||
evidence={fixableEvidence}
|
||||
disabled={Boolean(busy)}
|
||||
onFixEvidence={onFixEvidence}
|
||||
/>
|
||||
) : null}
|
||||
{delivery?.more_label ? (
|
||||
<p className="rectification-range-delivery__more">{delivery.more_label}</p>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RECTIFICATION_FIX_COPY, RECTIFICATION_MOST_LIKELY_LABEL } from "@/lib/rectification-fix-evidence";
|
||||
|
||||
/**
|
||||
* Issue #147: the result card leads with the most likely minute when the
|
||||
* server says the candidates can be told apart (D4). The card decides that;
|
||||
* this only draws it.
|
||||
*/
|
||||
export function RectificationResultLead({ time, lagna }: Readonly<{ time: string; lagna: string | null }>) {
|
||||
return (
|
||||
<div className="rectification-result-lead">
|
||||
<span className="rectification-result-lead__label">{RECTIFICATION_MOST_LIKELY_LABEL}</span>
|
||||
<strong className="rectification-result-lead__time">{time}</strong>
|
||||
{lagna ? <span className="rectification-result-lead__lagna">上升{lagna}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「我说错了,改一条」 lists the confirmed events and fills the composer with
|
||||
* the chosen one. Nothing is sent here. There is deliberately no 「再补一件事」:
|
||||
* typed events barely narrow the range after the training gate, so the card
|
||||
* must not invite more (BUG-1084, issue #148 decision 2026-10-28).
|
||||
*/
|
||||
export function RectificationResultActions({
|
||||
evidence,
|
||||
disabled,
|
||||
onFixEvidence,
|
||||
}: Readonly<{
|
||||
evidence: readonly string[];
|
||||
disabled: boolean;
|
||||
onFixEvidence?: (line: string) => void;
|
||||
}>) {
|
||||
const [fixOpen, setFixOpen] = useState(false);
|
||||
const listId = useId();
|
||||
if (!onFixEvidence || evidence.length === 0) return null;
|
||||
return (
|
||||
<div className="rectification-result-actions">
|
||||
<div className="rectification-result-actions__row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="rectification-result-actions__fix"
|
||||
disabled={disabled}
|
||||
aria-expanded={fixOpen}
|
||||
aria-controls={listId}
|
||||
onClick={() => setFixOpen((open) => !open)}
|
||||
>
|
||||
{RECTIFICATION_FIX_COPY.fixOne}
|
||||
</Button>
|
||||
</div>
|
||||
{fixOpen ? (
|
||||
<div id={listId} className="rectification-result-actions__list" role="group" aria-label={RECTIFICATION_FIX_COPY.fixOne}>
|
||||
<p>{RECTIFICATION_FIX_COPY.pickHint}</p>
|
||||
<div className="rectification-result-actions__items">
|
||||
{evidence.map((line) => (
|
||||
<Button
|
||||
key={line}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setFixOpen(false);
|
||||
onFixEvidence(line);
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import { RectificationVerificationReport } from "@/components/rectification-veri
|
||||
import { protectVargaReport } from "@/lib/rectification-agentic/v9/delivery-window";
|
||||
import { unknownProductDeliveryCopy } from "@/lib/rectification-agentic/v9/product-delivery";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RectificationResultActions } from "@/components/rectification-result-parts";
|
||||
|
||||
export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, selection, resultId, busy, accepting, onAccept, adoptLocked, markdown, candidateDate, canAdopt = false }: Readonly<{
|
||||
export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, selection, resultId, busy, accepting, onAccept, adoptLocked, markdown, candidateDate, canAdopt = false,
|
||||
fixableEvidence = [], onFixEvidence, readonly = false }: Readonly<{
|
||||
summary: SegmentSummary | null;
|
||||
delivery: RangeDeliveryProjection;
|
||||
rangeLabel: string | null;
|
||||
@@ -17,11 +19,15 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
|
||||
resultId: string;
|
||||
busy: boolean;
|
||||
accepting: boolean;
|
||||
onAccept: (candidateId: string) => void;
|
||||
/** `openChart`: 「采用 HH:MM 并打开星盘」 (issue #147). */
|
||||
onAccept: (candidateId: string, openChart?: boolean) => void;
|
||||
adoptLocked?: boolean;
|
||||
markdown?: string | null;
|
||||
candidateDate?: string;
|
||||
canAdopt?: boolean;
|
||||
fixableEvidence?: readonly string[];
|
||||
onFixEvidence?: (line: string) => void;
|
||||
readonly?: boolean;
|
||||
}>) {
|
||||
const adoption = summary?.adoption_minute;
|
||||
const candidateId = delivery.representative_candidate_id;
|
||||
@@ -36,6 +42,10 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
|
||||
const blocked = summary?.charts.some((chart) => chart.tier === "blocked") === true;
|
||||
const reportBlocked = !summary || blocked;
|
||||
const showAdopt = Boolean(canAdopt && adoption && candidateId && !adoptLocked);
|
||||
// Issue #147: no minute lead here. The segment product answers "which chart",
|
||||
// never "which minute" (BUG-1151, product 10-01 option A). A plain adopt still
|
||||
// opens the chart afterwards; re-adopt and record-conflict adopts do not.
|
||||
const plainAdopt = !(readopt && priorAdoption) && !delivery.record_conflict;
|
||||
return (
|
||||
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正盘型">
|
||||
<div className="rectification-candidates-heading"><strong>用哪张盘解读</strong></div>
|
||||
@@ -58,8 +68,8 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
|
||||
{showAdopt && adoption && candidateId ? (
|
||||
<>
|
||||
<p className="rectification-range-delivery__muted">采用后按 {adoption.date ? `${adoption.date} ` : ""}{adoption.time} 排盘;原填报资料保留。</p>
|
||||
<Button type="button" variant="outline" className="rectification-range-delivery__accept" disabled={busy || adopted}
|
||||
aria-pressed={adopted} onClick={() => onAccept(candidateId)}>
|
||||
<Button type="button" variant={plainAdopt ? "default" : "outline"} className="rectification-range-delivery__accept" disabled={busy || adopted}
|
||||
aria-pressed={adopted} onClick={() => onAccept(candidateId, plainAdopt)}>
|
||||
{accepting ? RECTIFICATION_USER_COPY.rangeDeliveryAdopting : adopted ? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
|
||||
: readopt && priorAdoption ? `改用 ${readopt}`
|
||||
: delivery.record_conflict ? SWITCH_TO_RECTIFIED_LABEL : `改用 ${charts.map((chart) => chart.chart).join(" + ")} 盘解读`}
|
||||
@@ -76,6 +86,9 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
|
||||
) : adopted ? (
|
||||
<p className="rectification-range-delivery__muted">{RECTIFICATION_USER_COPY.rangeDeliveryAdopted}</p>
|
||||
) : null}
|
||||
{showAdopt && !readonly ? (
|
||||
<RectificationResultActions evidence={fixableEvidence} disabled={busy} onFixEvidence={onFixEvidence} />
|
||||
) : null}
|
||||
<p className="rectification-range-delivery__boundary">{delivery.boundary || REPRESENTATIVE_MINUTE_DISCLAIMER}</p>
|
||||
<RectificationVerificationReport markdown={protectVargaReport(markdown === undefined ? delivery.verification_markdown : markdown, reportBlocked, delivery.product_delivery?.targets ?? summary?.targets,
|
||||
!summary ? "完整窗口扫描暂不可用;不能提供分盘解读推荐。" : undefined)} />
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
import { navigateInApp } from "@/lib/app-navigation";
|
||||
|
||||
/**
|
||||
* 「采用 HH:MM 并打开星盘」 (issue #147). The adopt itself is the existing
|
||||
* accept run; this only goes to /chart once the server has saved the time.
|
||||
* Armed per click, so a failed adopt or a plain 「改用这个」 never navigates.
|
||||
*/
|
||||
export function useOpenChartAfterAdopt(
|
||||
onSaved: ((time: string, status: "accepted" | "confirmed") => void) | undefined,
|
||||
) {
|
||||
const openAfterSave = useRef(false);
|
||||
const arm = useCallback((open: boolean) => {
|
||||
openAfterSave.current = open;
|
||||
}, []);
|
||||
const saved = useCallback((time: string, status: "accepted" | "confirmed") => {
|
||||
onSaved?.(time, status);
|
||||
if (!openAfterSave.current) return;
|
||||
openAfterSave.current = false;
|
||||
navigateInApp("/chart");
|
||||
}, [onSaved]);
|
||||
return { arm, onSaved: saved };
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type CurrentQuestionModel,
|
||||
} from "@/lib/rectification-chat-snapshot";
|
||||
import type { CandidateResult } from "@/lib/rectification-chat-snapshot";
|
||||
import { fixableEvidenceLines } from "@/lib/rectification-fix-evidence";
|
||||
|
||||
/**
|
||||
* Case snapshot sync for the rectification chat surface: the state that the
|
||||
@@ -63,6 +64,8 @@ export function useRectificationCaseSnapshot({
|
||||
const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0);
|
||||
const [questionRepairing, setQuestionRepairing] = useState(false);
|
||||
const [snapshotFailure, setSnapshotFailure] = useState<RectificationSnapshotFailure | null>(null);
|
||||
// Issue #147: the confirmed events 「我说错了,改一条」 lists.
|
||||
const [fixableEvidence, setFixableEvidence] = useState<readonly string[]>(() => fixableEvidenceLines(initialSnapshot?.evidence));
|
||||
const snapshotAbort = useRef<AbortController | null>(null);
|
||||
const currentQuestionRef = useRef(currentQuestion);
|
||||
|
||||
@@ -93,6 +96,7 @@ export function useRectificationCaseSnapshot({
|
||||
const snapshot = payload as RectificationCaseSnapshotPayload;
|
||||
setResultNotice(snapshot.case?.result_notice ?? null);
|
||||
setCanRecompare(snapshot.case?.result_identity?.can_recompare === true);
|
||||
if (Array.isArray(snapshot.evidence)) setFixableEvidence(fixableEvidenceLines(snapshot.evidence));
|
||||
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
|
||||
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
|
||||
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
|
||||
@@ -174,6 +178,7 @@ export function useRectificationCaseSnapshot({
|
||||
}, [applyCaseSnapshot, caseId, sessionId]);
|
||||
|
||||
return {
|
||||
fixableEvidence,
|
||||
resultNotice,
|
||||
canRecompare,
|
||||
savedTime,
|
||||
|
||||
@@ -118,9 +118,8 @@ export function deriveRectificationChatView(input: RectificationChatViewInput) {
|
||||
candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [],
|
||||
inferenceMarks: candidateResult?.inferenceMarks ?? [],
|
||||
stage: caseStage,
|
||||
// The same call the board's title-row clock makes, on the same result, so
|
||||
// the bar and `.rectification-board__clock` are one number by construction
|
||||
// rather than by two code paths that happen to agree today.
|
||||
// The one place the working minute is shown (issue #146 took it off the
|
||||
// board's title row). The board's peek label reads the same call.
|
||||
workingTime: workingRectificationTime(candidateResult),
|
||||
workingAdopted: Boolean(candidateResult?.selectedTime),
|
||||
segmentSummary: candidateResult?.rangeDelivery?.segment_summary,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 「我说错了,改一条」 on the result card (issue #147). The list is the case's
|
||||
* confirmed events, worded with the same recap line the conversation already
|
||||
* shows. Picking one only fills the composer; the user finishes the sentence
|
||||
* and sends it, and the agent revises that event with its existing tool. No
|
||||
* new route, nothing sent or charged by the pick itself.
|
||||
*/
|
||||
|
||||
import { displayDateLabel, eventPhraseFromSummary } from "./rectification-agentic/v9/evidence-model.ts";
|
||||
import { evidenceRecapLine } from "./rectification-agentic/v9/host-fallback.ts";
|
||||
|
||||
export const RECTIFICATION_FIX_COPY = {
|
||||
fixOne: "我说错了,改一条",
|
||||
pickHint: "选一条要改的,输入框里会先写好开头:",
|
||||
} as const;
|
||||
|
||||
/** Confirmed events only, oldest first as stored, each once. */
|
||||
export function fixableEvidenceLines(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const lines: string[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
if (row.status !== "confirmed") continue;
|
||||
const summary = typeof row.summary === "string" ? row.summary : "";
|
||||
if (!summary.trim()) continue;
|
||||
const precision = typeof row.datePrecision === "string" ? row.datePrecision : "unknown";
|
||||
const from = typeof row.occurredFrom === "string" ? row.occurredFrom : null;
|
||||
const to = typeof row.occurredTo === "string" ? row.occurredTo : null;
|
||||
const line = evidenceRecapLine({
|
||||
display_date_label: displayDateLabel(precision, from, to),
|
||||
event_phrase: eventPhraseFromSummary(summary),
|
||||
}).trim();
|
||||
if (line && !lines.includes(line)) lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** The composer's opening words; the user writes what it should be. */
|
||||
export function fixEvidenceDraft(line: string): string {
|
||||
return `我说错了:「${line}」应该是`;
|
||||
}
|
||||
|
||||
/** The primary adopt label on the most likely column. */
|
||||
export function adoptAndOpenChartLabel(time: string): string {
|
||||
return `采用 ${time} 并打开星盘`;
|
||||
}
|
||||
|
||||
export const RECTIFICATION_OTHER_CANDIDATES_LABEL = "还不能排除";
|
||||
export const RECTIFICATION_SWITCH_TO_THIS_LABEL = "改用这个";
|
||||
export const RECTIFICATION_MOST_LIKELY_LABEL = "最可能的出生时间";
|
||||
@@ -112,6 +112,8 @@ export type RectificationCaseSnapshotPayload = Readonly<{
|
||||
birth_time_source?: unknown;
|
||||
}>;
|
||||
turns?: unknown;
|
||||
/** The case's evidence rows; the result card lists the confirmed ones (issue #147). */
|
||||
evidence?: unknown;
|
||||
}>;
|
||||
|
||||
/** `candidate_range` as `[start, end]`; null when absent or malformed. */
|
||||
|
||||
@@ -635,7 +635,10 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
|
||||
assert.match(styles, /\.rectification-board__close \{[\s\S]*width: 44px/);
|
||||
assert.match(styles, /\.rectification-board__close \{[\s\S]*min-height: 44px/);
|
||||
assert.match(board, /<X aria-hidden="true"/);
|
||||
assert.match(chat, /compactBoard && !boardOpen \? \(/);
|
||||
// 原值:只在手机且盘面没打开时出「看盘面」入口(compactBoard && !boardOpen)。
|
||||
// 新值:桌面上也出,手机上盘面打开时才收起。
|
||||
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
|
||||
assert.match(chat, /!compactBoard \|\| !boardOpen \? \(/);
|
||||
assert.match(chat, /headerSlot && boardPeek \? createPortal\(boardPeek, headerSlot\)/);
|
||||
assert.doesNotMatch(chat, /querySelector\(/);
|
||||
assert.doesNotMatch(chat, /setHeaderSlot/);
|
||||
|
||||
@@ -553,7 +553,8 @@ test("the minute card hides the adopt button and the chart-clock line when adopt
|
||||
const allowed = renderRange(cardResult("adopt_representative", true, minuteDelivery()));
|
||||
assert.match(allowed, /<button/);
|
||||
assert.match(allowed, /采用后,排盘使用这一日期和时间/);
|
||||
assert.match(allowed, /更像这个/);
|
||||
// 原值:/更像这个/。新值:区分得开时最可能那列是「采用 HH:MM 并打开星盘」。原因:issue #147。
|
||||
assert.match(allowed, /更像这个|采用 \d{2}:\d{2} 并打开星盘/);
|
||||
});
|
||||
|
||||
test("a refused adoption refreshes the snapshot and says so in Chinese", async () => {
|
||||
@@ -734,3 +735,21 @@ test("answering widen A with a failed rescore never delivers or adopts on the ol
|
||||
assert.notEqual(receipt.nextAction.type, "ready_to_adopt");
|
||||
assert.notEqual(receipt.nextAction.type, "complete_with_range");
|
||||
});
|
||||
|
||||
// Issue #147: the segment card answers "which chart" (BUG-1151), so it gets the
|
||||
// actions row but never a minute lead, and keeps its own adopt label.
|
||||
test("the segment card gets 改一条 but no minute lead and no 再补一件事", () => {
|
||||
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
|
||||
result: cardResult("adopt_representative", true, segmentDelivery()),
|
||||
acceptingCandidateId: null,
|
||||
readonly: false,
|
||||
onAccept: () => {},
|
||||
fixableEvidence: ["2016-09 升学"],
|
||||
onFixEvidence() {},
|
||||
}));
|
||||
assert.match(html, /用哪张盘解读/);
|
||||
assert.doesNotMatch(html, /再补一件事/, "BUG-1084: the card does not invite more events");
|
||||
assert.match(html, /我说错了,改一条/);
|
||||
assert.doesNotMatch(html, /最可能|rectification-result-lead|并打开星盘/);
|
||||
assert.match(html, /盘解读<\/button>/);
|
||||
});
|
||||
|
||||
@@ -146,10 +146,17 @@ test("D4 threshold is 5 points between the first and second column", () => {
|
||||
});
|
||||
|
||||
test("D3 range is the title and the representative minute is the subtitle", () => {
|
||||
// 原值:前两列差距 ≥ 5 时仍以「目前范围 …」为 <strong> 卡头,「最可能 HH:MM」作副标题。
|
||||
// 新值:区分得开时卡头改为「最可能的出生时间 HH:MM」,范围降为下一行;区分不开时仍以范围为卡头(D3 只在这时成立)。
|
||||
// 原因:issue #147(产品 2026-10-28 同意:区分得开才以最可能时刻为卡头)。
|
||||
const html = render([0.45, 0.3, 0.25]);
|
||||
assert.match(html, /<strong>目前范围 05:00–05:15(对照了 2 件经历)<\/strong>/);
|
||||
assert.match(html, /rectification-range-delivery__most-likely">最可能 05:07</);
|
||||
assert.ok(html.indexOf("最可能 05:07") < html.indexOf("rectification-range-delivery__columns"));
|
||||
assert.match(html, /rectification-result-lead__time">05:07</);
|
||||
assert.match(html, /rectification-range-delivery__range-line">目前范围 05:00–05:15(对照了 2 件经历)</);
|
||||
assert.ok(html.indexOf("rectification-result-lead") < html.indexOf("rectification-range-delivery__range-line"));
|
||||
assert.ok(html.indexOf("rectification-range-delivery__range-line") < html.indexOf("rectification-range-delivery__columns"));
|
||||
const flat = render([0.28, 0.26, 0.24]);
|
||||
assert.match(flat, /<strong>目前范围 05:00–05:15(对照了 2 件经历)<\/strong>/);
|
||||
assert.doesNotMatch(flat, /rectification-result-lead/);
|
||||
assert.equal(rangeDeliveryMostLikely("05:07"), "最可能 05:07");
|
||||
});
|
||||
|
||||
@@ -315,3 +322,30 @@ test("D2 a delivered card does not carry an unasked guided window as the next qu
|
||||
const collecting = buildMethodFollowupPlan({ ...base, sessionOutcome: "collect_evidence" });
|
||||
assert.doesNotMatch(collecting.next_followup?.collection_key ?? "", /^collect:guided:window:/);
|
||||
});
|
||||
|
||||
// Issue #147: a distinct result leads with the most likely minute; its column
|
||||
// adopts and opens the chart, the others stay adoptable as 「改用这个」.
|
||||
test("a distinct result puts the most likely column first with 采用并打开星盘 and keeps the others adoptable", () => {
|
||||
const calls: Array<[string, boolean | undefined]> = [];
|
||||
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
|
||||
result: result([0.45, 0.3, 0.25]),
|
||||
acceptingCandidateId: null,
|
||||
readonly: false,
|
||||
onAccept: (candidateId: string, openChart?: boolean) => { calls.push([candidateId, openChart]); },
|
||||
fixableEvidence: ["2016-09 升学"],
|
||||
onFixEvidence() {},
|
||||
}));
|
||||
assert.match(html, /采用 05:07 并打开星盘/);
|
||||
assert.equal([...html.matchAll(/改用这个/g)].length, 2);
|
||||
assert.match(html, /还不能排除/);
|
||||
assert.ok(html.indexOf("采用 05:07 并打开星盘") < html.indexOf("还不能排除"));
|
||||
assert.doesNotMatch(html, /再补一件事/, "BUG-1084: the card does not invite more events");
|
||||
assert.match(html, /我说错了,改一条/);
|
||||
assert.doesNotMatch(html, /把握/, "no confidence label (issue #147, BUG-560)");
|
||||
});
|
||||
|
||||
test("an indistinct result keeps 更像这个 on every column and no lead", () => {
|
||||
const html = render([0.28, 0.26, 0.24]);
|
||||
assert.doesNotMatch(html, /并打开星盘|改用这个|还不能排除|rectification-result-lead/);
|
||||
assert.match(html, /更像这个/);
|
||||
});
|
||||
|
||||
@@ -58,7 +58,10 @@ test("the bar's working minute is the board clock's own value, not a second deri
|
||||
// passes the result of that same call into the bar, so the two surfaces
|
||||
// cannot drift apart — there is one expression, not two that agree today.
|
||||
assert.match(board, /const workingTime = workingRectificationTime\(result\);/);
|
||||
assert.match(board, /className="rectification-board__clock">\{workingTime\}/);
|
||||
// 原值:盘面标题行渲染 rectification-board__clock,与时间轴同一个值。
|
||||
// 新值:盘面不再写时刻,代表分钟只在时间轴上出现一次。
|
||||
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
|
||||
assert.doesNotMatch(board, /className="rectification-board__clock"/);
|
||||
assert.match(chat, /workingTime: workingRectificationTime\(candidateResult\)/);
|
||||
|
||||
const result = candidateSnapshot();
|
||||
|
||||
@@ -395,7 +395,8 @@ test("range-delivery DOM is one button per row and adopted is aria-pressed", ()
|
||||
assert.doesNotMatch(html, /说不好/);
|
||||
assert.doesNotMatch(html, /排盘用/);
|
||||
const source = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /onAccept\(column\.candidate_id\)/);
|
||||
// 原值:onAccept(column.candidate_id)。新值:带第二个参数,最可能那列(且无记录冲突)采用后打开星盘。原因:issue #147。
|
||||
assert.match(source, /onAccept\(column\.candidate_id, isPrimary && !recordConflict\)/);
|
||||
assert.match(source, /aria-pressed=\{adopted\}/);
|
||||
});
|
||||
|
||||
|
||||
@@ -191,7 +191,13 @@ test("non-conflict states keep 更像这个 and show no switch note", () => {
|
||||
[RECORD_OUTSIDE, "period_only"],
|
||||
] as const) {
|
||||
const markup = html(reported, source);
|
||||
assert.ok(markup.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), `${reported}/${source}`);
|
||||
// 原值:每列都是「更像这个」。
|
||||
// 新值:区分得开时最可能那列是「采用 HH:MM 并打开星盘」、其余「改用这个」;区分不开时仍是「更像这个」。
|
||||
// 原因:issue #147。无冲突时仍然不出现「改用校正结果」和换时间小字。
|
||||
assert.ok(
|
||||
markup.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis) || /采用 \d{2}:\d{2} 并打开星盘/.test(markup),
|
||||
`${reported}/${source}`,
|
||||
);
|
||||
assert.equal(markup.includes(SWITCH_TO_RECTIFIED_LABEL), false, `${reported}/${source}`);
|
||||
assert.equal(markup.includes("换成 04:53"), false, `${reported}/${source}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { RECTIFICATION_BOARD_OPEN_LABEL, RectificationBoard, RectificationBoardPeek } from "../src/components/rectification-board.tsx";
|
||||
import { RectificationResultActions, RectificationResultLead } from "../src/components/rectification-result-parts.tsx";
|
||||
import { diffRectificationBoard } from "../src/lib/rectification-board-model.ts";
|
||||
import {
|
||||
RECTIFICATION_FIX_COPY,
|
||||
adoptAndOpenChartLabel,
|
||||
fixEvidenceDraft,
|
||||
fixableEvidenceLines,
|
||||
} from "../src/lib/rectification-fix-evidence.ts";
|
||||
|
||||
// Issues #146 and #147: one working minute on the timeline, the board behind
|
||||
// 「看盘面」, and the result card's lead and actions. Fictional data only.
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
|
||||
test("only confirmed events are listed to fix, once each, in the conversation's recap wording", () => {
|
||||
const lines = fixableEvidenceLines([
|
||||
{ status: "confirmed", summary: "升学", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
|
||||
{ status: "confirmed", summary: "升学", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
|
||||
{ status: "pending_confirmation", summary: "搬家", datePrecision: "month", occurredFrom: "2023-08-01", occurredTo: null },
|
||||
{ status: "superseded", summary: "入职", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null },
|
||||
{ status: "confirmed", summary: "入职", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null },
|
||||
{ status: "confirmed", summary: " ", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null },
|
||||
null,
|
||||
"x",
|
||||
]);
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0]!, /2016-09/);
|
||||
assert.match(lines[1]!, /2024-04-07/);
|
||||
assert.deepEqual(fixableEvidenceLines(undefined), []);
|
||||
assert.equal(fixEvidenceDraft("2016-09 升学"), "我说错了:「2016-09 升学」应该是");
|
||||
assert.equal(adoptAndOpenChartLabel("07:24"), "采用 07:24 并打开星盘");
|
||||
});
|
||||
|
||||
test("the actions row offers only 改一条, and only with a callback and a confirmed event", () => {
|
||||
assert.equal(renderToStaticMarkup(createElement(RectificationResultActions, { evidence: ["2016-09 升学"], disabled: false })), "");
|
||||
const html = renderToStaticMarkup(createElement(RectificationResultActions, {
|
||||
evidence: ["2016-09 升学"],
|
||||
disabled: false,
|
||||
onFixEvidence() {},
|
||||
}));
|
||||
assert.match(html, new RegExp(RECTIFICATION_FIX_COPY.fixOne));
|
||||
assert.doesNotMatch(html, /再补一件事/, "BUG-1084: no invitation to add more events");
|
||||
assert.match(html, /aria-expanded="false"/);
|
||||
assert.doesNotMatch(html, /2016-09 升学/, "the event list opens on click");
|
||||
const none = renderToStaticMarkup(createElement(RectificationResultActions, {
|
||||
evidence: [],
|
||||
disabled: false,
|
||||
onFixEvidence() {},
|
||||
}));
|
||||
assert.equal(none, "", "nothing to fix without a confirmed event");
|
||||
});
|
||||
|
||||
test("the lead shows the minute and the Lagna when known", () => {
|
||||
assert.match(renderToStaticMarkup(createElement(RectificationResultLead, { time: "07:24", lagna: "巨蟹座" })), /最可能的出生时间[\s\S]*07:24[\s\S]*上升巨蟹座/);
|
||||
assert.doesNotMatch(renderToStaticMarkup(createElement(RectificationResultLead, { time: "07:24", lagna: null })), /上升/);
|
||||
});
|
||||
|
||||
test("the board is closed by default at every width and has no clock in its title row", () => {
|
||||
const props = {
|
||||
result: null,
|
||||
declaredTime: "05:10",
|
||||
savedStatus: null,
|
||||
diff: diffRectificationBoard(null, null),
|
||||
boardId: "board",
|
||||
titleId: "title",
|
||||
onClose() {},
|
||||
};
|
||||
assert.equal(renderToStaticMarkup(createElement(RectificationBoard, { ...props, compact: false, open: false })), "");
|
||||
assert.equal(renderToStaticMarkup(createElement(RectificationBoard, { ...props, compact: true, open: false })), "");
|
||||
const open = renderToStaticMarkup(createElement(RectificationBoard, { ...props, compact: false, open: true }));
|
||||
assert.match(open, /当前盘面/);
|
||||
assert.doesNotMatch(open, /rectification-board__clock/);
|
||||
assert.match(open, /aria-label="关闭盘面"/, "the desktop column closes too");
|
||||
});
|
||||
|
||||
test("看盘面 says what it does; the time stays in its accessible name only", () => {
|
||||
const html = renderToStaticMarkup(createElement(RectificationBoardPeek, {
|
||||
result: null,
|
||||
declaredTime: "05:10",
|
||||
expanded: false,
|
||||
boardId: "board",
|
||||
onOpen() {},
|
||||
}));
|
||||
assert.equal(RECTIFICATION_BOARD_OPEN_LABEL, "看盘面");
|
||||
assert.match(html, /<strong>看盘面<\/strong>/);
|
||||
assert.doesNotMatch(html, /<span>/);
|
||||
assert.match(html, /aria-label="看盘面,[^"]*05:10"/);
|
||||
});
|
||||
|
||||
test("adopting from the most likely column opens the chart only after the server saved it", () => {
|
||||
const hook = read("../src/hooks/use-open-chart-after-adopt.ts");
|
||||
assert.match(hook, /onSaved\?\.\(time, status\);\s*if \(!openAfterSave\.current\) return;\s*openAfterSave\.current = false;\s*navigateInApp\("\/chart"\);/);
|
||||
const chat = read("../src/components/rectification-agentic-chat.tsx");
|
||||
assert.match(chat, /armChartAfterAdopt\(openChart\);/);
|
||||
assert.match(chat, /onSaved: savedThenChart,/);
|
||||
assert.match(chat, /onFixEvidence=\{\(line\) => \{\s*setDraft\(fixEvidenceDraft\(line\)\);/);
|
||||
const segment = read("../src/components/rectification-segment-delivery.tsx");
|
||||
assert.doesNotMatch(segment, /RectificationResultLead/, "the segment product never leads with a minute (BUG-1151)");
|
||||
});
|
||||
@@ -226,7 +226,9 @@ test("the choice card confirms the tap and the board's first state shows the dec
|
||||
assert.match(board, /rectificationBoardPeekCopy\(result, declaredTime, birthTimeSource\)/);
|
||||
assert.doesNotMatch(board, /补充经历后,这里会显示当前本命宫位和换升时刻/);
|
||||
assert.match(chat, /\$\{candidateResult \? "" : " is-board-empty"\}/);
|
||||
assert.match(styles, /\.rectification-workspace\.is-board-empty \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
|
||||
// The empty modifier is declared before `.is-compact`, so the single-column layout still wins.
|
||||
assert.ok(styles.indexOf(".rectification-workspace.is-board-empty {") < styles.indexOf(".rectification-workspace.is-compact {"));
|
||||
// 原值:.rectification-workspace.is-board-empty 常驻两栏(右栏 16–18rem)。
|
||||
// 新值:只有盘面打开且不是手机布局时才分出右栏,空盘面仍用窄的那档。
|
||||
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
|
||||
assert.match(styles, /\.rectification-workspace\.is-board-empty\.is-board-open:not\(\.is-compact\) \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
|
||||
assert.match(styles, /\.rectification-workspace \{[^}]*grid-template-columns: minmax\(0, 1fr\);/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user