feat: add register/login/dashboard pages, signals nav
This commit is contained in:
parent
b1d959cf20
commit
89a390e6bd
112
frontend/app/dashboard/page.tsx
Normal file
112
frontend/app/dashboard/page.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface UserInfo {
|
||||
id: number;
|
||||
email: string;
|
||||
discord_id: string | null;
|
||||
tier: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [discordId, setDiscordId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("arb_token");
|
||||
if (!token) { router.push("/login"); return; }
|
||||
fetch("/api/user/me", { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(r => { if (!r.ok) { router.push("/login"); return null; } return r.json(); })
|
||||
.then(d => { if (d) { setUser(d); setDiscordId(d.discord_id || ""); } });
|
||||
}, [router]);
|
||||
|
||||
const bindDiscord = async () => {
|
||||
setSaving(true); setMsg("");
|
||||
const token = localStorage.getItem("arb_token");
|
||||
const r = await fetch("/api/user/bind-discord", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ discord_id: discordId }),
|
||||
});
|
||||
const d = await r.json();
|
||||
setMsg(r.ok ? "✅ 绑定成功" : d.detail || "绑定失败");
|
||||
setSaving(false);
|
||||
if (r.ok && user) setUser({ ...user, discord_id: discordId });
|
||||
};
|
||||
|
||||
const logout = () => { localStorage.removeItem("arb_token"); router.push("/"); };
|
||||
|
||||
if (!user) return <div className="text-slate-400 p-8">加载中...</div>;
|
||||
|
||||
const tierLabel: Record<string, string> = { free: "免费版", pro: "Pro", premium: "Premium" };
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-100">我的账户</h1>
|
||||
<button onClick={logout} className="text-sm text-slate-400 hover:text-slate-200">退出</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-700 bg-slate-800/50 p-6 space-y-3">
|
||||
<h2 className="text-slate-200 font-semibold">账户信息</h2>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="text-slate-400">邮箱</div>
|
||||
<div className="text-slate-200">{user.email}</div>
|
||||
<div className="text-slate-400">订阅等级</div>
|
||||
<div className="text-cyan-400 font-medium">{tierLabel[user.tier] || user.tier}</div>
|
||||
<div className="text-slate-400">到期时间</div>
|
||||
<div className="text-slate-200">{user.expires_at ? new Date(user.expires_at).toLocaleDateString("zh-CN") : "永久免费"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-700 bg-slate-800/50 p-6 space-y-4">
|
||||
<h2 className="text-slate-200 font-semibold">Discord 信号推送</h2>
|
||||
<p className="text-slate-400 text-sm">绑定Discord ID后,当套利信号触发时会自动@你</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={discordId} onChange={e => setDiscordId(e.target.value)}
|
||||
className="flex-1 bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-100 text-sm focus:outline-none focus:border-cyan-500"
|
||||
placeholder="Discord用户ID(18位数字)"
|
||||
/>
|
||||
<button
|
||||
onClick={bindDiscord} disabled={saving || !discordId}
|
||||
className="bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 text-white px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
{saving ? "保存中..." : "绑定"}
|
||||
</button>
|
||||
</div>
|
||||
{msg && <p className={`text-sm ${msg.startsWith("✅") ? "text-emerald-400" : "text-red-400"}`}>{msg}</p>}
|
||||
<p className="text-slate-500 text-xs">如何获取Discord ID:设置 → 外观 → 开发者模式 → 右键个人头像 → 复制用户ID</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-700 bg-slate-800/50 p-6">
|
||||
<h2 className="text-slate-200 font-semibold mb-3">升级订阅</h2>
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
{[
|
||||
{ tier: "free", label: "免费版", price: "¥0", features: ["实时费率面板"] },
|
||||
{ tier: "pro", label: "Pro", price: "¥99/月", features: ["实时费率面板", "信号Discord推送", "历史数据"] },
|
||||
{ tier: "premium", label: "Premium", price: "¥299/月", features: ["Pro全部功能", "定制阈值", "优先客服"] },
|
||||
].map(p => (
|
||||
<div key={p.tier} className={`rounded-lg border p-4 space-y-2 ${user.tier === p.tier ? "border-cyan-500 bg-cyan-950/30" : "border-slate-600"}`}>
|
||||
<div className="font-medium text-slate-200">{p.label}</div>
|
||||
<div className="text-cyan-400 font-bold">{p.price}</div>
|
||||
<ul className="space-y-1">
|
||||
{p.features.map(f => <li key={f} className="text-slate-400 text-xs">• {f}</li>)}
|
||||
</ul>
|
||||
{user.tier !== p.tier && p.tier !== "free" && (
|
||||
<button className="w-full mt-2 bg-slate-700 hover:bg-slate-600 text-slate-200 py-1 rounded text-xs">
|
||||
升级(即将开放)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -47,6 +47,12 @@ export default function RootLayout({
|
||||
>
|
||||
历史
|
||||
</Link>
|
||||
<Link
|
||||
href="/signals"
|
||||
className="text-slate-300 hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
信号
|
||||
</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-slate-300 hover:text-cyan-400 transition-colors"
|
||||
@ -54,6 +60,10 @@ export default function RootLayout({
|
||||
说明
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-3 text-sm">
|
||||
<Link href="/login" className="text-slate-400 hover:text-slate-200 transition-colors">登录</Link>
|
||||
<Link href="/register" className="bg-cyan-600 hover:bg-cyan-500 text-white px-3 py-1 rounded-lg transition-colors">注册</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
85
frontend/app/login/page.tsx
Normal file
85
frontend/app/login/page.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const form = new URLSearchParams();
|
||||
form.append("username", email);
|
||||
form.append("password", password);
|
||||
const r = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form.toString(),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) { setError(data.detail || "登录失败"); return; }
|
||||
localStorage.setItem("arb_token", data.access_token);
|
||||
router.push("/dashboard");
|
||||
} catch {
|
||||
setError("网络错误,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center">
|
||||
<div className="w-full max-w-md rounded-xl border border-slate-700 bg-slate-800/50 p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">登录</h1>
|
||||
{params.get("registered") && (
|
||||
<p className="text-emerald-400 text-sm mt-1">✅ 注册成功,请登录</p>
|
||||
)}
|
||||
<p className="text-slate-400 text-sm mt-1">登录后查看信号和账户信息</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">邮箱</label>
|
||||
<input
|
||||
type="email" required value={email} onChange={e => setEmail(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-100 text-sm focus:outline-none focus:border-cyan-500"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">密码</label>
|
||||
<input
|
||||
type="password" required value={password} onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-100 text-sm focus:outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit" disabled={loading}
|
||||
className="w-full bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 text-white font-medium py-2 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-slate-400">
|
||||
没有账号?<a href="/register" className="text-cyan-400 hover:underline">注册</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
80
frontend/app/register/page.tsx
Normal file
80
frontend/app/register/page.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [discordId, setDiscordId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, discord_id: discordId || undefined }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) { setError(data.detail || "注册失败"); return; }
|
||||
router.push("/login?registered=1");
|
||||
} catch {
|
||||
setError("网络错误,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center">
|
||||
<div className="w-full max-w-md rounded-xl border border-slate-700 bg-slate-800/50 p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">注册账号</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">注册后可接收套利信号推送</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">邮箱</label>
|
||||
<input
|
||||
type="email" required value={email} onChange={e => setEmail(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-100 text-sm focus:outline-none focus:border-cyan-500"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">密码</label>
|
||||
<input
|
||||
type="password" required value={password} onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-100 text-sm focus:outline-none focus:border-cyan-500"
|
||||
placeholder="至少8位"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">Discord ID <span className="text-slate-500">(选填,用于接收信号)</span></label>
|
||||
<input
|
||||
type="text" value={discordId} onChange={e => setDiscordId(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-100 text-sm focus:outline-none focus:border-cyan-500"
|
||||
placeholder="例:123456789012345678"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit" disabled={loading}
|
||||
className="w-full bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 text-white font-medium py-2 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
{loading ? "注册中..." : "注册"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-slate-400">
|
||||
已有账号?<a href="/login" className="text-cyan-400 hover:underline">登录</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user