dbbcb71b6a
eslint-plugin-react-hooks forbids updating ref.current in render, which failed staging lint after the compact rectification board landed. Co-authored-by: Cursor <cursoragent@cursor.com>
281 lines
9.2 KiB
TypeScript
281 lines
9.2 KiB
TypeScript
"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 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 = () => onClose();
|
||
node.addEventListener("close", handleClose);
|
||
return () => node.removeEventListener("close", handleClose);
|
||
}, [compact, onClose]);
|
||
|
||
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>
|
||
);
|
||
}
|