Merge remote-tracking branch 'origin/master' into feat/play-count-with-cache
Project CI / Backend tests (pull_request) Failing after 36s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m28s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m41s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-10-04 11:58:25 +08:00
272 changed files with 13531 additions and 1185 deletions
@@ -0,0 +1,199 @@
import {
CheckCircle2,
Clock3,
Copy,
LoaderCircle,
ShieldCheck,
} from 'lucide-react';
import QRCode from 'qrcode';
import { useEffect, useState } from 'react';
import type { PaymentOrder } from '../../../packages/shared/src/contracts/payment';
import { getPublicPaymentCheckout } from '../../services/payment/paymentCheckoutClient';
type PaymentCheckoutViewProps = {
checkoutToken: string;
};
export function PaymentCheckoutView({
checkoutToken,
}: PaymentCheckoutViewProps) {
const [order, setOrder] = useState<PaymentOrder | null>(null);
const [qrDataUrl, setQrDataUrl] = useState('');
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const response = await getPublicPaymentCheckout(checkoutToken);
if (!cancelled) {
setOrder(response.order);
setError('');
}
} catch (nextError: unknown) {
if (!cancelled) {
setError(
nextError instanceof Error ? nextError.message : '读取支付订单失败',
);
}
}
};
void load();
const timer = window.setInterval(() => {
if (
order?.status === 'paid' ||
order?.status === 'closed' ||
order?.status === 'expired'
) {
return;
}
void load();
}, 5000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [checkoutToken, order?.status]);
useEffect(() => {
let cancelled = false;
if (!order?.providerQrCode) {
setQrDataUrl('');
return;
}
QRCode.toDataURL(order.providerQrCode, {
width: 320,
margin: 2,
color: { dark: '#2c2723', light: '#ffffff' },
}).then((dataUrl) => {
if (!cancelled) {
setQrDataUrl(dataUrl);
}
});
return () => {
cancelled = true;
};
}, [order?.providerQrCode]);
const copyAmount = () => {
if (order) {
void navigator.clipboard?.writeText((order.amountCents / 100).toFixed(2));
}
};
if (error) {
return (
<main className="payment-checkout-page">
<div className="payment-checkout-error">{error}</div>
</main>
);
}
if (!order) {
return (
<main className="payment-checkout-page">
<div className="payment-checkout-loading">
<LoaderCircle className="h-5 w-5 animate-spin" />
正在读取支付订单...
</div>
</main>
);
}
const amount = (order.amountCents / 100).toFixed(2);
const isPaid = order.status === 'paid';
const isClosed = order.status === 'closed' || order.status === 'expired';
return (
<main className="payment-checkout-page">
<div className="payment-checkout-shell">
<section className="payment-checkout-summary">
<div className="payment-checkout-brand">
<span>¥</span>
<div>
<strong>扫码付款</strong>
<small>支付成功后自动更新订单状态</small>
</div>
</div>
<div className="payment-checkout-code">
订单号 <strong>{order.merchantOrderId}</strong>
</div>
<h1>{order.title}</h1>
<p className="payment-checkout-description">
请核对商品与订单信息,使用右侧二维码完成支付。
</p>
<div className="payment-checkout-alert">
<strong>付款金额说明</strong>
<span>
请完全按照页面显示的金额付款,少付或多付都可能导致支付无法自动确认。
</span>
</div>
<div className="payment-checkout-state">
{isPaid ? (
<CheckCircle2 className="text-emerald-600" />
) : (
<Clock3 className="text-amber-500" />
)}
<div>
<strong>
{isPaid ? '支付成功' : isClosed ? '订单已关闭' : '等待支付'}
</strong>
<span>
{isPaid
? '平台已确认到账'
: isClosed
? '请重新创建订单'
: '完成付款后页面会自动更新状态'}
</span>
</div>
</div>
<button
type="button"
className="payment-checkout-copy"
onClick={copyAmount}
>
<Copy className="h-4 w-4" />
复制金额
</button>
</section>
<section className="payment-checkout-qr-panel">
<div className="payment-checkout-qr-title">
<strong>扫码付款</strong>
<span>{isPaid ? '已完成' : isClosed ? '已关闭' : '支付中'}</span>
</div>
<div className="payment-checkout-amount">
<small>应付金额</small>
<strong>¥{amount}</strong>
</div>
{isPaid ? (
<div className="payment-checkout-success">
<CheckCircle2 className="h-12 w-12" />
<strong>支付成功</strong>
</div>
) : isClosed ? (
<div className="payment-checkout-success payment-checkout-success--muted">
<Clock3 className="h-12 w-12" />
<strong>订单已关闭</strong>
</div>
) : qrDataUrl ? (
<img
className="payment-checkout-qr"
src={qrDataUrl}
alt="支付二维码"
/>
) : (
<div className="payment-checkout-qr-placeholder">
<LoaderCircle className="h-6 w-6 animate-spin" />
</div>
)}
<div className="payment-checkout-provider">
<ShieldCheck className="h-4 w-4" />
微信支付
</div>
<p className="payment-checkout-hint">请使用微信扫一扫完成支付</p>
</section>
</div>
</main>
);
}
@@ -1,6 +1,7 @@
import {
Camera,
Coins,
CreditCard,
History,
KeyRound,
MessageCircle,
@@ -69,6 +70,7 @@ type PlatformActiveProfileViewProps = {
mudPointBalance: ProfileMudPointBalance | null;
onLogin: () => void;
onOpenApiKeys: () => void;
onOpenPaymentService?: () => void;
onOpenCommunity: () => void;
onOpenFeedback: () => void;
onOpenInvite: () => void;
@@ -279,6 +281,7 @@ export function PlatformActiveProfileView({
mudPointBalance,
onLogin,
onOpenApiKeys,
onOpenPaymentService,
onOpenCommunity,
onOpenFeedback,
onOpenInvite,
@@ -663,6 +666,11 @@ export function PlatformActiveProfileView({
icon={KeyRound}
onClick={onOpenApiKeys}
/>
<ProfileSettingsRow
label="支付服务"
icon={CreditCard}
onClick={() => onOpenPaymentService?.()}
/>
</section>
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
@@ -35,6 +35,7 @@ import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { ClientDownloadEntry } from '../creation-home/ClientDownloadEntry';
import { PaymentCheckoutView } from './PaymentCheckoutView';
import {
PlatformActiveMobileWelcomeDialog,
shouldOpenActiveMobileWelcomeDialog,
@@ -46,6 +47,7 @@ import {
import { PlatformActiveProfileView } from './PlatformActiveProfileView';
import type { PlatformEntryFlowShellProps } from './platformEntryActiveTypes';
import { usePlatformDesktopLayout } from './platformEntryResponsive';
import { PlatformPaymentServiceView } from './PlatformPaymentServiceView';
import { PlatformProfileApiKeysModal } from './PlatformProfileApiKeysModal';
import {
canPasteFromNativeHostClipboard,
@@ -365,6 +367,7 @@ export function PlatformEntryFlowShellImpl({
const isProfileStage =
selectionStage === 'profile' ||
selectionStage === 'profile-payment' ||
(!isDesktopLayout && selectionStage === 'platform');
const profileCenter = usePlatformProfileCenterController({
@@ -403,6 +406,10 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('profile', { path: '/profile' });
}, [setSelectionStage]);
const openPaymentService = useCallback(() => {
setSelectionStage('profile-payment', { path: '/profile/payment' });
}, [setSelectionStage]);
const openGames = useCallback(() => {
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
@@ -575,6 +582,14 @@ export function PlatformEntryFlowShellImpl({
);
}
if (selectionStage === 'payment-checkout') {
const checkoutToken =
typeof window === 'undefined'
? ''
: (window.location.pathname.split('/').filter(Boolean).at(-1) ?? '');
return <PaymentCheckoutView checkoutToken={checkoutToken} />;
}
const isAuthenticated = Boolean(authUi?.user);
const balance = mudPointBalance?.totalPoints ?? legacyWalletBalance;
const isCreationStage =
@@ -770,7 +785,13 @@ export function PlatformEntryFlowShellImpl({
id={isCreationStage ? 'platform-tab-panel-create' : undefined}
className={`platform-tab-panel min-h-0 min-w-0 flex-1 ${isGamePlayStage ? 'platform-tab-panel--game-play' : 'overflow-auto'} ${isCreationStage || isProfileStage || isGamePlayStage ? '' : 'px-3 py-4 sm:px-6 sm:py-6'}`}
>
{isProfileStage ? (
{selectionStage === 'profile-payment' ? (
<PlatformPaymentServiceView
user={authUi?.user}
onBack={openProfile}
onLogin={() => authUi?.openLoginModal()}
/>
) : isProfileStage ? (
<PlatformActiveProfileView
dashboard={dashboard}
isLoadingDashboard={isLoadingDashboard}
@@ -780,6 +801,7 @@ export function PlatformEntryFlowShellImpl({
user={authUi?.user}
onLogin={() => authUi?.openLoginModal()}
onOpenApiKeys={() => setIsApiKeysOpen(true)}
onOpenPaymentService={openPaymentService}
onOpenCommunity={() =>
profileCenter.openProfilePopupPanel('community')
}
@@ -0,0 +1,347 @@
import {
ArrowLeft,
Check,
Copy,
CreditCard,
ExternalLink,
KeyRound,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import type { AuthUser } from '@/packages/shared/src';
import type {
PaymentApiKey,
PaymentApp,
PaymentAppCreateResponse,
} from '../../../packages/shared/src/contracts/payment';
import {
createPlatformProfilePaymentApp,
listPlatformProfilePaymentApps,
revokePlatformProfilePaymentApiKey,
updatePlatformProfilePaymentApp,
} from '../../services/platform-entry/platformProfileClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
type PlatformPaymentServiceViewProps = {
user: AuthUser | null | undefined;
onBack: () => void;
onLogin: () => void;
};
export function PlatformPaymentServiceView({
user,
onBack,
onLogin,
}: PlatformPaymentServiceViewProps) {
const [apps, setApps] = useState<PaymentApp[]>([]);
const [keys, setKeys] = useState<PaymentApiKey[]>([]);
const [name, setName] = useState('我的游戏支付');
const [callbackUrl, setCallbackUrl] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState('');
const [created, setCreated] = useState<PaymentAppCreateResponse | null>(null);
useEffect(() => {
if (!user) {
return;
}
let cancelled = false;
setIsLoading(true);
listPlatformProfilePaymentApps()
.then((response) => {
if (!cancelled) {
setApps(response.apps ?? []);
setKeys(response.keys ?? []);
}
})
.catch((nextError: unknown) => {
if (!cancelled) {
setError(
nextError instanceof Error ? nextError.message : '读取支付服务失败',
);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [user]);
const submit = async () => {
const normalizedName = name.trim();
if (!normalizedName) {
setError('请填写应用名称');
return;
}
setIsCreating(true);
setError('');
try {
const response = await createPlatformProfilePaymentApp(
normalizedName,
callbackUrl.trim() || null,
);
setCreated(response);
setApps((current) => [response.app, ...current]);
} catch (nextError: unknown) {
setError(
nextError instanceof Error ? nextError.message : '创建支付应用失败',
);
} finally {
setIsCreating(false);
}
};
const toggleApp = async (app: PaymentApp) => {
try {
const updated = await updatePlatformProfilePaymentApp(
app.appId,
!app.enabled,
);
setApps((current) =>
current.map((item) => (item.appId === updated.appId ? updated : item)),
);
} catch (nextError: unknown) {
setError(
nextError instanceof Error ? nextError.message : '更新支付应用失败',
);
}
};
const revokeKey = async (key: PaymentApiKey) => {
try {
const updated = await revokePlatformProfilePaymentApiKey(key.keyId);
setKeys((current) =>
current.map((item) => (item.keyId === updated.keyId ? updated : item)),
);
} catch (nextError: unknown) {
setError(
nextError instanceof Error
? nextError.message
: '撤销支付 API Key 失败',
);
}
};
const copyKey = async () => {
if (!created?.apiKey) {
return;
}
await navigator.clipboard?.writeText(created.apiKey);
};
if (!user) {
return (
<main className="platform-profile-page platform-remap-surface w-full p-4 sm:p-8">
<PlatformSubpanel
className="mx-auto w-full max-w-xl p-6 text-center"
radius="xl"
>
<CreditCard className="mx-auto h-10 w-10 text-[var(--platform-accent-strong)]" />
<h1 className="mt-4 text-xl font-black">支付服务</h1>
<p className="mt-2 text-sm text-[var(--platform-text-base)]">
登录后可以创建游戏或外部产品的支付应用。
</p>
<PlatformActionButton className="mt-5" onClick={onLogin}>
登录
</PlatformActionButton>
</PlatformSubpanel>
</main>
);
}
return (
<main className="platform-profile-page platform-remap-surface w-full space-y-4 p-4 pb-8 sm:p-8">
<div className="flex items-center gap-3">
<button
type="button"
className="platform-profile-icon-button rounded-full p-2"
onClick={onBack}
aria-label="返回我的"
>
<ArrowLeft className="h-5 w-5" aria-hidden="true" />
</button>
<div>
<h1 className="text-xl font-black text-[var(--platform-text-strong)]">
支付服务
</h1>
<p className="mt-1 text-sm text-[var(--platform-text-base)]">
统一收款、动态二维码和支付结果通知。
</p>
</div>
</div>
{error ? (
<PlatformStatusMessage tone="error" surface="profile">
{error}
</PlatformStatusMessage>
) : null}
<PlatformSubpanel as="section" className="p-5 sm:p-6" radius="xl">
<div className="flex items-start gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[rgba(199,101,50,0.12)] text-[var(--platform-accent-strong)]">
<CreditCard className="h-5 w-5" aria-hidden="true" />
</span>
<div>
<h2 className="font-black">创建接入应用</h2>
<p className="mt-1 text-sm leading-6 text-[var(--platform-text-base)]">
为一款游戏或外部产品创建独立的应用身份。
</p>
</div>
</div>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="text-sm font-semibold">
应用名称
<input
className="platform-profile-input mt-2 w-full rounded-xl px-3 py-2.5"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={80}
/>
</label>
<label className="text-sm font-semibold">
支付成功回调地址(可选)
<input
className="platform-profile-input mt-2 w-full rounded-xl px-3 py-2.5"
value={callbackUrl}
onChange={(event) => setCallbackUrl(event.target.value)}
placeholder="https://example.com/payment/callback"
/>
</label>
</div>
<PlatformActionButton
className="mt-5"
onClick={() => void submit()}
disabled={isCreating}
>
<CreditCard className="h-4 w-4" aria-hidden="true" />
{isCreating ? '创建中...' : '创建支付应用'}
</PlatformActionButton>
</PlatformSubpanel>
{created ? (
<PlatformSubpanel
as="section"
className="border-[var(--platform-accent-strong)] p-5 sm:p-6"
radius="xl"
>
<div className="flex items-center gap-2 font-black text-[var(--platform-accent-strong)]">
<Check className="h-5 w-5" />
应用已创建
</div>
<p className="mt-2 text-sm text-[var(--platform-text-base)]">
完整 API Key 只显示这一次,请立即复制并保存到你的服务端。
</p>
<div className="mt-4 flex items-center gap-2 rounded-xl bg-[var(--platform-subpanel-fill)] p-3">
<code className="min-w-0 flex-1 break-all text-xs">
{created.apiKey}
</code>
<button
type="button"
className="platform-profile-icon-button rounded-lg p-2"
onClick={() => void copyKey()}
aria-label="复制 API Key"
>
<Copy className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex items-center gap-2 text-xs text-[var(--platform-text-soft)]">
<KeyRound className="h-4 w-4" />
{created.key.scopes.join('、')}
</div>
</PlatformSubpanel>
) : null}
<PlatformSubpanel as="section" className="p-5 sm:p-6" radius="xl">
<div className="flex items-center justify-between gap-3">
<h2 className="font-black">我的支付应用</h2>
<span className="text-xs text-[var(--platform-text-soft)]">
{isLoading ? '读取中...' : `${apps.length} 个应用`}
</span>
</div>
<div className="mt-4 space-y-3">
{apps.length === 0 && !isLoading ? (
<p className="text-sm text-[var(--platform-text-soft)]">
还没有支付应用。
</p>
) : null}
{apps.map((app) => (
<div
key={app.appId}
className="flex flex-col gap-3 rounded-2xl border border-[var(--platform-subpanel-border)] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<strong className="block text-sm">{app.name}</strong>
<code className="mt-1 block text-xs text-[var(--platform-text-soft)]">
{app.appId}
</code>
</div>
<div className="flex items-center gap-2 text-xs text-[var(--platform-text-base)]">
<span
className={`h-2 w-2 rounded-full ${app.enabled ? 'bg-emerald-500' : 'bg-slate-400'}`}
/>
{app.enabled ? '可用' : '已停用'}
<button
type="button"
className="ml-2 font-bold text-[var(--platform-accent-strong)]"
onClick={() => void toggleApp(app)}
>
{app.enabled ? '停用' : '启用'}
</button>
</div>
</div>
))}
</div>
</PlatformSubpanel>
<PlatformSubpanel as="section" className="p-5 sm:p-6" radius="xl">
<div className="flex items-center justify-between gap-3">
<h2 className="font-black">支付 API Key</h2>
<span className="text-xs text-[var(--platform-text-soft)]">
{keys.length} 个
</span>
</div>
<div className="mt-4 space-y-3">
{keys.length === 0 ? (
<p className="text-sm text-[var(--platform-text-soft)]">
创建应用后会生成默认 Key。
</p>
) : null}
{keys.map((key) => (
<div
key={key.keyId}
className="flex flex-col gap-2 rounded-2xl border border-[var(--platform-subpanel-border)] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<strong className="block text-sm">{key.name}</strong>
<code className="mt-1 block text-xs text-[var(--platform-text-soft)]">
{key.keyPrefix}
</code>
</div>
<button
type="button"
className="self-start text-xs font-bold text-rose-600 sm:self-auto"
disabled={Boolean(key.revokedAt)}
onClick={() => void revokeKey(key)}
>
{key.revokedAt ? '已撤销' : '撤销 Key'}
</button>
</div>
))}
</div>
</PlatformSubpanel>
<p className="flex items-center gap-2 text-xs leading-5 text-[var(--platform-text-soft)]">
<ExternalLink className="h-4 w-4 shrink-0" />
支付金额由服务端订单快照决定,到账以微信支付通知或查单为准。
</p>
</main>
);
}
@@ -3,6 +3,8 @@ export type SelectionStage =
| 'creation-home'
| 'project'
| 'profile'
| 'profile-payment'
| 'payment-checkout'
| 'image-editor'
| 'games'
| 'game-detail'