diff --git a/design-preview/index.html b/design-preview/index.html index 0833323..92bd48d 100644 --- a/design-preview/index.html +++ b/design-preview/index.html @@ -612,45 +612,49 @@
-
欢迎回来
+
Personal Account

用户中心

+
139****0041
-
-
总资产
-
¥ 1,284,600
较昨日 +0.42%
-
持仓收益
-
+¥ 86,240
收益率 +7.19%
-
风险等级
-
C3
稳健型
-
待办
-
2
风险测评待更新
+
+ ← 返回首页 +
+ +
+
+
账户余额
+
¥0.00
+
该账户暂无余额记录
+
持仓总金额
+
¥6,740.45
共 1 只基金
+
风险测评
+
待完成测评
+
当前后端仅提供提交接口
-
我的持仓
按市值排序
-
+
我的持仓
+
点击任一行查看该基金详情
- + - - - + + +
基金持有份额市值收益
基金持有份额市值盈亏
华夏成长混合12,400.00¥ 27,081+12.4%
华夏复兴股票5,200.00¥ 15,818-3.1%
华夏稳健债券30,000.00¥ 39,780+2.6%
快捷操作
- -
-
消息通知
-
· 您的风险测评将于 30 天后过期
-
· 华夏成长混合 09-13 净值已更新
+
交易记录
-
· 09-12 申购 华夏稳健债券 ¥ 10,000
-
· 09-05 赎回 华夏复兴股票 1,000 份
+
当前后端暂未提供交易记录查询接口,页面不会展示虚构数据。
+
消息通知
+
当前后端暂未提供用户消息查询接口。
diff --git a/frontend/app/account/page.tsx b/frontend/app/account/page.tsx index 1b7e6c5..2d5b4c5 100644 --- a/frontend/app/account/page.tsx +++ b/frontend/app/account/page.tsx @@ -1,23 +1,451 @@ "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 { + 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; current_value?: number | string; market_value?: number | string; profit_loss?: number | string; } - -export default function AccountPage() { - const [user, setUser] = useState(null); const [balance, setBalance] = useState(null); const [holdings, setHoldings] = useState([]); 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("/account/balance"), apiFetch("/holdings")]); setUser(me.user); setBalance(account); setHoldings(holdingList); setState("ready"); } catch { setState("error"); } } - useEffect(() => { void loadAccount(); }, []); - function signOut() { clearToken(); window.location.href = "/login"; } - return
华华夏基金
基金产品
返回首页

Personal Account

用户中心

{user?.real_name ?? user?.username ?? "您的华夏基金账户"}

{state === "loading" && }{state === "error" && }{state === "ready" && <>

账户余额

¥{balance?.balance ?? "--"}

可用余额

¥{balance?.available_balance ?? "--"}

风险测评

待完成测评

当前后端仅提供提交接口

我的持仓

{holdings.length ?
{holdings.map((item) =>

{item.product_name ?? "基金产品"}

{item.product_code}

¥{item.current_value ?? item.market_value ?? "--"}

份额 {item.shares ?? "--"} · 盈亏 {item.profit_loss ?? "--"}

)}
:

暂无持仓数据

}

快捷操作

交易记录

当前后端暂未提供交易记录查询接口,页面不会展示虚构数据。

消息通知

当前后端暂未提供用户消息查询接口。

}
{action && setAction(null)} onDone={() => { setAction(null); void loadAccount(); }} />}
; +interface Balance { + balance?: number | string; + available_balance?: number | string; } -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

{type === "purchase" ? "申购基金" : "赎回基金"}

{message &&

{message}

}
; } +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; +} -function Notice({ text }: { text: string }) { return
{text}
; } +/** /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> { + const page = await apiFetch("/products?status=全部&page_size=100"); + const map = new Map(); + 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(null); + const [balance, setBalance] = useState(null); + const [holdings, setHoldings] = useState([]); + const [productMap, setProductMap] = useState>(() => 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("/account/balance"), + apiFetch("/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 ( +
+
+
+ + + 华 + + 华夏基金 + +
+ + 基金产品 + + +
+
+
+ +
+ + + 返回首页 + + +
+
+

Personal Account

+

用户中心

+

+ {maskAccountName(user?.real_name ?? user?.username)} +

+
+ +
+ + {state === "loading" && } + {state === "error" && ( + + )} + + {state === "ready" && ( + <> +
+
+ +

账户余额

+

+ ¥{formatMoney(balance?.balance)} +

+
+
+ +

持仓总金额

+

+ ¥{formatMoney(totalHoldingValue)} +

+

+ 共 {holdings.length} 只基金 +

+
+
+ +

风险测评

+

待完成测评

+

当前后端仅提供提交接口

+
+
+ +
+
+
+ +

我的持仓

+
+ + {holdings.length ? ( +
+ {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 = ( + <> +
+

{name}

+

+ {meta || "暂无产品信息"} +

+
+
+
+

+ ¥{formatMoney(item.current_value ?? item.market_value)} +

+

+ 份额 {formatNumber(item.shares)} · 盈亏{" "} + + {formatMoney(item.profit_loss)} + +

+
+ {code && ( + + )} +
+ + ); + + return code ? ( + + {row} + + ) : ( +
+ {row} +
+ ); + })} +
+ ) : ( +

暂无持仓数据

+ )} +
+ +
+
+

快捷操作

+
+ + +
+
+
+

交易记录

+

+ 当前后端暂未提供交易记录查询接口,页面不会展示虚构数据。 +

+
+
+

消息通知

+

+ 当前后端暂未提供用户消息查询接口。 +

+
+
+
+ + )} +
+ + {action && ( + setAction(null)} + onDone={() => { + setAction(null); + void loadAccount(); + }} + /> + )} +
+ ); +} + +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 ( +
+
+
+

+ {type === "purchase" ? "申购基金" : "赎回基金"} +

+ +
+
+ + + {message &&

{message}

} + +
+
+
+ ); +} + +function Notice({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +}