Files
Jyotisha/frontend/src/components/chart-library-panel.tsx
T
jesse-ux b85c4a686a
Independent Staging Quality Gate / validate (push) Successful in 13m27s
Independent Staging Quality Gate / publish (push) Failing after 1h0m1s
fix(rectification): anchor candidate windows to civil dates across midnight
Carry explicit local date intervals instead of inferring the day from clock
order. Cluster width, delivery, adoption, and reports keep the actual civil
date; adopted date is stored separately from the reported birth_date.

Algorithm identity is scoring-9 / spec-v5. Scoring weights, confirmation
thresholds, and Skill version are unchanged. Isolated Linux final-3 gates
passed; four pre-existing Python failures remain. This is not a production
release.
2026-09-21 02:55:00 +08:00

351 lines
15 KiB
TypeScript

"use client";
import { ChevronRight } from "lucide-react";
import { effectiveBirthDate } from "@/lib/effective-birth-date";
import { useState, type Dispatch, FormEvent, SetStateAction } from "react";
import { ChartProfileForm } from "@/components/chart-profile-form";
import { activeChartStorageKey, deleteCloudChartProfile, saveCloudChartProfile, updateCloudChartProfile } from "@/lib/home-cloud-sync";
import {
chartRelationshipLabel,
formatChartUpdatedAt,
missingOtherProfileStep,
profileBirthTimeLabel,
profileBirthTimeStatusLabel,
profilePlaceLabel,
readProfile,
upsertSelfChart,
} from "@/lib/home-profile";
import {
emptyProfile,
timestamp,
type Account,
type ChartLibraryRecord,
type ChartRelationship,
type Profile,
type SynastryRelationshipType,
type SynastryReportCard,
} from "@/lib/home-types";
import {
CHART_LIBRARY_LIST_VIEW,
synastryHistoryForPartner,
type ChartLibraryView,
} from "@/lib/chart-library-view";
export type ChartLibraryPanelProps = {
readonly account: Account | null;
readonly accountId: string | undefined;
readonly chartLibrary: ChartLibraryRecord[];
readonly setChartLibrary: Dispatch<SetStateAction<ChartLibraryRecord[]>>;
readonly profile: Profile;
readonly profileDraft: Profile;
readonly setProfileDraft: Dispatch<SetStateAction<Profile>>;
readonly setProfile: Dispatch<SetStateAction<Profile>>;
readonly profileSaving: boolean;
readonly profileNotice: string;
readonly setProfileNotice: Dispatch<SetStateAction<string>>;
readonly setAccountError: Dispatch<SetStateAction<string>>;
readonly synastryRelationshipType: SynastryRelationshipType;
readonly setSynastryRelationshipType: Dispatch<SetStateAction<SynastryRelationshipType>>;
readonly synastryPendingId: string | null;
readonly synastryReportCard: SynastryReportCard | null;
readonly setSynastryReportCard: Dispatch<SetStateAction<SynastryReportCard | null>>;
readonly synastryHistory: SynastryReportCard[];
readonly activeChartId: string;
readonly setActiveChartId: Dispatch<SetStateAction<string>>;
readonly saveProfile: (event: FormEvent<HTMLFormElement>) => void;
readonly draftSynastryQuestionFromChart: (record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) => void;
};
export function ChartLibraryPanel({
account,
accountId,
chartLibrary,
setChartLibrary,
profile,
profileDraft,
setProfileDraft,
setProfile,
profileSaving,
profileNotice,
setProfileNotice,
setAccountError,
synastryRelationshipType,
setSynastryRelationshipType,
synastryPendingId,
synastryReportCard,
setSynastryReportCard,
synastryHistory,
activeChartId,
setActiveChartId,
saveProfile,
draftSynastryQuestionFromChart,
}: ChartLibraryPanelProps) {
const [view, setView] = useState<ChartLibraryView>(CHART_LIBRARY_LIST_VIEW);
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
const [otherChartRelationship, setOtherChartRelationship] = useState<Exclude<ChartRelationship, "self">>("other");
const [editingChartId, setEditingChartId] = useState<string | null>(null);
const selfCharts = chartLibrary.filter((record) => record.role === "self");
const otherCharts = chartLibrary.filter((record) => record.role === "other");
const selectedOther = view.kind === "other"
? otherCharts.find((record) => record.id === view.id) ?? null
: null;
const partnerHistory = selectedOther
? synastryHistoryForPartner(synastryHistory, {
id: selectedOther.id,
name: selectedOther.profile.name || "对方",
})
: [];
function goList() {
setView(CHART_LIBRARY_LIST_VIEW);
setEditingChartId(null);
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setProfileDraft(profile);
}
async function saveOtherChart(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim(), chartRelationship: otherChartRelationship };
if (missingOtherProfileStep(nextProfile)) {
setAccountError("请补全其他星盘的称呼、出生时间和出生地点。");
return;
}
if (!accountId) return;
const record: ChartLibraryRecord = {
id: editingChartId || globalThis.crypto.randomUUID(),
role: "other",
profile: nextProfile,
relationship: otherChartRelationship,
updatedAt: timestamp(),
};
try {
const saved = editingChartId
? await updateCloudChartProfile(record)
: await saveCloudChartProfile(record);
const selfProfile = account ? readProfile(account.profile) : profile;
setChartLibrary((current) => {
const others = editingChartId
? current.map((item) => item.id === saved.id ? saved : item)
: [...current, saved];
return upsertSelfChart(others, selfProfile);
});
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setEditingChartId(null);
setAccountError("");
setProfileNotice(editingChartId ? "已更新其他人的星盘资料。" : "已保存到云端星盘库。请选择关系类型后点击“用于合盘”。");
setView(CHART_LIBRARY_LIST_VIEW);
} catch {
setProfileNotice("保存失败,请重试");
setAccountError("");
}
}
function editOtherChart(record: ChartLibraryRecord) {
if (record.role !== "other") return;
setOtherProfileDraft(record.profile);
setOtherChartRelationship(record.relationship === "self" ? "other" : record.relationship);
setEditingChartId(record.id);
setAccountError("");
setProfileNotice("");
setView({ kind: "other", id: record.id });
}
async function deleteOtherChart(recordId: string) {
if (!accountId || !window.confirm("确定删除这份其他人的星盘资料吗?删除后无法恢复。")) return;
try {
await deleteCloudChartProfile(recordId);
} catch {
setProfileNotice("删除失败,请重试");
setAccountError("");
return;
}
setChartLibrary((current) => {
const next = current.filter((record) => record.id !== recordId || record.role === "self");
if (activeChartId === recordId) {
setActiveChartId("self");
localStorage.setItem(activeChartStorageKey(accountId), "self");
}
return next;
});
setAccountError("");
setProfileNotice("已从云端星盘库删除。");
goList();
}
function makeDefaultChart(record: ChartLibraryRecord) {
if (record.role !== "other" || profileSaving || !accountId) return;
setActiveChartId(record.id);
localStorage.setItem(activeChartStorageKey(accountId), record.id);
setProfile(record.profile);
setProfileNotice("已设为当前使用资料,账户本人的出生资料未被覆盖。");
}
function listRow(record: ChartLibraryRecord) {
const isSelf = record.role === "self";
return (
<button
className="chart-library-item"
key={record.id}
type="button"
onClick={() => {
if (isSelf) {
setProfileDraft(record.profile);
setView({ kind: "self" });
return;
}
editOtherChart(record);
}}
>
<div className="chart-library-item-main">
<div className="chart-library-item-title">
<strong>{record.profile.name || "未命名"}</strong>
<span className={`chart-role-badge${isSelf ? " is-self" : ""}`}>{isSelf ? "本人" : chartRelationshipLabel(record.relationship)}</span>
{isSelf ? <span className="chart-default-badge">当前默认</span> : null}
</div>
<small>{effectiveBirthDate(record.profile) || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)}</small>
<small>{profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)}</small>
</div>
<ChevronRight className="chart-library-item-chevron" aria-hidden="true" />
</button>
);
}
if (view.kind === "self") {
return (
<div className="chart-library-panel" aria-label="星盘资料管理">
<button className="chart-library-back" type="button" onClick={goList}>← 星盘资料</button>
<ChartProfileForm
title="编辑本人星盘"
description="这份资料会用于默认解盘与新对话。"
value={profileDraft}
onChange={setProfileDraft}
nameInputId="self-profile-name"
showAyanamsa
onSubmit={saveProfile}
onCancel={goList}
cancelLabel="返回列表"
submitLabel={profileSaving ? "保存中" : "保存本人资料"}
submitDisabled={!account || profileSaving}
notice={profileNotice}
/>
</div>
);
}
if (view.kind === "add" || (view.kind === "other" && selectedOther)) {
const isAdd = view.kind === "add";
return (
<div className="chart-library-panel" aria-label="星盘资料管理">
<button className="chart-library-back" type="button" onClick={goList}>← 星盘资料</button>
<ChartProfileForm
title={isAdd ? "添加其他人的星盘" : "编辑其他人的星盘"}
description="用于合盘、亲友盘或客户盘。"
value={otherProfileDraft}
onChange={setOtherProfileDraft}
nameInputId="other-profile-name"
relationship={otherChartRelationship}
onRelationshipChange={setOtherChartRelationship}
onSubmit={saveOtherChart}
onCancel={goList}
cancelLabel="返回列表"
submitLabel={profileSaving ? "保存中" : editingChartId ? "保存修改" : "添加到星盘库"}
submitDisabled={!account || profileSaving}
notice={profileNotice}
/>
{!isAdd && selectedOther ? (
<>
<div className="chart-library-actions">
<button className="button-secondary" type="button" onClick={() => makeDefaultChart(selectedOther)} disabled={profileSaving}>设为默认</button>
<button className="button-secondary" type="button" onClick={() => void deleteOtherChart(selectedOther.id)}>删除</button>
</div>
<section className="chart-library-group" aria-label="合盘">
<div className="chart-library-group-heading">
<div><b>合盘</b><small>把这份资料用于合盘分析</small></div>
</div>
<div className="chart-library-actions">
<select aria-label={`${selectedOther.profile.name || "其他人"}的关系类型`} value={synastryRelationshipType} onChange={(event) => setSynastryRelationshipType(event.target.value as SynastryRelationshipType)} disabled={synastryPendingId !== null}>
<option value="romance">婚恋</option>
<option value="business">商业合作</option>
<option value="family">亲友/家庭</option>
<option value="general">其他关系</option>
</select>
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(selectedOther, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === selectedOther.id ? "正在计算合盘..." : "用于合盘"}</button>
</div>
{synastryReportCard && synastryHistoryForPartner([synastryReportCard], { id: selectedOther.id, name: selectedOther.profile.name || "对方" }).length > 0 && (
<article className="synastry-report-card" aria-label="合盘结果摘要">
<div>
<span>合盘结果摘要</span>
<strong>{synastryReportCard.partnerName}</strong>
<small>Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"}</small>
</div>
{synastryReportCard.headline && <p>{synastryReportCard.headline}</p>}
<details>
<summary>查看证据</summary>
<ul>
{(synastryReportCard.strengths || []).map((item) => <li key={item}>{item}</li>)}
{(synastryReportCard.risks || []).map((item) => <li key={item}>{item}</li>)}
</ul>
<small>下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"}</small>
</details>
</article>
)}
{partnerHistory.length > 0 && (
<div className="synastry-history-list" aria-label="合盘历史">
<b>合盘历史</b>
{partnerHistory.slice(0, 5).map((item) => (
<button key={item.id} type="button" className="synastry-history-item" onClick={() => setSynastryReportCard(item)}>
<span>{item.partnerName}</span>
<small>Ashtakoot {item.score ?? "?"}/{item.maxScore ?? "?"} · {item.assessment || item.scoreBand || "待解释"}</small>
</button>
))}
</div>
)}
</section>
</>
) : null}
</div>
);
}
return (
<div className="chart-library-panel" aria-label="星盘资料管理">
<div className="chart-library-group">
<div className="chart-library-group-heading">
<div><b>我的星盘</b><small>当前账号的默认资料</small></div>
<span className="chart-library-count">{selfCharts.length}</span>
</div>
{selfCharts.map(listRow)}
{selfCharts.length === 0 && <p className="empty-library-copy">请先在个人资料中补全你的出生资料。</p>}
</div>
<div className="chart-library-group">
<div className="chart-library-group-heading">
<div><b>其他人</b><small>亲友、伴侣或客户资料</small></div>
<div className="chart-library-group-heading-actions">
<span className="chart-library-count">{otherCharts.length}</span>
<button
className="button-secondary chart-library-add chart-library-add--heading"
type="button"
onClick={() => {
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setEditingChartId(null);
setAccountError("");
setProfileNotice("");
setView({ kind: "add" });
}}
>
添加其他人
</button>
</div>
</div>
{otherCharts.length === 0 && <p className="empty-library-copy">还没有其他星盘,先添加一份资料。</p>}
{otherCharts.map(listRow)}
</div>
</div>
);
}