注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + 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
+5 -1
View File
@@ -30,6 +30,7 @@ const AVAILABLE_TOOLS_DESC = `可用工具列表(需要执行操作时,返
- **list-roles**: 角色列表
- **list-admins**: 管理员列表
- **get-enterprise-orgs**: 企业组织列表
- **search**: 全局搜索(q必填, type? 可选 all|users|orders|courses|contents
=== 创建 ===
- **create-course**: 创建课程(title必填, description?, price?, isFree?, status?
@@ -72,6 +73,9 @@ const AVAILABLE_TOOLS_DESC = `可用工具列表(需要执行操作时,返
=== 导航 ===
- **navigate**: 跳转到页面(path: /admin/users等)
=== 通知管理 ===
- **mark-all-notifications-read**: 将所有通知标记为已读
当用户请求执行操作时,先调用对应工具。工具执行完毕后会用自然语言总结结果。`;
const SYSTEM_PROMPT = `你是宇之然AI管理后台的智能助手,帮助管理员完成日常运营工作。
@@ -210,7 +214,7 @@ export function AdminAIAssistant() {
);
}
const starters = ['查看仪表盘数据', '列出最近用户', '查看待审核评论', '查看企业版组织'];
const starters = ['查看仪表盘数据', '列出最近用户', '搜索订单', '查看待审核评论', '查看企业版组织', '列出所有角色', '帮我查一下最近的课程'];
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
+145 -92
View File
@@ -2,19 +2,83 @@
import { useState, useRef, useEffect, FormEvent } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { MessageCircle, X, Send, Minus, Sparkles } from 'lucide-react';
import { getAssistantContext, parseActionCommand, type AssistantContext, type AssistantAction } from '@/lib/assistant-context';
import { executeAction, setRouter } from '@/lib/assistant-actions';
import { X, Send, Minus, Sparkles } from 'lucide-react';
import { executeClientAction } from '@/lib/assistant-actions';
import { useT } from '@/i18n';
import { getToken, apiFetch } from '@/lib/auth';
import { DEFAULT_MODEL } from '@/lib/models';
import { toast } from 'sonner';
interface Message {
role: 'user' | 'assistant';
content: string;
hasAction?: boolean;
actionExecuted?: boolean;
}
const PAGE_CONTEXTS: Record<string, { greeting: string; starters: string[] }> = {
'/': {
greeting: '你好!我是宇之然 AI 助手,可以帮你了解平台功能、搜索内容、查看学习进度等。试试下面的问题:',
starters: ['宇之然 AI 能做什么?', '如何开始学习 AI', '搜索一些课程', '推荐一个学习路线', '查看有哪些技能'],
},
'/sandbox': {
greeting: '我在 AI 沙盒页面,可以帮你了解沙盒功能、切换模型、开始对话等。',
starters: ['如何切换模型?', '高级参数怎么调?', '如何查看历史记录?', '对话次数限制是多少'],
},
'/skills': {
greeting: '我在技能库页面,可以帮你了解各种技能、推荐适合你的技能。',
starters: ['有哪些技能可以学习?', '如何选择适合我的技能?', '有哪些入门技能?', '如何开始练习一个技能?'],
},
'/learning': {
greeting: '我在学习路径页面,可以帮你分析学习情况、推荐学习内容。',
starters: ['查看我的学情分析', '学习路径有哪些阶段?', '我有哪些薄弱环节', '推荐学习内容'],
},
'/my': {
greeting: '我在个人中心页面,可以帮你管理账户、查看会员信息等。',
starters: ['查看个人信息', '我的会员状态', '查看订单记录', '查看通知'],
},
'/models': {
greeting: '我在模型百科页面,可以帮你了解各种 AI 模型的特点和用途。',
starters: ['有哪些模型可以参考?', '如何选择合适的模型?', '模型能力怎么对比?', '查看所有模型'],
},
'/prompts': {
greeting: '我在提示词工坊页面,可以帮你了解提示词编写技巧。',
starters: ['如何编写一个好的提示词?', '查看有哪些提示词', '如何测试提示词效果?'],
},
'/courses': {
greeting: '我在课程专题页面,可以帮你选择课程、规划学习。',
starters: ['有哪些课程可以学习?', '如何选择适合我的课程?', '有免费的课程吗?', '搜索课程'],
},
'/compare': {
greeting: '我在对比实验室页面,可以帮你了解如何使用对比功能。',
starters: ['对比实验室怎么用?', '如何添加对比模型?', '对比结果怎么看?'],
},
};
function getPageContext(pathname: string) {
const sorted = Object.keys(PAGE_CONTEXTS).sort((a, b) => b.length - a.length);
for (const key of sorted) {
if (pathname.startsWith(key)) return PAGE_CONTEXTS[key];
}
return PAGE_CONTEXTS['/'];
}
function parseToolCall(text: string): { tool: string; params: Record<string, any>; description?: string } | null {
let braceDepth = 0;
let start = -1;
for (let i = 0; i < text.length; i++) {
if (text[i] === '{') {
if (start === -1) start = i;
braceDepth++;
} else if (text[i] === '}') {
braceDepth--;
if (braceDepth === 0 && start !== -1) {
try {
const parsed = JSON.parse(text.slice(start, i + 1));
if (parsed.tool) return parsed;
} catch {}
start = -1;
}
}
}
return null;
}
export function AIAssistant() {
@@ -26,23 +90,18 @@ export function AIAssistant() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [context, setContext] = useState<AssistantContext>(getAssistantContext(pathname));
const [pageCtx, setPageCtx] = useState(getPageContext(pathname));
const [started, setStarted] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setRouter(router);
}, [router]);
useEffect(() => {
setContext(getAssistantContext(pathname));
setPageCtx(getPageContext(pathname));
}, [pathname]);
useEffect(() => {
if (open && !started) {
const ctx = getAssistantContext(pathname);
setMessages([{ role: 'assistant', content: ctx.systemPrompt }]);
setPageCtx(getPageContext(pathname));
setStarted(true);
}
}, [open, pathname, started]);
@@ -55,6 +114,29 @@ export function AIAssistant() {
if (open) inputRef.current?.focus();
}, [open]);
async function postChat(messages: { role: string; content: string }[], pageContext?: string): Promise<string> {
const tk = getToken();
if (!tk) throw new Error('请先登录');
const res = await apiFetch('/ai-assistant/chat', {
method: 'POST',
body: JSON.stringify({ messages, pageContext }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '请求失败');
return data.reply;
}
async function executeTool(tool: string, params: Record<string, any>, messages: { role: string; content: string }[]): Promise<{ reply: string }> {
const tk = getToken();
const res = await apiFetch('/ai-assistant/action', {
method: 'POST',
body: JSON.stringify({ tool, params, messages }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '工具执行失败');
return data;
}
async function handleSend(e: FormEvent) {
e.preventDefault();
const text = input.trim();
@@ -67,47 +149,34 @@ export function AIAssistant() {
try {
const tk = getToken();
let reply = '';
if (tk) {
const ctx = getAssistantContext(pathname);
const apiMessages = [
{ role: 'system', content: ctx.systemPrompt },
...messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })),
{ role: 'user', content: text },
];
const res = await apiFetch('/sandbox/chat', {
method: 'POST',
body: JSON.stringify({
conversationId: crypto.randomUUID(),
model: DEFAULT_MODEL,
messages: apiMessages,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '请求失败');
reply = data.reply;
} else {
await new Promise(r => setTimeout(r, 400));
reply = '📝 登录后可体验完整 AI 对话功能。\n\n点击右上角「登录」或「注册」即可开始使用,解锁 AI 助手的全部能力。';
if (!tk) {
const reply = '📝 登录后可体验完整 AI 对话功能。\n\n点击右上角「登录」或「注册」即可开始使用,解锁 AI 助手的全部能力。';
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
return;
}
const action = parseActionCommand(reply);
const hasAction = !!action;
if (hasAction) {
const cleanReply = reply.replace(/\{[\s\S]*?"action"\s*:\s*?"[^"]+"[\s\S]*?\}/, '').trim();
const finalReply = cleanReply || '收到你的请求,正在处理...';
setMessages(prev => [...prev, { role: 'assistant', content: finalReply, hasAction: true }]);
const result = await executeAction(action);
if (result.success) {
toast.success(result.message, { icon: <Sparkles className="w-4 h-4" /> });
setMessages(prev => prev.map((m, i) =>
i === prev.length - 1 ? { ...m, actionExecuted: true } : m
));
const apiMessages = [
...messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })),
{ role: 'user', content: text },
];
const currentPage = pathname.split('/').filter(Boolean)[0] || '首页';
let reply = await postChat(apiMessages, currentPage);
const toolCall = parseToolCall(reply);
if (toolCall) {
const clientActions = ['navigate', 'setModel', 'startChat', 'openSkill', 'setParameter'];
if (clientActions.includes(toolCall.tool)) {
setMessages(prev => [...prev, { role: 'assistant', content: `正在${toolCall.description || '执行操作'}...` }]);
const result = await executeClientAction(toolCall.tool, toolCall.params, router);
if (result.success) {
toast.success(result.message, { icon: <Sparkles className="w-4 h-4" /> });
} else {
toast.error(result.message);
}
} else {
toast.error(result.message);
const result = await executeTool(toolCall.tool, toolCall.params, apiMessages);
setMessages(prev => [...prev, { role: 'assistant', content: result.reply }]);
}
} else {
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
@@ -121,9 +190,7 @@ export function AIAssistant() {
function handleStarter(starter: string) {
setInput(starter);
setTimeout(() => {
inputRef.current?.focus();
}, 0);
setTimeout(() => inputRef.current?.focus(), 0);
}
if (!open) {
@@ -149,8 +216,6 @@ export function AIAssistant() {
);
}
const ctx = getAssistantContext(pathname);
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
<div
@@ -168,7 +233,7 @@ export function AIAssistant() {
<button onClick={() => setMinimized(!minimized)} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors">
<Minus className="w-4 h-4" />
</button>
<button onClick={() => { setOpen(false); setMinimized(false); }} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors">
<button onClick={() => { setOpen(false); setMinimized(false); setStarted(false); setMessages([]); }} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors">
<X className="w-4 h-4" />
</button>
</div>
@@ -177,13 +242,13 @@ export function AIAssistant() {
{!minimized && (
<>
<div className="overflow-y-auto p-3 space-y-3" style={{ maxHeight: '360px' }}>
{messages.length === 1 && messages[0].role === 'assistant' && (
{messages.length === 0 && (
<div className="mb-2">
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
{t.assistant?.greeting || '你好!我是宇之然 AI 助手,可以帮你了解和使用本站功能。试试下面的问题:'}
{pageCtx.greeting}
</p>
<div className="flex flex-wrap gap-1.5">
{ctx.starters.map((q, i) => (
{pageCtx.starters.map((q, i) => (
<button
key={i}
onClick={() => handleStarter(q)}
@@ -195,37 +260,25 @@ export function AIAssistant() {
</div>
</div>
)}
{messages.map((msg, i) => {
const showActionIndicator = msg.hasAction && msg.actionExecuted && i === messages.length - 1;
return (
<div key={i} className={`flex items-start gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-6 h-6 bg-brand-600 rounded-lg flex items-center justify-center text-white text-[10px] font-bold shrink-0 mt-0.5">Y</div>
)}
<div
className={`max-w-[85%] rounded-2xl px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap relative ${
msg.role === 'user'
? 'bg-brand-600 text-white rounded-tr-none'
: 'bg-muted text-foreground rounded-tl-none'
}`}
>
{i === messages.length - 1 && msg.role === 'assistant' && started && messages.length > 1
? msg.content
: msg.role === 'assistant' && i === 0
? null
: msg.content}
{showActionIndicator && (
<span className="absolute -top-2 -right-2 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center">
<Sparkles className="w-3 h-3 text-white" />
</span>
)}
</div>
{msg.role === 'user' && (
<div className="w-6 h-6 bg-muted-foreground/20 rounded-lg flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5"></div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex items-start gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-6 h-6 bg-brand-600 rounded-lg flex items-center justify-center text-white text-[10px] font-bold shrink-0 mt-0.5">Y</div>
)}
<div
className={`max-w-[85%] rounded-2xl px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap ${
msg.role === 'user'
? 'bg-brand-600 text-white rounded-tr-none'
: 'bg-muted text-foreground rounded-tl-none'
}`}
>
{msg.content}
</div>
);
})}
{msg.role === 'user' && (
<div className="w-6 h-6 bg-muted-foreground/20 rounded-lg flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5"></div>
)}
</div>
))}
{sending && (
<div className="flex items-start gap-2">
<div className="w-6 h-6 bg-brand-600 rounded-lg flex items-center justify-center text-white text-[10px] font-bold shrink-0 mt-0.5">Y</div>
@@ -266,4 +319,4 @@ export function AIAssistant() {
</div>
</div>
);
}
}
+18 -6
View File
@@ -15,7 +15,7 @@ describe('Footer', () => {
it('should render navigation links', () => {
render(<Footer />);
expect(screen.getByText('专题')).toBeInTheDocument();
expect(screen.getByText('课程')).toBeInTheDocument();
expect(screen.getByText('提示词库')).toBeInTheDocument();
expect(screen.getByText('AI 工具')).toBeInTheDocument();
});
@@ -34,22 +34,34 @@ describe('Footer', () => {
expect(screen.getByText('北京宇之然科技中心')).toBeInTheDocument();
});
it('should render ICP link', () => {
it('should render ICP and gongan beian links', () => {
render(<Footer />);
const icpLink = screen.getByText(/ICP 备案号/);
const icpLink = screen.getByText('京ICP备2026007249号-1');
expect(icpLink).toBeInTheDocument();
expect(icpLink.closest('a')).toHaveAttribute('href', 'https://beian.miit.gov.cn/');
const gonganLink = screen.getByText('京公网安备11011502039545号');
expect(gonganLink).toBeInTheDocument();
expect(gonganLink.closest('a')).toHaveAttribute('href', 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545');
});
it('should render QR code section', () => {
render(<Footer />);
expect(screen.getByText('微信公众号')).toBeInTheDocument();
expect(screen.getByText('微信服务号')).toBeInTheDocument();
expect(screen.getByText('小程序')).toBeInTheDocument();
expect(screen.getByText('微信客服')).toBeInTheDocument();
});
it('should render copyright with current year', () => {
render(<Footer />);
const year = new Date().getFullYear();
expect(screen.getByText(new RegExp(`${year}`))).toBeInTheDocument();
const year = String(new Date().getFullYear());
expect(screen.getAllByText(new RegExp(year)).length).toBeGreaterThanOrEqual(1);
});
it('should have correct links for navigation items', () => {
render(<Footer />);
const coursesLink = screen.getByText('专题').closest('a');
const coursesLink = screen.getByText('课程').closest('a');
expect(coursesLink).toHaveAttribute('href', '/courses');
const privacyLink = screen.getByText('隐私政策').closest('a');
View File
+58 -6
View File
@@ -4,12 +4,43 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
import Image from 'next/image';
interface BeianInfo {
icp: string
gongan: string
gonganLink: string
showGongan: boolean
}
function getBeianInfo(): BeianInfo {
if (typeof window === 'undefined') {
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
}
const hostname = window.location.hostname
if (hostname === 'yuzhiran.com' || hostname === 'www.yuzhiran.com') {
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
}
if (hostname === 'yuzhiran.com.cn' || hostname === 'www.yuzhiran.com.cn') {
return { icp: '京ICP备2026007249号-2', gongan: '京公网安备11011502039622号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039622', showGongan: true }
}
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
}
const qrCodes = [
{ src: '/images/yzr/yuzhiran.jpg', alt: '微信公众号', label: '微信公众号' },
{ src: '/images/yzr/yuzhiran-tech.jpg', alt: '微信服务号', label: '微信服务号' },
{ src: '/images/yzr/yuzhiran-yhl.jpg', alt: '小程序', label: '小程序' },
{ src: '/images/yzr/kefu.png', alt: '微信客服', label: '微信客服' },
]
export function Footer() {
const t = useT();
const [config, setConfig] = useState<Record<string, string>>({});
const [beian, setBeian] = useState<BeianInfo>({ icp: '', gongan: '', gonganLink: '', showGongan: false });
useEffect(() => {
setBeian(getBeianInfo());
fetch(`${API_BASE}/public/config`)
.then(r => r.json()).then(data => setConfig(data || {}))
.catch(() => {});
@@ -18,10 +49,25 @@ export function Footer() {
return (
<footer className="border-t border-border bg-muted/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
<div className="col-span-2 md:col-span-1">
<div className="grid grid-cols-2 md:grid-cols-5 gap-8">
<div className="col-span-2 md:col-span-2">
<h3 className="text-lg font-bold bg-gradient-to-r from-brand-600 to-brand-400 bg-clip-text text-transparent mb-4"> AI</h3>
<p className="text-sm text-muted-foreground">{t.footer.tagline}</p>
<p className="text-sm text-muted-foreground mb-4">{t.footer.tagline}</p>
<div className="flex flex-wrap gap-3">
{qrCodes.map((qr) => (
<div key={qr.alt} className="text-center group">
<Image
src={qr.src}
alt={qr.alt}
width={72}
height={72}
className="rounded-lg bg-white transition-transform duration-200 group-hover:scale-150 group-hover:z-10 group-hover:shadow-xl cursor-pointer"
unoptimized
/>
<p className="text-xs text-muted-foreground mt-1">{qr.label}</p>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-semibold mb-3">{t.footer.explore}</h4>
@@ -53,11 +99,17 @@ export function Footer() {
<div className="mt-10 pt-8 border-t border-border">
<div className="flex flex-col md:flex-row items-center justify-between gap-2 text-xs text-muted-foreground">
<p>{t.footer.copyright.replace('{year}', String(new Date().getFullYear()))}</p>
<p>
<div className="flex items-center gap-3">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">
{config.icp_number ? `ICP 备案号:${config.icp_number}` : 'ICP 备案号:京ICP备XXXXXXXX号'}
{beian.icp}
</a>
</p>
{beian.showGongan && (
<a href={beian.gonganLink} target="_blank" rel="noreferrer" className="hover:text-foreground transition-colors inline-flex items-center gap-1">
<Image src="/images/beian/gongan-beian.png" alt="公安备案" width={16} height={16} unoptimized />
{beian.gongan}
</a>
)}
</div>
</div>
</div>
</div>
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+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>
</>
)}
View File
View File
View File
View File
View File
View File