- 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
247 lines
8.4 KiB
TypeScript
247 lines
8.4 KiB
TypeScript
/**
|
||
* 最左活动栏:主导航 + 底部账号。
|
||
* 本地/团队工作区只在底部登录/头像菜单切换(设计师线框 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>
|
||
);
|
||
}
|