Files
opsxapi/packages/ui/src/components/TopBar.tsx
T
logan e77b76986e 顶栏固定单行 + 侧栏信息架构重构(接口/历史分段切换)
- 修复窄窗口下顶栏按钮被压缩导致中文逐字竖排:按钮 whitespace-nowrap + shrink-0,
  header flex-nowrap + 横向滚动兜底;协议徽标 xl 以下隐藏;实测 header 恒定 48px、零换行按钮
- 侧栏重构:历史上移——搜索框下「接口 | 历史(计数)」分段切换(历史为高频最近使用),
  历史视图含清空按钮;底部只留跨视图导航(自动化测试)+ 外链(官网与价格),职责单一
- 分段控件样式(panel-3 底、active 白底浮起);复验截图评审通过
2026-09-18 13:01:27 +08:00

336 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 顶栏:logo / 新建 / 导入导出 / 环境 / Mock / 账号 / 主题 / 文档 / 设置。
*/
import { useEffect, 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";
const NEW_ITEMS: { kind: string; label: string; icon: IconName }[] = [
{ kind: "http", label: "HTTP 请求", icon: "globe" },
{ kind: "graphql", label: "GraphQL", icon: "hexagon" },
{ kind: "websocket", label: "WebSocket", icon: "zap" },
{ kind: "sse", label: "SSE", icon: "radio" },
{ kind: "socketio", label: "Socket.IO", icon: "plug-zap" },
{ kind: "mqtt", label: "MQTT", icon: "mailbox" },
];
export function TopBar() {
const collections = useWorkspace((s) => s.collections);
const environments = useWorkspace((s) => s.environments);
const activeEnvironmentId = useWorkspace((s) => s.activeEnvironmentId);
const settings = useWorkspace((s) => s.settings);
const setActiveEnvironment = useWorkspace((s) => s.setActiveEnvironment);
const setTheme = useWorkspace((s) => s.setTheme);
const setDialog = useWorkspace((s) => s.setDialog);
const addRequest = useWorkspace((s) => s.addRequest);
const addFolder = useWorkspace((s) => s.addFolder);
const [newMenu, setNewMenu] = useState<{ x: number; y: number } | null>(null);
const [exportMenu, setExportMenu] = useState<{ x: number; y: number } | null>(null);
const [spaceMenu, setSpaceMenu] = useState<{ x: number; y: number } | null>(null);
const [accountMenu, setAccountMenu] = useState<{ x: number; y: number } | null>(null);
const account = useWorkspace((s) => s.account);
const switchSpace = useWorkspace((s) => s.switchSpace);
const syncNow = useWorkspace((s) => s.syncNow);
useEffect(() => {
document.title = "OpsxAPI · 接口工作台";
}, []);
const firstCollection = collections[0];
const quickCreate = (kind: string) => {
if (!firstCollection) return;
if (kind === "folder") addFolder(firstCollection.id, null);
else addRequest(firstCollection.id, null, kind as never);
};
const newItems: MenuItem[] = [
...NEW_ITEMS.map((i) => ({
label: i.label,
icon: <Icon name={i.icon} size={14} />,
onClick: () => quickCreate(i.kind),
})),
{ type: "sep" as const },
{ label: "新建目录", icon: <Icon name="folder-plus" size={14} />, onClick: () => quickCreate("folder") },
{ type: "sep" as const },
{ label: "选择位置新建…", icon: <Icon name="library" size={14} />, onClick: () => setDialog({ type: "new", collectionId: firstCollection?.id ?? "", parentId: null }) },
];
const activeEnv = environments.find((e) => e.id === activeEnvironmentId);
const [envMenu, setEnvMenu] = useState<{ x: number; y: number } | null>(null);
const envItems: MenuItem[] = [
...environments.map((env) => ({
label: (
<span className="flex min-w-0 items-center gap-2">
<EnvBadge envId={env.id} name={env.name} size={20} />
<span className="truncate">{env.name}</span>
{env.id === activeEnvironmentId ? (
<span className="ml-auto pl-3 text-[10px] ops-success">● 当前</span>
) : (
<span className="ops-faint ml-auto pl-3 text-[10px]">
{env.variables.filter((v) => v.enabled && v.key).length} 变量
</span>
)}
</span>
),
onClick: () => setActiveEnvironment(env.id),
})),
{ type: "sep" as const },
{
label: "不使用环境",
icon: <Icon name="circle-slash" size={14} />,
onClick: () => setActiveEnvironment(null),
},
{
label: "管理环境…",
icon: <Icon name="settings" size={14} />,
onClick: () => setDialog({ type: "environments" }),
},
];
const activeSpace =
account.activeSpaceId === null ? null : 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),
})),
];
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(),
},
]
: []),
{ type: "sep" as const },
{
label: "团队管理…",
icon: <Icon name="library" size={14} />,
onClick: () => setDialog({ type: "team" }),
},
{
label: "退出登录",
icon: <Icon name="circle-x" size={14} />,
onClick: () => void useWorkspace.getState().authLogout(),
},
]
: [];
return (
<header
className="ops-panel ops-divider flex h-12 shrink-0 flex-nowrap items-center justify-between gap-3 overflow-x-auto px-4"
style={{ zIndex: 30 }}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Logo size={26} />
<span className="text-[14px] font-bold tracking-tight">OpsxAPI</span>
</div>
<span className="ops-chip hidden whitespace-nowrap xl:inline-flex" style={{ padding: "1px 8px", fontSize: 10 }}>
HTTP · GraphQL · WS · SSE · Socket.IO · MQTT
</span>
</div>
<div className="flex items-center gap-2">
<button
className="ops-btn ops-btn-primary"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setNewMenu({ x: rect.left, y: rect.bottom + 4 });
}}
disabled={!firstCollection}
>
<Icon name="plus" size={14} /> 新建
</button>
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog({ type: "import" })}>
<Icon name="download" size={14} /> 导入
</button>
<button
className="ops-btn ops-btn-secondary"
onClick={(e) => {
if (collections.length === 1) {
setDialog({ type: "export", collectionId: collections[0].id });
return;
}
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setExportMenu({ x: rect.left, y: rect.bottom + 4 });
}}
disabled={collections.length === 0}
>
<Icon name="upload" size={14} /> 导出
</button>
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
<button
className="ops-envswitch"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setEnvMenu((m) => (m ? null : { x: rect.left, y: rect.bottom + 4 }));
}}
title="切换环境"
>
{activeEnv ? (
<>
<EnvBadge envId={activeEnv.id} name={activeEnv.name} size={18} />
<span className="max-w-28 truncate">{activeEnv.name}</span>
<span className="ops-faint text-[10px]">
{activeEnv.variables.filter((v) => v.enabled && v.key).length} 变量
</span>
</>
) : (
<span className="ops-faint">不使用环境</span>
)}
<Icon name="chevron-down" size={13} className="ops-faint" />
</button>
<button className="ops-btn ops-btn-ghost" onClick={() => setDialog({ type: "environments" })} title="环境管理">
<Icon name="settings" size={14} /> 环境
</button>
<button
className="ops-btn ops-btn-ghost"
onClick={() => setDialog({ type: "settings", tab: "mock" })}
title="Mock 数据:@表达式 + 智能匹配规则 + 预览"
>
<Icon name="flask-conical" size={15} />
</button>
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
{account.user ? (
<>
<button
className="ops-envswitch"
title={`当前空间:${spaceName}\n点击切换本地 / 个人 / 团队空间`}
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setSpaceMenu((m) => (m ? null : { x: rect.left, y: rect.bottom + 4 }));
}}
>
<span className="ops-user-avatar">{account.user.name.slice(0, 1).toUpperCase()}</span>
<span className="max-w-36 truncate">{spaceName}</span>
{account.syncing ? (
<span className="ops-faint text-[10px]">同步中…</span>
) : account.activeSpaceId !== null ? (
<span className={cx("text-[10px]", account.syncError ? "ops-danger" : "ops-success")} title={account.syncError ?? undefined}>
{account.syncError ? "同步失败" : "已同步"}
</span>
) : null}
<Icon name="chevron-down" size={13} className="ops-faint" />
</button>
<button
className="ops-btn ops-btn-ghost !px-2"
title={`${account.user.name}(${account.user.email})`}
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setAccountMenu({ x: rect.right, y: rect.bottom + 4 });
}}
>
<Icon name="ellipsis" size={14} />
</button>
</>
) : (
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog({ type: "auth" })} title="登录 / 注册,开启云端空间">
<Icon name="shield-check" size={13} /> 登录
</button>
)}
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
<button
className="ops-btn ops-btn-ghost"
onClick={() => setTheme(settings.theme === "dark" ? "light" : "dark")}
title="切换主题"
>
<Icon name={settings.theme === "dark" ? "moon" : "sun"} size={15} />
</button>
<a
className="ops-btn ops-btn-ghost"
href="/docs"
target="_blank"
rel="noopener noreferrer"
title="用户文档"
>
<Icon name="book-open" size={14} /> 文档
</a>
<button
className="ops-btn ops-btn-ghost"
onClick={() => setDialog({ type: "settings" })}
title="设置"
>
<Icon name="settings" size={15} />
</button>
</div>
<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)}
/>
<ContextMenu
menu={newMenu ? { x: newMenu.x, y: newMenu.y, items: newItems } : null}
onClose={() => setNewMenu(null)}
/>
<ContextMenu
menu={envMenu ? { x: envMenu.x, y: envMenu.y, items: envItems } : null}
onClose={() => setEnvMenu(null)}
/>
<ContextMenu
menu={
exportMenu
? {
x: exportMenu.x,
y: exportMenu.y,
items: collections.map((c) => ({
label: c.name,
onClick: () => setDialog({ type: "export", collectionId: c.id }),
})),
}
: null
}
onClose={() => setExportMenu(null)}
/>
</header>
);
}