注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + 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:
Regular → Executable
+5
-1
@@ -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">
|
||||
|
||||
Regular → Executable
+145
-92
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+18
-6
@@ -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');
|
||||
|
||||
Regular → Executable
Regular → Executable
+58
-6
@@ -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>
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+60
-62
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Reference in New Issue
Block a user