导航重构:Apifox 式左侧活动栏(空间切换/导航/账号),顶栏瘦身
- 新增 ActivityRail:顶部空间切换(本地/个人/团队组织菜单)、接口/历史/测试/Mock 导航、底部文档/主题/设置/账号(含云端同步状态点) - TopBar 仅保留新建/导入/导出 + 环境切换,账号/主题/文档/设置全部移入活动栏 - Sidebar 接口|历史 分段切换改由 store sidebarSide 驱动,与活动栏联动;移除底部导航区 - 900px 窄屏顶栏固定 48px 单行验证通过;53 项测试全绿
This commit is contained in:
1 parent
e77b76986e
commit
9566a5336c
6 files changed
+327
-184
No files matched your search
@@ -514,6 +514,48 @@
|
|||||||
}
|
}
|
||||||
.side-seg-item .ops-count { margin-left: 2px; }
|
.side-seg-item .ops-count { margin-left: 2px; }
|
||||||
|
|
||||||
|
/* ---------- 左侧活动栏(空间切换 / 导航 / 账号) ---------- */
|
||||||
|
.rail-space {
|
||||||
|
@apply flex flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 transition;
|
||||||
|
}
|
||||||
|
.rail-space:hover { background: var(--panel-2); }
|
||||||
|
.rail-item {
|
||||||
|
@apply relative flex w-12 flex-col items-center gap-0.5 rounded-lg py-1.5 transition whitespace-nowrap;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.rail-item:hover { background: var(--panel-2); color: var(--text); }
|
||||||
|
.rail-item.active {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
.rail-item.active::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -4px;
|
||||||
|
top: 20%;
|
||||||
|
height: 60%;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.rail-item.rail-login {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
.rail-sync-dot {
|
||||||
|
position: absolute;
|
||||||
|
right: -5px;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid var(--panel);
|
||||||
|
}
|
||||||
|
.rail-sync-dot.ok { background: var(--success); }
|
||||||
|
.rail-sync-dot.error { background: var(--danger); }
|
||||||
|
.rail-sync-dot.syncing { background: var(--accent); animation: rail-pulse 1s ease-in-out infinite; }
|
||||||
|
@keyframes rail-pulse { 50% { opacity: 0.35; } }
|
||||||
|
|
||||||
/* ---------- 方法 chip 着色 ---------- */
|
/* ---------- 方法 chip 着色 ---------- */
|
||||||
.ops-chip.ops-method-GET { background: color-mix(in srgb, var(--m-GET) 14%, transparent); color: var(--m-GET); }
|
.ops-chip.ops-method-GET { background: color-mix(in srgb, var(--m-GET) 14%, transparent); color: var(--m-GET); }
|
||||||
.ops-chip.ops-method-POST { background: color-mix(in srgb, var(--m-POST) 16%, transparent); color: var(--m-POST); }
|
.ops-chip.ops-method-POST { background: color-mix(in srgb, var(--m-POST) 16%, transparent); color: var(--m-POST); }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { closeSession } from "./send";
|
|||||||
import { Icon } from "./components/icons";
|
import { Icon } from "./components/icons";
|
||||||
import { Logo } from "./components/Logo";
|
import { Logo } from "./components/Logo";
|
||||||
import { useWorkspace } from "./workspaceStore";
|
import { useWorkspace } from "./workspaceStore";
|
||||||
|
import { ActivityRail } from "./components/ActivityRail";
|
||||||
import { Sidebar } from "./components/Sidebar";
|
import { Sidebar } from "./components/Sidebar";
|
||||||
import { TestingView } from "./components/TestingView";
|
import { TestingView } from "./components/TestingView";
|
||||||
import { startScheduler, stopScheduler } from "./scheduler";
|
import { startScheduler, stopScheduler } from "./scheduler";
|
||||||
@@ -62,8 +63,12 @@ export function OpsxWorkspace() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen min-h-[640px] flex-col overflow-hidden">
|
<div className="flex h-screen min-h-[640px] flex-col overflow-hidden">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<ActivityRail />
|
||||||
{view === "testing" ? (
|
{view === "testing" ? (
|
||||||
<TestingView />
|
<div className="flex min-h-0 min-w-0 flex-1">
|
||||||
|
<TestingView />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -89,6 +94,7 @@ export function OpsxWorkspace() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{dialog?.type === "new" && <NewItemDialog collectionId={dialog.collectionId} parentId={dialog.parentId} />}
|
{dialog?.type === "new" && <NewItemDialog collectionId={dialog.collectionId} parentId={dialog.parentId} />}
|
||||||
{dialog?.type === "import" && <ImportDialog />}
|
{dialog?.type === "import" && <ImportDialog />}
|
||||||
{dialog?.type === "export" && <ExportDialog collectionId={dialog.collectionId} />}
|
{dialog?.type === "export" && <ExportDialog collectionId={dialog.collectionId} />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ export function Sidebar() {
|
|||||||
const history = useWorkspace((s) => s.history);
|
const history = useWorkspace((s) => s.history);
|
||||||
const clearHistory = useWorkspace((s) => s.clearHistory);
|
const clearHistory = useWorkspace((s) => s.clearHistory);
|
||||||
const openTab = useWorkspace((s) => s.openTab);
|
const openTab = useWorkspace((s) => s.openTab);
|
||||||
const [side, setSide] = useState<"api" | "history">("api");
|
// 由活动栏驱动:接口树 / 历史记录 两个展示面共用本侧栏
|
||||||
|
const side = useWorkspace((s) => s.sidebarSide);
|
||||||
|
const setSide = useWorkspace((s) => s.setSidebarSide);
|
||||||
const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
|
const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
|
||||||
|
|
||||||
const hitIds = useMemo(() => searchTree(collections, search), [collections, search]);
|
const hitIds = useMemo(() => searchTree(collections, search), [collections, search]);
|
||||||
@@ -33,12 +35,14 @@ export function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<aside className="ops-panel flex w-64 shrink-0 flex-col" style={{ borderRight: "1px solid var(--border)" }}>
|
<aside className="ops-panel flex w-64 shrink-0 flex-col" style={{ borderRight: "1px solid var(--border)" }}>
|
||||||
<div className="space-y-2 p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
<div className="space-y-2 p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
<input
|
{side === "api" && (
|
||||||
className="ops-input ops-input-sm"
|
<input
|
||||||
placeholder="搜索接口 / 目录…"
|
className="ops-input ops-input-sm"
|
||||||
value={search}
|
placeholder="搜索接口 / 目录…"
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
value={search}
|
||||||
/>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="side-seg">
|
<div className="side-seg">
|
||||||
<button className={cx("side-seg-item", side === "api" && "active")} onClick={() => setSide("api")}>
|
<button className={cx("side-seg-item", side === "api" && "active")} onClick={() => setSide("api")}>
|
||||||
<Icon name="library" size={12} /> 接口
|
<Icon name="library" size={12} /> 接口
|
||||||
@@ -122,26 +126,6 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部:跨视图导航 + 外链 */}
|
|
||||||
<div className="space-y-1.5 p-2" style={{ borderTop: "1px solid var(--border)" }}>
|
|
||||||
<button
|
|
||||||
className="ops-btn ops-btn-secondary w-full"
|
|
||||||
onClick={() => useWorkspace.getState().setView("testing")}
|
|
||||||
title="场景用例 · AI 生成 · 定时任务"
|
|
||||||
>
|
|
||||||
<Icon name="list-checks" size={13} /> 自动化测试
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
className="ops-link flex items-center justify-center gap-1 py-1 text-[11px]"
|
|
||||||
href="/home"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title="官网与价格"
|
|
||||||
>
|
|
||||||
<Icon name="globe" size={11} /> 官网与价格
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ContextMenu menu={menu} onClose={() => setMenu(null)} />
|
<ContextMenu menu={menu} onClose={() => setMenu(null)} />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 顶栏:logo / 新建 / 导入导出 / 环境 / Mock / 账号 / 主题 / 文档 / 设置。
|
* 顶栏(瘦身版):品牌 + 新建/导入/导出 + 环境切换。
|
||||||
|
* 空间切换、导航、账号、主题、文档、设置均在左侧活动栏(ActivityRail)。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useWorkspace } from "../workspaceStore";
|
import { useWorkspace } from "../workspaceStore";
|
||||||
import { ContextMenu, cx, type MenuItem } from "./common";
|
import { ContextMenu, type MenuItem } from "./common";
|
||||||
import { EnvBadge } from "./EnvBadge";
|
import { EnvBadge } from "./EnvBadge";
|
||||||
import { Icon, type IconName } from "./icons";
|
import { Icon, type IconName } from "./icons";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
@@ -21,20 +22,14 @@ export function TopBar() {
|
|||||||
const collections = useWorkspace((s) => s.collections);
|
const collections = useWorkspace((s) => s.collections);
|
||||||
const environments = useWorkspace((s) => s.environments);
|
const environments = useWorkspace((s) => s.environments);
|
||||||
const activeEnvironmentId = useWorkspace((s) => s.activeEnvironmentId);
|
const activeEnvironmentId = useWorkspace((s) => s.activeEnvironmentId);
|
||||||
const settings = useWorkspace((s) => s.settings);
|
|
||||||
const setActiveEnvironment = useWorkspace((s) => s.setActiveEnvironment);
|
const setActiveEnvironment = useWorkspace((s) => s.setActiveEnvironment);
|
||||||
const setTheme = useWorkspace((s) => s.setTheme);
|
|
||||||
const setDialog = useWorkspace((s) => s.setDialog);
|
const setDialog = useWorkspace((s) => s.setDialog);
|
||||||
const addRequest = useWorkspace((s) => s.addRequest);
|
const addRequest = useWorkspace((s) => s.addRequest);
|
||||||
const addFolder = useWorkspace((s) => s.addFolder);
|
const addFolder = useWorkspace((s) => s.addFolder);
|
||||||
|
|
||||||
const [newMenu, setNewMenu] = useState<{ x: number; y: number } | null>(null);
|
const [newMenu, setNewMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [exportMenu, setExportMenu] = 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 [envMenu, setEnvMenu] = 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(() => {
|
useEffect(() => {
|
||||||
document.title = "OpsxAPI · 接口工作台";
|
document.title = "OpsxAPI · 接口工作台";
|
||||||
@@ -62,7 +57,6 @@ export function TopBar() {
|
|||||||
|
|
||||||
const activeEnv = environments.find((e) => e.id === activeEnvironmentId);
|
const activeEnv = environments.find((e) => e.id === activeEnvironmentId);
|
||||||
|
|
||||||
const [envMenu, setEnvMenu] = useState<{ x: number; y: number } | null>(null);
|
|
||||||
const envItems: MenuItem[] = [
|
const envItems: MenuItem[] = [
|
||||||
...environments.map((env) => ({
|
...environments.map((env) => ({
|
||||||
label: (
|
label: (
|
||||||
@@ -93,69 +87,9 @@ export function TopBar() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<header
|
<header
|
||||||
className="ops-panel ops-divider flex h-12 shrink-0 flex-nowrap items-center justify-between gap-3 overflow-x-auto px-4"
|
className="ops-panel ops-divider flex h-12 shrink-0 flex-nowrap items-center justify-between gap-3 px-4"
|
||||||
style={{ zIndex: 30 }}
|
style={{ zIndex: 30 }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -220,93 +154,8 @@ export function TopBar() {
|
|||||||
)}
|
)}
|
||||||
<Icon name="chevron-down" size={13} className="ops-faint" />
|
<Icon name="chevron-down" size={13} className="ops-faint" />
|
||||||
</button>
|
</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>
|
</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
|
<ContextMenu
|
||||||
menu={newMenu ? { x: newMenu.x, y: newMenu.y, items: newItems } : null}
|
menu={newMenu ? { x: newMenu.x, y: newMenu.y, items: newItems } : null}
|
||||||
onClose={() => setNewMenu(null)}
|
onClose={() => setNewMenu(null)}
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ type WorkspaceStore = PersistedShape & {
|
|||||||
runtime: Record<string, RequestRuntime>;
|
runtime: Record<string, RequestRuntime>;
|
||||||
/** 主视图:接口工作台 / 自动化测试 */
|
/** 主视图:接口工作台 / 自动化测试 */
|
||||||
view: "api" | "testing";
|
view: "api" | "testing";
|
||||||
|
/** 接口视图下侧栏的展示面:集合树 / 历史记录(由活动栏驱动) */
|
||||||
|
sidebarSide: "api" | "history";
|
||||||
|
|
||||||
/** 账号与云端空间(null 空间 = 本地) */
|
/** 账号与云端空间(null 空间 = 本地) */
|
||||||
account: {
|
account: {
|
||||||
@@ -142,6 +144,7 @@ type WorkspaceStore = PersistedShape & {
|
|||||||
|
|
||||||
init(): void;
|
init(): void;
|
||||||
setView(view: "api" | "testing"): void;
|
setView(view: "api" | "testing"): void;
|
||||||
|
setSidebarSide(side: "api" | "history"): void;
|
||||||
authLogin(email: string, password: string): Promise<void>;
|
authLogin(email: string, password: string): Promise<void>;
|
||||||
authRegister(name: string, email: string, password: string): Promise<void>;
|
authRegister(name: string, email: string, password: string): Promise<void>;
|
||||||
authLogout(): Promise<void>;
|
authLogout(): Promise<void>;
|
||||||
@@ -407,6 +410,7 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
|||||||
testRuns: [],
|
testRuns: [],
|
||||||
schedules: [],
|
schedules: [],
|
||||||
view: "api",
|
view: "api",
|
||||||
|
sidebarSide: "api",
|
||||||
account: {
|
account: {
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
@@ -729,6 +733,10 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
|||||||
set({ view });
|
set({ view });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSidebarSide(sidebarSide) {
|
||||||
|
set({ sidebarSide });
|
||||||
|
},
|
||||||
|
|
||||||
setEnvValues(values) {
|
setEnvValues(values) {
|
||||||
const envId = get().activeEnvironmentId;
|
const envId = get().activeEnvironmentId;
|
||||||
if (!envId) return;
|
if (!envId) return;
|
||||||
|
|||||||
Reference in new issue
Block a user