fix(web): keep the settings dialog one size and move billing into it (BUG-554)

The four account panes now share a fixed frame, chart profiles open as list then detail, and membership lives in the homepage dialog instead of a separate page.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-06 18:29:51 +08:00
co-authored by Cursor
parent 40bd0e9372
commit dc6598d7e4
48 changed files with 1962 additions and 1411 deletions
@@ -3,7 +3,7 @@
import { ChevronRight, Settings, UserRound, Users, WalletCards, X } from "lucide-react";
import { memo, type MutableRefObject, type ReactNode } from "react";
export type AccountSettingsDialog = "profile" | "chart-library" | "general";
export type AccountSettingsDialog = "profile" | "chart-library" | "billing" | "general";
export type AccountOverlayModel = Readonly<{
title: string;
@@ -11,11 +11,11 @@ export type AccountOverlayModel = Readonly<{
signingOut: boolean;
close: () => void;
navigate: (dialog: AccountSettingsDialog) => void;
openRedeem: () => void;
overlayRef: MutableRefObject<HTMLElement | null>;
closeButtonRef: MutableRefObject<HTMLButtonElement | null>;
renderProfile: () => ReactNode;
renderChartLibrary: () => ReactNode;
renderBilling: () => ReactNode;
renderGeneral: () => ReactNode;
renderLogout: () => ReactNode;
}>;
@@ -27,6 +27,7 @@ const settingsItems: ReadonlyArray<{
}> = [
{ dialog: "profile", label: "个人资料", icon: UserRound },
{ dialog: "chart-library", label: "星盘资料", icon: Users },
{ dialog: "billing", label: "账户与点数", icon: WalletCards },
{ dialog: "general", label: "通用设置", icon: Settings },
];
@@ -36,7 +37,7 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
model,
}: Readonly<{
open: boolean;
dialog: "profile" | "chart-library" | "general" | "logout" | null;
dialog: "profile" | "chart-library" | "billing" | "general" | "logout" | null;
model: AccountOverlayModel | null;
}>) {
if (!open || dialog === null || model === null) return null;
@@ -45,6 +46,7 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
const renderSettingsContent = () => {
if (dialog === "profile") return model.renderProfile();
if (dialog === "chart-library") return model.renderChartLibrary();
if (dialog === "billing") return model.renderBilling();
return model.renderGeneral();
};
@@ -87,11 +89,6 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
<ChevronRight aria-hidden="true" />
</button>
))}
<button className="settings-dialog-nav-item" type="button" onClick={model.openRedeem}>
<WalletCards aria-hidden="true" />
<span></span>
<ChevronRight aria-hidden="true" />
</button>
</nav>
<div className="settings-dialog-content">{renderSettingsContent()}</div>
</div>
+3 -3
View File
@@ -77,7 +77,7 @@ export type AppSidebarProps = {
onOpenProfile: () => void;
onOpenChartLibrary: () => void;
onOpenGeneral: () => void;
onOpenRedeem: () => void;
onOpenBilling: () => void;
onOpenLogout: () => void;
};
@@ -101,7 +101,7 @@ export function AppSidebar({
onOpenProfile,
onOpenChartLibrary,
onOpenGeneral,
onOpenRedeem,
onOpenBilling,
onOpenLogout,
}: AppSidebarProps) {
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
@@ -341,7 +341,7 @@ export function AppSidebar({
<Menu.Item className="account-menu-item" onClick={onOpenGeneral}>
<Settings aria-hidden="true" /><span></span><ChevronRight aria-hidden="true" />
</Menu.Item>
<Menu.Item className="account-menu-item" onClick={onOpenRedeem}>
<Menu.Item className="account-menu-item" onClick={onOpenBilling}>
<WalletCards aria-hidden="true" /><span></span><small>{account.credits} </small>
</Menu.Item>
<Menu.Separator className="account-menu-separator" />
+338
View File
@@ -0,0 +1,338 @@
"use client";
import { useEffect } from "react";
import { CheckCircle2, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBillingPanel } from "@/hooks/use-billing-panel";
import type { Account } from "@/lib/home-types";
import {
entitlementLabel,
formatMembershipDate,
formatPrice,
formatProductDuration,
orderStatusLabel,
planAlias,
planDisplayLabel,
productAudience,
selectMembershipPlans,
type BillingPaneTab,
type MembershipPlanAlias,
} from "@/lib/membership";
export type BillingPanelProps = {
readonly account: Account;
readonly highlightedPlan: MembershipPlanAlias | null;
readonly initialTab: BillingPaneTab;
};
function payingLabel(paying: boolean, idle: string) {
return paying ? "跳转收银台…" : idle;
}
export function BillingPanel({ account: seedAccount, highlightedPlan, initialTab }: BillingPanelProps) {
const billing = useBillingPanel({ account: seedAccount, highlightedPlan, initialTab });
const {
account,
activeSubscription,
closeRedeem,
createPayment,
currentProductCode,
fetchOrders,
highlightedCardRef,
orders,
ordersState,
packagesError,
packagesSettled,
payingProductId,
paymentEnabled,
paymentError,
paymentOrder,
paymentPackages,
redeem,
redeemCode,
redeemDone,
redeemError,
redeeming,
redeemMessage,
selectedProductId,
setRedeemCode,
setRedeemDone,
setRedeemError,
setRedeemMessage,
} = billing;
const plans = selectMembershipPlans(paymentPackages);
const creditPacks = paymentPackages.filter((product) => product.productType === "credit_pack");
const credits = account?.credits ?? seedAccount.credits;
useEffect(() => {
if (billing.initialTab === "orders") void fetchOrders();
}, [billing.initialTab, fetchOrders]);
return (
<div className="billing-panel">
<p className="billing-summary">
{credits}
{" · "}
{activeSubscription
? `${activeSubscription.product?.name || activeSubscription.productCode}${formatMembershipDate(activeSubscription.endsAt)} 到期`
: "未开通会员"}
</p>
<Tabs
defaultValue={billing.initialTab}
onValueChange={(value) => {
if (value === "orders") void fetchOrders();
}}
>
<TabsList className="membership-tabs" aria-label="账户与点数">
<TabsTrigger className="membership-tab" id="membership-plans-tab" value="membership">
</TabsTrigger>
<TabsTrigger className="membership-tab" id="membership-credits-tab" value="credits">
</TabsTrigger>
<TabsTrigger className="membership-tab" id="membership-redeem-tab" value="redeem">
</TabsTrigger>
<TabsTrigger className="membership-tab" id="membership-orders-tab" value="orders">
</TabsTrigger>
</TabsList>
{paymentError && (
<div className="membership-payment-error" role="alert">
<p>{paymentError}</p>
{selectedProductId && (
<Button
variant="outline"
type="button"
disabled={Boolean(payingProductId)}
onClick={() => void createPayment(selectedProductId)}
>
{payingLabel(payingProductId === selectedProductId, "重新支付")}
</Button>
)}
</div>
)}
{packagesError && <p className="form-error" role="alert">{packagesError}</p>}
<TabsContent className="membership-panel" value="membership">
{packagesSettled && !packagesError && !paymentEnabled && <p className="membership-empty">线</p>}
{plans.length > 0 && (
<div className="membership-plan-grid">
{plans.map((product) => {
const alias = planAlias(product);
const recommended = alias === "monthly";
const isCurrent = currentProductCode === product.code;
const highlighted = alias === highlightedPlan;
return (
<article
key={product.id}
ref={highlighted ? highlightedCardRef : undefined}
tabIndex={highlighted ? -1 : undefined}
aria-current={highlighted ? "true" : undefined}
className={`membership-plan-card${recommended ? " membership-plan-card--recommended" : ""}${isCurrent ? " membership-plan-card--current" : ""}${highlighted ? " membership-plan-card--highlighted" : ""}`}
>
<header className="membership-card-header">
<div>
<h3>{product.name}</h3>
<p className="membership-card-meta">{planDisplayLabel(alias ?? "monthly")}</p>
</div>
{(isCurrent || recommended) && <span className={`membership-status${isCurrent ? " is-current" : ""}`}>{isCurrent ? "当前套餐" : "推荐"}</span>}
</header>
<p className="membership-card-price">
{formatPrice(product.priceCents, product.currency)}
<small>/{formatProductDuration(product)}</small>
</p>
<p className="membership-card-audience">{productAudience(product)}</p>
<ul className="membership-card-entitlements">
{product.entitlements.map((entitlement, index) => (
<li key={index}>{entitlementLabel(entitlement)}</li>
))}
{product.entitlements.length === 0 && <li></li>}
</ul>
<details className="membership-rules">
<summary></summary>
<ul>
<li>{isCurrent
? `当前有效期至 ${formatMembershipDate(activeSubscription?.endsAt)},续费从到期后顺延,不会覆盖未到期权益。`
: "支付成功后权益立即生效。"}</li>
<li></li>
<li></li>
</ul>
</details>
<Button
className="membership-buy"
variant={recommended ? "default" : "outline"}
type="button"
disabled={!paymentEnabled || Boolean(payingProductId)}
onClick={() => void createPayment(product.id)}
>
{payingLabel(payingProductId === product.id, isCurrent ? "续费" : product.productType === "trial" ? "立即开通" : "立即购买")}
</Button>
</article>
);
})}
</div>
)}
{packagesSettled && !packagesError && paymentEnabled && plans.length === 0 && <p className="membership-empty"></p>}
</TabsContent>
<TabsContent className="membership-panel" value="credits">
{creditPacks.length > 0 && (
<div className="membership-credit-grid">
{creditPacks.map((product) => (
<article className="membership-credit-card" key={product.id}>
<div className="membership-credit-copy">
<h3>{product.name}</h3>
<p className="membership-card-meta">{product.credits > 0 ? `${product.credits}` : "点数包"}</p>
<p>{product.description || "按需补充咨询点数"}</p>
</div>
<p className="membership-card-price">{formatPrice(product.priceCents, product.currency)}</p>
<ul className="membership-card-entitlements">
{product.entitlements.map((entitlement, index) => (
<li key={index}>{entitlementLabel(entitlement)}</li>
))}
</ul>
<details className="membership-rules">
<summary></summary>
<ul>
<li></li>
</ul>
</details>
<Button
className="membership-buy"
variant="outline"
type="button"
disabled={!paymentEnabled || Boolean(payingProductId)}
onClick={() => void createPayment(product.id)}
>
{payingLabel(payingProductId === product.id, "立即购买")}
</Button>
</article>
))}
</div>
)}
{packagesSettled && !packagesError && paymentEnabled && creditPacks.length === 0 && <p className="membership-empty"></p>}
</TabsContent>
<TabsContent className="membership-panel" value="redeem">
<h2 id="membership-redeem-title"></h2>
<div className="redeem-balance">
<span>{activeSubscription ? "当前会员" : "当前余额"}</span>
<strong>{activeSubscription
? `${activeSubscription.product?.name || activeSubscription.productCode} · ${formatMembershipDate(activeSubscription.endsAt)} 到期`
: `${credits}`}</strong>
</div>
<form className="redeem-form" onSubmit={redeem}>
<label htmlFor="membership-redeem-code"></label>
<div>
<input
id="membership-redeem-code"
autoComplete="off"
value={redeemCode}
onChange={(event) => {
setRedeemCode(event.target.value);
setRedeemError("");
setRedeemMessage("");
setRedeemDone(false);
}}
placeholder="输入完整兑换码"
/>
<Button type="submit" disabled={!redeemCode.trim() || redeeming || redeemDone}>
{redeeming ? "兑换中" : "立即兑换"}
</Button>
</div>
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
{redeemDone && (
<div className="dialog-actions">
<Button type="button" onClick={closeRedeem}></Button>
</div>
)}
</form>
<details className="membership-rules">
<summary></summary>
<ul>
<li> JYOTISH-XXXX-XXXX </li>
<li></li>
<li>使退</li>
</ul>
</details>
</TabsContent>
<TabsContent className="membership-panel" value="orders">
<div className="membership-section-heading">
<div>
<h2 id="membership-orders-title"></h2>
<p></p>
</div>
<button className="membership-orders-refresh" type="button" onClick={() => void fetchOrders()}></button>
</div>
{ordersState === "unavailable" && <p className="membership-empty"></p>}
{ordersState === "ready" && orders.length === 0 && <p className="membership-empty"></p>}
{orders.length > 0 && (
<ul className="membership-order-list">
{orders.map((order) => (
<li className="membership-order-row" key={order.orderNo}>
<div>
<strong>{order.productName || "订单"}</strong>
<small>{order.orderNo}</small>
</div>
<span className={`membership-order-status status-${order.status}`}>
{orderStatusLabel(order.status)}
</span>
<b>{formatPrice(order.moneyCents, order.currency)}</b>
</li>
))}
</ul>
)}
</TabsContent>
</Tabs>
{paymentOrder && (
<section className="membership-payment-panel" role="status" aria-live="polite">
<span className="membership-payment-icon" aria-hidden="true">
{paymentOrder.status === "paid" ? <CheckCircle2 /> : paymentOrder.status === "failed" ? <X /> : <Sparkles />}
</span>
<div className="membership-payment-copy">
{paymentOrder.status === "paid" && (
<>
<h3></h3>
<p>{paymentOrder.orderNo}</p>
<p></p>
</>
)}
{paymentOrder.status === "failed" && (
<>
<h3></h3>
<p>{paymentOrder.orderNo}</p>
<p></p>
</>
)}
{paymentOrder.status === "pending" && (
<>
<h3></h3>
<p>{paymentOrder.orderNo}</p>
<p></p>
</>
)}
</div>
<div className="dialog-actions">
{paymentOrder.status === "failed" && (
<Button
variant="outline"
type="button"
disabled={Boolean(payingProductId)}
onClick={() => void createPayment(paymentOrder.productId)}
>
{payingLabel(payingProductId === paymentOrder.productId, "重新支付")}
</Button>
)}
</div>
</section>
)}
</div>
);
}
+195 -132
View File
@@ -1,7 +1,8 @@
"use client";
import type { Dispatch, FormEvent, SetStateAction } from "react";
import { ProfileFields } from "@/components/profile-fields";
import { ChevronRight } from "lucide-react";
import { useState, type Dispatch, FormEvent, SetStateAction } from "react";
import { ChartProfileForm } from "@/components/chart-profile-form";
import { activeChartStorageKey, deleteCloudChartProfile, saveCloudChartProfile, updateCloudChartProfile } from "@/lib/home-cloud-sync";
import {
chartRelationshipLabel,
@@ -23,8 +24,11 @@ import {
type SynastryRelationshipType,
type SynastryReportCard,
} from "@/lib/home-types";
import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field";
import { resolveAyanamsa } from "@/lib/ayanamsa";
import {
CHART_LIBRARY_LIST_VIEW,
synastryHistoryForPartner,
type ChartLibraryView,
} from "@/lib/chart-library-view";
export type ChartLibraryPanelProps = {
readonly account: Account | null;
@@ -39,14 +43,6 @@ export type ChartLibraryPanelProps = {
readonly profileNotice: string;
readonly setProfileNotice: Dispatch<SetStateAction<string>>;
readonly setAccountError: Dispatch<SetStateAction<string>>;
readonly editingSelfChart: boolean;
readonly setEditingSelfChart: Dispatch<SetStateAction<boolean>>;
readonly otherProfileDraft: Profile;
readonly setOtherProfileDraft: Dispatch<SetStateAction<Profile>>;
readonly otherChartRelationship: Exclude<ChartRelationship, "self">;
readonly setOtherChartRelationship: Dispatch<SetStateAction<Exclude<ChartRelationship, "self">>>;
readonly editingChartId: string | null;
readonly setEditingChartId: Dispatch<SetStateAction<string | null>>;
readonly synastryRelationshipType: SynastryRelationshipType;
readonly setSynastryRelationshipType: Dispatch<SetStateAction<SynastryRelationshipType>>;
readonly synastryPendingId: string | null;
@@ -72,14 +68,6 @@ export function ChartLibraryPanel({
profileNotice,
setProfileNotice,
setAccountError,
editingSelfChart,
setEditingSelfChart,
otherProfileDraft,
setOtherProfileDraft,
otherChartRelationship,
setOtherChartRelationship,
editingChartId,
setEditingChartId,
synastryRelationshipType,
setSynastryRelationshipType,
synastryPendingId,
@@ -91,6 +79,31 @@ export function ChartLibraryPanel({
saveProfile,
draftSynastryQuestionFromChart,
}: ChartLibraryPanelProps) {
const [view, setView] = useState<ChartLibraryView>(CHART_LIBRARY_LIST_VIEW);
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
const [otherChartRelationship, setOtherChartRelationship] = useState<Exclude<ChartRelationship, "self">>("other");
const [editingChartId, setEditingChartId] = useState<string | null>(null);
const selfCharts = chartLibrary.filter((record) => record.role === "self");
const otherCharts = chartLibrary.filter((record) => record.role === "other");
const selectedOther = view.kind === "other"
? otherCharts.find((record) => record.id === view.id) ?? null
: null;
const partnerHistory = selectedOther
? synastryHistoryForPartner(synastryHistory, {
id: selectedOther.id,
name: selectedOther.profile.name || "对方",
})
: [];
function goList() {
setView(CHART_LIBRARY_LIST_VIEW);
setEditingChartId(null);
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setProfileDraft(profile);
}
async function saveOtherChart(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim(), chartRelationship: otherChartRelationship };
@@ -123,6 +136,7 @@ export function ChartLibraryPanel({
setEditingChartId(null);
setAccountError("");
setProfileNotice(editingChartId ? "已更新其他人的星盘资料。" : "已保存到云端星盘库。请选择关系类型后点击“用于合盘”。");
setView(CHART_LIBRARY_LIST_VIEW);
} catch {
setProfileNotice("保存失败,请重试");
setAccountError("");
@@ -136,6 +150,7 @@ export function ChartLibraryPanel({
setEditingChartId(record.id);
setAccountError("");
setProfileNotice("");
setView({ kind: "other", id: record.id });
}
async function deleteOtherChart(recordId: string) {
@@ -157,6 +172,7 @@ export function ChartLibraryPanel({
});
setAccountError("");
setProfileNotice("已从云端星盘库删除。");
goList();
}
function makeDefaultChart(record: ChartLibraryRecord) {
@@ -167,118 +183,165 @@ export function ChartLibraryPanel({
setProfileNotice("已设为当前使用资料,账户本人的出生资料未被覆盖。");
}
return (
<div className="chart-library-panel" aria-label="星盘资料管理">
<div className="chart-library-group">
<div className="chart-library-group-heading">
<div><b></b><small></small></div>
<span className="chart-library-count">{chartLibrary.filter((record) => record.role === "self").length}</span>
</div>
{chartLibrary.filter((record) => record.role === "self").map((record) => (
<article className="chart-library-item" key={record.id}>
<div className="chart-library-item-main">
<div className="chart-library-item-title"><strong>{record.profile.name || "未命名"}</strong><span className="chart-role-badge is-self"></span><span className="chart-default-badge"></span></div>
<small>{record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)}</small>
<small>{profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)}</small>
</div>
<div className="chart-library-actions">
<button className="button-secondary" type="button" onClick={() => { setProfileDraft(record.profile); setEditingSelfChart(true); }} disabled={profileSaving || editingChartId !== null}></button>
</div>
</article>
))}
{editingSelfChart && (
<form className="profile-form chart-library-form" onSubmit={saveProfile}>
<div className="section-heading"><b></b><small></small></div>
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="self-profile-name" />
<AyanamsaPreferenceField
value={resolveAyanamsa(profileDraft)}
onChange={(ayanamsa) => setProfileDraft({ ...profileDraft, ayanamsa })}
/>
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
<div className="dialog-actions">
<button className="button-secondary" type="button" onClick={() => { setEditingSelfChart(false); setProfileDraft(profile); }}></button>
<button className="button-primary save-profile" type="submit" disabled={!account || profileSaving}>{profileSaving ? "保存中" : "保存本人资料"}</button>
</div>
</form>
)}
{chartLibrary.filter((record) => record.role === "self").length === 0 && <p className="empty-library-copy"></p>}
</div>
<div className="chart-library-group">
<div className="chart-library-group-heading">
<div><b></b><small></small></div>
<span className="chart-library-count">{chartLibrary.filter((record) => record.role === "other").length}</span>
</div>
{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 className="chart-library-item-main">
<div className="chart-library-item-title"><strong>{record.profile.name || "未命名"}</strong><span className="chart-role-badge">{chartRelationshipLabel(record.relationship)}</span></div>
<small>{record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)}</small>
<small>{profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)}</small>
</div>
<div className="chart-library-actions">
<select aria-label={`${record.profile.name || "其他人"}的关系类型`} value={synastryRelationshipType} onChange={(event) => setSynastryRelationshipType(event.target.value as SynastryRelationshipType)} disabled={synastryPendingId !== null}>
<option value="romance"></option>
<option value="business"></option>
<option value="family">/</option>
<option value="general"></option>
</select>
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === record.id ? "正在计算合盘..." : "用于合盘"}</button>
<button className="button-secondary" type="button" onClick={() => editOtherChart(record)} disabled={profileSaving || editingChartId !== null}></button>
<button className="button-secondary" type="button" onClick={() => makeDefaultChart(record)} disabled={profileSaving}></button>
<button className="button-secondary" type="button" onClick={() => void deleteOtherChart(record.id)}></button>
</div>
</article>
))}
</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>{editingChartId ? "编辑其他人的星盘" : "添加其他人的星盘"}</b><small></small></div>
<label>
<span></span>
<select value={otherChartRelationship} onChange={(event) => setOtherChartRelationship(event.target.value as Exclude<ChartRelationship, "self">)}>
<option value="partner"></option>
<option value="family"></option>
<option value="friend"></option>
<option value="client"></option>
<option value="other"></option>
</select>
</label>
<ProfileFields value={otherProfileDraft} onChange={setOtherProfileDraft} nameInputId="other-profile-name" />
<div className="dialog-actions">
{editingChartId && <button className="button-secondary" type="button" onClick={() => { setEditingChartId(null); setOtherProfileDraft(emptyProfile); setOtherChartRelationship("other"); }}></button>}
<button className="button-primary save-profile" type="submit" disabled={!account || profileSaving}>{profileSaving ? "保存中" : editingChartId ? "保存修改" : "添加到星盘库"}</button>
</div>
</form>
</div>
function listRow(record: ChartLibraryRecord) {
const isSelf = record.role === "self";
return (
<button
className="chart-library-item"
key={record.id}
type="button"
onClick={() => {
if (isSelf) {
setProfileDraft(record.profile);
setView({ kind: "self" });
return;
}
editOtherChart(record);
}}
>
<div className="chart-library-item-main">
<div className="chart-library-item-title">
<strong>{record.profile.name || "未命名"}</strong>
<span className={`chart-role-badge${isSelf ? " is-self" : ""}`}>{isSelf ? "本人" : chartRelationshipLabel(record.relationship)}</span>
{isSelf ? <span className="chart-default-badge"></span> : null}
</div>
<small>{record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)}</small>
<small>{profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)}</small>
</div>
<ChevronRight className="chart-library-item-chevron" aria-hidden="true" />
</button>
);
}
if (view.kind === "self") {
return (
<div className="chart-library-panel" aria-label="星盘资料管理">
<button className="chart-library-back" type="button" onClick={goList}> </button>
<ChartProfileForm
title="编辑本人星盘"
description="这份资料会用于默认解盘与新对话。"
value={profileDraft}
onChange={setProfileDraft}
nameInputId="self-profile-name"
showAyanamsa
onSubmit={saveProfile}
onCancel={goList}
cancelLabel="返回列表"
submitLabel={profileSaving ? "保存中" : "保存本人资料"}
submitDisabled={!account || profileSaving}
notice={profileNotice}
/>
</div>
);
}
if (view.kind === "add" || (view.kind === "other" && selectedOther)) {
const isAdd = view.kind === "add";
return (
<div className="chart-library-panel" aria-label="星盘资料管理">
<button className="chart-library-back" type="button" onClick={goList}> </button>
<ChartProfileForm
title={isAdd ? "添加其他人的星盘" : "编辑其他人的星盘"}
description="用于合盘、亲友盘或客户盘。"
value={otherProfileDraft}
onChange={setOtherProfileDraft}
nameInputId="other-profile-name"
relationship={otherChartRelationship}
onRelationshipChange={setOtherChartRelationship}
onSubmit={saveOtherChart}
onCancel={goList}
cancelLabel="返回列表"
submitLabel={profileSaving ? "保存中" : editingChartId ? "保存修改" : "添加到星盘库"}
submitDisabled={!account || profileSaving}
notice={profileNotice}
/>
{!isAdd && selectedOther ? (
<>
<div className="chart-library-actions">
<button className="button-secondary" type="button" onClick={() => makeDefaultChart(selectedOther)} disabled={profileSaving}></button>
<button className="button-secondary" type="button" onClick={() => void deleteOtherChart(selectedOther.id)}></button>
</div>
<section className="chart-library-group" aria-label="合盘">
<div className="chart-library-group-heading">
<div><b></b><small></small></div>
</div>
<div className="chart-library-actions">
<select aria-label={`${selectedOther.profile.name || "其他人"}的关系类型`} value={synastryRelationshipType} onChange={(event) => setSynastryRelationshipType(event.target.value as SynastryRelationshipType)} disabled={synastryPendingId !== null}>
<option value="romance"></option>
<option value="business"></option>
<option value="family">/</option>
<option value="general"></option>
</select>
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(selectedOther, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === selectedOther.id ? "正在计算合盘..." : "用于合盘"}</button>
</div>
{synastryReportCard && synastryHistoryForPartner([synastryReportCard], { id: selectedOther.id, name: selectedOther.profile.name || "对方" }).length > 0 && (
<article className="synastry-report-card" aria-label="合盘结果摘要">
<div>
<span></span>
<strong>{synastryReportCard.partnerName}</strong>
<small>Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"}</small>
</div>
{synastryReportCard.headline && <p>{synastryReportCard.headline}</p>}
<details>
<summary></summary>
<ul>
{(synastryReportCard.strengths || []).map((item) => <li key={item}>{item}</li>)}
{(synastryReportCard.risks || []).map((item) => <li key={item}>{item}</li>)}
</ul>
<small>{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"}</small>
</details>
</article>
)}
{partnerHistory.length > 0 && (
<div className="synastry-history-list" aria-label="合盘历史">
<b></b>
{partnerHistory.slice(0, 5).map((item) => (
<button key={item.id} type="button" className="synastry-history-item" onClick={() => setSynastryReportCard(item)}>
<span>{item.partnerName}</span>
<small>Ashtakoot {item.score ?? "?"}/{item.maxScore ?? "?"} · {item.assessment || item.scoreBand || "待解释"}</small>
</button>
))}
</div>
)}
</section>
</>
) : null}
</div>
);
}
return (
<div className="chart-library-panel" aria-label="星盘资料管理">
<div className="chart-library-group">
<div className="chart-library-group-heading">
<div><b></b><small></small></div>
<span className="chart-library-count">{selfCharts.length}</span>
</div>
{selfCharts.map(listRow)}
{selfCharts.length === 0 && <p className="empty-library-copy"></p>}
</div>
<div className="chart-library-group">
<div className="chart-library-group-heading">
<div><b></b><small></small></div>
<span className="chart-library-count">{otherCharts.length}</span>
</div>
{otherCharts.length === 0 && <p className="empty-library-copy"></p>}
{otherCharts.map(listRow)}
<button
className="button-secondary chart-library-add"
type="button"
onClick={() => {
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setEditingChartId(null);
setAccountError("");
setProfileNotice("");
setView({ kind: "add" });
}}
>
</button>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
"use client";
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";
export type ChartProfileFormProps = {
readonly title: string;
readonly description: string;
readonly value: Profile;
readonly onChange: Dispatch<SetStateAction<Profile>>;
readonly nameInputId: string;
readonly showAyanamsa?: boolean;
readonly relationship?: Exclude<ChartRelationship, "self">;
readonly onRelationshipChange?: (value: Exclude<ChartRelationship, "self">) => void;
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
readonly onCancel?: () => void;
readonly cancelLabel?: string;
readonly submitLabel: string;
readonly submitDisabled: boolean;
readonly notice?: string;
};
export function ChartProfileForm({
title,
description,
value,
onChange,
nameInputId,
showAyanamsa = false,
relationship,
onRelationshipChange,
onSubmit,
onCancel,
cancelLabel = "取消",
submitLabel,
submitDisabled,
notice,
}: ChartProfileFormProps) {
return (
<form className="profile-form chart-library-form" onSubmit={onSubmit}>
<div className="section-heading"><b>{title}</b><small>{description}</small></div>
{relationship !== undefined && onRelationshipChange ? (
<label>
<span></span>
<select value={relationship} onChange={(event) => onRelationshipChange(event.target.value as Exclude<ChartRelationship, "self">)}>
<option value="partner"></option>
<option value="family"></option>
<option value="friend"></option>
<option value="client"></option>
<option value="other"></option>
</select>
</label>
) : null}
<ProfileFields value={value} onChange={onChange} nameInputId={nameInputId} />
{showAyanamsa ? (
<AyanamsaPreferenceField
value={resolveAyanamsa(value)}
onChange={(ayanamsa) => onChange((current) => ({ ...current, ayanamsa }))}
/>
) : null}
{notice ? <p className="form-success" role="status">{notice}</p> : null}
<div className="dialog-actions">
{onCancel ? <button className="button-secondary" type="button" onClick={onCancel}>{cancelLabel}</button> : null}
<button className="button-primary save-profile" type="submit" disabled={submitDisabled}>{submitLabel}</button>
</div>
</form>
);
}
@@ -47,6 +47,7 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{
onCompleted?: () => void;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onOpenBilling?: (options?: { source?: string }) => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
onOpeningConsumed?: () => void;
pendingConsultationQuestion?: string | null;
@@ -1,22 +1,23 @@
"use client";
import Link from "next/link";
import { Gift, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { keepFocusWithin } from "@/lib/focus-trap";
import { membershipHref, notifyBalanceChanged, redeemErrorMessage } from "@/lib/membership";
import { notifyBalanceChanged, redeemErrorMessage } from "@/lib/membership";
type OnboardingRedeemPaywallProps = {
credits: number;
onClose: () => void;
onCreditsChanged: (credits: number) => void;
onOpenBilling: () => void;
};
export function OnboardingRedeemPaywall({
credits,
onClose,
onCreditsChanged,
onOpenBilling,
}: OnboardingRedeemPaywallProps) {
const [code, setCode] = useState("");
const [error, setError] = useState("");
@@ -126,7 +127,7 @@ export function OnboardingRedeemPaywall({
{unlocked ? (
<button className="button-primary" type="button" onClick={close}></button>
) : (
<Link className="button-secondary" href={membershipHref("onboarding-paywall")}></Link>
<button className="button-secondary" type="button" onClick={onOpenBilling}></button>
)}
</div>
</section>
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { UserAvatar } from "@/components/user-avatar";
import { beamAvatarPalettes, type BeamAvatarPatch } from "@/lib/beam-avatar";
import type { Account, Profile } from "@/lib/home-types";
export type ProfilePanelProps = {
readonly account: Account;
readonly profile: Profile;
readonly avatarSaving: boolean;
readonly avatarNotice: string;
readonly accountError: string;
readonly persistAvatar: (patch: BeamAvatarPatch) => void;
};
export function ProfilePanel({
account,
profile,
avatarSaving,
avatarNotice,
accountError,
persistAvatar,
}: ProfilePanelProps) {
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={48} 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" aria-labelledby="account-info-title">
<div className="section-heading"><b id="account-info-title"></b><small></small></div>
<dl className="account-info-list">
<div><dt></dt><dd>{profile.name.trim() || "尚未设置"}<small></small></dd></div>
<div><dt></dt><dd>{account.user.email || "尚未读取邮箱"}</dd></div>
</dl>
</section>
</>
);
}
@@ -44,14 +44,12 @@ import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
} from "@/lib/rectification-board-model";
import { membershipHref } from "@/lib/membership";
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
import {
rectificationAdoptingLabel,
RECTIFICATION_EMPTY_ACTION_LABEL,
RECTIFICATION_EMPTY_COPY,
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS,
RECTIFICATION_QUESTION_PREPARING_LABEL,
RECTIFICATION_QUESTION_RELOAD_LABEL,
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
@@ -240,6 +238,7 @@ type RectificationAgenticChatProps = Readonly<{
onCompleted?: () => void;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onOpenBilling?: (options?: { source?: string }) => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
onOpeningConsumed?: () => void;
pendingConsultationQuestion?: string | null;
@@ -476,6 +475,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onCompleted,
onPendingChange,
onProfileIncomplete,
onOpenBilling,
onSaved,
onOpeningConsumed,
pendingConsultationQuestion,
@@ -824,11 +824,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return;
}
if (response.status === 402) {
// Say why before the page leaves, instead of vanishing mid-chat.
// Keep the conversation on this page; the settings pane covers billing.
setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE);
window.setTimeout(() => {
window.location.assign(membershipHref("rectification"));
}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS);
onOpenBilling?.({ source: "rectification" });
return;
}
if (response.status === 401) setError("请先登录。");
@@ -1053,7 +1051,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
lastRunOutcome.current = readonly ? "readonly" : runOutcome;
setPending(false);
}
}, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
}, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onOpenBilling, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
useEffect(() => {
if (busy) return;