mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
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:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user