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 c4f7107038
commit 649165bf00
10 changed files with 539 additions and 372 deletions

View File

@ -10,11 +10,16 @@ import {
import ChatInput from '.'; import ChatInput from '.';
export default function ChatInputModal () { interface ChatInputModalProps {
children?: (onOpen: () => void) => React.ReactNode;
}
export default function ChatInputModal ({ children }: ChatInputModalProps) {
const { isOpen, onOpen, onOpenChange } = useDisclosure(); const { isOpen, onOpen, onOpenChange } = useDisclosure();
return ( return (
<> <>
{children ? children(onOpen) : (
<Button <Button
onPress={onOpen} onPress={onOpen}
color='primary' color='primary'
@ -25,6 +30,7 @@ export default function ChatInputModal () {
> >
</Button> </Button>
)}
<Modal <Modal
size='4xl' size='4xl'
scrollBehavior='inside' scrollBehavior='inside'

View File

@ -1,14 +1,15 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Input } from '@heroui/input'; import { Input } from '@heroui/input';
import { Snippet } from '@heroui/snippet';
import { Modal, ModalBody, ModalContent, ModalHeader } from '@heroui/modal';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Tooltip } from '@heroui/tooltip';
import { Tab, Tabs } from '@heroui/tabs';
import { Chip } from '@heroui/chip';
import { useLocalStorage } from '@uidotdev/usehooks'; import { useLocalStorage } from '@uidotdev/usehooks';
import { useEffect, useRef, useState } from 'react'; import clsx from 'clsx';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IoLink, IoSend, IoSettingsSharp } from 'react-icons/io5'; import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5';
import { TbApi, TbCode } from 'react-icons/tb'; import { TbCode, TbMessageCode } from 'react-icons/tb';
import key from '@/const/key'; import key from '@/const/key';
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api'; import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api';
@ -40,14 +41,18 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
const [requestBody, setRequestBody] = useState('{}'); const [requestBody, setRequestBody] = useState('{}');
const [responseContent, setResponseContent] = useState(''); const [responseContent, setResponseContent] = useState('');
const [isFetching, setIsFetching] = useState(false); const [isFetching, setIsFetching] = useState(false);
const [isStructOpen, setIsStructOpen] = useState(false); const [activeTab, setActiveTab] = useState<any>('request');
const responseRef = useRef<HTMLDivElement>(null); const [responseExpanded, setResponseExpanded] = useState(true);
const [responseStatus, setResponseStatus] = useState<{ code: number; text: string; } | null>(null);
const parsedRequest = parse(data.request); const parsedRequest = parse(data.request);
const parsedResponse = parse(data.response); const parsedResponse = parse(data.response);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const sendRequest = async () => { const sendRequest = async () => {
if (isFetching) return; if (isFetching) return;
setIsFetching(true); setIsFetching(true);
setResponseStatus(null);
const r = toast.loading('正在发送请求...'); const r = toast.loading('正在发送请求...');
try { try {
const parsedRequestBody = JSON.parse(requestBody); const parsedRequestBody = JSON.parse(requestBody);
@ -62,18 +67,20 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
}) })
.then((res) => { .then((res) => {
setResponseContent(parseAxiosResponse(res)); setResponseContent(parseAxiosResponse(res));
toast.success('请求发送完成,请查看响应'); setResponseStatus({ code: res.status, text: res.statusText });
setResponseExpanded(true);
toast.success('请求成功');
}) })
.catch((err) => { .catch((err) => {
toast.error('请求发送失败:' + err.message); toast.error('请求失败:' + err.message);
setResponseContent(parseAxiosResponse(err.response)); setResponseContent(parseAxiosResponse(err.response));
if (err.response) {
setResponseStatus({ code: err.response.status, text: err.response.statusText });
}
setResponseExpanded(true);
}) })
.finally(() => { .finally(() => {
setIsFetching(false); setIsFetching(false);
responseRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
toast.dismiss(r); toast.dismiss(r);
}); });
} catch (_error) { } catch (_error) {
@ -86,88 +93,36 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
useEffect(() => { useEffect(() => {
setRequestBody(generateDefaultJson(data.request)); setRequestBody(generateDefaultJson(data.request));
setResponseContent(''); setResponseContent('');
setResponseStatus(null);
}, [path]); }, [path]);
return ( return (
<section className='h-full flex flex-col gap-3 md:gap-4 p-3 md:p-6 bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm rounded-2xl overflow-hidden'> <section className='h-full flex flex-col overflow-hidden bg-transparent'>
<div className='flex flex-col md:flex-row md:items-center justify-between border-b border-white/10 pb-3 md:pb-4 gap-3'> {/* URL Bar */}
<div className='flex items-center gap-2 md:gap-4 overflow-hidden'> <div className='flex flex-wrap md:flex-nowrap items-center gap-2 p-2 md:p-4 pb-2 flex-shrink-0'>
<h1 className='text-lg md:text-xl font-bold flex items-center gap-2 text-primary-500 flex-shrink-0'> <div className={clsx(
<TbApi size={24} /> 'flex-grow flex items-center gap-2 px-3 md:px-4 h-10 rounded-xl transition-all w-full md:w-auto',
<span className='truncate'>{data.description}</span> hasBackground ? 'bg-white/5' : 'bg-black/5 dark:bg-white/5'
</h1> )}>
<Snippet <Chip size="sm" variant="shadow" color="primary" className="font-bold text-[10px] h-5 min-w-[40px]">POST</Chip>
className='bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 hidden md:flex' <span className={clsx(
symbol={<IoLink size={16} className='inline-block mr-1' />} 'text-xs font-mono truncate select-all flex-1 opacity-50',
tooltipProps={{ content: '点击复制地址' }} hasBackground ? 'text-white' : 'text-default-600'
size="sm" )}>{path}</span>
>
{path}
</Snippet>
</div> </div>
<div className='flex gap-2 items-center flex-shrink-0'> <div className='flex items-center gap-2 flex-shrink-0 ml-auto'>
<Button <Popover placement='bottom-end' backdrop='blur'>
size='sm'
variant='flat'
color='default'
radius='full'
isIconOnly
className='bg-white/40 dark:bg-white/10 md:hidden font-medium text-default-700'
onPress={() => setIsStructOpen(true)}
>
<TbCode className="text-lg" />
</Button>
<Button
size='sm'
variant='flat'
color='default'
radius='full'
className='bg-white/40 dark:bg-white/10 hidden md:flex font-medium text-default-700'
startContent={<TbCode className="text-lg" />}
onPress={() => setIsStructOpen(true)}
>
</Button>
<Popover placement='bottom-end'>
<PopoverTrigger> <PopoverTrigger>
<Button <Button size='sm' variant='light' radius='full' isIconOnly className='h-10 w-10 opacity-40 hover:opacity-100'>
size='sm' <IoSettingsSharp className="text-lg" />
variant='flat'
color='default'
radius='full'
className='bg-white/40 dark:bg-white/10 text-default-700 font-medium'
startContent={<IoSettingsSharp className="animate-spin-slow-on-hover text-lg" />}
>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className='w-[340px] p-4 bg-white/80 dark:bg-black/80 backdrop-blur-xl border border-white/20 shadow-xl rounded-2xl'> <PopoverContent className='w-[260px] p-3 rounded-xl border border-white/10 shadow-2xl bg-white/80 dark:bg-black/80 backdrop-blur-xl'>
<div className='flex flex-col gap-4 w-full'> <div className='flex flex-col gap-2'>
<h3 className='font-bold text-lg text-default-700'></h3> <p className='text-[10px] font-bold opacity-30 uppercase tracking-widest'>Debug Setup</p>
<Input <Input label='Base URL' value={httpConfig.url} onChange={(e) => setHttpConfig({ ...httpConfig, url: e.target.value })} size='sm' variant='flat' />
label='HTTP URL' <Input label='Token' value={httpConfig.token} onChange={(e) => setHttpConfig({ ...httpConfig, token: e.target.value })} size='sm' variant='flat' />
placeholder='输入 HTTP URL'
value={httpConfig.url}
onChange={(e) => setHttpConfig({ ...httpConfig, url: e.target.value })}
variant='bordered'
labelPlacement='outside'
classNames={{
inputWrapper: 'bg-default-100/50 backdrop-blur-sm border-default-200/50',
}}
/>
<Input
label='Token'
placeholder='输入 Token'
value={httpConfig.token}
onChange={(e) => setHttpConfig({ ...httpConfig, token: e.target.value })}
variant='bordered'
labelPlacement='outside'
classNames={{
inputWrapper: 'bg-default-100/50 backdrop-blur-sm border-default-200/50',
}}
/>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@ -176,133 +131,143 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
onPress={sendRequest} onPress={sendRequest}
color='primary' color='primary'
radius='full' radius='full'
className='font-bold px-6 shadow-lg shadow-primary/30' size='sm'
className='h-10 px-6 font-bold shadow-md shadow-primary/20 hover:scale-[1.02] active:scale-[0.98]'
isLoading={isFetching} isLoading={isFetching}
startContent={!isFetching && <IoSend />} startContent={!isFetching && <IoSend className="text-xs" />}
> >
</Button> </Button>
</div> </div>
</div> </div>
<div className='flex-1 grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 min-h-0 overflow-auto'> <div className='flex-1 flex flex-col min-h-0 bg-transparent'>
{/* Request Column */} <div className='px-4 flex flex-wrap items-center justify-between flex-shrink-0 min-h-[36px] gap-2 py-1'>
<Card className='bg-white/40 dark:bg-white/5 backdrop-blur-md border border-white/20 shadow-sm h-full flex flex-col'> <Tabs
<CardHeader className='font-bold text-lg gap-2 pb-2 px-4 pt-4 border-b border-white/10 flex-shrink-0 justify-between items-center'> size="sm"
<div className='flex items-center gap-2'> variant="underlined"
<span className='w-2 h-6 rounded-full bg-primary-500'></span> selectedKey={activeTab}
(Request) onSelectionChange={setActiveTab}
</div> classNames={{
<div className='flex gap-2'> cursor: 'bg-primary h-0.5',
<ChatInputModal /> tab: 'px-0 mr-5 h-8',
tabList: 'p-0 border-none',
tabContent: 'text-[11px] font-bold opacity-30 group-data-[selected=true]:opacity-80 transition-opacity'
}}
>
<Tab key="request" title="请求参数" />
<Tab key="docs" title="接口定义" />
</Tabs>
<div className='flex items-center gap-1 ml-auto'>
<ChatInputModal>
{(onOpen) => (
<Tooltip content="构造消息 (CQ码)" closeDelay={0}>
<Button <Button
isIconOnly
size='sm' size='sm'
color='primary' variant='light'
variant='flat'
radius='full' radius='full'
className="bg-primary/10 text-primary" className='h-7 w-7 text-primary/80 bg-primary/10 hover:bg-primary/20'
onPress={onOpen}
>
<TbMessageCode size={16} />
</Button>
</Tooltip>
)}
</ChatInputModal>
<Tooltip content="生成示例参数" closeDelay={0}>
<Button
isIconOnly
size='sm'
variant='light'
radius='full'
className='h-7 w-7 text-default-400 hover:text-primary hover:bg-default-100/50'
onPress={() => setRequestBody(generateDefaultJson(data.request))} onPress={() => setRequestBody(generateDefaultJson(data.request))}
> >
<TbCode size={16} />
</Button> </Button>
</Tooltip>
</div> </div>
</CardHeader> </div>
<CardBody className='p-0 flex-1 relative'>
<div className='absolute inset-0'> <div className='flex-1 min-h-0 relative px-3 pb-2 mt-1'>
<div className={clsx(
'h-full rounded-xl overflow-y-auto no-scrollbar transition-all',
hasBackground ? 'bg-transparent' : 'bg-white/10 dark:bg-black/10'
)}>
{activeTab === 'request' ? (
<CodeEditor <CodeEditor
value={requestBody} value={requestBody}
onChange={(value) => setRequestBody(value ?? '')} onChange={(value) => setRequestBody(value ?? '')}
language='json' language='json'
options={{ options={{
minimap: { enabled: false }, minimap: { enabled: false },
fontSize: 13, fontSize: 12,
padding: { top: 10, bottom: 10 },
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
wordWrap: 'on',
padding: { top: 12 },
lineNumbersMinChars: 3
}} }}
/> />
</div> ) : (
</CardBody> <div className='p-6 space-y-10'>
</Card> <section>
<h3 className='text-[10px] font-bold opacity-20 uppercase tracking-[0.2em] mb-4'>Request - </h3>
<Card className='bg-white/40 dark:bg-white/5 backdrop-blur-md border border-white/20 shadow-sm h-full flex flex-col'>
<PageLoading loading={isFetching} />
<CardHeader className='font-bold text-lg gap-2 pb-2 px-4 pt-4 border-b border-white/10 flex-shrink-0 justify-between items-center'>
<div className='flex items-center gap-2'>
<span className='w-2 h-6 rounded-full bg-secondary-500'></span>
(Response)
</div>
<Button
size='sm'
color='primary'
variant='flat'
radius='full'
className="bg-primary/10 text-primary"
onPress={() => {
navigator.clipboard.writeText(responseContent);
toast.success('已复制');
}}
>
</Button>
</CardHeader>
<CardBody className='p-0 flex-1 relative bg-black/5 dark:bg-black/30'>
<div className='absolute inset-0 overflow-auto p-4'>
<pre className='text-xs font-mono whitespace-pre-wrap break-all'>
{responseContent || <span className='text-default-400 italic'>...</span>}
</pre>
</div>
</CardBody>
</Card>
</div>
{/* Struct Display - maybe put in a modal or separate tab?
For now, putting it in a collapsed/compact area at bottom is tricky with "h-[calc(100vh)]".
User wants "Thorough optimization".
I will make Struct Display a Drawer or Modal, OR put it below if we want scrolling.
But I set height to fixed full screen.
Let's put Struct Display in a Tab or Toggle at Top?
Or just let the main container scroll and remove fixed height?
Layout choice: Fixed height editors are good for workflow. Structure is reference.
I will leave Struct Display OUT of the fixed view, or add a toggle to show it.
Let's add a "View Structure" button in header that opens a Modal.
Yes, that's cleaner.
*/}
<Modal
isOpen={isStructOpen}
onOpenChange={setIsStructOpen}
size='5xl'
scrollBehavior='inside'
backdrop='blur'
classNames={{
base: 'bg-white/80 dark:bg-black/80 backdrop-blur-xl border border-white/20',
header: 'border-b border-white/10',
body: 'p-6',
}}
>
<ModalContent>
{() => (
<>
<ModalHeader className='flex flex-col gap-1'>
API
</ModalHeader>
<ModalBody>
<div className='grid grid-cols-1 md:grid-cols-2 gap-6'>
<div>
<h2 className='text-xl font-bold mb-4 text-primary-500'> (Request)</h2>
<DisplayStruct schema={parsedRequest} /> <DisplayStruct schema={parsedRequest} />
</div>
<div>
<h2 className='text-xl font-bold mb-4 text-secondary-500'> (Response)</h2>
<DisplayStruct schema={parsedResponse} />
</div>
</div>
</ModalBody>
</>
)}
</ModalContent>
</Modal>
</section> </section>
<div className='h-px bg-white/5 w-full' />
<section>
<h3 className='text-[10px] font-bold opacity-20 uppercase tracking-[0.2em] mb-4'>Response - </h3>
<DisplayStruct schema={parsedResponse} />
</section>
</div>
)}
</div>
</div>
</div>
{/* Response Area */}
<div className='flex-shrink-0 px-3 pb-3'>
<div
className={clsx(
'rounded-xl transition-all overflow-hidden border border-white/5',
hasBackground ? 'bg-white/5' : 'bg-white/5 dark:bg-black/5'
)}
>
<div
className='flex items-center justify-between px-4 py-2 cursor-pointer hover:bg-white/5 transition-all select-none'
onClick={() => setResponseExpanded(!responseExpanded)}
>
<div className='flex items-center gap-2'>
<IoChevronDown className={clsx('text-[10px] transition-transform duration-300 opacity-20', !responseExpanded && '-rotate-90')} />
<span className='text-[10px] font-semibold tracking-wide opacity-30 uppercase'>Response</span>
</div>
<div className='flex items-center gap-2'>
{responseStatus && (
<Chip size="sm" variant="flat" color={responseStatus.code >= 200 && responseStatus.code < 300 ? 'success' : 'danger'} className="h-4 text-[9px] font-mono px-1.5 opacity-50">
{responseStatus.code}
</Chip>
)}
<Button size='sm' variant='light' isIconOnly radius='full' className='h-6 w-6 opacity-20 hover:opacity-80 transition-opacity' onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(responseContent); toast.success('已复制'); }}>
<IoCopy size={10} />
</Button>
</div>
</div>
{responseExpanded && (
<div className='h-36 overflow-auto relative font-mono text-[11px] px-4 pb-3 no-scrollbar transition-all'>
<PageLoading loading={isFetching} />
<div className={clsx(
'whitespace-pre-wrap break-all leading-relaxed opacity-40 transition-opacity',
hasBackground ? 'text-white' : 'text-default-600'
)}>
{responseContent || '...'}
</div>
</div>
)}
</div>
</div>
</section>
); );
}; };

View File

@ -1,11 +1,13 @@
import { Button } from '@heroui/button';
import { Card, CardBody } from '@heroui/card';
import { Input } from '@heroui/input'; import { Input } from '@heroui/input';
import clsx from 'clsx'; import clsx from 'clsx';
import { motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { TbApi, TbLayoutSidebarLeftCollapseFilled, TbSearch } from 'react-icons/tb'; import { TbChevronRight, TbFolder, TbSearch } from 'react-icons/tb';
import oneBotHttpApiGroup from '@/const/ob_api/group';
import oneBotHttpApiMessage from '@/const/ob_api/message';
import oneBotHttpApiSystem from '@/const/ob_api/system';
import oneBotHttpApiUser from '@/const/ob_api/user';
import type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api'; import type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api';
export interface OneBotApiNavListProps { export interface OneBotApiNavListProps {
@ -19,53 +21,77 @@ export interface OneBotApiNavListProps {
const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => { const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
const { data, selectedApi, onSelect, openSideBar, onToggle } = props; const { data, selectedApi, onSelect, openSideBar, onToggle } = props;
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
const groups = useMemo(() => {
const rawGroups = [
{ id: 'user', label: '账号相关', keys: Object.keys(oneBotHttpApiUser) },
{ id: 'message', label: '消息相关', keys: Object.keys(oneBotHttpApiMessage) },
{ id: 'group', label: '群聊相关', keys: Object.keys(oneBotHttpApiGroup) },
{ id: 'system', label: '系统操作', keys: Object.keys(oneBotHttpApiSystem) },
];
return rawGroups.map(g => {
const apis = g.keys
.filter(k => k in data)
.map(k => ({ path: k as OneBotHttpApiPath, ...data[k as OneBotHttpApiPath] }))
.filter(api =>
api.path.toLowerCase().includes(searchValue.toLowerCase()) ||
api.description?.toLowerCase().includes(searchValue.toLowerCase())
);
return { ...g, apis };
}).filter(g => g.apis.length > 0);
}, [data, searchValue]);
const toggleGroup = (id: string) => {
setExpandedGroups(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
return ( return (
<> <>
{/* Mobile backdrop overlay */} {/* Mobile backdrop overlay - below header (z-40) */}
<AnimatePresence>
{openSideBar && ( {openSideBar && (
<div <motion.div
className="fixed inset-0 bg-black/20 backdrop-blur-[1px] z-10 md:hidden" initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/50 backdrop-blur-[2px] z-30 md:hidden"
onClick={() => onToggle?.(false)} onClick={() => onToggle?.(false)}
/> />
)} )}
</AnimatePresence>
<motion.div <motion.div
className={clsx( className={clsx(
'h-full z-20 flex-shrink-0 border border-white/10 dark:border-white/5 bg-white/60 dark:bg-black/60 backdrop-blur-2xl shadow-xl overflow-hidden rounded-2xl', 'h-full z-40 flex-shrink-0 border-r border-white/10 dark:border-white/5 overflow-hidden transition-all',
'fixed md:relative left-0 top-0 md:top-auto md:left-auto' // Mobile: absolute position, drawer style
// Desktop: relative position, pushing content
'absolute md:relative left-0 top-0',
'bg-white/80 dark:bg-black/80 md:bg-transparent backdrop-blur-2xl md:backdrop-blur-none'
)} )}
initial={false} initial={false}
animate={{ width: openSideBar ? 280 : 0, opacity: openSideBar ? 1 : 0 }} animate={{
width: openSideBar ? 260 : 0,
opacity: openSideBar ? 1 : 0,
x: (window.innerWidth < 768 && !openSideBar) ? -260 : 0 // Optional: slide out completely on mobile
}}
transition={{ type: 'spring', stiffness: 300, damping: 30 }} transition={{ type: 'spring', stiffness: 300, damping: 30 }}
> >
<div className='w-[280px] h-full flex flex-col'> <div className='w-[260px] h-full flex flex-col'>
<div className='p-3 md:p-4 flex justify-between items-center border-b border-white/10'> <div className='p-3'>
<span className='font-bold text-lg px-2 flex items-center gap-2'>
<TbApi className="text-primary" /> API
</span>
{onToggle && (
<Button
isIconOnly
size='sm'
variant='light'
onPress={() => onToggle(false)}
className="text-default-500 hover:text-default-800"
>
<TbLayoutSidebarLeftCollapseFilled size={20} />
</Button>
)}
</div>
<div className='p-3 pb-0'>
<Input <Input
classNames={{ classNames={{
inputWrapper: inputWrapper:
'bg-white/40 dark:bg-white/10 backdrop-blur-md border border-white/20 hover:bg-white/60 dark:hover:bg-white/20 transition-all shadow-sm', 'bg-white/5 dark:bg-white/5 border border-white/10 hover:bg-white/10 transition-all shadow-none',
input: 'bg-transparent text-default-700 placeholder:text-default-400', input: 'bg-transparent text-xs placeholder:opacity-30',
}} }}
isClearable isClearable
radius='lg' radius='lg'
placeholder='搜索 API...' placeholder='搜索接口...'
startContent={<TbSearch className="text-default-400" />} startContent={<TbSearch size={14} className="opacity-30" />}
value={searchValue} value={searchValue}
onChange={(e) => setSearchValue(e.target.value)} onChange={(e) => setSearchValue(e.target.value)}
onClear={() => setSearchValue('')} onClear={() => setSearchValue('')}
@ -73,49 +99,68 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
/> />
</div> </div>
<div className='flex-1 p-3 flex flex-col gap-2 overflow-y-auto scroll-smooth'> <div className='flex-1 px-2 pb-4 flex flex-col gap-1 overflow-y-auto no-scrollbar'>
{Object.entries(data).map(([apiName, api]) => { {groups.map((group) => {
const isMatch = apiName.toLowerCase().includes(searchValue.toLowerCase()) || const isOpen = expandedGroups.includes(group.id) || searchValue.length > 0;
api.description?.toLowerCase().includes(searchValue.toLowerCase()); return (
if (!isMatch) return null; <div key={group.id} className="flex flex-col">
{/* Group Header */}
const isSelected = apiName === selectedApi; <div
className="flex items-center gap-2 px-2 py-2 rounded-lg cursor-pointer hover:bg-white/5 transition-all group/header"
onClick={() => toggleGroup(group.id)}
>
<TbChevronRight
size={12}
className={clsx(
'transition-transform duration-200 opacity-20 group-hover/header:opacity-50',
isOpen && 'rotate-90'
)}
/>
<TbFolder className="text-primary/60" size={16} />
<span className="text-[13px] font-medium opacity-70 flex-1">{group.label}</span>
<span className="text-[11px] opacity-20 font-mono tracking-tighter">({group.apis.length})</span>
</div>
{/* Group Content */}
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden flex flex-col gap-1 ml-4 border-l border-white/5 pl-2 my-1"
>
{group.apis.map((api) => {
const isSelected = api.path === selectedApi;
return ( return (
<div <div
key={apiName} key={api.path}
role="button" onClick={() => onSelect(api.path)}
tabIndex={0}
onClick={() => onSelect(apiName as OneBotHttpApiPath)}
onKeyDown={(e) => e.key === 'Enter' && onSelect(apiName as OneBotHttpApiPath)}
className="cursor-pointer focus:outline-none"
>
<Card
shadow='none'
className={clsx( className={clsx(
'w-full border border-transparent transition-all duration-200 group min-h-[60px]', 'flex flex-col gap-0.5 px-3 py-2 rounded-lg cursor-pointer transition-all border border-transparent select-none',
isSelected isSelected
? 'bg-primary/10 border-primary/20 shadow-sm' ? 'bg-primary/20 border-primary/20 shadow-sm'
: 'bg-transparent hover:bg-white/40 dark:hover:bg-white/5' : 'hover:bg-white/5'
)} )}
> >
<CardBody className='p-3 text-left'>
<div className='flex flex-col gap-1'>
<span className={clsx( <span className={clsx(
'font-medium text-sm transition-colors', 'text-[12px] font-medium transition-colors truncate',
isSelected ? 'text-primary-600 dark:text-primary-400' : 'text-default-700 dark:text-default-200 group-hover:text-default-900' isSelected ? 'text-primary' : 'opacity-60'
)}> )}>
{api.description} {api.description}
</span> </span>
<span className={clsx( <span className={clsx(
'text-xs font-mono truncate transition-colors', 'text-[10px] font-mono truncate transition-all',
isSelected ? 'text-primary-400 dark:text-primary-300' : 'text-default-400 group-hover:text-default-500' isSelected ? 'text-primary/60' : 'opacity-20'
)}> )}>
{apiName} {api.path}
</span> </span>
</div> </div>
</CardBody> );
</Card> })}
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
})} })}

View File

@ -3,8 +3,8 @@ import { SharedSelection } from '@heroui/system';
import type { Selection } from '@react-types/shared'; import type { Selection } from '@react-types/shared';
export interface FilterMessageTypeProps { export interface FilterMessageTypeProps {
filterTypes: Selection filterTypes: Selection;
onSelectionChange: (keys: SharedSelection) => void onSelectionChange: (keys: SharedSelection) => void;
} }
const items = [ const items = [
{ label: '元事件', value: 'meta_event' }, { label: '元事件', value: 'meta_event' },
@ -26,6 +26,7 @@ const FilterMessageType: React.FC<FilterMessageTypeProps> = (props) => {
}} }}
label='筛选消息类型' label='筛选消息类型'
selectionMode='multiple' selectionMode='multiple'
className='w-full'
items={items} items={items}
renderValue={(value) => { renderValue={(value) => {
if (value.length === items.length) { if (value.length === items.length) {

View File

@ -43,7 +43,7 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
return ( return (
<> <>
<Button onPress={onOpen} color='primary' radius='full' variant='flat'> <Button onPress={onOpen} color='primary' radius='full' variant='flat' size='sm' className="font-medium">
</Button> </Button>
<Modal <Modal

View File

@ -33,18 +33,18 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
}) => { }) => {
return ( return (
<div className={clsx( <div className={clsx(
'flex text-sm gap-2 p-3 items-center rounded-lg border border-white/20 transition-colors', 'flex text-sm gap-3 py-2 items-center transition-colors',
hasBackground hasBackground
? 'bg-white/10 hover:bg-white/20 text-white/90' ? 'text-white/90'
: 'bg-white/50 dark:bg-white/5 hover:bg-white/70 dark:hover:bg-white/10 text-default-600 dark:text-gray-300' : 'text-default-600 dark:text-gray-300'
)}> )}>
<div className="text-lg opacity-80">{icon}</div> <div className="text-lg opacity-70">{icon}</div>
<div className='w-24 font-medium'>{title}</div> <div className='w-24 font-medium'>{title}</div>
<div className={clsx( <div className={clsx(
'text-xs font-mono', 'text-xs font-mono flex-1',
hasBackground ? 'text-white/70' : 'text-default-500' hasBackground ? 'text-white/80' : 'text-default-500'
)}>{value}</div> )}>{value}</div>
<div className='ml-auto'>{endContent}</div> <div>{endContent}</div>
</div> </div>
); );
}; };

View File

@ -28,20 +28,17 @@ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
return ( return (
<div <div
className={clsx( className={clsx(
'p-2 rounded-lg text-sm border border-white/20 transition-colors', 'py-1.5 text-sm transition-colors',
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between', size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between',
hasBackground
? 'bg-white/10 hover:bg-white/20'
: 'bg-white/50 dark:bg-white/5 hover:bg-white/70 dark:hover:bg-white/10'
)} )}
> >
<div className={clsx( <div className={clsx(
'w-24 font-medium', 'w-24 font-medium',
hasBackground ? 'text-white/90' : 'text-default-600' hasBackground ? 'text-white/90' : 'text-default-600 dark:text-gray-300'
)}>{title}</div> )}>{title}</div>
<div className={clsx( <div className={clsx(
'font-mono text-xs', 'font-mono text-xs',
hasBackground ? 'text-white/70' : 'text-default-500' hasBackground ? 'text-white/80' : 'text-default-500'
)}> )}>
{value} {value}
{unit && <span className="ml-0.5 opacity-70">{unit}</span>} {unit && <span className="ml-0.5 opacity-70">{unit}</span>}

View File

@ -35,13 +35,17 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
const { className, onInput, onKey, onResize, ...rest } = props; const { className, onInput, onKey, onResize, ...rest } = props;
const { theme } = useTheme(); const { theme } = useTheme();
useEffect(() => { useEffect(() => {
// 根据屏幕宽度决定字体大小,手机端使用更小的字体
const isMobile = window.innerWidth < 768;
const fontSize = isMobile ? 11 : 14;
const terminal = new Terminal({ const terminal = new Terminal({
allowTransparency: true, allowTransparency: true,
fontFamily: fontFamily:
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace', '"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline', cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false, drawBoldTextInBrightColors: false,
fontSize: 14, fontSize: fontSize,
lineHeight: 1.2, lineHeight: 1.2,
}); });
terminalRef.current = terminal; terminalRef.current = terminal;
@ -56,7 +60,10 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
terminal.loadAddon(fitAddon); terminal.loadAddon(fitAddon);
terminal.open(domRef.current!); terminal.open(domRef.current!);
// 只在非手机端使用 Canvas 渲染器,手机端使用默认 DOM 渲染器以避免渲染问题
if (!isMobile) {
terminal.loadAddon(new CanvasAddon()); terminal.loadAddon(new CanvasAddon());
}
terminal.onData((data) => { terminal.onData((data) => {
if (onInput) { if (onInput) {
onInput(data); onInput(data);

View File

@ -1,63 +1,160 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; 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 { TbSquareRoundedChevronLeftFilled } from 'react-icons/tb';
import key from '@/const/key';
import oneBotHttpApi from '@/const/ob_api'; 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 OneBotApiDebug from '@/components/onebot/api/debug';
import OneBotApiNavList from '@/components/onebot/api/nav_list'; import OneBotApiNavList from '@/components/onebot/api/nav_list';
export default function HttpDebug () { export default function HttpDebug () {
const [selectedApi, setSelectedApi] = const [activeApi, setActiveApi] = useState<OneBotHttpApiPath | null>('/set_qq_profile');
useState<keyof OneBotHttpApi>('/set_qq_profile'); const [openApis, setOpenApis] = useState<OneBotHttpApiPath[]>(['/set_qq_profile']);
const data = oneBotHttpApi[selectedApi];
const contentRef = useRef<HTMLDivElement>(null);
const [openSideBar, setOpenSideBar] = useState(true); const [openSideBar, setOpenSideBar] = useState(true);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
// Auto-collapse sidebar on mobile initial load
useEffect(() => { useEffect(() => {
contentRef?.current?.scrollTo?.({ if (window.innerWidth < 768) {
top: 0, setOpenSideBar(false);
behavior: 'smooth', }
}); }, []);
}, [selectedApi]);
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 ( return (
<> <>
<title>HTTP调试 - NapCat WebUI</title> <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'> <div className='h-[calc(100vh-3.5rem)] p-0 md:p-4'>
<OneBotApiNavList <div className={clsx(
data={oneBotHttpApi} 'h-full flex flex-col overflow-hidden transition-all relative',
selectedApi={selectedApi} 'rounded-none md:rounded-2xl',
onSelect={(api) => { hasBackground
setSelectedApi(api); ? 'bg-white/5 dark:bg-black/5 backdrop-blur-sm'
// Auto-close sidebar on mobile after selection : 'bg-white/20 dark:bg-black/10 backdrop-blur-sm shadow-sm'
if (window.innerWidth < 768) { )}>
setOpenSideBar(false); {/* 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'>
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 <Button
isIconOnly isIconOnly
size="sm" size="sm"
variant="flat" variant="light"
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")} className={clsx(
onPress={() => setOpenSideBar(true)} "opacity-50 hover:opacity-100 transition-all",
openSideBar && "text-primary opacity-100"
)}
onPress={() => setOpenSideBar(!openSideBar)}
> >
<TbSquareRoundedChevronLeftFilled className="transform rotate-180" /> <TbSquareRoundedChevronLeftFilled className={clsx("text-lg transform transition-transform", !openSideBar && "rotate-180")} />
</Button> </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>
<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>
</div> </div>
</> </>

View File

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