feat:前端修改(我认为ok)
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, AlertTriangle, BarChart3, CheckCircle2, FileText, Users, Wallet } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { adminApi, errMessage, type DashboardData, type PersonalReport, type RiskAlert, type WorkOrder } from "@/lib/admin-api";
|
||||
import { isAdvisor, isRiskOfficer } from "@/lib/roles";
|
||||
import type { UserPayload } from "@/types/api";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingBlock,
|
||||
StatCard,
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
formatTime,
|
||||
isOverdue,
|
||||
} from "@/components/admin/ui";
|
||||
|
||||
const LEVEL_KEYS = ["C1", "C2", "C3", "C4", "C5", "unknown"];
|
||||
const LEVEL_LABEL: Record<string, string> = {
|
||||
C1: "C1 保守型",
|
||||
C2: "C2 稳健型",
|
||||
C3: "C3 平衡型",
|
||||
C4: "C4 成长型",
|
||||
C5: "C5 进取型",
|
||||
unknown: "未测评",
|
||||
};
|
||||
|
||||
export function OverviewSection({
|
||||
user,
|
||||
onNavigate,
|
||||
}: {
|
||||
user: UserPayload;
|
||||
onNavigate: (section: "todos" | "customers" | "risk" | "workorders") => void;
|
||||
}) {
|
||||
const advisor = isAdvisor(user);
|
||||
const risk = isRiskOfficer(user);
|
||||
|
||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
|
||||
const [personal, setPersonal] = useState<PersonalReport | null>(null);
|
||||
const [alerts, setAlerts] = useState<RiskAlert[]>([]);
|
||||
const [orders, setOrders] = useState<WorkOrder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
if (advisor) {
|
||||
tasks.push(
|
||||
adminApi.dashboard().then(setDashboard).catch(() => setDashboard(null)),
|
||||
adminApi.personalReport().then(setPersonal).catch(() => setPersonal(null))
|
||||
);
|
||||
}
|
||||
if (risk) {
|
||||
tasks.push(
|
||||
adminApi
|
||||
.alerts({ page: 1, page_size: 5 })
|
||||
.then((result) => setAlerts(result.items ?? []))
|
||||
.catch(() => setAlerts([])),
|
||||
adminApi
|
||||
.workOrders({ page: 1, page_size: 5 })
|
||||
.then((result) => setOrders(result.items ?? []))
|
||||
.catch(() => setOrders([]))
|
||||
);
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
} catch (e) {
|
||||
setError(errMessage(e, "工作台数据加载失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [advisor, risk]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (loading) return <LoadingBlock text="正在加载工作台数据..." />;
|
||||
if (error) return <ErrorState message={error} onRetry={() => void load()} />;
|
||||
|
||||
const overview = dashboard?.overview;
|
||||
const pendingTodos = dashboard?.todos?.items ?? [];
|
||||
const levels = dashboard?.customer_levels ?? {};
|
||||
const maxLevel = Math.max(1, ...LEVEL_KEYS.map((key) => levels[key] ?? 0));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{advisor ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
label="名下客户"
|
||||
value={overview?.total_customers ?? "--"}
|
||||
hint={`已签约 ${overview?.signed_customers ?? 0} · 已结束 ${overview?.closed_customers ?? 0}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Wallet className="h-5 w-5" />}
|
||||
label="管理资产"
|
||||
value={overview?.total_aum ? formatMoney(overview.total_aum) : "--"}
|
||||
hint="名下客户持有中市值合计"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<BarChart3 className="h-5 w-5" />}
|
||||
label="客户流失率"
|
||||
value={overview?.churn_rate !== undefined ? formatPercent(overview.churn_rate) : "--"}
|
||||
hint="已结束 / 总客户"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Activity className="h-5 w-5" />}
|
||||
label="待办事项"
|
||||
value={dashboard?.todos?.pending ?? "--"}
|
||||
hint={
|
||||
pendingTodos.some((item) => isOverdue(item.due_at))
|
||||
? `${pendingTodos.filter((item) => isOverdue(item.due_at)).length} 项已逾期`
|
||||
: "暂无逾期"
|
||||
}
|
||||
hintTone={pendingTodos.some((item) => isOverdue(item.due_at)) ? "danger" : "muted"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<StatCard label="报告已发送" value={overview?.reports_sent ?? "--"} />
|
||||
<StatCard label="报告草稿" value={overview?.reports_draft ?? "--"} hint="待发送" />
|
||||
<StatCard label="累计回访" value={overview?.visits_count ?? "--"} />
|
||||
<StatCard
|
||||
label="个人绩效 AUM"
|
||||
value={personal?.total_aum ? formatMoney(personal.total_aum) : "--"}
|
||||
hint={`覆盖 ${personal?.total_customers ?? 0} 名客户`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card
|
||||
title="客户风险分层"
|
||||
description="按客户风险等级统计人数"
|
||||
className="lg:col-span-1"
|
||||
>
|
||||
{Object.keys(levels).length === 0 ? (
|
||||
<EmptyState text="暂无分层数据" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{LEVEL_KEYS.filter((key) => (levels[key] ?? 0) > 0 || key === "unknown").map((key) => (
|
||||
<div key={key}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="text-slate-600">{LEVEL_LABEL[key] ?? key}</span>
|
||||
<span className="font-medium text-slate-900">{levels[key] ?? 0}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-500"
|
||||
style={{ width: `${((levels[key] ?? 0) / maxLevel) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="待办提醒"
|
||||
description="按到期时间排序"
|
||||
className="lg:col-span-2"
|
||||
actions={
|
||||
<Button size="sm" variant="ghost" onClick={() => onNavigate("todos")}>
|
||||
全部待办
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{pendingTodos.length === 0 ? (
|
||||
<EmptyState text="暂无待办事项" />
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{pendingTodos.slice(0, 6).map((todo) => (
|
||||
<li key={todo.id} className="flex flex-wrap items-center justify-between gap-3 py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{todo.todo_type}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{todo.customer_id ? `客户 #${todo.customer_id}` : "无关联客户"} · 截止{" "}
|
||||
{formatTime(todo.due_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOverdue(todo.due_at) && <Badge tone="danger">已逾期</Badge>}
|
||||
<Badge tone={todo.priority === "高" ? "danger" : todo.priority === "中" ? "warning" : "neutral"}>
|
||||
{todo.priority ?? "普通"}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card title="投顾概览">
|
||||
<EmptyState text="当前账号无投顾权限,投顾概览不可用" />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{risk ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card
|
||||
title="最新风险预警"
|
||||
actions={
|
||||
<Button size="sm" variant="ghost" onClick={() => onNavigate("risk")}>
|
||||
进入风控
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{alerts.length === 0 ? (
|
||||
<EmptyState text="暂无风险预警" />
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{alerts.map((alert) => (
|
||||
<li key={alert.id} className="flex items-center justify-between gap-3 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-slate-900">{alert.alert_type}</span>
|
||||
<span className="text-xs text-slate-500">客户 #{alert.customer_id}</span>
|
||||
</div>
|
||||
<Badge tone={alert.alert_level === "高" ? "danger" : alert.alert_level === "中" ? "warning" : "neutral"}>
|
||||
{alert.alert_level}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="待处理工单"
|
||||
actions={
|
||||
<Button size="sm" variant="ghost" onClick={() => onNavigate("workorders")}>
|
||||
进入工单
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{orders.length === 0 ? (
|
||||
<EmptyState text="暂无待处理工单" />
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{orders.map((order) => (
|
||||
<li key={order.id} className="flex items-center justify-between gap-3 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-indigo-500" />
|
||||
<span className="text-slate-900">{order.order_type}</span>
|
||||
<span className="text-xs text-slate-400">{order.work_order_no}</span>
|
||||
</div>
|
||||
<Badge tone={order.priority === "特急" ? "danger" : order.priority === "紧急" ? "warning" : "neutral"}>
|
||||
{order.priority}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card title="风控概览">
|
||||
<EmptyState text="当前账号无风控权限,风控概览不可用" />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="快捷入口" description="常用系统与数据页面">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Link
|
||||
href="/products"
|
||||
className="group rounded-xl border border-slate-200 p-4 transition hover:border-indigo-500 hover:shadow-sm"
|
||||
>
|
||||
<BarChart3 className="h-5 w-5 text-indigo-600" />
|
||||
<p className="mt-3 text-sm font-semibold">基金产品</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">查看在售基金与历史净值</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/tools/nl2sql"
|
||||
className="group rounded-xl border border-slate-200 p-4 transition hover:border-indigo-500 hover:shadow-sm"
|
||||
>
|
||||
<Activity className="h-5 w-5 text-indigo-600" />
|
||||
<p className="mt-3 text-sm font-semibold">NL2SQL 查询</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">自然语言查询业务数据</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/tools/knowledge"
|
||||
className="group rounded-xl border border-slate-200 p-4 transition hover:border-indigo-500 hover:shadow-sm"
|
||||
>
|
||||
<FileText className="h-5 w-5 text-indigo-600" />
|
||||
<p className="mt-3 text-sm font-semibold">知识库管理</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">维护 RAG 文档与检索数据</p>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => onNavigate("customers")}
|
||||
className="group rounded-xl border border-slate-200 p-4 text-left transition hover:border-indigo-500 hover:shadow-sm"
|
||||
>
|
||||
<Users className="h-5 w-5 text-indigo-600" />
|
||||
<p className="mt-3 text-sm font-semibold">客户管理</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">查看名下客户与持仓诊断</p>
|
||||
</button>
|
||||
</div>
|
||||
{!isAdvisor(user) && (
|
||||
<p className="mt-4 flex items-center gap-2 text-xs text-amber-600">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
部分入口需要对应角色权限,无权限时会在目标模块给出提示
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user