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 */} +