Files
Jyotisha/frontend/src/components/chart-library-panel.tsx
T
Jesse_ChenandCursor dc6598d7e4 fix(web): keep the settings dialog one size and move billing into it (BUG-554)
The four account panes now share a fixed frame, chart profiles open as list then detail, and membership lives in the homepage dialog instead of a separate page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 18:29:51 +08:00

348 lines
14 KiB
TypeScript

"use client";
import { ChevronRight } from "lucide-react";
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>{record.profile.date || "出生日期待补全"} · {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>
<span className="chart-library-count">{otherCharts.length}</span>
</div>
{otherCharts.length === 0 && <p className="empty-library-copy">还没有其他星盘,先添加一份资料。</p>}
{otherCharts.map(listRow)}
<button
className="button-secondary chart-library-add"
type="button"
onClick={() => {
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setEditingChartId(null);
setAccountError("");
setProfileNotice("");
setView({ kind: "add" });
}}
>
添加其他人
</button>
</div>
</div>
);
}