桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)
- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化) - 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转 - 设置→外观 主题顺序调整为 深色/浅色/跟随系统 - release 二进制重编并验证(浏览器实测关于页链接可开文档页) - 附带工作区中未提交的扁平化重构现状(另一会话进行中) - homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
This commit is contained in:
1 parent
6452026fd6
commit
2bb4ef7a95
299 files changed
+6059
-3322
No files matched your search
@@ -0,0 +1,336 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user