302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
"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>
|
|||
|
|
);
|
|||
|
|
}
|