452 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|