fix(frontend): preserve owner profile when switching charts

This commit is contained in:
Jesse_Chen
2026-08-30 14:54:36 +08:00
parent 796e4cf8cf
commit f9a00dc568
3 changed files with 173 additions and 35 deletions
@@ -36,3 +36,34 @@ export async function DELETE(_request: Request, context: RouteContext) {
return NextResponse.json({ error: errorMessage(error, "星盘删除失败") }, { status: 500 });
}
}
export async function PUT(request: Request, context: RouteContext) {
try {
const { id } = await context.params;
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const body = await request.json().catch(() => null) as { profile?: unknown } | null;
if (!body?.profile || typeof body.profile !== "object" || Array.isArray(body.profile)) {
return NextResponse.json({ error: "星盘资料格式不正确" }, { status: 400 });
}
const { data, error } = await supabase
.from("chart_profiles")
.update({ profile: body.profile, updated_at: new Date().toISOString() })
.eq("id", id)
.eq("user_id", user.id)
.eq("role", "other")
.select("id, role, profile, updated_at")
.maybeSingle();
if (error) throw error;
if (!data) return NextResponse.json({ error: "星盘不存在或无权更新" }, { status: 404 });
return NextResponse.json({ profile: data });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: errorMessage(error, "星盘更新失败") }, { status: 500 });
}
}
+132 -34
View File
@@ -184,11 +184,14 @@ type Profile = BirthTimeDraft & {
longitude: number | null;
timezoneOffset: number | null;
rectificationCaseId: string;
chartRelationship?: ChartRelationship;
};
type ChartRelationship = "self" | "partner" | "family" | "friend" | "client" | "other";
type ChartLibraryRecord = {
id: string;
role: "self" | "other";
profile: Profile;
relationship: ChartRelationship;
updatedAt: number;
};
type SynastryRelationshipType = "romance" | "business" | "family" | "general";
@@ -472,6 +475,9 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null {
function chartLibraryStorageKey(accountId: string) {
return `jyotisha_chart_library:${accountId}`;
}
function activeChartStorageKey(accountId: string) {
return `jyotisha_active_chart:${accountId}`;
}
function synastryHistoryStorageKey(accountId: string) {
return `jyotisha_synastry_history:${accountId}`;
}
@@ -504,7 +510,7 @@ function profileReadyForLibrary(profile: Profile) {
}
function buildSelfChartRecord(profile: Profile): ChartLibraryRecord {
return { id: "self", role: "self", profile, updatedAt: timestamp() };
return { id: "self", role: "self", profile: { ...profile, chartRelationship: "self" }, relationship: "self", updatedAt: timestamp() };
}
function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) {
@@ -516,7 +522,14 @@ function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) {
function readChartLibrary(accountId: string): ChartLibraryRecord[] {
try {
const parsed = JSON.parse(localStorage.getItem(chartLibraryStorageKey(accountId)) || "[]") as ChartLibraryRecord[];
return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.profile) : [];
return Array.isArray(parsed)
? parsed
.filter((record) => record?.id && record?.profile)
.map((record) => {
const relationship = record.role === "self" ? "self" : record.relationship || record.profile.chartRelationship || "other";
return { ...record, profile: { ...record.profile, chartRelationship: relationship }, relationship };
})
: [];
} catch {
return [];
}
@@ -563,10 +576,12 @@ async function saveCloudSynastryReport(report: SynastryReportCard) {
}
function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord {
const relationship = record.role === "self" ? "self" : record.profile.chartRelationship || "other";
return {
id: record.role === "self" ? "self" : record.id,
role: record.role,
profile: record.profile,
profile: { ...record.profile, chartRelationship: relationship },
relationship,
updatedAt: Date.parse(record.updated_at || "") || timestamp(),
};
}
@@ -592,6 +607,17 @@ async function saveCloudChartProfile(record: ChartLibraryRecord) {
return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record;
}
async function updateCloudChartProfile(record: ChartLibraryRecord) {
const response = await fetch(`/api/chart-profiles/${encodeURIComponent(record.id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: record.profile }),
});
const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord; error?: string } | null;
if (!response.ok) throw new Error(payload?.error || "cloud_chart_profile_update_failed");
return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record;
}
async function deleteCloudChartProfile(recordId: string) {
const response = await fetch(`/api/chart-profiles/${encodeURIComponent(recordId)}`, { method: "DELETE" });
if (!response.ok) throw new Error("cloud_chart_profile_delete_failed");
@@ -614,6 +640,10 @@ function profileBirthTimeStatusLabel(profile: Profile) {
return "时间待确认";
}
function chartRelationshipLabel(relationship: ChartRelationship) {
return relationship === "partner" ? "伴侣" : relationship === "family" ? "家人" : relationship === "friend" ? "朋友" : relationship === "client" ? "客户" : relationship === "self" ? "本人" : "其他";
}
function formatChartUpdatedAt(updatedAt: number) {
if (!Number.isFinite(updatedAt) || updatedAt <= 0) return "刚刚更新";
return `更新于 ${new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric" }).format(new Date(updatedAt))}`;
@@ -724,6 +754,7 @@ function readProfile(value: unknown): Profile {
latitude?: unknown;
longitude?: unknown;
timezone_offset?: unknown;
chartRelationship?: unknown;
};
const date = normalizePersistedBirthDate(
typeof profile.birth_date === "string" ? profile.birth_date : profile.date,
@@ -770,6 +801,8 @@ function readProfile(value: unknown): Profile {
: typeof profile.timezoneOffset === "number" && Number.isFinite(profile.timezoneOffset)
? profile.timezoneOffset
: null;
const chartRelationships: readonly ChartRelationship[] = ["self", "partner", "family", "friend", "client", "other"];
const chartRelationship = chartRelationships.find((item) => item === profile.chartRelationship);
return {
name: typeof profile.name === "string" ? profile.name.slice(0, 80) : "",
@@ -798,6 +831,7 @@ function readProfile(value: unknown): Profile {
latitude,
longitude,
timezoneOffset,
...(chartRelationship ? { chartRelationship } : {}),
};
}
@@ -1134,9 +1168,12 @@ export default function Home() {
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
const [activeChartId, setActiveChartId] = useState("self");
const [synastryRelationshipType, setSynastryRelationshipType] = useState<SynastryRelationshipType>("romance");
const [synastryPendingId, setSynastryPendingId] = useState<string | null>(null);
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
const [otherChartRelationship, setOtherChartRelationship] = useState<Exclude<ChartRelationship, "self">>("other");
const [editingChartId, setEditingChartId] = useState<string | null>(null);
const [synastryReportCard, setSynastryReportCard] = useState<SynastryReportCard | null>(null);
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
const [dailyStarlanguage, setDailyStarlanguage] = useState<DailyStarlanguageState>({ kind: "pending" });
@@ -1346,22 +1383,29 @@ export default function Home() {
localStorage.setItem(`${prefix}archived`, JSON.stringify(archivedSessionIds));
}, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]);
useEffect(() => {
if (!hydrated || !accountId) return;
setActiveChartId(localStorage.getItem(activeChartStorageKey(accountId)) || "self");
}, [accountId, hydrated]);
useEffect(() => {
const branch = chartLibrarySessionBranch(accountId, chartLibraryLoadedAccount.current);
if (branch === "clear" || !accountId) {
setChartLibrary([]);
setSynastryHistory([]);
setActiveChartId("self");
chartLibraryLoadedAccount.current = "";
return;
}
const profileForLibrary: Profile = activeChartId === "self" ? profile : account ? readProfile(account.profile) : profile;
if (branch === "hydrate-then-persist") {
chartLibraryLoadedAccount.current = accountId;
setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profile));
setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profileForLibrary));
setSynastryHistory(readSynastryHistory(accountId));
void fetchCloudChartLibrary()
.then((cloudLibrary) => {
setChartLibrary(() => {
const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile);
const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profileForLibrary);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
@@ -1383,11 +1427,24 @@ export default function Home() {
});
}
setChartLibrary((current) => {
const next = upsertSelfChart(current, profile);
const next = upsertSelfChart(current, profileForLibrary);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
}, [accountId, profile]);
}, [account, accountId, activeChartId, profile]);
useEffect(() => {
if (!hydrated || !accountId || chartLibrary.length === 0) return;
const record = chartLibrary.find((item) => item.id === activeChartId)
|| chartLibrary.find((item) => item.role === "self");
if (!record) return;
if (record.id !== activeChartId) {
setActiveChartId(record.id);
localStorage.setItem(activeChartStorageKey(accountId), record.id);
}
setProfile((current) => preserveShallowEqual(current, record.profile));
if (record.role === "self") setProfileDraft((current) => preserveShallowEqual(current, record.profile));
}, [accountId, activeChartId, chartLibrary, hydrated]);
const profileComplete = isProfileComplete(profile);
const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId);
@@ -2061,7 +2118,11 @@ export default function Home() {
const latest = await fetchAccount();
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
const nextProfile = readProfile(latest.profile);
setProfile((current) => preserveShallowEqual(current, nextProfile));
setProfile((current) => {
const activeOther = activeChartId !== "self" && chartLibrary.find((record) => record.id === activeChartId && record.role === "other");
return activeOther ? current : preserveShallowEqual(current, nextProfile);
});
setProfileDraft(nextProfile);
setAccount(latest);
setAccountError("");
} catch (caught) {
@@ -2352,46 +2413,73 @@ export default function Home() {
throw new Error(payload?.error || "账户资料暂时无法保存。");
}
const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime);
setAccount((current) => current ? { ...current, profile: savedProfile } : current);
setActiveChartId("self");
localStorage.setItem(activeChartStorageKey(account.user.id), "self");
await saveCloudChartProfile({ ...buildSelfChartRecord(savedProfile), updatedAt: timestamp() }).catch(() => null);
return savedProfile;
}
async function saveOtherChart(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim() };
const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim(), chartRelationship: otherChartRelationship };
if (missingOtherProfileStep(nextProfile)) {
setAccountError("请补全其他星盘的称呼、出生时间和出生地点。");
return;
}
if (!accountId) return;
let record: ChartLibraryRecord = {
id: globalThis.crypto.randomUUID(),
id: editingChartId || globalThis.crypto.randomUUID(),
role: "other",
profile: nextProfile,
relationship: otherChartRelationship,
updatedAt: timestamp(),
};
let cloudSaved = false;
try {
record = await saveCloudChartProfile(record);
if (editingChartId) {
record = await updateCloudChartProfile(record);
} else {
record = await saveCloudChartProfile(record);
cloudSaved = true;
}
cloudSaved = true;
} catch {
setProfileNotice("已保存到本地星盘库;云端同步失败,稍后会继续使用本地记录。");
setAccountError("");
}
const selfProfile = account ? readProfile(account.profile) : profile;
setChartLibrary((current) => {
const next = [...upsertSelfChart(current, profile), record];
const others = editingChartId
? current.map((item) => item.id === record.id ? record : item)
: [...current, record];
const next = upsertSelfChart(others, selfProfile);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setEditingChartId(null);
setAccountError("");
if (cloudSaved) {
setAccountError("");
setProfileNotice("已保存到云端星盘库。请选择关系类型后点击“用于合盘”。");
setProfileNotice(editingChartId ? "已更新其他人的星盘资料。" : "已保存到云端星盘库。请选择关系类型后点击“用于合盘”。");
} else {
setProfileNotice("已保存到本地星盘库。");
}
}
function editOtherChart(record: ChartLibraryRecord) {
if (record.role !== "other") return;
setOtherProfileDraft(record.profile);
setOtherChartRelationship(record.relationship === "self" ? "other" : record.relationship);
setEditingChartId(record.id);
setAccountError("");
setProfileNotice("");
}
async function deleteOtherChart(recordId: string) {
if (!accountId) return;
if (!accountId || !window.confirm("确定删除这份其他人的星盘资料吗?删除后无法恢复。")) return;
let cloudDeleted = false;
try {
await deleteCloudChartProfile(recordId);
@@ -2401,6 +2489,10 @@ export default function Home() {
}
setChartLibrary((current) => {
const next = current.filter((record) => record.id !== recordId || record.role === "self");
if (activeChartId === recordId) {
setActiveChartId("self");
localStorage.setItem(activeChartStorageKey(accountId), "self");
}
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
@@ -2410,20 +2502,12 @@ export default function Home() {
: "已从本地星盘库删除;云端同步失败,稍后云端可能仍显示旧记录。");
}
async function makeDefaultChart(record: ChartLibraryRecord) {
if (record.role !== "other" || profileSaving) return;
setProfileSaving(true);
setAccountError("");
try {
const savedProfile = await persistProfile(record.profile);
setProfile(savedProfile);
setProfileDraft(savedProfile);
setProfileNotice("已设为当前默认星盘。");
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "默认星盘保存失败"));
} finally {
setProfileSaving(false);
}
function makeDefaultChart(record: ChartLibraryRecord) {
if (record.role !== "other" || profileSaving || !accountId) return;
setActiveChartId(record.id);
localStorage.setItem(activeChartStorageKey(accountId), record.id);
setProfile(record.profile);
setProfileNotice("已设为当前使用资料,账户本人的出生资料未被覆盖。");
}
async function assessSavedBirthTime(nextProfile: Profile) {
@@ -3911,7 +3995,7 @@ export default function Home() {
{chartLibrary.filter((record) => record.role === "other").map((record) => (
<article className="chart-library-item" key={record.id}>
<div className="chart-library-item-main">
<div className="chart-library-item-title"><strong>{record.profile.name || "未命名"}</strong><span className="chart-role-badge"></span></div>
<div className="chart-library-item-title"><strong>{record.profile.name || "未命名"}</strong><span className="chart-role-badge">{chartRelationshipLabel(record.relationship)}</span></div>
<small>{record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)}</small>
<small>{profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)}</small>
</div>
@@ -3923,8 +4007,9 @@ export default function Home() {
<option value="general"></option>
</select>
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === record.id ? "正在计算合盘..." : "用于合盘"}</button>
<button className="button-secondary" type="button" onClick={() => void makeDefaultChart(record)} disabled={profileSaving}></button>
<button className="button-secondary" type="button" onClick={() => deleteOtherChart(record.id)}></button>
<button className="button-secondary" type="button" onClick={() => editOtherChart(record)} disabled={profileSaving || editingChartId !== null}></button>
<button className="button-secondary" type="button" onClick={() => makeDefaultChart(record)} disabled={profileSaving}></button>
<button className="button-secondary" type="button" onClick={() => void deleteOtherChart(record.id)}></button>
</div>
</article>
))}
@@ -3959,9 +4044,22 @@ export default function Home() {
</div>
)}
<form className="profile-form chart-library-form" onSubmit={saveOtherChart}>
<div className="section-heading"><b></b><small></small></div>
<div className="section-heading"><b>{editingChartId ? "编辑其他人的星盘" : "添加其他人的星盘"}</b><small></small></div>
<label>
<span></span>
<select value={otherChartRelationship} onChange={(event) => setOtherChartRelationship(event.target.value as Exclude<ChartRelationship, "self">)}>
<option value="partner"></option>
<option value="family"></option>
<option value="friend"></option>
<option value="client"></option>
<option value="other"></option>
</select>
</label>
<ProfileFields value={otherProfileDraft} onChange={setOtherProfileDraft} nameInputId="other-profile-name" />
<button className="button-primary save-profile" type="submit" disabled={!account}></button>
<div className="dialog-actions">
{editingChartId && <button className="button-secondary" type="button" onClick={() => { setEditingChartId(null); setOtherProfileDraft(emptyProfile); setOtherChartRelationship("other"); }}></button>}
<button className="button-primary save-profile" type="submit" disabled={!account || profileSaving}>{profileSaving ? "保存中" : editingChartId ? "保存修改" : "添加到星盘库"}</button>
</div>
</form>
</div>
</>
@@ -33,7 +33,7 @@ test("adding another chart waits for the user to choose a relationship type", ()
test("a successful cloud read replaces stale local other charts", () => {
assert.match(
source,
/fetchCloudChartLibrary\(\)[\s\S]{0,800}upsertSelfChart\(cloudLibrary\.filter\(\(record\) => record\.role !== "self"\), profile\)/,
/fetchCloudChartLibrary\(\)[\s\S]{0,800}upsertSelfChart\(cloudLibrary\.filter\(\(record\) => record\.role !== "self"\), profileForLibrary\)/,
);
assert.doesNotMatch(source, /fetchCloudChartLibrary\(\)[\s\S]{0,800}new Map\(\[[\s\S]{0,500}current\.filter\(\(record\) => record\.role === "other"\)/);
});
@@ -81,3 +81,12 @@ test("relationship intent selects domain-specific evidence instead of treating e
assert.match(source, /基础商业合作证据筛查/);
assert.match(source, /relationshipType === "business"/);
});
test("current chart selection is local and does not overwrite the owner's profile", () => {
assert.match(source, /activeChartStorageKey/);
assert.match(source, /localStorage\.setItem\(activeChartStorageKey\(accountId\), record\.id\)/);
assert.match(source, /已设为当前使用资料,账户本人的出生资料未被覆盖/);
assert.doesNotMatch(source, /function makeDefaultChart[\s\S]{0,500}persistProfile\(/);
assert.match(readFileSync(new URL("../src/app/api/chart-profiles/[id]/route.ts", import.meta.url), "utf8"), /星盘不存在或无权更新/);
});