新建体验升级:彩色协议图标体系 + Apifox 式卡片新建面板

- 新增 KindIcon:6 协议 + 目录的彩色圆角方块图标(渐变底 + 白色图形 + 内高光),全应用统一配色
- 新增 NewMenu 下拉面板:双列网格卡片(图标 + 名称 + 副标题)、分组标题、通栏「新建目录 / 选择位置新建…」
- 新建弹窗类型选择、侧栏树非 HTTP 请求行、标签页图标全部换用彩色 KindIcon
- 浏览器验证:面板开合/创建联动、弹窗选中态、树与标签页图标;53 项测试全绿
This commit is contained in:
logan committed 2026-09-18 13:26:51 +08:00
1 parent 9566a5336c
commit 6d72264793
7 files changed
+208 -51

No files matched your search

+40
View File
@@ -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); }
+47
View File
@@ -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>
);
}
+100
View File
@@ -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>
);
}
+5 -13
View File
@@ -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>
+2 -10
View File
@@ -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" />}
+11 -26
View File
@@ -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}
onClose={() => setNewMenu(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>