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
+46
View File
@@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import { Activity, AlertTriangle, BarChart3, Check, FileText, LogOut, ShieldAlert, Users } from "lucide-react";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { clearToken, getRoleLabel } from "@/lib/auth";
import type { UserPayload } from "@/types/api";
interface DashboardData { overview?: { total_customers?: number; signed_customers?: number; total_aum?: string }; todos?: { pending?: number } }
interface RiskAlert { id: number; customer_id: number; alert_type: string; alert_level: string; status: string }
interface WorkOrder { id: number; work_order_no: string; order_type: string; status: string; priority: string }
interface Customer { customer_id?: number; id?: number; real_name?: string; name?: string; risk_level?: string; total_aum?: string | number }
interface Todo { id: number; todo_type: string; status: string; priority: string }
interface Visit { id: number; customer_id: number; visit_type?: string; content?: string }
function Frame({ title, subtitle, role, children }: { title: string; subtitle: string; role: string; children: React.ReactNode }) {
return <main className="min-h-screen bg-slate-50 text-slate-900"><header className="flex h-20 items-center justify-between border-b border-slate-200 bg-white px-10"><div><p className="text-xs uppercase tracking-[0.2em] text-slate-400">Huaxia Fund Operations</p><h1 className="mt-1 text-xl font-semibold text-[var(--brand-navy)]">{title}</h1></div><div className="flex items-center gap-5"><span className="text-sm text-slate-500">{role}</span><button onClick={() => { clearToken(); window.location.href = "/login"; }} className="flex items-center gap-2 text-sm text-slate-500 hover:text-[var(--brand-primary)]"><LogOut className="h-4 w-4" />退出登录</button></div></header><section className="mx-auto max-w-7xl px-10 py-10"><p className="text-sm text-slate-500">{subtitle}</p>{children}</section></main>;
}
async function getMe() { return (await apiFetch<{ user: UserPayload }>("/auth/me")).user; }
function Notice({ children }: { children: React.ReactNode }) { return <div className="mt-5 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">{children}</div>; }
export function AdvisorWorkspace() {
const [user, setUser] = useState<UserPayload | null>(null); const [dashboard, setDashboard] = useState<DashboardData | null>(null); const [customers, setCustomers] = useState<Customer[]>([]); const [todos, setTodos] = useState<Todo[]>([]); const [visits, setVisits] = useState<Visit[]>([]); const [report, setReport] = useState<Record<string, unknown> | null>(null); const [error, setError] = useState("");
async function load() { try { const [current, overview, customerData, todoData, visitData, personalReport] = await Promise.all([getMe(), apiFetch<DashboardData>("/advisor/dashboard"), apiFetch<{ items?: Customer[] }>("/advisor/customers?page=1&page_size=50"), apiFetch<{ items?: Todo[] }>("/advisor/todos?page=1&page_size=20"), apiFetch<{ items?: Visit[] }>("/advisor/visits?page=1&page_size=20"), apiFetch<Record<string, unknown>>("/advisor/report/personal")]); setUser(current); setDashboard(overview); setCustomers(customerData.items ?? []); setTodos(todoData.items ?? []); setVisits(visitData.items ?? []); setReport(personalReport); } catch { setError("部分投顾数据加载失败,请确认投顾权限或后端服务状态。"); } }
useEffect(() => { void load(); }, []);
async function finishTodo(id: number) { await apiFetch(`/advisor/todos/${id}/handle`, { method: "POST", body: JSON.stringify({ action: "done" }) }); await load(); }
const overview = dashboard?.overview;
return <Frame title="投顾工作区" subtitle="客户关系、投顾服务与业务待办" role={getRoleLabel(user?.employee_role)}>{error && <Notice>{error}</Notice>}<div className="mt-8 grid gap-5 md:grid-cols-4"><div className="rounded-xl border border-slate-200 bg-white p-6"><Users className="h-5 w-5 text-[var(--brand-primary)]" /><p className="mt-5 text-sm text-slate-500">名下客户</p><p className="mt-2 text-3xl font-semibold">{overview?.total_customers ?? "--"}</p></div><div className="rounded-xl border border-slate-200 bg-white p-6"><Check className="h-5 w-5 text-[var(--brand-primary)]" /><p className="mt-5 text-sm text-slate-500">签约客户</p><p className="mt-2 text-3xl font-semibold">{overview?.signed_customers ?? "--"}</p></div><div className="rounded-xl border border-slate-200 bg-white p-6"><BarChart3 className="h-5 w-5 text-[var(--brand-primary)]" /><p className="mt-5 text-sm text-slate-500">管理资产</p><p className="mt-2 text-3xl font-semibold">{overview?.total_aum ? `¥${overview.total_aum}` : "--"}</p></div><div className="rounded-xl border border-slate-200 bg-white p-6"><Activity className="h-5 w-5 text-[var(--brand-primary)]" /><p className="mt-5 text-sm text-slate-500">待办事项</p><p className="mt-2 text-3xl font-semibold">{dashboard?.todos?.pending ?? "--"}</p></div></div><div className="mt-8 grid gap-6 lg:grid-cols-2"><section className="rounded-xl border border-slate-200 bg-white p-6"><h2 className="font-semibold">客户管理</h2><div className="mt-4 divide-y divide-slate-100">{customers.slice(0, 8).map((item) => <div key={item.customer_id ?? item.id} className="flex justify-between py-3 text-sm"><span>{item.real_name ?? item.name ?? `客户 #${item.customer_id ?? item.id}`}</span><span className="text-slate-500">{item.risk_level ?? "未评估"} · {item.total_aum ?? "--"}</span></div>)}{!customers.length && <p className="py-6 text-sm text-slate-400">暂无客户数据</p>}</div></section><section className="rounded-xl border border-slate-200 bg-white p-6"><h2 className="font-semibold">待办处理</h2><div className="mt-4 space-y-3">{todos.slice(0, 6).map((todo) => <div key={todo.id} className="flex items-center justify-between rounded-lg bg-slate-50 p-3 text-sm"><span>{todo.todo_type} · {todo.priority}</span><button onClick={() => void finishTodo(todo.id)} className="text-[var(--brand-primary)]">完成</button></div>)}{!todos.length && <p className="py-6 text-sm text-slate-400">暂无待办</p>}</div></section><section className="rounded-xl border border-slate-200 bg-white p-6"><h2 className="font-semibold">回访记录</h2><div className="mt-4 space-y-3">{visits.slice(0, 5).map((visit) => <div key={visit.id} className="rounded-lg bg-slate-50 p-3 text-sm">客户 #{visit.customer_id} · {visit.visit_type ?? "回访"}<p className="mt-1 text-xs text-slate-500">{visit.content ?? "暂无内容"}</p></div>)}{!visits.length && <p className="py-6 text-sm text-slate-400">暂无回访记录</p>}</div></section><section className="rounded-xl border border-slate-200 bg-white p-6"><h2 className="font-semibold">个人绩效</h2><pre className="mt-4 max-h-48 overflow-auto rounded-lg bg-slate-50 p-3 text-xs text-slate-600">{report ? JSON.stringify(report, null, 2) : "暂无绩效数据"}</pre></section></div></Frame>;
}
export function RiskWorkspace() {
const [user, setUser] = useState<UserPayload | null>(null); const [alerts, setAlerts] = useState<RiskAlert[]>([]); const [orders, setOrders] = useState<WorkOrder[]>([]); const [error, setError] = useState("");
async function load() { try { const [current, alertData, orderData] = await Promise.all([getMe(), apiFetch<{ items?: RiskAlert[] }>("/risk/alert/list?page=1&page_size=10"), apiFetch<{ items?: WorkOrder[] }>("/work-order/list?page=1&page_size=10")]); setUser(current); setAlerts(alertData.items ?? []); setOrders(orderData.items ?? []); } catch { setError("风控数据加载失败,请确认风控权限或后端服务状态。"); } }
useEffect(() => { void load(); }, []);
async function alertAction(id: number, action: "release" | "block" | "freeze") { await apiFetch(`/risk/alert/${id}/${action}`, { method: "POST" }); await load(); }
async function orderAction(id: number, action: "claim" | "submit-review" | "review") { const body = action === "review" ? { work_order_id: id, approve: window.confirm("确认通过该工单?"), comment: "前端工作区复核" } : { work_order_id: id }; await apiFetch(`/work-order/${action}`, { method: "POST", body: JSON.stringify(body) }); await load(); }
return <Frame title="风控工作区" subtitle="风险预警、处置与工单审核" role={getRoleLabel(user?.employee_role)}>{error && <Notice>{error}</Notice>}<div className="mt-8 grid gap-5 md:grid-cols-3"><div className="rounded-xl border border-slate-200 bg-white p-6"><ShieldAlert className="h-5 w-5 text-rose-500" /><p className="mt-5 text-sm text-slate-500">当前预警</p><p className="mt-2 text-3xl font-semibold">{alerts.length}</p></div><div className="rounded-xl border border-slate-200 bg-white p-6"><AlertTriangle className="h-5 w-5 text-amber-500" /><p className="mt-5 text-sm text-slate-500">高风险事项</p><p className="mt-2 text-3xl font-semibold">{alerts.filter((item) => item.alert_level === "高").length}</p></div><div className="rounded-xl border border-slate-200 bg-white p-6"><FileText className="h-5 w-5 text-[var(--brand-primary)]" /><p className="mt-5 text-sm text-slate-500">工单数量</p><p className="mt-2 text-3xl font-semibold">{orders.length}</p></div></div><section className="mt-8 rounded-xl border border-slate-200 bg-white p-6"><h2 className="font-semibold">风险预警处置</h2><div className="mt-4 divide-y divide-slate-100">{alerts.map((alert) => <div key={alert.id} className="flex items-center justify-between gap-4 py-4 text-sm"><span>{alert.alert_type} · 客户 #{alert.customer_id}<small className="ml-3 text-slate-400">{alert.status}</small></span><div className="flex gap-2"><button onClick={() => void alertAction(alert.id, "release")} className="rounded border px-2 py-1 text-xs hover:text-emerald-600">放行</button><button onClick={() => void alertAction(alert.id, "block")} className="rounded border px-2 py-1 text-xs hover:text-rose-600">拦截</button><button onClick={() => void alertAction(alert.id, "freeze")} className="rounded border px-2 py-1 text-xs hover:text-amber-600">冻结</button></div></div>)}{!alerts.length && <p className="py-6 text-sm text-slate-400">暂无风险预警</p>}</div></section><section className="mt-8 rounded-xl border border-slate-200 bg-white p-6"><h2 className="font-semibold">业务工单</h2><div className="mt-4 divide-y divide-slate-100">{orders.map((item) => <div key={item.id} className="flex items-center justify-between gap-4 py-4 text-sm"><span>{item.work_order_no} · {item.order_type} · {item.status}</span><div className="flex gap-2"><button onClick={() => void orderAction(item.id, "claim")} className="rounded border px-2 py-1 text-xs">认领</button><button onClick={() => void orderAction(item.id, "submit-review")} className="rounded border px-2 py-1 text-xs">提交审核</button><button onClick={() => void orderAction(item.id, "review")} className="rounded border px-2 py-1 text-xs">复核</button></div></div>)}{!orders.length && <p className="py-6 text-sm text-slate-400">暂无工单</p>}</div></section></Frame>;
}
export function CommonWorkspace() {
const [user, setUser] = useState<UserPayload | null>(null); useEffect(() => { void getMe().then(setUser).catch(() => undefined); }, []);
const isAdmin = user?.user_type === "ADMIN";
return <Frame title="员工通用工作区" subtitle="基金信息、系统工具与基础运营入口" role={getRoleLabel(user?.employee_role)}><div className="mt-8 grid gap-5 md:grid-cols-3"><Link href="/products" className="rounded-xl border border-slate-200 bg-white p-6 hover:border-[var(--brand-primary)]"><FileText className="h-5 w-5 text-[var(--brand-primary)]" /><h2 className="mt-5 font-semibold">基金数据</h2><p className="mt-2 text-sm text-slate-500">查看真实基金产品和历史净值</p></Link>{isAdmin ? <Link href="/tools/nl2sql" className="rounded-xl border border-slate-200 bg-white p-6 hover:border-[var(--brand-primary)]"><Activity className="h-5 w-5 text-[var(--brand-primary)]" /><h2 className="mt-5 font-semibold">NL2SQL 配置</h2><p className="mt-2 text-sm text-slate-500">管理员专用:运行配置和查询权限管理</p></Link> : <Link href="/tools/nl2sql" className="rounded-xl border border-slate-200 bg-white p-6 hover:border-[var(--brand-primary)]"><Activity className="h-5 w-5 text-[var(--brand-primary)]" /><h2 className="mt-5 font-semibold">数据查询</h2><p className="mt-2 text-sm text-slate-500">员工自然语言查询与历史记录</p></Link>}{isAdmin && <Link href="/tools/knowledge" className="rounded-xl border border-slate-200 bg-white p-6 hover:border-[var(--brand-primary)]"><FileText className="h-5 w-5 text-[var(--brand-primary)]" /><h2 className="mt-5 font-semibold">知识库管理</h2><p className="mt-2 text-sm text-slate-500">管理员专用:文档上传、预览和删除</p></Link>}</div></Frame>;
}