Add WeChat in-app JSAPI recharge for g2.tkmind.cn.
Use JSAPI with bound openid inside WeChat instead of QR long-press, and include related auth redirect and admin UX fixes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+10
-7
@@ -100,11 +100,7 @@ function AuthenticatedApp({
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireAdmin user={user}>
|
||||
{user ? (
|
||||
<AdminLayout user={user} onLogout={handleLogout} />
|
||||
) : (
|
||||
<Navigate to="/" replace />
|
||||
)}
|
||||
{user ? <AdminLayout user={user} onLogout={handleLogout} /> : null}
|
||||
</RequireAdmin>
|
||||
}
|
||||
>
|
||||
@@ -143,7 +139,9 @@ export function App() {
|
||||
setUnauthorizedHandler(() => {
|
||||
setAuthed(false);
|
||||
setUser(null);
|
||||
navigate('/', { replace: true });
|
||||
if (window.location.pathname !== '/') {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
});
|
||||
void checkAuth().then((status) => {
|
||||
setLegacyMode(status.mode === 'legacy');
|
||||
@@ -181,7 +179,12 @@ export function App() {
|
||||
<AuthView
|
||||
legacyMode={legacyMode}
|
||||
onAuth={(nextUser, nextCapabilities, nextSkills) => {
|
||||
const returnTo = new URLSearchParams(window.location.search).get('return_to');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const returnTo =
|
||||
params.get('return_to') ??
|
||||
(window.location.pathname.startsWith('/admin')
|
||||
? `${window.location.pathname}${window.location.search}`
|
||||
: null);
|
||||
if (returnTo) {
|
||||
try {
|
||||
const url = new URL(returnTo, window.location.origin);
|
||||
|
||||
@@ -56,12 +56,14 @@ export function UsersPage() {
|
||||
<form className="admin-form" onSubmit={handleCreate}>
|
||||
<input
|
||||
placeholder="用户名"
|
||||
autoComplete="username"
|
||||
value={newUser.username}
|
||||
onChange={(e) => setNewUser((s) => ({ ...s, username: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
placeholder="密码"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newUser.password}
|
||||
onChange={(e) => setNewUser((s) => ({ ...s, password: e.target.value }))}
|
||||
/>
|
||||
|
||||
+20
-6
@@ -111,9 +111,21 @@ async function parseErrorResponse(res: Response) {
|
||||
}
|
||||
|
||||
let unauthorizedHandler: (() => void) | null = null;
|
||||
let unauthorizedHandling = false;
|
||||
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
unauthorizedHandler = handler;
|
||||
if (handler) unauthorizedHandling = false;
|
||||
}
|
||||
|
||||
export function resetUnauthorizedGuard() {
|
||||
unauthorizedHandling = false;
|
||||
}
|
||||
|
||||
function notifyUnauthorized() {
|
||||
if (!unauthorizedHandler || unauthorizedHandling) return;
|
||||
unauthorizedHandling = true;
|
||||
unauthorizedHandler();
|
||||
}
|
||||
|
||||
async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -131,7 +143,7 @@ async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
unauthorizedHandler?.();
|
||||
notifyUnauthorized();
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new ApiError(401, text || '未授权,请重新登录');
|
||||
}
|
||||
@@ -173,7 +185,7 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
unauthorizedHandler?.();
|
||||
notifyUnauthorized();
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new ApiError(401, text || '未授权,请重新登录');
|
||||
}
|
||||
@@ -258,6 +270,7 @@ export async function checkAuth(): Promise<AuthStatus> {
|
||||
const response = await fetch('/auth/status');
|
||||
if (!response.ok) return { authenticated: false };
|
||||
const status = (await response.json()) as AuthStatus;
|
||||
if (status.authenticated) resetUnauthorizedGuard();
|
||||
if (status.authenticated && status.mode === 'user' && !status.capabilities) {
|
||||
try {
|
||||
const me = await getMe();
|
||||
@@ -303,7 +316,7 @@ export async function getBillingConfig(): Promise<BillingConfig> {
|
||||
|
||||
export async function createRechargeOrder(input: {
|
||||
amountCents: number;
|
||||
payScene: 'native' | 'h5';
|
||||
payScene: 'native' | 'h5' | 'jsapi';
|
||||
}): Promise<RechargeOrder> {
|
||||
const result = await portalFetch<{ order: RechargeOrder }>('/auth/billing/recharge-orders', {
|
||||
method: 'POST',
|
||||
@@ -565,7 +578,7 @@ export async function fetchPreviewAsset(path: string): Promise<string> {
|
||||
throw new ApiError(0, formatNetworkError(err));
|
||||
}
|
||||
if (res.status === 401) {
|
||||
unauthorizedHandler?.();
|
||||
notifyUnauthorized();
|
||||
throw new ApiError(401, '未授权,请重新登录');
|
||||
}
|
||||
if (!res.ok) {
|
||||
@@ -699,7 +712,7 @@ export async function fetchMindSpacePageDraftPreview(
|
||||
throw new ApiError(0, formatNetworkError(err));
|
||||
}
|
||||
if (res.status === 401) {
|
||||
unauthorizedHandler?.();
|
||||
notifyUnauthorized();
|
||||
throw new ApiError(401, '未授权,请重新登录');
|
||||
}
|
||||
if (!res.ok) {
|
||||
@@ -1178,6 +1191,7 @@ export async function login(username: string, password: string): Promise<PortalU
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, body?.message ?? '登录失败');
|
||||
}
|
||||
resetUnauthorizedGuard();
|
||||
if (body?.user) {
|
||||
try {
|
||||
const me = await getMe();
|
||||
@@ -1734,7 +1748,7 @@ export function subscribeSessionEvents(
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
unauthorizedHandler?.();
|
||||
notifyUnauthorized();
|
||||
throw new ApiError(401, 'SSE 未授权');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,14 @@ import { createPortal } from 'react-dom';
|
||||
import QRCode from 'qrcode';
|
||||
import { createRechargeOrder, getBillingConfig, getRechargeOrder } from '../api/client';
|
||||
import type { BillingConfig, RechargeOrder } from '../types';
|
||||
import { invokeWechatJsapiPay, isWeChatBrowser } from '../utils/wechatPay';
|
||||
|
||||
function formatYuan(cents: number) {
|
||||
return `¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)}`;
|
||||
}
|
||||
|
||||
function isWeChatBrowser() {
|
||||
return /MicroMessenger/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function detectPayScene(): 'native' | 'h5' {
|
||||
// 微信内置浏览器走扫码:H5 收银台需商户单独开通,长按识别二维码即可支付
|
||||
if (isWeChatBrowser()) return 'native';
|
||||
function detectPayScene(): 'native' | 'h5' | 'jsapi' {
|
||||
if (isWeChatBrowser()) return 'jsapi';
|
||||
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) ? 'h5' : 'native';
|
||||
}
|
||||
|
||||
@@ -156,6 +152,14 @@ export function RechargeModal({
|
||||
try {
|
||||
const created = await createRechargeOrderWithFallback(selectedCents);
|
||||
setOrder(created);
|
||||
if (created.payMode === 'jsapi' && created.jsapiParams) {
|
||||
startPolling(created.id);
|
||||
const result = await invokeWechatJsapiPay(created.jsapiParams);
|
||||
if (result === 'cancel') {
|
||||
setError('已取消支付,可重新发起');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (created.payMode === 'h5' && created.h5Url && !redirectedRef.current) {
|
||||
redirectedRef.current = true;
|
||||
window.location.href = created.h5Url;
|
||||
@@ -236,10 +240,15 @@ export function RechargeModal({
|
||||
{order?.payMode === 'native' && qrDataUrl && (
|
||||
<div className="recharge-qr-panel">
|
||||
<img src={qrDataUrl} alt="微信支付二维码" className="recharge-qr" />
|
||||
<p className="recharge-muted">请使用微信扫一扫完成支付</p>
|
||||
{expireLabel && <p className="recharge-muted">订单将于 {expireLabel} 过期</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order?.payMode === 'jsapi' && (
|
||||
<div className="recharge-h5-panel">
|
||||
<p className="recharge-muted">
|
||||
{isWeChatBrowser()
|
||||
? '长按下方二维码,选择「识别图中二维码」完成支付'
|
||||
: '请使用微信扫一扫完成支付'}
|
||||
{polling ? '请在微信支付弹窗中完成付款…' : '点击「微信支付」后将直接调起微信收银台'}
|
||||
</p>
|
||||
{expireLabel && <p className="recharge-muted">订单将于 {expireLabel} 过期</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { PortalUser } from '../types';
|
||||
|
||||
export function RequireAdmin({
|
||||
@@ -8,8 +9,15 @@ export function RequireAdmin({
|
||||
user: PortalUser | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (user?.role !== 'admin') {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
const navigate = useNavigate();
|
||||
const allowed = user?.role === 'admin';
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowed) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [allowed, navigate]);
|
||||
|
||||
if (!allowed) return null;
|
||||
return children;
|
||||
}
|
||||
|
||||
+11
-1
@@ -143,14 +143,24 @@ export type BillingConfig = {
|
||||
balanceCents: number;
|
||||
};
|
||||
|
||||
export type JsapiPayParams = {
|
||||
appId: string;
|
||||
timeStamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
signType: 'MD5' | 'RSA';
|
||||
paySign: string;
|
||||
};
|
||||
|
||||
export type RechargeOrder = {
|
||||
id: string;
|
||||
amountCents: number;
|
||||
status: 'pending' | 'paid' | 'failed' | 'expired' | 'refunded';
|
||||
payMode: 'native' | 'h5';
|
||||
payMode: 'native' | 'h5' | 'jsapi';
|
||||
expireAt: number;
|
||||
codeUrl?: string | null;
|
||||
h5Url?: string | null;
|
||||
jsapiParams?: JsapiPayParams | null;
|
||||
};
|
||||
|
||||
export type InsufficientBalanceDetails = {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { JsapiPayParams } from '../types';
|
||||
|
||||
type WeixinJsBridge = {
|
||||
invoke(
|
||||
api: 'getBrandWCPayRequest',
|
||||
params: JsapiPayParams,
|
||||
callback: (result: { err_msg?: string }) => void,
|
||||
): void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
WeixinJSBridge?: WeixinJsBridge;
|
||||
}
|
||||
}
|
||||
|
||||
export function isWeChatBrowser() {
|
||||
return /MicroMessenger/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
export function invokeWechatJsapiPay(params: JsapiPayParams): Promise<'ok' | 'cancel'> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const invoke = () => {
|
||||
const bridge = window.WeixinJSBridge;
|
||||
if (!bridge) {
|
||||
reject(new Error('当前环境不支持微信支付,请在微信内打开'));
|
||||
return;
|
||||
}
|
||||
bridge.invoke('getBrandWCPayRequest', params, (result) => {
|
||||
const message = result.err_msg ?? '';
|
||||
if (message === 'get_brand_wcpay_request:ok') {
|
||||
resolve('ok');
|
||||
return;
|
||||
}
|
||||
if (message === 'get_brand_wcpay_request:cancel') {
|
||||
resolve('cancel');
|
||||
return;
|
||||
}
|
||||
reject(new Error(message || '微信支付失败'));
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof window.WeixinJSBridge === 'undefined') {
|
||||
document.addEventListener('WeixinJSBridgeReady', invoke, { once: true });
|
||||
return;
|
||||
}
|
||||
invoke();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user