注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + 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
-7
@@ -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
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+206
-29
@@ -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
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+35
-8
@@ -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}
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
|
Before Width: | Height: | Size: 242 B After Width: | Height: | Size: 242 B |
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+10
@@ -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>
|
||||
);
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+64
-30
@@ -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
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user