diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index d445bf8..efa863b 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -514,6 +514,48 @@
}
.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 着色 ---------- */
.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); }
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index 88d6aec..b13442d 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -7,6 +7,7 @@ import { closeSession } from "./send";
import { Icon } from "./components/icons";
import { Logo } from "./components/Logo";
import { useWorkspace } from "./workspaceStore";
+import { ActivityRail } from "./components/ActivityRail";
import { Sidebar } from "./components/Sidebar";
import { TestingView } from "./components/TestingView";
import { startScheduler, stopScheduler } from "./scheduler";
@@ -62,8 +63,12 @@ export function OpsxWorkspace() {
return (
+
+
{view === "testing" ? (
-
+
+
+
) : (
@@ -89,6 +94,7 @@ export function OpsxWorkspace() {
)}
+
{dialog?.type === "new" &&
}
{dialog?.type === "import" &&
}
{dialog?.type === "export" &&
}
diff --git a/packages/ui/src/components/ActivityRail.tsx b/packages/ui/src/components/ActivityRail.tsx
new file mode 100644
index 0000000..633281d
--- /dev/null
+++ b/packages/ui/src/components/ActivityRail.tsx
@@ -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: (
+
+
+ 本地空间
+ {account.activeSpaceId === null && ● 当前}
+
+ ),
+ onClick: () => void switchSpace(null),
+ },
+ ...account.spaces.map((sp) => ({
+ label: (
+
+
+ {sp.teamId !== null ? `团队 · ${sp.name}` : sp.name}
+ {sp.id === account.activeSpaceId ? (
+ ● 当前
+ ) : (
+
+ {new Date(sp.updatedAt * 1000).toLocaleDateString("zh-CN")}
+
+ )}
+
+ ),
+ onClick: () => void switchSpace(sp.id),
+ })),
+ { type: "sep" as const },
+ {
+ label: "团队管理…",
+ icon:
,
+ onClick: () => setDialog({ type: "team" }),
+ },
+ ];
+
+ const siteItem: MenuItem = {
+ label: (
+
+ 官网与价格
+
+
+ ),
+ icon:
,
+ 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:
,
+ onClick: () => void syncNow(),
+ },
+ ]
+ : []),
+ {
+ label: "团队管理…",
+ icon:
,
+ onClick: () => setDialog({ type: "team" }),
+ },
+ { type: "sep" as const },
+ siteItem,
+ {
+ label: "退出登录",
+ icon:
,
+ onClick: () => void authLogout(),
+ },
+ ]
+ : [
+ {
+ label: "登录 / 注册",
+ icon:
,
+ 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 (
+
+ );
+}
diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx
index d76681a..62b8c85 100644
--- a/packages/ui/src/components/Sidebar.tsx
+++ b/packages/ui/src/components/Sidebar.tsx
@@ -25,7 +25,9 @@ export function Sidebar() {
const history = useWorkspace((s) => s.history);
const clearHistory = useWorkspace((s) => s.clearHistory);
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 hitIds = useMemo(() => searchTree(collections, search), [collections, search]);
@@ -33,12 +35,14 @@ export function Sidebar() {
return (