fix: polish storefront account and membership state
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user