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:
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user