新建体验升级:彩色协议图标体系 + Apifox 式卡片新建面板
- 新增 KindIcon:6 协议 + 目录的彩色圆角方块图标(渐变底 + 白色图形 + 内高光),全应用统一配色 - 新增 NewMenu 下拉面板:双列网格卡片(图标 + 名称 + 副标题)、分组标题、通栏「新建目录 / 选择位置新建…」 - 新建弹窗类型选择、侧栏树非 HTTP 请求行、标签页图标全部换用彩色 KindIcon - 浏览器验证:面板开合/创建联动、弹窗选中态、树与标签页图标;53 项测试全绿
This commit is contained in:
1 parent
9566a5336c
commit
6d72264793
7 files changed
+206
-49
No files matched your search
@@ -556,6 +556,46 @@
|
||||
.rail-sync-dot.syncing { background: var(--accent); animation: rail-pulse 1s ease-in-out infinite; }
|
||||
@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 着色 ---------- */
|
||||
.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); }
|
||||
|
||||
@@ -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 { KIND_LABELS, findNode, searchTree } from "@opsxapi/core";
|
||||
import type { Collection, CollectionNode } from "@opsxapi/core";
|
||||
import { findNode, searchTree } from "@opsxapi/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useWorkspace } from "../workspaceStore";
|
||||
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";
|
||||
|
||||
const KIND_ICONS: Record<RequestKind, IconName> = {
|
||||
http: "globe",
|
||||
graphql: "hexagon",
|
||||
websocket: "zap",
|
||||
sse: "radio",
|
||||
socketio: "plug-zap",
|
||||
mqtt: "mailbox",
|
||||
};
|
||||
|
||||
export function Sidebar() {
|
||||
const collections = useWorkspace((s) => s.collections);
|
||||
const search = useWorkspace((s) => s.search);
|
||||
@@ -309,7 +301,7 @@ function TreeRow({
|
||||
{requestNode.data.method}
|
||||
</span>
|
||||
) : 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}
|
||||
<span className={cx("min-w-0 flex-1 truncate", isFolder && "font-medium")}>{label}</span>
|
||||
</div>
|
||||
|
||||
@@ -5,15 +5,7 @@ import { KIND_LABELS, findNode } from "@opsxapi/core";
|
||||
import { useState } from "react";
|
||||
import { useWorkspace } from "../workspaceStore";
|
||||
import { ContextMenu, cx, type MenuItem } from "./common";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
|
||||
const KIND_TAB_ICON: Record<string, IconName> = {
|
||||
graphql: "hexagon",
|
||||
websocket: "zap",
|
||||
sse: "radio",
|
||||
socketio: "plug-zap",
|
||||
mqtt: "mailbox",
|
||||
};
|
||||
import { KindIcon } from "./KindIcon";
|
||||
|
||||
export function TabBar() {
|
||||
const collections = useWorkspace((s) => s.collections);
|
||||
@@ -77,7 +69,7 @@ export function TabBar() {
|
||||
{node.type === "request" && node.kind === "http" ? (
|
||||
<span className={`ops-method-${node.data.method} font-bold`}>{node.data.method}</span>
|
||||
) : 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}
|
||||
<span className="max-w-48 truncate">{node.name}</span>
|
||||
{busy && <span className="ops-dot ops-dot-connecting" />}
|
||||
|
||||
@@ -6,17 +6,9 @@ import { useEffect, useState } from "react";
|
||||
import { useWorkspace } from "../workspaceStore";
|
||||
import { ContextMenu, type MenuItem } from "./common";
|
||||
import { EnvBadge } from "./EnvBadge";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { Icon } from "./icons";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
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" },
|
||||
];
|
||||
import { NewMenu } from "./NewMenu";
|
||||
|
||||
export function TopBar() {
|
||||
const collections = useWorkspace((s) => s.collections);
|
||||
@@ -43,18 +35,6 @@ export function TopBar() {
|
||||
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 envItems: MenuItem[] = [
|
||||
@@ -156,10 +136,15 @@ export function TopBar() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
menu={newMenu ? { x: newMenu.x, y: newMenu.y, items: newItems } : null}
|
||||
{newMenu && (
|
||||
<NewMenu
|
||||
anchor={newMenu}
|
||||
onCreate={(kind) => quickCreate(kind)}
|
||||
onFolder={() => quickCreate("folder")}
|
||||
onPickLocation={() => setDialog({ type: "new", collectionId: firstCollection?.id ?? "", parentId: null })}
|
||||
onClose={() => setNewMenu(null)}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu
|
||||
menu={envMenu ? { x: envMenu.x, y: envMenu.y, items: envItems } : null}
|
||||
onClose={() => setEnvMenu(null)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useWorkspace } from "../../workspaceStore";
|
||||
import { KVEditor, Modal, cx } from "../common";
|
||||
import { EnvBadge } from "../EnvBadge";
|
||||
import { Icon, type IconName } from "../icons";
|
||||
import { KindIcon } from "../KindIcon";
|
||||
import { Logo } from "../Logo";
|
||||
|
||||
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)" }}
|
||||
onClick={() => setKind(k.kind)}
|
||||
>
|
||||
<div className="text-xl ops-dim"><Icon name={k.icon} size={22} /></div>
|
||||
<div className="mt-1 text-xs font-semibold">
|
||||
<div className="flex justify-center"><KindIcon kind={k.kind} size={30} /></div>
|
||||
<div className="mt-1.5 text-xs font-semibold">
|
||||
{k.kind === "folder" ? "目录" : KIND_LABELS[k.kind]}
|
||||
</div>
|
||||
<div className="ops-faint mt-0.5 text-[10px]">{k.desc}</div>
|
||||
|
||||
Reference in new issue
Block a user