feat:前端

This commit is contained in:
2026-09-14 11:54:20 +08:00
parent 86b13c6484
commit e2e1d73b3d
46 changed files with 4477 additions and 128 deletions
+12
View File
@@ -0,0 +1,12 @@
"use client";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { FormEvent, useState } from "react";
import { apiFetch } from "@/lib/api";
export default function AccountAdjustPage() {
const [direction, setDirection] = useState<"add" | "sub">("add"); const [amount, setAmount] = useState(""); const [message, setMessage] = useState("");
async function submit(event: FormEvent) { event.preventDefault(); try { await apiFetch("/account/adjust", { method: "POST", body: JSON.stringify({ direction, amount }) }); setMessage("余额调整已提交"); setAmount(""); } catch (error) { setMessage(error instanceof Error ? error.message : "余额调整失败"); } }
return <main className="min-h-screen bg-slate-50 px-8 py-12"><div className="mx-auto max-w-xl"><Link href="/account" className="inline-flex items-center gap-2 text-sm text-slate-500"><ArrowLeft className="h-4 w-4" />返回用户中心</Link><div className="mt-8 rounded-xl border border-slate-200 bg-white p-8"><h1 className="text-2xl font-semibold text-[var(--brand-navy)]">账户余额调整</h1><p className="mt-2 text-sm text-slate-500">充值或提现将由后端进行权限与金额校验。</p><form onSubmit={submit} className="mt-7 space-y-5"><label className="block text-sm font-medium">操作类型<select value={direction} onChange={(event) => setDirection(event.target.value as "add" | "sub")} className="mt-2 w-full rounded-lg border border-slate-200 px-3 py-3"><option value="add">充值</option><option value="sub">提现</option></select></label><label className="block text-sm font-medium">金额<input required min="0.01" step="0.01" type="number" value={amount} onChange={(event) => setAmount(event.target.value)} className="mt-2 w-full rounded-lg border border-slate-200 px-3 py-3" /></label><button className="w-full rounded-lg bg-[var(--brand-primary)] px-4 py-3 text-sm font-semibold text-white">提交</button></form>{message && <p className="mt-4 rounded-lg bg-slate-50 p-3 text-sm text-slate-600">{message}</p>}</div></div></main>;
}
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
import { AdvisorWorkspace } from "@/components/role-workspaces";
export default function AdvisorPage() { return <AdvisorWorkspace />; }
+26
View File
@@ -0,0 +1,26 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Download, RefreshCw, Save, Send, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
interface Customer { customer_id?: number; id?: number; real_name?: string; name?: string; }
interface Draft { draft_id?: string; id?: string; title?: string; content?: string; status?: string; customer_id?: number; }
interface Visit { id: number; customer_id: number; visit_type?: string; visit_time?: string; summary?: string; content?: string; }
export default function AdvisorToolsPage() {
const [customers, setCustomers] = useState<Customer[]>([]); const [customerId, setCustomerId] = useState(""); const [section, setSection] = useState("detail"); const [data, setData] = useState<unknown>(null); const [message, setMessage] = useState("");
const [drafts, setDrafts] = useState<Draft[]>([]); const [draftId, setDraftId] = useState(""); const [draftTitle, setDraftTitle] = useState(""); const [draftContent, setDraftContent] = useState("");
const [visits, setVisits] = useState<Visit[]>([]); const [visitId, setVisitId] = useState<number | null>(null); const [visitType, setVisitType] = useState("电话回访"); const [visitTime, setVisitTime] = useState(""); const [visitSummary, setVisitSummary] = useState("");
useEffect(() => { void apiFetch<{ items?: Customer[] }>("/advisor/customers?page=1&page_size=100").then((result) => { setCustomers(result.items ?? []); const first = result.items?.[0]; if (first) setCustomerId(String(first.customer_id ?? first.id)); }).catch(() => setMessage("客户列表加载失败")); }, []);
async function load() { setMessage(""); try { const id = Number(customerId); const paths: Record<string, string> = { detail: `/advisor/customers/${id}`, holdings: `/advisor/customers/${id}/holdings`, reports: `/advisor/customers/${id}/reports`, diagnosis: `/advisor/diagnosis/${id}`, strategies: "/advisor/strategies", funds: "/advisor/funds", audit: "/advisor/audit/ledger" }; if (section === "drafts") { const result = await apiFetch<{ items?: Draft[] }>(`/advisor/drafts?page=1&page_size=50${customerId ? `&customer_id=${id}` : ""}`); setDrafts(result.items ?? []); setData(result); return; } if (section === "visits") { const result = await apiFetch<{ items?: Visit[] }>(`/advisor/visits?page=1&page_size=50${customerId ? `&customer_id=${id}` : ""}`); setVisits(result.items ?? []); setData(result); return; } setData(await apiFetch(paths[section])); } catch (error) { setMessage(error instanceof Error ? error.message : "加载失败"); } }
function chooseDraft(draft: Draft) { const id = String(draft.draft_id ?? draft.id ?? ""); setDraftId(id); setDraftTitle(draft.title ?? ""); setDraftContent(draft.content ?? ""); }
async function draftAction(action: "save" | "discard" | "send") { if (!draftId) return; try { if (action === "save") await apiFetch(`/advisor/drafts/${draftId}/save`, { method: "PUT", body: JSON.stringify({ title: draftTitle, content: draftContent }) }); else await apiFetch(`/advisor/drafts/${draftId}/${action}`, { method: "POST" }); setMessage(action === "save" ? "草稿已保存" : action === "discard" ? "草稿已废弃" : "草稿已发送"); await load(); } catch (error) { setMessage(error instanceof Error ? error.message : "草稿操作失败"); } }
async function saveVisit(event: FormEvent) { event.preventDefault(); try { const body = { customer_id: Number(customerId), visit_type: visitType, visit_time: new Date(visitTime).toISOString(), summary: visitSummary }; if (visitId) await apiFetch(`/advisor/visits/${visitId}`, { method: "PUT", body: JSON.stringify(body) }); else await apiFetch("/advisor/visits", { method: "POST", body: JSON.stringify(body) }); setMessage(visitId ? "回访已更新" : "回访已新增"); setVisitId(null); setVisitSummary(""); await load(); } catch (error) { setMessage(error instanceof Error ? error.message : "回访保存失败"); } }
return <main className="min-h-screen bg-slate-50 px-10 py-10"><div className="mx-auto max-w-7xl"><Link href="/admin/advisor" className="inline-flex items-center gap-2 text-sm text-slate-500"><ArrowLeft className="h-4 w-4" />返回投顾工作区</Link><h1 className="mt-8 text-3xl font-semibold text-[var(--brand-navy)]">投顾业务工具</h1><div className="mt-6 flex flex-wrap gap-3 rounded-xl border border-slate-200 bg-white p-5"><select value={customerId} onChange={(event) => setCustomerId(event.target.value)} className="rounded-lg border border-slate-200 px-3 py-2 text-sm"><option value="">选择客户</option>{customers.map((item) => { const id = item.customer_id ?? item.id; return <option key={id} value={id}>{item.real_name ?? item.name ?? `客户 #${id}`}</option>; })}</select><select value={section} onChange={(event) => setSection(event.target.value)} className="rounded-lg border border-slate-200 px-3 py-2 text-sm">{[["detail", "客户详情"], ["holdings", "客户持仓"], ["reports", "历史报告"], ["diagnosis", "持仓诊断"], ["strategies", "标准策略"], ["funds", "白名单基金"], ["drafts", "投顾草稿"], ["visits", "回访记录"], ["audit", "审计台账"]].map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select><button onClick={() => void load()} disabled={!customerId && ["detail", "holdings", "reports", "diagnosis", "visits"].includes(section)} className="flex items-center gap-2 rounded-lg bg-[var(--brand-primary)] px-4 py-2 text-sm text-white"><RefreshCw className="h-4 w-4" />加载</button>{section === "audit" && <button onClick={() => window.open("/backend/advisor/audit/export", "_blank")} className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm"><Download className="h-4 w-4" />导出</button>}</div>{message && <p className="mt-4 rounded-lg bg-rose-50 p-4 text-sm text-rose-600">{message}</p>}
{section === "drafts" && <div className="mt-6 grid gap-6 lg:grid-cols-[.8fr_1.2fr]"><div className="rounded-xl border border-slate-200 bg-white p-5"><h2 className="font-semibold">草稿列表</h2><div className="mt-4 divide-y divide-slate-100">{drafts.map((draft) => <button key={draft.draft_id ?? draft.id} onClick={() => chooseDraft(draft)} className={`block w-full px-2 py-3 text-left text-sm ${String(draft.draft_id ?? draft.id) === draftId ? "bg-indigo-50 text-[var(--brand-primary)]" : "text-slate-600"}`}>{draft.title ?? "未命名草稿"}<small className="ml-2 text-slate-400">{draft.status ?? "草稿"}</small></button>)}{!drafts.length && <p className="py-6 text-sm text-slate-400">暂无草稿</p>}</div></div><div className="rounded-xl border border-slate-200 bg-white p-5"><input value={draftTitle} onChange={(event) => setDraftTitle(event.target.value)} placeholder="草稿标题" className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm" /><textarea value={draftContent} onChange={(event) => setDraftContent(event.target.value)} placeholder="草稿内容" className="mt-4 min-h-64 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm" /><div className="mt-4 flex gap-3"><button onClick={() => void draftAction("save")} disabled={!draftId} className="flex items-center gap-2 rounded-lg bg-[var(--brand-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"><Save className="h-4 w-4" />保存</button><button onClick={() => void draftAction("send")} disabled={!draftId} className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm disabled:opacity-50"><Send className="h-4 w-4" />发送</button><button onClick={() => void draftAction("discard")} disabled={!draftId} className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm text-rose-600 disabled:opacity-50"><Trash2 className="h-4 w-4" />废弃</button></div></div></div>}
{section === "visits" && <div className="mt-6 grid gap-6 lg:grid-cols-[1fr_1fr]"><form onSubmit={saveVisit} className="rounded-xl border border-slate-200 bg-white p-5"><h2 className="font-semibold">{visitId ? "编辑回访" : "新增回访"}</h2><label className="mt-4 block text-sm">回访类型<select value={visitType} onChange={(event) => setVisitType(event.target.value)} className="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2"><option>电话回访</option><option>线上沟通</option><option>线下拜访</option></select></label><label className="mt-4 block text-sm">回访时间<input required type="datetime-local" value={visitTime} onChange={(event) => setVisitTime(event.target.value)} className="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2" /></label><label className="mt-4 block text-sm">回访摘要<textarea value={visitSummary} onChange={(event) => setVisitSummary(event.target.value)} className="mt-2 min-h-32 w-full rounded-lg border border-slate-200 px-3 py-2" /></label><button className="mt-4 rounded-lg bg-[var(--brand-primary)] px-4 py-2 text-sm text-white">保存回访</button></form><div className="rounded-xl border border-slate-200 bg-white p-5"><h2 className="font-semibold">历史回访</h2>{visits.map((visit) => <button key={visit.id} onClick={() => { setVisitId(visit.id); setVisitType(visit.visit_type ?? "电话回访"); setVisitSummary(visit.summary ?? visit.content ?? ""); setVisitTime(visit.visit_time?.slice(0, 16) ?? ""); }} className="mt-3 block w-full rounded-lg bg-slate-50 p-3 text-left text-sm">客户 #{visit.customer_id} · {visit.visit_type ?? "回访"}<p className="mt-1 text-xs text-slate-500">{visit.summary ?? visit.content ?? "暂无摘要"}</p></button>)}{!visits.length && <p className="mt-5 text-sm text-slate-400">暂无回访记录</p>}</div></div>}
{!['drafts', 'visits'].includes(section) && <pre className="mt-6 min-h-96 overflow-auto rounded-xl border border-slate-200 bg-white p-6 text-xs text-slate-700">{data ? JSON.stringify(data, null, 2) : "选择模块后点击加载"}</pre>}
</div></main>;
}
+2
View File
@@ -0,0 +1,2 @@
import { CommonWorkspace } from "@/components/role-workspaces";
export default function CommonPage() { return <CommonWorkspace />; }
+2
View File
@@ -0,0 +1,2 @@
import { RiskWorkspace } from "@/components/role-workspaces";
export default function RiskPage() { return <RiskWorkspace />; }
+48
View File
@@ -0,0 +1,48 @@
@import "tailwindcss";
:root {
--brand-primary: #4f46e5;
--brand-primary-dark: #4338ca;
--brand-navy: #071a49;
--brand-gold: #d9a441;
--page-background: #f8fafc;
--text-primary: #111827;
--text-muted: #64748b;
--border-subtle: #e2e8f0;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--page-background);
color: var(--text-primary);
}
::selection {
background: var(--brand-primary);
color: white;
}
.hero-grid {
background-image: linear-gradient(rgba(7, 26, 73, 0.16) 1px, transparent 1px),
linear-gradient(90deg, rgba(7, 26, 73, 0.16) 1px, transparent 1px);
background-size: 46px 46px;
}
.section-rule {
border-top: 1px solid rgba(148, 163, 184, 0.24);
}
button,
input,
select,
textarea {
font: inherit;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import "./globals.css";
import { AgentMount } from "@/components/agent-mount";
export const metadata: Metadata = {
title: "华夏基金",
description: "华夏基金 PC 网页平台",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body>{children}<AgentMount /></body>
</html>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import Link from "next/link";
import { ArrowLeft, ArrowRight, LockKeyhole } from "lucide-react";
import { FormEvent, useState } from "react";
import { apiFetch } from "@/lib/api";
import { getPostLoginPath, setToken } from "@/lib/auth";
interface LoginResult { token: string; user: { user_type: string; employee_role?: string | null } }
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault(); setMessage(""); setLoading(true);
try {
const result = await apiFetch<LoginResult>("/auth/login", { method: "POST", body: JSON.stringify({ username, password }) });
setToken(result.token);
window.location.href = getPostLoginPath(result.user);
} catch (error) { setMessage(error instanceof Error ? error.message : "登录失败,请稍后重试"); } finally { setLoading(false); }
}
return <main className="flex min-h-screen items-center justify-center bg-[var(--brand-navy)] p-8"><div className="grid w-full max-w-5xl overflow-hidden rounded-2xl bg-white shadow-2xl lg:grid-cols-[.9fr_1.1fr]"><div className="hidden bg-[linear-gradient(145deg,#071a49,#263a8a)] p-10 text-white lg:block"><Link href="/" className="inline-flex items-center gap-2 text-sm text-blue-100 hover:text-white"><ArrowLeft className="h-4 w-4" />返回首页</Link><div className="mt-36"><p className="text-sm uppercase tracking-[0.24em] text-blue-200">Huaxia Fund</p><h1 className="mt-5 text-4xl font-semibold leading-tight">专业研究,<br />陪伴长期价值。</h1><p className="mt-5 max-w-sm text-sm leading-7 text-blue-100">登录后查看您的账户、持仓和专属服务。</p></div></div><div className="p-8 sm:p-12"><div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-50 text-[var(--brand-primary)]"><LockKeyhole className="h-5 w-5" /></div><h2 className="mt-7 text-2xl font-semibold text-[var(--brand-navy)]">欢迎回来</h2><p className="mt-2 text-sm text-slate-500">登录华夏基金账户</p><form onSubmit={handleSubmit} className="mt-8 space-y-5"><label className="block text-sm font-medium text-slate-700">用户名<input value={username} onChange={(event) => setUsername(event.target.value)} required 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">密码<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} required 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="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-600">{message}</p>}<button disabled={loading} className="flex w-full items-center justify-center gap-2 rounded-lg bg-[var(--brand-primary)] px-4 py-3.5 text-sm font-semibold text-white hover:bg-[var(--brand-primary-dark)] disabled:opacity-50">{loading ? "登录中..." : "登录"}<ArrowRight className="h-4 w-4" /></button></form><p className="mt-7 text-center text-sm text-slate-500">还没有账号? <Link href="/register" className="font-semibold text-[var(--brand-primary)]">立即注册</Link></p></div></div></main>;
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import { ArrowRight, BarChart3, Globe2, ShieldCheck, Sparkles, TrendingUp } from "lucide-react";
import { useEffect, useState } from "react";
import { PublicHeader } from "@/components/public-header";
import { apiFetch } from "@/lib/api";
import type { FundProduct, FundProductList } from "@/types/api";
export default function HomePage() {
const [products, setProducts] = useState<FundProduct[]>([]);
const [fundState, setFundState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
apiFetch<FundProductList>("/products?page=1&page_size=3")
.then((data) => { setProducts(data.items); setFundState("ready"); })
.catch(() => setFundState("error"));
}, []);
return (
<main id="top" className="bg-white text-slate-900">
<PublicHeader />
<section className="relative isolate flex min-h-[760px] items-end overflow-hidden bg-[var(--brand-navy)] pb-24 pt-40 text-white">
<div className="absolute inset-0 -z-20 bg-cover bg-center" style={{ backgroundImage: "url('/hero-finance.jpg')" }} />
<div className="absolute inset-0 -z-10 bg-[linear-gradient(90deg,rgba(4,18,52,.96)_0%,rgba(4,18,52,.78)_40%,rgba(4,18,52,.2)_100%)]" />
<div className="absolute inset-0 -z-10 hero-grid opacity-20" />
<div className="mx-auto w-full max-w-7xl px-8">
<div className="max-w-3xl">
<p className="mb-6 flex items-center gap-3 text-sm font-medium uppercase tracking-[0.28em] text-blue-100"><span className="h-px w-10 bg-[var(--brand-gold)]" />长期主义的财富伙伴</p>
<h1 className="max-w-3xl text-6xl font-semibold leading-[1.05] tracking-tight md:text-7xl">让每一份长期信任,<br /><span className="text-blue-100">都拥有专业的答案。</span></h1>
<p className="mt-8 max-w-xl text-lg leading-8 text-blue-50">华夏基金以专业研究、稳健风控和智能科技,为每一位投资者提供更清晰的基金选择与更长期的财富陪伴。</p>
<div className="mt-10 flex flex-wrap gap-4"><Link href="#funds" className="flex items-center gap-2 rounded-lg bg-[var(--brand-primary)] px-5 py-3.5 text-sm font-semibold text-white transition hover:bg-[var(--brand-primary-dark)]">查看基金产品 <ArrowRight className="h-4 w-4" /></Link><Link href="/register" className="rounded-lg border border-white/40 px-5 py-3.5 text-sm font-semibold text-white transition hover:bg-white/10">注册账户</Link></div>
</div>
<div className="mt-20 flex items-center gap-8 text-xs text-blue-100"><span className="flex items-center gap-2"><ShieldCheck className="h-4 w-4 text-[var(--brand-gold)]" />合规投资服务</span><span className="flex items-center gap-2"><Globe2 className="h-4 w-4 text-[var(--brand-gold)]" />面向长期价值</span></div>
</div>
</section>
<section id="about" className="mx-auto grid max-w-7xl gap-16 px-8 py-28 lg:grid-cols-[.85fr_1.15fr] lg:items-center"><div><p className="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--brand-primary)]">About Huaxia Fund</p><h2 className="mt-4 text-4xl font-semibold leading-tight text-[var(--brand-navy)]">把复杂的市场,<br />变成清晰的选择。</h2><p className="mt-6 max-w-lg text-base leading-8 text-slate-500">让财富管理更智能、更普惠。我们坚持客户至上、诚信为本、专业精进与合规底线,用科技提升每一次投资决策的确定感。</p><Link href="#services" className="mt-8 inline-flex items-center gap-2 text-sm font-semibold text-[var(--brand-primary)]">了解我们的理念 <ArrowRight className="h-4 w-4" /></Link></div><div className="grid gap-4 sm:grid-cols-2"><div className="rounded-2xl bg-slate-50 p-7"><BarChart3 className="h-7 w-7 text-[var(--brand-primary)]" /><p className="mt-10 text-3xl font-semibold text-[var(--brand-navy)]">长期</p><p className="mt-2 text-sm leading-6 text-slate-500">用持续研究穿越周期,陪伴客户建立长期投资习惯。</p></div><div className="rounded-2xl bg-[var(--brand-navy)] p-7 text-white"><ShieldCheck className="h-7 w-7 text-[var(--brand-gold)]" /><p className="mt-10 text-3xl font-semibold">稳健</p><p className="mt-2 text-sm leading-6 text-blue-100">把风险管理放在每一次产品选择与服务流程之前。</p></div><div className="rounded-2xl bg-[var(--brand-primary)] p-7 text-white sm:col-span-2"><Sparkles className="h-7 w-7 text-blue-100" /><div className="mt-8 flex flex-wrap items-end justify-between gap-6"><div><p className="text-3xl font-semibold">科技赋能</p><p className="mt-2 max-w-md text-sm leading-6 text-blue-100">通过数据、智能投顾与数字化服务,让专业能力更容易被每一位投资者使用。</p></div><ArrowRight className="h-8 w-8" /></div></div></div></section>
<section id="services" className="bg-slate-50 py-28"><div className="mx-auto max-w-7xl px-8"><div className="max-w-2xl"><p className="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--brand-primary)]">Core Services</p><h2 className="mt-4 text-4xl font-semibold text-[var(--brand-navy)]">为每一种目标,<br />提供更合适的方案。</h2></div><div className="mt-14 grid gap-5 md:grid-cols-3"><div className="border-t-2 border-[var(--brand-primary)] pt-6"><TrendingUp className="h-6 w-6 text-[var(--brand-primary)]" /><h3 className="mt-8 text-xl font-semibold text-[var(--brand-navy)]">公募基金</h3><p className="mt-3 text-sm leading-7 text-slate-500">覆盖多种基金类型,帮助投资者按风险偏好和目标筛选产品。</p></div><div className="border-t-2 border-[var(--brand-gold)] pt-6"><ShieldCheck className="h-6 w-6 text-[var(--brand-gold)]" /><h3 className="mt-8 text-xl font-semibold text-[var(--brand-navy)]">风险管理</h3><p className="mt-3 text-sm leading-7 text-slate-500">通过风险测评和适当性服务,让投资选择建立在理解之上。</p></div><div className="border-t-2 border-[var(--brand-navy)] pt-6"><Sparkles className="h-6 w-6 text-[var(--brand-navy)]" /><h3 className="mt-8 text-xl font-semibold text-[var(--brand-navy)]">智能投顾</h3><p className="mt-3 text-sm leading-7 text-slate-500">用智能工具辅助信息理解和资产配置,服务每一段长期旅程。</p></div></div></div></section>
<section id="funds" className="mx-auto max-w-7xl px-8 py-28"><div className="flex flex-col justify-between gap-5 md:flex-row md:items-end"><div><p className="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--brand-primary)]">Fund Marketplace</p><h2 className="mt-4 text-4xl font-semibold text-[var(--brand-navy)]">从真实数据开始,<br />找到适合自己的基金。</h2></div><Link href="/products" className="inline-flex items-center gap-2 text-sm font-semibold text-[var(--brand-primary)]">查看全部产品 <ArrowRight className="h-4 w-4" /></Link></div><div className="mt-12 grid gap-5 md:grid-cols-3">{fundState === "loading" && [1, 2, 3].map((item) => <div key={item} className="h-64 animate-pulse rounded-2xl bg-slate-100" />)}{fundState === "error" && <div className="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-800 md:col-span-3">基金数据暂时无法加载,请确认后端服务已启动后重试。</div>}{fundState === "ready" && products.map((product) => <Link href={`/products/${product.product_code}`} key={product.id} className="group rounded-2xl border border-slate-200 bg-white p-6 transition hover:-translate-y-1 hover:border-[var(--brand-primary)] hover:shadow-xl hover:shadow-slate-200/60"><div className="flex items-start justify-between gap-4"><span className="rounded-full bg-indigo-50 px-3 py-1 text-xs font-semibold text-[var(--brand-primary)]">{product.risk_level}</span><ArrowRight className="h-4 w-4 text-slate-300 transition group-hover:text-[var(--brand-primary)]" /></div><h3 className="mt-8 text-lg font-semibold text-[var(--brand-navy)]">{product.product_name}</h3><p className="mt-1 text-xs text-slate-400">{product.product_code} · {product.product_type}</p><div className="mt-8 flex items-end justify-between"><div><p className="text-xs text-slate-400">最新净值</p><p className="mt-1 text-2xl font-semibold text-[var(--brand-navy)]">{product.nav ?? "--"}</p></div><span className="text-xs text-slate-400">{product.nav_date ?? "暂无日期"}</span></div></Link>)}{fundState === "ready" && products.length === 0 && <div className="rounded-2xl border border-slate-200 p-8 text-sm text-slate-500 md:col-span-3">当前暂无可展示的基金产品。</div>}</div></section>
<section className="bg-[var(--brand-navy)] py-20 text-white"><div className="mx-auto flex max-w-7xl flex-col justify-between gap-8 px-8 md:flex-row md:items-center"><div><p className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-200">Start With Clarity</p><h2 className="mt-3 text-3xl font-semibold">先了解自己,再做投资选择。</h2><p className="mt-3 max-w-xl text-sm leading-7 text-blue-100">基金投资有风险,投资者应根据自身风险承受能力选择适合的产品。过往业绩不代表未来表现。</p></div><Link href="/register" className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white px-5 py-3.5 text-sm font-semibold text-[var(--brand-navy)] transition hover:bg-blue-50">开启账户 <ArrowRight className="h-4 w-4" /></Link></div></section>
<footer className="bg-white"><div className="mx-auto flex max-w-7xl flex-col gap-7 px-8 py-10 text-sm text-slate-500 md:flex-row md:items-center md:justify-between"><div><p className="font-semibold text-[var(--brand-navy)]">华夏基金</p><p className="mt-2 text-xs">让财富管理更智能、更普惠</p></div><div className="flex flex-wrap gap-5"><Link href="/login" className="hover:text-[var(--brand-primary)]">登录</Link><Link href="/register" className="hover:text-[var(--brand-primary)]">注册</Link><span>风险揭示</span></div></div></footer>
</main>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import Link from "next/link";
import { ArrowLeft, CalendarDays, LineChart as LineChartIcon, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { PublicHeader } from "@/components/public-header";
import { apiFetch } from "@/lib/api";
import type { FundProduct, FundProductList } from "@/types/api";
interface HistoryPoint { nav_date: string; unit_nav: number; daily_growth?: number | null; cum_return_pct?: number; }
interface HistoryResponse { items: HistoryPoint[]; total_growth_rate: number; max_return_rate: number; max_drawdown_rate: number; }
export default function FundDetailPage({ params }: { params: Promise<{ code: string }> }) {
const [product, setProduct] = useState<FundProduct | null>(null);
const [history, setHistory] = useState<HistoryResponse | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
void params.then(({ code }) => Promise.all([
apiFetch<FundProductList>(`/products?keyword=${encodeURIComponent(code)}&page_size=10`),
apiFetch<HistoryResponse>(`/products/${encodeURIComponent(code)}/history`),
]).then(([list, chart]) => {
setProduct(list.items.find((item) => item.product_code === code) ?? null);
setHistory(chart); setState("ready");
}).catch(() => setState("error")));
}, [params]);
return <main className="min-h-screen bg-slate-50 text-slate-900"><PublicHeader /><div className="mx-auto max-w-7xl px-8 pb-24 pt-32"><Link href="/products" className="inline-flex items-center gap-2 text-sm text-slate-500 hover:text-[var(--brand-primary)]"><ArrowLeft className="h-4 w-4" />返回基金列表</Link>{state === "loading" && <div className="mt-10 h-96 animate-pulse rounded-2xl bg-slate-200" />}{state === "error" && <Notice text="基金详情暂时无法加载,请稍后重试。" />}{state === "ready" && product && <><div className="mt-10 rounded-2xl bg-[var(--brand-navy)] p-10 text-white"><div className="flex flex-wrap items-start justify-between gap-8"><div><span className="rounded-full bg-white/10 px-3 py-1 text-xs text-blue-100">{product.risk_level}</span><h1 className="mt-5 text-4xl font-semibold">{product.product_name}</h1><p className="mt-2 text-sm text-blue-100">{product.product_code} · {product.product_type}</p></div><div className="text-right"><p className="text-xs text-blue-200">最新单位净值</p><p className="mt-2 text-4xl font-semibold">{product.nav ?? "--"}</p><p className="mt-2 text-xs text-blue-200">更新于 {product.nav_date ?? "暂无日期"}</p></div></div></div><div className="mt-6 grid gap-5 md:grid-cols-3"><Metric label="区间收益" value={history?.total_growth_rate} tone="green" /><Metric label="区间最大收益" value={history?.max_return_rate} tone="gold" /><Metric label="最大回撤" value={history?.max_drawdown_rate} tone="red" /></div><div className="mt-6 rounded-2xl border border-slate-200 bg-white p-8"><div className="flex items-center justify-between"><div><h2 className="flex items-center gap-2 text-lg font-semibold text-[var(--brand-navy)]"><LineChartIcon className="h-5 w-5 text-[var(--brand-primary)]" />历史净值走势</h2><p className="mt-1 text-sm text-slate-400">数据来自基金历史净值接口 · {history?.items.length ?? 0} 个数据点</p></div><CalendarDays className="h-5 w-5 text-slate-300" /></div><NavLineChart points={history?.items ?? []} /><p className="mt-4 flex items-center gap-2 text-xs text-slate-400"><ShieldCheck className="h-4 w-4" />基金投资有风险,过往业绩不代表未来表现。</p></div></>}{state === "ready" && !product && <Notice text="未找到该基金产品。" />}</div></main>;
}
function Metric({ label, value, tone }: { label: string; value?: number; tone: "green" | "gold" | "red" }) { const color = tone === "green" ? "text-emerald-600" : tone === "gold" ? "text-[var(--brand-gold)]" : "text-rose-600"; return <div className="rounded-2xl border border-slate-200 bg-white p-6"><p className="text-xs text-slate-400">{label}</p><p className={`mt-3 text-2xl font-semibold ${color}`}>{value ?? "--"}{value !== undefined && "%"}</p></div>; }
function NavLineChart({ points }: { points: HistoryPoint[] }) {
const chartPoints = useMemo(() => points.slice(-60), [points]);
if (!chartPoints.length) return <div className="mt-8 flex h-64 items-center justify-center rounded-xl bg-slate-50 text-sm text-slate-400">暂无历史净值数据</div>;
const values = chartPoints.map((point) => point.unit_nav); const min = Math.min(...values); const max = Math.max(...values); const range = Math.max(max - min, 0.000001); const coords = chartPoints.map((point, index) => { const x = (index / Math.max(chartPoints.length - 1, 1)) * 100; const y = 92 - ((point.unit_nav - min) / range) * 76; return `${x},${y}`; }).join(" ");
return <div className="mt-8"><div className="relative h-64 overflow-hidden rounded-xl border border-slate-100 bg-slate-50 px-4 py-5"><svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-full w-full" role="img" aria-label="基金历史净值折线图"><line x1="0" y1="16" x2="100" y2="16" stroke="#e2e8f0" strokeWidth="0.4" /><line x1="0" y1="54" x2="100" y2="54" stroke="#e2e8f0" strokeWidth="0.4" /><line x1="0" y1="92" x2="100" y2="92" stroke="#cbd5e1" strokeWidth="0.5" /><polyline points={coords} fill="none" stroke="var(--brand-primary)" strokeWidth="1.2" vectorEffect="non-scaling-stroke" strokeLinecap="round" strokeLinejoin="round" /></svg><div className="pointer-events-none absolute inset-x-4 bottom-2 flex justify-between text-[10px] text-slate-400"><span>{chartPoints[0].nav_date}</span><span>{chartPoints[chartPoints.length - 1].nav_date}</span></div></div><div className="mt-3 flex justify-between text-xs text-slate-400"><span>最低净值 {min.toFixed(4)}</span><span>最高净值 {max.toFixed(4)}</span></div></div>;
}
function Notice({ text }: { text: string }) { return <div className="mt-10 rounded-2xl border border-amber-200 bg-amber-50 p-8 text-sm text-amber-800">{text}</div>; }
+33
View File
@@ -0,0 +1,33 @@
"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 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(() => {
const params = new URLSearchParams({ page: "1", page_size: "24" });
if (keyword.trim()) params.set("keyword", keyword.trim());
if (riskLevel) params.set("risk_level", riskLevel);
setState("loading");
apiFetch<FundProductList>(`/products?${params.toString()}`)
.then((data) => { setItems(data.items); setState("ready"); })
.catch(() => setState("error"));
}, [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><option value="低风险">低风险</option><option value="中风险">中风险</option><option value="高风险">高风险</option></select></div><div className="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-3">{state === "loading" && [1, 2, 3, 4, 5, 6].map((item) => <div key={item} className="h-64 animate-pulse rounded-2xl bg-slate-200" />)}{state === "error" && <div className="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-800 lg:col-span-3">基金数据暂时无法加载,请确认后端服务已启动后重试。</div>}{state === "ready" && items.map((item) => <Link href={`/products/${item.product_code}`} key={item.id} className="group rounded-2xl border border-slate-200 bg-white p-6 transition hover:-translate-y-1 hover:border-[var(--brand-primary)] hover:shadow-xl hover:shadow-slate-200/50"><div className="flex items-start justify-between"><span className="rounded-full bg-indigo-50 px-3 py-1 text-xs font-semibold text-[var(--brand-primary)]">{item.risk_level}</span><ArrowRight className="h-4 w-4 text-slate-300 group-hover:text-[var(--brand-primary)]" /></div><h2 className="mt-8 text-lg font-semibold text-[var(--brand-navy)]">{item.product_name}</h2><p className="mt-1 text-xs text-slate-400">{item.product_code} · {item.product_type}</p><div className="mt-8 flex items-end justify-between"><div><p className="text-xs text-slate-400">最新净值</p><p className="mt-1 text-2xl font-semibold text-[var(--brand-navy)]">{item.nav ?? "--"}</p></div><span className="text-xs text-slate-400">{item.nav_date ?? "暂无日期"}</span></div></Link>)}{state === "ready" && items.length === 0 && <div className="rounded-2xl border border-slate-200 bg-white p-10 text-center text-sm text-slate-500 lg:col-span-3">没有找到匹配的基金产品。</div>}</div></section>
</main>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import Link from "next/link";
import { ArrowLeft, ArrowRight, UserPlus } from "lucide-react";
import { FormEvent, useState } from "react";
import { apiFetch } from "@/lib/api";
export default function RegisterPage() {
const [form, setForm] = useState({ username: "", password: "", phone: "", real_name: "" });
const [message, setMessage] = useState(""); const [loading, setLoading] = useState(false); const [done, setDone] = useState(false);
async function handleSubmit(event: FormEvent) { event.preventDefault(); setMessage(""); setLoading(true); try { await apiFetch("/auth/register", { method: "POST", body: JSON.stringify(form) }); setDone(true); } catch (error) { setMessage(error instanceof Error ? error.message : "注册失败,请稍后重试"); } finally { setLoading(false); } }
const update = (key: keyof typeof form, value: string) => setForm((current) => ({ ...current, [key]: value }));
return <main className="flex min-h-screen items-center justify-center bg-slate-50 p-8"><div className="w-full max-w-xl rounded-2xl border border-slate-200 bg-white p-8 shadow-xl sm:p-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 h-10 w-10 items-center justify-center rounded-xl bg-indigo-50 text-[var(--brand-primary)]"><UserPlus className="h-5 w-5" /></div><h1 className="mt-6 text-2xl font-semibold text-[var(--brand-navy)]">创建华夏基金账户</h1><p className="mt-2 text-sm text-slate-500">注册后即可查看个人账户和基金持仓。</p>{done ? <div className="mt-8 rounded-xl bg-emerald-50 p-5 text-sm text-emerald-700">注册成功,请前往登录。<Link href="/login" className="ml-2 font-semibold underline">登录账户</Link></div> : <form onSubmit={handleSubmit} className="mt-8 grid gap-5 sm:grid-cols-2"><label className="text-sm font-medium text-slate-700">用户名<input required value={form.username} onChange={(event) => update("username", 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="text-sm font-medium text-slate-700">真实姓名<input value={form.real_name} onChange={(event) => update("real_name", 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="text-sm font-medium text-slate-700">手机号<input required pattern="1\d{10}" value={form.phone} onChange={(event) => update("phone", 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="text-sm font-medium text-slate-700">密码<input required minLength={8} type="password" value={form.password} onChange={(event) => update("password", 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="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-600 sm:col-span-2">{message}</p>}<button disabled={loading} className="flex items-center justify-center gap-2 rounded-lg bg-[var(--brand-primary)] px-4 py-3.5 text-sm font-semibold text-white hover:bg-[var(--brand-primary-dark)] disabled:opacity-50 sm:col-span-2">{loading ? "注册中..." : "创建账户"}<ArrowRight className="h-4 w-4" /></button></form>}<p className="mt-7 text-center text-sm text-slate-500">已有账号? <Link href="/login" className="font-semibold text-[var(--brand-primary)]">立即登录</Link></p></div></main>;
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import Link from "next/link";
import { ArrowLeft, CheckCircle2 } from "lucide-react";
import { FormEvent, useState } from "react";
import { apiFetch } from "@/lib/api";
const questions = ["您的投资目标是什么?", "您能接受多大程度的本金波动?", "您的投资期限通常是?", "您对基金投资的了解程度?", "如果短期亏损,您会如何处理?"];
const options = ["稳健保值", "平衡增长", "积极增值", "长期财富积累"];
export default function RiskAssessmentPage() {
const [answers, setAnswers] = useState<string[]>(Array(questions.length).fill(""));
const [message, setMessage] = useState(""); const [done, setDone] = useState(false); const [loading, setLoading] = useState(false);
async function submit(event: FormEvent) { event.preventDefault(); if (answers.some((item) => !item)) { setMessage("请完成全部测评问题"); return; } setLoading(true); setMessage(""); try { await apiFetch("/risk-assessment/submit", { method: "POST", body: JSON.stringify({ questionnaire_id: 1, answers: answers.map((option, index) => ({ question_no: index + 1, option })) }) }); setDone(true); } catch (error) { setMessage(error instanceof Error ? error.message : "测评提交失败"); } finally { setLoading(false); } }
return <main className="min-h-screen bg-slate-50 px-8 py-12"><div className="mx-auto max-w-3xl"><Link href="/account" className="inline-flex items-center gap-2 text-sm text-slate-500"><ArrowLeft className="h-4 w-4" />返回用户中心</Link><div className="mt-8 rounded-xl border border-slate-200 bg-white p-8"><p className="text-sm text-[var(--brand-primary)]">Risk Profile</p><h1 className="mt-2 text-3xl font-semibold text-[var(--brand-navy)]">风险承受能力测评</h1>{done ? <div className="mt-8 flex items-center gap-3 rounded-lg bg-emerald-50 p-5 text-emerald-700"><CheckCircle2 className="h-5 w-5" />测评已提交,系统将根据结果提供适当性提示。</div> : <form onSubmit={submit} className="mt-8 space-y-7">{questions.map((question, index) => <fieldset key={question}><legend className="text-sm font-medium text-slate-800">{index + 1}. {question}</legend><div className="mt-3 grid gap-2 sm:grid-cols-2">{options.map((option) => <label key={option} className={`cursor-pointer rounded-lg border px-4 py-3 text-sm ${answers[index] === option ? "border-[var(--brand-primary)] bg-indigo-50 text-[var(--brand-primary)]" : "border-slate-200 text-slate-600"}`}><input type="radio" name={`question-${index}`} value={option} checked={answers[index] === option} onChange={() => setAnswers((current) => current.map((item, itemIndex) => itemIndex === index ? option : item))} className="sr-only" />{option}</label>)}</div></fieldset>)}{message && <p className="rounded-lg bg-rose-50 p-3 text-sm text-rose-600">{message}</p>}<button disabled={loading} className="w-full rounded-lg bg-[var(--brand-primary)] px-4 py-3 text-sm font-semibold text-white disabled:opacity-50">{loading ? "提交中..." : "提交测评"}</button></form>}</div></div></main>;
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Trash2, Upload } from "lucide-react";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
interface DocumentItem { id?: string; doc_id?: string; title?: string; filename?: string; status?: string; }
export default function KnowledgePage() {
const [items, setItems] = useState<DocumentItem[]>([]); const [file, setFile] = useState<File | null>(null); const [message, setMessage] = useState(""); const [isAdmin, setIsAdmin] = useState(false);
async function load() { try { setItems(await apiFetch<DocumentItem[]>("/knowledge/documents")); } catch (error) { setMessage(error instanceof Error ? error.message : "知识库加载失败"); } }
useEffect(() => { void apiFetch<{ user: { user_type?: string } }>("/auth/me").then(({ user }) => { const allowed = user.user_type === "ADMIN"; setIsAdmin(allowed); if (allowed) void load(); else setMessage("知识库管理仅对 ADMIN 开放"); }).catch(() => setMessage("请先登录管理员账号")); }, []);
async function upload(path: "/knowledge/documents/preview" | "/knowledge/documents/confirm") { if (!file) return; const body = new FormData(); body.append("file", file); body.append("title", file.name); body.append("strategy", "default"); const token = localStorage.getItem("huaxia_token"); const response = await fetch(`/backend${path}`, { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : undefined, body }); const payload = await response.json(); setMessage(payload.message ?? (response.ok ? "操作成功" : "操作失败")); if (response.ok && path.endsWith("confirm")) await load(); }
async function remove(id: string) { await apiFetch(`/knowledge/documents/${id}`, { method: "DELETE" }); await load(); }
return <main className="min-h-screen bg-slate-50 px-10 py-10"><div className="mx-auto max-w-6xl"><Link href="/admin/common" className="inline-flex items-center gap-2 text-sm text-slate-500"><ArrowLeft className="h-4 w-4" />返回员工工作区</Link><h1 className="mt-8 text-3xl font-semibold text-[var(--brand-navy)]">知识库管理</h1>{!isAdmin ? <p className="mt-6 rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-800">知识库管理仅对 ADMIN 开放。</p> : <><div className="mt-6 rounded-xl border border-slate-200 bg-white p-6"><input type="file" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /><div className="mt-4 flex gap-3"><button onClick={() => void upload("/knowledge/documents/preview")} className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm"><Upload className="h-4 w-4" />预览</button><button onClick={() => void upload("/knowledge/documents/confirm")} className="rounded-lg bg-[var(--brand-primary)] px-4 py-2 text-sm text-white">确认入库</button></div>{message && <p className="mt-3 text-sm text-slate-500">{message}</p>}</div><div className="mt-6 divide-y divide-slate-100 rounded-xl border border-slate-200 bg-white">{items.map((item) => { const id = item.id ?? item.doc_id ?? ""; return <div key={id} className="flex items-center justify-between p-5 text-sm"><span>{item.title ?? item.filename ?? id} <small className="ml-3 text-slate-400">{item.status ?? "已入库"}</small></span><button onClick={() => void remove(id)} aria-label="删除文档" className="text-rose-500"><Trash2 className="h-4 w-4" /></button></div>; })}{!items.length && <p className="p-8 text-sm text-slate-400">暂无知识库文档</p>}</div></>}</div></main>;
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Download, RefreshCw, Send } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import { ApiError, apiFetch } from "@/lib/api";
interface HistoryItem { query_id?: string; question?: string; status?: string; row_count?: number; create_time?: string; }
export default function Nl2SqlPage() {
const [tab, setTab] = useState<"query" | "history" | "config">("query");
const [question, setQuestion] = useState("");
const [result, setResult] = useState<unknown>(null);
const [history, setHistory] = useState<HistoryItem[]>([]);
const [config, setConfig] = useState<Record<string, unknown>>({});
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [userType, setUserType] = useState<string | null>(null);
const isAdmin = userType === "ADMIN";
const canQuery = userType === "EMPLOYEE";
useEffect(() => {
void apiFetch<{ user: { user_type?: string } }>("/auth/me")
.then(({ user }) => {
const nextType = user.user_type ?? null;
setUserType(nextType);
if (nextType === "ADMIN") setTab("config");
})
.catch(() => setUserType(null));
}, []);
useEffect(() => {
if (tab === "history" && canQuery) void loadHistory();
if (tab === "config" && isAdmin) void loadConfig();
}, [tab, isAdmin, canQuery]);
async function query(event: FormEvent) {
event.preventDefault(); setLoading(true); setMessage("");
if (!canQuery) { setMessage("当前账号仅可管理 NL2SQL 配置,不能执行数据查询。"); setLoading(false); return; }
try {
setResult(await apiFetch("/nl2sql/query", { method: "POST", body: JSON.stringify({ question, output_format: "json", include_sql: true, page: 1, page_size: 100 }) }));
} catch (error) {
setMessage(error instanceof ApiError && error.status === 403 ? "当前账号没有 NL2SQL 查询权限,请使用员工账号登录。" : error instanceof Error ? error.message : "查询失败");
} finally { setLoading(false); }
}
async function loadHistory() { try { const response = await apiFetch<{ items?: HistoryItem[] }>("/nl2sql/query-history?page=1&page_size=50"); setHistory(response.items ?? []); } catch (error) { setMessage(error instanceof Error ? error.message : "历史加载失败"); } }
async function loadConfig() { try { setConfig(await apiFetch<Record<string, unknown>>("/nl2sql/admin/runtime-config")); } catch (error) { setMessage(error instanceof Error ? error.message : "配置加载失败"); } }
async function saveConfig() { try { await apiFetch("/nl2sql/admin/runtime-config", { method: "PATCH", body: JSON.stringify(config) }); setMessage("运行配置已保存"); } catch (error) { setMessage(error instanceof Error ? error.message : "配置保存失败"); } }
if (userType === null) return <main className="min-h-screen bg-slate-50 px-10 py-10"><div className="mx-auto max-w-6xl"><Link href="/admin/common" className="inline-flex items-center gap-2 text-sm text-slate-500"><ArrowLeft className="h-4 w-4" />返回员工工作区</Link><p className="mt-10 text-sm text-slate-500">正在校验账号权限...</p></div></main>;
return <main className="min-h-screen bg-slate-50 px-10 py-10"><div className="mx-auto max-w-6xl"><Link href="/admin/common" className="inline-flex items-center gap-2 text-sm text-slate-500"><ArrowLeft className="h-4 w-4" />返回员工工作区</Link><h1 className="mt-8 text-3xl font-semibold text-[var(--brand-navy)]">NL2SQL 数据中心</h1>{isAdmin ? <p className="mt-4 rounded-lg bg-blue-50 p-4 text-sm text-blue-700">当前为 ADMIN 账号,仅开放运行配置管理。数据查询由普通员工账号使用。</p> : <p className="mt-4 rounded-lg bg-blue-50 p-4 text-sm text-blue-700">当前账号可使用查询和历史功能,运行配置仅对 ADMIN 开放。</p>}<div className="mt-6 flex gap-2 border-b border-slate-200">{canQuery && <><button onClick={() => setTab("query")} className={`border-b-2 px-4 py-3 text-sm ${tab === "query" ? "border-[var(--brand-primary)] text-[var(--brand-primary)]" : "border-transparent text-slate-500"}`}>自然语言查询</button><button onClick={() => setTab("history")} className={`border-b-2 px-4 py-3 text-sm ${tab === "history" ? "border-[var(--brand-primary)] text-[var(--brand-primary)]" : "border-transparent text-slate-500"}`}>查询历史</button></>}{isAdmin && <button onClick={() => setTab("config")} className={`border-b-2 px-4 py-3 text-sm ${tab === "config" ? "border-[var(--brand-primary)] text-[var(--brand-primary)]" : "border-transparent text-slate-500"}`}>运行配置</button>}</div>{message && <p className="mt-4 rounded-lg bg-rose-50 p-4 text-sm text-rose-600">{message}</p>}{tab === "query" && canQuery && <><form onSubmit={query} className="mt-6 flex gap-3 rounded-xl border border-slate-200 bg-white p-5"><input required value={question} onChange={(event) => setQuestion(event.target.value)} placeholder="例如:查询本月基金产品数量" className="min-w-0 flex-1 rounded-lg border border-slate-200 px-4 py-3 text-sm" /><button disabled={loading} className="flex items-center gap-2 rounded-lg bg-[var(--brand-primary)] px-4 py-3 text-sm text-white"><Send className="h-4 w-4" />查询</button></form><pre className="mt-6 min-h-64 overflow-auto rounded-xl border border-slate-200 bg-white p-6 text-xs text-slate-700">{result ? JSON.stringify(result, null, 2) : "查询结果将在这里显示"}</pre></>}{tab === "history" && canQuery && <section className="mt-6 rounded-xl border border-slate-200 bg-white p-6"><div className="flex justify-between"><h2 className="font-semibold">查询历史</h2><button onClick={() => void loadHistory()} aria-label="刷新历史" className="text-slate-500"><RefreshCw className="h-4 w-4" /></button></div><div className="mt-4 divide-y divide-slate-100">{history.map((item) => <div key={item.query_id} className="flex items-center justify-between gap-4 py-4 text-sm"><button onClick={() => void apiFetch(`/nl2sql/query-history/${item.query_id}`).then(setResult)} className="text-left"><p>{item.question ?? item.query_id}</p><p className="mt-1 text-xs text-slate-400">{item.status} · {item.row_count ?? 0} 行 · {item.create_time ?? ""}</p></button><button onClick={() => window.open(`/backend/nl2sql/query-history/${item.query_id}/export`, "_blank")} aria-label="导出查询历史" className="text-slate-500"><Download className="h-4 w-4" /></button></div>)}{!history.length && <p className="py-8 text-sm text-slate-400">暂无查询历史</p>}</div>{Boolean(result) && <pre className="mt-5 max-h-80 overflow-auto rounded-lg bg-slate-50 p-4 text-xs">{JSON.stringify(result, null, 2)}</pre>}</section>}{tab === "config" && isAdmin && <section className="mt-6 rounded-xl border border-slate-200 bg-white p-6"><div className="flex justify-between"><h2 className="font-semibold">运行配置</h2><button onClick={() => void loadConfig()} aria-label="刷新配置" className="text-slate-500"><RefreshCw className="h-4 w-4" /></button></div><textarea value={JSON.stringify(config, null, 2)} onChange={(event) => { try { setConfig(JSON.parse(event.target.value)); } catch { /* wait for valid JSON */ } }} className="mt-5 min-h-80 w-full rounded-lg border border-slate-200 p-4 font-mono text-xs" /><button onClick={() => void saveConfig()} className="mt-4 rounded-lg bg-[var(--brand-primary)] px-4 py-2 text-sm text-white">保存配置</button></section>}</div></main>;
}