Files
Mutual_Fund/frontend/app/account/page.tsx
T

452 lines
17 KiB
TypeScript

"use client";
import Link from "next/link";
import {
ArrowLeft,
ArrowUpRight,
ChevronRight,
CircleUserRound,
Landmark,
RefreshCw,
ShieldCheck,
WalletCards,
X,
} from "lucide-react";
import { FormEvent, useEffect, useMemo, 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;
cost_amount?: number | string;
current_value?: number | string;
market_value?: number | string;
profit_loss?: number | string;
profit_ratio?: number | string;
}
/** /products 列表项(只取展示与跳转需要的字段)。 */
interface ProductLite {
id?: number;
product_code?: string;
product_name?: string;
product_type?: string;
risk_level?: string;
}
interface ProductPage {
items?: ProductLite[];
}
/**
* 持仓接口(/holdings)只返回 product_id,不含基金名称与代码,
* 因此用产品列表(status=全部,含暂停/到期/清盘)在前端做一次映射,
* 以便展示「具体是哪一只基金」并跳转到详情页。后端不改动。
*/
async function loadProductMap(): Promise<Map<number, ProductLite>> {
const page = await apiFetch<ProductPage>("/products?status=全部&page_size=100");
const map = new Map<number, ProductLite>();
for (const item of page.items ?? []) {
if (typeof item.id === "number") map.set(item.id, item);
}
return map;
}
function toNumber(value: number | string | undefined | null): number | null {
if (value === undefined || value === null || value === "") return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function formatMoney(value: number | string | undefined | null): string {
const n = toNumber(value);
if (n === null) return "--";
return n.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatNumber(value: number | string | undefined | null, digits = 4): string {
const n = toNumber(value);
if (n === null) return "--";
return n.toLocaleString("zh-CN", { minimumFractionDigits: 0, maximumFractionDigits: digits });
}
/** 盈亏着色:盈利红、亏损绿(国内习惯)。 */
function profitTone(value: number | string | undefined | null): string {
const n = toNumber(value);
if (n === null || n === 0) return "text-slate-500";
return n > 0 ? "text-rose-600" : "text-emerald-600";
}
/**
* /auth/me 不返回 real_name,客户账号的用户名即手机号,
* 直接展示会暴露完整号码,这里做脱敏(139****0041)。
*/
function maskAccountName(value: string | undefined | null): string {
if (!value) return "您的华夏基金账户";
return /^\d{11}$/.test(value) ? `${value.slice(0, 3)}****${value.slice(7)}` : value;
}
export default function AccountPage() {
const [user, setUser] = useState<UserPayload | null>(null);
const [balance, setBalance] = useState<Balance | null>(null);
const [holdings, setHoldings] = useState<Holding[]>([]);
const [productMap, setProductMap] = useState<Map<number, ProductLite>>(() => new Map());
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");
}
// 产品映射失败不影响账户主体数据展示,名称回退为「基金 #id」。
try {
setProductMap(await loadProductMap());
} catch {
/* 静默降级 */
}
}
useEffect(() => {
void loadAccount();
}, []);
function signOut() {
clearToken();
window.location.href = "/login";
}
/* 持仓总金额:各持仓当前市值合计。 */
const totalHoldingValue = useMemo(
() =>
holdings.reduce(
(sum, item) => sum + (toNumber(item.current_value ?? item.market_value) ?? 0),
0
),
[holdings]
);
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">
{maskAccountName(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">
¥{formatMoney(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)]">
¥{formatMoney(totalHoldingValue)}
</p>
<p className="mt-2 text-xs text-slate-400">
共 {holdings.length} 只基金
</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) => {
const product = item.product_id ? productMap.get(item.product_id) : undefined;
const code = item.product_code ?? product?.product_code;
const name =
item.product_name ??
product?.product_name ??
(item.product_id ? `基金 #${item.product_id}` : "未知基金");
const meta = [code, product?.product_type, product?.risk_level]
.filter(Boolean)
.join(" · ");
const row = (
<>
<div className="min-w-0">
<p className="truncate font-medium text-slate-900">{name}</p>
<p className="mt-1 text-xs text-slate-400">
{meta || "暂无产品信息"}
</p>
</div>
<div className="flex shrink-0 items-center gap-3 text-right">
<div>
<p className="text-sm font-semibold text-slate-900">
¥{formatMoney(item.current_value ?? item.market_value)}
</p>
<p className="mt-1 text-xs text-slate-400">
份额 {formatNumber(item.shares)} · 盈亏{" "}
<span className={profitTone(item.profit_loss)}>
{formatMoney(item.profit_loss)}
</span>
</p>
</div>
{code && (
<ChevronRight className="h-4 w-4 text-slate-300 transition-colors group-hover:text-[var(--brand-primary)]" />
)}
</div>
</>
);
return code ? (
<Link
key={`${item.product_id}-${code}`}
href={`/products/${encodeURIComponent(code)}`}
aria-label={`查看 ${name} 详情`}
className="group flex items-center justify-between gap-4 px-6 py-5 transition-colors hover:bg-slate-50"
>
{row}
</Link>
) : (
<div
key={`${item.product_id}-unknown`}
className="flex items-center justify-between gap-4 px-6 py-5"
>
{row}
</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="text-sm text-rose-600">{message}</p>}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-[var(--brand-primary)] px-4 py-3 text-sm font-medium text-white hover:bg-[var(--brand-primary-dark)] disabled:opacity-60"
>
{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>
);
}