reuse recharge modal

This commit is contained in:
2026-07-17 10:28:34 +08:00
parent 601133d748
commit 088d3d962b
5 changed files with 332 additions and 4 deletions
+139 -1
View File
@@ -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<ProfileRechargeCenterResponse | null>(null);
const [rechargeLoading, setRechargeLoading] = useState(false);
const [rechargeError, setRechargeError] = useState<string | null>(null);
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
useState<string | null>(null);
const [nativeRechargePayment, setNativeRechargePayment] =
useState<PlatformProfileRechargeNativePaymentState | null>(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('使用详情')}
/>
</div>
@@ -9271,6 +9392,23 @@ export function WorkspaceLauncher({
onClose={() => setRuntimeConfigOpen(false)}
/>
) : null}
{rechargeOpen ? (
<PlatformProfileRechargeModal
center={rechargeCenter}
isLoading={rechargeLoading}
error={rechargeError}
submittingProductId={submittingRechargeProductId}
nativePayment={nativeRechargePayment}
onClose={() => {
setRechargeOpen(false);
setNativeRechargePayment(null);
}}
onRetry={() => void loadRechargeCenter()}
onBuy={(product) => void buyRechargeProduct(product)}
onConfirmNativePayment={() => void confirmNativeRechargePayment()}
onCloseNativePayment={() => setNativeRechargePayment(null)}
/>
) : null}
{agentChatGoalDialog ? (
<div
className="launcher-dialog-backdrop"
@@ -1,6 +1,8 @@
import {
API_RESPONSE_ENVELOPE_HEADER,
API_RESPONSE_ENVELOPE_VERSION,
ConfirmWechatProfileRechargeOrderResponse,
CreateProfileRechargeOrderResponse,
ProfileDashboardSummary,
ProfileRechargeCenterResponse,
unwrapApiResponse,
@@ -142,6 +144,29 @@ export function getClientProfileRechargeCenter() {
);
}
export function createClientProfileRechargeOrder(productId: string) {
return requestClientApi<CreateProfileRechargeOrderResponse>(
'/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<ConfirmWechatProfileRechargeOrderResponse>(
`/api/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/confirm`,
{ method: 'POST' },
'确认微信支付订单失败',
);
}
export function listClientShowcaseResources() {
return requestClientApi<EditorShowcaseResourceListResponse>(
'/api/editor/showcase/resources',
@@ -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();
}}
>
<Plus size={14} aria-hidden="true" />
@@ -200,6 +203,7 @@ export function Sidebar({
currentUser,
onViewChange,
onNoticeRequest,
onRechargeRequest,
onRuntimeConfigOpen,
onLogout,
}: LauncherSidebarProps) {
@@ -310,6 +314,7 @@ export function Sidebar({
onClose={closeMenus}
onLogout={onLogout}
onNoticeRequest={onNoticeRequest}
onRechargeRequest={onRechargeRequest}
/>
) : null}
</div>
@@ -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');
@@ -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 原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。