feat(web): 积分中心 - 改为全局弹窗并去掉独立钱包页
顶栏、账户菜单、命令面板、画布余额和 /wallet 回跳都打开同一积分中心弹窗,支付结果不再跳进整页超市。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -134,7 +134,7 @@ if (key === "frontendModelsEnabled" && !enabled) {
|
||||
│ ☑️ 任务 /tasks [ON] │
|
||||
│ 关闭后仅隐藏并拦截任务中心页面... │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 💰 积分中心 /wallet [ON] │
|
||||
│ 💰 积分中心 弹窗 [ON] │
|
||||
│ 关闭后隐藏用户积分入口... │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 📡 自定义渠道 /settings?section... [ON] │
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getWallet } from "@/services/api/wallet";
|
||||
import { getWallet, type WalletSummary } from "@/services/api/wallet";
|
||||
|
||||
// 余额不是页面心跳数据。工作区内多个位置共享同一个 Query,只在首次进入、显式
|
||||
// wallet:updated 事件或用户主动刷新时访问服务端,避免常驻布局产生永久轮询。
|
||||
const WALLET_STALE_TIME_MS = 5 * 60_000;
|
||||
const walletBalanceQueryKey = (userId: string) => ["wallet-balance", userId] as const;
|
||||
|
||||
export type WalletSnapshot = {
|
||||
availableMicrocredits: number;
|
||||
policy: WalletSummary["policy"];
|
||||
};
|
||||
|
||||
export function useWalletBalance(userId?: string, enabled = true) {
|
||||
const activeUserId = enabled ? userId || "" : "";
|
||||
const queryClient = useQueryClient();
|
||||
@@ -15,10 +20,10 @@ export function useWalletBalance(userId?: string, enabled = true) {
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
enabled: Boolean(activeUserId),
|
||||
queryFn: async () => {
|
||||
queryFn: async (): Promise<WalletSnapshot> => {
|
||||
const wallet = await getWallet(1, 1);
|
||||
if (wallet.account.userId !== activeUserId) throw new Error("积分账户与当前用户不一致");
|
||||
return wallet.account.availableMicrocredits;
|
||||
return { availableMicrocredits: wallet.account.availableMicrocredits, policy: wallet.policy };
|
||||
},
|
||||
staleTime: WALLET_STALE_TIME_MS,
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -38,7 +43,8 @@ export function useWalletBalance(userId?: string, enabled = true) {
|
||||
}, [query.error]);
|
||||
|
||||
return {
|
||||
availableMicrocredits: query.data ?? null,
|
||||
availableMicrocredits: query.data?.availableMicrocredits ?? null,
|
||||
policy: query.data?.policy ?? null,
|
||||
refreshing: query.isFetching,
|
||||
refresh: query.refetch,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ const workspaceRouteLoaders = {
|
||||
create: () => import("@/pages/create"),
|
||||
projects: () => import("@/pages/projects"),
|
||||
projectDetail: () => import("@/pages/projects/detail"),
|
||||
wallet: () => import("@/pages/wallet"),
|
||||
};
|
||||
|
||||
export const loadAssetsPage = workspaceRouteLoaders.assets;
|
||||
@@ -13,7 +12,6 @@ export const loadCanvasProjectPage = () => import("@/pages/canvas/project");
|
||||
export const loadCreatePage = workspaceRouteLoaders.create;
|
||||
export const loadProjectDetailPage = workspaceRouteLoaders.projectDetail;
|
||||
export const loadProjectsPage = workspaceRouteLoaders.projects;
|
||||
export const loadWalletPage = workspaceRouteLoaders.wallet;
|
||||
|
||||
export function preloadWorkspaceRoute(pathnameOrSlug: string) {
|
||||
// 根路径就是创作页,预加载时仍映射到其内部模块名。
|
||||
|
||||
11
web/src/lib/workspace-wallet.ts
Normal file
11
web/src/lib/workspace-wallet.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const WORKSPACE_WALLET_OPEN_EVENT = "wallet:open";
|
||||
|
||||
export type WorkspaceWalletOpenDetail = {
|
||||
paymentOrderId?: string;
|
||||
paymentInvalid?: boolean;
|
||||
};
|
||||
|
||||
/** 工作台任意入口打开积分中心弹窗,不再进入独立钱包页。 */
|
||||
export function openWorkspaceWallet(detail: WorkspaceWalletOpenDetail = {}) {
|
||||
window.dispatchEvent(new CustomEvent<WorkspaceWalletOpenDetail>(WORKSPACE_WALLET_OPEN_EVENT, { detail }));
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const workspaceFeatureRows: FeatureRow[] = [
|
||||
{
|
||||
key: "creditsEnabled",
|
||||
title: "积分计费",
|
||||
description: "控制钱包入口及新任务的积分预授权与结算。",
|
||||
description: "控制积分中心弹窗及新任务的积分预授权与结算。",
|
||||
icon: <Coins className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Clapperboard, CloudDownload, CloudUpload, Coins, CopyPlus, Focus, Folde
|
||||
import { Button, Dropdown, Tooltip } from "antd";
|
||||
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { openWorkspaceWallet } from "@/lib/workspace-wallet";
|
||||
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
|
||||
import type { CanvasShortDramaProgress } from "@/lib/canvas/canvas-short-drama";
|
||||
@@ -232,16 +233,17 @@ export function CanvasTopBar({
|
||||
</Dropdown>
|
||||
</CanvasTopBarTooltip>
|
||||
{user && creditsEnabled ? (
|
||||
<CanvasTopBarTooltip label="进入积分超市">
|
||||
<Link
|
||||
to="/wallet"
|
||||
<CanvasTopBarTooltip label="打开积分中心">
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-topbar-action inline-flex h-9 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium tabular-nums"
|
||||
style={{ color: theme.node.text }}
|
||||
aria-label="进入积分超市"
|
||||
aria-label="打开积分中心"
|
||||
onClick={() => openWorkspaceWallet()}
|
||||
>
|
||||
{refreshing && availableMicrocredits === null ? <LoaderCircle className="size-3.5 animate-spin opacity-60" style={{ color: theme.accent.primary }} /> : <Coins className="size-3.5" style={{ color: theme.accent.primary }} />}
|
||||
<span>{availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 })}</span>
|
||||
</Link>
|
||||
</button>
|
||||
</CanvasTopBarTooltip>
|
||||
) : null}
|
||||
<CanvasTopBarTooltip label="进入专注模式(Shift + Ctrl/Cmd + F)">
|
||||
|
||||
@@ -1,681 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons";
|
||||
import { App, Button, Grid, Input, Modal, QRCode, Skeleton, Table } from "antd";
|
||||
import { StatusBadge } from "@/components/ui/base/badges/status-badge";
|
||||
import { SegmentedControl } from "@/components/ui/base/segmented-control";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { ArrowDownLeft, ArrowUpRight, CalendarCheck, Coins, CreditCard, RefreshCw, RotateCcw, ShieldCheck, SlidersHorizontal, Sparkles, TicketCheck } from "lucide-react";
|
||||
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import { PaginationBar, TableSurface } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { checkinCredits, getWallet, redeemCredits, type CreditLedgerEntry, type WalletSummary } from "@/services/api/wallet";
|
||||
import { closePaymentOrder, createPaymentOrder, getPaymentOrder, listPaymentProviders, listTopupProducts, queryPaymentOrder, refreshPaymentCheckout, type PaymentOrder, type PaymentProvider, type TopupProduct } from "@/services/api/payments";
|
||||
import { modelDisplayName, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
type LedgerFilter = "all" | "income" | "consume" | "refund";
|
||||
|
||||
const ledgerFilterOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "充值与调整", value: "income" },
|
||||
{ label: "模型消费", value: "consume" },
|
||||
{ label: "退款", value: "refund" },
|
||||
];
|
||||
|
||||
export default function WalletPage() {
|
||||
const { message } = App.useApp();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const config = useEffectiveConfig();
|
||||
const [wallet, setWallet] = useState<WalletSummary | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [filter, setFilter] = useState<LedgerFilter>("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [redeeming, setRedeeming] = useState(false);
|
||||
const [checkingIn, setCheckingIn] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [paymentProducts, setPaymentProducts] = useState<TopupProduct[]>([]);
|
||||
const [paymentProviders, setPaymentProviders] = useState<PaymentProvider[]>([]);
|
||||
const [paymentsLoading, setPaymentsLoading] = useState(true);
|
||||
const [selectedProductId, setSelectedProductId] = useState("");
|
||||
const [selectedProviderId, setSelectedProviderId] = useState("");
|
||||
const [paymentCreating, setPaymentCreating] = useState(false);
|
||||
const [paymentOrder, setPaymentOrder] = useState<PaymentOrder | null>(null);
|
||||
const [paymentModalOpen, setPaymentModalOpen] = useState(false);
|
||||
const [paymentQuerying, setPaymentQuerying] = useState(false);
|
||||
const [clock, setClock] = useState(Date.now());
|
||||
const requestSequence = useRef(0);
|
||||
const paymentPollSequence = useRef(0);
|
||||
const paymentRefreshInFlight = useRef(false);
|
||||
const nextPaymentRefreshAt = useRef(0);
|
||||
const paymentIdempotencyKey = useRef("");
|
||||
const completedPaymentOrderId = useRef("");
|
||||
|
||||
const reload = async (targetPage = page, targetPageSize = pageSize) => {
|
||||
const sequence = ++requestSequence.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const nextWallet = await getWallet(targetPage, targetPageSize, filter);
|
||||
if (sequence === requestSequence.current) setWallet(nextWallet);
|
||||
} catch (error) {
|
||||
if (sequence === requestSequence.current) message.error(error instanceof Error ? error.message : "读取积分记录失败");
|
||||
} finally {
|
||||
if (sequence === requestSequence.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reload(page, pageSize);
|
||||
}, [filter, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setPaymentsLoading(true);
|
||||
Promise.all([listTopupProducts(), listPaymentProviders()])
|
||||
.then(([productsResult, providersResult]) => {
|
||||
if (!active) return;
|
||||
setPaymentProducts(productsResult.products);
|
||||
setPaymentProviders(providersResult.providers);
|
||||
setSelectedProductId((current) => current || productsResult.products[0]?.id || "");
|
||||
setSelectedProviderId((current) => current || providersResult.providers[0]?.id || "");
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) message.error(error instanceof Error ? error.message : "读取在线充值配置失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setPaymentsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const returnedOrderId = new URLSearchParams(window.location.search).get("paymentOrder");
|
||||
if (!returnedOrderId) return;
|
||||
getPaymentOrder(returnedOrderId)
|
||||
.then(({ order }) => {
|
||||
setPaymentOrder(order);
|
||||
nextPaymentRefreshAt.current = 0;
|
||||
setPaymentModalOpen(true);
|
||||
if (order.status === "pending") void confirmPayment(order.id);
|
||||
})
|
||||
.catch((error) => message.error(error instanceof Error ? error.message : "读取支付结果失败"));
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paymentModalOpen || !paymentOrder || !["created", "pending", "closing"].includes(paymentOrder.status)) return;
|
||||
const interval = window.setInterval(() => {
|
||||
setClock(Date.now());
|
||||
const sequence = ++paymentPollSequence.current;
|
||||
void getPaymentOrder(paymentOrder.id)
|
||||
.then(({ order }) => {
|
||||
if (sequence !== paymentPollSequence.current) return;
|
||||
setPaymentOrder(order);
|
||||
if (order.status === "credited") {
|
||||
void paymentCompleted(order.id);
|
||||
return;
|
||||
}
|
||||
const checkoutExpired = order.checkout.mode === "qr_code" && order.checkout.expiresAt && new Date(order.checkout.expiresAt).getTime() <= Date.now();
|
||||
if (checkoutExpired && new Date(order.expiresAt).getTime() > Date.now() && !paymentRefreshInFlight.current && Date.now() >= nextPaymentRefreshAt.current) {
|
||||
paymentRefreshInFlight.current = true;
|
||||
nextPaymentRefreshAt.current = Date.now() + 15 * 60_000;
|
||||
void refreshPaymentCheckout(order.id)
|
||||
.then(async (result) => {
|
||||
setPaymentOrder(result.order);
|
||||
if (result.order.status === "credited") await paymentCompleted(result.order.id);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
paymentRefreshInFlight.current = false;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 2_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [paymentModalOpen, paymentOrder?.id, paymentOrder?.status]);
|
||||
|
||||
const selectedProduct = useMemo(() => paymentProducts.find((item) => item.id === selectedProductId), [paymentProducts, selectedProductId]);
|
||||
const selectedProvider = useMemo(() => paymentProviders.find((item) => item.id === selectedProviderId), [paymentProviders, selectedProviderId]);
|
||||
|
||||
useEffect(() => {
|
||||
paymentIdempotencyKey.current = "";
|
||||
}, [selectedProductId, selectedProviderId]);
|
||||
|
||||
const redeem = async () => {
|
||||
const normalized = code.trim().toLowerCase();
|
||||
if (normalized.length !== 32) {
|
||||
message.error("请输入完整的 32 位兑换码");
|
||||
return;
|
||||
}
|
||||
setRedeeming(true);
|
||||
try {
|
||||
await redeemCredits(normalized);
|
||||
setCode("");
|
||||
setPage(1);
|
||||
await reload(1, pageSize);
|
||||
window.dispatchEvent(new CustomEvent("wallet:updated"));
|
||||
message.success("兑换成功,积分已到账");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "兑换失败");
|
||||
} finally {
|
||||
setRedeeming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkin = async () => {
|
||||
setCheckingIn(true);
|
||||
try {
|
||||
await checkinCredits();
|
||||
await reload(page, pageSize);
|
||||
window.dispatchEvent(new CustomEvent("wallet:updated"));
|
||||
message.success("签到成功,积分已到账");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "签到失败");
|
||||
} finally {
|
||||
setCheckingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startPayment = async () => {
|
||||
if (!selectedProduct || !selectedProvider) {
|
||||
message.error("请选择充值商品和支付方式");
|
||||
return;
|
||||
}
|
||||
setPaymentCreating(true);
|
||||
try {
|
||||
if (!paymentIdempotencyKey.current) paymentIdempotencyKey.current = crypto.randomUUID();
|
||||
const result = await createPaymentOrder({ productId: selectedProduct.id, providerId: selectedProvider.id, idempotencyKey: paymentIdempotencyKey.current });
|
||||
paymentIdempotencyKey.current = "";
|
||||
setPaymentOrder(result.order);
|
||||
nextPaymentRefreshAt.current = 0;
|
||||
if (result.order.status === "credited") {
|
||||
setPaymentModalOpen(true);
|
||||
await paymentCompleted(result.order.id);
|
||||
return;
|
||||
}
|
||||
if (result.order.checkout.mode === "redirect" && result.order.checkout.url) {
|
||||
window.location.assign(result.order.checkout.url);
|
||||
return;
|
||||
}
|
||||
setPaymentModalOpen(true);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "创建支付订单失败");
|
||||
} finally {
|
||||
setPaymentCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function confirmPayment(orderId = paymentOrder?.id) {
|
||||
if (!orderId) return;
|
||||
setPaymentQuerying(true);
|
||||
try {
|
||||
const result = await queryPaymentOrder(orderId);
|
||||
setPaymentOrder(result.order);
|
||||
if (result.order.status === "credited") await paymentCompleted(result.order.id);
|
||||
else if (result.order.status === "closed") message.warning("订单已关闭,未产生积分充值");
|
||||
else message.info("渠道尚未确认支付,请稍后再试");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "查询支付结果失败");
|
||||
} finally {
|
||||
setPaymentQuerying(false);
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPayment = async () => {
|
||||
if (!paymentOrder) return;
|
||||
setPaymentQuerying(true);
|
||||
try {
|
||||
const result = await closePaymentOrder(paymentOrder.id);
|
||||
setPaymentOrder(result.order);
|
||||
if (result.order.status === "credited") await paymentCompleted(result.order.id);
|
||||
else message.success("未支付订单已关闭");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "关闭订单失败");
|
||||
} finally {
|
||||
setPaymentQuerying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const retryPaymentCheckout = async () => {
|
||||
if (!paymentOrder) return;
|
||||
setPaymentQuerying(true);
|
||||
try {
|
||||
const result = await refreshPaymentCheckout(paymentOrder.id);
|
||||
setPaymentOrder(result.order);
|
||||
if (result.order.status === "credited") await paymentCompleted(result.order.id);
|
||||
else if (result.order.status === "closed") message.warning("订单已关闭,未产生积分充值");
|
||||
else if (result.order.checkout.mode === "redirect" && result.order.checkout.url) {
|
||||
window.location.assign(result.order.checkout.url);
|
||||
return;
|
||||
} else message.success("支付二维码已重新生成");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "重新生成支付收银台失败");
|
||||
} finally {
|
||||
setPaymentQuerying(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function paymentCompleted(orderId: string) {
|
||||
if (completedPaymentOrderId.current === orderId) return;
|
||||
completedPaymentOrderId.current = orderId;
|
||||
await reload(1, pageSize);
|
||||
setPage(1);
|
||||
window.dispatchEvent(new CustomEvent("wallet:updated"));
|
||||
message.success("支付成功,积分已到账");
|
||||
}
|
||||
|
||||
const entries = wallet?.entries || [];
|
||||
const account = wallet?.account;
|
||||
const totalMicrocredits = (account?.availableMicrocredits || 0) + (account?.reservedMicrocredits || 0);
|
||||
|
||||
const columns: ColumnsType<CreditLedgerEntry> = [
|
||||
{ title: "发生时间", dataIndex: "createdAt", width: 180, render: formatTime },
|
||||
{ title: "类型", dataIndex: "type", width: 120, render: (type) => <LedgerTypeTag type={type} /> },
|
||||
{
|
||||
title: "明细",
|
||||
width: 400,
|
||||
ellipsis: true,
|
||||
render: (_, entry) => (
|
||||
<div className="min-w-0 max-w-full overflow-hidden" title={[ledgerModelName(config, entry), [sceneLabel(entry.scene), entry.note].filter(Boolean).join(" · ")].filter(Boolean).join("\n")}>
|
||||
<div className="truncate font-medium">{ledgerModelName(config, entry)}</div>
|
||||
<div className="mt-1 truncate text-xs text-foreground/50">{[sceneLabel(entry.scene), entry.note].filter(Boolean).join(" · ") || "无补充说明"}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "积分变化",
|
||||
dataIndex: "amountMicrocredits",
|
||||
width: 145,
|
||||
align: "right",
|
||||
render: (value: number) => <CreditDelta value={value} />,
|
||||
},
|
||||
{ title: "变更后余额", dataIndex: "availableAfterMicrocredits", width: 145, align: "right", render: (value) => <span className="tabular-nums">{formatCredits(value)}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="app-user-content app-workspace-scroll library-page wallet-library-page wallet-market-page relative h-full overflow-y-auto text-foreground">
|
||||
<div className="relative w-full px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="studio-band">
|
||||
<motion.header
|
||||
initial={reducedMotion ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}
|
||||
className="app-page-header flex flex-wrap items-start justify-between gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[var(--fs-heading-lg)] font-semibold leading-7">积分超市</h1>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/58">用真实余额补充创作额度,兑换码、充值与消费记录都可追溯。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="app-projects-header-meta wallet-credit-meta">
|
||||
<Coins className="size-3" />
|
||||
可用 {formatCredits(account?.availableMicrocredits || 0, 6)}
|
||||
</span>
|
||||
<Button
|
||||
className="library-primary-action"
|
||||
icon={<CalendarCheck className="size-4" />}
|
||||
type={wallet?.policy.checkedInToday ? "default" : "primary"}
|
||||
loading={checkingIn}
|
||||
disabled={wallet?.policy.checkedInToday}
|
||||
onClick={() => void checkin()}
|
||||
>
|
||||
{wallet?.policy.checkedInToday ? "今日已签到" : `签到 +${formatCredits(wallet?.policy.checkinBonusMicrocredits || 0)}`}
|
||||
</Button>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
|
||||
刷新余额
|
||||
</Button>
|
||||
</div>
|
||||
</motion.header>
|
||||
</div>
|
||||
|
||||
<section className="library-feature-grid mt-6 grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.65fr)]">
|
||||
<section className="credit-balance-card">
|
||||
<div className="wallet-balance-inner">
|
||||
<div className="wallet-balance-primary">
|
||||
<div className="wallet-balance-heading">
|
||||
<span className="library-icon-tile wallet-balance-icon">
|
||||
<Coins />
|
||||
</span>
|
||||
<div>
|
||||
<strong>我的创作积分</strong>
|
||||
<span>最近更新 {formatTime(account?.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wallet-balance-number">
|
||||
<strong>{formatCredits(account?.availableMicrocredits || 0, 6)}</strong>
|
||||
<span>积分</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wallet-balance-details">
|
||||
<span className="wallet-account-status">
|
||||
<ShieldCheck />
|
||||
账户正常
|
||||
</span>
|
||||
<BalanceMetric label="冻结积分" description="调用中或待核对" value={account?.reservedMicrocredits || 0} icon={<TicketCheck className="size-4" />} />
|
||||
<BalanceMetric label="账户总额" description="可用与冻结合计" value={totalMicrocredits} icon={<Coins className="size-4" />} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<motion.div
|
||||
initial={reducedMotion ? false : { opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}
|
||||
className="wallet-redeem-panel app-workspace-surface flex flex-col rounded-lg p-5 backdrop-blur-xl sm:p-6"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="wallet-redeem-icon grid size-9 shrink-0 place-items-center rounded-lg">
|
||||
<TicketCheck className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">兑换码入账</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/55">输入管理员发放的 32 位兑换码。</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-6 block">
|
||||
<span className="text-xs font-medium text-foreground/70">兑换码</span>
|
||||
<Input
|
||||
className="mt-2 font-mono"
|
||||
size="large"
|
||||
value={code}
|
||||
maxLength={32}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
onChange={(event) => setCode(event.target.value.replace(/[-\s]/g, ""))}
|
||||
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
onPressEnter={() => void redeem()}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-foreground/45">
|
||||
<span>兑换成功后立即到账</span>
|
||||
<span className="tabular-nums">{code.length} / 32</span>
|
||||
</div>
|
||||
<Button className="mt-5" type="primary" size="large" block loading={redeeming} disabled={code.length !== 32} onClick={() => void redeem()}>
|
||||
兑换积分
|
||||
</Button>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="app-workspace-surface mt-6 rounded-lg p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-foreground/6 text-foreground/70">
|
||||
<CreditCard className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">购买创作积分</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/55">支付成功后自动充值积分。平台不提供退款,请确认商品和金额后付款。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedProduct ? (
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-foreground/45">应付金额</div>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums">¥ {(selectedProduct.amountFen / 100).toFixed(2)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{paymentsLoading ? (
|
||||
<Skeleton className="mt-6" active paragraph={{ rows: 2 }} />
|
||||
) : paymentProducts.length && paymentProviders.length ? (
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-medium text-foreground/65">选择充值商品</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{paymentProducts.map((product) => (
|
||||
<button
|
||||
key={product.id}
|
||||
type="button"
|
||||
className={`rounded-lg border px-4 py-3 text-left transition-colors ${selectedProductId === product.id ? "border-primary bg-primary/5" : "border-border/70 bg-background/35 hover:border-foreground/25"}`}
|
||||
onClick={() => setSelectedProductId(product.id)}
|
||||
>
|
||||
<div className="font-medium">{product.name}</div>
|
||||
<div className="mt-1 text-xs text-foreground/48">
|
||||
{formatCredits(product.creditsMicrocredits, 6)} 积分 · ¥ {(product.amountFen / 100).toFixed(2)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-medium text-foreground/65">选择支付方式</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{paymentProviders.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-3 text-left transition-colors ${selectedProviderId === provider.id ? "border-primary bg-primary/5" : "border-border/70 bg-background/35 hover:border-foreground/25"}`}
|
||||
onClick={() => setSelectedProviderId(provider.id)}
|
||||
>
|
||||
<PaymentBrandIcon providerId={provider.id} />
|
||||
<span className="min-w-0 truncate text-sm font-medium">{provider.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button className="mt-3" type="primary" size="large" block loading={paymentCreating} disabled={!selectedProduct || !selectedProvider} onClick={() => void startPayment()}>
|
||||
{selectedProvider?.checkoutMode === "qr_code" ? "生成支付二维码" : `前往${selectedProvider?.name || "支付"}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<WorkspaceState compact icon="wallet" title="在线充值暂未开放" description="管理员配置充值商品和支付渠道后即可使用。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="wallet-ledger-panel app-workspace-surface mt-9 rounded-lg p-4 backdrop-blur-xl sm:p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">积分使用明细</h2>
|
||||
<p className="mt-1 text-xs text-foreground/55">当前展示最近 {wallet?.entries.length || 0} 条记录。</p>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
block={!screens.sm}
|
||||
value={filter}
|
||||
options={ledgerFilterOptions}
|
||||
onChange={(value) => {
|
||||
setFilter(value as LedgerFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{screens.md ? (
|
||||
<TableSurface className="mt-0 rounded-xl border-border/70 bg-transparent">
|
||||
<Table className="app-data-table wallet-ledger-table" rowKey="id" size="middle" loading={loading} columns={columns} dataSource={entries} pagination={false} tableLayout="fixed" scroll={{ x: 990 }} />
|
||||
</TableSurface>
|
||||
) : (
|
||||
<div className="grid gap-1 overflow-hidden rounded-md bg-transparent">
|
||||
{entries.length ? (
|
||||
entries.map((entry) => <LedgerMobileRow key={entry.id} config={config} entry={entry} />)
|
||||
) : (
|
||||
<WorkspaceState compact icon="wallet" title="没有匹配的积分记录" description="切换流水类型,或完成一次生成后再回来查看。" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<PaginationBar
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={wallet?.total || 0}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
onChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={paymentModalOpen}
|
||||
title={paymentOrder?.status === "credited" ? "充值完成" : paymentOrder?.checkout.mode === "qr_code" ? "扫码支付" : "确认支付结果"}
|
||||
onCancel={() => setPaymentModalOpen(false)}
|
||||
footer={
|
||||
paymentOrder?.status === "pending"
|
||||
? [
|
||||
<Button key="close" danger disabled={paymentQuerying} onClick={() => void cancelPayment()}>
|
||||
关闭订单
|
||||
</Button>,
|
||||
<Button key="query" type="primary" loading={paymentQuerying} onClick={() => void confirmPayment()}>
|
||||
我已完成支付
|
||||
</Button>,
|
||||
]
|
||||
: paymentOrder?.status === "create_failed"
|
||||
? [
|
||||
<Button key="done" onClick={() => setPaymentModalOpen(false)}>
|
||||
稍后处理
|
||||
</Button>,
|
||||
<Button key="retry" type="primary" loading={paymentQuerying} onClick={() => void retryPaymentCheckout()}>
|
||||
{paymentOrder.checkout.mode === "redirect" ? "重新打开收银台" : "重新生成二维码"}
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button key="done" type="primary" onClick={() => setPaymentModalOpen(false)}>
|
||||
完成
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
destroyOnHidden
|
||||
>
|
||||
{paymentOrder ? (
|
||||
<div className="py-2 text-center">
|
||||
<div className="mx-auto grid size-12 place-items-center rounded-xl bg-foreground/5">
|
||||
<PaymentBrandIcon providerId={paymentOrder.providerId} size="large" />
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-semibold">¥ {(paymentOrder.amountFen / 100).toFixed(2)}</div>
|
||||
<div className="mt-1 text-xs text-foreground/48">
|
||||
{paymentOrder.productName} · {formatCredits(paymentOrder.creditsMicrocredits, 6)} 积分
|
||||
</div>
|
||||
{paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? (
|
||||
<div className="mt-5 flex flex-col items-center">
|
||||
<QRCode value={paymentOrder.checkout.value} size={208} bordered={false} />
|
||||
<p className="mt-3 text-sm font-medium">请使用支付应用扫码完成支付</p>
|
||||
<p className="mt-1 text-xs text-foreground/45">请勿保存二维码后长按识别</p>
|
||||
</div>
|
||||
) : null}
|
||||
<PaymentOrderStatus order={paymentOrder} now={clock} />
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function BalanceMetric({ label, description, value, icon }: { label: string; description: string; value: number; icon: ReactNode }) {
|
||||
return (
|
||||
<div className="wallet-balance-metric">
|
||||
<span className="wallet-balance-metric-icon">{icon}</span>
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
<strong>{formatCredits(value, 6)}</strong>
|
||||
<small>{description}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerMobileRow({ config, entry }: { config: AiConfig; entry: CreditLedgerEntry }) {
|
||||
const meta = ledgerTypeMeta(entry.type);
|
||||
return (
|
||||
<article className="flex items-start gap-3 rounded-md bg-foreground/[.025] px-4 py-4">
|
||||
<span className={`mt-0.5 grid size-8 shrink-0 place-items-center rounded-md ${meta.iconClass}`}>{meta.icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{ledgerModelName(config, entry)}</div>
|
||||
<div className="mt-1 text-xs text-foreground/45">{formatTime(entry.createdAt)}</div>
|
||||
</div>
|
||||
<CreditDelta value={entry.amountMicrocredits} />
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-2 break-words text-xs leading-5 text-foreground/55">{[sceneLabel(entry.scene), entry.note].filter(Boolean).join(" · ") || meta.label}</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CreditDelta({ value }: { value: number }) {
|
||||
const colorClass = value > 0 ? "text-emerald-600 dark:text-emerald-400" : value < 0 ? "text-rose-600 dark:text-rose-400" : "text-foreground/60";
|
||||
return (
|
||||
<span className={`shrink-0 font-medium tabular-nums ${colorClass}`}>
|
||||
{value > 0 ? "+" : ""}
|
||||
{formatCredits(value, 6)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerTypeTag({ type }: { type: CreditLedgerEntry["type"] }) {
|
||||
const meta = ledgerTypeMeta(type);
|
||||
// 用项目 StatusBadge 而不是 antd Tag:Tag 的 default/error 配色在本项目主题下不可读。
|
||||
return <StatusBadge variant="filled" tone={meta.tone} label={meta.label} />;
|
||||
}
|
||||
|
||||
function ledgerTypeMeta(type: CreditLedgerEntry["type"]) {
|
||||
// tone 走 StatusBadge 的语义色,明暗主题都自适应;不再用 antd Tag 的 default 配色。
|
||||
const values = {
|
||||
redeem: { label: "兑换充值", tone: "neutral", icon: <ArrowDownLeft className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" },
|
||||
payment_topup: { label: "在线充值", tone: "success", icon: <CreditCard className="size-4" />, iconClass: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-300" },
|
||||
admin_grant: { label: "管理员充值", tone: "neutral", icon: <ArrowDownLeft className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" },
|
||||
consume: { label: "模型消费", tone: "error", icon: <Sparkles className="size-4" />, iconClass: "bg-rose-500/10 text-rose-600 dark:text-rose-300" },
|
||||
reserve: { label: "积分冻结", tone: "warning", icon: <ArrowUpRight className="size-4" />, iconClass: "bg-amber-500/10 text-amber-600 dark:text-amber-300" },
|
||||
refund: { label: "消费退款", tone: "warning", icon: <RotateCcw className="size-4" />, iconClass: "bg-amber-500/10 text-amber-600 dark:text-amber-300" },
|
||||
admin_adjustment: { label: "管理员调账", tone: "neutral", icon: <SlidersHorizontal className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" },
|
||||
signup_bonus: { label: "注册奖励", tone: "neutral", icon: <Sparkles className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" },
|
||||
checkin_bonus: { label: "签到奖励", tone: "neutral", icon: <CalendarCheck className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" },
|
||||
} as const;
|
||||
return values[type] || { label: "其他积分变动", tone: "neutral", icon: <ArrowUpRight className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" };
|
||||
}
|
||||
|
||||
function ledgerTitle(entry: CreditLedgerEntry) {
|
||||
if (entry.type === "redeem") return "兑换码充值";
|
||||
if (entry.type === "payment_topup") return "在线支付充值";
|
||||
if (entry.type === "refund") return "模型消费退款";
|
||||
if (entry.type === "consume") return "模型调用";
|
||||
if (entry.type === "signup_bonus") return "新用户注册奖励";
|
||||
if (entry.type === "checkin_bonus") return "每日签到奖励";
|
||||
return entry.note || "积分调整";
|
||||
}
|
||||
|
||||
function PaymentBrandIcon({ providerId, size = "normal" }: { providerId: string; size?: "normal" | "large" }) {
|
||||
const className = size === "large" ? "text-3xl" : "text-xl";
|
||||
if (providerId === "wechat-native") return <WechatFilled className={`${className} text-[#07C160]`} aria-label="微信支付" />;
|
||||
return <CreditCard className={`${className} text-foreground/60`} aria-label="支付" />;
|
||||
}
|
||||
|
||||
function PaymentOrderStatus({ order, now }: { order: PaymentOrder; now: number }) {
|
||||
const remainingSeconds = Math.max(0, Math.floor((new Date(order.expiresAt).getTime() - now) / 1000));
|
||||
if (order.status === "credited") return <div className="mt-5 rounded-lg bg-emerald-500/8 px-4 py-3 text-sm font-medium text-emerald-600 dark:text-emerald-300">支付已确认,积分已经到账</div>;
|
||||
if (order.status === "closed") return <div className="mt-5 rounded-lg bg-foreground/5 px-4 py-3 text-sm text-foreground/60">订单已关闭,未产生积分充值</div>;
|
||||
if (order.status === "create_failed") {
|
||||
const action = order.checkout.mode === "redirect" ? "重新打开收银台" : "重新生成支付二维码";
|
||||
return <div className="mt-5 rounded-lg bg-rose-500/8 px-4 py-3 text-sm text-rose-600 dark:text-rose-300">创建渠道订单失败,可使用下方按钮{action}</div>;
|
||||
}
|
||||
return <div className="mt-5 text-xs text-foreground/48">订单剩余支付时间 {formatCountdown(remainingSeconds)},页面将自动确认支付结果</div>;
|
||||
}
|
||||
|
||||
function formatCountdown(seconds: number) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const rest = seconds % 60;
|
||||
return [hours, minutes, rest].map((value) => String(value).padStart(2, "0")).join(":");
|
||||
}
|
||||
|
||||
function ledgerModelName(config: AiConfig, entry: CreditLedgerEntry) {
|
||||
return entry.model ? modelDisplayName(config, entry.model) : ledgerTitle(entry);
|
||||
}
|
||||
|
||||
function sceneLabel(scene?: string) {
|
||||
const labels: Record<string, string> = { image: "图片生成", text: "文本生成", video: "视频生成", audio: "音频生成", storyboard: "分镜生成" };
|
||||
return scene ? labels[scene] || "其他场景" : "";
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createBrowserRouter, Navigate, Outlet, useLocation } from "react-router
|
||||
|
||||
import { RequireAuth } from "@/components/auth/require-auth";
|
||||
import { FullScreenLoader, WorkspaceRouteLoader } from "@/components/ui/aceternity/full-screen-loader";
|
||||
import { loadAssetsPage, loadCanvasPage, loadCanvasProjectPage, loadCreatePage, loadProjectDetailPage, loadProjectsPage, loadWalletPage } from "@/lib/workspace-route-modules";
|
||||
import { loadAssetsPage, loadCanvasPage, loadCanvasProjectPage, loadCreatePage, loadProjectDetailPage, loadProjectsPage } from "@/lib/workspace-route-modules";
|
||||
import { CanvasRefreshShell } from "@/pages/canvas/canvas-refresh-shell";
|
||||
import { AuthScene } from "@/pages/auth/auth-scene";
|
||||
import RouteErrorPage from "@/pages/route-error";
|
||||
@@ -47,7 +47,6 @@ const SkillsPage = lazy(() => import("@/pages/skills"));
|
||||
const PluginsPage = lazy(() => import("@/pages/plugins"));
|
||||
const EagleLibraryPage = lazy(() => import("@/pages/plugins/eagle"));
|
||||
const TasksPage = lazy(() => import("@/pages/tasks"));
|
||||
const WalletPage = lazy(loadWalletPage);
|
||||
const ProjectsPage = lazy(loadProjectsPage);
|
||||
const ProjectDetailPage = lazy(loadProjectDetailPage);
|
||||
const SettingsPage = lazy(() => import("@/pages/settings"));
|
||||
@@ -133,11 +132,7 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: "/wallet",
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<RequireFeature feature="creditsEnabled">{deferred(<WalletPage />)}</RequireFeature>
|
||||
</RequireAuth>
|
||||
),
|
||||
element: <RequireAuth>{null}</RequireAuth>,
|
||||
},
|
||||
{ path: "/settings", element: <RequireAuth>{deferred(<SettingsPage />)}</RequireAuth> },
|
||||
{ path: "/test-voice-recording", element: <RequireAuth>{deferred(<TestVoiceRecording />)}</RequireAuth> },
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("workspace route loading", () => {
|
||||
const modules = source("../src/lib/workspace-route-modules.ts");
|
||||
const navigation = source("../src/components/layout/workspace-sidebar-nav.tsx");
|
||||
|
||||
for (const route of ["projects", "canvas", "assets", "wallet", "create"]) {
|
||||
for (const route of ["projects", "canvas", "assets", "create"]) {
|
||||
expect(modules).toContain(`${route}: () => import`);
|
||||
}
|
||||
expect(modules).toContain('projectDetail: () => import("@/pages/projects/detail")');
|
||||
@@ -123,15 +123,29 @@ describe("workspace route loading", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("wallet balance summary", () => {
|
||||
test("uses the workspace surface instead of an inverted primary button surface", () => {
|
||||
describe("workspace wallet entry", () => {
|
||||
test("opens the credits modal instead of a dedicated wallet page", () => {
|
||||
const router = source("../src/router.tsx");
|
||||
const modules = source("../src/lib/workspace-route-modules.ts");
|
||||
const host = source("../src/components/layout/workspace-wallet-modal.tsx");
|
||||
const palette = source("../src/components/layout/workspace-command-palette.tsx");
|
||||
const canvasTopBar = source("../src/pages/canvas/canvas-project-top-bar.tsx");
|
||||
const topBar = source("../src/components/layout/workspace-top-bar.tsx");
|
||||
const css = source("../src/styles/globals.css");
|
||||
const rule = css.match(/\.credit-balance-card \{[^}]+}/)?.[0] || "";
|
||||
|
||||
expect(rule).toContain("background: var(--library-surface)");
|
||||
expect(rule).toContain("color: var(--foreground)");
|
||||
expect(rule).not.toContain("--btn-solid-bg");
|
||||
expect(css.match(/\.wallet-balance-inner \{/g)).toHaveLength(3);
|
||||
expect(css).not.toContain(".wallet-library-page .wallet-balance-inner { padding-left: 0; }");
|
||||
expect(router).toContain('path: "/wallet"');
|
||||
expect(router).toContain("element: <RequireAuth>{null}</RequireAuth>");
|
||||
expect(router).not.toContain("WalletPage");
|
||||
expect(router).not.toContain("loadWalletPage");
|
||||
expect(modules).not.toContain("pages/wallet");
|
||||
expect(host).toContain("pathname !== \"/wallet\"");
|
||||
expect(host).toContain("openWorkspaceWallet");
|
||||
expect(palette).toContain('run: () => openWorkspaceWallet()');
|
||||
expect(palette).not.toContain('"/wallet"');
|
||||
expect(canvasTopBar).toContain("openWorkspaceWallet()");
|
||||
expect(canvasTopBar).not.toContain('to="/wallet"');
|
||||
expect(topBar).toContain("openWorkspaceWallet()");
|
||||
expect(css).not.toContain(".wallet-library-page");
|
||||
expect(css).not.toContain(".wallet-market-page");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user