diff --git a/CHANGELOG.md b/CHANGELOG.md index 261f8ff4..26d2a32e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-09-25 — 星盘档案可以保存最多五个人 + +- 侧栏新增「星盘档案」。每个人只有称呼和出生资料,没有关系。本人不可删除,其他人最多四个。 +- 对话、星盘、星历、我的报告和档案页的标题旁可以切换当前人物。已开始的对话里换成别人会新开一段空对话。历史列表只显示当前这个人。 +- 给别人排盘、写报告、看今日星语都用服务端保存的资料。资料不存在就不会改用本人,也不会扣点。 +- 当前人物不是本人时,生时校正先不能用。任意两人合盘仍留到下一轮。 +- 设置里不再有「星盘资料」。Skill 版本不 bump。浏览器未验收。 + ## 2026-09-25 — 星盘大运改为折叠,西洋盘重画 - 大运每条轨道先给「现在」卡和时间条,子运收在当前段里。0 年段仍是灰色「0 年」,不占时间轴,不能展开。 diff --git a/CONTEXT.md b/CONTEXT.md index 5580b3b0..2edd09a9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,6 +48,18 @@ _避免使用_:聊天日志、客服工单 对话质量记录的内部处理进度,取值为待处理、处理中、已解决或忽略。 _避免使用_:用户反馈状态、通知状态 +**人物**: +一张星盘档案对应的一个人。户主本人也是其中一个人。不要再叫「当前星盘」来指这个人。 +_避免使用_:当前星盘、关系、默认星盘 + +**星盘档案**: +侧栏页面 `/people` 里保存的人物列表。最多 5 人,含本人。本人不可删除。 +_避免使用_:星盘库、星盘资料(设置里的旧面板) + +**当前人物**: +这一次星盘、星历、报告、今日星语和新对话默认使用的那个人。只作为请求参数交给服务端,不替换页面上的本人资料。 +_避免使用_:设为默认、active chart + **领域别名**: 生时校正里,健康在证据账本和焦点层记为 `health`,计划层、引擎探针和盘外提示用 `health_pressure`;职业焦点落库为 `other`(题号仍是 `collect:occupation:`),计划层与账本用 `occupation`。比较时两侧都要先归并,不能按字面相等。数据库 `target_domain` 约束不改。 _避免使用_:把 `health` 和 `health_pressure` 当成两个领域、把职业拒答当成 `other` 领域 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 601c6df4..4f7ab799 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13762,3 +13762,19 @@ - 复发自:无 - 修复版本:本分支,待推送 - 断言三栏:原值是扫描面不含引导屏组件、只要求其中出现 ` *`); list panes (星盘资料, 账户与点数) stay full-bleed within the inset so tables and card grids keep their columns. The cap sits on the children of the scroll container only — never on `.settings-modal` or `.account-settings-shell` — so it cannot make the frame resize between panes (BUG-554 / BUG-698). - **Personal profile:** one row of avatar editing (56px preview, eight palettes, 换一个形象) plus nickname and login email. The header uses the product-level “设置” eyebrow above the current pane title; there is no duplicate 管理星盘资料 button. -- **Chart library:** list first (self row, other rows, 添加其他人). 添加其他人 sits in the 其他人 group heading action area instead of below the list, so the entry remains discoverable when the list grows. A row opens a detail with ← 星盘资料. Other details own 设为默认 / 删除 / 用于合盘 and that person's synastry history. The add form is a view, not an always-on stack. +- **Chart archive (`/people`):** a static secondary page beside 星盘 / 星历 / 我的报告. Self is the first row and cannot be deleted. Each other row is a name plus birth facts, with no relationship field. Detail shows the reported time and the chart time separately, then place and ayanamsa. Shortcuts are 和 TA 对话, 看星盘, 生成报告, and 和我合盘. Adding a sixth person is disabled with 「最多保存 5 个人的星盘」. Delete confirms with the session and report counts before the server removes them together. There is no 「设为默认」 and no 「当前默认」 badge. +- **Subject switcher:** the same control sits beside the title on chat, chart, ephemeris, reports, and the archive. It is not in the sidebar header. Choosing someone in a chat that already has messages switches the current person and opens an empty chat. An empty chat rebinds in place. The list shows at most five people. - **States:** open, pane switch, list / self / other / add, saving, success, and error. ### Billing pane @@ -970,7 +971,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 | 还没有出生资料 | 标题「还没有星盘资料」+ 原句 + 主按钮「去填写出生资料」 | | 资料读不出来 / 已采用时间缺时区 / 时区没有对上 | 各自的原句 + 同一个「去填写出生资料」;不加「还没有星盘资料」 | -资料类这四句的按钮链到 `/?settings=chart`,打开设置里的星盘资料。标题用 `--type-title-md`,整块居中,按钮是主按钮。引擎忙、超时和坏结果不加这个按钮。解析 `?settings=` 只在 `settings-url.ts`;`?settings=billing` 仍只开账户与点数。 +资料类这四句的按钮链到 `/people`。旧的 `?settings=chart` 也改去 `/people`,不再打开设置。标题用 `--type-title-md`,整块居中,按钮是主按钮。引擎忙、超时和坏结果不加这个按钮。解析 `?settings=` 只在 `settings-url.ts`;`?settings=billing` 仍只开账户与点数。 ## 16. 星历页 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 7431aa4e..e7254327 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -157,11 +157,11 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 ## 设置弹窗 -分区名只用:个人资料 / 星盘资料 / 账户与点数 / 通用设置。不要写成「会员页」「充值页」。 +分区名只用:个人资料 / 账户与点数 / 通用设置。星盘档案是侧栏页面,不写进设置分区。不要写成「会员页」「充值页」。 账户与点数的页签只用:会员套餐 / 点数包 / 兑换码 / 订单记录。购买中按钮写「跳转收银台…」,轮询中写「等待支付结果」,不要写「正在加载」。 -星盘资料列表条只写人名、关系、出生摘要。详情返回用「← 星盘资料」。添加入口写「添加其他人」。 +星盘档案不写关系。人数到顶时只说「最多保存 5 个人的星盘」。当前人物不是本人时,生时校正入口禁用,提示「生时校正暂时只支持本人」。删除一个人之前必须写出「将同时删除 N 个对话和 M 份报告,无法恢复。」本人不能删除,不要给删除按钮配一句绕过去的确认。 余额不足时输入框占位写「余额不足,可在账户与点数中补充」,不要叫用户去会员页。 diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 495d4929..ebb8e328 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -42,7 +42,9 @@ import { SidebarTrigger } from "@/components/ui/sidebar"; import { useHomeShellRegistration } from "@/hooks/use-home-shell-registration"; import { useSessionList } from "@/hooks/use-session-list"; import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft"; -import { ChartLibraryPanel } from "@/components/chart-library-panel"; +import { bindCurrentSubjectAccount } from "@/lib/current-subject"; +import { DailyStarlanguageBinder } from "@/components/daily-starlanguage-binder"; +import { NewChatDeepLink, SynastryDeepLink } from "@/components/people-home-links"; import { StarterEntries, StarterGreeting } from "@/components/starter-home"; import { GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION, @@ -113,11 +115,6 @@ import { } from "@/lib/consultation-activity-labels"; import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { createStartGreetingParts } from "@/lib/starter-greeting"; -import { - calendarDateInTimeZone, - dailyStarlanguageProfileKey, -} from "@/lib/daily-starlanguage"; -import { preserveShallowEqual } from "@/lib/preserve-shallow-equal"; import { SessionModelPersistenceQueue, persistSessionModelSelection, @@ -128,7 +125,6 @@ import { import { accountDialogClasses, accountDialogTitles, - dailyStarlanguageRetryDelayMs, pendingConsultationStorageKey, presetOnboardingMessage, type AccountDialog, @@ -155,18 +151,14 @@ import { sessionChartLabel, upsertSelfChart, } from "@/lib/home-profile"; import { - activeChartStorageKey, CancellationResponseError, ConsultationResponseError, discardLegacyCloudMirrorKeys, fetchCloudChartLibrary, fetchCloudSynastryHistory, - fetchDailyStarlanguage, payloadCode, payloadMessage, - readStoredDailyStarlanguage, waitForUndoWindow, - writeStoredDailyStarlanguage, } from "@/lib/home-cloud-sync"; import { runHomeBootstrap } from "@/lib/home-bootstrap-run"; import { runConsultationRecoveryPoll } from "@/lib/consultation-recovery-poll"; @@ -201,7 +193,6 @@ export default function Home() { const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [activeAccountDialog, setActiveAccountDialog] = useState(null); const [chartLibrary, setChartLibrary] = useState([]); - const [activeChartId, setActiveChartId] = useState("self"); const [billingPane, setBillingPane] = useState({}); const [dailyStarlanguage, setDailyStarlanguage] = useState({ kind: "pending" }); const [accountError, setAccountError] = useState(""); @@ -316,10 +307,10 @@ export default function Home() { retryBirthTimeAssessment, signOut, } = useProfileOnboarding({ - account, accountRefreshGuard, accountTrigger, activeChartId, - birthTimeRevisionPendingRef, chartLibrary, dialogReturnTargetRef, + account, accountRefreshGuard, accountTrigger, + birthTimeRevisionPendingRef, dialogReturnTargetRef, setAccount, setAccountError, setAccountMenuOpen, setActiveAccountDialog, - setActiveChartId, setBirthTimeAssessmentPhase, + setBirthTimeAssessmentPhase, setBirthTimeConsultationConsent, setBirthTimeError, setBirthTimeJourney, setBillingPane, setDraft, setOnboardingJustCompleted, setOnboardingStep, setPresetMessageLength, setRectificationError, @@ -405,11 +396,11 @@ export default function Home() { openChatBoundToProfile, selectSession, selectSessionModel, loadMoreSessions, } = useSessionManagement({ - account, accountId, activeChartId, activeSession, activeSessionId, activeSessionIdRef, + account, accountId, activeSession, activeSessionId, activeSessionIdRef, applySessionPopStateRef, cancellationPending, chartLibrary, creatingSession, modelCatalog, modelPersistence, modelSelectionVersions, modelSyncFailures, pendingSessionId, profile, rectificationSessionId, sessionDetailInFlight, sessionSelectionSource, sessions, sessionsRef, - setActiveChartId, setActiveSessionId, setBirthTimeConsultationConsent, setCreatingSession, + setActiveSessionId, setBirthTimeConsultationConsent, setCreatingSession, setDraft, setDraftEntrypoint, setDraftTheme, setRectificationError, setRectificationErrorSessionId, setRequestError, setSessions, uiPreview, openRectificationSession: (exactSessionId) => rectificationSessionOpenerRef.current(exactSessionId), @@ -423,7 +414,7 @@ export default function Home() { setRectificationHeaderSlot, rectificationPanel, resumeRectificationSession, openRectificationFromHomepage, openRectificationSession, reopenRectificationSession, } = useRectificationSurface({ - account, activeChartId, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog, + account, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog, persistSession, profile, rectification, setRectification, sessionSelectionSource, sessions, setActiveSessionId, setDraft, setDraftEntrypoint, setDraftTheme, setOnboardingStep, setProfileNotice, setSessions, uiPreview, @@ -444,8 +435,11 @@ export default function Home() { const applySettingsFromSearch = (search: string) => { const query = parseSettingsQuery(search); if (!query) return; - if (query.pane === "chart") openAccountDialog("chart-library"); - else openAccountDialog("billing", { source: query.source ?? undefined, plan: query.plan ?? undefined, tab: query.tab ?? undefined }); + if (query.pane === "chart") { + window.location.assign("/people"); + return; + } + openAccountDialog("billing", { source: query.source ?? undefined, plan: query.plan ?? undefined, tab: query.tab ?? undefined }); const next = stripSettingsQuery(search, window.location.pathname); if (`${window.location.pathname}${window.location.search}` !== next) window.history.replaceState(null, "", next); }; @@ -507,12 +501,6 @@ export default function Home() { })(); }, [accountId, bootstrapPhase, setRectificationEntrySummary, setRectificationEntrySummarySettled]); - useEffect(() => { - if (!hydrated || !accountId) return; - const storedChartId = localStorage.getItem(activeChartStorageKey(accountId)) || "self"; - queueMicrotask(() => setActiveChartId(storedChartId)); - }, [accountId, hydrated]); - const { status: chatProfileStatus, markReady: markChatProfilesReady, @@ -528,12 +516,14 @@ export default function Home() { function reloadChatProfiles() { if (!accountId) return; const requested = accountId; - const profileForLibrary: Profile = activeChartId === "self" ? profile : account ? readProfile(account.profile) : profile; + const profileForLibrary: Profile = account ? readProfile(account.profile) : profile; beginChatProfileReload(); void fetchCloudChartLibrary() .then((cloudLibrary) => { if (chartLibraryLoadedAccount.current !== requested) return; - setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart)); + const next = chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart); + setChartLibrary(next); + bindCurrentSubjectAccount(requested, next.filter((record) => record.role === "other").map((record) => record.id)); markChatProfilesReady(requested); }) .catch(() => { @@ -550,11 +540,11 @@ export default function Home() { queueMicrotask(() => { setChartLibrary([]); clearSynastryHistory(); - setActiveChartId("self"); + bindCurrentSubjectAccount("", null); }); return; } - const profileForLibrary: Profile = activeChartId === "self" ? profile : account ? readProfile(account.profile) : profile; + const profileForLibrary: Profile = account ? readProfile(account.profile) : profile; if (branch === "hydrate-then-persist") { chartLibraryLoadedAccount.current = accountId; discardLegacyCloudMirrorKeys(accountId); @@ -563,7 +553,9 @@ export default function Home() { void fetchCloudChartLibrary() .then((cloudLibrary) => { if (chartLibraryLoadedAccount.current !== accountId) return; - setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart)); + const next = chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart); + setChartLibrary(next); + bindCurrentSubjectAccount(accountId, next.filter((record) => record.role === "other").map((record) => record.id)); markChatProfilesReady(accountId); }) .catch(() => { @@ -576,7 +568,9 @@ export default function Home() { void fetchCloudChartLibrary() .then((cloudLibrary) => { if (chartLibraryLoadedAccount.current !== accountId) return; - setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart)); + const next = chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart); + setChartLibrary(next); + bindCurrentSubjectAccount(accountId, next.filter((record) => record.role === "other").map((record) => record.id)); markChatProfilesReady(accountId); }) .catch(() => { @@ -607,31 +601,12 @@ export default function Home() { }); } setChartLibrary((current) => upsertSelfChart(current, profileForLibrary)); - }, [account, accountId, activeChartId, applyCloudSynastryHistory, beginChatProfileReload, clearSynastryHistory, markChatProfilesFailed, markChatProfilesReady, 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) { - localStorage.setItem(activeChartStorageKey(accountId), record.id); - } - const nextChartId = record.id !== activeChartId ? record.id : null; - const nextProfile = record.profile; - const syncSelfDraft = record.role === "self"; - queueMicrotask(() => { - if (nextChartId) setActiveChartId(nextChartId); - setProfile((current) => preserveShallowEqual(current, nextProfile)); - if (syncSelfDraft) setProfileDraft((current) => preserveShallowEqual(current, nextProfile)); - }); - }, [accountId, activeChartId, chartLibrary, hydrated, setProfile, setProfileDraft]); + }, [account, accountId, applyCloudSynastryHistory, beginChatProfileReload, clearSynastryHistory, markChatProfilesFailed, markChatProfilesReady, profile]); const profileComplete = isProfileComplete(profile); const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId); const natalMinuteAvailable = birthTimeRoute.kind === "consult" && (birthTimeRoute.mode === "verified_chart" || birthTimeRoute.mode === "unverified_birth_time"); - const dailyStarlanguageFingerprint = dailyStarlanguageProfileKey(profile); const dailyStarlanguageQuestion = !natalMinuteAvailable ? GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION @@ -879,50 +854,6 @@ export default function Home() { return () => window.clearInterval(timer); }, [currentOnboardingMessage, hydrated, shouldStreamOnboarding]); - useEffect(() => { - if (bootstrapPhase === "account" || !accountId || !profileComplete || !natalMinuteAvailable) return; - const today = calendarDateInTimeZone(new Date(), profile.timezoneId); - const fingerprint = dailyStarlanguageProfileKey(profile); - const stored = readStoredDailyStarlanguage(accountId); - const controller = new AbortController(); - let retryTimer: ReturnType | undefined; - const cachedCard = stored && stored.day === today && stored.fingerprint === fingerprint - ? stored.card - : null; - queueMicrotask(() => { - if (controller.signal.aborted) return; - if (cachedCard) setDailyStarlanguage({ kind: "ready", card: cachedCard }); - else setDailyStarlanguage({ kind: "pending" }); - }); - const attempt = (remainingRetries: number) => { - void fetchDailyStarlanguage(controller.signal) - .then((next) => { - if (controller.signal.aborted) return; - if (next.kind === "unavailable" && remainingRetries > 0) { - retryTimer = setTimeout(() => attempt(remainingRetries - 1), dailyStarlanguageRetryDelayMs); - return; - } - if (next.kind === "ready") { - writeStoredDailyStarlanguage(accountId, { day: today, fingerprint, card: next.card }); - setDailyStarlanguage(next); - return; - } - if (stored && stored.day === today && stored.fingerprint === fingerprint) return; - setDailyStarlanguage(next); - }) - .catch(() => { - if (controller.signal.aborted) return; - if (stored && stored.day === today && stored.fingerprint === fingerprint) return; - setDailyStarlanguage({ kind: "unavailable" }); - }); - }; - attempt(1); - return () => { - controller.abort(); - if (retryTimer !== undefined) clearTimeout(retryTimer); - }; - }, [accountId, bootstrapPhase, dailyStarlanguageFingerprint, natalMinuteAvailable, profileComplete]); - useEffect(() => { if (starterHomeVisible) { starterGreetingUnseen.current = false; @@ -1180,18 +1111,7 @@ export default function Home() { ), renderChartLibrary() { - return ( - <> -

管理本人和其他人的出生资料。本人资料用于默认解盘,其他人资料可用于合盘或单独查看。

- - - ); + return null; }, renderBilling: () => ( @@ -1216,6 +1136,16 @@ export default function Home() { return ( <> + + + {replyAnnouncement} {pendingSessionDeletion ? (
setPendingSessionDeletion(null)}> diff --git a/frontend/src/app/(app)/people/page.tsx b/frontend/src/app/(app)/people/page.tsx new file mode 100644 index 00000000..e7ccd1c5 --- /dev/null +++ b/frontend/src/app/(app)/people/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { PeoplePage } from "@/components/people/people-page"; + +export const metadata: Metadata = { + title: "星盘档案 · Jyotisha", + robots: { index: false, follow: false }, +}; + +export default function PeopleRoute() { + return ; +} diff --git a/frontend/src/app/api/chart-profiles/[id]/route.ts b/frontend/src/app/api/chart-profiles/[id]/route.ts index bda1e807..df17f08e 100644 --- a/frontend/src/app/api/chart-profiles/[id]/route.ts +++ b/frontend/src/app/api/chart-profiles/[id]/route.ts @@ -1,4 +1,6 @@ import { NextResponse } from "next/server"; +import { libraryProfileFromSubjectRow, parseChartSubjectWrite } from "@/lib/chart-subject-write"; +import { CHART_SUBJECT_API_SELECT } from "@/lib/subject-birth"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; @@ -10,34 +12,90 @@ function errorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; } -export async function DELETE(_request: Request, context: RouteContext) { +function databaseFailure(error: unknown): NextResponse | null { + const message = errorMessage(error, ""); + if (message.includes("chart_subject_not_found")) { + return NextResponse.json({ error: "星盘不存在或无权删除" }, { status: 404 }); + } + if (message.includes("reported_birth_time_is_immutable")) { + return NextResponse.json({ error: "填报的出生时间不能再改", code: "reported_birth_time_is_immutable" }, { status: 409 }); + } + if (message.includes("chart_subject_active_birth_is_locked") || message.includes("chart_subject_birth_status_locked")) { + return NextResponse.json({ error: "不能直接写入排盘采用时间", code: "active_birth_locked" }, { status: 403 }); + } + return null; +} + +function toClientProfile(row: Record) { + return { + id: row.id, + role: "other", + profile: libraryProfileFromSubjectRow(row), + updated_at: row.updated_at, + }; +} + +export async function GET(_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 { count, error } = await supabase + const { data, error } = await supabase .from("chart_profiles") - .delete({ count: "exact" }) + .select(CHART_SUBJECT_API_SELECT) .eq("id", id) .eq("user_id", user.id) - .eq("role", "other"); - + .eq("role", "other") + .maybeSingle(); if (error) throw error; - if (count !== 1) { - return NextResponse.json({ error: "星盘不存在或无权删除" }, { status: 404 }); + if (!data) return NextResponse.json({ error: "星盘不存在或无权查看" }, { status: 404 }); + const usage = await supabase.rpc("chart_subject_usage", { p_id: id }); + if (usage.error) throw usage.error; + const row = Array.isArray(usage.data) ? usage.data[0] as { session_count?: number; report_count?: number } : null; + return NextResponse.json({ + profile: toClientProfile(data as Record), + usage: { + sessions: row?.session_count ?? 0, + reports: row?.report_count ?? 0, + }, + }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); + } + const mapped = databaseFailure(error); + if (mapped) return mapped; + return NextResponse.json({ error: errorMessage(error, "星盘暂时无法读取") }, { status: 500 }); + } +} + +export async function DELETE(_request: Request, context: RouteContext) { + try { + const { id } = await context.params; + if (id === "self") { + return NextResponse.json({ error: "本人不能删除" }, { status: 403 }); + } + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const { error } = await supabase.rpc("delete_chart_subject", { p_id: id }); + if (error) { + const mapped = databaseFailure(error); + if (mapped) return mapped; + throw error; } return NextResponse.json({ ok: true }); } catch (error) { if (isSupabaseConfigurationError(error)) { return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); } + const mapped = databaseFailure(error); + if (mapped) return mapped; return NextResponse.json({ error: errorMessage(error, "星盘删除失败") }, { status: 500 }); } } - export async function PUT(request: Request, context: RouteContext) { try { const { id } = await context.params; @@ -46,24 +104,53 @@ export async function PUT(request: Request, context: RouteContext) { 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 + const parsed = parseChartSubjectWrite(body?.profile); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 }); + const current = await supabase .from("chart_profiles") - .update({ profile: body.profile, updated_at: new Date().toISOString() }) + .select("reported_birth_time") .eq("id", id) .eq("user_id", user.id) .eq("role", "other") - .select("id, role, profile, updated_at") .maybeSingle(); - if (error) throw error; + if (current.error) throw current.error; + if (!current.data) return NextResponse.json({ error: "星盘不存在或无权更新" }, { status: 404 }); + const storedReported = typeof current.data.reported_birth_time === "string" + ? current.data.reported_birth_time.slice(0, 5) + : ""; + const nextReported = typeof parsed.write.columns.reported_birth_time === "string" + ? parsed.write.columns.reported_birth_time.slice(0, 5) + : ""; + if (storedReported && nextReported && storedReported !== nextReported) { + return NextResponse.json({ error: "填报的出生时间不能再改", code: "reported_birth_time_is_immutable" }, { status: 409 }); + } + const columns = { ...parsed.write.columns }; + if (storedReported) delete columns.reported_birth_time; + const { data, error } = await supabase + .from("chart_profiles") + .update({ + profile: parsed.write.profile, + updated_at: new Date().toISOString(), + ...columns, + }) + .eq("id", id) + .eq("user_id", user.id) + .eq("role", "other") + .select(CHART_SUBJECT_API_SELECT) + .maybeSingle(); + if (error) { + const mapped = databaseFailure(error); + if (mapped) return mapped; + throw error; + } if (!data) return NextResponse.json({ error: "星盘不存在或无权更新" }, { status: 404 }); - return NextResponse.json({ profile: data }); + return NextResponse.json({ profile: toClientProfile(data as Record) }); } catch (error) { if (isSupabaseConfigurationError(error)) { return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); } + const mapped = databaseFailure(error); + if (mapped) return mapped; return NextResponse.json({ error: errorMessage(error, "星盘更新失败") }, { status: 500 }); } } diff --git a/frontend/src/app/api/chart-profiles/route.ts b/frontend/src/app/api/chart-profiles/route.ts index f190866e..d44b9a60 100644 --- a/frontend/src/app/api/chart-profiles/route.ts +++ b/frontend/src/app/api/chart-profiles/route.ts @@ -1,17 +1,45 @@ import { NextResponse } from "next/server"; +import { + libraryProfileFromSubjectRow, + parseChartSubjectWrite, + subjectLimitReached, +} from "@/lib/chart-subject-write"; +import { CHART_SUBJECT_API_SELECT, MAX_SAVED_SUBJECTS } from "@/lib/subject-birth"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; -type ChartProfilePayload = { - id?: string; - role?: "self" | "other"; - profile?: unknown; -}; +const SUBJECT_LIMIT_MESSAGE = "最多保存 5 个人的星盘"; function errorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; } +function databaseFailure(error: unknown): NextResponse | null { + const message = errorMessage(error, ""); + if (message.includes("subject_limit_reached")) { + return NextResponse.json({ error: SUBJECT_LIMIT_MESSAGE, code: "subject_limit_reached" }, { status: 409 }); + } + if (message.includes("reported_birth_time_is_immutable")) { + return NextResponse.json({ error: "填报的出生时间不能再改", code: "reported_birth_time_is_immutable" }, { status: 409 }); + } + if (message.includes("chart_subject_active_birth_is_locked") || message.includes("chart_subject_birth_status_locked")) { + return NextResponse.json({ error: "不能直接写入排盘采用时间", code: "active_birth_locked" }, { status: 403 }); + } + if (message.includes("chart_subject_self_mirror_retired")) { + return NextResponse.json({ error: "本人资料保存在账户里", code: "self_mirror_retired" }, { status: 409 }); + } + return null; +} + +function toClientProfile(row: Record) { + return { + id: row.id, + role: "other", + profile: libraryProfileFromSubjectRow(row), + updated_at: row.updated_at, + }; +} + export async function GET() { try { const supabase = await createServerSupabaseClient(); @@ -20,12 +48,18 @@ export async function GET() { const { data, error } = await supabase .from("chart_profiles") - .select("id, role, profile, updated_at") + .select(CHART_SUBJECT_API_SELECT) .eq("user_id", user.id) + .eq("role", "other") .order("updated_at", { ascending: false }); if (error) throw error; - return NextResponse.json({ profiles: data ?? [] }); + const profiles = (data ?? []).map((row) => toClientProfile(row as Record)); + return NextResponse.json({ + profiles, + limit: MAX_SAVED_SUBJECTS, + atLimit: subjectLimitReached(profiles.length), + }); } catch (error) { if (isSupabaseConfigurationError(error)) { return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); @@ -40,49 +74,46 @@ export async function POST(request: Request) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); - const body = await request.json().catch(() => null) as ChartProfilePayload | null; - if (!body?.profile || typeof body.profile !== "object" || Array.isArray(body.profile)) { - return NextResponse.json({ error: "星盘资料格式不正确" }, { status: 400 }); + const body = await request.json().catch(() => null) as { role?: unknown; profile?: unknown } | null; + if (body?.role === "self") { + return NextResponse.json({ error: "本人资料保存在账户里", code: "self_mirror_retired" }, { status: 409 }); } - const role = body.role === "self" ? "self" : "other"; - const updatedAt = new Date().toISOString(); - let data; - let error; - if (role === "self") { - const existing = await supabase - .from("chart_profiles") - .select("id") - .eq("user_id", user.id) - .eq("role", "self") - .maybeSingle(); - if (existing.error) throw existing.error; - const query = existing.data?.id - ? supabase - .from("chart_profiles") - .update({ profile: body.profile, updated_at: updatedAt }) - .eq("id", existing.data.id) - .select("id, role, profile, updated_at") - .single() - : supabase - .from("chart_profiles") - .insert({ user_id: user.id, role, profile: body.profile, updated_at: updatedAt }) - .select("id, role, profile, updated_at") - .single(); - ({ data, error } = await query); - } else { - ({ data, error } = await supabase - .from("chart_profiles") - .insert({ user_id: user.id, role, profile: body.profile, updated_at: updatedAt }) - .select("id, role, profile, updated_at") - .single()); + const parsed = parseChartSubjectWrite(body?.profile); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 }); + + const existing = await supabase + .from("chart_profiles") + .select("id", { count: "exact", head: true }) + .eq("user_id", user.id) + .eq("role", "other"); + if (existing.error) throw existing.error; + if (subjectLimitReached(existing.count ?? 0)) { + return NextResponse.json({ error: SUBJECT_LIMIT_MESSAGE, code: "subject_limit_reached" }, { status: 409 }); } - if (error) throw error; - return NextResponse.json({ profile: data }); + const { data, error } = await supabase + .from("chart_profiles") + .insert({ + user_id: user.id, + role: "other", + profile: parsed.write.profile, + updated_at: new Date().toISOString(), + ...parsed.write.columns, + }) + .select(CHART_SUBJECT_API_SELECT) + .single(); + if (error) { + const mapped = databaseFailure(error); + if (mapped) return mapped; + throw error; + } + return NextResponse.json({ profile: toClientProfile(data as Record) }); } catch (error) { if (isSupabaseConfigurationError(error)) { return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); } + const mapped = databaseFailure(error); + if (mapped) return mapped; return NextResponse.json({ error: errorMessage(error, "星盘保存失败") }, { status: 500 }); } } diff --git a/frontend/src/app/api/chart-view/route.ts b/frontend/src/app/api/chart-view/route.ts index cada5af6..9fa06e4d 100644 --- a/frontend/src/app/api/chart-view/route.ts +++ b/frontend/src/app/api/chart-view/route.ts @@ -7,7 +7,8 @@ export const runtime = "nodejs"; export const maxDuration = 20; export async function GET(request: Request) { - const layers = parseChartViewLayers(new URL(request.url).searchParams.get("layers")); - const result = await loadChartView({ layers }); + const url = new URL(request.url); + const layers = parseChartViewLayers(url.searchParams.get("layers")); + const result = await loadChartView({ layers, subjectId: url.searchParams.get("subject") }); return NextResponse.json(result.body, { status: result.httpStatus }); } diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index b8445794..8d27cf4c 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -79,6 +79,7 @@ import { consultationSubjectBindingFromSession, consultationSubjectFailureResponse, } from "@/lib/consultation-subject-resolver"; +import { CHART_SUBJECT_SELECT } from "@/lib/subject-birth"; import { loadReportCandidateRange } from "@/lib/report-candidate-range"; import { loadGeneralDailyPanchangaContext, @@ -404,7 +405,7 @@ export async function POST(request: Request) { async loadOwnedChartProfile(chartProfileId) { const { data, error } = await supabase .from("chart_profiles") - .select("id,user_id,role,profile") + .select(CHART_SUBJECT_SELECT) .eq("id", chartProfileId) .eq("user_id", userId) .maybeSingle(); @@ -412,7 +413,8 @@ export async function POST(request: Request) { if (!data || typeof data.id !== "string" || typeof data.user_id !== "string" || typeof data.role !== "string") { return null; } - return { id: data.id, userId: data.user_id, role: data.role, profile: data.profile }; + const { id, user_id: userIdFromRow, role, ...typed } = data; + return { id, userId: userIdFromRow, role, profile: typed }; }, }, async loadProfile(profileUserId) { diff --git a/frontend/src/app/api/daily-starlanguage/route.ts b/frontend/src/app/api/daily-starlanguage/route.ts index ab0608ca..14288258 100644 --- a/frontend/src/app/api/daily-starlanguage/route.ts +++ b/frontend/src/app/api/daily-starlanguage/route.ts @@ -13,7 +13,8 @@ import { import { dailyProfilePayload } from "@/lib/global-birth-payloads"; import { loadLanguageModelCatalog } from "@/lib/model-catalog"; import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; -import { ACCOUNT_BIRTH_SELECT, globalBirthProfileFromAccountRow } from "@/lib/server-owned-birth-profile"; +import { globalBirthProfileFromAccountRow } from "@/lib/server-owned-birth-profile"; +import { ConsultationSubjectError, loadSubjectBirth, subjectIdFromQuery } from "@/lib/subject-birth"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { getDailyStarlanguageAgent } from "@/mastra"; @@ -172,21 +173,27 @@ export async function POST(request: Request) { const supabase = await createServerSupabaseClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) return NextResponse.json({ status: "unauthenticated" }, { status: 401 }); - await request.json().catch(() => null); + const body = await request.json().catch(() => null) as { subjectId?: unknown } | null; + const requestedSubject = subjectIdFromQuery(body?.subjectId); - const { data: row, error } = await supabase - .from("profiles") - .select(ACCOUNT_BIRTH_SELECT) - .eq("id", user.id) - .maybeSingle(); - if (error || !row) return unavailable("birth_profile_incomplete"); - - const profile = globalBirthProfileFromAccountRow(row); + let profile: Profile; + let subjectId = requestedSubject; + try { + const resolved = await loadSubjectBirth(supabase, user.id, requestedSubject); + subjectId = resolved.subjectId; + if (!resolved.chartable) return unavailable("birth_profile_incomplete"); + profile = globalBirthProfileFromAccountRow(resolved.row); + } catch (error) { + if (error instanceof ConsultationSubjectError) { + return unavailable(error.code === "subject_unavailable" ? "subject_unavailable" : error.code); + } + throw error; + } const today = calendarDateInTimeZone(new Date(), profile.timezoneId); const payload = await dailyProfilePayload(profile, today).catch(() => null); if (!payload) return unavailable("birth_profile_incomplete"); - const key = dailyStarlanguageCacheKey(user.id, JSON.stringify(payload), today); + const key = dailyStarlanguageCacheKey(user.id, subjectId, JSON.stringify(payload), today); const cached = readCache(key, today); if (cached) return ok(cached.card, `${cached.source}_cache`); diff --git a/frontend/src/app/api/ephemeris/route.ts b/frontend/src/app/api/ephemeris/route.ts index 9f92908a..6ff8552f 100644 --- a/frontend/src/app/api/ephemeris/route.ts +++ b/frontend/src/app/api/ephemeris/route.ts @@ -12,7 +12,8 @@ import { } from "@/lib/ephemeris-view"; import { dailyProfilePayload } from "@/lib/global-birth-payloads"; import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; -import { ACCOUNT_BIRTH_SELECT, globalBirthProfileFromAccountRow } from "@/lib/server-owned-birth-profile"; +import { globalBirthProfileFromAccountRow } from "@/lib/server-owned-birth-profile"; +import { ConsultationSubjectError, loadSubjectBirth, subjectIdFromQuery } from "@/lib/subject-birth"; import { createServerSupabaseClient } from "@/lib/supabase/server"; export const runtime = "nodejs"; @@ -40,13 +41,14 @@ async function fetchEngine(path: string, body: Record, signal: return await response.json() as unknown; } -function natalCacheKey(userId: string, birth: BirthPayload): string { +function natalCacheKey(userId: string, subjectId: string, birth: BirthPayload): string { const month = String(birth.month).padStart(2, "0"); const day = String(birth.day).padStart(2, "0"); const hour = String(birth.hour).padStart(2, "0"); const minute = String(birth.minute).padStart(2, "0"); return chartViewEngineCacheKey({ userId, + subjectId, date: `${birth.year}-${month}-${day}`, time: `${hour}:${minute}`, latitude: birth.lat, @@ -58,8 +60,8 @@ function natalCacheKey(userId: string, birth: BirthPayload): string { }); } -async function fetchNatalChart(userId: string, birth: BirthPayload, signal: AbortSignal): Promise { - const key = natalCacheKey(userId, birth); +async function fetchNatalChart(userId: string, subjectId: string, birth: BirthPayload, signal: AbortSignal): Promise { + const key = natalCacheKey(userId, subjectId, birth); const hit = readChartViewEngineCache(key); if (hit) return hit; const payload = await fetchEngine("/api/chart", { @@ -100,13 +102,22 @@ export async function GET(request: Request) { }, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } }); } - const { data: row } = await supabase - .from("profiles") - .select(ACCOUNT_BIRTH_SELECT) - .eq("id", user.id) - .maybeSingle(); - - const profile = globalBirthProfileFromAccountRow(row); + const subjectId = subjectIdFromQuery(new URL(request.url).searchParams.get("subject")); + let profile: Profile; + try { + const resolved = await loadSubjectBirth(supabase, user.id, subjectId); + profile = globalBirthProfileFromAccountRow(resolved.row); + } catch (error) { + if (error instanceof ConsultationSubjectError) { + const status = error.code === "subject_unavailable" ? 503 : 409; + return NextResponse.json({ + status: "unavailable", + reason: error.code, + subjectId, + }, { status }); + } + throw error; + } const today = calendarDateInTimeZone(new Date(), profile.timezoneId); const date = requestedDate(request, profile.timezoneId); const birth = await dailyProfilePayload(profile, date).catch(() => null); @@ -126,7 +137,7 @@ export async function GET(request: Request) { }); const natalPromise = birth - ? fetchNatalChart(user.id, birth, signal).catch((error: unknown) => { + ? fetchNatalChart(user.id, subjectId, birth, signal).catch((error: unknown) => { console.warn("ephemeris_natal_chart_unavailable", error); return null; }) @@ -164,6 +175,7 @@ export async function GET(request: Request) { const payload: EphemerisOkResponse = { status: "ok", + subjectId, date, today, timezoneId: profile.timezoneId ?? null, diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index f3a75c44..bc0d673c 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -34,6 +34,12 @@ import { projectOrdinarySnippet } from "@/lib/report-public-projection"; import { sanitizedErrorReason } from "@/lib/safe-error-reason"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { + ConsultationSubjectError, + loadSubjectBirth, + subjectIdFromQuery, +} from "@/lib/subject-birth"; +import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver"; export const runtime = "nodejs"; export const maxDuration = 120; @@ -90,17 +96,22 @@ function listReportView( } /** Lightweight report-centre index. Report bodies never leave this endpoint. */ -export async function GET() { +export async function GET(request: Request) { try { const supabase = await createServerSupabaseClient(); const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } - const { data, error } = await supabase + const subjectId = subjectIdFromQuery(new URL(request.url).searchParams.get("subject")); + let listQuery = supabase .from("personal_reports") .select(REPORT_LIST_COLUMNS) - .eq("user_id", user.id) + .eq("user_id", user.id); + listQuery = subjectId === "self" + ? listQuery.is("chart_profile_id", null) + : listQuery.eq("chart_profile_id", subjectId); + const { data, error } = await listQuery .order("created_at", { ascending: false }) .limit(20); if (error) throw error; @@ -169,16 +180,32 @@ export async function POST(request: Request) { const persistence: PersonalReportService = createSupabasePersonalReportService(admin); const adminDataClient = createPersonalReportDataClient(admin); + const rawBody = await request.json().catch(() => null); + const requestedSubject = subjectIdFromQuery( + rawBody && typeof rawBody === "object" && "chartProfileId" in rawBody + ? (rawBody as { chartProfileId?: unknown }).chartProfileId + : rawBody && typeof rawBody === "object" && "subjectId" in rawBody + ? (rawBody as { subjectId?: unknown }).subjectId + : null, + ); let profile: unknown = null; - let profileError: unknown = null; if (userId) { - const result = await supabase - .from("profiles") - .select("name,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,rectification_case_id,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_offset,birth_place_label,ayanamsa") - .eq("id", userId) - .maybeSingle(); - profile = result.data ? await resolveMissingBirthTimezoneOffset(result.data, { useActiveDate: true }) : null; - profileError = result.error; + try { + const resolved = await loadSubjectBirth(supabase, userId, requestedSubject); + if (!resolved.chartable) { + return NextResponse.json({ + error: "请先完善出生资料", + code: REPORT_STABLE_CODES.profileIncomplete, + }, { status: 422 }); + } + profile = await resolveMissingBirthTimezoneOffset(resolved.row, { useActiveDate: true }); + } catch (error) { + if (error instanceof ConsultationSubjectError) { + const failure = consultationSubjectFailureResponse(error); + return NextResponse.json(failure.body, { status: failure.status }); + } + throw error; + } } const catalog = await loadLanguageModelCatalog(); @@ -190,7 +217,7 @@ export async function POST(request: Request) { allowedOrigins: resolveAllowedReportOrigins(process.env), requestHeaders: request.headers, userId, - rawBody: await request.json().catch(() => null), + rawBody, profile, loadCandidateRange: async () => { const profileRow = profile && typeof profile === "object" ? profile as Record : {}; @@ -305,11 +332,6 @@ export async function POST(request: Request) { { status: 503 }, ); } - if (response.status >= 500 && profileError) { - console.error(`[reports] create failed request=${String(deps.rawBody && typeof deps.rawBody === "object" - ? (deps.rawBody as Record).requestId ?? "unknown" - : "unknown")} reason=${sanitizedErrorCode(profileError)}`); - } return toNextResponse(response); } catch (error) { if (isSupabaseConfigurationError(error)) { diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts index 338cc874..e0cc01e5 100644 --- a/frontend/src/app/api/sessions/[id]/route.ts +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -19,6 +19,7 @@ import { selfNameFromResult, sessionBindingRequested, } from "@/lib/consultation-subject-resolver"; +import { CHART_SUBJECT_SELECT } from "@/lib/subject-birth"; type RouteContext = { params: Promise<{ id: string }> }; @@ -121,7 +122,7 @@ export async function PATCH(request: Request, context: RouteContext) { .maybeSingle()), loadOwnedChartProfile: async (chartProfileId) => ownedChartProfileFromResult(await supabase .from("chart_profiles") - .select("id,user_id,role,profile") + .select(CHART_SUBJECT_SELECT) .eq("id", chartProfileId) .eq("user_id", user.id) .maybeSingle()), diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index 6b0e5223..09fded84 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -16,6 +16,7 @@ import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; import { resolveInheritedContextSummary } from "@/lib/session-context-summary"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { CHART_SUBJECT_SELECT, subjectIdFromQuery } from "@/lib/subject-birth"; import { clampSessionLimit, nextSessionCursor, @@ -25,6 +26,7 @@ import { import { excludeEmptyConsultations } from "@/lib/session-list-filter"; const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"; +const SELF_SESSION_FILTER = "chart_profile_id.is.null,chart_profile_id.eq.self"; export async function GET(request: Request) { try { @@ -39,13 +41,15 @@ export async function GET(request: Request) { const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const subjectId = subjectIdFromQuery(url.searchParams.get("subject")); + const unpinned = supabase + .from("chat_sessions") + .select(SESSION_LIST_COLUMNS) + .eq("user_id", user.id) + .eq("pinned", false) + .is("archived_at", null); let pageQuery = excludeEmptyConsultations( - supabase - .from("chat_sessions") - .select(SESSION_LIST_COLUMNS) - .eq("user_id", user.id) - .eq("pinned", false) - .is("archived_at", null), + subjectId === "self" ? unpinned.or(SELF_SESSION_FILTER) : unpinned.eq("chart_profile_id", subjectId), ) .order("updated_at", { ascending: false }) .order("id", { ascending: false }) @@ -58,13 +62,14 @@ export async function GET(request: Request) { let pinnedRows: typeof pageRows = []; if (!cursor) { + const pinned = supabase + .from("chat_sessions") + .select(SESSION_LIST_COLUMNS) + .eq("user_id", user.id) + .eq("pinned", true) + .is("archived_at", null); const { data: pinnedData, error: pinnedError } = await excludeEmptyConsultations( - supabase - .from("chat_sessions") - .select(SESSION_LIST_COLUMNS) - .eq("user_id", user.id) - .eq("pinned", true) - .is("archived_at", null), + subjectId === "self" ? pinned.or(SELF_SESSION_FILTER) : pinned.eq("chart_profile_id", subjectId), ) .order("updated_at", { ascending: false }) .order("id", { ascending: false }); @@ -112,7 +117,7 @@ export async function POST(request: Request) { .maybeSingle()), loadOwnedChartProfile: async (chartProfileId) => ownedChartProfileFromResult(await supabase .from("chart_profiles") - .select("id,user_id,role,profile") + .select(CHART_SUBJECT_SELECT) .eq("id", chartProfileId) .eq("user_id", user.id) .maybeSingle()), diff --git a/frontend/src/app/api/synastry/route.ts b/frontend/src/app/api/synastry/route.ts index 9076ce84..24501e0a 100644 --- a/frontend/src/app/api/synastry/route.ts +++ b/frontend/src/app/api/synastry/route.ts @@ -2,11 +2,8 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { isProductEnabled } from "@/lib/product-access"; import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; -import { - ACCOUNT_BIRTH_SELECT, - globalBirthProfileFromAccountRow, - globalBirthProfileFromStoredChart, -} from "@/lib/server-owned-birth-profile"; +import { globalBirthProfileFromAccountRow } from "@/lib/server-owned-birth-profile"; +import { ConsultationSubjectError, loadSubjectBirth } from "@/lib/subject-birth"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { synastryBirthPayload } from "@/lib/global-birth-payloads"; @@ -151,32 +148,37 @@ export async function POST(request: Request) { return NextResponse.json({ error: "请选择已保存的对方星盘" }, { status: 400 }); } - const { data: selfRow, error: selfError } = await supabase - .from("profiles") - .select(ACCOUNT_BIRTH_SELECT) - .eq("id", user.id) - .maybeSingle(); - if (selfError || !selfRow) { + let selfResolved; + try { + selfResolved = await loadSubjectBirth(supabase, user.id, "self"); + } catch (error) { + if (error instanceof ConsultationSubjectError) { + return NextResponse.json({ error: "请先完成本人出生资料" }, { status: 409 }); + } + throw error; + } + if (!selfResolved.chartable) { return NextResponse.json({ error: "请先完成本人出生资料" }, { status: 409 }); } - const { data: partnerRow, error: partnerError } = await supabase - .from("chart_profiles") - .select("id, role, profile") - .eq("id", parsed.data.partnerChartProfileId) - .eq("user_id", user.id) - .eq("role", "other") - .maybeSingle(); - if (partnerError) { - return NextResponse.json({ error: "暂时无法读取对方星盘" }, { status: 503 }); + let partnerResolved; + try { + partnerResolved = await loadSubjectBirth(supabase, user.id, parsed.data.partnerChartProfileId); + } catch (error) { + if (error instanceof ConsultationSubjectError && error.code === "subject_unavailable") { + return NextResponse.json({ error: "暂时无法读取对方星盘" }, { status: 503 }); + } + if (error instanceof ConsultationSubjectError) { + return NextResponse.json({ error: "请先把对方星盘保存到云端星盘库" }, { status: 404 }); + } + throw error; } - const partnerProfile = globalBirthProfileFromStoredChart(partnerRow?.profile); - if (!partnerProfile) { + if (!partnerResolved.chartable) { return NextResponse.json({ error: "请先把对方星盘保存到云端星盘库" }, { status: 404 }); } const [selfPayload, partnerPayload] = await Promise.all([ - synastryBirthPayload(globalBirthProfileFromAccountRow(selfRow)), - synastryBirthPayload(partnerProfile), + synastryBirthPayload(globalBirthProfileFromAccountRow(selfResolved.row)), + synastryBirthPayload(globalBirthProfileFromAccountRow(partnerResolved.row)), ]); const selfChart = await postPython("/api/chart", selfPayload); const partnerChart = await postPython("/api/chart", partnerPayload); diff --git a/frontend/src/components/account-dialog-overlay.tsx b/frontend/src/components/account-dialog-overlay.tsx index 9d9a3ada..ea845ffe 100644 --- a/frontend/src/components/account-dialog-overlay.tsx +++ b/frontend/src/components/account-dialog-overlay.tsx @@ -1,6 +1,6 @@ "use client"; -import { Settings, UserRound, Users, WalletCards, X } from "lucide-react"; +import { Settings, UserRound, WalletCards, X } from "lucide-react"; import { memo, type MutableRefObject, type ReactNode } from "react"; import { createPortal } from "react-dom"; @@ -27,7 +27,6 @@ const settingsItems: ReadonlyArray<{ icon: typeof UserRound; }> = [ { dialog: "profile", label: "个人资料", icon: UserRound }, - { dialog: "chart-library", label: "星盘资料", icon: Users }, { dialog: "billing", label: "账户与点数", icon: WalletCards }, { dialog: "general", label: "通用设置", icon: Settings }, ]; @@ -56,7 +55,7 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({ const isFormPane = dialog !== "logout" && formPanes.includes(dialog); const renderSettingsContent = () => { if (dialog === "profile") return model.renderProfile(); - if (dialog === "chart-library") return model.renderChartLibrary(); + if (dialog === "chart-library") return null; if (dialog === "billing") return model.renderBilling(); return model.renderGeneral(); }; diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 020fc5d7..a772f0d3 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -79,7 +79,6 @@ export type AppSidebarControls = { onNewChat: () => void; onSelectSession: (sessionId: string) => void; onOpenProfile: () => void; - onOpenChartLibrary: () => void; onOpenGeneral: () => void; onOpenBilling: () => void; onOpenLogout: () => void; @@ -105,6 +104,7 @@ const NAV_PAGES = [ { href: "/chart", label: "星盘", icon: Orbit }, { href: "/ephemeris", label: "星历", icon: CalendarDays }, { href: "/reports", label: "我的报告", icon: FileText }, + { href: "/people", label: "星盘档案", icon: Users }, ] as const; export function AppSidebar({ @@ -142,7 +142,6 @@ export function AppSidebar({ onNewChat, onSelectSession, onOpenProfile, - onOpenChartLibrary, onOpenGeneral, onOpenBilling, onOpenLogout, @@ -364,9 +363,6 @@ export function AppSidebar({ - - diff --git a/frontend/src/components/chart-library-panel.tsx b/frontend/src/components/chart-library-panel.tsx index 9a85188f..a09f937d 100644 --- a/frontend/src/components/chart-library-panel.tsx +++ b/frontend/src/components/chart-library-panel.tsx @@ -4,9 +4,8 @@ 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 { deleteCloudChartProfile, saveCloudChartProfile, updateCloudChartProfile } from "@/lib/home-cloud-sync"; import { - chartRelationshipLabel, formatChartUpdatedAt, missingOtherProfileStep, profileBirthTimeLabel, @@ -20,7 +19,6 @@ import { timestamp, type Account, type ChartLibraryRecord, - type ChartRelationship, type Profile, type SynastryRelationshipType, type SynastryReportCard, @@ -50,8 +48,6 @@ export type ChartLibraryPanelProps = { readonly synastryReportCard: SynastryReportCard | null; readonly setSynastryReportCard: Dispatch>; readonly synastryHistory: SynastryReportCard[]; - readonly activeChartId: string; - readonly setActiveChartId: Dispatch>; readonly saveProfile: (event: FormEvent) => void; readonly draftSynastryQuestionFromChart: (record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) => void; }; @@ -64,7 +60,6 @@ export function ChartLibraryPanel({ profile, profileDraft, setProfileDraft, - setProfile, profileSaving, profileNotice, setProfileNotice, @@ -75,14 +70,12 @@ export function ChartLibraryPanel({ synastryReportCard, setSynastryReportCard, synastryHistory, - activeChartId, - setActiveChartId, saveProfile, draftSynastryQuestionFromChart, }: ChartLibraryPanelProps) { const [view, setView] = useState(CHART_LIBRARY_LIST_VIEW); const [otherProfileDraft, setOtherProfileDraft] = useState(emptyProfile); - const [otherChartRelationship, setOtherChartRelationship] = useState>("other"); + const [editingChartId, setEditingChartId] = useState(null); const selfCharts = chartLibrary.filter((record) => record.role === "self"); @@ -101,13 +94,12 @@ export function ChartLibraryPanel({ setView(CHART_LIBRARY_LIST_VIEW); setEditingChartId(null); setOtherProfileDraft(emptyProfile); - setOtherChartRelationship("other"); setProfileDraft(profile); } async function saveOtherChart(event: FormEvent) { event.preventDefault(); - const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim(), chartRelationship: otherChartRelationship }; + const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim() }; if (missingOtherProfileStep(nextProfile)) { setAccountError("请补全其他星盘的称呼、出生时间和出生地点。"); @@ -118,7 +110,7 @@ export function ChartLibraryPanel({ id: editingChartId || globalThis.crypto.randomUUID(), role: "other", profile: nextProfile, - relationship: otherChartRelationship, + relationship: "other", updatedAt: timestamp(), }; try { @@ -133,8 +125,7 @@ export function ChartLibraryPanel({ return upsertSelfChart(others, selfProfile); }); setOtherProfileDraft(emptyProfile); - setOtherChartRelationship("other"); - setEditingChartId(null); + setEditingChartId(null); setAccountError(""); setProfileNotice(editingChartId ? "已更新其他人的星盘资料。" : "已保存到云端星盘库。请选择关系类型后点击“用于合盘”。"); setView(CHART_LIBRARY_LIST_VIEW); @@ -147,7 +138,6 @@ export function ChartLibraryPanel({ function editOtherChart(record: ChartLibraryRecord) { if (record.role !== "other") return; setOtherProfileDraft(record.profile); - setOtherChartRelationship(record.relationship === "self" ? "other" : record.relationship); setEditingChartId(record.id); setAccountError(""); setProfileNotice(""); @@ -163,27 +153,12 @@ export function ChartLibraryPanel({ 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; - }); + setChartLibrary((current) => current.filter((record) => record.id !== recordId || record.role === "self")); 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 ( @@ -203,8 +178,8 @@ export function ChartLibraryPanel({
{record.profile.name || "未命名"} - {isSelf ? "本人" : chartRelationshipLabel(record.relationship)} - {isSelf ? 当前默认 : null} + {isSelf ? "本人" : "其他人"} +
{effectiveBirthDate(record.profile) || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)} {profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)} @@ -247,8 +222,6 @@ export function ChartLibraryPanel({ value={otherProfileDraft} onChange={setOtherProfileDraft} nameInputId="other-profile-name" - relationship={otherChartRelationship} - onRelationshipChange={setOtherChartRelationship} onSubmit={saveOtherChart} onCancel={goList} cancelLabel="返回列表" @@ -259,7 +232,7 @@ export function ChartLibraryPanel({ {!isAdd && selectedOther ? ( <>
- +
@@ -331,8 +304,7 @@ export function ChartLibraryPanel({ type="button" onClick={() => { setOtherProfileDraft(emptyProfile); - setOtherChartRelationship("other"); - setEditingChartId(null); + setEditingChartId(null); setAccountError(""); setProfileNotice(""); setView({ kind: "add" }); diff --git a/frontend/src/components/chart-profile-form.tsx b/frontend/src/components/chart-profile-form.tsx index b1ee6267..42826b9a 100644 --- a/frontend/src/components/chart-profile-form.tsx +++ b/frontend/src/components/chart-profile-form.tsx @@ -4,7 +4,7 @@ import type { Dispatch, FormEvent, SetStateAction } from "react"; import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field"; import { ProfileFields } from "@/components/profile-fields"; import { resolveAyanamsa } from "@/lib/ayanamsa"; -import type { ChartRelationship, Profile } from "@/lib/home-types"; +import type { Profile } from "@/lib/home-types"; export type ChartProfileFormProps = { readonly title: string; @@ -13,8 +13,6 @@ export type ChartProfileFormProps = { readonly onChange: Dispatch>; readonly nameInputId: string; readonly showAyanamsa?: boolean; - readonly relationship?: Exclude; - readonly onRelationshipChange?: (value: Exclude) => void; readonly onSubmit: (event: FormEvent) => void; readonly onCancel?: () => void; readonly cancelLabel?: string; @@ -30,8 +28,6 @@ export function ChartProfileForm({ onChange, nameInputId, showAyanamsa = false, - relationship, - onRelationshipChange, onSubmit, onCancel, cancelLabel = "取消", @@ -42,18 +38,6 @@ export function ChartProfileForm({ return (
{title}{description}
- {relationship !== undefined && onRelationshipChange ? ( - - ) : null} {showAyanamsa ? ( (null); - const triggerRef = useRef(null); - const listId = useId(); const label = chatProfileDisplayName({ session, status, library }); const selectedId = chatProfileSelectedId(session); - const rows = chatProfilePickerRows({ status, library, selectedId }); const locked = chatSessionSubjectLocked(session); - const deleted = status === "ready" && Boolean(selectedId) && selectedId !== "self" && !rows.some((row) => row.id === selectedId); + const deleted = status === "ready" && Boolean(selectedId) && selectedId !== "self" && !library.some((record) => ( + record.role === "other" && record.id === selectedId + )); - function closePicker() { - setOpen(false); - triggerRef.current?.focus({ preventScroll: true }); - } - - async function choose(row: ChatProfilePickerRow) { - if (!row.complete || pendingId) return; + async function choose(row: ChatProfilePickerRow): Promise { const choice = chatProfileChoice({ session, chartProfileId: row.id, @@ -58,135 +46,23 @@ export function ChatProfilePicker({ libraryReady: status === "ready", selectable: row.complete, }); - if (choice.action === "ignore") { - if (row.selected) closePicker(); - return; - } - setPendingId(row.id); - try { - const ok = choice.action === "bind" - ? await onBindEmpty(choice.chartProfileId) - : await onOpenNew(choice.chartProfileId); - if (ok) closePicker(); - } finally { - setPendingId(null); - } + if (choice.action === "ignore") return false; + setCurrentSubject(choice.chartProfileId); + if (choice.action === "bind") return onBindEmpty(choice.chartProfileId); + return onOpenNew(choice.chartProfileId); } return ( - { - setOpen(next); - if (!next) triggerRef.current?.focus({ preventScroll: true }); - }} - > - - {label} - - - - { - if (event.key !== "Escape") return; - event.preventDefault(); - closePicker(); - }} - > - 当前星盘 - {status === "loading" ?

人物列表还在读

: null} - {status === "failed" ? ( -
-

人物列表暂时读不出来

- -
- ) : null} - {deleted ?

这份资料已经删除,这段对话只能看。

: null} - {rows.length > 0 ? ( -
- {rows.map((row) => ( - void choose(row)} - /> - ))} -
- ) : null} -
-
-
-
- ); -} - -function ProfileRow({ - row, - locked, - requestInFlight, - pending, - onChoose, -}: { - readonly row: ChatProfilePickerRow; - readonly locked: boolean; - readonly requestInFlight: boolean; - readonly pending: boolean; - readonly onChoose: () => void; -}) { - if (!row.complete) { - return ( -
- - {row.name} - {row.detail} - -
- ); - } - - if (locked && !row.selected) { - return ( - - ); - } - - return ( - + ); } diff --git a/frontend/src/components/current-subject-switcher.tsx b/frontend/src/components/current-subject-switcher.tsx new file mode 100644 index 00000000..90e4791f --- /dev/null +++ b/frontend/src/components/current-subject-switcher.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { SubjectSwitcher } from "@/components/subject-switcher"; +import { loadSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject"; +import type { ChatProfileCatalogStatus } from "@/lib/chat-profile-picker-model"; +import type { ChartLibraryRecord } from "@/lib/home-types"; + +export function CurrentSubjectSwitcher() { + const subjectId = useCurrentSubject(); + const [library, setLibrary] = useState([]); + const [status, setStatus] = useState("loading"); + + function reload() { + setStatus("loading"); + void loadSubjectCatalog(true) + .then((next) => { + setLibrary(next); + setStatus("ready"); + }) + .catch(() => setStatus("failed")); + } + + useEffect(() => { + let cancelled = false; + void loadSubjectCatalog() + .then((next) => { + if (cancelled) return; + setLibrary(next); + setStatus("ready"); + }) + .catch(() => { + if (!cancelled) setStatus("failed"); + }); + return () => { + cancelled = true; + }; + }, []); + + const selected = subjectId === "self" + ? library.find((record) => record.role === "self") + : library.find((record) => record.id === subjectId); + const label = selected?.profile.name.trim() + || (subjectId === "self" ? "我" : "未命名资料"); + + return ( + { + setCurrentSubject(row.id); + return true; + }} + onReload={reload} + /> + ); +} diff --git a/frontend/src/components/daily-starlanguage-binder.tsx b/frontend/src/components/daily-starlanguage-binder.tsx new file mode 100644 index 00000000..136c4d0d --- /dev/null +++ b/frontend/src/components/daily-starlanguage-binder.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect } from "react"; + +import { useCurrentSubject } from "@/lib/current-subject"; +import { calendarDateInTimeZone, dailyStarlanguageProfileKey } from "@/lib/daily-starlanguage"; +import type { BootstrapPhase } from "@/lib/home-bootstrap"; +import { + fetchDailyStarlanguage, + readStoredDailyStarlanguage, + writeStoredDailyStarlanguage, +} from "@/lib/home-cloud-sync"; +import { + dailyStarlanguageRetryDelayMs, + type DailyStarlanguageState, + type Profile, +} from "@/lib/home-types"; + +export function DailyStarlanguageBinder(props: { + accountId: string | undefined; + bootstrapPhase: BootstrapPhase; + profile: Profile; + profileComplete: boolean; + natalMinuteAvailable: boolean; + setDailyStarlanguage: (next: DailyStarlanguageState) => void; +}) { + const { accountId, bootstrapPhase, profile, profileComplete, natalMinuteAvailable, setDailyStarlanguage } = props; + const subjectId = useCurrentSubject(); + + useEffect(() => { + if (bootstrapPhase === "account" || !accountId || !profileComplete || !natalMinuteAvailable) return; + const today = calendarDateInTimeZone(new Date(), profile.timezoneId); + const fingerprint = `${subjectId}:${dailyStarlanguageProfileKey(profile)}`; + const stored = readStoredDailyStarlanguage(accountId); + const controller = new AbortController(); + let retryTimer: ReturnType | undefined; + const cachedCard = stored && stored.day === today && stored.fingerprint === fingerprint + ? stored.card + : null; + queueMicrotask(() => { + if (controller.signal.aborted) return; + if (cachedCard) setDailyStarlanguage({ kind: "ready", card: cachedCard }); + else setDailyStarlanguage({ kind: "pending" }); + }); + const attempt = (remainingRetries: number) => { + void fetchDailyStarlanguage(controller.signal, subjectId) + .then((next) => { + if (controller.signal.aborted) return; + if (next.kind === "unavailable" && remainingRetries > 0) { + retryTimer = setTimeout(() => attempt(remainingRetries - 1), dailyStarlanguageRetryDelayMs); + return; + } + if (next.kind === "ready") { + writeStoredDailyStarlanguage(accountId, { day: today, fingerprint, card: next.card }); + setDailyStarlanguage(next); + return; + } + if (stored && stored.day === today && stored.fingerprint === fingerprint) return; + setDailyStarlanguage(next); + }) + .catch(() => { + if (controller.signal.aborted) return; + if (stored && stored.day === today && stored.fingerprint === fingerprint) return; + setDailyStarlanguage({ kind: "unavailable" }); + }); + }; + attempt(1); + return () => { + controller.abort(); + if (retryTimer !== undefined) clearTimeout(retryTimer); + }; + }, [accountId, bootstrapPhase, natalMinuteAvailable, profile, profileComplete, setDailyStarlanguage, subjectId]); + + return null; +} diff --git a/frontend/src/components/ephemeris/ephemeris-page.tsx b/frontend/src/components/ephemeris/ephemeris-page.tsx index 7cfb06ce..540d4164 100644 --- a/frontend/src/components/ephemeris/ephemeris-page.tsx +++ b/frontend/src/components/ephemeris/ephemeris-page.tsx @@ -10,9 +10,11 @@ import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { SecondaryPageShell } from "@/components/secondary-page-shell"; import { setComposerDraft } from "@/lib/composer-draft"; +import { readCurrentSubjectId, useCurrentSubject } from "@/lib/current-subject"; import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract"; import { EPHEMERIS_WAITING_COPY, + invalidateEphemerisPage, peekEphemerisPage, refreshEphemerisPage, writeEphemerisPage, @@ -42,10 +44,11 @@ function snapshotToState(): PageState { const cached = peekEphemerisPage(); if (cached?.kind === "unauthorized") return { phase: "unauthorized" }; if (cached?.kind === "ready") return { phase: "ready", payload: cached.payload }; - return { phase: "open" }; + return { phase: "pending" }; } export function EphemerisPage() { + const subjectId = useCurrentSubject(); const cachedFirst = peekEphemerisPage(); const initialDate = cachedFirst?.kind === "ready" ? cachedFirst.payload.date : null; const [date, setDate] = useState(initialDate); @@ -53,14 +56,21 @@ export function EphemerisPage() { const [knownToday, setKnownToday] = useState( cachedFirst?.kind === "ready" ? cachedFirst.payload.today : null, ); + const [seenSubject, setSeenSubject] = useState(subjectId); const [state, setState] = useState(snapshotToState); + if (seenSubject !== subjectId) { + setSeenSubject(subjectId); + setDate(null); + setKnownToday(null); + setState({ phase: "pending" }); + } const selectedDateRef = useRef(initialDate); const cache = useRef(new Map( cachedFirst?.kind === "ready" ? [[cachedFirst.payload.date, cachedFirst.payload]] : [], )); const load = useCallback(async (nextDate: string, signal: AbortSignal) => { - const response = await fetch(`/api/ephemeris?date=${encodeURIComponent(nextDate)}`, { + const response = await fetch(`/api/ephemeris?date=${encodeURIComponent(nextDate)}&subject=${encodeURIComponent(readCurrentSubjectId())}`, { credentials: "same-origin", headers: { Accept: "application/json" }, signal, @@ -74,6 +84,7 @@ export function EphemerisPage() { const json: unknown = await response.json().catch(() => null); if (signal.aborted || selectedDateRef.current !== nextDate) return; const payload = parseEphemerisOkResponse(json); + if (payload?.subjectId && payload.subjectId !== readCurrentSubjectId()) return; if (!payload || payload.date !== nextDate) { setState({ phase: "failed" }); return; @@ -88,6 +99,8 @@ export function EphemerisPage() { useEffect(() => { let cancelled = false; + cache.current.clear(); + invalidateEphemerisPage(); void refreshEphemerisPage() .then((snapshot) => { if (cancelled) return; @@ -110,7 +123,7 @@ export function EphemerisPage() { return () => { cancelled = true; }; - }, []); + }, [subjectId]); useEffect(() => { if (!date || cache.current.has(date)) return; diff --git a/frontend/src/components/people-home-links.tsx b/frontend/src/components/people-home-links.tsx new file mode 100644 index 00000000..838fb6bf --- /dev/null +++ b/frontend/src/components/people-home-links.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import type { ChartLibraryRecord, Profile, SynastryRelationshipType } from "@/lib/home-types"; + +function stripQuery(key: string) { + const params = new URLSearchParams(window.location.search); + if (!params.has(key)) return; + params.delete(key); + const query = params.toString(); + window.history.replaceState(null, "", `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`); +} + +export function NewChatDeepLink(props: { + ready: boolean; + startNewChat: () => Promise; +}) { + const started = useRef(false); + useEffect(() => { + if (!props.ready || started.current) return; + if (!new URLSearchParams(window.location.search).has("newChat")) return; + started.current = true; + stripQuery("newChat"); + void props.startNewChat(); + }, [props.ready, props.startNewChat]); + return null; +} + +export function SynastryDeepLink(props: { + profile: Profile; + draft: (record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) => Promise; +}) { + const started = useRef(false); + useEffect(() => { + if (started.current) return; + const id = new URLSearchParams(window.location.search).get("synastry"); + if (!id || !props.profile.name.trim() || !props.profile.date) return; + started.current = true; + stripQuery("synastry"); + void (async () => { + const response = await fetch(`/api/chart-profiles/${encodeURIComponent(id)}`, { cache: "no-store" }); + if (!response.ok) return; + const payload = await response.json() as { profile?: { id?: string; profile?: Profile } }; + const row = payload.profile; + if (!row?.id || !row.profile) return; + await props.draft({ + id: row.id, + role: "other", + profile: row.profile, + relationship: "other", + updatedAt: Date.now(), + }, "romance"); + })(); + }, [props.draft, props.profile]); + return null; +} diff --git a/frontend/src/components/people/people-page.tsx b/frontend/src/components/people/people-page.tsx new file mode 100644 index 00000000..fb4b4543 --- /dev/null +++ b/frontend/src/components/people/people-page.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useEffect, useState, type FormEvent } from "react"; + +import { ChartProfileForm } from "@/components/chart-profile-form"; +import { SecondaryPageShell } from "@/components/secondary-page-shell"; +import { setCurrentSubject, useCurrentSubject } from "@/lib/current-subject"; +import { fetchAccount } from "@/lib/home-cloud-sync"; +import { + missingOtherProfileStep, + profileBirthTimeLabel, + profilePlaceLabel, + readProfile, +} from "@/lib/home-profile"; +import { emptyProfile, type Profile } from "@/lib/home-types"; +import { MAX_OTHER_SUBJECTS, MAX_SAVED_SUBJECTS } from "@/lib/subject-birth"; + +type PersonRow = { + readonly id: string; + readonly role: "self" | "other"; + readonly profile: Profile; + readonly incomplete: boolean; +}; + +type Usage = { sessions: number; reports: number }; + +const LIMIT_COPY = "最多保存 5 个人的星盘"; + +function incompleteProfile(profile: Profile): boolean { + return missingOtherProfileStep(profile) !== null; +} + +export function PeoplePage() { + const subjectId = useCurrentSubject(); + const [people, setPeople] = useState([]); + const [status, setStatus] = useState<"loading" | "ready" | "failed">("loading"); + const [selectedId, setSelectedId] = useState("self"); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(emptyProfile); + const [notice, setNotice] = useState(""); + const [pendingDelete, setPendingDelete] = useState(null); + const [saving, setSaving] = useState(false); + + async function load() { + setStatus("loading"); + try { + const [account, response] = await Promise.all([ + fetchAccount(), + fetch("/api/chart-profiles", { cache: "no-store" }), + ]); + if (!response.ok) throw new Error("archive_unavailable"); + const payload = await response.json() as { profiles?: Array<{ id?: string; profile?: unknown }> }; + const self = readProfile(account.profile); + const others = (payload.profiles ?? []).flatMap((row) => { + if (!row.id) return []; + const profile = readProfile(row.profile); + return [{ id: row.id, role: "other" as const, profile, incomplete: incompleteProfile(profile) }]; + }); + setPeople([ + { id: "self", role: "self", profile: self, incomplete: incompleteProfile(self) }, + ...others, + ]); + setStatus("ready"); + } catch { + setStatus("failed"); + } + } + + useEffect(() => { + const timer = window.setTimeout(() => { + void load(); + }, 0); + return () => window.clearTimeout(timer); + }, []); + + const selected = people.find((person) => person.id === selectedId) ?? people[0] ?? null; + const otherCount = people.filter((person) => person.role === "other").length; + const atLimit = otherCount >= MAX_OTHER_SUBJECTS; + + function startAdd() { + if (atLimit) return; + setDraft(emptyProfile); + setNotice(""); + setEditing(true); + setSelectedId(""); + setPendingDelete(null); + } + + async function saveOther(event: FormEvent) { + event.preventDefault(); + if (missingOtherProfileStep(draft) || saving) return; + setSaving(true); + setNotice(""); + try { + const editingOther = selected?.role === "other" ? selected.id : ""; + const response = await fetch(editingOther ? `/api/chart-profiles/${editingOther}` : "/api/chart-profiles", { + method: editingOther ? "PUT" : "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ role: "other", profile: draft }), + }); + const payload = await response.json().catch(() => null) as { error?: string; profile?: { id?: string } } | null; + if (!response.ok) { + setNotice(payload?.error || "保存失败,请重试"); + return; + } + setEditing(false); + setNotice(""); + await load(); + if (payload?.profile?.id) setSelectedId(payload.profile.id); + } finally { + setSaving(false); + } + } + + async function askDelete() { + if (!selected || selected.role !== "other") return; + const response = await fetch(`/api/chart-profiles/${selected.id}`, { cache: "no-store" }); + const payload = await response.json().catch(() => null) as { usage?: Usage } | null; + setPendingDelete(response.ok && payload?.usage ? payload.usage : { sessions: 0, reports: 0 }); + } + + async function confirmDelete() { + if (!selected || selected.role !== "other") return; + const response = await fetch(`/api/chart-profiles/${selected.id}`, { method: "DELETE" }); + if (!response.ok) { + setNotice("删除失败,请重试"); + return; + } + if (subjectId === selected.id) setCurrentSubject("self"); + setPendingDelete(null); + setSelectedId("self"); + await load(); + } + + function openPerson(id: string, href: string) { + setCurrentSubject(id); + window.location.assign(href); + } + + return ( + + {status === "loading" ?

人物列表还在读

: null} + {status === "failed" ? ( +
+

人物列表暂时读不出来

+ +
+ ) : null} + {status === "ready" ? ( +
+
+ {people.map((person) => ( + + ))} + + {atLimit ?

{LIMIT_COPY}

:

还可以保存 {MAX_SAVED_SUBJECTS - people.length} 个人。

} +
+
+ {editing ? ( + void saveOther(event)} + onCancel={() => setEditing(false)} + submitLabel={saving ? "保存中" : "保存"} + submitDisabled={saving || Boolean(missingOtherProfileStep(draft))} + notice={notice} + /> + ) : selected ? ( + <> +

{selected.profile.name.trim() || (selected.role === "self" ? "我" : "未命名资料")}

+ {selected.incomplete ?

这份资料还不完整,补全后才能排盘。

: null} +

填报时间:{selected.profile.reportedTime || "还没填"}

+

排盘时间:{profileBirthTimeLabel(selected.profile)}

+

地点:{profilePlaceLabel(selected.profile)}

+

岁差:{selected.profile.ayanamsa}

+
+ + + + {selected.role === "other" ? ( + + ) : null} +
+ {selected.role === "other" ? ( + + ) : null} + {selected.role === "self" ?

本人不能删除。

: ( + + )} + {pendingDelete && selected.role === "other" ? ( +
+

将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告,无法恢复。

+
+ + +
+
+ ) : null} + {notice ?

{notice}

: null} + + ) : null} +
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/personal-report/generate-personal-report-button.tsx b/frontend/src/components/personal-report/generate-personal-report-button.tsx index ad8c9f6e..61252d5d 100644 --- a/frontend/src/components/personal-report/generate-personal-report-button.tsx +++ b/frontend/src/components/personal-report/generate-personal-report-button.tsx @@ -16,6 +16,7 @@ import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; +import { readCurrentSubjectId } from "@/lib/current-subject"; import { navigateAppPath } from "@/lib/app-navigation"; import { FileText } from "lucide-react"; @@ -31,6 +32,7 @@ export interface PersonalReportCreateRequest { presentationMode: "default"; depth: "standard"; themes: readonly string[]; + chartProfileId?: string; } /** @@ -51,12 +53,14 @@ export function createReportRequestId(): string | null { export function buildPersonalReportCreateRequest( requestId: string, ): PersonalReportCreateRequest { + const subjectId = readCurrentSubjectId(); return { requestId, reportType: "personal_full", presentationMode: "default", depth: "standard", themes: [...DEFAULT_REPORT_THEMES], + ...(subjectId !== "self" ? { chartProfileId: subjectId } : {}), }; } diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index 17e07d32..b15f09de 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -6,6 +6,7 @@ import { InlineSpinner } from "@/components/inline-spinner"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SecondaryPageShell } from "@/components/secondary-page-shell"; +import { useCurrentSubject } from "@/lib/current-subject"; import { GeneratePersonalReportButton } from "./generate-personal-report-button"; import { Button } from "@/components/ui/button"; import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll"; @@ -64,9 +65,15 @@ export function rowMeta(report: Pick & { } export function PersonalReportCenter() { + const subjectId = useCurrentSubject(); + const [seenSubject, setSeenSubject] = useState(subjectId); const [state, setState] = useState(() => snapshotToState(peekReportsPage())); const deletedIds = useRef(new Set()); const cancelled = useRef(false); + if (seenSubject !== subjectId) { + setSeenSubject(subjectId); + setState({ phase: "loading", reports: [] }); + } const applySnapshot = useCallback((snapshot: ReportsPageSnapshot) => { if (snapshot.kind === "unauthorized") { @@ -100,12 +107,13 @@ export function PersonalReportCenter() { useEffect(() => { cancelled.current = false; - const timer = window.setTimeout(() => void load(), 0); + invalidateReportsPage(); + const timer = window.setTimeout(() => void load(true), 0); return () => { window.clearTimeout(timer); cancelled.current = true; }; - }, [load]); + }, [load, subjectId]); const hasGenerating = state.reports.some((report) => report.status === "generating"); const refresh = useCallback(() => void load(), [load]); diff --git a/frontend/src/components/secondary-header.tsx b/frontend/src/components/secondary-header.tsx index 895715f7..ad241911 100644 --- a/frontend/src/components/secondary-header.tsx +++ b/frontend/src/components/secondary-header.tsx @@ -25,17 +25,20 @@ export type SecondaryHeaderProps = { readonly actions?: ReactNode; /** A quiet chip beside the title — birth line, report date. */ readonly note?: ReactNode; + /** Person switcher beside the title. Not the birth-line note. */ + readonly besideTitle?: ReactNode; /** Keep the note slot so a late birth line cannot collapse the 46px row. */ readonly reserveNote?: boolean; }; -export function SecondaryHeader({ title, actions, note, reserveNote = false }: SecondaryHeaderProps) { +export function SecondaryHeader({ title, actions, note, besideTitle, reserveNote = false }: SecondaryHeaderProps) { const showNote = Boolean(note) || reserveNote; return (
{title} + {besideTitle} {showNote ? ( {note ?? "\u00a0"} diff --git a/frontend/src/components/secondary-page-shell.tsx b/frontend/src/components/secondary-page-shell.tsx index 965e48c5..9da7a2bd 100644 --- a/frontend/src/components/secondary-page-shell.tsx +++ b/frontend/src/components/secondary-page-shell.tsx @@ -2,11 +2,13 @@ import type { ReactNode } from "react"; +import { CurrentSubjectSwitcher } from "@/components/current-subject-switcher"; import { SecondaryHeader } from "@/components/secondary-header"; export type SecondaryPageShellProps = { readonly title: string; readonly note?: ReactNode; + readonly besideTitle?: ReactNode; readonly actions?: ReactNode; readonly waiting?: string | null; readonly children?: ReactNode; @@ -23,13 +25,20 @@ export type SecondaryPageShellProps = { export function SecondaryPageShell({ title, note, + besideTitle, actions, waiting, children, }: SecondaryPageShellProps) { return ( <> - + } + reserveNote + actions={actions} + />
{waiting ?

{waiting}

: children}
diff --git a/frontend/src/components/starter-home.tsx b/frontend/src/components/starter-home.tsx index 234f3818..e9594ad9 100644 --- a/frontend/src/components/starter-home.tsx +++ b/frontend/src/components/starter-home.tsx @@ -1,6 +1,7 @@ "use client"; import { Clock3, Sparkles } from "lucide-react"; +import { useCurrentSubject } from "@/lib/current-subject"; import type { DailyStarlanguageState } from "@/lib/home-types"; import type { RectificationEntrySummary } from "@/lib/rectification-entry"; @@ -67,6 +68,8 @@ export function StarterEntries({ rectificationSurfaceOpen, rectificationErrorMessage, }: StarterEntriesProps) { + const subjectId = useCurrentSubject(); + const rectificationForSelf = subjectId === "self"; const dailyLabel = natalMinuteAvailable ? "今日星语" : "每日运势"; /* The trend line is the only part that arrives late. It stays a live region so a screen reader hears it settle, but it is a tooltip-rank hint now, not a @@ -75,7 +78,9 @@ export function StarterEntries({ /* Three branches, same as the card it replaces: an unfinished case, a finished one that can be redone, and a first run. The label itself is server-driven from the entry summary (`rectificationCardAction`), not from the session list. */ - const rectificationHint = rectificationEntrySummary?.hasResumableCase + const rectificationHint = !rectificationForSelf + ? "生时校正暂时只支持本人" + : rectificationEntrySummary?.hasResumableCase ? "未完成的那次可以从左侧继续" : rectificationCardAction === "restart" ? "上次已经校正完,可以拿最新资料再来一次" @@ -99,7 +104,7 @@ export function StarterEntries({ type="button" aria-label={rectificationCardLabel} data-opening={rectificationLoading ? "true" : undefined} - disabled={productEntrypointsDisabled || rectificationLoading || rectificationMutationPending} + disabled={productEntrypointsDisabled || rectificationLoading || rectificationMutationPending || !rectificationForSelf} onClick={() => void openRectificationFromHomepage()} >