diff --git a/frontend/src/app/api/chart-profiles/[id]/route.ts b/frontend/src/app/api/chart-profiles/[id]/route.ts index 82f050b8..bda1e807 100644 --- a/frontend/src/app/api/chart-profiles/[id]/route.ts +++ b/frontend/src/app/api/chart-profiles/[id]/route.ts @@ -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 }); + } +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 96e6bd01..cdbc39c5 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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(null); const [chartLibrary, setChartLibrary] = useState([]); + const [activeChartId, setActiveChartId] = useState("self"); const [synastryRelationshipType, setSynastryRelationshipType] = useState("romance"); const [synastryPendingId, setSynastryPendingId] = useState(null); const [otherProfileDraft, setOtherProfileDraft] = useState(emptyProfile); + const [otherChartRelationship, setOtherChartRelationship] = useState>("other"); + const [editingChartId, setEditingChartId] = useState(null); const [synastryReportCard, setSynastryReportCard] = useState(null); const [synastryHistory, setSynastryHistory] = useState([]); const [dailyStarlanguage, setDailyStarlanguage] = useState({ 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) { 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) => (
-
{record.profile.name || "未命名"}其他人
+
{record.profile.name || "未命名"}{chartRelationshipLabel(record.relationship)}
{record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)} {profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)}
@@ -3923,8 +4007,9 @@ export default function Home() { - - + + +
))} @@ -3959,9 +4044,22 @@ export default function Home() { )}
-
添加其他星盘用于合盘、亲友盘或客户盘。
+
{editingChartId ? "编辑其他人的星盘" : "添加其他人的星盘"}用于合盘、亲友盘或客户盘。
+ - +
+ {editingChartId && } + +
diff --git a/frontend/tests/chart-library-other-profile.test.ts b/frontend/tests/chart-library-other-profile.test.ts index 207457bb..a36e75e2 100644 --- a/frontend/tests/chart-library-other-profile.test.ts +++ b/frontend/tests/chart-library-other-profile.test.ts @@ -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"), /星盘不存在或无权更新/); +});