fix: polish storefront account and membership state

This commit is contained in:
Jesse_Chen
2026-08-11 11:59:03 +08:00
parent 8eed0603f9
commit ce46aae5c8
8 changed files with 122 additions and 37 deletions
+1 -4
View File
@@ -61,7 +61,7 @@ function MembershipContent() {
const [paymentPackages, setPaymentPackages] = useState<MembershipProduct[]>([]);
const [paymentEnabled, setPaymentEnabled] = useState(false);
const [packagesError, setPackagesError] = useState("");
const [redeemOpen, setRedeemOpen] = useState(() => searchParams.get("redeem") === "1");
const [redeemOpen, setRedeemOpen] = useState(false);
const [redeemCode, setRedeemCode] = useState("");
const [redeemError, setRedeemError] = useState("");
const [redeemMessage, setRedeemMessage] = useState("");
@@ -174,9 +174,6 @@ function MembershipContent() {
function closeRedeem() {
setRedeemOpen(false);
setRedeemError("");
const url = new URL(window.location.href);
url.searchParams.delete("redeem");
window.history.replaceState(null, "", `${url.pathname}${url.search}`);
}
async function redeem(event: FormEvent<HTMLFormElement>) {
+20 -17
View File
@@ -882,6 +882,18 @@ class ConsultationStatusError extends Error {
}
}
class LoginRedirectError extends Error {
constructor() {
super("Redirecting to login");
this.name = "LoginRedirectError";
}
}
function redirectToLogin(): never {
window.location.replace("/login");
throw new LoginRedirectError();
}
function waitForUndoWindow(signal: AbortSignal) {
return new Promise<void>((resolve) => {
const finish = () => {
@@ -896,10 +908,7 @@ function waitForUndoWindow(signal: AbortSignal) {
async function fetchAccount(signal?: AbortSignal): Promise<Account> {
const response = await fetch("/api/account", { signal, cache: "no-store" });
if (response.status === 401) {
window.location.assign("/login");
throw new Error("请先登录");
}
if (response.status === 401) redirectToLogin();
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取账户信息"));
return payload as Account;
@@ -914,10 +923,7 @@ async function fetchModelCatalog(signal?: AbortSignal) {
async function fetchSessions(signal?: AbortSignal): Promise<unknown> {
const response = await fetch("/api/sessions", { signal, cache: "no-store" });
if (response.status === 401) {
window.location.assign("/login");
throw new Error("请先登录");
}
if (response.status === 401) redirectToLogin();
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录"));
return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null;
@@ -1008,7 +1014,7 @@ export default function Home() {
const [draft, setDraft] = useState("");
const [draftTheme, setDraftTheme] = useState<Theme | null>(null);
const [draftEntrypoint, setDraftEntrypoint] = useState<ConsultationEntrypoint | null>(null);
const [composerNotice, setComposerNotice] = useState("");
const [, setComposerNotice] = useState("");
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
const [cancellationPending, setCancellationPending] = useState(false);
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
@@ -1485,6 +1491,7 @@ export default function Home() {
}
}
} catch (caught) {
if (caught instanceof LoginRedirectError) return;
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据"));
}
@@ -1734,8 +1741,8 @@ export default function Home() {
if (event.key === BALANCE_SYNC_KEY) void refreshAccount();
};
const onBalanceChanged = () => void refreshAccount();
const onPageShow = (event: PageTransitionEvent) => {
if (event.persisted) void refreshAccount();
const onPageShow = () => {
void refreshAccount();
consultationRecoveryCheck.current();
};
const onOnline = () => consultationRecoveryCheck.current();
@@ -1768,6 +1775,7 @@ export default function Home() {
setAccount(latest);
setAccountError("");
} catch (caught) {
if (caught instanceof LoginRedirectError) return;
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息");
}
@@ -3081,7 +3089,7 @@ export default function Home() {
onOpenReports={() => window.location.assign("/reports")}
onSelectSession={selectSession}
onOpenProfile={() => openAccountDialog("profile")}
onOpenRedeem={() => window.location.assign(membershipHref("account-menu", { redeem: true }))}
onOpenRedeem={() => window.location.assign(membershipHref("account-menu"))}
onOpenLogout={() => openAccountDialog("logout")}
/>
{pendingSessionDeletion ? (
@@ -3374,11 +3382,6 @@ export default function Home() {
disabled={!activeSession || isLoading || cancellationPending || creatingSession}
onSelect={(modelId) => void selectSessionModel(modelId)}
/>
{(composerNotice || consultationPhase === "undo" || (!profileComplete && onboardingStep !== "name")) && (
<p className={composerNotice || consultationPhase === "undo" ? "composer-notice" : undefined} role={composerNotice || consultationPhase === "undo" ? "status" : undefined}>{composerNotice || (consultationPhase === "undo"
? "已加入发送队列,2.5 秒内可免费撤回。"
: onboardingStep === "rectification" ? "生时校正为可选增强" : "请先完成上方资料")}</p>
)}
</div>
</div>}
</SidebarInset>
@@ -59,6 +59,33 @@ type Product = {
entitlements: Entitlement[];
};
const productTypeLabels: Record<Product["productType"], string> = {
credit_pack: "点数包",
trial: "体验套餐",
subscription: "订阅套餐",
};
const billingPeriodLabels: Record<Product["billingPeriod"], string> = {
none: "一次性",
day: "按天",
month: "按月",
year: "按年",
};
const allowanceTypeLabels: Record<string, string> = {
access: "使用权限",
unlimited: "不限量",
quota: "限额",
credits: "点数",
};
const resetPeriodLabels: Record<string, string> = {
none: "不重置",
day: "每日",
month: "每月",
billing_period: "每个计费周期",
};
type ProductForm = Omit<Product, "id" | "version" | "priceCents" | "status" | "effectiveFrom" | "updatedAt" | "entitlements"> & {
id?: string;
priceYuan: number;
@@ -198,10 +225,10 @@ export default function ProductManagement() {
dataIndex: "name",
render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.code} · v{item.version}</Text></Space>,
},
{ title: "类型", dataIndex: "productType", render: (value) => <Tag>{value}</Tag> },
{ title: "周期", render: (_, item) => item.billingPeriod === "none" ? "—" : `${item.intervalCount} ${item.billingPeriod}` },
{ title: "类型", dataIndex: "productType", render: (value: Product["productType"]) => <Tag>{productTypeLabels[value]}</Tag> },
{ title: "周期", render: (_, item) => item.billingPeriod === "none" ? billingPeriodLabels.none : `${item.intervalCount} 个周期(${billingPeriodLabels[item.billingPeriod]}` },
{ title: "价格", render: (_, item) => `¥${(item.priceCents / 100).toFixed(2)}` },
{ title: "权益", dataIndex: "entitlements", render: (items: Entitlement[]) => <Space wrap>{items.map((item) => <Tag key={`${item.featureKey}-${item.allowanceType}`}>{item.featureKey}: {item.allowanceType}</Tag>)}</Space> },
{ title: "权益", dataIndex: "entitlements", render: (items: Entitlement[]) => <Space wrap>{items.map((item) => <Tag key={`${item.featureKey}-${item.allowanceType}`}>{item.featureKey}{allowanceTypeLabels[item.allowanceType] ?? item.allowanceType}{item.allowanceCount === null ? "" : ` ${item.allowanceCount}`} · {resetPeriodLabels[item.resetPeriod] ?? item.resetPeriod}</Tag>)}</Space> },
{ title: "状态", render: (_, item) => <Space><Tag color={item.status === "published" ? "green" : "gold"}>{item.status}</Tag>{item.enabled ? <Tag color="blue"></Tag> : <Tag></Tag>}</Space> },
{ title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate },
{
@@ -237,8 +264,8 @@ export default function ProductManagement() {
<Col xs={24} md={8}><Form.Item name="priceYuan" label="价格(元)" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: "100%" }} /></Form.Item></Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12} lg={6}><Form.Item name="productType" label="类型" rules={[{ required: true }]}><Select options={["credit_pack", "trial", "subscription"].map((value) => ({ value, label: value }))} /></Form.Item></Col>
<Col xs={24} md={12} lg={6}><Form.Item name="billingPeriod" label="计费周期" rules={[{ required: true }]}><Select options={["none", "day", "month", "year"].map((value) => ({ value, label: value }))} /></Form.Item></Col>
<Col xs={24} md={12} lg={6}><Form.Item name="productType" label="类型" rules={[{ required: true }]}><Select options={Object.entries(productTypeLabels).map(([value, label]) => ({ value, label }))} /></Form.Item></Col>
<Col xs={24} md={12} lg={6}><Form.Item name="billingPeriod" label="计费周期" rules={[{ required: true }]}><Select options={Object.entries(billingPeriodLabels).map(([value, label]) => ({ value, label }))} /></Form.Item></Col>
<Col xs={24} md={12} lg={6}><Form.Item name="intervalCount" label="周期数量" rules={[{ required: true }]}><InputNumber min={0} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
<Col xs={24} md={12} lg={6}><Form.Item name="sortOrder" label="排序" rules={[{ required: true }]}><InputNumber precision={0} style={{ width: "100%" }} /></Form.Item></Col>
</Row>
+1 -2
View File
@@ -28,9 +28,8 @@ export type MembershipProduct = {
export type MembershipPlanAlias = "trial" | "monthly" | "yearly";
export function membershipHref(source: string, options: { redeem?: boolean; plan?: MembershipPlanAlias } = {}) {
export function membershipHref(source: string, options: { plan?: MembershipPlanAlias } = {}) {
const params = new URLSearchParams({ source });
if (options.redeem) params.set("redeem", "1");
if (options.plan) params.set("plan", options.plan);
return `/membership?${params.toString()}`;
}