feat: 支付闭环 + 运营助手 Tool Calling + 管理后台完善

- 支付系统:微信支付 mock 自动完成、NATIVE 扫码支付、JSAPI 集成
- 运营助手:Tool Calling 架构,19 个可执行工具,AI 驱动操作
- 角色管理:表格布局 + Dialog 表单 + 权限勾选
- 配置统一:config.ts 单一数据源
- API 审计:补齐 status toggle / comments 端点
- 暗黑模式硬件编码颜色全部替换为 CSS 变量
This commit is contained in:
yuzhiran-dev
2026-05-20 18:29:00 +08:00
parent 728edc59ef
commit 23edb74bce
76 changed files with 1589 additions and 473 deletions
@@ -0,0 +1,154 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import QRCode from 'qrcode';
import { apiFetch } from '@/lib/auth';
import { isWeChatBrowser } from '@/lib/wechat';
interface PayResult {
prepay_id?: string;
nonceStr?: string;
timeStamp?: string;
package?: string;
paySign?: string;
signType?: string;
codeUrl?: string;
}
interface Props {
open: boolean;
orderNo: string;
payResult: PayResult;
tradeType: 'JSAPI' | 'NATIVE';
onPaid: () => void;
onClose: () => void;
}
export default function PaymentModal({ open, orderNo, payResult, tradeType, onPaid, onClose }: Props) {
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) => {
if (!err) setQrDataUrl(url);
});
setMessage('请使用微信扫描二维码完成支付');
startPolling();
}
// JSAPI in WeChat: call WeixinJSBridge
if (tradeType === 'JSAPI' && isWeChatBrowser() && !wechatBridgeCalled.current) {
wechatBridgeCalled.current = true;
setMessage('正在调起微信支付...');
callWechatJsapi(payResult);
startPolling();
}
}, [open, tradeType, payResult?.codeUrl]);
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();
if (data.trade_state === 'SUCCESS' || data.localStatus === 'PAID') {
setStatus('paid');
setMessage('支付成功!');
if (pollingRef.current) clearInterval(pollingRef.current);
setTimeout(onPaid, 1500);
}
} catch {}
}, 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;
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>
{tradeType === 'NATIVE' && (
<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>
)}
{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>
<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" />
...
</div>
<button onClick={onClose} className="mt-4 w-full py-2 text-sm text-muted-foreground border border-border rounded-xl hover:bg-accent">
</button>
</>
)}
</div>
</div>
);
}