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