- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化) - 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转 - 设置→外观 主题顺序调整为 深色/浅色/跟随系统 - release 二进制重编并验证(浏览器实测关于页链接可开文档页) - 附带工作区中未提交的扁平化重构现状(另一会话进行中) - homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
337 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|