From 649165bf00b6ce6dae425b6aa68d01320eb7678e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Mon, 22 Dec 2025 15:21:45 +0800 Subject: [PATCH] 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. --- .../src/components/chat_input/modal.tsx | 28 +- .../src/components/onebot/api/debug.tsx | 343 ++++++++---------- .../src/components/onebot/api/nav_list.tsx | 203 +++++++---- .../components/onebot/filter_message_type.tsx | 5 +- .../src/components/onebot/send_modal.tsx | 2 +- .../src/components/system_info.tsx | 14 +- .../src/components/system_status_display.tsx | 9 +- .../src/components/xterm.tsx | 11 +- .../src/pages/dashboard/debug/http/index.tsx | 179 ++++++--- .../pages/dashboard/debug/websocket/index.tsx | 117 ++++-- 10 files changed, 539 insertions(+), 372 deletions(-) diff --git a/packages/napcat-webui-frontend/src/components/chat_input/modal.tsx b/packages/napcat-webui-frontend/src/components/chat_input/modal.tsx index a693de2e..47010946 100644 --- a/packages/napcat-webui-frontend/src/components/chat_input/modal.tsx +++ b/packages/napcat-webui-frontend/src/components/chat_input/modal.tsx @@ -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 ( <> - + {children ? children(onOpen) : ( + + )} = (props) => { const [requestBody, setRequestBody] = useState('{}'); const [responseContent, setResponseContent] = useState(''); const [isFetching, setIsFetching] = useState(false); - const [isStructOpen, setIsStructOpen] = useState(false); - const responseRef = useRef(null); + const [activeTab, setActiveTab] = useState('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(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 = (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 = (props) => { useEffect(() => { setRequestBody(generateDefaultJson(data.request)); setResponseContent(''); + setResponseStatus(null); }, [path]); return ( -
-
-
-

- - {data.description} -

- } - tooltipProps={{ content: '点击复制地址' }} - size="sm" - > - {path} - +
+ {/* URL Bar */} +
+
+ POST + {path}
-
- - - - +
+ - - -
-

请求配置

- setHttpConfig({ ...httpConfig, url: e.target.value })} - variant='bordered' - labelPlacement='outside' - classNames={{ - inputWrapper: 'bg-default-100/50 backdrop-blur-sm border-default-200/50', - }} - /> - setHttpConfig({ ...httpConfig, token: e.target.value })} - variant='bordered' - labelPlacement='outside' - classNames={{ - inputWrapper: 'bg-default-100/50 backdrop-blur-sm border-default-200/50', - }} - /> + +
+

Debug Setup

+ setHttpConfig({ ...httpConfig, url: e.target.value })} size='sm' variant='flat' /> + setHttpConfig({ ...httpConfig, token: e.target.value })} size='sm' variant='flat' />
@@ -176,133 +131,143 @@ const OneBotApiDebug: React.FC = (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 && } + startContent={!isFetching && } > 发送
-
- {/* Request Column */} - - -
- - 请求体 (Request) -
-
- +
+
+ + + + +
+ + {(onOpen) => ( + + + + )} + + + -
- - -
+ +
+
+ +
+
+ {activeTab === 'request' ? ( 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 }} /> -
- - - - - - -
- - 响应 (Response) -
- -
- -
-
-                {responseContent || 等待请求响应...}
-              
-
-
-
+ ) : ( +
+
+

Request - 请求数据结构

+ +
+
+
+

Response - 返回数据结构

+ +
+
+ )} +
+
- {/* 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. - */} - - - {() => ( - <> - - API 数据结构定义 - - -
-
-

请求体结构 (Request)

- -
-
-

响应体结构 (Response)

- -
-
-
- + {/* Response Area */} +
+
- + > +
setResponseExpanded(!responseExpanded)} + > +
+ + Response +
+
+ {responseStatus && ( + = 200 && responseStatus.code < 300 ? 'success' : 'danger'} className="h-4 text-[9px] font-mono px-1.5 opacity-50"> + {responseStatus.code} + + )} + +
+
+ {responseExpanded && ( +
+ +
+ {responseContent || '...'} +
+
+ )} +
+
- ); }; diff --git a/packages/napcat-webui-frontend/src/components/onebot/api/nav_list.tsx b/packages/napcat-webui-frontend/src/components/onebot/api/nav_list.tsx index 7e7f945c..2e4d58fa 100644 --- a/packages/napcat-webui-frontend/src/components/onebot/api/nav_list.tsx +++ b/packages/napcat-webui-frontend/src/components/onebot/api/nav_list.tsx @@ -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 = (props) => { const { data, selectedApi, onSelect, openSideBar, onToggle } = props; const [searchValue, setSearchValue] = useState(''); + const [expandedGroups, setExpandedGroups] = useState([]); + + 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 && ( -
onToggle?.(false)} - /> - )} + {/* Mobile backdrop overlay - below header (z-40) */} + + {openSideBar && ( + onToggle?.(false)} + /> + )} + + -
-
- - API 列表 - - {onToggle && ( - - )} -
- -
+
+
} + placeholder='搜索接口...' + startContent={} value={searchValue} onChange={(e) => setSearchValue(e.target.value)} onClear={() => setSearchValue('')} @@ -73,49 +99,68 @@ const OneBotApiNavList: React.FC = (props) => { />
-
- {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; - +
+ {groups.map((group) => { + const isOpen = expandedGroups.includes(group.id) || searchValue.length > 0; return ( -
onSelect(apiName as OneBotHttpApiPath)} - onKeyDown={(e) => e.key === 'Enter' && onSelect(apiName as OneBotHttpApiPath)} - className="cursor-pointer focus:outline-none" - > - + {/* Group Header */} +
toggleGroup(group.id)} > - -
- - {api.description} - - - {apiName} - -
-
- + + + {group.label} + ({group.apis.length}) +
+ + {/* Group Content */} + + {isOpen && ( + + {group.apis.map((api) => { + const isSelected = api.path === selectedApi; + return ( +
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' + )} + > + + {api.description} + + + {api.path} + +
+ ); + })} +
+ )} +
); })} diff --git a/packages/napcat-webui-frontend/src/components/onebot/filter_message_type.tsx b/packages/napcat-webui-frontend/src/components/onebot/filter_message_type.tsx index 0c5fba0a..cebe66a0 100644 --- a/packages/napcat-webui-frontend/src/components/onebot/filter_message_type.tsx +++ b/packages/napcat-webui-frontend/src/components/onebot/filter_message_type.tsx @@ -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 = (props) => { }} label='筛选消息类型' selectionMode='multiple' + className='w-full' items={items} renderValue={(value) => { if (value.length === items.length) { diff --git a/packages/napcat-webui-frontend/src/components/onebot/send_modal.tsx b/packages/napcat-webui-frontend/src/components/onebot/send_modal.tsx index 0d6ba019..993f11f7 100644 --- a/packages/napcat-webui-frontend/src/components/onebot/send_modal.tsx +++ b/packages/napcat-webui-frontend/src/components/onebot/send_modal.tsx @@ -43,7 +43,7 @@ const OneBotSendModal: React.FC = (props) => { return ( <> - = ({ }) => { return (
-
{icon}
+
{icon}
{title}
{value}
-
{endContent}
+
{endContent}
); }; diff --git a/packages/napcat-webui-frontend/src/components/system_status_display.tsx b/packages/napcat-webui-frontend/src/components/system_status_display.tsx index 037b1886..857fa71f 100644 --- a/packages/napcat-webui-frontend/src/components/system_status_display.tsx +++ b/packages/napcat-webui-frontend/src/components/system_status_display.tsx @@ -28,20 +28,17 @@ const SystemStatusItem: React.FC = ({ return (
{title}
{value} {unit && {unit}} diff --git a/packages/napcat-webui-frontend/src/components/xterm.tsx b/packages/napcat-webui-frontend/src/components/xterm.tsx index 7fb3eb6a..c62df910 100644 --- a/packages/napcat-webui-frontend/src/components/xterm.tsx +++ b/packages/napcat-webui-frontend/src/components/xterm.tsx @@ -35,13 +35,17 @@ const XTerm = forwardRef((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((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); diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/debug/http/index.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/debug/http/index.tsx index 2418a245..e734d72f 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/debug/http/index.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/debug/http/index.tsx @@ -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('/set_qq_profile'); - const data = oneBotHttpApi[selectedApi]; - const contentRef = useRef(null); + const [activeApi, setActiveApi] = useState('/set_qq_profile'); + const [openApis, setOpenApis] = useState(['/set_qq_profile']); const [openSideBar, setOpenSideBar] = useState(true); + const [backgroundImage] = useLocalStorage(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 ( <> HTTP调试 - NapCat WebUI -
- { - setSelectedApi(api); - // Auto-close sidebar on mobile after selection - if (window.innerWidth < 768) { - setOpenSideBar(false); - } - }} - openSideBar={openSideBar} - onToggle={setOpenSideBar} - /> -
- {/* Toggle Button Container - positioned on top-left of content if sidebar is closed */} -
- +
+
+ {/* Unifed Header */} +
+
+ +

接口调试

+
- +
+ + +
+ {/* Tab Bar */} +
+ {openApis.map((api) => { + const isActive = api === activeApi; + const item = oneBotHttpApi[api]; + return ( +
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' + )} + > + POST + {item?.description || api} +
handleCloseTab(e, api)} + > + +
+
+ ); + })} +
+ + {/* Content Panels */} +
+ {activeApi === null && ( +
+ 选择一个接口开始调试 +
+ )} + + {openApis.map((api) => ( +
+ +
+ ))} +
+
+
diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/debug/websocket/index.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/debug/websocket/index.tsx index f9a0e942..044eb1e3 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/debug/websocket/index.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/debug/websocket/index.tsx @@ -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(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 ( <> Websocket调试 - NapCat WebUI -
- - -
+
+ {/* Config Card */} + + + {/* Connection Config */} +
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' : '', + }} /> 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' : '', + }} /> -
- -
+
-
-
- -
+ + {/* Status Bar */} +
+
+
+ +
+
{FilterMessagesType}
-
- - -
+
+
+ +
-
+ {/* Message List */} +