538de50bb1
- Prisma User 模型新增 username 字段(唯一索引) - 注册先查重复再创建,返回友好中文提示(非 500) - 登录支持用户名/手机号/邮箱三种方式 - 前端注册表单增加用户名输入框,预校验 2-20 位格式 - 新增 scripts/deploy.sh:一键构建并部署主站+镜像站+重启后端+重载 Nginx - 镜像站 www.yuzhiran.com.cn Nginx 配置与主站同步 - AI 助手架构升级:用户端/管理后台均采用完整 Tool Calling 架构 - 新增 UserAiAssistantService(18 工具)+ AiAssistantController - admin 助手新增 search + mark-all-notifications-read 工具 - 修复注册 500 错误:catch Prisma P2002 → BadRequestException - Baidu Analytics Script 注入 root layout
153 lines
5.2 KiB
TypeScript
Executable File
153 lines
5.2 KiB
TypeScript
Executable File
'use client';
|
|
|
|
import { useEffect, useState, useRef } from 'react';
|
|
import QRCode from 'qrcode';
|
|
import { apiFetch } from '@/lib/auth';
|
|
import { isWeChatBrowser } from '@/lib/wechat';
|
|
import { useT } from '@/i18n';
|
|
|
|
interface PayResult {
|
|
gatewayOrderId?: string;
|
|
payUrl?: string;
|
|
qrcode?: string;
|
|
codeUrl?: string;
|
|
redirectUrl?: string;
|
|
status?: string;
|
|
}
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
orderNo: string;
|
|
payResult: PayResult;
|
|
payChannel: 'wxpay' | 'alipay';
|
|
onPaid: () => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function PaymentModal({ open, orderNo, payResult, payChannel, onPaid, onClose }: Props) {
|
|
const t = useT();
|
|
const [status, setStatus] = useState<'pending' | 'paid' | 'failed'>('pending');
|
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
|
const [message, setMessage] = useState('');
|
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setStatus('pending');
|
|
setMessage('');
|
|
if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
|
|
return;
|
|
}
|
|
|
|
const qrCode = payResult.qrcode || payResult.codeUrl;
|
|
const redirectUrl = payResult.payUrl || payResult.redirectUrl;
|
|
|
|
if (payChannel === 'alipay') {
|
|
setMessage(t.member.alipayRedirect);
|
|
if (redirectUrl && redirectUrl !== 'mock://pay') {
|
|
window.open(redirectUrl, '_blank');
|
|
}
|
|
startPolling();
|
|
return;
|
|
}
|
|
|
|
// WeChat NATIVE: render QR code
|
|
if (qrCode) {
|
|
QRCode.toDataURL(qrCode, { margin: 1, width: 280 }, (err, url) => {
|
|
if (!err) setQrDataUrl(url);
|
|
});
|
|
setMessage(t.member.scanQrCode);
|
|
startPolling();
|
|
}
|
|
|
|
// WeChat JSAPI in browser
|
|
if (isWeChatBrowser() && payResult.gatewayOrderId) {
|
|
setMessage(t.member.alipayRedirect);
|
|
startPolling();
|
|
}
|
|
}, [open, payChannel, payResult?.qrcode, payResult?.codeUrl, payResult?.payUrl]);
|
|
|
|
function startPolling() {
|
|
if (pollingRef.current) clearInterval(pollingRef.current);
|
|
pollingRef.current = setInterval(async () => {
|
|
try {
|
|
const res = await apiFetch(`/payment/wxpay/query?outTradeNo=${orderNo}`);
|
|
const data = await res.json();
|
|
const isPaid = data.trade_state === 'SUCCESS' || data.localStatus === 'PAID' || data.gatewayStatus === 'paid';
|
|
if (isPaid) {
|
|
setStatus('paid');
|
|
setMessage(t.member.orderPaid);
|
|
if (pollingRef.current) clearInterval(pollingRef.current);
|
|
setTimeout(onPaid, 1500);
|
|
}
|
|
} catch {}
|
|
}, 3000);
|
|
}
|
|
|
|
useEffect(() => {
|
|
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
|
|
}, []);
|
|
|
|
if (!open) return null;
|
|
|
|
const title = payChannel === 'alipay' ? t.member.alipay : t.member.wechatPay;
|
|
const qrCode = payResult.qrcode || payResult.codeUrl;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
|
<div className="bg-card rounded-2xl p-8 w-full max-w-sm mx-4 shadow-xl border border-border" onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-lg font-semibold text-foreground text-center mb-4">{title}</h3>
|
|
|
|
{payChannel === 'wxpay' && qrCode && (
|
|
<div className="flex justify-center mb-4">
|
|
{qrDataUrl ? (
|
|
<img src={qrDataUrl} alt="支付二维码" className="w-56 h-56 rounded-xl border border-border" />
|
|
) : (
|
|
<div className="w-56 h-56 bg-muted rounded-xl animate-pulse" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{payChannel === 'alipay' && status === 'pending' && (
|
|
<div className="flex justify-center mb-4">
|
|
<div className="w-56 h-56 bg-muted rounded-xl flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="text-5xl mb-2">💳</div>
|
|
<p className="text-xs text-muted-foreground">{t.member.alipayRedirect}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'paid' ? (
|
|
<div className="text-center">
|
|
<div className="text-5xl mb-3">✅</div>
|
|
<p className="text-green-600 font-medium">{message}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p className="text-sm text-muted-foreground text-center mb-4">{message}</p>
|
|
{payChannel === 'alipay' && (payResult.payUrl || payResult.redirectUrl) && (
|
|
<a
|
|
href={payResult.payUrl || payResult.redirectUrl || '#'}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block w-full py-2.5 mb-2 text-sm font-medium text-center text-white bg-blue-600 rounded-xl hover:bg-blue-700"
|
|
>
|
|
{t.member.openAlipay}
|
|
</a>
|
|
)}
|
|
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
|
<span className="w-2 h-2 bg-brand-600 rounded-full animate-pulse" />
|
|
{t.member.waitingPay}
|
|
</div>
|
|
<button onClick={onClose} className="mt-4 w-full py-2 text-sm text-muted-foreground border border-border rounded-xl hover:bg-accent">
|
|
{t.member.cancelPay}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|