perf(frontend): split chat streaming, load Inter, isolate admin CSS
Independent Staging Quality Gate / validate (push) Successful in 10m14s
Independent Staging Quality Gate / publish (push) Successful in 11m5s

Settled messages no longer rebuild on every token, Inter is actually
requested, and admin routes drop the 33 KB chat stylesheet. Root
force-dynamic is gone so public shells can prerender without changing
the no-store Cache-Control contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-29 03:39:43 +08:00
co-authored by Cursor
parent bf697d71a2
commit 3198fb6b2b
41 changed files with 1528 additions and 307 deletions
+9
View File
@@ -1,3 +1,12 @@
:root {
--font-body: StyreneB, var(--font-inter, Inter), system-ui, sans-serif;
--font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
}
html, body {
width: 100%;
min-height: 100%;
margin: 0;
}
.admin-app-shell {
min-height: 100dvh;
background: #f3f2ee;
+1 -1
View File
@@ -89,7 +89,7 @@
--shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2);
--shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18);
--font-display: "Tiempos Headline", "Songti SC", STSong, "Noto Serif CJK SC", Georgia, serif;
--font-body: StyreneB, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-body: StyreneB, var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
--type-display-lg: 48px;
--type-display-md: 36px;
+7 -4
View File
@@ -1,11 +1,14 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import Script from "next/script";
import { Toaster } from "@/components/ui/sonner";
import { StaleClientRecovery } from "@/components/stale-client-recovery";
import "./globals.css";
import "./birth-time-choice.css";
export const dynamic = "force-dynamic";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
export const metadata: Metadata = {
title: "Jyotisha · 印度占星",
@@ -24,7 +27,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
&& process.env.NEXT_PUBLIC_ENABLE_REACT_DEVTOOLS === "1";
return (
<html lang="zh-CN" suppressHydrationWarning>
<html lang="zh-CN" className={inter.variable} suppressHydrationWarning>
<head>
{enableReactDevTools && (
<>
+1
View File
@@ -3,6 +3,7 @@ import { readIdentityConfig } from "@/modules/identity/config";
import { resolveIdentitySurface } from "@/modules/identity/host";
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import "../site-styles";
export const dynamic = "force-dynamic";
@@ -1,5 +1,6 @@
"use client";
import "@/app/site-styles";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
+1
View File
@@ -1,5 +1,6 @@
"use client";
import "@/app/site-styles";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import Link from "next/link";
+251 -243
View File
@@ -1,11 +1,13 @@
"use client";
import "@/app/site-styles";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { ArrowDown, ArrowUpRight, Sparkles, X } from "lucide-react";
import { ArrowDown, ArrowUpRight, Sparkles } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
import { AppSidebar } from "@/components/app-sidebar";
import { UserAvatar } from "@/components/user-avatar";
import {
@@ -25,12 +27,12 @@ import {
} from "@/lib/rectification-entry";
import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
import { ChatMessageContent } from "@/components/chat-message-content";
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
import { AgentAvatar } from "@/components/chat-message-row";
import {
ChatMessageActions,
toggleChatMessageFeedback,
type ChatMessageFeedback,
} from "@/components/chat-message-actions";
import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-transcript";
import { ModelSelector } from "@/components/model-selector";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
import { BirthPlacePicker } from "@/components/birth-place-picker";
@@ -39,9 +41,7 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
import { clearStaleClientReload } from "@/lib/stale-client-recovery";
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
import { ConversationFollowUps } from "@/components/conversation-follow-ups";
import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply";
import { deriveConsultationFollowUps } from "@/lib/consultation-follow-ups";
import {
beamAvatarPalettes,
beamAvatarSchema,
@@ -100,7 +100,8 @@ import {
BALANCE_CHANGED_EVENT,
membershipHref,
} from "@/lib/membership";
import { chatMessageViews, nextActivityView, activityCompletedTrail, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
import { nextActivityView, activityCompletedTrail, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
import { chartLibrarySessionBranch } from "@/lib/chart-library-session";
import {
emptyConsultationTimeline,
reduceConsultationTimeline,
@@ -1094,7 +1095,6 @@ export default function Home() {
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
const [chartLibraryOpen, setChartLibraryOpen] = useState(false);
const [synastryRelationshipType, setSynastryRelationshipType] = useState<SynastryRelationshipType>("romance");
const [synastryPendingId, setSynastryPendingId] = useState<string | null>(null);
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
@@ -1178,6 +1178,15 @@ export default function Home() {
const modelSelectionVersions = useRef(new Map<string, number>());
const activeSessionIdRef = useRef("");
const chartLibraryLoadedAccount = useRef("");
const openChartLibraryOnDialog = useRef(false);
const accountOverlayRef = useRef<AccountOverlayModel | null>(null);
const accountOverlayEpochRef = useRef(0);
const chatActionsRef = useRef<ChatTranscriptActions>({
onFeedback() {},
onCopy() {},
onRegenerate() {},
onFollowUp() {},
});
const activeOnboardingRequestIdentity = useRef("");
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
@@ -1300,43 +1309,41 @@ export default function Home() {
}, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]);
useEffect(() => {
if (!accountId) {
const branch = chartLibrarySessionBranch(accountId, chartLibraryLoadedAccount.current);
if (branch === "clear" || !accountId) {
setChartLibrary([]);
setSynastryHistory([]);
chartLibraryLoadedAccount.current = "";
return;
}
if (chartLibraryLoadedAccount.current === accountId) return;
chartLibraryLoadedAccount.current = accountId;
setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profile));
setSynastryHistory(readSynastryHistory(accountId));
void fetchCloudChartLibrary()
.then((cloudLibrary) => {
setChartLibrary(() => {
const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
if (branch === "hydrate-then-persist") {
chartLibraryLoadedAccount.current = accountId;
setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profile));
setSynastryHistory(readSynastryHistory(accountId));
void fetchCloudChartLibrary()
.then((cloudLibrary) => {
setChartLibrary(() => {
const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
})
.catch(() => {
// Cloud chart library is best-effort; local library remains usable.
});
})
.catch(() => {
// Cloud chart library is best-effort; local library remains usable.
});
void fetchCloudSynastryHistory()
.then((cloudHistory) => {
setSynastryHistory((current) => {
const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const));
const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10);
writeSynastryHistory(accountId, next);
return next;
void fetchCloudSynastryHistory()
.then((cloudHistory) => {
setSynastryHistory((current) => {
const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const));
const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10);
writeSynastryHistory(accountId, next);
return next;
});
})
.catch(() => {
// Cloud synastry history is best-effort; local history remains usable.
});
})
.catch(() => {
// Cloud synastry history is best-effort; local history remains usable.
});
}, [accountId, profile]);
useEffect(() => {
if (!accountId) return;
}
setChartLibrary((current) => {
const next = upsertSelfChart(current, profile);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
@@ -3745,6 +3752,188 @@ export default function Home() {
}));
const modalOpen = activeAccountDialog !== null || onboardingPaywallOpen;
chatActionsRef.current = {
onFeedback(feedbackKey, requested) {
setMessageFeedback((current) => {
const next = { ...current };
const value = toggleChatMessageFeedback(current[feedbackKey], requested);
if (value) next[feedbackKey] = value;
else delete next[feedbackKey];
return next;
});
},
onCopy(feedbackKey, text) {
void copyAssistantMessage(feedbackKey, text);
},
onRegenerate(renderKey) {
regenerateLatestAnswer(renderKey);
},
onFollowUp(question) {
void send(question, activeSession?.theme);
},
};
if (activeAccountDialog !== null) accountOverlayEpochRef.current += 1;
accountOverlayRef.current = {
title: activeAccountDialog ? accountDialogTitles[activeAccountDialog] : "",
dialogClass: activeAccountDialog ? accountDialogClasses[activeAccountDialog] : "",
signingOut,
close: closeAccountDialog,
takeOpenLibraryRequest() {
if (!openChartLibraryOnDialog.current) return false;
openChartLibraryOnDialog.current = false;
return true;
},
overlayRef: accountDialog,
closeButtonRef: closeButton,
renderProfile(library) {
return (
<>
{accountError && <p className="form-error" role="alert">{accountError}</p>}
{account.avatar && (
<section className="sheet-section avatar-section" aria-labelledby="avatar-section-title">
<div className="section-heading">
<b id="avatar-section-title"></b>
<small>Beam </small>
</div>
<div className="avatar-editor">
<UserAvatar avatar={account.avatar} size={96} label="当前头像预览" />
<div className="avatar-editor-controls">
<div className="avatar-palette-list" role="radiogroup" aria-label="头像配色">
{beamAvatarPalettes.map((palette, index) => (
<button
key={palette.name}
className="avatar-palette"
type="button"
role="radio"
aria-checked={account.avatar?.palette === index}
aria-label={palette.name}
title={palette.name}
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "set_palette", palette: index })}
>
{palette.colors.map((color) => <span key={color} style={{ backgroundColor: color }} />)}
</button>
))}
</div>
<button
className="button-secondary avatar-randomize"
type="button"
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "randomize" })}
>
{avatarSaving ? "保存中" : "换一个形象"}
</button>
</div>
</div>
{avatarNotice && <p className="form-success avatar-notice" role="status">{avatarNotice}</p>}
</section>
)}
<section className="sheet-section birth-section">
<div className="section-heading"><b></b><small></small></div>
<div className="default-chart-card" aria-label="当前默认星盘">
<div>
<span></span>
<strong>{profileDraft.name.trim() || "未命名"}</strong>
<small></small>
</div>
<button className="button-secondary" type="button" onClick={library.toggle}></button>
</div>
{library.open && (
<div className="chart-library-panel" aria-label="星盘库">
<div className="chart-library-group">
<b></b>
{chartLibrary.filter((record) => record.role === "self").map((record) => (
<article className="chart-library-item" key={record.id}>
<div>
<strong>{record.profile.name || "未命名"}</strong>
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<span></span>
</article>
))}
</div>
<div className="chart-library-group">
<b></b>
{chartLibrary.filter((record) => record.role === "other").length === 0 && <p className="empty-library-copy"></p>}
{chartLibrary.filter((record) => record.role === "other").map((record) => (
<article className="chart-library-item" key={record.id}>
<div>
<strong>{record.profile.name || "未命名"}</strong>
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<div className="chart-library-actions">
<select aria-label="关系类型" value={synastryRelationshipType} onChange={(event) => setSynastryRelationshipType(event.target.value as SynastryRelationshipType)} disabled={synastryPendingId !== null}>
<option value="romance"></option>
<option value="business"></option>
<option value="family">/</option>
<option value="general"></option>
</select>
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === record.id ? "正在计算合盘..." : "用于合盘"}</button>
<button className="button-secondary" type="button" onClick={() => void makeDefaultChart(record)} disabled={profileSaving}></button>
<button className="button-secondary danger-button" type="button" onClick={() => deleteOtherChart(record.id)}></button>
</div>
</article>
))}
</div>
{synastryReportCard && (
<article className="synastry-report-card" aria-label="合盘结果摘要">
<div>
<span></span>
<strong>{synastryReportCard.partnerName}</strong>
<small>Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"}</small>
</div>
{synastryReportCard.headline && <p>{synastryReportCard.headline}</p>}
<details>
<summary></summary>
<ul>
{(synastryReportCard.strengths || []).map((item) => <li key={item}>{item}</li>)}
{(synastryReportCard.risks || []).map((item) => <li key={item}>{item}</li>)}
</ul>
<small>{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"}</small>
</details>
</article>
)}
{synastryHistory.length > 0 && (
<div className="synastry-history-list" aria-label="合盘历史">
<b></b>
{synastryHistory.slice(0, 5).map((item) => (
<button key={item.id} type="button" className="synastry-history-item" onClick={() => setSynastryReportCard(item)}>
<span>{item.partnerName}</span>
<small>Ashtakoot {item.score ?? "?"}/{item.maxScore ?? "?"} · {item.assessment || item.scoreBand || "待解释"}</small>
</button>
))}
</div>
)}
<form className="profile-form chart-library-form" onSubmit={saveOtherChart}>
<div className="section-heading"><b></b><small></small></div>
<ProfileFields value={otherProfileDraft} onChange={setOtherProfileDraft} nameInputId="other-profile-name" />
<button className="button-primary save-profile" type="submit" disabled={!account}></button>
</form>
</div>
)}
</section>
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
<form className="profile-form" onSubmit={saveProfile}>
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="profile-name" />
<button className="button-primary save-profile" type="submit" disabled={profileSaving || !account}>{profileSaving ? "保存中" : "保存出生资料"}</button>
</form>
</>
);
},
renderLogout() {
return (
<>
<p className="logout-copy">退</p>
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="dialog-actions">
<button className="button-secondary" type="button" onClick={closeAccountDialog} disabled={signingOut}></button>
<button className="button-primary danger-primary" type="button" onClick={() => void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "确认退出"}</button>
</div>
</>
);
},
};
return (
<SidebarProvider escapeBlocked={accountMenuOpen || modalOpen}>
<main className="chat-app">
@@ -3788,11 +3977,11 @@ export default function Home() {
onOpenReports={() => router.push("/reports")}
onSelectSession={selectSession}
onSelectChart={() => {
setChartLibraryOpen(true);
openChartLibraryOnDialog.current = true;
openAccountDialog("profile");
}}
onAddChart={() => {
setChartLibraryOpen(true);
openChartLibraryOnDialog.current = true;
openAccountDialog("profile");
}}
onOpenProfile={() => openAccountDialog("profile")}
@@ -4007,55 +4196,23 @@ export default function Home() {
</div>
) : (
<div className="message-list" aria-busy={isLoading}>
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity, activeStreamingThinking, activeStreamingSections, activeStreamingTimeline).map((message, index, views) => {
const showActions = message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
const feedbackKey = `${activeSession.id}:${message.renderKey}`;
const latestRegeneratableKey = !isLoading && !cancellationPending
? [...views].reverse().find((item) => (
item.role === "assistant" && item.state === "settled" && Boolean(item.text)
))?.renderKey
: undefined;
const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : "";
const followUps = showActions
&& message.renderKey === latestRegeneratableKey
&& activeSession.sessionType === "consultation"
&& previousQuestion
? deriveConsultationFollowUps({
question: previousQuestion,
answer: message.text,
theme: activeSession.theme,
entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null,
})
: [];
return (
<div className="message-entry" key={message.renderKey}>
<ChatMessageRow message={message} />
{showActions && (
<ChatMessageActions
feedback={messageFeedback[feedbackKey]}
copied={copiedMessageKey === feedbackKey}
canRegenerate={message.renderKey === latestRegeneratableKey}
onFeedback={(requested) => setMessageFeedback((current) => {
const next = { ...current };
const value = toggleChatMessageFeedback(current[feedbackKey], requested);
if (value) next[feedbackKey] = value;
else delete next[feedbackKey];
return next;
})}
onCopy={() => void copyAssistantMessage(feedbackKey, message.text)}
onRegenerate={() => regenerateLatestAnswer(message.renderKey)}
/>
)}
<ConversationFollowUps
questions={followUps}
disabled={productEntrypointsDisabled}
onSelect={(question) => void send(question, activeSession.theme)}
/>
</div>
);
})}
<ChatTranscript
messages={activeSession.messages}
loading={isLoading}
streamingText={activeStreamingText}
streamingActivity={activeStreamingActivity}
streamingThinking={activeStreamingThinking}
streamingSections={activeStreamingSections}
streamingTimeline={activeStreamingTimeline}
sessionId={activeSession.id}
sessionType={activeSession.sessionType}
theme={activeSession.theme}
messageFeedback={messageFeedback}
copiedMessageKey={copiedMessageKey}
cancellationPending={cancellationPending}
productEntrypointsDisabled={productEntrypointsDisabled}
actionsRef={chatActionsRef}
/>
{activeError && <p className="error-message" role="alert">{activeError}</p>}
</div>
)}
@@ -4145,161 +4302,12 @@ export default function Home() {
</div>}
</SidebarInset>
{activeAccountDialog !== null && (
<div className="account-modal-overlay" onMouseDown={closeAccountDialog}>
<section className={`account-modal ${accountDialogClasses[activeAccountDialog]}`} ref={accountDialog} role="dialog" aria-modal="true" aria-labelledby="account-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
<header className="account-modal-header">
<h2 id="account-dialog-title">{accountDialogTitles[activeAccountDialog]}</h2>
<button className="dialog-close" ref={closeButton} aria-label="关闭" type="button" onClick={closeAccountDialog} disabled={signingOut}><X aria-hidden="true" /></button>
</header>
{activeAccountDialog === "profile" && (
<>
{accountError && <p className="form-error" role="alert">{accountError}</p>}
{account.avatar && (
<section className="sheet-section avatar-section" aria-labelledby="avatar-section-title">
<div className="section-heading">
<b id="avatar-section-title"></b>
<small>Beam </small>
</div>
<div className="avatar-editor">
<UserAvatar avatar={account.avatar} size={96} label="当前头像预览" />
<div className="avatar-editor-controls">
<div className="avatar-palette-list" role="radiogroup" aria-label="头像配色">
{beamAvatarPalettes.map((palette, index) => (
<button
key={palette.name}
className="avatar-palette"
type="button"
role="radio"
aria-checked={account.avatar?.palette === index}
aria-label={palette.name}
title={palette.name}
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "set_palette", palette: index })}
>
{palette.colors.map((color) => <span key={color} style={{ backgroundColor: color }} />)}
</button>
))}
</div>
<button
className="button-secondary avatar-randomize"
type="button"
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "randomize" })}
>
{avatarSaving ? "保存中" : "换一个形象"}
</button>
</div>
</div>
{avatarNotice && <p className="form-success avatar-notice" role="status">{avatarNotice}</p>}
</section>
)}
<section className="sheet-section birth-section">
<div className="section-heading"><b></b><small></small></div>
<div className="default-chart-card" aria-label="当前默认星盘">
<div>
<span></span>
<strong>{profileDraft.name.trim() || "未命名"}</strong>
<small></small>
</div>
<button className="button-secondary" type="button" onClick={() => setChartLibraryOpen((current) => !current)}></button>
</div>
{chartLibraryOpen && (
<div className="chart-library-panel" aria-label="星盘库">
<div className="chart-library-group">
<b></b>
{chartLibrary.filter((record) => record.role === "self").map((record) => (
<article className="chart-library-item" key={record.id}>
<div>
<strong>{record.profile.name || "未命名"}</strong>
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<span></span>
</article>
))}
</div>
<div className="chart-library-group">
<b></b>
{chartLibrary.filter((record) => record.role === "other").length === 0 && <p className="empty-library-copy"></p>}
{chartLibrary.filter((record) => record.role === "other").map((record) => (
<article className="chart-library-item" key={record.id}>
<div>
<strong>{record.profile.name || "未命名"}</strong>
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<div className="chart-library-actions">
<select aria-label="关系类型" value={synastryRelationshipType} onChange={(event) => setSynastryRelationshipType(event.target.value as SynastryRelationshipType)} disabled={synastryPendingId !== null}>
<option value="romance"></option>
<option value="business"></option>
<option value="family">/</option>
<option value="general"></option>
</select>
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === record.id ? "正在计算合盘..." : "用于合盘"}</button>
<button className="button-secondary" type="button" onClick={() => void makeDefaultChart(record)} disabled={profileSaving}></button>
<button className="button-secondary danger-button" type="button" onClick={() => deleteOtherChart(record.id)}></button>
</div>
</article>
))}
</div>
{synastryReportCard && (
<article className="synastry-report-card" aria-label="合盘结果摘要">
<div>
<span></span>
<strong>{synastryReportCard.partnerName}</strong>
<small>Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"}</small>
</div>
{synastryReportCard.headline && <p>{synastryReportCard.headline}</p>}
<details>
<summary></summary>
<ul>
{(synastryReportCard.strengths || []).map((item) => <li key={item}>{item}</li>)}
{(synastryReportCard.risks || []).map((item) => <li key={item}>{item}</li>)}
</ul>
<small>{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"}</small>
</details>
</article>
)}
{synastryHistory.length > 0 && (
<div className="synastry-history-list" aria-label="合盘历史">
<b></b>
{synastryHistory.slice(0, 5).map((item) => (
<button key={item.id} type="button" className="synastry-history-item" onClick={() => setSynastryReportCard(item)}>
<span>{item.partnerName}</span>
<small>Ashtakoot {item.score ?? "?"}/{item.maxScore ?? "?"} · {item.assessment || item.scoreBand || "待解释"}</small>
</button>
))}
</div>
)}
<form className="profile-form chart-library-form" onSubmit={saveOtherChart}>
<div className="section-heading"><b></b><small></small></div>
<ProfileFields value={otherProfileDraft} onChange={setOtherProfileDraft} nameInputId="other-profile-name" />
<button className="button-primary save-profile" type="submit" disabled={!account}></button>
</form>
</div>
)}
</section>
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
<form className="profile-form" onSubmit={saveProfile}>
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="profile-name" />
<button className="button-primary save-profile" type="submit" disabled={profileSaving || !account}>{profileSaving ? "保存中" : "保存出生资料"}</button>
</form>
</>
)}
{activeAccountDialog === "logout" && (
<>
<p className="logout-copy">退</p>
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="dialog-actions">
<button className="button-secondary" type="button" onClick={closeAccountDialog} disabled={signingOut}></button>
<button className="button-primary danger-primary" type="button" onClick={() => void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "确认退出"}</button>
</div>
</>
)}
</section>
</div>
)}
<AccountDialogOverlay
open={activeAccountDialog !== null}
dialog={activeAccountDialog}
openEpoch={activeAccountDialog !== null ? accountOverlayEpochRef.current : 0}
modelRef={accountOverlayRef}
/>
{onboardingPaywallOpen && account && (
<OnboardingRedeemPaywall
credits={account.credits}
@@ -5,6 +5,7 @@ import { useEffect } from "react";
import { TriangleAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import "../../site-styles";
export default function ReportError({
error,
@@ -1,4 +1,5 @@
import { LoaderCircle } from "lucide-react";
import "../../site-styles";
export default function ReportLoading() {
return (
@@ -3,6 +3,7 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import "../../site-styles";
export default function ReportNotFound() {
return (
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { PersonalReportPage } from "@/components/personal-report/personal-report-page";
import "../../site-styles";
export const dynamic = "force-dynamic";
+1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { PersonalReportCenter } from "@/components/personal-report/personal-report-center";
import "../site-styles";
export const dynamic = "force-dynamic";
+2
View File
@@ -0,0 +1,2 @@
import "./globals.css";
import "./birth-time-choice.css";