Files
opsxapi/src/components/Sidebar.tsx
T
logan 2bb4ef7a95 桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)
- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化)
- 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转
- 设置→外观 主题顺序调整为 深色/浅色/跟随系统
- release 二进制重编并验证(浏览器实测关于页链接可开文档页)
- 附带工作区中未提交的扁平化重构现状(另一会话进行中)
- homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
2026-10-16 03:03:36 +08:00

337 lines
13 KiB
TypeScript

import { replayHistory } from "../send";
/**
* 左侧集合树:搜索 / 目录 / 请求 / 右键菜单 / 拖拽移动 / 历史记录。
*/
import type { Collection, CollectionNode } from "../core/index";
import { findNode, searchTree } from "../core/index";
import { useMemo, useState } from "react";
import { useWorkspace } from "../workspaceStore";
import { ContextMenu, cx, formatTime, type MenuItem } from "./common";
import { Icon } from "./icons";
import { KindIcon } from "./KindIcon";
import { Logo } from "./Logo";
import { WorkspaceSidebar } from "./WorkspaceSidebar";
export function Sidebar() {
const collections = useWorkspace((s) => s.collections);
const search = useWorkspace((s) => s.search);
const setSearch = useWorkspace((s) => s.setSearch);
const history = useWorkspace((s) => s.history);
const clearHistory = useWorkspace((s) => s.clearHistory);
const openTab = useWorkspace((s) => s.openTab);
// 接口树 / 历史在侧栏分段切换;活动栏只保留「接口」入口,避免语义叠层
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]);
return (
<WorkspaceSidebar label="集合">
<div className="space-y-2 p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
{side === "api" && (
<input
className="ops-input ops-input-sm"
placeholder="搜索接口 / 目录…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
)}
<div className="side-seg">
<button className={cx("side-seg-item", side === "api" && "active")} onClick={() => setSide("api")}>
<Icon name="library" size={12} /> 接口
</button>
<button className={cx("side-seg-item", side === "history" && "active")} onClick={() => setSide("history")}>
<Icon name="clock" size={12} /> 历史
{history.length > 0 && <span className="ops-count">{Math.min(history.length, 99)}</span>}
</button>
</div>
</div>
<div className="ops-scroll min-h-0 flex-1 p-2">
{side === "history" ? (
<>
{history.length === 0 && (
<div className="ops-faint px-2 py-8 text-center text-xs leading-5">
暂无请求记录
<br />
发送请求后自动归档
</div>
)}
{history.slice(0, 50).map((h) => (
<div key={h.id} className="ops-tree-row group flex w-full items-center !py-1 text-left">
<button
className="flex min-w-0 flex-1 items-center gap-1 text-left"
onClick={() => {
if (h.nodeId && findNode(useWorkspace.getState().collections, h.nodeId)) {
openTab(h.nodeId);
}
}}
title={`${h.label}\n${h.url}`}
>
<span className="ops-faint w-14 shrink-0 text-[11px]">{formatTime(h.at)}</span>
{h.method && <span className={`ops-method-${h.method} w-12 shrink-0 text-[11px] font-bold`}>{h.method}</span>}
<span className="truncate text-[11px]">{h.label}</span>
{h.status !== undefined && (
<span className={cx("ml-auto text-[11px]", h.ok ? "ops-success" : "ops-danger")}>{h.status}</span>
)}
</button>
{h.nodeId && (
<button
className="ops-btn ops-btn-ghost ml-1 shrink-0 px-1.5 py-0.5 text-[11px] opacity-0 group-hover:opacity-100"
title="打开并重放"
onClick={(e) => {
e.stopPropagation();
replayHistory(h);
}}
>
重放
</button>
)}
</div>
))}
{history.length > 0 && (
<button className="ops-btn ops-btn-ghost ops-danger mt-2 w-full py-1 text-[11px]" onClick={clearHistory}>
<Icon name="eraser" size={11} /> 清空历史
</button>
)}
</>
) : (
<>
{collections.length === 0 && (
<div className="ops-faint px-2 py-8 text-center text-xs leading-5">
还没有集合
<br />
点击顶部「导入」或「新建」开始
</div>
)}
{collections.map((collection) => (
<div key={collection.id} className="mb-1">
<TreeRow
node={collection}
depth={0}
hitIds={hitIds}
collectionId={collection.id}
isCollection
onMenu={setMenu}
/>
{collection.children
.filter((n) => search === "" || hitIds.has(n.id))
.map((node) => (
<TreeChildren
key={node.id}
nodes={[node]}
depth={1}
hitIds={hitIds}
collectionId={collection.id}
onMenu={setMenu}
/>
))}
</div>
))}
</>
)}
</div>
<ContextMenu menu={menu} onClose={() => setMenu(null)} />
</WorkspaceSidebar>
);
}
function TreeChildren({
nodes,
depth,
hitIds,
collectionId,
onMenu,
}: {
nodes: CollectionNode[];
depth: number;
hitIds: Set<string>;
collectionId: string;
onMenu: (m: { x: number; y: number; items: MenuItem[] } | null) => void;
}) {
const expanded = useWorkspace((s) => s.expanded);
const toggleExpanded = useWorkspace((s) => s.toggleExpanded);
const search = useWorkspace((s) => s.search);
return (
<>
{nodes
.filter((n) => search === "" || hitIds.has(n.id))
.map((node) => (
<div key={node.id}>
<TreeRow node={node} depth={depth} hitIds={hitIds} collectionId={collectionId} onMenu={onMenu} />
{node.type === "folder" && expanded[node.id] && (
<TreeChildren
nodes={node.children}
depth={depth + 1}
hitIds={hitIds}
collectionId={collectionId}
onMenu={onMenu}
/>
)}
</div>
))}
</>
);
}
function TreeRow({
node,
depth,
hitIds,
collectionId,
isCollection = false,
onMenu,
}: {
node: CollectionNode | Collection;
depth: number;
hitIds: Set<string>;
collectionId: string;
isCollection?: boolean;
onMenu: (m: { x: number; y: number; items: MenuItem[] } | null) => void;
}) {
const activeTabId = useWorkspace((s) => s.activeTabId);
const dirtyIds = useWorkspace((s) => s.dirtyIds);
const expanded = useWorkspace((s) => s.expanded);
const toggleExpanded = useWorkspace((s) => s.toggleExpanded);
const openTab = useWorkspace((s) => s.openTab);
const removeNodeById = useWorkspace((s) => s.removeNodeById);
const duplicateNodeById = useWorkspace((s) => s.duplicateNodeById);
const addRequest = useWorkspace((s) => s.addRequest);
const addFolder = useWorkspace((s) => s.addFolder);
const addCollection = useWorkspace((s) => s.addCollection);
const setDialog = useWorkspace((s) => s.setDialog);
const moveNodeTo = useWorkspace((s) => s.moveNodeTo);
const [dragOver, setDragOver] = useState(false);
const isFolder = node.type === "folder" || node.type === "collection";
const requestNode = node.type === "request" ? node : null;
const isOpen = isFolder && expanded[node.id];
const isActive = requestNode !== null && node.id === activeTabId;
const buildMenu = (): MenuItem[] => {
const items: MenuItem[] = [];
if (isCollection) {
items.push(
{ label: "新建请求", icon: <Icon name="plus" size={14} />, onClick: () => setDialog({ type: "new", collectionId, parentId: null }) },
{ label: "新建目录", icon: <Icon name="folder-plus" size={14} />, onClick: () => addFolder(collectionId, null) },
{ type: "sep" },
{ label: "重命名", icon: <Icon name="pencil" size={14} />, onClick: () => setDialog({ type: "rename", id: node.id }) },
{ label: "导出该集合…", icon: <Icon name="upload" size={14} />, onClick: () => setDialog({ type: "export", collectionId }) },
{ type: "sep" },
{ label: "新建集合", icon: <Icon name="library" size={14} />, onClick: () => addCollection() },
);
return items;
}
if (isFolder) {
items.push(
{ label: "新建请求", icon: <Icon name="plus" size={14} />, onClick: () => setDialog({ type: "new", collectionId, parentId: node.id }) },
{ label: "新建子目录", icon: <Icon name="folder-plus" size={14} />, onClick: () => addFolder(collectionId, node.id) },
{ type: "sep" },
{ label: "重命名", icon: "✎", onClick: () => setDialog({ type: "rename", id: node.id }) },
{
label: "复制目录",
icon: <Icon name="copy" size={14} />,
onClick: () => duplicateNodeById(node.id),
},
{ type: "sep" },
{ label: "删除目录", icon: <Icon name="trash" size={14} />, danger: true, onClick: () => removeNodeById(node.id) },
);
return items;
}
// request
if (node.kind === "http") {
items.unshift({ label: "打开", icon: <Icon name="play" size={14} />, onClick: () => openTab(node.id) });
items.push(
{ label: "重命名", icon: <Icon name="pencil" size={14} />, onClick: () => setDialog({ type: "rename", id: node.id }) },
{ label: "复制接口", icon: <Icon name="copy" size={14} />, onClick: () => duplicateNodeById(node.id) },
{ type: "sep" },
{ label: "删除", icon: <Icon name="trash" size={14} />, danger: true, onClick: () => removeNodeById(node.id) },
);
} else {
items.push(
{ label: "打开", icon: <Icon name="play" size={14} />, onClick: () => openTab(node.id) },
{ label: "重命名", icon: <Icon name="pencil" size={14} />, onClick: () => setDialog({ type: "rename", id: node.id }) },
{ label: "复制", icon: <Icon name="copy" size={14} />, onClick: () => duplicateNodeById(node.id) },
{ type: "sep" },
{ label: "删除", icon: <Icon name="trash" size={14} />, danger: true, onClick: () => removeNodeById(node.id) },
);
}
return items;
};
const label = (
<span className="flex min-w-0 items-center gap-1 truncate">
<span className="truncate">{node.name}</span>
{requestNode && dirtyIds[node.id] ? (
<span
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ background: "var(--accent)" }}
title="未保存"
/>
) : null}
</span>
);
return (
<div
className={cx(
"ops-tree-row",
isActive && "ops-tree-row-active",
dragOver && "ops-drop-target",
)}
style={{ paddingLeft: 8 + depth * 14 }}
draggable={!isCollection}
onDragStart={(e) => {
e.dataTransfer.setData("text/opsxapi-node", node.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!isFolder) return;
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
if (!isFolder) return;
const dragId = e.dataTransfer.getData("text/opsxapi-node");
if (dragId && dragId !== node.id) {
const parentId = isCollection ? null : node.id;
moveNodeTo(dragId, collectionId, parentId);
if (parentId) toggleExpanded(parentId, true);
}
}}
onClick={() => {
if (isFolder) toggleExpanded(node.id);
else openTab(node.id);
}}
onContextMenu={(e) => {
e.preventDefault();
onMenu({ x: e.clientX, y: e.clientY, items: buildMenu() });
}}
>
{isFolder ? (
<Icon name={isOpen ? "chevron-down" : "chevron-right"} size={12} className="ops-faint" />
) : (
<span className="w-3 shrink-0" />
)}
{node.type === "collection" ? (
<Logo size={14} className="shrink-0" />
) : node.type === "folder" ? (
<Icon name={isOpen ? "folder-open" : "folder"} size={13} className="shrink-0 ops-dim" />
) : requestNode && requestNode.kind === "http" ? (
<span className={`ops-method-badge ops-method-${requestNode.data.method}`} style={{ width: 46, textAlign: "center" }}>
{requestNode.data.method}
</span>
) : requestNode ? (
<KindIcon kind={requestNode.kind} size={16} />
) : null}
<span className={cx("min-w-0 flex-1 truncate", isFolder && "font-medium")}>{label}</span>
</div>
);
}