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,21 +10,27 @@ 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 (
<> <>
<Button {children ? children(onOpen) : (
onPress={onOpen} <Button
color='primary' onPress={onOpen}
radius='full' color='primary'
variant='flat' radius='full'
size='sm' variant='flat'
className="bg-primary/10 text-primary" size='sm'
> className="bg-primary/10 text-primary"
>
</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
isIconOnly
size='sm'
variant='light'
radius='full'
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 <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-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>
</div> </Tooltip>
</CardHeader> </div>
<CardBody className='p-0 flex-1 relative'> </div>
<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'> <DisplayStruct schema={parsedRequest} />
<PageLoading loading={isFetching} /> </section>
<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='h-px bg-white/5 w-full' />
<div className='flex items-center gap-2'> <section>
<span className='w-2 h-6 rounded-full bg-secondary-500'></span> <h3 className='text-[10px] font-bold opacity-20 uppercase tracking-[0.2em] mb-4'>Response - </h3>
(Response) <DisplayStruct schema={parsedResponse} />
</div> </section>
<Button </div>
size='sm' )}
color='primary' </div>
variant='flat' </div>
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> </div>
{/* Struct Display - maybe put in a modal or separate tab? {/* Response Area */}
For now, putting it in a collapsed/compact area at bottom is tricky with "h-[calc(100vh)]". <div className='flex-shrink-0 px-3 pb-3'>
User wants "Thorough optimization". <div
I will make Struct Display a Drawer or Modal, OR put it below if we want scrolling. className={clsx(
But I set height to fixed full screen. 'rounded-xl transition-all overflow-hidden border border-white/5',
Let's put Struct Display in a Tab or Toggle at Top? hasBackground ? 'bg-white/5' : 'bg-white/5 dark:bg-black/5'
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} />
</div>
<div>
<h2 className='text-xl font-bold mb-4 text-secondary-500'> (Response)</h2>
<DisplayStruct schema={parsedResponse} />
</div>
</div>
</ModalBody>
</>
)} )}
</ModalContent> >
</Modal> <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> </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) */}
{openSideBar && ( <AnimatePresence>
<div {openSideBar && (
className="fixed inset-0 bg-black/20 backdrop-blur-[1px] z-10 md:hidden" <motion.div
onClick={() => onToggle?.(false)} 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)}
/>
)}
</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());
if (!isMatch) return null;
const isSelected = apiName === selectedApi;
return ( return (
<div <div key={group.id} className="flex flex-col">
key={apiName} {/* Group Header */}
role="button" <div
tabIndex={0} className="flex items-center gap-2 px-2 py-2 rounded-lg cursor-pointer hover:bg-white/5 transition-all group/header"
onClick={() => onSelect(apiName as OneBotHttpApiPath)} onClick={() => toggleGroup(group.id)}
onKeyDown={(e) => e.key === 'Enter' && onSelect(apiName as OneBotHttpApiPath)}
className="cursor-pointer focus:outline-none"
>
<Card
shadow='none'
className={clsx(
'w-full border border-transparent transition-all duration-200 group min-h-[60px]',
isSelected
? 'bg-primary/10 border-primary/20 shadow-sm'
: 'bg-transparent hover:bg-white/40 dark:hover:bg-white/5'
)}
> >
<CardBody className='p-3 text-left'> <TbChevronRight
<div className='flex flex-col gap-1'> size={12}
<span className={clsx( className={clsx(
'font-medium text-sm transition-colors', 'transition-transform duration-200 opacity-20 group-hover/header:opacity-50',
isSelected ? 'text-primary-600 dark:text-primary-400' : 'text-default-700 dark:text-default-200 group-hover:text-default-900' isOpen && 'rotate-90'
)}> )}
{api.description} />
</span> <TbFolder className="text-primary/60" size={16} />
<span className={clsx( <span className="text-[13px] font-medium opacity-70 flex-1">{group.label}</span>
'text-xs font-mono truncate transition-colors', <span className="text-[11px] opacity-20 font-mono tracking-tighter">({group.apis.length})</span>
isSelected ? 'text-primary-400 dark:text-primary-300' : 'text-default-400 group-hover:text-default-500' </div>
)}>
{apiName} {/* Group Content */}
</span> <AnimatePresence initial={false}>
</div> {isOpen && (
</CardBody> <motion.div
</Card> 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 (
<div
key={api.path}
onClick={() => onSelect(api.path)}
className={clsx(
'flex flex-col gap-0.5 px-3 py-2 rounded-lg cursor-pointer transition-all border border-transparent select-none',
isSelected
? 'bg-primary/20 border-primary/20 shadow-sm'
: 'hover:bg-white/5'
)}
>
<span className={clsx(
'text-[12px] font-medium transition-colors truncate',
isSelected ? 'text-primary' : 'opacity-60'
)}>
{api.description}
</span>
<span className={clsx(
'text-[10px] font-mono truncate transition-all',
isSelected ? 'text-primary/60' : 'opacity-20'
)}>
{api.path}
</span>
</div>
);
})}
</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!);
terminal.loadAddon(new CanvasAddon()); // 只在非手机端使用 Canvas 渲染器,手机端使用默认 DOM 渲染器以避免渲染问题
if (!isMobile) {
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} <Button
onToggle={setOpenSideBar} isIconOnly
/> size="sm"
<div variant="light"
ref={contentRef} className={clsx(
className='flex-1 h-full overflow-hidden flex flex-col relative' "opacity-50 hover:opacity-100 transition-all",
> openSideBar && "text-primary opacity-100"
{/* Toggle Button Container - positioned on top-left of content if sidebar is closed */} )}
<div className='absolute top-2 left-2 z-30'> onPress={() => setOpenSideBar(!openSideBar)}
<Button >
isIconOnly <TbSquareRoundedChevronLeftFilled className={clsx("text-lg transform transition-transform", !openSideBar && "rotate-180")} />
size="sm" </Button>
variant="flat" <h1 className={clsx(
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")} 'text-sm font-bold tracking-tight',
onPress={() => setOpenSideBar(true)} hasBackground ? 'text-white/80' : 'text-default-700 dark:text-gray-200'
> )}></h1>
<TbSquareRoundedChevronLeftFilled className="transform rotate-180" /> </div>
</Button>
</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,61 +52,106 @@ 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='md'
size='lg' radius='full'
radius='full' color={shouldConnect ? 'danger' : 'primary'}
color={shouldConnect ? 'danger' : 'primary'} className='font-bold shadow-lg min-w-[100px]'
className='w-full md:w-auto' 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'>
<div className='grid gap-2 md:grid-cols-5 items-center md:w-fit'> {/* Status Bar */}
<WSStatus state={readyState} /> <div className={clsx(
<div className='md:w-64 max-w-full col-span-2'> '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} {FilterMessagesType}
</div> </div>
<div className='flex gap-2 justify-end col-span-2 md:col-span-2'> </div>
<Button <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'>
size='sm' <Button
color='danger' size='sm'
variant='flat' color='danger'
onPress={clearMessages} variant='flat'
> radius='full'
className='font-medium'
</Button> onPress={clearMessages}
<OneBotSendModal sendMessage={sendMessage} /> >
</div>
</Button>
<OneBotSendModal sendMessage={sendMessage} />
</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>