diff --git a/web/FRONTEND_FEATURE_TOGGLE.md b/web/FRONTEND_FEATURE_TOGGLE.md
index f0c5f29e6..9ee42e32f 100644
--- a/web/FRONTEND_FEATURE_TOGGLE.md
+++ b/web/FRONTEND_FEATURE_TOGGLE.md
@@ -134,7 +134,7 @@ if (key === "frontendModelsEnabled" && !enabled) {
│ ☑️ 任务 /tasks [ON] │
│ 关闭后仅隐藏并拦截任务中心页面... │
├─────────────────────────────────────────────────────────────────┤
-│ 💰 积分中心 /wallet [ON] │
+│ 💰 积分中心 弹窗 [ON] │
│ 关闭后隐藏用户积分入口... │
├─────────────────────────────────────────────────────────────────┤
│ 📡 自定义渠道 /settings?section... [ON] │
diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx
index 3c27ad0ba..0775ad1c9 100644
--- a/web/src/components/layout/app-top-nav.tsx
+++ b/web/src/components/layout/app-top-nav.tsx
@@ -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 ? setPaletteOpen(false)} /> : null}
+
>
);
diff --git a/web/src/components/layout/workspace-account-menu.tsx b/web/src/components/layout/workspace-account-menu.tsx
index a4f6d771c..1cb26c42d 100644
--- a/web/src/components/layout/workspace-account-menu.tsx
+++ b/web/src/components/layout/workspace-account-menu.tsx
@@ -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 ;
@@ -34,7 +32,7 @@ export function WorkspaceAccountMenu() {
onOpenChange={setMenuOpen}
content={(
-
setMenuOpen(false)} onWallet={() => { setMenuOpen(false); setWalletOpen(true); }} />
+ setMenuOpen(false)} onWallet={() => { setMenuOpen(false); openWorkspaceWallet(); }} />
@@ -51,7 +49,7 @@ export function WorkspaceAccountMenu() {
- {creditsEnabled ?
setWalletOpen(false)} /> : null}>
+ >
) : (
diff --git a/web/src/components/layout/workspace-command-palette.tsx b/web/src/components/layout/workspace-command-palette.tsx
index 22b6594e7..a85b7bc03 100644
--- a/web/src/components/layout/workspace-command-palette.tsx
+++ b/web/src/components/layout/workspace-command-palette.tsx
@@ -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(null);
const entries = useMemo(() => {
- 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"),
: null}
diff --git a/web/src/hooks/use-wallet-balance.ts b/web/src/hooks/use-wallet-balance.ts
index 381bc54bc..6567efdca 100644
--- a/web/src/hooks/use-wallet-balance.ts
+++ b/web/src/hooks/use-wallet-balance.ts
@@ -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 => {
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,
};
diff --git a/web/src/lib/workspace-route-modules.ts b/web/src/lib/workspace-route-modules.ts
index 673431322..fca0a5351 100644
--- a/web/src/lib/workspace-route-modules.ts
+++ b/web/src/lib/workspace-route-modules.ts
@@ -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) {
// 根路径就是创作页,预加载时仍映射到其内部模块名。
diff --git a/web/src/lib/workspace-wallet.ts b/web/src/lib/workspace-wallet.ts
new file mode 100644
index 000000000..41dff5b02
--- /dev/null
+++ b/web/src/lib/workspace-wallet.ts
@@ -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(WORKSPACE_WALLET_OPEN_EVENT, { detail }));
+}
diff --git a/web/src/pages/admin/components/feature-availability-panel.tsx b/web/src/pages/admin/components/feature-availability-panel.tsx
index 3e25a2104..df0fb449a 100644
--- a/web/src/pages/admin/components/feature-availability-panel.tsx
+++ b/web/src/pages/admin/components/feature-availability-panel.tsx
@@ -35,7 +35,7 @@ const workspaceFeatureRows: FeatureRow[] = [
{
key: "creditsEnabled",
title: "积分计费",
- description: "控制钱包入口及新任务的积分预授权与结算。",
+ description: "控制积分中心弹窗及新任务的积分预授权与结算。",
icon: ,
},
{
diff --git a/web/src/pages/canvas/canvas-project-top-bar.tsx b/web/src/pages/canvas/canvas-project-top-bar.tsx
index 94656aa20..53efec4e1 100644
--- a/web/src/pages/canvas/canvas-project-top-bar.tsx
+++ b/web/src/pages/canvas/canvas-project-top-bar.tsx
@@ -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({
{user && creditsEnabled ? (
-
-
+ openWorkspaceWallet()}
>
{refreshing && availableMicrocredits === null ? : }
{availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 })}
-
+
) : null}
diff --git a/web/src/pages/wallet/index.tsx b/web/src/pages/wallet/index.tsx
deleted file mode 100644
index 76fa833e5..000000000
--- a/web/src/pages/wallet/index.tsx
+++ /dev/null
@@ -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(null);
- const [code, setCode] = useState("");
- const [filter, setFilter] = useState("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([]);
- const [paymentProviders, setPaymentProviders] = useState([]);
- const [paymentsLoading, setPaymentsLoading] = useState(true);
- const [selectedProductId, setSelectedProductId] = useState("");
- const [selectedProviderId, setSelectedProviderId] = useState("");
- const [paymentCreating, setPaymentCreating] = useState(false);
- const [paymentOrder, setPaymentOrder] = useState(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 = [
- { title: "发生时间", dataIndex: "createdAt", width: 180, render: formatTime },
- { title: "类型", dataIndex: "type", width: 120, render: (type) => },
- {
- title: "明细",
- width: 400,
- ellipsis: true,
- render: (_, entry) => (
-
-
{ledgerModelName(config, entry)}
-
{[sceneLabel(entry.scene), entry.note].filter(Boolean).join(" · ") || "无补充说明"}
-
- ),
- },
- {
- title: "积分变化",
- dataIndex: "amountMicrocredits",
- width: 145,
- align: "right",
- render: (value: number) => ,
- },
- { title: "变更后余额", dataIndex: "availableAfterMicrocredits", width: 145, align: "right", render: (value) => {formatCredits(value)} },
- ];
-
- return (
-
-
-
-
-
-
-
积分超市
-
用真实余额补充创作额度,兑换码、充值与消费记录都可追溯。
-
-
-
-
-
- 可用 {formatCredits(account?.availableMicrocredits || 0, 6)}
-
- }
- type={wallet?.policy.checkedInToday ? "default" : "primary"}
- loading={checkingIn}
- disabled={wallet?.policy.checkedInToday}
- onClick={() => void checkin()}
- >
- {wallet?.policy.checkedInToday ? "今日已签到" : `签到 +${formatCredits(wallet?.policy.checkinBonusMicrocredits || 0)}`}
-
- } loading={loading} onClick={() => void reload()}>
- 刷新余额
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 我的创作积分
- 最近更新 {formatTime(account?.updatedAt)}
-
-
-
- {formatCredits(account?.availableMicrocredits || 0, 6)}
- 积分
-
-
-
-
-
- 账户正常
-
- } />
- } />
-
-
-
-
-
-
-
-
-
-
-
兑换码入账
-
输入管理员发放的 32 位兑换码。
-
-
-
-
- 兑换成功后立即到账
- {code.length} / 32
-
- void redeem()}>
- 兑换积分
-
-
-
-
-
-
-
-
-
-
-
-
-
购买创作积分
-
支付成功后自动充值积分。平台不提供退款,请确认商品和金额后付款。
-
-
-
- {selectedProduct ? (
-
-
应付金额
-
¥ {(selectedProduct.amountFen / 100).toFixed(2)}
-
- ) : null}
-
- {paymentsLoading ? (
-
- ) : paymentProducts.length && paymentProviders.length ? (
-
-
-
选择充值商品
-
- {paymentProducts.map((product) => (
-
setSelectedProductId(product.id)}
- >
- {product.name}
-
- {formatCredits(product.creditsMicrocredits, 6)} 积分 · ¥ {(product.amountFen / 100).toFixed(2)}
-
-
- ))}
-
-
-
-
选择支付方式
-
- {paymentProviders.map((provider) => (
-
setSelectedProviderId(provider.id)}
- >
-
- {provider.name}
-
- ))}
-
-
void startPayment()}>
- {selectedProvider?.checkoutMode === "qr_code" ? "生成支付二维码" : `前往${selectedProvider?.name || "支付"}`}
-
-
-
- ) : (
-
- )}
-
-
-
-
-
-
积分使用明细
-
当前展示最近 {wallet?.entries.length || 0} 条记录。
-
-
{
- setFilter(value as LedgerFilter);
- setPage(1);
- }}
- />
-
-
- {screens.md ? (
-
-
-
- ) : (
-
- {entries.length ? (
- entries.map((entry) => )
- ) : (
-
- )}
-
- )}
- {
- setPage(nextPageSize !== pageSize ? 1 : nextPage);
- setPageSize(nextPageSize);
- }}
- />
-
-
-
- setPaymentModalOpen(false)}
- footer={
- paymentOrder?.status === "pending"
- ? [
- void cancelPayment()}>
- 关闭订单
- ,
- void confirmPayment()}>
- 我已完成支付
- ,
- ]
- : paymentOrder?.status === "create_failed"
- ? [
- setPaymentModalOpen(false)}>
- 稍后处理
- ,
- void retryPaymentCheckout()}>
- {paymentOrder.checkout.mode === "redirect" ? "重新打开收银台" : "重新生成二维码"}
- ,
- ]
- : [
- setPaymentModalOpen(false)}>
- 完成
- ,
- ]
- }
- destroyOnHidden
- >
- {paymentOrder ? (
-
-
-
¥ {(paymentOrder.amountFen / 100).toFixed(2)}
-
- {paymentOrder.productName} · {formatCredits(paymentOrder.creditsMicrocredits, 6)} 积分
-
- {paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? (
-
-
-
请使用支付应用扫码完成支付
-
请勿保存二维码后长按识别
-
- ) : null}
-
-
- ) : null}
-
-
- );
-}
-
-function BalanceMetric({ label, description, value, icon }: { label: string; description: string; value: number; icon: ReactNode }) {
- return (
-
-
{icon}
-
- {label}
- {formatCredits(value, 6)}
- {description}
-
-
- );
-}
-
-function LedgerMobileRow({ config, entry }: { config: AiConfig; entry: CreditLedgerEntry }) {
- const meta = ledgerTypeMeta(entry.type);
- return (
-
- {meta.icon}
-
-
-
-
{ledgerModelName(config, entry)}
-
{formatTime(entry.createdAt)}
-
-
-
-
{[sceneLabel(entry.scene), entry.note].filter(Boolean).join(" · ") || meta.label}
-
-
- );
-}
-
-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 (
-
- {value > 0 ? "+" : ""}
- {formatCredits(value, 6)}
-
- );
-}
-
-function LedgerTypeTag({ type }: { type: CreditLedgerEntry["type"] }) {
- const meta = ledgerTypeMeta(type);
- // 用项目 StatusBadge 而不是 antd Tag:Tag 的 default/error 配色在本项目主题下不可读。
- return ;
-}
-
-function ledgerTypeMeta(type: CreditLedgerEntry["type"]) {
- // tone 走 StatusBadge 的语义色,明暗主题都自适应;不再用 antd Tag 的 default 配色。
- const values = {
- redeem: { label: "兑换充值", tone: "neutral", icon: , iconClass: "bg-foreground/8 text-foreground/70" },
- payment_topup: { label: "在线充值", tone: "success", icon: , iconClass: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-300" },
- admin_grant: { label: "管理员充值", tone: "neutral", icon: , iconClass: "bg-foreground/8 text-foreground/70" },
- consume: { label: "模型消费", tone: "error", icon: , iconClass: "bg-rose-500/10 text-rose-600 dark:text-rose-300" },
- reserve: { label: "积分冻结", tone: "warning", icon: , iconClass: "bg-amber-500/10 text-amber-600 dark:text-amber-300" },
- refund: { label: "消费退款", tone: "warning", icon: , iconClass: "bg-amber-500/10 text-amber-600 dark:text-amber-300" },
- admin_adjustment: { label: "管理员调账", tone: "neutral", icon: , iconClass: "bg-foreground/8 text-foreground/70" },
- signup_bonus: { label: "注册奖励", tone: "neutral", icon: , iconClass: "bg-foreground/8 text-foreground/70" },
- checkin_bonus: { label: "签到奖励", tone: "neutral", icon: , iconClass: "bg-foreground/8 text-foreground/70" },
- } as const;
- return values[type] || { label: "其他积分变动", tone: "neutral", icon: , 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 ;
- return ;
-}
-
-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 支付已确认,积分已经到账
;
- if (order.status === "closed") return 订单已关闭,未产生积分充值
;
- if (order.status === "create_failed") {
- const action = order.checkout.mode === "redirect" ? "重新打开收银台" : "重新生成支付二维码";
- return 创建渠道订单失败,可使用下方按钮{action}
;
- }
- return 订单剩余支付时间 {formatCountdown(remainingSeconds)},页面将自动确认支付结果
;
-}
-
-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 = { image: "图片生成", text: "文本生成", video: "视频生成", audio: "音频生成", storyboard: "分镜生成" };
- return scene ? labels[scene] || "其他场景" : "";
-}
-
-function formatTime(value?: string) {
- return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
-}
diff --git a/web/src/router.tsx b/web/src/router.tsx
index 301a6cc19..565310299 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -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: (
-
- {deferred()}
-
- ),
+ element: {null},
},
{ path: "/settings", element: {deferred()} },
{ path: "/test-voice-recording", element: {deferred()} },
diff --git a/web/test/workspace-route-loading.test.ts b/web/test/workspace-route-loading.test.ts
index 6e7ed0d2d..1745b7011 100644
--- a/web/test/workspace-route-loading.test.ts
+++ b/web/test/workspace-route-loading.test.ts
@@ -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: {null}");
+ 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");
});
});