注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + AI 助手 Tool Calling 重构

- 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
This commit is contained in:
yuzhiran-dev
2026-06-01 23:14:42 +08:00
parent 6f3fe50ee0
commit 538de50bb1
329 changed files with 8777 additions and 868 deletions
+60 -62
View File
@@ -4,59 +4,68 @@ 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 {
prepay_id?: string;
nonceStr?: string;
timeStamp?: string;
package?: string;
paySign?: string;
signType?: string;
gatewayOrderId?: string;
payUrl?: string;
qrcode?: string;
codeUrl?: string;
redirectUrl?: string;
status?: string;
}
interface Props {
open: boolean;
orderNo: string;
payResult: PayResult;
tradeType: 'JSAPI' | 'NATIVE';
payChannel: 'wxpay' | 'alipay';
onPaid: () => void;
onClose: () => void;
}
export default function PaymentModal({ open, orderNo, payResult, tradeType, onPaid, onClose }: Props) {
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);
const wechatBridgeCalled = useRef(false);
useEffect(() => {
if (!open) {
setStatus('pending');
setMessage('');
wechatBridgeCalled.current = false;
if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
return;
}
// NATIVE: render QR code
if (tradeType === 'NATIVE' && payResult?.codeUrl) {
QRCode.toDataURL(payResult.codeUrl, { margin: 1, width: 280 }, (err, url) => {
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('请使用微信扫描二维码完成支付');
setMessage(t.member.scanQrCode);
startPolling();
}
// JSAPI in WeChat: call WeixinJSBridge
if (tradeType === 'JSAPI' && isWeChatBrowser() && !wechatBridgeCalled.current) {
wechatBridgeCalled.current = true;
setMessage('正在调起微信支付...');
callWechatJsapi(payResult);
// WeChat JSAPI in browser
if (isWeChatBrowser() && payResult.gatewayOrderId) {
setMessage(t.member.alipayRedirect);
startPolling();
}
}, [open, tradeType, payResult?.codeUrl]);
}, [open, payChannel, payResult?.qrcode, payResult?.codeUrl, payResult?.payUrl]);
function startPolling() {
if (pollingRef.current) clearInterval(pollingRef.current);
@@ -64,9 +73,10 @@ export default function PaymentModal({ open, orderNo, payResult, tradeType, onPa
try {
const res = await apiFetch(`/payment/wxpay/query?outTradeNo=${orderNo}`);
const data = await res.json();
if (data.trade_state === 'SUCCESS' || data.localStatus === 'PAID') {
const isPaid = data.trade_state === 'SUCCESS' || data.localStatus === 'PAID' || data.gatewayStatus === 'paid';
if (isPaid) {
setStatus('paid');
setMessage('支付成功!');
setMessage(t.member.orderPaid);
if (pollingRef.current) clearInterval(pollingRef.current);
setTimeout(onPaid, 1500);
}
@@ -74,54 +84,21 @@ export default function PaymentModal({ open, orderNo, payResult, tradeType, onPa
}, 3000);
}
function callWechatJsapi(params: PayResult) {
if (typeof WeixinJSBridge === 'undefined') {
document.addEventListener('WeixinJSBridgeReady', () => doInvoke(params), false);
} else {
doInvoke(params);
}
}
function doInvoke(params: PayResult) {
WeixinJSBridge.invoke(
'getBrandWCPayRequest',
{
appId: '', // filled by WeChat
timeStamp: params.timeStamp || '',
nonceStr: params.nonceStr || '',
package: params.package || '',
signType: params.signType || 'RSA',
paySign: params.paySign || '',
},
(res: any) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
setStatus('paid');
setMessage('支付成功!');
if (pollingRef.current) clearInterval(pollingRef.current);
setTimeout(onPaid, 1500);
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
setMessage('已取消支付');
setStatus('pending');
} else {
setMessage('支付失败,请重试');
setStatus('failed');
}
}
);
}
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"></h3>
<h3 className="text-lg font-semibold text-foreground text-center mb-4">{title}</h3>
{tradeType === 'NATIVE' && (
{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" />
@@ -131,6 +108,17 @@ export default function PaymentModal({ open, orderNo, payResult, tradeType, onPa
</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>
@@ -139,12 +127,22 @@ export default function PaymentModal({ open, orderNo, payResult, tradeType, onPa
) : (
<>
<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>
</>
)}