From 6d72264793c3ea023bcd2042b64083f9d38780bd Mon Sep 17 00:00:00 2001 From: logan Date: Fri, 18 Sep 2026 13:26:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=BB=BA=E4=BD=93=E9=AA=8C=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=EF=BC=9A=E5=BD=A9=E8=89=B2=E5=8D=8F=E8=AE=AE=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E4=BD=93=E7=B3=BB=20+=20Apifox=20=E5=BC=8F=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=96=B0=E5=BB=BA=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 KindIcon:6 协议 + 目录的彩色圆角方块图标(渐变底 + 白色图形 + 内高光),全应用统一配色 - 新增 NewMenu 下拉面板:双列网格卡片(图标 + 名称 + 副标题)、分组标题、通栏「新建目录 / 选择位置新建…」 - 新建弹窗类型选择、侧栏树非 HTTP 请求行、标签页图标全部换用彩色 KindIcon - 浏览器验证:面板开合/创建联动、弹窗选中态、树与标签页图标;53 项测试全绿 --- apps/web/src/styles.css | 40 +++++++ packages/ui/src/components/KindIcon.tsx | 47 ++++++++ packages/ui/src/components/NewMenu.tsx | 100 ++++++++++++++++++ packages/ui/src/components/Sidebar.tsx | 18 +--- packages/ui/src/components/TabBar.tsx | 12 +-- packages/ui/src/components/TopBar.tsx | 37 ++----- .../components/dialogs/WorkspaceDialogs.tsx | 5 +- 7 files changed, 208 insertions(+), 51 deletions(-) create mode 100644 packages/ui/src/components/KindIcon.tsx create mode 100644 packages/ui/src/components/NewMenu.tsx diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index efa863b..1701fcc 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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); } diff --git a/packages/ui/src/components/KindIcon.tsx b/packages/ui/src/components/KindIcon.tsx new file mode 100644 index 0000000..bd639c2 --- /dev/null +++ b/packages/ui/src/components/KindIcon.tsx @@ -0,0 +1,47 @@ +/** + * 协议彩色图标:饱和渐变圆角方块 + 白色图形(对齐 Apifox / Apipost 的新建面板视觉)。 + * 新建面板、新建弹窗、侧栏树、标签页共用同一套配色,保证全应用一致。 + */ +import type { RequestKind } from "@opsxapi/core"; +import { Icon, type IconName } from "./icons"; + +export const KIND_COLORS: Record = { + 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 ( + + ); +} diff --git a/packages/ui/src/components/NewMenu.tsx b/packages/ui/src/components/NewMenu.tsx new file mode 100644 index 0000000..4e2c5f2 --- /dev/null +++ b/packages/ui/src/components/NewMenu.tsx @@ -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 ( +
+
新建请求
+
+ {PANEL_KINDS.map((p) => ( + + ))} +
+
+ + +
+ ); +} diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index 62b8c85..ae200da 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -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 = { - 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} ) : requestNode ? ( - + ) : null} {label}
diff --git a/packages/ui/src/components/TabBar.tsx b/packages/ui/src/components/TabBar.tsx index 6618f1b..8811f3b 100644 --- a/packages/ui/src/components/TabBar.tsx +++ b/packages/ui/src/components/TabBar.tsx @@ -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 = { - 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" ? ( {node.data.method} ) : node.type === "request" ? ( - + ) : null} {node.name} {busy && } diff --git a/packages/ui/src/components/TopBar.tsx b/packages/ui/src/components/TopBar.tsx index e98829e..85ea411 100644 --- a/packages/ui/src/components/TopBar.tsx +++ b/packages/ui/src/components/TopBar.tsx @@ -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: , - onClick: () => quickCreate(i.kind), - })), - { type: "sep" as const }, - { label: "新建目录", icon: , onClick: () => quickCreate("folder") }, - { type: "sep" as const }, - { label: "选择位置新建…", icon: , 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() { - setNewMenu(null)} - /> + {newMenu && ( + quickCreate(kind)} + onFolder={() => quickCreate("folder")} + onPickLocation={() => setDialog({ type: "new", collectionId: firstCollection?.id ?? "", parentId: null })} + onClose={() => setNewMenu(null)} + /> + )} setEnvMenu(null)} diff --git a/packages/ui/src/components/dialogs/WorkspaceDialogs.tsx b/packages/ui/src/components/dialogs/WorkspaceDialogs.tsx index aa1e558..5bab8b5 100644 --- a/packages/ui/src/components/dialogs/WorkspaceDialogs.tsx +++ b/packages/ui/src/components/dialogs/WorkspaceDialogs.tsx @@ -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)} > -
-
+
+
{k.kind === "folder" ? "目录" : KIND_LABELS[k.kind]}
{k.desc}