2026-09-08 21:29:21 +08:00
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
import { Avatar, Modal, Popover, Tooltip } from "@douyinfe/semi-ui-19";
|
|
|
|
|
import {
|
|
|
|
|
IconBeaker,
|
|
|
|
|
IconChevronDown,
|
|
|
|
|
IconClose,
|
|
|
|
|
IconCommand,
|
|
|
|
|
IconExit,
|
|
|
|
|
IconGridView,
|
|
|
|
|
IconList,
|
|
|
|
|
IconMoon,
|
|
|
|
|
IconSidebar,
|
|
|
|
|
IconUser,
|
|
|
|
|
} from "@douyinfe/semi-icons";
|
|
|
|
|
import type { Account } from "../types";
|
|
|
|
|
import type { WorkspacePage } from "../ai/workspace";
|
|
|
|
|
|
|
|
|
|
const navigation = [
|
2026-09-09 18:20:08 +08:00
|
|
|
{ id: "home", label: "首页看板", icon: IconGridView, group: "概览" },
|
2026-09-08 21:29:21 +08:00
|
|
|
{ id: "datasets", label: "数据目录", icon: IconList, group: "数据与素材" },
|
2026-09-12 01:24:02 +08:00
|
|
|
{ id: "fields", label: "字段目录", icon: IconList, group: "数据与素材" },
|
|
|
|
|
{
|
|
|
|
|
id: "preparations",
|
|
|
|
|
label: "数据准备",
|
|
|
|
|
icon: IconList,
|
|
|
|
|
group: "数据与素材",
|
|
|
|
|
},
|
2026-09-08 21:29:21 +08:00
|
|
|
{ id: "operators", label: "算子库", icon: IconList, group: "数据与素材" },
|
|
|
|
|
{
|
|
|
|
|
id: "templates",
|
|
|
|
|
label: "模板工坊",
|
|
|
|
|
icon: IconGridView,
|
|
|
|
|
group: "数据与素材",
|
|
|
|
|
},
|
2026-09-08 21:52:46 +08:00
|
|
|
{ id: "features", label: "特征工程", icon: IconBeaker, group: "研究实验" },
|
2026-09-08 21:29:21 +08:00
|
|
|
{ id: "variants", label: "Alpha 变体", icon: IconBeaker, group: "研究实验" },
|
|
|
|
|
{ id: "backtests", label: "回测研究", icon: IconBeaker, group: "研究实验" },
|
2026-09-08 22:16:05 +08:00
|
|
|
{ id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" },
|
2026-09-08 22:42:29 +08:00
|
|
|
{ id: "quantflow", label: "QuantFlow", icon: IconBeaker, group: "研究编排" },
|
2026-09-08 21:29:21 +08:00
|
|
|
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" },
|
2026-09-09 16:18:27 +08:00
|
|
|
{ id: "mcp-keys", label: "MCP Key", icon: IconCommand, group: "系统管理" },
|
2026-09-08 21:29:21 +08:00
|
|
|
{ id: "account", label: "个人信息", icon: IconUser, group: "" },
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
export function AppSidebar({
|
|
|
|
|
account,
|
|
|
|
|
page,
|
|
|
|
|
collapsed,
|
|
|
|
|
blocked,
|
|
|
|
|
onCollapse,
|
|
|
|
|
onNavigate,
|
|
|
|
|
onLogout,
|
|
|
|
|
onTheme,
|
|
|
|
|
}: {
|
|
|
|
|
account: Account | null;
|
|
|
|
|
page: WorkspacePage;
|
|
|
|
|
collapsed: boolean;
|
|
|
|
|
blocked: boolean;
|
|
|
|
|
onCollapse: () => void;
|
|
|
|
|
onNavigate: (page: WorkspacePage) => void;
|
|
|
|
|
onLogout: () => void;
|
|
|
|
|
onTheme: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [userOpen, setUserOpen] = useState(false);
|
|
|
|
|
const [commandOpen, setCommandOpen] = useState(false);
|
|
|
|
|
const [recent, setRecent] = useState<WorkspacePage[]>([page]);
|
|
|
|
|
const name = account?.display_name || "研究员";
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setRecent((old) => [page, ...old.filter((id) => id !== page)].slice(0, 4));
|
|
|
|
|
}, [page]);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (blocked) {
|
|
|
|
|
setUserOpen(false);
|
|
|
|
|
setCommandOpen(false);
|
|
|
|
|
}
|
|
|
|
|
const shortcut = (event: KeyboardEvent) => {
|
|
|
|
|
if (
|
|
|
|
|
!blocked &&
|
|
|
|
|
(event.metaKey || event.ctrlKey) &&
|
|
|
|
|
event.key.toLowerCase() === "k"
|
|
|
|
|
) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
setCommandOpen((open) => !open);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener("keydown", shortcut);
|
|
|
|
|
return () => window.removeEventListener("keydown", shortcut);
|
|
|
|
|
}, [blocked]);
|
|
|
|
|
function navigate(id: WorkspacePage) {
|
|
|
|
|
setUserOpen(false);
|
|
|
|
|
setCommandOpen(false);
|
|
|
|
|
onNavigate(id);
|
|
|
|
|
}
|
|
|
|
|
function closeRecent(id: WorkspacePage) {
|
|
|
|
|
if (recent.length <= 1) return;
|
|
|
|
|
const next = recent.filter((item) => item !== id);
|
|
|
|
|
setRecent(next);
|
|
|
|
|
if (id === page) navigate(next[0]);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<aside
|
|
|
|
|
className={`app-sidebar ${collapsed ? "is-collapsed" : ""}`}
|
|
|
|
|
inert={blocked}
|
|
|
|
|
aria-label="工作空间导航"
|
|
|
|
|
>
|
|
|
|
|
<div className="sidebar-user-row">
|
|
|
|
|
<Popover
|
|
|
|
|
trigger="click"
|
|
|
|
|
position={collapsed ? "rightTop" : "bottomLeft"}
|
|
|
|
|
visible={userOpen}
|
|
|
|
|
onVisibleChange={setUserOpen}
|
|
|
|
|
showArrow={false}
|
|
|
|
|
content={
|
|
|
|
|
<div className="sidebar-user-menu">
|
|
|
|
|
<div className="sidebar-user-details">
|
|
|
|
|
<strong>{name}</strong>
|
|
|
|
|
{account?.email && account.email !== name && (
|
|
|
|
|
<span>{account.email}</span>
|
|
|
|
|
)}
|
|
|
|
|
<span>
|
|
|
|
|
WorldQuant{" "}
|
|
|
|
|
{account?.connection_status === "connected"
|
|
|
|
|
? "已连接"
|
|
|
|
|
: "未连接"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
aria-label="查看个人信息"
|
|
|
|
|
onClick={() => navigate("account")}
|
|
|
|
|
>
|
|
|
|
|
<IconUser />
|
|
|
|
|
查看个人信息
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
aria-label={
|
|
|
|
|
account?.theme === "dark" ? "切换浅色主题" : "切换深色主题"
|
|
|
|
|
}
|
|
|
|
|
onClick={onTheme}
|
|
|
|
|
>
|
|
|
|
|
<IconMoon />
|
|
|
|
|
{account?.theme === "dark" ? "切换浅色主题" : "切换深色主题"}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
aria-label="退出登录"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setUserOpen(false);
|
|
|
|
|
onLogout();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<IconExit />
|
|
|
|
|
退出登录
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
className="sidebar-user-trigger"
|
|
|
|
|
aria-label="用户菜单"
|
|
|
|
|
aria-expanded={userOpen}
|
|
|
|
|
>
|
|
|
|
|
<Avatar size="small" color="grey">
|
|
|
|
|
{name.slice(0, 2).toUpperCase()}
|
|
|
|
|
</Avatar>
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="account-name">{name}</span>
|
|
|
|
|
<IconChevronDown size="small" />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
<div className="sidebar-command">
|
|
|
|
|
<button onClick={() => setCommandOpen(true)}>
|
|
|
|
|
<IconCommand />
|
|
|
|
|
<span>快捷导航</span>
|
|
|
|
|
<kbd>⌘K</kbd>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<nav className="sidebar-nav" aria-label="主导航">
|
2026-09-09 18:20:08 +08:00
|
|
|
{[
|
|
|
|
|
"概览",
|
|
|
|
|
"数据与素材",
|
|
|
|
|
"研究实验",
|
|
|
|
|
"研究编排",
|
|
|
|
|
"研究成果",
|
|
|
|
|
"系统管理",
|
|
|
|
|
].map((group) => (
|
|
|
|
|
<div key={group}>
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
<div className="sidebar-group-heading">{group}</div>
|
|
|
|
|
)}
|
|
|
|
|
{navigation
|
|
|
|
|
.filter((item) => item.group === group)
|
|
|
|
|
.map(({ id, label, icon: Icon }) => (
|
|
|
|
|
<Tooltip
|
|
|
|
|
key={id}
|
|
|
|
|
content={label}
|
|
|
|
|
position="right"
|
|
|
|
|
visible={collapsed ? undefined : false}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
className={`sidebar-nav-item ${id === page ? "active" : ""}`}
|
|
|
|
|
aria-label={label}
|
|
|
|
|
aria-current={id === page ? "page" : undefined}
|
|
|
|
|
onClick={() => navigate(id)}
|
2026-09-08 21:29:21 +08:00
|
|
|
>
|
2026-09-09 18:20:08 +08:00
|
|
|
<Icon style={{ fontSize: 17 }} />
|
|
|
|
|
{!collapsed && <span>{label}</span>}
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
2026-09-08 21:29:21 +08:00
|
|
|
</nav>
|
|
|
|
|
<div className="sidebar-footer">
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
<div className="recent-heading">
|
|
|
|
|
<span>最近访问</span>
|
|
|
|
|
<button onClick={() => setRecent([page])}>全部关闭</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<nav aria-label="最近访问">
|
|
|
|
|
{recent.map((id) => {
|
|
|
|
|
const item = navigation.find((item) => item.id === id)!;
|
|
|
|
|
const Icon = item.icon;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={id}
|
|
|
|
|
className={`recent-item ${id === page ? "active" : ""}`}
|
|
|
|
|
>
|
|
|
|
|
<Tooltip
|
|
|
|
|
content={item.label}
|
|
|
|
|
position="right"
|
|
|
|
|
visible={collapsed ? undefined : false}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
className="recent-link"
|
|
|
|
|
aria-label={`最近访问:${item.label}`}
|
|
|
|
|
onClick={() => navigate(id)}
|
|
|
|
|
>
|
|
|
|
|
<Icon style={{ fontSize: 15 }} />
|
|
|
|
|
{!collapsed && <span>{item.label}</span>}
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
<button
|
|
|
|
|
className="recent-close"
|
|
|
|
|
aria-label={`关闭最近访问:${item.label}`}
|
|
|
|
|
disabled={recent.length === 1}
|
|
|
|
|
onClick={() => closeRecent(id)}
|
|
|
|
|
>
|
|
|
|
|
<IconClose size="small" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</nav>
|
|
|
|
|
<div className="sidebar-collapse">
|
|
|
|
|
<Tooltip
|
|
|
|
|
content={collapsed ? "展开导航" : "收起导航"}
|
|
|
|
|
position="right"
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onCollapse}
|
|
|
|
|
aria-label={collapsed ? "展开导航" : "收起导航"}
|
|
|
|
|
>
|
|
|
|
|
<IconSidebar />
|
|
|
|
|
{!collapsed && <span>收起导航</span>}
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
<Modal
|
|
|
|
|
title="快捷导航"
|
|
|
|
|
visible={commandOpen}
|
|
|
|
|
footer={null}
|
|
|
|
|
onCancel={() => setCommandOpen(false)}
|
|
|
|
|
width={420}
|
|
|
|
|
>
|
|
|
|
|
<div className="command-items">
|
|
|
|
|
{navigation.map(({ id, label, icon: Icon }) => (
|
|
|
|
|
<button key={id} onClick={() => navigate(id)}>
|
|
|
|
|
<Icon />
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|