From 088d3d962b5301c949ac68104f2fcced6b6152a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 17 Jul 2026 10:28:34 +0800 Subject: [PATCH] reuse recharge modal --- apps/ai-game-creator-shell/src/App.tsx | 140 ++++++++++++++- .../src/services/clientApi.ts | 25 +++ .../ai-game-creator-shell/src/view/layout.tsx | 9 +- .../tests/appSurface.test.ts | 160 ++++++++++++++++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 5 files changed, 332 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 5595f104a..2b9d9713a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -40,6 +40,10 @@ import { import { PlatformMudPointWalletEntry, } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry'; +import { + PlatformProfileRechargeModal, + type PlatformProfileRechargeNativePaymentState, +} from '../../../packages/shared/src/components/PlatformProfileRechargeModal'; import type { AuthEntryResponse, @@ -78,6 +82,8 @@ import { import type { ProfileDashboardSummary, ProfileMudPointBalance, + ProfileRechargeCenterResponse, + ProfileRechargeProduct, } from '../../../packages/shared/src/contracts/runtime'; import { API_RESPONSE_ENVELOPE_HEADER, @@ -85,6 +91,8 @@ import { unwrapApiResponse, } from '../../../packages/shared/src/http'; import { + confirmClientWechatProfileRechargeOrder, + createClientProfileRechargeOrder, getClientProfileDashboard, getClientProfileRechargeCenter, } from './services/clientApi'; @@ -5361,6 +5369,15 @@ export function WorkspaceLauncher({ 'idle' | 'loading' | 'ready' | 'error' >('idle'); const [mudPointBalanceError, setMudPointBalanceError] = useState(''); + const [rechargeOpen, setRechargeOpen] = useState(false); + const [rechargeCenter, setRechargeCenter] = + useState(null); + const [rechargeLoading, setRechargeLoading] = useState(false); + const [rechargeError, setRechargeError] = useState(null); + const [submittingRechargeProductId, setSubmittingRechargeProductId] = + useState(null); + const [nativeRechargePayment, setNativeRechargePayment] = + useState(null); const [launcherNotice, setLauncherNotice] = useState<{ title: string; message: string; @@ -6185,6 +6202,109 @@ export function WorkspaceLauncher({ } } + function applyRechargeCenter(center: ProfileRechargeCenterResponse) { + setRechargeCenter(center); + setMudPointBalance(center.mudPointBalance ?? null); + setMudPointBalanceStatus('ready'); + setMudPointBalanceError(''); + setProfileDashboard((current) => + current ? { ...current, walletBalance: center.walletBalance } : current, + ); + } + + async function loadRechargeCenter() { + setRechargeLoading(true); + setRechargeError(null); + try { + applyRechargeCenter(await getClientProfileRechargeCenter()); + } catch (error) { + setRechargeError( + error instanceof Error ? error.message : '读取泥点购买信息失败', + ); + } finally { + setRechargeLoading(false); + } + } + + function openRecharge() { + setRechargeOpen(true); + setRechargeError(null); + void loadRechargeCenter(); + } + + async function buyRechargeProduct(product: ProfileRechargeProduct) { + if (submittingRechargeProductId) { + return; + } + setSubmittingRechargeProductId(product.productId); + setRechargeError(null); + try { + const response = await createClientProfileRechargeOrder( + product.productId, + ); + applyRechargeCenter(response.center); + const nativePayment = response.wechatNativePayment; + const codeUrl = nativePayment?.codeUrl?.trim(); + const expiresAt = nativePayment?.expiresAt?.trim(); + if (!nativePayment || !codeUrl || !expiresAt) { + throw new Error('微信 Native 支付链接生成失败'); + } + setNativeRechargePayment({ + orderId: response.order.orderId, + productTitle: response.order.productTitle, + amountCents: response.order.amountCents, + codeUrl, + expiresAt, + isConfirming: false, + }); + } catch (error) { + setRechargeError(error instanceof Error ? error.message : '充值失败'); + } finally { + setSubmittingRechargeProductId(null); + } + } + + async function confirmNativeRechargePayment() { + if (!nativeRechargePayment || nativeRechargePayment.isConfirming) { + return; + } + const orderId = nativeRechargePayment.orderId; + setNativeRechargePayment((current) => + current?.orderId === orderId + ? { ...current, isConfirming: true, confirmMessage: undefined } + : current, + ); + try { + const response = await confirmClientWechatProfileRechargeOrder(orderId); + applyRechargeCenter(response.center); + if (response.order.status === 'paid') { + setNativeRechargePayment(null); + return; + } + const confirmMessage = + response.order.status === 'pending' || + (response.order.status === 'expired' && + !response.order.expirationCheckedAt) + ? '暂未确认到账,请确认付款完成后再点一次。' + : '订单未支付成功,请关闭二维码后重新下单。'; + setNativeRechargePayment((current) => + current?.orderId === orderId + ? { ...current, isConfirming: false, confirmMessage } + : current, + ); + } catch { + setNativeRechargePayment((current) => + current?.orderId === orderId + ? { + ...current, + isConfirming: false, + confirmMessage: '暂时没能确认到账状态,请稍后再试。', + } + : current, + ); + } + } + function showLauncherNotice(title: string) { setLauncherNotice({ title, @@ -8484,6 +8604,7 @@ export function WorkspaceLauncher({ onLogout(); }} onNoticeRequest={showLauncherNotice} + onRechargeRequest={openRecharge} onRuntimeConfigOpen={() => setRuntimeConfigOpen(true)} onViewChange={setLauncherView} /> @@ -8513,7 +8634,7 @@ export function WorkspaceLauncher({ isLoading={mudPointBalanceStatus === 'loading'} error={mudPointBalanceError || null} onRequestDetails={() => void loadMudPointBalance()} - onRecharge={() => showLauncherNotice('充值')} + onRecharge={openRecharge} onOpenLedger={() => showLauncherNotice('使用详情')} /> @@ -9271,6 +9392,23 @@ export function WorkspaceLauncher({ onClose={() => setRuntimeConfigOpen(false)} /> ) : null} + {rechargeOpen ? ( + { + setRechargeOpen(false); + setNativeRechargePayment(null); + }} + onRetry={() => void loadRechargeCenter()} + onBuy={(product) => void buyRechargeProduct(product)} + onConfirmNativePayment={() => void confirmNativeRechargePayment()} + onCloseNativePayment={() => setNativeRechargePayment(null)} + /> + ) : null} {agentChatGoalDialog ? (
( + '/api/profile/recharge/orders', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + productId, + paymentChannel: 'wechat_native', + }), + }, + '充值失败', + ); +} + +export function confirmClientWechatProfileRechargeOrder(orderId: string) { + return requestClientApi( + `/api/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/confirm`, + { method: 'POST' }, + '确认微信支付订单失败', + ); +} + export function listClientShowcaseResources() { return requestClientApi( '/api/editor/showcase/resources', diff --git a/apps/ai-game-creator-shell/src/view/layout.tsx b/apps/ai-game-creator-shell/src/view/layout.tsx index 799a258c0..e80140ada 100644 --- a/apps/ai-game-creator-shell/src/view/layout.tsx +++ b/apps/ai-game-creator-shell/src/view/layout.tsx @@ -42,6 +42,7 @@ type LauncherSidebarProps = { currentUser: SidebarUserInfo; onViewChange: (view: LauncherView) => void; onNoticeRequest: (title: string) => void; + onRechargeRequest: () => void; onRuntimeConfigOpen: () => void; onLogout: () => void; }; @@ -51,6 +52,7 @@ type SidebarAccountMenuProps = { onClose: () => void; onLogout: () => void; onNoticeRequest: (title: string) => void; + onRechargeRequest: () => void; }; function formatMudPoints(value: number | null | undefined) { @@ -65,6 +67,7 @@ function SidebarAccountMenu({ onClose, onLogout, onNoticeRequest, + onRechargeRequest, }: SidebarAccountMenuProps) { const [walletBalanceLabel, setWalletBalanceLabel] = useState('--'); const [walletBalanceStatus, setWalletBalanceStatus] = @@ -140,9 +143,9 @@ function SidebarAccountMenu({ type="button" className="grid size-7 shrink-0 place-items-center rounded-full border border-white/70 bg-white/15 text-white" aria-label="充值泥点" - onClick={()=>{ + onClick={() => { onClose(); - // TODO recharge component and logic is outdated on this branch + onRechargeRequest(); }} >
diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 748243738..7f49e4658 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1518,6 +1518,166 @@ describe('AI 游戏创作 App 界面边界', () => { ).toContain('/tmp/authorized-game'); }); + it('opens the shared recharge modal from the wallet and sidebar entries', async () => { + const rechargeCenter = { + walletBalance: 120, + mudPointBalance: { + totalPoints: 120, + permanentPoints: 100, + limitedPoints: 20, + limitedExpiresAt: '2026-08-01T00:00:00Z', + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-07-18T00:00:00Z', + }, + membership: { + status: 'inactive', + tier: 'normal', + startedAt: null, + expiresAt: null, + updatedAt: null, + cycleStartedAt: null, + cycleResetsAt: null, + cycleGrantedPoints: 0, + cycleRemainingPoints: 0, + cyclePeriodDays: 0, + }, + pointProducts: [ + { + productId: 'points_60', + title: '60泥点', + priceCents: 600, + kind: 'points', + pointsAmount: 60, + bonusPoints: 0, + durationDays: 0, + badgeLabel: '', + description: '', + tier: 'normal', + membershipPeriodPoints: 0, + membershipPeriodDays: 0, + membershipQueueLimit: 0, + membershipDiscountBps: 0, + }, + ], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: true, + }; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/profile/dashboard') { + return new Response(JSON.stringify({ walletBalance: 120 }), { + status: 200, + }); + } + if (url === '/api/profile/recharge-center') { + return new Response(JSON.stringify(rechargeCenter), { status: 200 }); + } + if (url === '/api/profile/recharge/orders') { + return new Response( + JSON.stringify({ + order: { + orderId: 'order-native-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending', + paymentChannel: 'wechat_native', + paidAt: null, + providerTransactionId: null, + createdAt: '2026-07-17T00:00:00Z', + pointsDelta: 0, + membershipExpiresAt: null, + }, + center: rechargeCenter, + wechatNativePayment: { + codeUrl: 'weixin://pay.weixin.qq.com/native-test', + expiresAt: '2099-01-01T00:05:00Z', + }, + }), + { status: 200 }, + ); + } + if ( + url === '/api/profile/recharge/orders/order-native-1/wechat/confirm' + ) { + return new Response( + JSON.stringify({ + order: { + orderId: 'order-native-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'paid', + paymentChannel: 'wechat_native', + paidAt: '2026-07-17T00:01:00Z', + providerTransactionId: 'wechat-transaction-1', + createdAt: '2026-07-17T00:00:00Z', + pointsDelta: 60, + membershipExpiresAt: null, + }, + center: { + ...rechargeCenter, + walletBalance: 180, + mudPointBalance: { + ...rechargeCenter.mudPointBalance, + totalPoints: 180, + permanentPoints: 160, + }, + }, + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }); + + renderLauncherAt('/?launcher'); + fireEvent.click(screen.getByRole('button', { name: '充值' })); + + expect( + await screen.findByRole('dialog', { name: '购买更多泥点' }), + ).not.toBeNull(); + expect(screen.getByText('当前余额 120 泥点')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '关闭购买更多泥点' })); + + fireEvent.click(screen.getByRole('button', { name: '我的' })); + fireEvent.click(screen.getByRole('button', { name: '充值泥点' })); + + expect( + await screen.findByRole('dialog', { name: '购买更多泥点' }), + ).not.toBeNull(); + expect(fetchSpy).toHaveBeenCalledWith( + '/api/profile/recharge-center', + expect.objectContaining({ method: 'GET' }), + ); + fireEvent.click(screen.getByRole('button', { name: /60泥点.*购买/ })); + expect( + await screen.findByRole('dialog', { name: '微信扫码支付' }), + ).not.toBeNull(); + expect(fetchSpy).toHaveBeenCalledWith( + '/api/profile/recharge/orders', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + productId: 'points_60', + paymentChannel: 'wechat_native', + }), + }), + ); + fireEvent.click(screen.getByRole('button', { name: '我已支付' })); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: '微信扫码支付' })).toBeNull(); + }); + expect(screen.getByText('当前余额 180 泥点')).not.toBeNull(); + }); + it('opens the help notice and account menu from the sidebar', () => { renderLauncherAt('/?launcher'); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index b3869d702..ad55350cb 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -526,7 +526,7 @@ game-project/ - 聊天输入 `/publish` 只使用主窗口当前已加载的 manifest、最近 run trace、预览状态、资产来源和最近命令摘要,在聊天里生成发布准备清单,列出原型通过状态、预览、任务、资产、音频、包装说明和试玩包导出状态,并提供 `/run`、`/trace`、`/agent-resume ` 或 `/export` 草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件、不新增普通用户面板,真正导出仍由用户发送 `/export` 并走确认流。 - 开发窗口可从 Agent 状态列表进入单个专业 Agent 对话并管理其 Session;正式用户项目开发页只读展示专业 Agent 协作状态,不提供单 Agent 对话入口、Session 控件或工具台。 - v1 普通用户登录后直接进入单窗口客户端首页;同一窗口中切换首页、项目组、指南 / 反馈和项目开发页。项目组页管理最近项目、打开项目、新建项目和显示目录;打开项目只切换到项目开发页,不调用 `open_game_creator_workspace_window` 打开第二窗口。旧 Tauri 窗口 command 只保留兼容,不进入用户主流程。 -- 2026-07-16 补充:普通用户窗口顶部账户资产统一复用 `PlatformMudPointWalletEntry`;首屏余额来自 `/api/profile/dashboard`,展开时按需读取 `/api/profile/recharge-center` 中的泥点拆分。AI 游戏创作壳的 Tailwind 入口必须显式扫描 `packages/shared/src/components`,避免共享组件的 utility 样式在构建时被遗漏。 +- 2026-07-16 补充,2026-07-17 扩展:普通用户窗口顶部账户资产统一复用 `PlatformMudPointWalletEntry`;首屏余额来自 `/api/profile/dashboard`,展开时按需读取 `/api/profile/recharge-center` 中的泥点拆分。顶部资产“充值”与侧栏账户菜单“充值泥点”共用 `PlatformProfileRechargeModal`,桌面壳固定使用 `wechat_native` 下单并在弹窗内完成扫码与到账确认,成功后同步刷新顶部余额和泥点拆分。AI 游戏创作壳的 Tailwind 入口必须显式扫描 `packages/shared/src/components`,避免共享组件的 utility 样式在构建时被遗漏。 - 主窗口可通过系统文件管理器显示当前项目目录,也可在聊天输入 `/open-project` 走同一只读打开动作;该操作只打开本地目录,不初始化项目、不写项目文件、不切换工作区。主窗口头部显示最近 `.agent/run.latest.json` 的 run 状态摘要和当前项目预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。 - 首页、项目组页和项目开发页共用单窗口壳的全局运行时配置弹窗,读写 Tauri 应用配置目录中的 `game-creator.config.json`;正式 Supervisor 项目页缺配置时只显示错误,不自动打开该弹窗。API Key 仍不进入本地项目、trace、manifest 或聊天记录。 - 首页发送和项目组新建都通过 Tauri 原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。