93 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|