Enhance HTTP debug UI with command palette and UI improvements

Added a new CommandPalette component for quick API selection and execution (Ctrl/Cmd+K). Refactored the HTTP debug page to use the command palette, improved tab and panel UI, and enhanced the code editor's appearance and theme integration. Updated OneBotApiDebug to support imperative methods for request body and sending, improved response panel resizing, and made various UI/UX refinements across related components.
This commit is contained in:
手瓜一十雪
2026-01-04 20:38:08 +08:00
parent 874c270093
commit 52cc13c15c
8 changed files with 638 additions and 265 deletions

View File

@@ -1,31 +1,43 @@
import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { IoClose } from 'react-icons/io5';
import { TbSquareRoundedChevronLeftFilled } from 'react-icons/tb';
import { TbSearch } from 'react-icons/tb';
import key from '@/const/key';
import oneBotHttpApi from '@/const/ob_api';
import type { OneBotHttpApiPath } from '@/const/ob_api';
import OneBotApiDebug from '@/components/onebot/api/debug';
import OneBotApiNavList from '@/components/onebot/api/nav_list';
import CommandPalette from '@/components/command_palette';
import type { CommandPaletteCommand, CommandPaletteExecuteMode } from '@/components/command_palette';
import { generateDefaultJson } from '@/utils/zod';
import type { OneBotApiDebugRef } from '@/components/onebot/api/debug';
export default function HttpDebug () {
const [activeApi, setActiveApi] = useState<OneBotHttpApiPath | null>('/set_qq_profile');
const [openApis, setOpenApis] = useState<OneBotHttpApiPath[]>(['/set_qq_profile']);
const [openSideBar, setOpenSideBar] = useState(true);
const [activeApi, setActiveApi] = useState<OneBotHttpApiPath | null>(null);
const [openApis, setOpenApis] = useState<OneBotHttpApiPath[]>([]);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const [adapterName, setAdapterName] = useState<string>('');
const [paletteOpen, setPaletteOpen] = useState(false);
// Auto-collapse sidebar on mobile initial load
const debugRefs = useRef(new Map<string, OneBotApiDebugRef>());
const [pendingRun, setPendingRun] = useState<{ path: OneBotHttpApiPath; body: string; } | null>(null);
// Ctrl/Cmd + K 打开命令面板
useEffect(() => {
if (window.innerWidth < 768) {
setOpenSideBar(false);
}
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setPaletteOpen(true);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// Initialize Debug Adapter
@@ -64,9 +76,48 @@ export default function HttpDebug () {
setOpenApis([...openApis, api]);
}
setActiveApi(api);
if (window.innerWidth < 768) {
setOpenSideBar(false);
};
// 等对应 Debug 组件挂载后再触发发送
useEffect(() => {
if (!pendingRun) return;
if (activeApi !== pendingRun.path) return;
const ref = debugRefs.current.get(pendingRun.path);
if (!ref) return;
ref.sendWithBody(pendingRun.body);
setPendingRun(null);
}, [activeApi, pendingRun]);
const commands: CommandPaletteCommand[] = useMemo(() => {
return Object.keys(oneBotHttpApi).map((p) => {
const path = p as OneBotHttpApiPath;
const item = oneBotHttpApi[path];
// 简单分组:按描述里已有分类不可靠,这里只用 path 前缀推断
const group = path.startsWith('/get_') ? 'GET' : (path.startsWith('/set_') ? 'SET' : 'API');
return {
id: path,
title: item?.description || path,
subtitle: item?.request ? '回车发送 · Shift+Enter 仅打开' : undefined,
group,
};
});
}, []);
const executeCommand = (commandId: string, mode: CommandPaletteExecuteMode) => {
const api = commandId as OneBotHttpApiPath;
const item = oneBotHttpApi[api];
const body = item?.request ? generateDefaultJson(item.request) : '{}';
handleSelectApi(api);
// 确保请求参数可见
const ref = debugRefs.current.get(api);
if (ref) {
if (mode === 'send') ref.sendWithBody(body);
else ref.setRequestBody(body);
return;
}
// 若还没挂载,延迟执行
if (mode === 'send') setPendingRun({ path: api, body });
};
const handleCloseTab = (e: React.MouseEvent, apiToRemove: OneBotHttpApiPath) => {
@@ -76,9 +127,6 @@ export default function HttpDebug () {
if (activeApi === apiToRemove) {
if (newOpenApis.length > 0) {
// Switch to the last opened tab or the previous one?
// Usually the one to the right or left. Let's pick the last one for simplicity or neighbor.
// Finding index of removed api to pick neighbor is better UX, but last one is acceptable.
setActiveApi(newOpenApis[newOpenApis.length - 1]);
} else {
setActiveApi(null);
@@ -89,50 +137,24 @@ export default function HttpDebug () {
return (
<>
<title>HTTP调试 - NapCat WebUI</title>
<div className='h-[calc(100vh-3.5rem)] p-0 md:p-4'>
<div className='h-[calc(100vh-3.5rem)] pt-2 px-0 md:px-4'>
<div className={clsx(
'h-full flex flex-col overflow-hidden transition-all relative',
'rounded-none md:rounded-2xl',
hasBackground
? 'bg-white/5 dark:bg-black/5 backdrop-blur-sm'
: 'bg-white/20 dark:bg-black/10 backdrop-blur-sm shadow-sm'
// 'rounded-none md:rounded-2xl border', // Removing the main border/radius
// hasBackground
// ? 'bg-white/5 dark:bg-black/5 backdrop-blur-sm border-white/10'
// : 'bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border-white/40 dark:border-white/10'
'bg-transparent'
)}>
{/* Unifed Header */}
<div className='h-12 border-b border-white/10 flex items-center justify-between px-4 z-50 bg-white/5 flex-shrink-0'>
<div className='flex items-center gap-3'>
<Button
isIconOnly
size="sm"
variant="light"
className={clsx(
"opacity-50 hover:opacity-100 transition-all",
openSideBar && "text-primary opacity-100"
)}
onPress={() => setOpenSideBar(!openSideBar)}
>
<TbSquareRoundedChevronLeftFilled className={clsx("text-lg transform transition-transform", !openSideBar && "rotate-180")} />
</Button>
<h1 className={clsx(
'text-sm font-bold tracking-tight',
hasBackground ? 'text-white/80' : 'text-default-700 dark:text-gray-200'
)}></h1>
</div>
</div>
<div className='flex-1 flex flex-row overflow-hidden relative'>
<OneBotApiNavList
data={oneBotHttpApi}
selectedApi={activeApi || '' as any}
onSelect={handleSelectApi}
openSideBar={openSideBar}
onToggle={setOpenSideBar}
/>
<div
className='flex-1 h-full overflow-hidden flex flex-col relative'
>
{/* Tab Bar */}
<div className='flex items-center w-full overflow-x-auto no-scrollbar border-b border-white/5 bg-white/5 flex-shrink-0'>
<div className='flex-1 flex flex-col overflow-hidden relative'>
<div className={clsx(
'flex items-center w-full flex-shrink-0 pr-2 md:pl-4 py-1 relative z-20 rounded-md',
hasBackground
? 'bg-white/5'
: 'bg-white/30 dark:bg-white/5'
)}>
{/* Tab List */}
<div className="flex-1 overflow-x-auto no-scrollbar flex items-center">
{openApis.map((api) => {
const isActive = api === activeApi;
const item = oneBotHttpApi[api];
@@ -141,21 +163,26 @@ export default function HttpDebug () {
key={api}
onClick={() => setActiveApi(api)}
className={clsx(
'group flex items-center gap-2 px-4 h-9 cursor-pointer border-r border-white/5 select-none transition-all min-w-[120px] max-w-[200px]',
'group flex items-center gap-2 px-3 h-8 my-1 mr-1 rounded-md cursor-pointer border select-none transition-all min-w-[120px] max-w-[260px]',
hasBackground ? 'border-transparent hover:bg-white/10' : 'border-transparent hover:bg-white/10 dark:hover:bg-white/5',
isActive
? (hasBackground ? 'bg-white/10 text-white' : 'bg-white/40 dark:bg-white/5 text-primary font-medium')
: 'opacity-50 hover:opacity-100 hover:bg-white/5'
? (hasBackground
? 'bg-white/15 text-white border-white/20'
: 'bg-default-100 dark:bg-white/15 text-foreground dark:text-white font-medium shadow-sm border-default-200 dark:border-white/10')
: (hasBackground ? 'text-white/70 hover:text-white' : 'text-default-600 dark:text-white/70 hover:text-default-900 dark:hover:text-white')
)}
>
<span className={clsx(
'text-[10px] font-bold uppercase tracking-wider',
isActive ? 'opacity-100' : 'opacity-50'
'text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded-sm',
isActive
? 'bg-success/20 text-success'
: 'opacity-60 bg-default-200/50 dark:bg-white/10'
)}>POST</span>
<span className='text-xs truncate flex-1'>{item?.description || api}</span>
<div
className={clsx(
'p-0.5 rounded-full hover:bg-black/10 dark:hover:bg-white/20 transition-opacity',
isActive ? 'opacity-50 hover:opacity-100' : 'opacity-0 group-hover:opacity-50'
'p-0.5 rounded-sm hover:bg-black/10 dark:hover:bg-white/20 transition-opacity',
isActive ? 'opacity-40 hover:opacity-100' : 'opacity-0 group-hover:opacity-40'
)}
onClick={(e) => handleCloseTab(e, api)}
>
@@ -163,37 +190,67 @@ export default function HttpDebug () {
</div>
</div>
);
})}
</div>
{/* Content Panels */}
<div className='flex-1 relative overflow-hidden'>
{activeApi === null && (
<div className='h-full flex items-center justify-center text-default-400 text-sm opacity-50 select-none'>
</div>
)}
{openApis.map((api) => (
<div
key={api}
className={clsx(
'h-full w-full absolute top-0 left-0 transition-opacity duration-200',
api === activeApi ? 'opacity-100 z-10' : 'opacity-0 z-0 pointer-events-none'
)}
>
<OneBotApiDebug
path={api}
data={oneBotHttpApi[api]}
adapterName={adapterName}
/>
</div>
))}
{/* Actions */}
<div className='flex items-center gap-2 pl-2 border-l border-white/5 flex-shrink-0'>
<Button
isIconOnly
size='sm'
radius='sm'
variant='light'
className='text-default-500 hover:text-primary w-10 h-10 min-w-10'
onClick={() => setPaletteOpen(true)}
onPress={() => setPaletteOpen(true)}
>
<TbSearch size={18} />
</Button>
</div>
</div>
{/* Content Panels */}
<div className='flex-1 relative overflow-hidden'>
{activeApi === null && (
<div className='h-full flex items-center justify-center text-default-400 text-sm opacity-50 select-none'>
使Ctrl/Cmd + K
</div>
)}
{openApis.map((api) => (
<div
key={api}
className={clsx(
'h-full w-full absolute top-0 left-0 transition-opacity duration-200',
api === activeApi ? 'opacity-100 z-10' : 'opacity-0 z-0 pointer-events-none'
)}
>
<OneBotApiDebug
ref={(node) => {
if (!node) {
debugRefs.current.delete(api);
return;
}
debugRefs.current.set(api, node);
}}
path={api}
data={oneBotHttpApi[api]}
adapterName={adapterName}
/>
</div>
))}
</div>
</div>
</div>
</div>
<CommandPalette
isOpen={paletteOpen}
onOpenChange={setPaletteOpen}
commands={commands}
onExecute={executeCommand}
/>
</>
);
}