2026-09-08 15:42:28 +00:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import Link from "next/link";
|
|
|
|
|
import { FormEvent, useEffect, useState } from "react";
|
|
|
|
|
import { ArrowRight } from "@/components/icons";
|
2026-09-11 19:00:43 +00:00
|
|
|
import { BrandIcon } from "@/components/brand-icon";
|
2026-09-08 15:42:28 +00:00
|
|
|
import { PreviewNotice, StatusCard } from "@/components/ui";
|
|
|
|
|
|
|
|
|
|
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
|
|
|
|
|
|
2026-09-11 10:22:48 +00:00
|
|
|
type User = { email: string | null; phone: string | null; username: string; role: "user" | "admin"; plan: "free" | "vip"; status: string; email_verified: boolean; phone_verified: boolean };
|
2026-09-08 15:42:28 +00:00
|
|
|
type Usage = { period_start: string; period_end: string; limit: number; adjustment: number; used: number; reserved: number; available: number; plan: string };
|
|
|
|
|
|
|
|
|
|
export function AccountPanel() {
|
|
|
|
|
const [csrf, setCsrf] = useState("");
|
|
|
|
|
const [user, setUser] = useState<User | null>(null);
|
|
|
|
|
const [usage, setUsage] = useState<Usage | null>(null);
|
|
|
|
|
const [error, setError] = useState("");
|
|
|
|
|
const [passwordMessage, setPasswordMessage] = useState("");
|
2026-09-11 10:22:48 +00:00
|
|
|
const [profileMessage, setProfileMessage] = useState("");
|
2026-09-08 15:42:28 +00:00
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
const csrfResponse = await fetch(`${apiBase}/auth/csrf`, { credentials: "include" });
|
|
|
|
|
const csrfBody = await csrfResponse.json();
|
|
|
|
|
setCsrf(csrfBody.csrf_token ?? "");
|
|
|
|
|
const [meResponse, usageResponse] = await Promise.all([
|
|
|
|
|
fetch(`${apiBase}/auth/me`, { credentials: "include" }),
|
|
|
|
|
fetch(`${apiBase}/account/usage`, { credentials: "include" }),
|
|
|
|
|
]);
|
|
|
|
|
if (!meResponse.ok) throw new Error("请先登录后查看账户");
|
|
|
|
|
setUser(await meResponse.json());
|
|
|
|
|
if (usageResponse.ok) setUsage(await usageResponse.json());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useEffect(() => { load().catch((loadError) => setError(loadError instanceof Error ? loadError.message : "无法加载账户")); }, []);
|
|
|
|
|
|
|
|
|
|
async function logout() {
|
|
|
|
|
await fetch(`${apiBase}/auth/logout`, { method: "POST", credentials: "include", headers: { "X-CSRF-Token": csrf } });
|
2026-09-11 19:37:41 +00:00
|
|
|
window.location.assign("/");
|
2026-09-08 15:42:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function changePassword(event: FormEvent<HTMLFormElement>) {
|
|
|
|
|
event.preventDefault();
|
2026-09-08 16:20:11 +00:00
|
|
|
const form = event.currentTarget;
|
|
|
|
|
const data = new FormData(form);
|
2026-09-08 15:42:28 +00:00
|
|
|
const response = await fetch(`${apiBase}/auth/password/change`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: JSON.stringify({ current_password: data.get("current_password"), new_password: data.get("new_password") }) });
|
|
|
|
|
const body = await response.json().catch(() => ({}));
|
2026-09-11 10:22:48 +00:00
|
|
|
if (!response.ok) setPasswordMessage(body.error?.message ?? body.detail?.message ?? "密码修改失败");
|
2026-09-08 16:20:11 +00:00
|
|
|
else { setPasswordMessage("密码已修改,其他会话已撤销"); form.reset(); }
|
2026-09-08 15:42:28 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-11 10:22:48 +00:00
|
|
|
async function updateProfile(event: FormEvent<HTMLFormElement>) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const data = new FormData(event.currentTarget);
|
|
|
|
|
const response = await fetch(`${apiBase}/auth/profile`, { method: "PATCH", credentials: "include", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: JSON.stringify({ username: data.get("username"), email: data.get("email") || null, phone: data.get("phone") || null }) });
|
|
|
|
|
const body = await response.json().catch(() => ({}));
|
|
|
|
|
if (!response.ok) setProfileMessage(body.error?.message ?? body.detail?.message ?? "资料保存失败");
|
|
|
|
|
else { setUser(body); setProfileMessage("资料已保存;如修改邮箱或手机号,请重新完成验证"); }
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 15:42:28 +00:00
|
|
|
if (error) return <div className="mt-8"><PreviewNotice>{error}。<Link className="text-cyan underline" href="/login">前往登录</Link></PreviewNotice></div>;
|
|
|
|
|
if (!user || !usage) return <div className="mt-8"><StatusCard title="正在加载账户" description="正在从业务 API 读取当前用户和额度。" tone="info" /></div>;
|
|
|
|
|
|
|
|
|
|
return <>
|
|
|
|
|
<div className="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-4"><StatusCard title="账户状态" description={user.status === "active" ? "正常" : "已停用"} tone="success" /><StatusCard title="会员计划" description={usage.plan.toUpperCase()} /><StatusCard title="可用额度" description={`${usage.available.toLocaleString()} 字符`} tone="info" /><StatusCard title="验证状态" description={`邮箱 ${user.email_verified ? "已验证" : "未验证"} · 手机 ${user.phone_verified ? "已验证" : "未验证"}`} tone="warning" /></div>
|
|
|
|
|
<div className="mt-8 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
2026-09-11 19:00:43 +00:00
|
|
|
<section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Profile</p><h2 className="mt-3 text-xl font-semibold">基本资料</h2><form className="mt-6 space-y-4" onSubmit={updateProfile}><div><label className="field-label" htmlFor="profile-username">用户名</label><input className="field-input" id="profile-username" name="username" required defaultValue={user.username} /></div><div><label className="field-label" htmlFor="profile-email">邮箱</label><input className="field-input" id="profile-email" name="email" type="email" defaultValue={user.email ?? ""} /></div><div><label className="field-label" htmlFor="profile-phone">手机号</label><input className="field-input" id="profile-phone" name="phone" defaultValue={user.phone ?? ""} /></div>{profileMessage ? <p className="text-sm text-muted" role="status">{profileMessage}</p> : null}<button className="button-secondary w-full" type="submit"><BrandIcon />保存资料</button></form>{user.role === "admin" ? <div className="mt-6 space-y-4 border-t border-line pt-5"><div className="flex justify-between gap-4 text-sm"><dt className="text-muted">业务后台</dt><dd><Link className="text-cyan hover:text-copy" href="/admin">进入管理后台</Link></dd></div><div className="flex justify-between gap-4 text-sm"><dt className="text-muted">内容管理</dt><dd><a className="text-cyan hover:text-copy" href="/cms/admin">进入 CMS</a></dd></div></div> : null}<button className="button-ghost mt-7 w-full" onClick={logout} type="button"><BrandIcon />退出登录 <ArrowRight /></button></section>
|
|
|
|
|
<div className="space-y-6"><section className="rounded-2xl border border-line bg-panel/60 p-6"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Usage</p><h2 className="mt-3 text-xl font-semibold">额度明细</h2></div><Link className="text-sm text-cyan hover:text-copy" href="/tts">打开工作台</Link></div><div className="mt-6 grid gap-4 sm:grid-cols-3"><StatusCard title="周期上限" description={usage.limit.toLocaleString()} /><StatusCard title="已使用" description={usage.used.toLocaleString()} /><StatusCard title="冻结" description={usage.reserved.toLocaleString()} /></div></section><section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Security</p><h2 className="mt-3 text-xl font-semibold">修改密码</h2><form className="mt-5 grid gap-4 sm:grid-cols-2" onSubmit={changePassword}><div><label className="field-label" htmlFor="current-password">当前密码</label><input className="field-input" id="current-password" name="current_password" required type="password" /></div><div><label className="field-label" htmlFor="new-password">新密码</label><input className="field-input" id="new-password" minLength={8} name="new_password" required type="password" /></div>{passwordMessage ? <p className="sm:col-span-2 text-sm text-muted" role="status">{passwordMessage}</p> : null}<button className="button-secondary sm:col-span-2" type="submit"><BrandIcon />更新密码</button></form></section></div>
|
2026-09-08 15:42:28 +00:00
|
|
|
</div>
|
|
|
|
|
</>;
|
|
|
|
|
}
|