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>
|
||||||
|
<Link
|
||||||
|
href="/signals"
|
||||||
|
className="text-slate-300 hover:text-cyan-400 transition-colors"
|
||||||
|
>
|
||||||
|
信号
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/about"
|
href="/about"
|
||||||
className="text-slate-300 hover:text-cyan-400 transition-colors"
|
className="text-slate-300 hover:text-cyan-400 transition-colors"
|
||||||
@ -54,6 +60,10 @@ export default function RootLayout({
|
|||||||
说明
|
说明
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</nav>
|
</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