Files
opsxapi/packages/ui/src/components/ActivityRail.tsx
T
logan 39f4d5addc 桌面应用图标替换为 B-brackets 设计(深藏青 + 白/蓝花括号)
- icon.svg 取自 opsx-icon-options/api/B-brackets.svg,全套光栅重生成:
  icon.icns(iconutil)、icon.ico(PNG-in-ICO 6 尺寸)、16-1024 PNG、Windows Square*/StoreLogo
- 替换 Tauri 脚手架默认五角星图标;GUI 重启实测 Dock 图标已生效
- 服务全量重启:server 8787(官网/文档 200)+ web 5173 + 桌面 GUI
2026-09-20 00:14:32 +08:00

247 lines
8.4 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.
/**
* 最左活动栏:主导航 + 底部账号。
* 本地/团队工作区只在底部登录/头像菜单切换(设计师线框 B);不再显示顶部「本」字标。
*/
import { useState } from "react";
import { useWorkspace } from "../workspaceStore";
import { ContextMenu, cx, type MenuItem } from "./common";
import { Icon, type IconName } from "./icons";
export function ActivityRail() {
const view = useWorkspace((s) => s.view);
const setView = useWorkspace((s) => s.setView);
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 [accountMenu, setAccountMenu] = useState<{ x: number; y: number } | null>(null);
const activeSpace = account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null;
const isTeam = activeSpace?.teamId != null;
const spaceName = activeSpace
? `${isTeam ? "团队 · " : ""}${activeSpace.name}`
: "本地工作区";
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 workspaceItems: MenuItem[] = [
{
label: (
<span className="flex min-w-0 items-center gap-2">
<span className="rail-ws-dot local" aria-hidden />
<span className="truncate">本地工作区</span>
{account.activeSpaceId === null && (
<span className="ml-auto pl-3 text-[11px] ops-success">● 当前</span>
)}
</span>
),
onClick: () => void switchSpace(null),
},
...account.spaces.map((sp) => {
const team = sp.teamId != null;
return {
label: (
<span className="flex min-w-0 items-center gap-2">
<span className={cx("rail-ws-dot", team ? "team" : "local")} aria-hidden />
<span className="truncate">{team ? `团队 · ${sp.name}` : sp.name}</span>
{sp.id === account.activeSpaceId ? (
<span className="ml-auto pl-3 text-[11px] ops-success">● 当前</span>
) : (
<span className="ops-faint ml-auto pl-3 text-[11px]">
{new Date(sp.updatedAt * 1000).toLocaleDateString("zh-CN")}
</span>
)}
</span>
),
onClick: () => void switchSpace(sp.id),
};
}),
];
const accountItems: MenuItem[] = account.user
? [
...workspaceItems,
{ type: "sep" as const },
{
label: "环境变量",
icon: <Icon name="settings" size={14} />,
onClick: () => setDialog({ type: "environments" }),
},
{
label: "账号设置",
icon: <Icon name="settings" size={14} />,
onClick: () => setDialog({ type: "settings" }),
},
...(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 },
{
label: "本地工作区",
icon: <Icon name="shield-check" size={14} />,
onClick: () => void switchSpace(null),
},
{
label: "环境变量",
icon: <Icon name="settings" size={14} />,
onClick: () => setDialog({ type: "environments" }),
},
{ type: "sep" as const },
siteItem,
];
const navs: { id: string; icon: IconName; label: string; active: boolean; onClick: () => void; soon?: boolean }[] = [
{
id: "api",
icon: "globe",
label: "接口",
active: view === "api",
onClick: () => {
setView("api");
setSidebarSide("api");
},
},
{
id: "testing",
icon: "list-checks",
label: "测试",
active: view === "testing",
onClick: () => setView("testing"),
soon: true,
},
];
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)" }}
>
{/* 主导航:不再放顶部「本」字标 / 空间切换 */}
{navs.map((n) => (
<button
key={n.id}
className={cx("rail-item", n.active && "active", n.soon && "opacity-35 grayscale")}
title={n.soon ? `${n.label} · 即将推出` : n.label}
onClick={n.onClick}
>
<Icon name={n.icon} size={17} />
<span className="text-[11px]">{n.label}</span>
</button>
))}
<button
className="rail-item opacity-35 grayscale"
title="Mock · 即将推出"
onClick={() => setDialog({ type: "settings", tab: "mock" })}
>
<Icon name="flask-conical" size={17} />
<span className="text-[11px]">Mock</span>
</button>
<div className="flex-1" />
<a className="rail-item opacity-35 grayscale" href="/docs" target="_blank" rel="noopener noreferrer" title="文档 · 即将推出">
<Icon name="book-open" size={16} />
<span className="text-[11px]">文档</span>
</a>
<button className="rail-item" title="设置" onClick={() => setDialog({ type: "settings" })}>
<Icon name="settings" size={16} />
<span className="text-[11px]">设置</span>
</button>
<button
className={cx("rail-item", !account.user && "rail-login")}
title={syncTitle}
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setAccountMenu((m) => (m ? null : { x: rect.right + 4, y: Math.max(8, rect.bottom - 240) }));
}}
>
<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 ? (
<span
className={cx(
"rail-ws-badge",
account.activeSpaceId === null ? "local" : isTeam ? "team" : "cloud",
)}
title={spaceName}
/>
) : null}
{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-[11px]">{account.user ? account.user.name : "登录"}</span>
</button>
<ContextMenu
menu={accountMenu ? { x: accountMenu.x, y: accountMenu.y, items: accountItems } : null}
onClose={() => setAccountMenu(null)}
/>
</nav>
);
}