- 修复窄窗口下顶栏按钮被压缩导致中文逐字竖排:按钮 whitespace-nowrap + shrink-0, header flex-nowrap + 横向滚动兜底;协议徽标 xl 以下隐藏;实测 header 恒定 48px、零换行按钮 - 侧栏重构:历史上移——搜索框下「接口 | 历史(计数)」分段切换(历史为高频最近使用), 历史视图含清空按钮;底部只留跨视图导航(自动化测试)+ 外链(官网与价格),职责单一 - 分段控件样式(panel-3 底、active 白底浮起);复验截图评审通过
336 lines
13 KiB
TypeScript
336 lines
13 KiB
TypeScript
/**
|
||
* 顶栏: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>
|
||
);
|
||
}
|