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

93 lines
5.6 KiB
TypeScript

"use client";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { PublicHeader } from "@/components/public-header";
import { apiFetch } from "@/lib/api";
import { normalizeRiskLevel, RISK_LEVELS, riskLevelDescription } from "@/lib/risk";
import type { FundProduct, FundProductList } from "@/types/api";
export default function ProductsPage() {
const [items, setItems] = useState<FundProduct[]>([]);
const [keyword, setKeyword] = useState("");
const [riskLevel, setRiskLevel] = useState("");
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
let cancelled = false;
async function load() {
setState("loading");
try {
const params = new URLSearchParams({ page: "1", page_size: "24" });
if (keyword.trim()) params.set("keyword", keyword.trim());
if (riskLevel) params.set("risk_level", riskLevel);
let data = await apiFetch<FundProductList>(`/products?${params.toString()}`);
if (riskLevel && data.items.length === 0) {
const fallbackParams = new URLSearchParams({ page: "1", page_size: "100" });
if (keyword.trim()) fallbackParams.set("keyword", keyword.trim());
const fallback = await apiFetch<FundProductList>(`/products?${fallbackParams.toString()}`);
data = {
...fallback,
items: fallback.items.filter((item) => normalizeRiskLevel(item.risk_level) === riskLevel).slice(0, 24),
};
}
if (!cancelled) {
setItems(data.items);
setState("ready");
}
} catch {
if (!cancelled) setState("error");
}
}
void load();
return () => { cancelled = true; };
}, [keyword, riskLevel]);
return (
<main className="min-h-screen bg-slate-50 text-slate-900">
<PublicHeader />
<section className="bg-[var(--brand-navy)] px-8 pb-16 pt-36 text-white">
<div className="mx-auto max-w-7xl">
<Link href="/" className="inline-flex items-center gap-2 text-sm text-blue-100 hover:text-white"><ArrowLeft className="h-4 w-4" />返回首页</Link>
<p className="mt-12 text-sm font-semibold uppercase tracking-[0.22em] text-blue-200">Fund Marketplace</p>
<h1 className="mt-4 text-5xl font-semibold">基金产品</h1>
<p className="mt-4 max-w-xl text-base leading-7 text-blue-100">基于真实产品数据,按基金类型、风险等级和投资目标了解更多选择。</p>
</div>
</section>
<section className="mx-auto max-w-7xl px-8 py-12">
<div className="flex flex-wrap gap-3">
<div className="relative w-80">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索基金名称或代码" className="w-full rounded-xl border border-slate-200 bg-white py-3 pl-10 pr-4 text-sm outline-none focus:border-[var(--brand-primary)]" />
</div>
<select value={riskLevel} onChange={(event) => setRiskLevel(event.target.value)} className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700 outline-none focus:border-[var(--brand-primary)]">
<option value="">全部风险等级</option>
{RISK_LEVELS.map((level) => <option key={level} value={level}>{level} · {riskLevelDescription(level)}</option>)}
</select>
</div>
<div className="mt-8 overflow-hidden rounded-xl border border-slate-200 bg-white">
{state === "loading" && <div className="space-y-px bg-slate-100">{[1, 2, 3, 4, 5, 6].map((item) => <div key={item} className="h-20 animate-pulse bg-white" />)}</div>}
{state === "error" && <div className="p-6 text-sm text-amber-800">基金数据暂时无法加载,请确认后端服务已启动后重试。</div>}
{state === "ready" && (
<>
<div className="grid grid-cols-[minmax(280px,1.8fr)_140px_140px_140px_140px_32px] items-center gap-4 border-b border-slate-100 bg-gray-50 px-6 py-3 text-xs font-medium uppercase tracking-wide text-gray-500"><span>基金产品</span><span>风险等级</span><span>基金类型</span><span>最新净值</span><span>净值日期</span><span /></div>
{items.map((item) => {
const level = normalizeRiskLevel(item.risk_level);
return <Link href={`/products/${item.product_code}`} key={item.id} className="group grid min-h-20 grid-cols-[minmax(280px,1.8fr)_140px_140px_140px_140px_32px] items-center gap-4 border-b border-slate-100 px-6 py-4 last:border-b-0 hover:bg-indigo-50/40"><div><h2 className="font-semibold text-[var(--brand-navy)] group-hover:text-[var(--brand-primary)]">{item.product_name}</h2><p className="mt-1 text-xs text-slate-400">{item.product_code}</p></div><span className="w-fit rounded-full bg-indigo-50 px-3 py-1 text-xs font-semibold text-[var(--brand-primary)]">{level}</span><span className="text-sm text-slate-600">{item.product_type}</span><span className="text-lg font-semibold text-[var(--brand-navy)]">{item.nav ?? "--"}</span><span className="text-sm text-slate-500">{item.nav_date ?? "暂无日期"}</span><ArrowRight className="h-4 w-4 text-slate-300 transition group-hover:translate-x-1 group-hover:text-[var(--brand-primary)]" /></Link>;
})}
{items.length === 0 && <div className="p-10 text-center text-sm text-slate-500">没有找到匹配的基金产品。</div>}
</>
)}
</div>
</section>
</main>
);
}