218 lines
8.4 KiB
TypeScript
218 lines
8.4 KiB
TypeScript
"use client";
|
||||
|
|
|
|||
|
|
import { ShieldAlert } from "lucide-react";
|
|||
|
|
import Link from "next/link";
|
|||
|
|
import { useCallback, useEffect, useState } from "react";
|
|||
|
|
import { AdminShell, type ShellNotification } from "@/components/admin/shell";
|
|||
|
|
import { AdminSection } from "@/components/admin/sections/admin";
|
|||
|
|
import { CustomersSection } from "@/components/admin/sections/customers";
|
|||
|
|
import { DraftsSection } from "@/components/admin/sections/drafts";
|
|||
|
|
import { FundsSection } from "@/components/admin/sections/funds";
|
|||
|
|
import { Nl2SqlSection } from "@/components/admin/sections/nl2sql";
|
|||
|
|
import { OverviewSection } from "@/components/admin/sections/overview";
|
|||
|
|
import { RiskSection } from "@/components/admin/sections/risk";
|
|||
|
|
import { TodosSection } from "@/components/admin/sections/todos";
|
|||
|
|
import { VisitsSection } from "@/components/admin/sections/visits";
|
|||
|
|
import { WorkOrdersSection } from "@/components/admin/sections/workorders";
|
|||
|
|
import { LoadingBlock, NoPermission, ToastProvider, formatTime } from "@/components/admin/ui";
|
|||
|
|
import { adminApi } from "@/lib/admin-api";
|
|||
|
|
import { canUseSection, isAdvisor, isRiskOfficer, isStaff, isWorkspaceSection, type WorkspaceSection } from "@/lib/roles";
|
|||
|
|
import type { UserPayload } from "@/types/api";
|
|||
|
|
|
|||
|
|
const SECTION_META: Record<WorkspaceSection, { title: string; subtitle: string }> = {
|
|||
|
|
overview: { title: "工作台概览", subtitle: "按当前角色展示核心经营指标" },
|
|||
|
|
todos: { title: "我的待办", subtitle: "投顾待办事项的处理与跟踪" },
|
|||
|
|
customers: { title: "客户管理", subtitle: "名下客户、持仓、诊断与签约状态" },
|
|||
|
|
drafts: { title: "投顾草稿", subtitle: "建议草稿的编辑、发送与废弃" },
|
|||
|
|
visits: { title: "客户回访", subtitle: "回访留痕与合规话术参考" },
|
|||
|
|
risk: { title: "风险预警", subtitle: "待处置预警与人工处置通道" },
|
|||
|
|
workorders: { title: "工单处理", subtitle: "认领、提交审核与复核闭环" },
|
|||
|
|
funds: { title: "基金数据", subtitle: "白名单产品与净值数据" },
|
|||
|
|
nl2sql: { title: "智能问数", subtitle: "自然语言查数,自动生成 SQL 并返回结果" },
|
|||
|
|
admin: { title: "系统管理", subtitle: "NL2SQL 配置、知识库与数据管理" },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const REQUIRED_ROLE: Record<WorkspaceSection, string> = {
|
|||
|
|
overview: "员工账号",
|
|||
|
|
todos: "投顾权限",
|
|||
|
|
customers: "投顾权限",
|
|||
|
|
drafts: "投顾权限",
|
|||
|
|
visits: "投顾权限",
|
|||
|
|
risk: "风控权限",
|
|||
|
|
workorders: "风控权限",
|
|||
|
|
funds: "员工账号",
|
|||
|
|
nl2sql: "员工账号",
|
|||
|
|
admin: "管理员权限",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export function UnifiedWorkspace({ defaultSection = "overview" }: { defaultSection?: WorkspaceSection }) {
|
|||
|
|
const [user, setUser] = useState<UserPayload | null>(null);
|
|||
|
|
const [access, setAccess] = useState<"checking" | "allowed" | "denied">("checking");
|
|||
|
|
const [section, setSection] = useState<WorkspaceSection>(defaultSection);
|
|||
|
|
const [notifications, setNotifications] = useState<ShellNotification[]>([]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const params = new URLSearchParams(window.location.search);
|
|||
|
|
const initial = params.get("section");
|
|||
|
|
if (isWorkspaceSection(initial)) setSection(initial);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const changeSection = useCallback((next: WorkspaceSection) => {
|
|||
|
|
setSection(next);
|
|||
|
|
const url = new URL(window.location.href);
|
|||
|
|
url.searchParams.set("section", next);
|
|||
|
|
window.history.replaceState(null, "", url.toString());
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
void (async () => {
|
|||
|
|
try {
|
|||
|
|
const current = await adminApi.me();
|
|||
|
|
setUser(current);
|
|||
|
|
setAccess(isStaff(current) ? "allowed" : "denied");
|
|||
|
|
} catch {
|
|||
|
|
setAccess("denied");
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (access !== "allowed" || !user) return;
|
|||
|
|
void (async () => {
|
|||
|
|
const result: ShellNotification[] = [];
|
|||
|
|
|
|||
|
|
if (isAdvisor(user)) {
|
|||
|
|
try {
|
|||
|
|
const dashboard = await adminApi.dashboard();
|
|||
|
|
for (const todo of (dashboard.todos?.items ?? []).slice(0, 5)) {
|
|||
|
|
result.push({
|
|||
|
|
id: `todo-${todo.id}`,
|
|||
|
|
title: todo.todo_type,
|
|||
|
|
body: `${todo.customer_id ? `客户 #${todo.customer_id} · ` : ""}截止 ${formatTime(todo.due_at)}`,
|
|||
|
|
time: todo.priority ?? "普通",
|
|||
|
|
tone: todo.priority === "高" ? "alert" : "info",
|
|||
|
|
section: "todos",
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
/* 无投顾权限时静默跳过 */
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isRiskOfficer(user)) {
|
|||
|
|
try {
|
|||
|
|
const alerts = await adminApi.alerts({ status: "未处理", page: 1, page_size: 5 });
|
|||
|
|
for (const alert of alerts.items ?? []) {
|
|||
|
|
result.push({
|
|||
|
|
id: `alert-${alert.id}`,
|
|||
|
|
title: `${alert.alert_type}(${alert.alert_level})`,
|
|||
|
|
body: `客户 #${alert.customer_id} · 待处置`,
|
|||
|
|
time: formatTime(alert.create_time),
|
|||
|
|
tone: alert.alert_level === "高" ? "alert" : "info",
|
|||
|
|
section: "risk",
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
/* 无风控权限时静默跳过 */
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const orders = await adminApi.workOrders({ status: "待审核", page: 1, page_size: 5 });
|
|||
|
|
for (const order of orders.items ?? []) {
|
|||
|
|
result.push({
|
|||
|
|
id: `order-${order.id}`,
|
|||
|
|
title: `${order.order_type} 待复核`,
|
|||
|
|
body: `${order.work_order_no} · 优先级 ${order.priority}`,
|
|||
|
|
time: formatTime(order.create_time),
|
|||
|
|
tone: "check",
|
|||
|
|
section: "workorders",
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
/* 无风控权限时静默跳过 */
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setNotifications(result);
|
|||
|
|
})();
|
|||
|
|
}, [access, user]);
|
|||
|
|
|
|||
|
|
if (access === "checking") {
|
|||
|
|
return (
|
|||
|
|
<main className="flex min-h-screen items-center justify-center bg-slate-50">
|
|||
|
|
<LoadingBlock text="正在验证员工身份..." />
|
|||
|
|
</main>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (access === "denied" || !user) {
|
|||
|
|
return (
|
|||
|
|
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-6">
|
|||
|
|
<section className="w-full max-w-md rounded-xl border border-slate-200 bg-white p-8 text-center">
|
|||
|
|
<ShieldAlert className="mx-auto h-10 w-10 text-rose-500" />
|
|||
|
|
<h1 className="mt-5 text-xl font-semibold text-slate-900">无权限访问员工工作台</h1>
|
|||
|
|
<p className="mt-2 text-sm leading-6 text-slate-500">
|
|||
|
|
当前账号不是员工账号,无法进入统一员工工作台。请使用员工账号登录,或返回官网浏览基金产品。
|
|||
|
|
</p>
|
|||
|
|
<div className="mt-6 flex justify-center gap-3">
|
|||
|
|
<Link
|
|||
|
|
href="/"
|
|||
|
|
className="rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
|
|||
|
|
>
|
|||
|
|
返回官网
|
|||
|
|
</Link>
|
|||
|
|
<Link
|
|||
|
|
href="/login"
|
|||
|
|
className="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
|||
|
|
>
|
|||
|
|
重新登录
|
|||
|
|
</Link>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
</main>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const meta = SECTION_META[section];
|
|||
|
|
const allowed = canUseSection(user, section);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<ToastProvider>
|
|||
|
|
<AdminShell
|
|||
|
|
user={user}
|
|||
|
|
section={section}
|
|||
|
|
onSectionChange={changeSection}
|
|||
|
|
title={meta.title}
|
|||
|
|
subtitle={meta.subtitle}
|
|||
|
|
notifications={notifications}
|
|||
|
|
>
|
|||
|
|
{!allowed ? (
|
|||
|
|
<NoPermission module={meta.title} hint={`该模块需要${REQUIRED_ROLE[section]},当前账号不具备操作权限。`} />
|
|||
|
|
) : section === "overview" ? (
|
|||
|
|
<OverviewSection
|
|||
|
|
user={user}
|
|||
|
|
onNavigate={(target) => changeSection(target)}
|
|||
|
|
/>
|
|||
|
|
) : section === "todos" ? (
|
|||
|
|
<TodosSection onOpenCustomer={() => changeSection("customers")} />
|
|||
|
|
) : section === "customers" ? (
|
|||
|
|
<CustomersSection />
|
|||
|
|
) : section === "drafts" ? (
|
|||
|
|
<DraftsSection />
|
|||
|
|
) : section === "visits" ? (
|
|||
|
|
<VisitsSection />
|
|||
|
|
) : section === "risk" ? (
|
|||
|
|
<RiskSection />
|
|||
|
|
) : section === "workorders" ? (
|
|||
|
|
<WorkOrdersSection />
|
|||
|
|
) : section === "funds" ? (
|
|||
|
|
<FundsSection user={user} />
|
|||
|
|
) : section === "nl2sql" ? (
|
|||
|
|
<Nl2SqlSection />
|
|||
|
|
) : (
|
|||
|
|
<AdminSection username={user.real_name ?? user.username} />
|
|||
|
|
)}
|
|||
|
|
</AdminShell>
|
|||
|
|
</ToastProvider>
|
|||
|
|
);
|
|||
|
|
}
|