fix(rectification): show natal houses and lagna changes in a live right rail
The board was live data sitting under chat, so each new event looked like nothing changed. Keep candidate cards in the transcript and group 换升 by minute for the side panel. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
groupWindowTransitions,
|
||||
rectificationBoardPeekCopy,
|
||||
workingRectificationTime,
|
||||
type RectificationBoardDiff,
|
||||
} from "@/lib/rectification-board-model";
|
||||
import {
|
||||
workingRectificationHouseTable,
|
||||
type RectificationCandidateResult,
|
||||
} from "@/lib/rectification-candidate-result";
|
||||
import {
|
||||
windowScanLayerLabel,
|
||||
type WindowScanLayer,
|
||||
} from "@/lib/rectification-agentic/v9/refinement-packet";
|
||||
import { RectificationHouseTableView } from "./rectification-house-table";
|
||||
import { ConfirmationGateDisclosure, TechniqueAuditDisclosure } from "./technique-audit-disclosure";
|
||||
|
||||
function LayerChips({ layers }: Readonly<{ layers: readonly WindowScanLayer[] }>) {
|
||||
if (layers.length === 0) return null;
|
||||
return (
|
||||
<ul className="rectification-board__chips">
|
||||
{layers.map((layer) => (
|
||||
<li key={layer}>
|
||||
<span className="rectification-board__chip">{windowScanLayerLabel(layer)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function RectificationBoardBody({
|
||||
result,
|
||||
savedStatus,
|
||||
diff,
|
||||
compact,
|
||||
titleId,
|
||||
onClose,
|
||||
}: Readonly<{
|
||||
result: RectificationCandidateResult | null;
|
||||
savedStatus: "accepted" | "confirmed" | null;
|
||||
diff: RectificationBoardDiff;
|
||||
compact: boolean;
|
||||
titleId: string;
|
||||
onClose: () => void;
|
||||
}>) {
|
||||
const table = result ? workingRectificationHouseTable(result) : null;
|
||||
const workingTime = workingRectificationTime(result);
|
||||
const grouped = result ? groupWindowTransitions(result.windowTransitions) : [];
|
||||
const scoringMinutes = grouped.filter((row) => row.scoringLayers.length > 0);
|
||||
const displayMinutes = grouped.filter((row) => row.displayLayers.length > 0);
|
||||
const changedHouses = new Set(diff.houseNumbers);
|
||||
const changedMinutes = new Set(diff.transitionMinutes);
|
||||
const stage = result?.precisionStage?.user_meaning
|
||||
?? result?.natalRecast?.user_meaning
|
||||
?? null;
|
||||
const ledger = result?.eventDashaLedger ?? [];
|
||||
const contrast = result?.lagnaContrast ?? null;
|
||||
const agreement = result?.dashaAgreement ?? null;
|
||||
const nakshatra = result?.nakshatraBoundary ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="rectification-board__header">
|
||||
<div className="rectification-board__title-row">
|
||||
<h2 id={titleId}>当前盘面</h2>
|
||||
{workingTime ? <span className="rectification-board__clock">{workingTime}</span> : null}
|
||||
{compact && (
|
||||
<button
|
||||
className="rectification-board__close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭盘面
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{stage ? <p className="rectification-board__stage">{stage}</p> : null}
|
||||
</header>
|
||||
<div className="rectification-board__body">
|
||||
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">{diff.liveMessage}</p>
|
||||
{!table && (
|
||||
<p className="rectification-board__empty">
|
||||
补充经历后,这里会显示当前本命宫位和换升时刻。
|
||||
</p>
|
||||
)}
|
||||
{table && (
|
||||
<div className="rectification-snapshot">
|
||||
<RectificationHouseTableView
|
||||
table={table}
|
||||
changedHouses={changedHouses}
|
||||
lagnaChanged={diff.lagnaChanged}
|
||||
/>
|
||||
{result && <ConfirmationGateDisclosure gate={result.confirmationGate} />}
|
||||
{savedStatus === "accepted" && result && result.techniqueAudit.length > 0 && (
|
||||
<TechniqueAuditDisclosure rows={result.techniqueAudit} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(scoringMinutes.length > 0 || displayMinutes.length > 0) && (
|
||||
<section className="rectification-board__section" aria-label="换升时刻">
|
||||
<h3>换升时刻</h3>
|
||||
{scoringMinutes.length > 0 && (
|
||||
<ol className="rectification-board__minutes">
|
||||
{scoringMinutes.map((row) => {
|
||||
const changed = changedMinutes.has(row.at);
|
||||
const working = row.at === workingTime;
|
||||
return (
|
||||
<li
|
||||
key={row.at}
|
||||
className={[
|
||||
"rectification-board__minute",
|
||||
changed ? "is-changed" : "",
|
||||
working ? "is-working" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<time dateTime={row.at}>{row.at}</time>
|
||||
{working ? <span className="rectification-board__minute-note">当前时间</span> : null}
|
||||
{changed ? <span className="rectification-board__minute-note">已更新</span> : null}
|
||||
<LayerChips layers={row.scoringLayers} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
{displayMinutes.length > 0 && (
|
||||
<details className="technique-audit">
|
||||
<summary>
|
||||
显示层
|
||||
<small>{displayMinutes.length} 个时刻</small>
|
||||
</summary>
|
||||
<ol className="rectification-board__minutes">
|
||||
{displayMinutes.map((row) => (
|
||||
<li
|
||||
key={`display-${row.at}`}
|
||||
className={changedMinutes.has(row.at) ? "rectification-board__minute is-changed" : "rectification-board__minute"}
|
||||
>
|
||||
<time dateTime={row.at}>{row.at}</time>
|
||||
{changedMinutes.has(row.at) ? <span className="rectification-board__minute-note">已更新</span> : null}
|
||||
<LayerChips layers={row.displayLayers} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{contrast && (
|
||||
<details className="technique-audit">
|
||||
<summary>两段本命上升</summary>
|
||||
<div className="rectification-board__disclosure">
|
||||
<p>{contrast.user_meaning}</p>
|
||||
<ul>
|
||||
{contrast.intervals.map((interval) => (
|
||||
<li key={`${interval.start}-${interval.end}`}>
|
||||
{interval.start}-{interval.end}:{interval.lagna}
|
||||
{interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{ledger.length > 0 && (
|
||||
<details className="technique-audit">
|
||||
<summary>
|
||||
经历与大运对照
|
||||
<small>{ledger.length} 项</small>
|
||||
</summary>
|
||||
<ul className="rectification-board__disclosure">
|
||||
{ledger.map((item) => (
|
||||
<li key={item.summary}>{item.user_meaning}</li>
|
||||
))}
|
||||
</ul>
|
||||
{agreement ? <p className="rectification-board__disclosure">{agreement.user_meaning}</p> : null}
|
||||
</details>
|
||||
)}
|
||||
{nakshatra?.near_boundary && nakshatra.user_meaning && (
|
||||
<details className="technique-audit">
|
||||
<summary>交界节奏</summary>
|
||||
<p className="rectification-board__disclosure">{nakshatra.user_meaning}</p>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RectificationBoard({
|
||||
result,
|
||||
savedStatus,
|
||||
diff,
|
||||
compact,
|
||||
open,
|
||||
boardId,
|
||||
titleId,
|
||||
onClose,
|
||||
}: Readonly<{
|
||||
result: RectificationCandidateResult | null;
|
||||
savedStatus: "accepted" | "confirmed" | null;
|
||||
diff: RectificationBoardDiff;
|
||||
compact: boolean;
|
||||
open: boolean;
|
||||
boardId: string;
|
||||
titleId: string;
|
||||
onClose: () => void;
|
||||
}>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const body = (
|
||||
<RectificationBoardBody
|
||||
result={result}
|
||||
savedStatus={savedStatus}
|
||||
diff={diff}
|
||||
compact={compact}
|
||||
titleId={titleId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const node = dialogRef.current;
|
||||
if (!compact || !node) return;
|
||||
if (open) {
|
||||
if (!node.open) node.showModal();
|
||||
} else if (node.open) {
|
||||
node.close();
|
||||
}
|
||||
}, [compact, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!compact) return;
|
||||
const node = dialogRef.current;
|
||||
if (!node) return;
|
||||
const handleClose = () => onCloseRef.current();
|
||||
node.addEventListener("close", handleClose);
|
||||
return () => node.removeEventListener("close", handleClose);
|
||||
}, [compact]);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<dialog ref={dialogRef} id={boardId} className="rectification-board" aria-labelledby={titleId}>
|
||||
{body}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside id={boardId} className="rectification-board" aria-labelledby={titleId}>
|
||||
{body}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function RectificationBoardPeek({
|
||||
result,
|
||||
expanded,
|
||||
boardId,
|
||||
onOpen,
|
||||
}: Readonly<{
|
||||
result: RectificationCandidateResult | null;
|
||||
expanded: boolean;
|
||||
boardId: string;
|
||||
onOpen: () => void;
|
||||
}>) {
|
||||
const peek = rectificationBoardPeekCopy(result);
|
||||
return (
|
||||
<button
|
||||
className="rectification-board-peek"
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={boardId}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<strong>{peek.title}</strong>
|
||||
<span>{peek.detail}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user