新建体验升级:彩色协议图标体系 + Apifox 式卡片新建面板
- 新增 KindIcon:6 协议 + 目录的彩色圆角方块图标(渐变底 + 白色图形 + 内高光),全应用统一配色 - 新增 NewMenu 下拉面板:双列网格卡片(图标 + 名称 + 副标题)、分组标题、通栏「新建目录 / 选择位置新建…」 - 新建弹窗类型选择、侧栏树非 HTTP 请求行、标签页图标全部换用彩色 KindIcon - 浏览器验证:面板开合/创建联动、弹窗选中态、树与标签页图标;53 项测试全绿
This commit is contained in:
1 parent
9566a5336c
commit
6d72264793
7 files changed
+208
-51
No files matched your search
@@ -556,6 +556,46 @@
|
|||||||
.rail-sync-dot.syncing { background: var(--accent); animation: rail-pulse 1s ease-in-out infinite; }
|
.rail-sync-dot.syncing { background: var(--accent); animation: rail-pulse 1s ease-in-out infinite; }
|
||||||
@keyframes rail-pulse { 50% { opacity: 0.35; } }
|
@keyframes rail-pulse { 50% { opacity: 0.35; } }
|
||||||
|
|
||||||
|
/* ---------- 「新建」卡片面板(双列网格 + 彩色协议图标) ---------- */
|
||||||
|
.new-panel {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 60;
|
||||||
|
width: 304px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18), 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.new-panel-title {
|
||||||
|
@apply px-2 pb-1.5 pt-1 text-[11px] font-semibold tracking-wider;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.new-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.new-card {
|
||||||
|
@apply flex items-center gap-2.5 rounded-lg px-2 py-2 text-left transition;
|
||||||
|
}
|
||||||
|
.new-card:hover { background: var(--panel-2); }
|
||||||
|
.new-card-full { width: 100%; }
|
||||||
|
.new-card-neutral {
|
||||||
|
@apply grid shrink-0 place-items-center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.new-panel-sep {
|
||||||
|
height: 1px;
|
||||||
|
margin: 6px 4px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 方法 chip 着色 ---------- */
|
/* ---------- 方法 chip 着色 ---------- */
|
||||||
.ops-chip.ops-method-GET { background: color-mix(in srgb, var(--m-GET) 14%, transparent); color: var(--m-GET); }
|
.ops-chip.ops-method-GET { background: color-mix(in srgb, var(--m-GET) 14%, transparent); color: var(--m-GET); }
|
||||||
.ops-chip.ops-method-POST { background: color-mix(in srgb, var(--m-POST) 16%, transparent); color: var(--m-POST); }
|
.ops-chip.ops-method-POST { background: color-mix(in srgb, var(--m-POST) 16%, transparent); color: var(--m-POST); }
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 协议彩色图标:饱和渐变圆角方块 + 白色图形(对齐 Apifox / Apipost 的新建面板视觉)。
|
||||||
|
* 新建面板、新建弹窗、侧栏树、标签页共用同一套配色,保证全应用一致。
|
||||||
|
*/
|
||||||
|
import type { RequestKind } from "@opsxapi/core";
|
||||||
|
import { Icon, type IconName } from "./icons";
|
||||||
|
|
||||||
|
export const KIND_COLORS: Record<RequestKind | "folder", { color: string; icon: IconName; label: string }> = {
|
||||||
|
http: { color: "#3b82f6", icon: "globe", label: "HTTP" },
|
||||||
|
graphql: { color: "#ec4899", icon: "hexagon", label: "GraphQL" },
|
||||||
|
websocket: { color: "#f97316", icon: "zap", label: "WebSocket" },
|
||||||
|
sse: { color: "#0ea5e9", icon: "radio", label: "SSE" },
|
||||||
|
socketio: { color: "#14b8a6", icon: "plug-zap", label: "Socket.IO" },
|
||||||
|
mqtt: { color: "#10b981", icon: "mailbox", label: "MQTT" },
|
||||||
|
folder: { color: "#8b5cf6", icon: "folder-plus", label: "目录" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KindIcon({
|
||||||
|
kind,
|
||||||
|
size = 28,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
kind: RequestKind | "folder";
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const meta = KIND_COLORS[kind];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: Math.max(5, Math.round(size * 0.3)),
|
||||||
|
background: `linear-gradient(135deg, ${meta.color}, color-mix(in srgb, ${meta.color} 72%, #000))`,
|
||||||
|
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.25), 0 1px 2px rgba(0,0,0,0.18)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={meta.icon} size={Math.round(size * 0.56)} style={{ color: "#fff", strokeWidth: 2.2 }} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 「新建」卡片式下拉面板(对齐 Apifox / Apipost):
|
||||||
|
* 双列网格 + 彩色协议图标 + 分组标题 + 通栏次级行。
|
||||||
|
*/
|
||||||
|
import type { RequestKind } from "@opsxapi/core";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { KIND_COLORS, KindIcon } from "./KindIcon";
|
||||||
|
import { Icon } from "./icons";
|
||||||
|
|
||||||
|
const PANEL_KINDS: { kind: RequestKind; desc: string }[] = [
|
||||||
|
{ kind: "http", desc: "REST 接口" },
|
||||||
|
{ kind: "graphql", desc: "查询 + 变量" },
|
||||||
|
{ kind: "websocket", desc: "双向实时" },
|
||||||
|
{ kind: "sse", desc: "事件流推送" },
|
||||||
|
{ kind: "socketio", desc: "事件收发" },
|
||||||
|
{ kind: "mqtt", desc: "订阅 / 发布" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function NewMenu({
|
||||||
|
anchor,
|
||||||
|
onCreate,
|
||||||
|
onFolder,
|
||||||
|
onPickLocation,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
anchor: { x: number; y: number };
|
||||||
|
onCreate: (kind: RequestKind) => void;
|
||||||
|
onFolder: () => void;
|
||||||
|
onPickLocation: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
const close = (e: Event) => {
|
||||||
|
const panel = document.querySelector(".new-panel");
|
||||||
|
if (panel && e.target instanceof Node && panel.contains(e.target)) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
window.addEventListener("pointerdown", close, true);
|
||||||
|
window.addEventListener("resize", close);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", close, true);
|
||||||
|
window.removeEventListener("resize", close);
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const left = Math.min(anchor.x, window.innerWidth - 320);
|
||||||
|
const top = Math.min(anchor.y, window.innerHeight - 420);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="new-panel" style={{ left, top }}>
|
||||||
|
<div className="new-panel-title">新建请求</div>
|
||||||
|
<div className="new-grid">
|
||||||
|
{PANEL_KINDS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.kind}
|
||||||
|
className="new-card"
|
||||||
|
onClick={() => {
|
||||||
|
onCreate(p.kind);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KindIcon kind={p.kind} size={26} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-[12.5px] font-semibold">{KIND_COLORS[p.kind].label}</span>
|
||||||
|
<span className="ops-faint block truncate text-[10px]">{p.desc}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="new-panel-sep" />
|
||||||
|
<button
|
||||||
|
className="new-card new-card-full"
|
||||||
|
onClick={() => {
|
||||||
|
onFolder();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KindIcon kind="folder" size={26} />
|
||||||
|
<span className="text-[12.5px] font-semibold">新建目录</span>
|
||||||
|
<span className="ops-faint ml-auto pr-1 text-[10px]">接口分组</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="new-card new-card-full"
|
||||||
|
onClick={() => {
|
||||||
|
onPickLocation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="new-card-neutral">
|
||||||
|
<Icon name="library" size={15} />
|
||||||
|
</span>
|
||||||
|
<span className="text-[12.5px] font-semibold">选择位置新建…</span>
|
||||||
|
<span className="ops-faint ml-auto pr-1 text-[10px]">指定集合 / 目录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* 左侧集合树:搜索 / 目录 / 请求 / 右键菜单 / 拖拽移动 / 历史记录。
|
* 左侧集合树:搜索 / 目录 / 请求 / 右键菜单 / 拖拽移动 / 历史记录。
|
||||||
*/
|
*/
|
||||||
import type { Collection, CollectionNode, RequestKind } from "@opsxapi/core";
|
import type { Collection, CollectionNode } from "@opsxapi/core";
|
||||||
import { KIND_LABELS, findNode, searchTree } from "@opsxapi/core";
|
import { findNode, searchTree } from "@opsxapi/core";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useWorkspace } from "../workspaceStore";
|
import { useWorkspace } from "../workspaceStore";
|
||||||
import { ContextMenu, cx, formatTime, type MenuItem } from "./common";
|
import { ContextMenu, cx, formatTime, type MenuItem } from "./common";
|
||||||
import { Icon, type IconName } from "./icons";
|
import { Icon } from "./icons";
|
||||||
|
import { KindIcon } from "./KindIcon";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
const KIND_ICONS: Record<RequestKind, IconName> = {
|
|
||||||
http: "globe",
|
|
||||||
graphql: "hexagon",
|
|
||||||
websocket: "zap",
|
|
||||||
sse: "radio",
|
|
||||||
socketio: "plug-zap",
|
|
||||||
mqtt: "mailbox",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const collections = useWorkspace((s) => s.collections);
|
const collections = useWorkspace((s) => s.collections);
|
||||||
const search = useWorkspace((s) => s.search);
|
const search = useWorkspace((s) => s.search);
|
||||||
@@ -309,7 +301,7 @@ function TreeRow({
|
|||||||
{requestNode.data.method}
|
{requestNode.data.method}
|
||||||
</span>
|
</span>
|
||||||
) : requestNode ? (
|
) : requestNode ? (
|
||||||
<Icon name={KIND_ICONS[requestNode.kind]} size={13} className="shrink-0 ops-dim" title={KIND_LABELS[requestNode.kind]} />
|
<KindIcon kind={requestNode.kind} size={16} />
|
||||||
) : null}
|
) : null}
|
||||||
<span className={cx("min-w-0 flex-1 truncate", isFolder && "font-medium")}>{label}</span>
|
<span className={cx("min-w-0 flex-1 truncate", isFolder && "font-medium")}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,15 +5,7 @@ import { KIND_LABELS, findNode } from "@opsxapi/core";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useWorkspace } from "../workspaceStore";
|
import { useWorkspace } from "../workspaceStore";
|
||||||
import { ContextMenu, cx, type MenuItem } from "./common";
|
import { ContextMenu, cx, type MenuItem } from "./common";
|
||||||
import { Icon, type IconName } from "./icons";
|
import { KindIcon } from "./KindIcon";
|
||||||
|
|
||||||
const KIND_TAB_ICON: Record<string, IconName> = {
|
|
||||||
graphql: "hexagon",
|
|
||||||
websocket: "zap",
|
|
||||||
sse: "radio",
|
|
||||||
socketio: "plug-zap",
|
|
||||||
mqtt: "mailbox",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TabBar() {
|
export function TabBar() {
|
||||||
const collections = useWorkspace((s) => s.collections);
|
const collections = useWorkspace((s) => s.collections);
|
||||||
@@ -77,7 +69,7 @@ export function TabBar() {
|
|||||||
{node.type === "request" && node.kind === "http" ? (
|
{node.type === "request" && node.kind === "http" ? (
|
||||||
<span className={`ops-method-${node.data.method} font-bold`}>{node.data.method}</span>
|
<span className={`ops-method-${node.data.method} font-bold`}>{node.data.method}</span>
|
||||||
) : node.type === "request" ? (
|
) : node.type === "request" ? (
|
||||||
<Icon name={KIND_TAB_ICON[node.kind]} size={13} className="ops-dim shrink-0" title={KIND_LABELS[node.kind]} />
|
<span title={KIND_LABELS[node.kind]}><KindIcon kind={node.kind} size={14} /></span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="max-w-48 truncate">{node.name}</span>
|
<span className="max-w-48 truncate">{node.name}</span>
|
||||||
{busy && <span className="ops-dot ops-dot-connecting" />}
|
{busy && <span className="ops-dot ops-dot-connecting" />}
|
||||||
|
|||||||
@@ -6,17 +6,9 @@ import { useEffect, useState } from "react";
|
|||||||
import { useWorkspace } from "../workspaceStore";
|
import { useWorkspace } from "../workspaceStore";
|
||||||
import { ContextMenu, type MenuItem } from "./common";
|
import { ContextMenu, type MenuItem } from "./common";
|
||||||
import { EnvBadge } from "./EnvBadge";
|
import { EnvBadge } from "./EnvBadge";
|
||||||
import { Icon, type IconName } from "./icons";
|
import { Icon } from "./icons";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
|
import { NewMenu } from "./NewMenu";
|
||||||
const NEW_ITEMS: { kind: string; label: string; icon: IconName }[] = [
|
|
||||||
{ kind: "http", label: "HTTP 请求", icon: "globe" },
|
|
||||||
{ kind: "graphql", label: "GraphQL", icon: "hexagon" },
|
|
||||||
{ kind: "websocket", label: "WebSocket", icon: "zap" },
|
|
||||||
{ kind: "sse", label: "SSE", icon: "radio" },
|
|
||||||
{ kind: "socketio", label: "Socket.IO", icon: "plug-zap" },
|
|
||||||
{ kind: "mqtt", label: "MQTT", icon: "mailbox" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const collections = useWorkspace((s) => s.collections);
|
const collections = useWorkspace((s) => s.collections);
|
||||||
@@ -43,18 +35,6 @@ export function TopBar() {
|
|||||||
else addRequest(firstCollection.id, null, kind as never);
|
else addRequest(firstCollection.id, null, kind as never);
|
||||||
};
|
};
|
||||||
|
|
||||||
const newItems: MenuItem[] = [
|
|
||||||
...NEW_ITEMS.map((i) => ({
|
|
||||||
label: i.label,
|
|
||||||
icon: <Icon name={i.icon} size={14} />,
|
|
||||||
onClick: () => quickCreate(i.kind),
|
|
||||||
})),
|
|
||||||
{ type: "sep" as const },
|
|
||||||
{ label: "新建目录", icon: <Icon name="folder-plus" size={14} />, onClick: () => quickCreate("folder") },
|
|
||||||
{ type: "sep" as const },
|
|
||||||
{ label: "选择位置新建…", icon: <Icon name="library" size={14} />, onClick: () => setDialog({ type: "new", collectionId: firstCollection?.id ?? "", parentId: null }) },
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeEnv = environments.find((e) => e.id === activeEnvironmentId);
|
const activeEnv = environments.find((e) => e.id === activeEnvironmentId);
|
||||||
|
|
||||||
const envItems: MenuItem[] = [
|
const envItems: MenuItem[] = [
|
||||||
@@ -156,10 +136,15 @@ export function TopBar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ContextMenu
|
{newMenu && (
|
||||||
menu={newMenu ? { x: newMenu.x, y: newMenu.y, items: newItems } : null}
|
<NewMenu
|
||||||
onClose={() => setNewMenu(null)}
|
anchor={newMenu}
|
||||||
/>
|
onCreate={(kind) => quickCreate(kind)}
|
||||||
|
onFolder={() => quickCreate("folder")}
|
||||||
|
onPickLocation={() => setDialog({ type: "new", collectionId: firstCollection?.id ?? "", parentId: null })}
|
||||||
|
onClose={() => setNewMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
menu={envMenu ? { x: envMenu.x, y: envMenu.y, items: envItems } : null}
|
menu={envMenu ? { x: envMenu.x, y: envMenu.y, items: envItems } : null}
|
||||||
onClose={() => setEnvMenu(null)}
|
onClose={() => setEnvMenu(null)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useWorkspace } from "../../workspaceStore";
|
|||||||
import { KVEditor, Modal, cx } from "../common";
|
import { KVEditor, Modal, cx } from "../common";
|
||||||
import { EnvBadge } from "../EnvBadge";
|
import { EnvBadge } from "../EnvBadge";
|
||||||
import { Icon, type IconName } from "../icons";
|
import { Icon, type IconName } from "../icons";
|
||||||
|
import { KindIcon } from "../KindIcon";
|
||||||
import { Logo } from "../Logo";
|
import { Logo } from "../Logo";
|
||||||
|
|
||||||
const KIND_MENU: { kind: RequestKind | "folder"; icon: IconName; desc: string }[] = [
|
const KIND_MENU: { kind: RequestKind | "folder"; icon: IconName; desc: string }[] = [
|
||||||
@@ -81,8 +82,8 @@ export function NewItemDialog({
|
|||||||
style={{ borderColor: kind === k.kind ? "var(--accent)" : "var(--border)" }}
|
style={{ borderColor: kind === k.kind ? "var(--accent)" : "var(--border)" }}
|
||||||
onClick={() => setKind(k.kind)}
|
onClick={() => setKind(k.kind)}
|
||||||
>
|
>
|
||||||
<div className="text-xl ops-dim"><Icon name={k.icon} size={22} /></div>
|
<div className="flex justify-center"><KindIcon kind={k.kind} size={30} /></div>
|
||||||
<div className="mt-1 text-xs font-semibold">
|
<div className="mt-1.5 text-xs font-semibold">
|
||||||
{k.kind === "folder" ? "目录" : KIND_LABELS[k.kind]}
|
{k.kind === "folder" ? "目录" : KIND_LABELS[k.kind]}
|
||||||
</div>
|
</div>
|
||||||
<div className="ops-faint mt-0.5 text-[10px]">{k.desc}</div>
|
<div className="ops-faint mt-0.5 text-[10px]">{k.desc}</div>
|
||||||
|
|||||||
Reference in new issue
Block a user