Redesign OneBot API debug UI and improve usability

Refactored the OneBot API debug interface for a more modern, tabbed layout with improved sidebar navigation, request/response panels, and better mobile support. Enhanced code editor, response display, and message construction modal. Updated system info and status display for cleaner visuals. Improved xterm font sizing and rendering logic for mobile. WebSocket debug page now features a unified header, status bar, and clearer connection controls. Overall, this commit provides a more user-friendly and visually consistent debugging experience.
This commit is contained in:
手瓜一十雪
2025-12-22 15:21:45 +08:00
parent a54c5bd7ef
commit 2a4ef581d7
10 changed files with 539 additions and 372 deletions

View File

@@ -1,63 +1,160 @@
import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { IoClose } from 'react-icons/io5';
import { TbSquareRoundedChevronLeftFilled } from 'react-icons/tb';
import key from '@/const/key';
import oneBotHttpApi from '@/const/ob_api';
import type { 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';
export default function HttpDebug () {
const [selectedApi, setSelectedApi] =
useState<keyof OneBotHttpApi>('/set_qq_profile');
const data = oneBotHttpApi[selectedApi];
const contentRef = useRef<HTMLDivElement>(null);
const [activeApi, setActiveApi] = useState<OneBotHttpApiPath | null>('/set_qq_profile');
const [openApis, setOpenApis] = useState<OneBotHttpApiPath[]>(['/set_qq_profile']);
const [openSideBar, setOpenSideBar] = useState(true);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
// Auto-collapse sidebar on mobile initial load
useEffect(() => {
contentRef?.current?.scrollTo?.({
top: 0,
behavior: 'smooth',
});
}, [selectedApi]);
if (window.innerWidth < 768) {
setOpenSideBar(false);
}
}, []);
const handleSelectApi = (api: OneBotHttpApiPath) => {
if (!openApis.includes(api)) {
setOpenApis([...openApis, api]);
}
setActiveApi(api);
if (window.innerWidth < 768) {
setOpenSideBar(false);
}
};
const handleCloseTab = (e: React.MouseEvent, apiToRemove: OneBotHttpApiPath) => {
e.stopPropagation();
const newOpenApis = openApis.filter((api) => api !== apiToRemove);
setOpenApis(newOpenApis);
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);
}
}
};
return (
<>
<title>HTTP调试 - NapCat WebUI</title>
<div className='flex h-[calc(100vh-3.5rem)] overflow-hidden relative p-2 md:p-4 gap-2 md:gap-4'>
<OneBotApiNavList
data={oneBotHttpApi}
selectedApi={selectedApi}
onSelect={(api) => {
setSelectedApi(api);
// Auto-close sidebar on mobile after selection
if (window.innerWidth < 768) {
setOpenSideBar(false);
}
}}
openSideBar={openSideBar}
onToggle={setOpenSideBar}
/>
<div
ref={contentRef}
className='flex-1 h-full overflow-hidden flex flex-col relative'
>
{/* Toggle Button Container - positioned on top-left of content if sidebar is closed */}
<div className='absolute top-2 left-2 z-30'>
<Button
isIconOnly
size="sm"
variant="flat"
className={clsx("bg-white/40 dark:bg-black/40 backdrop-blur-md transition-opacity rounded-full shadow-sm", openSideBar ? "opacity-0 pointer-events-none md:opacity-0" : "opacity-100")}
onPress={() => setOpenSideBar(true)}
>
<TbSquareRoundedChevronLeftFilled className="transform rotate-180" />
</Button>
<div className='h-[calc(100vh-3.5rem)] p-0 md:p-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'
)}>
{/* 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>
<OneBotApiDebug path={selectedApi} data={data} />
<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'>
{openApis.map((api) => {
const isActive = api === activeApi;
const item = oneBotHttpApi[api];
return (
<div
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]',
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'
)}
>
<span className={clsx(
'text-[10px] font-bold uppercase tracking-wider',
isActive ? 'opacity-100' : 'opacity-50'
)}>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'
)}
onClick={(e) => handleCloseTab(e, api)}
>
<IoClose size={12} />
</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]} />
</div>
))}
</div>
</div>
</div>
</div>
</div>
</>

View File

@@ -2,8 +2,10 @@ import { Button } from '@heroui/button';
import { Card, CardBody } from '@heroui/card';
import { Input } from '@heroui/input';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
import toast from 'react-hot-toast';
import { IoFlash, IoFlashOff } from 'react-icons/io5';
import key from '@/const/key';
@@ -25,6 +27,8 @@ export default function WSDebug () {
const [inputUrl, setInputUrl] = useState(socketConfig.url);
const [inputToken, setInputToken] = useState(socketConfig.token);
const [shouldConnect, setShouldConnect] = useState(false);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const { sendMessage, readyState, FilterMessagesType, filteredMessages, clearMessages } =
useWebSocketDebug(socketConfig.url, socketConfig.token, shouldConnect);
@@ -48,61 +52,106 @@ export default function WSDebug () {
return (
<>
<title>Websocket调试 - NapCat WebUI</title>
<div className='h-[calc(100vh-4rem)] overflow-hidden flex flex-col p-2 md:p-0'>
<Card className='md:mx-2 md:mt-2 flex-shrink-0 bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm'>
<CardBody className='gap-2'>
<div className='grid gap-2 items-center md:grid-cols-5'>
<div className='h-[calc(100vh-4rem)] overflow-hidden flex flex-col p-2 md:p-4 gap-2 md:gap-4'>
{/* Config Card */}
<Card className={clsx(
'flex-shrink-0 backdrop-blur-xl border shadow-sm',
hasBackground
? 'bg-white/10 dark:bg-black/10 border-white/40 dark:border-white/10'
: 'bg-white/60 dark:bg-black/40 border-white/40 dark:border-white/10'
)}>
<CardBody className='gap-3 p-3 md:p-4'>
{/* Connection Config */}
<div className='grid gap-3 items-end md:grid-cols-[1fr_1fr_auto]'>
<Input
className='col-span-2'
label='WebSocket URL'
type='text'
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder='输入 WebSocket URL'
size='sm'
variant='bordered'
classNames={{
inputWrapper: clsx(
'backdrop-blur-sm border',
hasBackground
? 'bg-white/10 border-white/20'
: 'bg-default-100/50 border-default-200/50'
),
label: hasBackground ? 'text-white/80' : '',
input: hasBackground ? 'text-white placeholder:text-white/50' : '',
}}
/>
<Input
className='col-span-2'
label='Token'
type='text'
value={inputToken}
onChange={(e) => setInputToken(e.target.value)}
placeholder='输入 Token'
placeholder='输入 Token (可选)'
size='sm'
variant='bordered'
classNames={{
inputWrapper: clsx(
'backdrop-blur-sm border',
hasBackground
? 'bg-white/10 border-white/20'
: 'bg-default-100/50 border-default-200/50'
),
label: hasBackground ? 'text-white/80' : '',
input: hasBackground ? 'text-white placeholder:text-white/50' : '',
}}
/>
<div className='flex-shrink-0 flex gap-2 col-span-2 md:col-span-1'>
<Button
onPress={shouldConnect ? handleDisconnect : handleConnect}
size='lg'
radius='full'
color={shouldConnect ? 'danger' : 'primary'}
className='w-full md:w-auto'
>
{shouldConnect ? '断开' : '连接'}
</Button>
</div>
<Button
onPress={shouldConnect ? handleDisconnect : handleConnect}
size='md'
radius='full'
color={shouldConnect ? 'danger' : 'primary'}
className='font-bold shadow-lg min-w-[100px]'
startContent={shouldConnect ? <IoFlashOff /> : <IoFlash />}
>
{shouldConnect ? '断开' : '连接'}
</Button>
</div>
<div className='p-2 rounded-lg bg-white/50 dark:bg-white/5 border border-white/20 transition-colors'>
<div className='grid gap-2 md:grid-cols-5 items-center md:w-fit'>
<WSStatus state={readyState} />
<div className='md:w-64 max-w-full col-span-2'>
{/* Status Bar */}
<div className={clsx(
'p-2.5 rounded-xl border transition-colors flex flex-col md:flex-row gap-3 md:items-center md:justify-between',
hasBackground
? 'bg-white/10 border-white/20'
: 'bg-white/50 dark:bg-white/5 border-white/20'
)}>
<div className='flex items-center gap-3 w-full md:w-auto'>
<div className="flex-shrink-0">
<WSStatus state={readyState} />
</div>
<div className='flex-1 md:w-56 overflow-hidden'>
{FilterMessagesType}
</div>
<div className='flex gap-2 justify-end col-span-2 md:col-span-2'>
<Button
size='sm'
color='danger'
variant='flat'
onPress={clearMessages}
>
</Button>
<OneBotSendModal sendMessage={sendMessage} />
</div>
</div>
<div className='flex gap-2 justify-end w-full md:w-auto pt-1 md:pt-0 border-t border-white/5 md:border-t-0'>
<Button
size='sm'
color='danger'
variant='flat'
radius='full'
className='font-medium'
onPress={clearMessages}
>
</Button>
<OneBotSendModal sendMessage={sendMessage} />
</div>
</div>
</CardBody>
</Card>
<div className='flex-1 overflow-hidden'>
{/* Message List */}
<div className={clsx(
'flex-1 overflow-hidden rounded-2xl border backdrop-blur-xl',
hasBackground
? 'bg-white/10 dark:bg-black/10 border-white/40 dark:border-white/10'
: 'bg-white/60 dark:bg-black/40 border-white/40 dark:border-white/10'
)}>
<OneBotMessageList messages={filteredMessages} />
</div>
</div>