mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-12-26 18:52:49 +08: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:
parent
c4f7107038
commit
649165bf00
@ -10,21 +10,27 @@ import {
|
||||
|
||||
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();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onPress={onOpen}
|
||||
color='primary'
|
||||
radius='full'
|
||||
variant='flat'
|
||||
size='sm'
|
||||
className="bg-primary/10 text-primary"
|
||||
>
|
||||
构造消息
|
||||
</Button>
|
||||
{children ? children(onOpen) : (
|
||||
<Button
|
||||
onPress={onOpen}
|
||||
color='primary'
|
||||
radius='full'
|
||||
variant='flat'
|
||||
size='sm'
|
||||
className="bg-primary/10 text-primary"
|
||||
>
|
||||
构造消息
|
||||
</Button>
|
||||
)}
|
||||
<Modal
|
||||
size='4xl'
|
||||
scrollBehavior='inside'
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
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 { Tooltip } from '@heroui/tooltip';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Chip } from '@heroui/chip';
|
||||
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 { IoLink, IoSend, IoSettingsSharp } from 'react-icons/io5';
|
||||
import { TbApi, TbCode } from 'react-icons/tb';
|
||||
import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5';
|
||||
import { TbCode, TbMessageCode } from 'react-icons/tb';
|
||||
|
||||
import key from '@/const/key';
|
||||
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api';
|
||||
@ -40,14 +41,18 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
const [requestBody, setRequestBody] = useState('{}');
|
||||
const [responseContent, setResponseContent] = useState('');
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [isStructOpen, setIsStructOpen] = useState(false);
|
||||
const responseRef = useRef<HTMLDivElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<any>('request');
|
||||
const [responseExpanded, setResponseExpanded] = useState(true);
|
||||
const [responseStatus, setResponseStatus] = useState<{ code: number; text: string; } | null>(null);
|
||||
const parsedRequest = parse(data.request);
|
||||
const parsedResponse = parse(data.response);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const sendRequest = async () => {
|
||||
if (isFetching) return;
|
||||
setIsFetching(true);
|
||||
setResponseStatus(null);
|
||||
const r = toast.loading('正在发送请求...');
|
||||
try {
|
||||
const parsedRequestBody = JSON.parse(requestBody);
|
||||
@ -62,18 +67,20 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
})
|
||||
.then((res) => {
|
||||
setResponseContent(parseAxiosResponse(res));
|
||||
toast.success('请求发送完成,请查看响应');
|
||||
setResponseStatus({ code: res.status, text: res.statusText });
|
||||
setResponseExpanded(true);
|
||||
toast.success('请求成功');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('请求发送失败:' + err.message);
|
||||
toast.error('请求失败:' + err.message);
|
||||
setResponseContent(parseAxiosResponse(err.response));
|
||||
if (err.response) {
|
||||
setResponseStatus({ code: err.response.status, text: err.response.statusText });
|
||||
}
|
||||
setResponseExpanded(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false);
|
||||
responseRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
toast.dismiss(r);
|
||||
});
|
||||
} catch (_error) {
|
||||
@ -86,88 +93,36 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
useEffect(() => {
|
||||
setRequestBody(generateDefaultJson(data.request));
|
||||
setResponseContent('');
|
||||
setResponseStatus(null);
|
||||
}, [path]);
|
||||
|
||||
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'>
|
||||
<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'>
|
||||
<div className='flex items-center gap-2 md:gap-4 overflow-hidden'>
|
||||
<h1 className='text-lg md:text-xl font-bold flex items-center gap-2 text-primary-500 flex-shrink-0'>
|
||||
<TbApi size={24} />
|
||||
<span className='truncate'>{data.description}</span>
|
||||
</h1>
|
||||
<Snippet
|
||||
className='bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 hidden md:flex'
|
||||
symbol={<IoLink size={16} className='inline-block mr-1' />}
|
||||
tooltipProps={{ content: '点击复制地址' }}
|
||||
size="sm"
|
||||
>
|
||||
{path}
|
||||
</Snippet>
|
||||
<section className='h-full flex flex-col overflow-hidden bg-transparent'>
|
||||
{/* URL Bar */}
|
||||
<div className='flex flex-wrap md:flex-nowrap items-center gap-2 p-2 md:p-4 pb-2 flex-shrink-0'>
|
||||
<div className={clsx(
|
||||
'flex-grow flex items-center gap-2 px-3 md:px-4 h-10 rounded-xl transition-all w-full md:w-auto',
|
||||
hasBackground ? 'bg-white/5' : 'bg-black/5 dark:bg-white/5'
|
||||
)}>
|
||||
<Chip size="sm" variant="shadow" color="primary" className="font-bold text-[10px] h-5 min-w-[40px]">POST</Chip>
|
||||
<span className={clsx(
|
||||
'text-xs font-mono truncate select-all flex-1 opacity-50',
|
||||
hasBackground ? 'text-white' : 'text-default-600'
|
||||
)}>{path}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2 items-center flex-shrink-0'>
|
||||
<Button
|
||||
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'>
|
||||
<div className='flex items-center gap-2 flex-shrink-0 ml-auto'>
|
||||
<Popover placement='bottom-end' backdrop='blur'>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size='sm'
|
||||
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 size='sm' variant='light' radius='full' isIconOnly className='h-10 w-10 opacity-40 hover:opacity-100'>
|
||||
<IoSettingsSharp className="text-lg" />
|
||||
</Button>
|
||||
</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'>
|
||||
<div className='flex flex-col gap-4 w-full'>
|
||||
<h3 className='font-bold text-lg text-default-700'>请求配置</h3>
|
||||
<Input
|
||||
label='HTTP URL'
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
<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-2'>
|
||||
<p className='text-[10px] font-bold opacity-30 uppercase tracking-widest'>Debug Setup</p>
|
||||
<Input label='Base URL' value={httpConfig.url} onChange={(e) => setHttpConfig({ ...httpConfig, url: e.target.value })} size='sm' variant='flat' />
|
||||
<Input label='Token' value={httpConfig.token} onChange={(e) => setHttpConfig({ ...httpConfig, token: e.target.value })} size='sm' variant='flat' />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@ -176,133 +131,143 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
onPress={sendRequest}
|
||||
color='primary'
|
||||
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}
|
||||
startContent={!isFetching && <IoSend />}
|
||||
startContent={!isFetching && <IoSend className="text-xs" />}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 min-h-0 overflow-auto'>
|
||||
{/* Request Column */}
|
||||
<Card className='bg-white/40 dark:bg-white/5 backdrop-blur-md border border-white/20 shadow-sm h-full flex flex-col'>
|
||||
<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-primary-500'></span>
|
||||
请求体 (Request)
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<ChatInputModal />
|
||||
<div className='flex-1 flex flex-col min-h-0 bg-transparent'>
|
||||
<div className='px-4 flex flex-wrap items-center justify-between flex-shrink-0 min-h-[36px] gap-2 py-1'>
|
||||
<Tabs
|
||||
size="sm"
|
||||
variant="underlined"
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={setActiveTab}
|
||||
classNames={{
|
||||
cursor: 'bg-primary h-0.5',
|
||||
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
|
||||
isIconOnly
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
variant='light'
|
||||
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))}
|
||||
>
|
||||
内置示例
|
||||
<TbCode size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className='p-0 flex-1 relative'>
|
||||
<div className='absolute inset-0'>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
value={requestBody}
|
||||
onChange={(value) => setRequestBody(value ?? '')}
|
||||
language='json'
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
padding: { top: 10, bottom: 10 },
|
||||
fontSize: 12,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
padding: { top: 12 },
|
||||
lineNumbersMinChars: 3
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<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 className='p-6 space-y-10'>
|
||||
<section>
|
||||
<h3 className='text-[10px] font-bold opacity-20 uppercase tracking-[0.2em] mb-4'>Request - 请求数据结构</h3>
|
||||
<DisplayStruct schema={parsedRequest} />
|
||||
</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>
|
||||
|
||||
{/* 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} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-xl font-bold mb-4 text-secondary-500'>响应体结构 (Response)</h2>
|
||||
<DisplayStruct schema={parsedResponse} />
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</>
|
||||
{/* 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'
|
||||
)}
|
||||
</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>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Input } from '@heroui/input';
|
||||
import clsx from 'clsx';
|
||||
import { motion } from 'motion/react';
|
||||
import { useState } from 'react';
|
||||
import { TbApi, TbLayoutSidebarLeftCollapseFilled, TbSearch } from 'react-icons/tb';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
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';
|
||||
|
||||
export interface OneBotApiNavListProps {
|
||||
@ -19,53 +21,77 @@ export interface OneBotApiNavListProps {
|
||||
const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
const { data, selectedApi, onSelect, openSideBar, onToggle } = props;
|
||||
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 (
|
||||
<>
|
||||
{/* Mobile backdrop overlay */}
|
||||
{openSideBar && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 backdrop-blur-[1px] z-10 md:hidden"
|
||||
onClick={() => onToggle?.(false)}
|
||||
/>
|
||||
)}
|
||||
{/* Mobile backdrop overlay - below header (z-40) */}
|
||||
<AnimatePresence>
|
||||
{openSideBar && (
|
||||
<motion.div
|
||||
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
|
||||
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',
|
||||
'fixed md:relative left-0 top-0 md:top-auto md:left-auto'
|
||||
'h-full z-40 flex-shrink-0 border-r border-white/10 dark:border-white/5 overflow-hidden transition-all',
|
||||
// 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}
|
||||
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 }}
|
||||
>
|
||||
<div className='w-[280px] h-full flex flex-col'>
|
||||
<div className='p-3 md:p-4 flex justify-between items-center border-b border-white/10'>
|
||||
<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'>
|
||||
<div className='w-[260px] h-full flex flex-col'>
|
||||
<div className='p-3'>
|
||||
<Input
|
||||
classNames={{
|
||||
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',
|
||||
input: 'bg-transparent text-default-700 placeholder:text-default-400',
|
||||
'bg-white/5 dark:bg-white/5 border border-white/10 hover:bg-white/10 transition-all shadow-none',
|
||||
input: 'bg-transparent text-xs placeholder:opacity-30',
|
||||
}}
|
||||
isClearable
|
||||
radius='lg'
|
||||
placeholder='搜索 API...'
|
||||
startContent={<TbSearch className="text-default-400" />}
|
||||
placeholder='搜索接口...'
|
||||
startContent={<TbSearch size={14} className="opacity-30" />}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onClear={() => setSearchValue('')}
|
||||
@ -73,49 +99,68 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 p-3 flex flex-col gap-2 overflow-y-auto scroll-smooth'>
|
||||
{Object.entries(data).map(([apiName, api]) => {
|
||||
const isMatch = apiName.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
api.description?.toLowerCase().includes(searchValue.toLowerCase());
|
||||
if (!isMatch) return null;
|
||||
|
||||
const isSelected = apiName === selectedApi;
|
||||
|
||||
<div className='flex-1 px-2 pb-4 flex flex-col gap-1 overflow-y-auto no-scrollbar'>
|
||||
{groups.map((group) => {
|
||||
const isOpen = expandedGroups.includes(group.id) || searchValue.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={apiName}
|
||||
role="button"
|
||||
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(
|
||||
'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'
|
||||
)}
|
||||
<div key={group.id} className="flex flex-col">
|
||||
{/* Group Header */}
|
||||
<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)}
|
||||
>
|
||||
<CardBody className='p-3 text-left'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className={clsx(
|
||||
'font-medium text-sm transition-colors',
|
||||
isSelected ? 'text-primary-600 dark:text-primary-400' : 'text-default-700 dark:text-default-200 group-hover:text-default-900'
|
||||
)}>
|
||||
{api.description}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'text-xs font-mono truncate transition-colors',
|
||||
isSelected ? 'text-primary-400 dark:text-primary-300' : 'text-default-400 group-hover:text-default-500'
|
||||
)}>
|
||||
{apiName}
|
||||
</span>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<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 (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -3,8 +3,8 @@ import { SharedSelection } from '@heroui/system';
|
||||
import type { Selection } from '@react-types/shared';
|
||||
|
||||
export interface FilterMessageTypeProps {
|
||||
filterTypes: Selection
|
||||
onSelectionChange: (keys: SharedSelection) => void
|
||||
filterTypes: Selection;
|
||||
onSelectionChange: (keys: SharedSelection) => void;
|
||||
}
|
||||
const items = [
|
||||
{ label: '元事件', value: 'meta_event' },
|
||||
@ -26,6 +26,7 @@ const FilterMessageType: React.FC<FilterMessageTypeProps> = (props) => {
|
||||
}}
|
||||
label='筛选消息类型'
|
||||
selectionMode='multiple'
|
||||
className='w-full'
|
||||
items={items}
|
||||
renderValue={(value) => {
|
||||
if (value.length === items.length) {
|
||||
|
||||
@ -43,7 +43,7 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
||||
|
||||
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>
|
||||
<Modal
|
||||
|
||||
@ -33,18 +33,18 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<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
|
||||
? 'bg-white/10 hover:bg-white/20 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-white/90'
|
||||
: '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={clsx(
|
||||
'text-xs font-mono',
|
||||
hasBackground ? 'text-white/70' : 'text-default-500'
|
||||
'text-xs font-mono flex-1',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500'
|
||||
)}>{value}</div>
|
||||
<div className='ml-auto'>{endContent}</div>
|
||||
<div>{endContent}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -28,20 +28,17 @@ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
|
||||
return (
|
||||
<div
|
||||
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',
|
||||
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(
|
||||
'w-24 font-medium',
|
||||
hasBackground ? 'text-white/90' : 'text-default-600'
|
||||
hasBackground ? 'text-white/90' : 'text-default-600 dark:text-gray-300'
|
||||
)}>{title}</div>
|
||||
<div className={clsx(
|
||||
'font-mono text-xs',
|
||||
hasBackground ? 'text-white/70' : 'text-default-500'
|
||||
hasBackground ? 'text-white/80' : 'text-default-500'
|
||||
)}>
|
||||
{value}
|
||||
{unit && <span className="ml-0.5 opacity-70">{unit}</span>}
|
||||
|
||||
@ -35,13 +35,17 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
const { className, onInput, onKey, onResize, ...rest } = props;
|
||||
const { theme } = useTheme();
|
||||
useEffect(() => {
|
||||
// 根据屏幕宽度决定字体大小,手机端使用更小的字体
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const fontSize = isMobile ? 11 : 14;
|
||||
|
||||
const terminal = new Terminal({
|
||||
allowTransparency: true,
|
||||
fontFamily:
|
||||
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
|
||||
cursorInactiveStyle: 'outline',
|
||||
drawBoldTextInBrightColors: false,
|
||||
fontSize: 14,
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1.2,
|
||||
});
|
||||
terminalRef.current = terminal;
|
||||
@ -56,7 +60,10 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(domRef.current!);
|
||||
|
||||
terminal.loadAddon(new CanvasAddon());
|
||||
// 只在非手机端使用 Canvas 渲染器,手机端使用默认 DOM 渲染器以避免渲染问题
|
||||
if (!isMobile) {
|
||||
terminal.loadAddon(new CanvasAddon());
|
||||
}
|
||||
terminal.onData((data) => {
|
||||
if (onInput) {
|
||||
onInput(data);
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user