Files
Mutual_Fund/frontend/components/admin/shell.tsx
T

302 lines
12 KiB
TypeScript
Raw Normal View History

2026-09-14 20:31:06 +08:00
"use client";
import Link from "next/link";
import {
Bell,
ChevronDown,
Database,
FileText,
LayoutDashboard,
ListChecks,
LogOut,
PanelLeftClose,
PanelLeftOpen,
PhoneCall,
Settings,
ShieldAlert,
Sparkles,
Users,
X,
FileSignature,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { AssistantLauncher } from "@/components/admin/assistant-launcher";
import { clearToken, getRoleLabel } from "@/lib/auth";
import {
canUseSection,
isAdmin,
isAdvisor,
isStaff,
visibleZones,
type WorkspaceSection,
} from "@/lib/roles";
import type { UserPayload } from "@/types/api";
import { Badge, cn } from "@/components/admin/ui";
export interface ShellNotification {
id: string;
title: string;
body: string;
time: string;
tone: "alert" | "info" | "check";
section: WorkspaceSection;
}
/** 侧边栏条目元数据,与 roles.ts 的分区定义一一对应。 */
const NAV_META: Record<
WorkspaceSection,
{ label: string; icon: typeof Users; requires: "staff" | "advisor" | "risk" | "admin" }
> = {
overview: { label: "工作台概览", icon: LayoutDashboard, requires: "staff" },
todos: { label: "我的待办", icon: ListChecks, requires: "advisor" },
customers: { label: "客户管理", icon: Users, requires: "advisor" },
drafts: { label: "投顾草稿", icon: FileSignature, requires: "advisor" },
visits: { label: "客户回访", icon: PhoneCall, requires: "advisor" },
risk: { label: "风险预警", icon: ShieldAlert, requires: "risk" },
workorders: { label: "工单处理", icon: FileText, requires: "risk" },
funds: { label: "基金数据", icon: Database, requires: "staff" },
nl2sql: { label: "智能问数", icon: Sparkles, requires: "staff" },
admin: { label: "系统管理", icon: Settings, requires: "admin" },
};
const REQUIREMENT_TEXT: Record<string, string> = {
staff: "仅员工账号可用",
advisor: "需要投顾权限",
risk: "需要风控权限",
admin: "需要管理员权限",
};
export function AdminShell({
user,
section,
onSectionChange,
title,
subtitle,
notifications = [],
children,
}: {
user: UserPayload;
section: WorkspaceSection;
onSectionChange: (section: WorkspaceSection) => void;
title: string;
subtitle: string;
notifications?: ShellNotification[];
children: ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const roleLabel = isAdmin(user) ? "管理员" : getRoleLabel(user.employee_role);
const accessible = isStaff(user);
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<aside
className={cn(
"fixed inset-y-0 left-0 z-40 flex flex-col bg-slate-950 text-white transition-all duration-200",
collapsed ? "w-16" : "w-56"
)}
>
<div className={cn("flex items-center gap-3 px-5 py-6", collapsed && "justify-center px-3")}>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500">
<LayoutDashboard className="h-5 w-5" />
</div>
{!collapsed && (
<div className="min-w-0">
<p className="truncate text-sm font-semibold">华夏基金</p>
<p className="truncate text-xs text-slate-400">统一员工工作台</p>
</div>
)}
</div>
<nav className="flex-1 space-y-4 overflow-y-auto px-3 pb-4">
{visibleZones(user).map((zone, zoneIndex) => (
<div key={zone.id} className="space-y-1">
{zoneIndex > 0 && (
<div className={cn("mx-1 mb-2 border-t border-white/10", collapsed && "mx-2")} />
)}
{!collapsed && (
<p className="px-3 pb-1 text-[10px] font-medium uppercase tracking-wider text-slate-500">
{zone.label}
</p>
)}
{zone.sections.map((item) => {
const meta = NAV_META[item];
const allowed = canUseSection(user, item) && accessible;
const active = section === item;
return (
<button
key={item}
onClick={() => onSectionChange(item)}
title={allowed ? meta.label : `${meta.label}(${REQUIREMENT_TEXT[meta.requires]})`}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
collapsed && "justify-center px-0",
active
? "bg-white/10 text-white"
: allowed
? "text-slate-400 hover:bg-white/5 hover:text-white"
: "text-slate-600 hover:bg-white/5"
)}
>
<meta.icon className="h-[18px] w-[18px] shrink-0" />
{!collapsed && <span className="truncate">{meta.label}</span>}
{!collapsed && !allowed && (
<span className="ml-auto rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400">
无权限
</span>
)}
</button>
);
})}
</div>
))}
</nav>
<div className={cn("border-t border-white/10 p-4", collapsed && "px-2")}>
<Link
href="/"
className={cn(
"mb-3 block text-xs text-slate-400 hover:text-white",
collapsed && "text-center"
)}
>
返回官网
</Link>
<button
onClick={() => {
clearToken();
window.location.href = "/login";
}}
className={cn(
"flex items-center gap-2 text-sm text-slate-300 transition-colors hover:text-white",
collapsed && "justify-center w-full"
)}
>
<LogOut className="h-4 w-4 shrink-0" />
{!collapsed && "退出登录"}
</button>
</div>
</aside>
<div
className={cn(
"min-h-screen transition-all duration-200",
collapsed ? "ml-16" : "ml-56"
)}
>
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6">
<div className="flex items-center gap-3">
<button
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? "展开侧边栏" : "收起侧边栏"}
className="rounded-lg p-2 text-slate-500 transition-colors hover:bg-slate-100"
>
{collapsed ? <PanelLeftOpen className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
</button>
<div>
<p className="text-sm font-semibold text-slate-800">{title}</p>
<p className="text-xs text-slate-500">{subtitle}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
aria-label="通知与提醒"
className="relative rounded-lg p-2 text-slate-500 transition-colors hover:bg-slate-100"
>
<Bell className="h-5 w-5" />
{notifications.length > 0 && (
<span className="absolute right-1 top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
{notifications.length > 99 ? "99+" : notifications.length}
</span>
)}
</button>
{showNotifications && (
<div className="absolute right-0 top-full mt-2 w-80 rounded-xl border border-slate-200 bg-white shadow-lg z-50">
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<span className="text-sm font-semibold text-slate-900">通知与提醒</span>
<button
onClick={() => setShowNotifications(false)}
aria-label="关闭通知"
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{notifications.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-slate-400">暂无待处理提醒</p>
) : (
<>
<ul className="max-h-80 divide-y divide-slate-50 overflow-y-auto">
{notifications.map((item) => (
<li key={item.id}>
<button
onClick={() => {
onSectionChange(item.section);
setShowNotifications(false);
}}
className="w-full px-4 py-3 text-left transition-colors hover:bg-slate-50"
>
<div className="flex items-start gap-2">
<span
className={cn(
"mt-1.5 h-2 w-2 shrink-0 rounded-full",
item.tone === "alert" && "bg-amber-500",
item.tone === "info" && "bg-indigo-500",
item.tone === "check" && "bg-emerald-500"
)}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-900">{item.title}</p>
<p className="mt-0.5 text-xs leading-snug text-slate-500">{item.body}</p>
<p className="mt-1 text-xs text-slate-400">{item.time}</p>
</div>
</div>
</button>
</li>
))}
</ul>
<div className="border-t border-slate-100 px-4 py-2.5">
<span className="text-xs text-slate-400">点击提醒可跳转到对应功能区</span>
</div>
</>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 border-l border-slate-200 pl-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-50 text-sm font-medium text-indigo-600">
{(user.real_name ?? user.username ?? "员").slice(0, 1)}
</div>
<div className="hidden sm:block">
<p className="text-sm font-medium leading-tight text-slate-700">
{user.real_name ?? user.username}
</p>
<p className="text-xs leading-tight text-slate-400">{roleLabel}</p>
</div>
<ChevronDown className="h-4 w-4 text-slate-400" />
</div>
</div>
</header>
<main className="mx-auto max-w-[1400px] p-6">
{!accessible && (
<div className="mb-6">
<Badge tone="danger">当前账号不是员工账号,部分功能不可用</Badge>
</div>
)}
{children}
</main>
</div>
{/* 投顾助手常驻入口:右下角悬浮球,打开后是抽屉式会话面板 */}
{accessible && isAdvisor(user) && <AssistantLauncher />}
</div>
);
}