导航重构:Apifox 式左侧活动栏(空间切换/导航/账号),顶栏瘦身

- 新增 ActivityRail:顶部空间切换(本地/个人/团队组织菜单)、接口/历史/测试/Mock 导航、底部文档/主题/设置/账号(含云端同步状态点)
- TopBar 仅保留新建/导入/导出 + 环境切换,账号/主题/文档/设置全部移入活动栏
- Sidebar 接口|历史 分段切换改由 store sidebarSide 驱动,与活动栏联动;移除底部导航区
- 900px 窄屏顶栏固定 48px 单行验证通过;53 项测试全绿
This commit is contained in:
logan committed 2026-09-18 13:20:37 +08:00
1 parent e77b76986e
commit 9566a5336c
6 files changed
+327 -184

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
/**
* 最左活动栏(借鉴 Apifox):空间切换(本地/个人/团队)+ 主导航 + 底部账号与工具。
* 顶栏因此只保留与当前编辑相关的操作。
*/
import { useState } from "react";
import { useWorkspace } from "../workspaceStore";
import { ContextMenu, cx, type MenuItem } from "./common";
import { EnvBadge } from "./EnvBadge";
import { Icon, type IconName } from "./icons";
import { Logo } from "./Logo";
export function ActivityRail() {
const view = useWorkspace((s) => s.view);
const setView = useWorkspace((s) => s.setView);
const sidebarSide = useWorkspace((s) => s.sidebarSide);
const setSidebarSide = useWorkspace((s) => s.setSidebarSide);
const account = useWorkspace((s) => s.account);
const switchSpace = useWorkspace((s) => s.switchSpace);
const authLogout = useWorkspace((s) => s.authLogout);
const syncNow = useWorkspace((s) => s.syncNow);
const setDialog = useWorkspace((s) => s.setDialog);
const theme = useWorkspace((s) => s.settings.theme);
const setTheme = useWorkspace((s) => s.setTheme);
const [spaceMenu, setSpaceMenu] = useState<{ x: number; y: number } | null>(null);
const [accountMenu, setAccountMenu] = useState<{ x: number; y: number } | null>(null);
const activeSpace = account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null;
const spaceName = activeSpace ? `${activeSpace.teamId !== null ? "团队 · " : ""}${activeSpace.name}` : "本地空间";
const spaceItems: MenuItem[] = [
{
label: (
<span className="flex min-w-0 items-center gap-2">
<Icon name="shield-check" size={13} className="ops-faint" />
<span className="truncate">本地空间</span>
{account.activeSpaceId === null && <span className="ml-auto pl-3 text-[10px] ops-success">● 当前</span>}
</span>
),
onClick: () => void switchSpace(null),
},
...account.spaces.map((sp) => ({
label: (
<span className="flex min-w-0 items-center gap-2">
<Icon name={sp.teamId !== null ? "library" : "shield-check"} size={13} className="ops-faint" />
<span className="truncate">{sp.teamId !== null ? `团队 · ${sp.name}` : sp.name}</span>
{sp.id === account.activeSpaceId ? (
<span className="ml-auto pl-3 text-[10px] ops-success">● 当前</span>
) : (
<span className="ops-faint ml-auto pl-3 text-[10px]">
{new Date(sp.updatedAt * 1000).toLocaleDateString("zh-CN")}
</span>
)}
</span>
),
onClick: () => void switchSpace(sp.id),
})),
{ type: "sep" as const },
{
label: "团队管理…",
icon: <Icon name="library" size={14} />,
onClick: () => setDialog({ type: "team" }),
},
];
const siteItem: MenuItem = {
label: (
<span className="flex items-center gap-2">
官网与价格
<Icon name="external-link" size={11} className="ops-faint" />
</span>
),
icon: <Icon name="globe" size={14} />,
onClick: () => window.open("/home", "_blank", "noopener,noreferrer"),
};
const accountItems: MenuItem[] = account.user
? [
...(account.activeSpaceId !== null
? [
{
label: account.syncing
? "同步中…"
: account.lastSyncAt
? `已同步 ${new Date(account.lastSyncAt).toLocaleTimeString("zh-CN")}`
: "立即同步到云端",
icon: <Icon name="arrow-left-right" size={14} />,
onClick: () => void syncNow(),
},
]
: []),
{
label: "团队管理…",
icon: <Icon name="library" size={14} />,
onClick: () => setDialog({ type: "team" }),
},
{ type: "sep" as const },
siteItem,
{
label: "退出登录",
icon: <Icon name="circle-x" size={14} />,
onClick: () => void authLogout(),
},
]
: [
{
label: "登录 / 注册",
icon: <Icon name="shield-check" size={14} />,
onClick: () => setDialog({ type: "auth" }),
},
{ type: "sep" as const },
siteItem,
];
const navs: { id: string; icon: IconName; label: string; active: boolean; onClick: () => void }[] = [
{
id: "api",
icon: "globe",
label: "接口",
active: view === "api" && sidebarSide === "api",
onClick: () => {
setView("api");
setSidebarSide("api");
},
},
{
id: "history",
icon: "clock",
label: "历史",
active: view === "api" && sidebarSide === "history",
onClick: () => {
setView("api");
setSidebarSide("history");
},
},
{
id: "testing",
icon: "list-checks",
label: "测试",
active: view === "testing",
onClick: () => setView("testing"),
},
];
const syncTitle = !account.user
? "登录 / 注册,开启云端个人与团队空间"
: `${account.user.name}(${account.user.email})\n当前空间:${spaceName}${
account.syncing
? "\n同步中…"
: account.syncError
? `\n同步失败:${account.syncError}`
: account.lastSyncAt && account.activeSpaceId !== null
? `\n已同步 ${new Date(account.lastSyncAt).toLocaleTimeString("zh-CN")}`
: ""
}`;
return (
<nav
className="ops-panel flex w-14 shrink-0 flex-col items-center gap-1 py-2"
style={{ borderRight: "1px solid var(--border)" }}
>
{/* 空间切换(本地 / 个人 / 团队组织) */}
<button
className="rail-space"
title={`当前空间:${spaceName}\n点击切换本地 / 个人 / 团队`}
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setSpaceMenu((m) => (m ? null : { x: rect.right + 4, y: rect.top }));
}}
>
{activeSpace ? <EnvBadge envId={String(activeSpace.id)} name={spaceName} size={26} /> : <Logo size={26} />}
<Icon name="chevron-down" size={10} className="ops-faint" />
</button>
<div className="my-1 h-px w-8" style={{ background: "var(--border)" }} />
{/* 主导航 */}
{navs.map((n) => (
<button key={n.id} className={cx("rail-item", n.active && "active")} title={n.label} onClick={n.onClick}>
<Icon name={n.icon} size={17} />
<span className="text-[10px]">{n.label}</span>
</button>
))}
<button
className="rail-item"
title="Mock 数据:@表达式 + 智能匹配规则"
onClick={() => setDialog({ type: "settings", tab: "mock" })}
>
<Icon name="flask-conical" size={17} />
<span className="text-[10px]">Mock</span>
</button>
<div className="flex-1" />
{/* 底部:文档 / 主题 / 设置 / 账号 */}
<a className="rail-item" href="/docs" target="_blank" rel="noopener noreferrer" title="用户文档">
<Icon name="book-open" size={16} />
<span className="text-[10px]">文档</span>
</a>
<button
className="rail-item"
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<Icon name={theme === "dark" ? "sun" : "moon"} size={16} />
<span className="text-[10px]">主题</span>
</button>
<button className="rail-item" title="设置" onClick={() => setDialog({ type: "settings" })}>
<Icon name="settings" size={16} />
<span className="text-[10px]">设置</span>
</button>
<button
className={cx("rail-item", !account.user && "rail-login")}
title={syncTitle}
onClick={(e) => {
if (!account.user) {
setDialog({ type: "auth" });
return;
}
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setAccountMenu((m) => (m ? null : { x: rect.right + 4, y: rect.bottom + 4 }));
}}
>
<span className="relative">
{account.user ? (
<span className="ops-user-avatar" style={{ width: 22, height: 22, fontSize: 11 }}>
{account.user.name.slice(0, 1).toUpperCase()}
</span>
) : (
<Icon name="shield-check" size={17} />
)}
{account.user && account.activeSpaceId !== null && (
<span
className={cx(
"rail-sync-dot",
account.syncing ? "syncing" : account.syncError ? "error" : "ok",
)}
/>
)}
</span>
<span className="max-w-11 truncate text-[10px]">{account.user ? account.user.name : "登录"}</span>
</button>
<ContextMenu
menu={spaceMenu ? { x: spaceMenu.x, y: spaceMenu.y, items: spaceItems } : null}
onClose={() => setSpaceMenu(null)}
/>
<ContextMenu
menu={accountMenu ? { x: accountMenu.x, y: accountMenu.y, items: accountItems } : null}
onClose={() => setAccountMenu(null)}
/>
</nav>
);
}