Files
Mutual_Fund/frontend/app/account/page.tsx
T
2026-09-14 11:54:20 +08:00

24 lines
9.2 KiB
TypeScript

"use client";
import Link from "next/link";
import { ArrowLeft, ArrowUpRight, CircleUserRound, Landmark, RefreshCw, ShieldCheck, WalletCards, X } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { clearToken } from "@/lib/auth";
import type { UserPayload } from "@/types/api";
interface Balance { balance?: number | string; available_balance?: number | string; }
interface Holding { product_id?: number; product_name?: string; product_code?: string; shares?: number | string; current_value?: number | string; market_value?: number | string; profit_loss?: number | string; }
export default function AccountPage() {
const [user, setUser] = useState<UserPayload | null>(null); const [balance, setBalance] = useState<Balance | null>(null); const [holdings, setHoldings] = useState<Holding[]>([]); const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [action, setAction] = useState<"purchase" | "redeem" | null>(null);
async function loadAccount() { setState("loading"); try { const [me, account, holdingList] = await Promise.all([apiFetch<{ user: UserPayload }>("/auth/me"), apiFetch<Balance>("/account/balance"), apiFetch<Holding[]>("/holdings")]); setUser(me.user); setBalance(account); setHoldings(holdingList); setState("ready"); } catch { setState("error"); } }
useEffect(() => { void loadAccount(); }, []);
function signOut() { clearToken(); window.location.href = "/login"; }
return <main className="min-h-screen bg-slate-50"><header className="border-b border-slate-200 bg-white"><div className="mx-auto flex h-20 max-w-7xl items-center justify-between px-8"><Link href="/" className="flex items-center gap-3 text-[var(--brand-navy)]"><span className="flex h-9 w-9 items-center justify-center rounded-xl bg-[var(--brand-primary)] font-bold text-white">华</span><span className="font-semibold">华夏基金</span></Link><div className="flex items-center gap-5 text-sm"><Link href="/products" className="text-slate-500 hover:text-[var(--brand-primary)]">基金产品</Link><button onClick={signOut} className="text-slate-500 hover:text-rose-600">退出登录</button></div></div></header><section className="mx-auto max-w-7xl px-8 py-12"><Link href="/" className="inline-flex items-center gap-2 text-sm text-slate-500 hover:text-[var(--brand-primary)]"><ArrowLeft className="h-4 w-4" />返回首页</Link><div className="mt-10 flex items-end justify-between"><div><p className="text-sm text-slate-500">Personal Account</p><h1 className="mt-2 text-4xl font-semibold text-[var(--brand-navy)]">用户中心</h1><p className="mt-3 text-sm text-slate-500">{user?.real_name ?? user?.username ?? "您的华夏基金账户"}</p></div><button onClick={() => void loadAccount()} aria-label="刷新账户数据" className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-600 hover:border-[var(--brand-primary)] hover:text-[var(--brand-primary)]"><RefreshCw className="h-4 w-4" />刷新</button></div>{state === "loading" && <Notice text="正在加载账户数据..." />}{state === "error" && <Notice text="账户数据加载失败,请确认已登录且后端服务可用。" />}{state === "ready" && <><div className="mt-8 grid gap-4 md:grid-cols-3"><div className="rounded-xl bg-[var(--brand-navy)] p-6 text-white"><WalletCards className="h-6 w-6 text-blue-200" /><p className="mt-8 text-sm text-blue-100">账户余额</p><p className="mt-2 text-3xl font-semibold">¥{balance?.balance ?? "--"}</p></div><div className="rounded-xl border border-slate-200 bg-white p-6"><Landmark className="h-6 w-6 text-[var(--brand-primary)]" /><p className="mt-8 text-sm text-slate-500">可用余额</p><p className="mt-2 text-3xl font-semibold text-[var(--brand-navy)]">¥{balance?.available_balance ?? "--"}</p></div><div className="rounded-xl border border-slate-200 bg-white p-6"><ShieldCheck className="h-6 w-6 text-emerald-500" /><p className="mt-8 text-sm text-slate-500">风险测评</p><p className="mt-2 text-lg font-semibold text-[var(--brand-navy)]">待完成测评</p><p className="mt-2 text-xs text-slate-400">当前后端仅提供提交接口</p></div></div><div className="mt-6 grid gap-6 lg:grid-cols-[1.3fr_.7fr]"><div className="rounded-xl border border-slate-200 bg-white"><div className="flex items-center gap-3 border-b border-slate-100 px-6 py-5"><CircleUserRound className="h-5 w-5 text-[var(--brand-primary)]" /><h2 className="font-semibold text-slate-900">我的持仓</h2></div>{holdings.length ? <div className="divide-y divide-slate-100">{holdings.map((item) => <div key={`${item.product_id}-${item.product_code}`} className="flex items-center justify-between px-6 py-5"><div><p className="font-medium text-slate-900">{item.product_name ?? "基金产品"}</p><p className="mt-1 text-xs text-slate-400">{item.product_code}</p></div><div className="text-right"><p className="text-sm font-semibold text-slate-900">¥{item.current_value ?? item.market_value ?? "--"}</p><p className="mt-1 text-xs text-slate-400">份额 {item.shares ?? "--"} · 盈亏 {item.profit_loss ?? "--"}</p></div></div>)}</div> : <p className="px-6 py-10 text-center text-sm text-slate-400">暂无持仓数据</p>}</div><div className="space-y-4"><div className="rounded-xl border border-slate-200 bg-white p-6"><h2 className="font-semibold text-slate-900">快捷操作</h2><div className="mt-5 grid gap-3"><button onClick={() => setAction("purchase")} className="flex items-center justify-between rounded-lg bg-[var(--brand-primary)] px-4 py-3 text-sm font-medium text-white hover:bg-[var(--brand-primary-dark)]">申购基金 <ArrowUpRight className="h-4 w-4" /></button><button onClick={() => setAction("redeem")} className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700 hover:border-[var(--brand-primary)] hover:text-[var(--brand-primary)]">赎回基金 <ArrowUpRight className="h-4 w-4" /></button></div></div><div className="rounded-xl border border-slate-200 bg-white p-6"><h2 className="font-semibold text-slate-900">交易记录</h2><p className="mt-3 text-sm leading-6 text-slate-500">当前后端暂未提供交易记录查询接口,页面不会展示虚构数据。</p></div><div className="rounded-xl border border-slate-200 bg-white p-6"><h2 className="font-semibold text-slate-900">消息通知</h2><p className="mt-3 text-sm leading-6 text-slate-500">当前后端暂未提供用户消息查询接口。</p></div></div></div></>}</section>{action && <TradeModal type={action} holdings={holdings} onClose={() => setAction(null)} onDone={() => { setAction(null); void loadAccount(); }} />}</main>;
}
function TradeModal({ type, holdings, onClose, onDone }: { type: "purchase" | "redeem"; holdings: Holding[]; onClose: () => void; onDone: () => void }) { const [productId, setProductId] = useState(String(holdings[0]?.product_id ?? "")); const [value, setValue] = useState(""); const [message, setMessage] = useState(""); const [loading, setLoading] = useState(false); async function submit(event: FormEvent) { event.preventDefault(); setLoading(true); setMessage(""); try { await apiFetch(type === "purchase" ? "/purchase" : "/redeem", { method: "POST", body: JSON.stringify(type === "purchase" ? { product_id: Number(productId), amount: value } : { product_id: Number(productId), shares: value }) }); onDone(); } catch (error) { setMessage(error instanceof Error ? error.message : "操作失败,请稍后重试"); } finally { setLoading(false); } } return <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-6"><div role="dialog" aria-modal="true" aria-label={type === "purchase" ? "申购基金" : "赎回基金"} className="w-full max-w-md rounded-2xl bg-white p-7 shadow-2xl"><div className="flex items-center justify-between"><h2 className="text-lg font-semibold text-[var(--brand-navy)]">{type === "purchase" ? "申购基金" : "赎回基金"}</h2><button onClick={onClose} aria-label="关闭弹窗" className="rounded-lg p-2 text-slate-400 hover:bg-slate-100"><X className="h-4 w-4" /></button></div><form onSubmit={submit} className="mt-6 space-y-4"><label className="block text-sm font-medium text-slate-700">基金产品 ID<input required type="number" min="1" value={productId} onChange={(event) => setProductId(event.target.value)} className="mt-2 w-full rounded-lg border border-slate-200 px-3 py-3 outline-none focus:border-[var(--brand-primary)]" /></label><label className="block text-sm font-medium text-slate-700">{type === "purchase" ? "申购金额" : "赎回份额"}<input required min="0.01" step="0.01" type="number" value={value} onChange={(event) => setValue(event.target.value)} className="mt-2 w-full rounded-lg border border-slate-200 px-3 py-3 outline-none focus:border-[var(--brand-primary)]" /></label>{message && <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-600">{message}</p>}<button disabled={loading} className="w-full rounded-lg bg-[var(--brand-primary)] px-4 py-3 text-sm font-semibold text-white hover:bg-[var(--brand-primary-dark)] disabled:opacity-50">{loading ? "提交中..." : "确认提交"}</button></form></div></div>; }
function Notice({ text }: { text: string }) { return <div className="mt-8 rounded-xl border border-slate-200 bg-white p-8 text-sm text-slate-500">{text}</div>; }