合并最新 master 到作品管理分支
Project CI / Backend tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m44s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m43s
Project CI / Repository checks (pull_request) Failing after 40s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m52s
Project CI / Frontend tests (pull_request) Successful in 3m17s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m12s
Project CI / Native shell tests (pull_request) Successful in 6m41s
Project CI / Backend tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m44s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m43s
Project CI / Repository checks (pull_request) Failing after 40s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m52s
Project CI / Frontend tests (pull_request) Successful in 3m17s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m12s
Project CI / Native shell tests (pull_request) Successful in 6m41s
This commit is contained in:
@@ -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]);
|
||||
@@ -579,6 +586,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 =
|
||||
@@ -774,7 +789,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}
|
||||
@@ -784,6 +805,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'
|
||||
|
||||
@@ -15,6 +15,71 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.payment-checkout-page {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: radial-gradient(circle at 18% 12%, rgba(199, 101, 50, 0.11), transparent 32%), #f5f1ea;
|
||||
color: #2c2723;
|
||||
}
|
||||
|
||||
.payment-checkout-shell {
|
||||
width: min(68rem, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(20rem, 1.15fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.payment-checkout-summary,
|
||||
.payment-checkout-qr-panel {
|
||||
border: 1px solid rgba(119, 92, 68, 0.13);
|
||||
border-radius: 1.8rem;
|
||||
background: rgba(255, 253, 249, 0.92);
|
||||
box-shadow: 0 1.5rem 3.5rem rgba(92, 70, 49, 0.1);
|
||||
}
|
||||
|
||||
.payment-checkout-summary { padding: 2rem; }
|
||||
.payment-checkout-qr-panel { padding: 2rem; text-align: center; }
|
||||
.payment-checkout-brand { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.payment-checkout-brand > span { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; border-radius: 0.9rem; background: #f0e4d8; font-weight: 900; }
|
||||
.payment-checkout-brand strong, .payment-checkout-brand small { display: block; }
|
||||
.payment-checkout-brand small { margin-top: 0.2rem; color: #88796d; font-size: 0.76rem; }
|
||||
.payment-checkout-code { margin-top: 2rem; color: #8c7d70; font-size: 0.78rem; }
|
||||
.payment-checkout-code strong { display: block; margin-top: 0.35rem; color: #2c2723; font-size: 0.9rem; }
|
||||
.payment-checkout-summary h1 { margin-top: 1.8rem; font-size: clamp(1.8rem, 4vw, 2.6rem); font-weight: 900; }
|
||||
.payment-checkout-description { margin-top: 0.65rem; color: #75675c; font-size: 0.9rem; line-height: 1.7; }
|
||||
.payment-checkout-alert { display: grid; gap: 0.45rem; margin-top: 1.7rem; padding: 1rem; border: 1px solid #f1d9d1; border-radius: 1rem; background: #fff7f4; color: #8f3e32; font-size: 0.82rem; line-height: 1.6; }
|
||||
.payment-checkout-state { display: flex; align-items: center; gap: 0.75rem; margin-top: 1.1rem; padding: 1rem; border: 1px solid rgba(119, 92, 68, 0.1); border-radius: 1rem; }
|
||||
.payment-checkout-state strong, .payment-checkout-state span { display: block; }
|
||||
.payment-checkout-state span { margin-top: 0.2rem; color: #88796d; font-size: 0.78rem; }
|
||||
.payment-checkout-copy { display: inline-flex; align-items: center; gap: 0.4rem; margin-top: 1rem; color: #7a5b42; font-size: 0.8rem; font-weight: 700; }
|
||||
.payment-checkout-qr-title { display: flex; justify-content: space-between; font-size: 0.95rem; }
|
||||
.payment-checkout-qr-title span { padding: 0.35rem 0.7rem; border: 1px solid rgba(119, 92, 68, 0.12); border-radius: 999px; color: #88796d; font-size: 0.75rem; }
|
||||
.payment-checkout-amount { display: flex; align-items: center; justify-content: space-between; margin-top: 1.2rem; padding: 1.15rem; border: 1px solid rgba(119, 92, 68, 0.11); border-radius: 1rem; background: #f8f1e9; text-align: left; }
|
||||
.payment-checkout-amount small { color: #88796d; }
|
||||
.payment-checkout-amount strong { font-size: 2.1rem; }
|
||||
.payment-checkout-qr, .payment-checkout-qr-placeholder { width: min(19rem, 100%); aspect-ratio: 1; margin: 1.8rem auto 1rem; border-radius: 1.2rem; background: #fff; }
|
||||
.payment-checkout-qr { display: block; padding: 0.65rem; border: 1px solid rgba(119, 92, 68, 0.12); }
|
||||
.payment-checkout-qr-placeholder { display: grid; place-items: center; color: #a17c5b; }
|
||||
.payment-checkout-success { display: grid; place-items: center; gap: 0.7rem; min-height: 19rem; margin-top: 1.8rem; color: #27845d; }
|
||||
.payment-checkout-success--muted { color: #998b7f; }
|
||||
.payment-checkout-provider { display: inline-flex; align-items: center; gap: 0.35rem; font-weight: 800; }
|
||||
.payment-checkout-hint { margin-top: 0.45rem; color: #88796d; font-size: 0.8rem; }
|
||||
.payment-checkout-loading, .payment-checkout-error { display: flex; align-items: center; gap: 0.6rem; padding: 1rem 1.25rem; border-radius: 1rem; background: rgba(255, 253, 249, 0.95); box-shadow: 0 1rem 2rem rgba(92, 70, 49, 0.1); }
|
||||
.payment-checkout-error { color: #8f3e32; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.payment-checkout-page { align-items: flex-start; padding: 1rem; }
|
||||
.payment-checkout-shell { grid-template-columns: 1fr; gap: 0.8rem; }
|
||||
.payment-checkout-summary, .payment-checkout-qr-panel { border-radius: 1.25rem; padding: 1.25rem; }
|
||||
.payment-checkout-summary h1 { margin-top: 1.25rem; font-size: 1.7rem; }
|
||||
.payment-checkout-code { margin-top: 1.25rem; }
|
||||
.payment-checkout-qr, .payment-checkout-qr-placeholder { margin-top: 1.25rem; }
|
||||
.payment-checkout-success { min-height: 14rem; }
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: 'Noto Serif SC', 'Georgia', serif;
|
||||
|
||||
@@ -30,6 +30,9 @@ describe('appPageRoutes', () => {
|
||||
expect(resolveSelectionStageFromPath('/games/publish')).toBe(
|
||||
'game-publish',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/pay/checkout-token')).toBe(
|
||||
'payment-checkout',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/creation/rpg')).toBe('platform');
|
||||
expect(resolveSelectionStageFromPath('/runtime/puzzle')).toBe('platform');
|
||||
expect(isKnownMainAppPagePath('/creation')).toBe(true);
|
||||
@@ -42,6 +45,10 @@ describe('appPageRoutes', () => {
|
||||
expect(resolvePathForSelectionStage('games')).toBe('/games');
|
||||
expect(resolvePathForSelectionStage('game-mine')).toBe('/games/mine');
|
||||
expect(resolvePathForSelectionStage('game-publish')).toBe('/games/publish');
|
||||
expect(resolvePathForSelectionStage('profile-payment')).toBe(
|
||||
'/profile/payment',
|
||||
);
|
||||
expect(resolvePathForSelectionStage('payment-checkout')).toBe('/pay');
|
||||
});
|
||||
|
||||
it('requires a project id for direct editor navigation', () => {
|
||||
|
||||
@@ -8,6 +8,8 @@ const STAGE_ROUTE_ENTRIES = [
|
||||
['creation-home', '/creation'],
|
||||
['project', '/project'],
|
||||
['profile', '/profile'],
|
||||
['profile-payment', '/profile/payment'],
|
||||
['payment-checkout', '/pay'],
|
||||
['image-editor', '/editor/canvas'],
|
||||
['games', '/games'],
|
||||
['game-detail', '/games/detail'],
|
||||
@@ -39,6 +41,9 @@ export function normalizeAppPath(pathname: string) {
|
||||
export function resolveSelectionStageFromPath(
|
||||
pathname: string,
|
||||
): SelectionStage {
|
||||
if (normalizeAppPath(pathname).startsWith('/pay/')) {
|
||||
return 'payment-checkout';
|
||||
}
|
||||
return ROUTE_STAGE_BY_PATH.get(normalizeAppPath(pathname)) ?? 'platform';
|
||||
}
|
||||
|
||||
@@ -69,7 +74,8 @@ export function shouldRedirectEditorCanvasWithoutProject(
|
||||
}
|
||||
|
||||
export function isKnownMainAppPagePath(pathname: string) {
|
||||
return ROUTE_STAGE_BY_PATH.has(normalizeAppPath(pathname));
|
||||
const normalized = normalizeAppPath(pathname);
|
||||
return ROUTE_STAGE_BY_PATH.has(normalized) || normalized.startsWith('/pay/');
|
||||
}
|
||||
|
||||
export function pushAppHistoryPath(path: string) {
|
||||
|
||||
@@ -9,6 +9,8 @@ const APP_TITLE_BY_SELECTION_STAGE: Record<SelectionStage, string> = {
|
||||
'creation-home': APP_HOME_TITLE,
|
||||
project: '项目 - 陶泥儿',
|
||||
profile: '我的 - 陶泥儿',
|
||||
'profile-payment': '支付服务 - 陶泥儿',
|
||||
'payment-checkout': '扫码支付 - 陶泥儿',
|
||||
'image-editor': '美术编辑器 - 陶泥儿',
|
||||
games: '游戏 - 陶泥儿',
|
||||
'game-detail': '游戏详情 - 陶泥儿',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { PaymentOrderCreateResponse } from '../../../packages/shared/src/contracts/payment';
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
export function getPublicPaymentCheckout(checkoutToken: string) {
|
||||
return requestJson<PaymentOrderCreateResponse>(
|
||||
`/api/payment/checkout/${encodeURIComponent(checkoutToken)}`,
|
||||
{ method: 'GET' },
|
||||
'读取支付订单失败',
|
||||
{ skipAuth: true, skipRefresh: true },
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
import type {
|
||||
PaymentApiKey,
|
||||
PaymentApp,
|
||||
PaymentAppCreateResponse,
|
||||
PaymentAppListResponse,
|
||||
} from '../../../packages/shared/src/contracts/payment';
|
||||
import type {
|
||||
ConfirmWechatProfileRechargeOrderResponse,
|
||||
CreateProfileRechargeOrderResponse,
|
||||
@@ -140,6 +146,63 @@ export function revokePlatformProfileExternalApiKey(
|
||||
);
|
||||
}
|
||||
|
||||
export function listPlatformProfilePaymentApps(
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<PaymentAppListResponse>(
|
||||
'/payment/apps',
|
||||
{ method: 'GET' },
|
||||
'读取支付服务失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function createPlatformProfilePaymentApp(
|
||||
name: string,
|
||||
callbackUrl: string | null,
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<PaymentAppCreateResponse>(
|
||||
'/payment/apps',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, callbackUrl }),
|
||||
},
|
||||
'创建支付应用失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function updatePlatformProfilePaymentApp(
|
||||
appId: string,
|
||||
enabled: boolean,
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<PaymentApp>(
|
||||
`/payment/apps/${encodeURIComponent(appId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
},
|
||||
'更新支付应用失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function revokePlatformProfilePaymentApiKey(
|
||||
keyId: string,
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<PaymentApiKey>(
|
||||
`/payment/keys/${encodeURIComponent(keyId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'撤销支付 API Key 失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlatformProfileRechargeCenter(
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user