注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + 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
Reference in New Issue
Block a user