注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + 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 -7
View File
@@ -43,8 +43,8 @@ describe('HomePage', () => {
it('should render sandbox preview section', () => {
render(<HomePage />);
expect(screen.getByText('AI 沙盒')).toBeInTheDocument();
expect(screen.getByText('在线体验 AI 对话,边学边练')).toBeInTheDocument();
expect(screen.getByText('AI 沙盒实战')).toBeInTheDocument();
expect(screen.getByText('内置 AI 对话沙盒,边学边练')).toBeInTheDocument();
});
it('should render CTA section', () => {
@@ -64,14 +64,12 @@ describe('HomePage', () => {
it('should render course descriptions', () => {
render(<HomePage />);
expect(screen.getByText(/面向零基础用户/)).toBeInTheDocument();
expect(screen.getByText(/系统学习提示词编写技巧/)).toBeInTheDocument();
expect(screen.getByText(/学习使用 AI 工具/)).toBeInTheDocument();
expect(screen.getAllByText('探索 AI 世界').length).toBe(3);
});
it('should render sandbox with mock chat interface', () => {
render(<HomePage />);
expect(screen.getByText(/你好!我是宇之然 AI 助手/)).toBeInTheDocument();
expect(screen.getByText(/正在输入/)).toBeInTheDocument();
expect(screen.getByText('AI 沙盒实战')).toBeInTheDocument();
expect(screen.getByText('内置 AI 对话沙盒,边学边练')).toBeInTheDocument();
});
});
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+206 -29
View File
@@ -1,7 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
@@ -11,48 +10,109 @@ interface Order {
amount: number;
planType: string;
status: string;
payChannel: string | null;
transactionId: string | null;
paidAt: string | null;
createdAt: string;
user?: { nickname: string };
user?: { id: number; nickname: string; phone?: string; email?: string };
}
export default function AdminOrders() {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [channelFilter, setChannelFilter] = useState('');
const [refundModal, setRefundModal] = useState<{ open: boolean; order: Order | null; reason: string; loading: boolean }>({
open: false, order: null, reason: '', loading: false,
});
useEffect(() => { loadOrders(); }, []);
const token = typeof window !== 'undefined' ? localStorage.getItem('adminToken') : null;
async function loadOrders() {
const loadOrders = useCallback(async () => {
setLoading(true);
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/orders`, {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('pageSize', String(pageSize));
if (search) params.set('search', search);
if (statusFilter) params.set('status', statusFilter);
if (channelFilter) params.set('payChannel', channelFilter);
const res = await fetch(`${API_BASE}/admin/orders?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setOrders(data.items || []);
setTotal(data.total || 0);
}
} catch {}
setLoading(false);
}, [page, pageSize, search, statusFilter, channelFilter, token]);
useEffect(() => { loadOrders(); }, [loadOrders]);
function handleSearch() {
setSearch(searchInput);
setPage(1);
}
async function refund(orderNo: string) {
if (!confirm('确认要退款吗?')) return;
function handleRefund(order: Order) {
setRefundModal({ open: true, order, reason: '用户申请退款', loading: false });
}
async function confirmRefund() {
const order = refundModal.order;
if (!order) return;
setRefundModal(prev => ({ ...prev, loading: true }));
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/payment/wxpay/refund`, {
const res = await fetch(`${API_BASE}/admin/orders/${order.orderNo}/refund`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ outTradeNo: orderNo, amount: 0, reason: '管理员退款' }),
body: JSON.stringify({ amount: order.amount, reason: refundModal.reason }),
});
alert('退款成功');
loadOrders();
} catch {}
if (res.ok) {
alert('退款成功');
setRefundModal({ open: false, order: null, reason: '', loading: false });
loadOrders();
} else {
const err = await res.json();
alert(err.message || '退款失败');
}
} catch {
alert('退款失败,请重试');
}
}
async function handleMarkPaid(orderNo: string) {
if (!confirm('确认要将此订单标记为已支付吗?')) return;
try {
const res = await fetch(`${API_BASE}/admin/orders/${orderNo}/mark-paid`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
alert('已标记为已支付');
loadOrders();
} else {
const err = await res.json();
alert(err.message || '操作失败');
}
} catch {
alert('操作失败');
}
}
const totalPages = Math.ceil(total / pageSize);
if (loading) return (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-48" />
@@ -69,6 +129,46 @@ export default function AdminOrders() {
</div>
<div className="p-6">
<div className="flex flex-wrap gap-3 mb-4 items-center">
<div className="flex gap-2">
<input
type="text"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
placeholder="搜索订单号/用户/手机号..."
className="px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-600"
/>
<button
onClick={handleSearch}
className="px-4 py-2 text-sm bg-brand-600 text-white rounded-lg hover:bg-brand-700"
>
</button>
</div>
<select
value={statusFilter}
onChange={e => { setStatusFilter(e.target.value); setPage(1); }}
className="px-3 py-2 text-sm bg-background border border-border rounded-lg"
>
<option value=""></option>
<option value="PENDING"></option>
<option value="PAID"></option>
<option value="CANCELLED"></option>
<option value="REFUNDED">退</option>
</select>
<select
value={channelFilter}
onChange={e => { setChannelFilter(e.target.value); setPage(1); }}
className="px-3 py-2 text-sm bg-background border border-border rounded-lg"
>
<option value=""></option>
<option value="wxpay"></option>
<option value="alipay"></option>
</select>
<span className="text-sm text-muted-foreground"> {total} </span>
</div>
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
@@ -77,6 +177,7 @@ export default function AdminOrders() {
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
@@ -85,8 +186,12 @@ export default function AdminOrders() {
<tbody className="divide-y divide-border">
{orders.map(order => (
<tr key={order.id} className="hover:bg-accent/50">
<td className="px-6 py-4 text-sm text-foreground font-mono">{order.orderNo}</td>
<td className="px-6 py-4 text-sm text-foreground">{order.user?.nickname || '-'}</td>
<td className="px-6 py-4 text-sm text-foreground font-mono max-w-[160px] truncate" title={order.orderNo}>
{order.orderNo}
</td>
<td className="px-6 py-4 text-sm text-foreground">{order.user?.nickname || '-'}
{order.user?.phone && <span className="text-xs text-muted-foreground ml-1">({order.user.phone})</span>}
</td>
<td className="px-6 py-4 text-sm text-foreground font-medium">¥{order.amount}</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
@@ -97,6 +202,17 @@ export default function AdminOrders() {
{order.planType === 'YEARLY' ? '年卡' : order.planType === 'MONTHLY' ? '月卡' : order.planType}
</span>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
order.payChannel === 'alipay'
? 'bg-blue-100 text-blue-700'
: order.payChannel === 'wxpay'
? 'bg-green-100 text-green-700'
: 'bg-muted text-muted-foreground'
}`}>
{order.payChannel === 'alipay' ? '支付宝' : order.payChannel === 'wxpay' ? '微信' : '-'}
</span>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
order.status === 'PAID' ? 'bg-green-100 text-green-700' :
@@ -104,21 +220,25 @@ export default function AdminOrders() {
order.status === 'REFUNDED' ? 'bg-red-100 text-red-700' :
'bg-muted text-muted-foreground'
}`}>
{order.status}
{order.status === 'PAID' ? '已支付' : order.status === 'PENDING' ? '待支付' : order.status === 'REFUNDED' ? '已退款' : '已取消'}
</span>
</td>
<td className="px-6 py-4 text-sm text-muted-foreground">
{new Date(order.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4">
{order.status === 'PAID' && (
<button
onClick={() => refund(order.orderNo)}
className="text-xs text-red-600 hover:text-red-800"
>
退
</button>
)}
<div className="flex gap-2">
{order.status === 'PAID' && (
<button onClick={() => handleRefund(order)} className="text-xs text-red-600 hover:text-red-800">
退
</button>
)}
{order.status === 'PENDING' && (
<button onClick={() => handleMarkPaid(order.orderNo)} className="text-xs text-green-600 hover:text-green-800">
</button>
)}
</div>
</td>
</tr>
))}
@@ -126,12 +246,69 @@ export default function AdminOrders() {
</table>
{orders.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
<div className="text-center py-20 text-muted-foreground"></div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
<span className="text-sm text-muted-foreground"> {page}/{totalPages} </span>
<div className="flex gap-2">
<button
disabled={page <= 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
className="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-30 hover:bg-accent"
>
</button>
<button
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
className="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-30 hover:bg-accent"
>
</button>
</div>
</div>
)}
</div>
</div>
{refundModal.open && refundModal.order && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setRefundModal({ ...refundModal, open: false })}>
<div className="bg-card rounded-2xl p-6 w-full max-w-md mx-4 shadow-xl border border-border" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold text-foreground mb-2">退</h3>
<p className="text-sm text-muted-foreground mb-4">
: {refundModal.order.orderNo}<br />
: ¥{refundModal.order.amount}
</p>
<div className="mb-4">
<label className="text-sm text-foreground block mb-1">退</label>
<input
type="text"
value={refundModal.reason}
onChange={e => setRefundModal(prev => ({ ...prev, reason: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg"
placeholder="请输入退款原因"
/>
</div>
<div className="flex gap-3">
<button
onClick={() => setRefundModal({ open: false, order: null, reason: '', loading: false })}
className="flex-1 py-2 text-sm border border-border rounded-xl hover:bg-accent"
>
</button>
<button
onClick={confirmRefund}
disabled={refundModal.loading}
className="flex-1 py-2 text-sm bg-red-600 text-white rounded-xl hover:bg-red-700 disabled:opacity-50"
>
{refundModal.loading ? '处理中...' : '确认退款'}
</button>
</div>
</div>
</div>
)}
</>
);
}
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
Regular → Executable
+35 -8
View File
@@ -25,7 +25,20 @@ function AuthForm() {
const [error, setError] = useState('');
const [loginForm, setLoginForm] = useState({ account: '', password: '' });
const [registerForm, setRegisterForm] = useState({ phone: '', email: '', password: '', confirmPassword: '', nickname: '' });
const [registerForm, setRegisterForm] = useState({ username: '', phone: '', email: '', password: '', confirmPassword: '', nickname: '' });
function getFriendlyError(msg: string): string {
const map: Record<string, string> = {
'手机号格式不正确': '手机号格式不正确(11位手机号)',
'邮箱格式不正确': '邮箱格式不正确',
'用户名格式不正确(2-20位,支持中英文、数字、下划线)': '用户名格式不正确(2-20位,支持中英文、数字、下划线)',
'用户名已被注册': t.auth.usernameConflict,
'手机号已被注册': t.auth.phoneConflict,
'邮箱已被注册': t.auth.emailConflict,
'请填写用户名、手机号或邮箱': '请填写用户名、手机号或邮箱',
};
return map[msg] || msg || t.auth.loginFailed;
}
async function handleLogin(e: FormEvent) {
e.preventDefault();
@@ -37,38 +50,50 @@ function AuthForm() {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account: loginForm.account, password: loginForm.password }),
});
if (!res.ok) {
let msg = t.auth.loginFailed;
try { const d = await res.json(); msg = d.message || msg; } catch {}
throw new Error(msg);
}
const data = await res.json();
if (!res.ok) throw new Error(data.message || t.auth.loginFailed);
login(data.accessToken, data.refreshToken);
toast.success(t.auth.loginSuccess);
router.push('/');
} catch (err: any) { setError(err.message); }
} catch (err: any) { setError(getFriendlyError(err.message)); }
finally { setLoading(false); }
}
async function handleRegister(e: FormEvent) {
e.preventDefault();
setError('');
const { phone, email, password, confirmPassword, nickname } = registerForm;
if (!phone && !email) { setError(t.auth.fillPhoneOrEmail); return; }
const { username, phone, email, password, confirmPassword, nickname } = registerForm;
if (!username && !phone && !email) { setError(t.auth.fillPhoneOrEmail); return; }
if (username && !/^[a-zA-Z0-9_\u4e00-\u9fa5]{2,20}$/.test(username)) { setError('用户名格式不正确(2-20位,支持中英文、数字、下划线)'); return; }
if (phone && !/^1[3-9]\d{9}$/.test(phone)) { setError('手机号格式不正确(11位手机号)'); return; }
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { setError('邮箱格式不正确'); return; }
if (!password) { setError(t.auth.fillPassword); return; }
if (password.length < 6) { setError(t.auth.passwordMinLength); return; }
if (password !== confirmPassword) { setError(t.auth.passwordsNotMatch); return; }
setLoading(true);
try {
const body: Record<string, string> = { password };
if (username) body.username = username;
if (phone) body.phone = phone;
if (email) body.email = email;
if (nickname) body.nickname = nickname;
const res = await fetch(`${API_BASE}/auth/register`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!res.ok) {
let msg = t.auth.registerFailed;
try { const d = await res.json(); msg = d.message || msg; } catch {}
throw new Error(msg);
}
const data = await res.json();
if (!res.ok) throw new Error(data.message || t.auth.registerFailed);
login(data.accessToken, data.refreshToken);
toast.success(t.auth.registerSuccess);
router.push('/');
} catch (err: any) { setError(err.message); }
} catch (err: any) { setError(getFriendlyError(err.message)); }
finally { setLoading(false); }
}
@@ -99,7 +124,7 @@ function AuthForm() {
<TabsContent value="login">
<form onSubmit={handleLogin} className="space-y-4">
<Input type="text" placeholder={t.auth.accountPlaceholder} value={loginForm.account}
<Input type="text" placeholder="用户名 / 手机号 / 邮箱" value={loginForm.account}
onChange={(e) => setLoginForm({ ...loginForm, account: e.target.value })} />
<Input type="password" placeholder={t.auth.password} value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })} />
@@ -115,6 +140,8 @@ function AuthForm() {
onChange={(e) => setRegisterForm({ ...registerForm, phone: e.target.value })} />
<Input type="email" placeholder="Email" value={registerForm.email}
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })} />
<Input type="text" placeholder={t.auth.usernameOptional} value={registerForm.username}
onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })} />
<Input type="text" placeholder={t.auth.nicknameOptional} value={registerForm.nickname}
onChange={(e) => setRegisterForm({ ...registerForm, nickname: e.target.value })} />
<Input type="password" placeholder={t.auth.passwordHint} value={registerForm.password}
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
Regular → Executable
View File
Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 242 B

After

Width:  |  Height:  |  Size: 242 B

View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
+10
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import Script from 'next/script';
import './globals.css';
import { RootLayoutClient } from './layout-client';
import { SITE_URL } from '@/lib/config';
@@ -30,6 +31,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="zh-CN" suppressHydrationWarning>
<body className="min-h-screen flex flex-col">
<RootLayoutClient>{children}</RootLayoutClient>
<Script id="baidu-analytics" strategy="afterInteractive">
{`var _hmt = _hmt || [];
(function() {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?1225ac1c8630e699ed659abf2630a176";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();`}
</Script>
</body>
</html>
);
View File
View File
Regular → Executable
View File
View File
View File
View File
+64 -30
View File
@@ -6,16 +6,15 @@ import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import PaymentModal from '@/components/ui/payment-modal';
import { useT } from '@/i18n';
import { isWeChatBrowser, getOpenidFromUrl, isMiniProgram } from '@/lib/wechat';
import { isWeChatBrowser } from '@/lib/wechat';
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 Subscription {
@@ -23,7 +22,7 @@ interface Subscription {
}
interface Order {
id: number; orderNo: string; amount: number; planType: string; status: string; createdAt: string;
id: number; orderNo: string; amount: number; planType: string; status: string; payChannel?: string; createdAt: string;
}
const PLANS = [
@@ -41,9 +40,10 @@ export default function MemberPage() {
const [quota, setQuota] = useState<{ used: number; remaining: number; dailyLimit: number } | null>(null);
const [loading, setLoading] = useState(true);
const [payLoading, setPayLoading] = useState<string | null>(null);
const [payChannel, setPayChannel] = useState<'wxpay' | 'alipay'>('alipay');
const [paymentModal, setPaymentModal] = useState<{
open: boolean; orderNo: string; payResult: PayResult; tradeType: 'JSAPI' | 'NATIVE';
}>({ open: false, orderNo: '', payResult: {}, tradeType: 'NATIVE' });
open: boolean; orderNo: string; payResult: PayResult; payChannel: 'wxpay' | 'alipay';
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
useEffect(() => { loadData(); }, []);
@@ -66,16 +66,11 @@ export default function MemberPage() {
async function handleSubscribe(planType: string) {
setPayLoading(planType);
try {
const inWeChat = isWeChatBrowser();
const openid = getOpenidFromUrl();
const useJsapi = inWeChat && openid && isMiniProgram();
const tradeType = useJsapi ? 'JSAPI' : 'NATIVE';
const body: Record<string, any> = {
amount: planType === 'MONTHLY' ? 49.9 : 299,
planType, payChannel: 'wxpay', tradeType,
planType,
payChannel,
};
if (useJsapi && openid) body.openid = openid;
const res = await apiFetch('/orders/create', {
method: 'POST',
@@ -84,13 +79,17 @@ export default function MemberPage() {
const data = await res.json();
if (data.order && data.payResult) {
// Mock mode → auto-completed by backend
if (data.payResult.codeUrl === 'mock://pay') {
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
if ((payUrl === 'mock://pay' || qrCode === 'mock://pay') || data.message === '模拟支付完成') {
loadData();
} else {
} else if (payUrl || qrCode) {
setPaymentModal({
open: true, orderNo: data.order.orderNo,
payResult: data.payResult, tradeType,
open: true,
orderNo: data.order.orderNo,
payResult: data.payResult,
payChannel,
});
}
}
@@ -164,10 +163,40 @@ export default function MemberPage() {
))}
</div>
{plan.id !== 'FREE' && (
<button onClick={() => handleSubscribe(plan.id)} disabled={payLoading === plan.id || isCurrent}
className={`w-full py-2.5 rounded-xl text-sm font-medium transition-all ${isCurrent ? 'bg-muted text-muted-foreground cursor-default' : plan.popular ? 'bg-brand-600 text-white hover:bg-brand-700' : 'border border-border text-foreground hover:bg-accent'} disabled:opacity-50`}>
{payLoading === plan.id ? t.member.processing : isCurrent ? t.member.currentPlan_badge : t.member.subscribe}
</button>
<div className="space-y-3">
{!isCurrent && (
<div className="flex gap-2">
<button
onClick={() => { setPayChannel('alipay'); handleSubscribe(plan.id); }}
disabled={payLoading === plan.id}
className={`flex-1 py-2.5 rounded-xl text-sm font-medium transition-all disabled:opacity-50 ${
plan.popular
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'border border-border text-foreground hover:bg-accent'
}`}
>
{payLoading === plan.id ? t.member.processing : t.member.alipay}
</button>
<button
onClick={() => { setPayChannel('wxpay'); handleSubscribe(plan.id); }}
disabled={payLoading === plan.id}
className={`flex-1 py-2.5 rounded-xl text-sm font-medium transition-all disabled:opacity-50 ${
plan.popular
? 'bg-brand-600 text-white hover:bg-brand-700'
: 'border border-border text-foreground hover:bg-accent'
}`}
>
{payLoading === plan.id ? t.member.processing : t.member.wechatPay}
</button>
</div>
)}
{isCurrent && (
<button disabled
className="w-full py-2.5 rounded-xl text-sm font-medium bg-muted text-muted-foreground cursor-default">
{t.member.currentPlan_badge}
</button>
)}
</div>
)}
</div>
);
@@ -188,9 +217,14 @@ export default function MemberPage() {
</div>
<div className="text-right">
<div className="font-semibold text-foreground">¥{order.amount}</div>
<span className={`text-xs px-2 py-0.5 rounded ${order.status === 'PAID' ? 'bg-green-100 text-green-700' : order.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' : 'bg-muted text-muted-foreground'}`}>
{order.status}
</span>
<div className="flex items-center gap-2 justify-end">
{order.payChannel && (
<span className="text-xs text-muted-foreground">{order.payChannel === 'alipay' ? '支付宝' : '微信'}</span>
)}
<span className={`text-xs px-2 py-0.5 rounded ${order.status === 'PAID' ? 'bg-green-100 text-green-700' : order.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' : 'bg-muted text-muted-foreground'}`}>
{order.status}
</span>
</div>
</div>
</div>
))}
@@ -201,7 +235,7 @@ export default function MemberPage() {
open={paymentModal.open}
orderNo={paymentModal.orderNo}
payResult={paymentModal.payResult}
tradeType={paymentModal.tradeType}
payChannel={paymentModal.payChannel}
onPaid={handlePaymentPaid}
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
/>
Regular → Executable
View File
View File
Regular → Executable
View File
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
Regular → Executable
View File
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
+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

Some files were not shown because too many files have changed in this diff Show More