feat(web): 积分中心 - 改为全局弹窗并去掉独立钱包页
顶栏、账户菜单、命令面板、画布余额和 /wallet 回跳都打开同一积分中心弹窗,支付结果不再跳进整页超市。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { WorkspaceSidebarNav } from "@/components/layout/workspace-sidebar-nav";
|
||||
import { readWorkspaceSidebarCollapsed, writeWorkspaceSidebarCollapsed } from "@/components/layout/workspace-sidebar-state";
|
||||
import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar";
|
||||
import { WorkspaceTopBarExtensionProvider } from "@/components/layout/workspace-top-bar-extension";
|
||||
import { WorkspaceWalletHost } from "@/components/layout/workspace-wallet-modal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isSpatialWorkbenchPath } from "@/lib/workspace-routes";
|
||||
|
||||
@@ -108,6 +109,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
{paletteOpen ? <Suspense fallback={null}><WorkspaceCommandPalette open onClose={() => setPaletteOpen(false)} /></Suspense> : null}
|
||||
</div>
|
||||
</WorkspaceTopBarExtensionProvider>
|
||||
<WorkspaceWalletHost />
|
||||
<ModelSetupGuide hidden={pathname === "/login" || pathname === "/register" || pathname.startsWith("/admin")} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Link } from "react-router";
|
||||
|
||||
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
|
||||
import { WorkspaceAccountCard } from "./workspace-account-card";
|
||||
import { WorkspaceWalletModal } from "./workspace-wallet-modal";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
import { openWorkspaceWallet } from "@/lib/workspace-wallet";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
@@ -17,9 +17,7 @@ export function WorkspaceAccountMenu() {
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const hydrated = useUserStore((state) => state.hydrated);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [walletOpen, setWalletOpen] = useState(false);
|
||||
|
||||
if (!hydrated) {
|
||||
return <span className="size-9 animate-pulse rounded-[var(--r-md)] bg-foreground/[.06]" aria-hidden />;
|
||||
@@ -34,7 +32,7 @@ export function WorkspaceAccountMenu() {
|
||||
onOpenChange={setMenuOpen}
|
||||
content={(
|
||||
<div className="workspace-topbar-account-menu">
|
||||
<WorkspaceAccountCard onNavigate={() => setMenuOpen(false)} onWallet={() => { setMenuOpen(false); setWalletOpen(true); }} />
|
||||
<WorkspaceAccountCard onNavigate={() => setMenuOpen(false)} onWallet={() => { setMenuOpen(false); openWorkspaceWallet(); }} />
|
||||
|
||||
<div className="workspace-topbar-account-section">
|
||||
<AppChangelogButton className="flex h-8 w-full items-center gap-2 rounded px-2 text-[var(--fs-label)] text-foreground/58 hover:bg-surface-hover hover:text-foreground [&_svg]:size-3.5" showLabel showVersion versionClassName="ml-auto text-[var(--fs-micro)] tabular-nums text-foreground/32" />
|
||||
@@ -51,7 +49,7 @@ export function WorkspaceAccountMenu() {
|
||||
<button type="button" className="app-workspace-topbar-icon-button app-workspace-account-trigger" aria-label="账户菜单" title={user.displayName || user.username}>
|
||||
<UserAvatar user={user} className="size-6" />
|
||||
</button>
|
||||
</Popover>{creditsEnabled ? <WorkspaceWalletModal open={walletOpen} onClose={() => setWalletOpen(false)} /> : null}</>
|
||||
</Popover></>
|
||||
) : (
|
||||
<Link to="/login" className="app-workspace-topbar-icon-button" aria-label="登录" title="登录">
|
||||
<LogIn />
|
||||
|
||||
@@ -5,13 +5,15 @@ import { useNavigate } from "react-router";
|
||||
import { navigationTools } from "@/constant/navigation-tools";
|
||||
import { Kbd } from "@/components/ui/base/kbd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { openWorkspaceWallet } from "@/lib/workspace-wallet";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type PaletteEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
to: string;
|
||||
to?: string;
|
||||
run?: () => void;
|
||||
};
|
||||
|
||||
/** 顶栏搜索 / ⌘K 命令面板:按功能开关过滤当前可用页面入口。 */
|
||||
@@ -23,18 +25,18 @@ export function WorkspaceCommandPalette({ open, onClose }: { open: boolean; onCl
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const entries = useMemo<PaletteEntry[]>(() => {
|
||||
const toolEntry = (slug: string, to: string): PaletteEntry => {
|
||||
const toolEntry = (slug: string, to?: string): PaletteEntry => {
|
||||
const tool = navigationTools.find((item) => item.slug === slug);
|
||||
return { id: slug, title: tool?.label ?? slug, icon: tool?.icon ?? Home, to };
|
||||
};
|
||||
return [
|
||||
{ id: "home", title: "首页", icon: Home, to: "/" },
|
||||
toolEntry("projects", "/projects"),
|
||||
toolEntry("projects", "/projects"),
|
||||
toolEntry("canvas", "/canvas"),
|
||||
...(features.taskCenterEnabled ? [toolEntry("tasks", "/tasks")] : []),
|
||||
toolEntry("assets", "/assets"),
|
||||
toolEntry("skills", "/skills"),
|
||||
...(features.creditsEnabled ? [toolEntry("wallet", "/wallet")] : []),
|
||||
...(features.creditsEnabled ? [{ ...toolEntry("wallet"), run: () => openWorkspaceWallet() }] : []),
|
||||
toolEntry("settings", "/settings"),
|
||||
];
|
||||
}, [features]);
|
||||
@@ -45,6 +47,15 @@ toolEntry("projects", "/projects"),
|
||||
return entries.filter((entry) => entry.title.toLowerCase().includes(keyword));
|
||||
}, [entries, query]);
|
||||
|
||||
const activateEntry = (entry: PaletteEntry) => {
|
||||
onClose();
|
||||
if (entry.run) {
|
||||
entry.run();
|
||||
return;
|
||||
}
|
||||
if (entry.to) navigate(entry.to);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
@@ -76,14 +87,12 @@ toolEntry("projects", "/projects"),
|
||||
}
|
||||
if (event.key === "Enter" && filtered[highlight]) {
|
||||
event.preventDefault();
|
||||
const target = filtered[highlight];
|
||||
onClose();
|
||||
navigate(target.to);
|
||||
activateEntry(filtered[highlight]);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, filtered, highlight, navigate, onClose]);
|
||||
}, [activateEntry, filtered, highlight, onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -125,10 +134,7 @@ toolEntry("projects", "/projects"),
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setHighlight(index)}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(entry.to);
|
||||
}}
|
||||
onClick={() => activateEntry(entry)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-[var(--r-sm)] px-3 py-2.5 text-left text-[var(--fs-body)] transition-colors",
|
||||
index === highlight ? "bg-surface-hover text-foreground" : "text-foreground/65",
|
||||
|
||||
@@ -1,44 +1,32 @@
|
||||
import { createContext, useCallback, useContext, useId, useLayoutEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { createContext, useContext, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type WorkspaceTopBarRegistration = {
|
||||
id: string;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
type WorkspaceTopBarRegistrar = {
|
||||
register: (registration: WorkspaceTopBarRegistration) => void;
|
||||
unregister: (id: string) => void;
|
||||
};
|
||||
|
||||
const WorkspaceTopBarRegistrarContext = createContext<WorkspaceTopBarRegistrar | null>(null);
|
||||
const WorkspaceTopBarContentContext = createContext<ReactNode>(null);
|
||||
const WorkspaceTopBarMountContext = createContext<HTMLElement | null | undefined>(undefined);
|
||||
const WorkspaceTopBarMountSetterContext = createContext<(node: HTMLElement | null) => void>(() => undefined);
|
||||
|
||||
export function WorkspaceTopBarExtensionProvider({ children }: { children: ReactNode }) {
|
||||
const [registration, setRegistration] = useState<WorkspaceTopBarRegistration | null>(null);
|
||||
const register = useCallback((next: WorkspaceTopBarRegistration) => setRegistration(next), []);
|
||||
const unregister = useCallback((id: string) => setRegistration((current) => current?.id === id ? null : current), []);
|
||||
const registrar = useMemo(() => ({ register, unregister }), [register, unregister]);
|
||||
const [mount, setMount] = useState<HTMLElement | null>(null);
|
||||
|
||||
return (
|
||||
<WorkspaceTopBarRegistrarContext.Provider value={registrar}>
|
||||
<WorkspaceTopBarContentContext.Provider value={registration?.content || null}>
|
||||
<WorkspaceTopBarMountSetterContext.Provider value={setMount}>
|
||||
<WorkspaceTopBarMountContext.Provider value={mount}>
|
||||
{children}
|
||||
</WorkspaceTopBarContentContext.Provider>
|
||||
</WorkspaceTopBarRegistrarContext.Provider>
|
||||
</WorkspaceTopBarMountContext.Provider>
|
||||
</WorkspaceTopBarMountSetterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceTopBarExtension(content: ReactNode) {
|
||||
const registrar = useContext(WorkspaceTopBarRegistrarContext);
|
||||
const id = useId();
|
||||
export function WorkspaceTopBarExtensionSlot() {
|
||||
const setMount = useContext(WorkspaceTopBarMountSetterContext);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!registrar) return;
|
||||
registrar.register({ id, content });
|
||||
return () => registrar.unregister(id);
|
||||
}, [content, id, registrar]);
|
||||
setMount(ref.current);
|
||||
return () => setMount(null);
|
||||
}, [setMount]);
|
||||
|
||||
return <div className="app-workspace-topbar-extension" ref={ref} />;
|
||||
}
|
||||
|
||||
export function useWorkspaceTopBarContent() {
|
||||
return useContext(WorkspaceTopBarContentContext);
|
||||
export function useWorkspaceTopBarMount() {
|
||||
return useContext(WorkspaceTopBarMountContext);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { Coins, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
|
||||
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
|
||||
import { WorkspaceAccountMenu } from "@/components/layout/workspace-account-menu";
|
||||
import { useWorkspaceTopBarContent } from "@/components/layout/workspace-top-bar-extension";
|
||||
import { WorkspaceTopBarExtensionSlot } from "@/components/layout/workspace-top-bar-extension";
|
||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { WorkspaceWalletModal } from "@/components/layout/workspace-wallet-modal";
|
||||
import { openWorkspaceWallet } from "@/lib/workspace-wallet";
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
home: "创作",
|
||||
@@ -21,12 +20,10 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
assets: "资产",
|
||||
skills: "技能",
|
||||
plugins: "插件",
|
||||
wallet: "积分超市",
|
||||
settings: "设置",
|
||||
};
|
||||
|
||||
export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen: boolean; onToggleSidebar: () => void }) {
|
||||
const [walletOpen, setWalletOpen] = useState(false);
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
@@ -34,14 +31,12 @@ export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen:
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled);
|
||||
const { pathname } = useLocation();
|
||||
const extension = useWorkspaceTopBarContent();
|
||||
|
||||
const slug = pathname.split("/").filter(Boolean)[0];
|
||||
const pageTitle = slug ? PAGE_TITLES[slug] || brandName : PAGE_TITLES.home;
|
||||
const balance = availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
return (
|
||||
<header className={`app-workspace-topbar ${extension ? "has-extension" : ""}`}>
|
||||
<header className="app-workspace-topbar">
|
||||
<button type="button" className="app-workspace-mobile-menu app-workspace-topbar-icon-button" aria-label={sidebarOpen ? "收起侧栏" : "展开侧栏"} onClick={onToggleSidebar}>
|
||||
{sidebarOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
|
||||
</button>
|
||||
@@ -50,9 +45,9 @@ export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen:
|
||||
<span aria-hidden="true">/</span>
|
||||
<span className="truncate font-medium text-foreground">{pageTitle}</span>
|
||||
</nav>
|
||||
{extension ? <div className="app-workspace-topbar-extension">{extension}</div> : null}
|
||||
<WorkspaceTopBarExtensionSlot />
|
||||
<div className="app-workspace-topbar-actions">
|
||||
{creditsEnabled ? <button type="button" className="app-workspace-topbar-credit-pill" aria-label={`打开积分中心,可用 ${balance} 积分`} onClick={() => setWalletOpen(true)}>
|
||||
{creditsEnabled ? <button type="button" className="app-workspace-topbar-credit-pill" aria-label={`打开积分中心,可用 ${balance} 积分`} onClick={() => openWorkspaceWallet()}>
|
||||
<Coins aria-hidden="true" />
|
||||
<span>积分</span>
|
||||
<strong>{balance}</strong>
|
||||
@@ -61,7 +56,6 @@ export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen:
|
||||
<AnimatedThemeToggler className="app-workspace-topbar-icon-button" theme={theme} onThemeChange={setTheme} aria-label="切换主题" />
|
||||
<WorkspaceAccountMenu />
|
||||
</div>
|
||||
{creditsEnabled ? <WorkspaceWalletModal open={walletOpen} onClose={() => setWalletOpen(false)} /> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,78 @@
|
||||
import { App, Button, Input, QRCode, Skeleton } from "antd";
|
||||
import { App, Button, Input, Skeleton } from "antd";
|
||||
import { Check, ChevronLeft, ChevronRight, CircleAlert, Coins, CreditCard, History, RefreshCw, TicketCheck, WalletCards } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { PaymentCheckoutCode } from "@/components/payment-checkout-code";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import { closePaymentOrder, createPaymentOrder, listPaymentProviders, listTopupProducts, queryPaymentOrder, refreshPaymentCheckout, type PaymentOrder, type PaymentProvider, type TopupProduct } from "@/services/api/payments";
|
||||
import { closePaymentOrder, createPaymentOrder, getPaymentOrder, listPaymentProviders, listTopupProducts, queryPaymentOrder, refreshPaymentCheckout, type PaymentOrder, type PaymentProvider, type TopupProduct } from "@/services/api/payments";
|
||||
import { getWallet, redeemCredits, type CreditLedgerEntry, type WalletSummary } from "@/services/api/wallet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { openWorkspaceWallet, WORKSPACE_WALLET_OPEN_EVENT, type WorkspaceWalletOpenDetail } from "@/lib/workspace-wallet";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type WalletModalTab = "topup" | "history";
|
||||
|
||||
export function WorkspaceWalletModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
export function WorkspaceWalletHost() {
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const { pathname, search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingPaymentOrderId, setPendingPaymentOrderId] = useState("");
|
||||
const [paymentInvalid, setPaymentInvalid] = useState(false);
|
||||
|
||||
const applyOpen = (detail: WorkspaceWalletOpenDetail = {}) => {
|
||||
if (!useUserStore.getState().features.creditsEnabled) return;
|
||||
setPendingPaymentOrderId(detail.paymentOrderId || "");
|
||||
setPaymentInvalid(Boolean(detail.paymentInvalid));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (raw: Event) => {
|
||||
applyOpen((raw as CustomEvent<WorkspaceWalletOpenDetail>).detail || {});
|
||||
};
|
||||
window.addEventListener(WORKSPACE_WALLET_OPEN_EVENT, handleOpen);
|
||||
return () => window.removeEventListener(WORKSPACE_WALLET_OPEN_EVENT, handleOpen);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname !== "/wallet") return;
|
||||
const params = new URLSearchParams(search);
|
||||
openWorkspaceWallet({
|
||||
paymentOrderId: params.get("paymentOrder") || undefined,
|
||||
paymentInvalid: params.get("payment") === "invalid",
|
||||
});
|
||||
navigate("/", { replace: true });
|
||||
}, [navigate, pathname, search]);
|
||||
|
||||
if (!creditsEnabled) return null;
|
||||
return (
|
||||
<WorkspaceWalletModal
|
||||
open={open}
|
||||
pendingPaymentOrderId={pendingPaymentOrderId}
|
||||
paymentInvalid={paymentInvalid}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
setPendingPaymentOrderId("");
|
||||
setPaymentInvalid(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceWalletModal({
|
||||
open,
|
||||
onClose,
|
||||
pendingPaymentOrderId,
|
||||
paymentInvalid,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
pendingPaymentOrderId?: string;
|
||||
paymentInvalid?: boolean;
|
||||
}) {
|
||||
const { message } = App.useApp();
|
||||
const [tab, setTab] = useState<WalletModalTab>("topup");
|
||||
const [wallet, setWallet] = useState<WalletSummary | null>(null);
|
||||
@@ -66,6 +128,11 @@ export function WorkspaceWalletModal({ open, onClose }: { open: boolean; onClose
|
||||
.finally(() => setPaymentsLoading(false));
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !paymentInvalid) return;
|
||||
message.error("支付结果无效,未产生积分充值");
|
||||
}, [open, paymentInvalid]);
|
||||
|
||||
useEffect(() => {
|
||||
idempotencyKey.current = "";
|
||||
}, [selectedProductId, selectedProviderId]);
|
||||
@@ -92,6 +159,17 @@ export function WorkspaceWalletModal({ open, onClose }: { open: boolean; onClose
|
||||
window.dispatchEvent(new CustomEvent("wallet:updated"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !pendingPaymentOrderId) return;
|
||||
getPaymentOrder(pendingPaymentOrderId)
|
||||
.then(async ({ order }) => {
|
||||
setPaymentOrder(order);
|
||||
setPaymentOpen(true);
|
||||
if (order.status === "credited") await announceWalletUpdated(order.id);
|
||||
})
|
||||
.catch((error) => message.error(error instanceof Error ? error.message : "读取支付结果失败"));
|
||||
}, [open, pendingPaymentOrderId]);
|
||||
|
||||
const redeem = async () => {
|
||||
const normalized = code.trim().toLowerCase();
|
||||
if (normalized.length !== 32) {
|
||||
@@ -257,7 +335,7 @@ export function WorkspaceWalletModal({ open, onClose }: { open: boolean; onClose
|
||||
<span className="workspace-wallet-payment-icon"><CreditCard /></span>
|
||||
<strong>¥ {(paymentOrder.amountFen / 100).toFixed(2)}</strong>
|
||||
<p>{paymentOrder.productName} · {formatCredits(paymentOrder.creditsMicrocredits, 6)} 积分</p>
|
||||
{paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? <><QRCode value={paymentOrder.checkout.value} size={208} bordered={false} /><span>请使用支付应用扫码完成支付</span></> : null}
|
||||
{paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? <><PaymentCheckoutCode value={paymentOrder.checkout.value} /><span>请使用支付应用扫码完成支付</span></> : null}
|
||||
<PaymentStatus order={paymentOrder} now={clock} />
|
||||
</div> : null}
|
||||
</AppModal>
|
||||
|
||||
Reference in New Issue
Block a user