feat(chart): 大运右栏在 Chara 与 Yogini 之间切换

点开大运只取 Chara。切到 Yogini 才去取那一轨。失败留在右栏,再点一次会再请求。

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-22 03:55:47 +08:00
co-authored by Grok (agent loop) Cursor
parent 5ebbd57b04
commit 7b1070f86d
3 changed files with 144 additions and 13 deletions
@@ -1,10 +1,21 @@
"use client";
import { useState, type ReactNode } from "react";
import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
import { OrbitWaiting } from "@/components/orbit-waiting";
import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract";
import type { ChartViewLayer } from "@/lib/chart-view-engine";
import { CHARA_ZERO_YEAR_LABEL, CHARA_ZERO_YEAR_NOTE, CHART_VIEW_COPY } from "@/lib/chart-view-labels";
type DashaTrack = ChartViewOk["dasha"]["vimshottari"];
type DashaPeriod = DashaTrack["periods"][number];
type DashaSystemId = "chara" | "yogini";
const DASHA_SYSTEMS: ReadonlyArray<{ id: DashaSystemId; label: string }> = [
{ id: "chara", label: "Chara" },
{ id: "yogini", label: "Yogini" },
];
function formatYearMonth(iso: string): string {
return `${iso.slice(0, 4)}.${iso.slice(5, 7)}`;
@@ -90,9 +101,11 @@ function Track({
antardashaNote,
periods,
asOf,
}: DashaTrack & { asOf: string }) {
leading,
}: DashaTrack & { asOf: string; leading?: ReactNode }) {
return (
<section className="chart-page-dasha-track">
{leading}
<header>
<h3>{title}</h3>
{method && method !== title ? <p>{method}</p> : null}
@@ -141,26 +154,61 @@ function Track({
export function ChartDashaTab({
view,
charaPending = false,
charaFailure,
pendingLayers,
layerFailures,
system: controlledSystem,
initialSystem = "chara",
onSelectSystem,
}: {
view: ChartViewOk;
charaPending?: boolean;
charaFailure?: ChartViewFailureStatus;
pendingLayers?: ReadonlySet<ChartViewLayer>;
layerFailures?: ReadonlyMap<ChartViewLayer, ChartViewFailureStatus>;
system?: DashaSystemId;
initialSystem?: DashaSystemId;
onSelectSystem?: (system: DashaSystemId) => void;
}) {
const [uncontrolledSystem, setUncontrolledSystem] = useState<DashaSystemId>(initialSystem);
const system = controlledSystem ?? uncontrolledSystem;
const asOf = view.dasha.asOf;
const pending = pendingLayers?.has(system) ?? false;
const failure = layerFailures?.get(system);
const track = view.dasha[system];
function selectSystem(id: DashaSystemId) {
if (controlledSystem === undefined) setUncontrolledSystem(id);
onSelectSystem?.(id);
}
const switcher = (
<div className="chart-page-dasha-switch" role="tablist" aria-label="大运体系">
{DASHA_SYSTEMS.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={system === item.id}
className={system === item.id ? "chart-page-tab is-current" : "chart-page-tab"}
onClick={() => selectSystem(item.id)}
>
{item.label}
</button>
))}
</div>
);
return (
<div className="chart-page-dasha">
<div className="chart-page-dasha-columns">
<Track {...view.dasha.vimshottari} asOf={asOf} />
{charaPending || charaFailure ? (
{pending || failure ? (
<section className="chart-page-dasha-track">
{charaPending
{switcher}
{pending
? <OrbitWaiting label={CHART_VIEW_COPY.waitingLayer} />
: <p className="chart-page-note">{chartLayerFailureCopy(charaFailure!)}</p>}
: <p className="chart-page-note">{chartLayerFailureCopy(failure!)}</p>}
</section>
) : (
<Track {...view.dasha.chara} asOf={asOf} />
<Track {...track} asOf={asOf} leading={switcher} />
)}
</div>
<p className="chart-page-boundary">{view.dasha.dualTrackNote}</p>
@@ -95,6 +95,7 @@ export function ChartPageView({
const [tab, setTab] = useState<ChartViewTabId>(initialTab);
const [vargaId, setVargaId] = useState<ChartTypeId>("D1");
const [editingBirth, setEditingBirth] = useState(false);
const [dashaSystem, setDashaSystem] = useState<"chara" | "yogini">("chara");
function selectTab(id: ChartViewTabId) {
setTab(id);
@@ -102,6 +103,11 @@ export function ChartPageView({
if (id === "western") onNeedLayer?.("western");
}
function selectDashaSystem(id: "chara" | "yogini") {
setDashaSystem(id);
onNeedLayer?.(id);
}
function selectVarga(id: ChartTypeId) {
setVargaId(id);
const layer = chartTypeLayer(id);
@@ -126,7 +132,10 @@ export function ChartPageView({
async function afterBirthSaved() {
await onBirthSaved?.();
if (tab === "dasha") onNeedLayer?.("chara");
if (tab === "dasha") {
onNeedLayer?.("chara");
if (dashaSystem === "yogini") onNeedLayer?.("yogini");
}
if (tab === "western") onNeedLayer?.("western");
if (tab === "vedic") {
const layer = chartTypeLayer(vargaId);
@@ -185,7 +194,13 @@ export function ChartPageView({
) : null}
{tab === "basics" ? <ChartBasicsTab view={view} /> : null}
{tab === "dasha" ? (
<ChartDashaTab view={view} charaPending={pendingLayers?.has("chara") ?? false} charaFailure={layerFailures?.get("chara")} />
<ChartDashaTab
view={view}
pendingLayers={pendingLayers}
layerFailures={layerFailures}
system={dashaSystem}
onSelectSystem={selectDashaSystem}
/>
) : null}
{tab === "western" ? (
pendingLayers?.has("western") ? (
+70 -2
View File
@@ -11,14 +11,15 @@ import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "../src/components/perso
import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { CHART_VIEW_TABS, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts";
import { CHART_VIEW_TABS, type ChartViewFailureStatus, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { CHART_VIEW_LAYERS, type ChartViewLayer } from "../src/lib/chart-view-engine.ts";
import { chartViewFailureResponse } from "../src/lib/chart-view-failure.ts";
import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts";
import { CHART_SETTINGS_HREF } from "../src/lib/settings-url.ts";
import { westernLabelBox } from "../src/components/chart-page/western-wheel-layout.ts";
import { cssDeclarations } from "./css-contract-test-support.ts";
import { goldenSky } from "./birth-sky-test-support.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
@@ -551,3 +552,70 @@ test("birth-sky prefetch follows the current subject and the dialog is a lazy ch
assert.match(cover, /await import\("\.\/render\.ts"\)/);
assert.doesNotMatch(chartView, /birth-sky-dialog|lib\/birth-sky\/(draw|render)/);
});
function buttonByLabel(h: ReturnType<typeof createClientLifecycleHarness>, label: string) {
const node = h.elements().find((item) => item.tagName === "BUTTON" && item.text === label);
assert.ok(node, `missing button ${label}`);
return node;
}
test("opening dasha requests only chara, and Yogini is a separate request", async () => {
const view = await okView();
const calls: string[] = [];
const h = createClientLifecycleHarness();
try {
await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, {
view,
onNeedLayer: (layer: string) => { calls.push(layer); },
})));
await h.event(buttonByLabel(h, "大运"));
assert.deepEqual(calls, ["chara"]);
assert.match(h.container.text, /Chara Dasha(kn_rao 变体)/);
assert.match(h.container.text, /Vimshottari/);
await h.event(buttonByLabel(h, "Yogini"));
assert.deepEqual(calls, ["chara", "yogini"]);
} finally {
await h.close();
}
});
test("a yogini failure stays in the right column, and clicking Yogini again asks again", async () => {
const view = await okView();
const calls: string[] = [];
const h = createClientLifecycleHarness();
try {
await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, {
view,
layerFailures: new Map<ChartViewLayer, ChartViewFailureStatus>([["yogini", "engine_busy"]]),
onNeedLayer: (layer: string) => { calls.push(layer); },
})));
await h.event(buttonByLabel(h, "大运"));
await h.event(buttonByLabel(h, "Yogini"));
assert.match(h.container.text, /Vimshottari/);
assert.match(h.container.text, new RegExp(CHART_VIEW_COPY.busy));
assert.doesNotMatch(h.container.text, /小运未经外部软件核对。/);
assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false);
await h.event(buttonByLabel(h, "Yogini"));
assert.deepEqual(calls, ["chara", "yogini", "yogini"]);
} finally {
await h.close();
}
});
test("yogini pending reuses the orbit sentence and does not draw a skeleton", async () => {
const view = await okView();
const h = createClientLifecycleHarness();
try {
await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, {
view,
pendingLayers: new Set<ChartViewLayer>(["yogini"]),
})));
await h.event(buttonByLabel(h, "大运"));
await h.event(buttonByLabel(h, "Yogini"));
assert.match(h.container.text, /这一栏还没拿到/);
assert.match(h.container.text, /Vimshottari/);
assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false);
} finally {
await h.close();
}
});