mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 08:10:25 +00:00
feat: 新版webui
This commit is contained in:
225
napcat.webui/src/components/onebot/api/debug.tsx
Normal file
225
napcat.webui/src/components/onebot/api/debug.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Snippet } from '@heroui/snippet'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoLink, IoSend } from 'react-icons/io5'
|
||||
import { PiCatDuotone } from 'react-icons/pi'
|
||||
|
||||
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api'
|
||||
|
||||
import ChatInputModal from '@/components/chat_input/modal'
|
||||
import CodeEditor from '@/components/code_editor'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
import { parseAxiosResponse } from '@/utils/url'
|
||||
import { generateDefaultJson, parse } from '@/utils/zod'
|
||||
|
||||
import DisplayStruct from './display_struct'
|
||||
|
||||
export interface OneBotApiDebugProps {
|
||||
path: OneBotHttpApiPath
|
||||
data: OneBotHttpApiContent
|
||||
}
|
||||
|
||||
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
const { path, data } = props
|
||||
const url = new URL(window.location.origin).href
|
||||
const defaultHttpUrl = url.replace(':6099', ':3000')
|
||||
const [httpConfig, setHttpConfig] = useState({
|
||||
url: defaultHttpUrl,
|
||||
token: ''
|
||||
})
|
||||
const [requestBody, setRequestBody] = useState('{}')
|
||||
const [responseContent, setResponseContent] = useState('')
|
||||
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false)
|
||||
const [isResponseOpen, setIsResponseOpen] = useState(false)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const parsedRequest = parse(data.request)
|
||||
const parsedResponse = parse(data.response)
|
||||
|
||||
const sendRequest = async () => {
|
||||
if (isFetching) return
|
||||
setIsFetching(true)
|
||||
const r = toast.loading('正在发送请求...')
|
||||
try {
|
||||
const parsedRequestBody = JSON.parse(requestBody)
|
||||
request
|
||||
.post(httpConfig.url + path, parsedRequestBody, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${httpConfig.token}`
|
||||
},
|
||||
responseType: 'text'
|
||||
})
|
||||
.then((res) => {
|
||||
setResponseContent(parseAxiosResponse(res))
|
||||
toast.success('请求发送完成,请查看响应')
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('请求发送失败:' + err.message)
|
||||
setResponseContent(parseAxiosResponse(err.response))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false)
|
||||
toast.dismiss(r)
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('请求体 JSON 格式错误')
|
||||
setIsFetching(false)
|
||||
toast.dismiss(r)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setRequestBody(generateDefaultJson(data.request))
|
||||
setResponseContent('')
|
||||
}, [path])
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 rounded-lg shadow-md">
|
||||
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-danger-400 ">
|
||||
<PiCatDuotone />
|
||||
{data.description}
|
||||
</h1>
|
||||
<h1 className="text-lg font-bold mb-4">
|
||||
<Snippet
|
||||
className="bg-default-50 bg-opacity-50 backdrop-blur-md"
|
||||
symbol={<IoLink size={18} className="inline-block mr-1" />}
|
||||
>
|
||||
{path}
|
||||
</Snippet>
|
||||
</h1>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
label="HTTP URL"
|
||||
placeholder="输入 HTTP URL"
|
||||
value={httpConfig.url}
|
||||
onChange={(e) =>
|
||||
setHttpConfig({ ...httpConfig, url: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Token"
|
||||
placeholder="输入 Token"
|
||||
value={httpConfig.token}
|
||||
onChange={(e) =>
|
||||
setHttpConfig({ ...httpConfig, token: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onPress={sendRequest}
|
||||
color="danger"
|
||||
size="lg"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
isDisabled={isFetching}
|
||||
>
|
||||
<IoSend />
|
||||
</Button>
|
||||
</div>
|
||||
<Card shadow="sm" className="my-4 bg-opacity-50 backdrop-blur-md">
|
||||
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
||||
<span className="mr-2">请求体</span>
|
||||
<Button
|
||||
color="warning"
|
||||
variant="flat"
|
||||
onPress={() => setIsCodeEditorOpen(!isCodeEditorOpen)}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{isCodeEditorOpen ? '收起' : '展开'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<motion.div
|
||||
className="overflow-hidden"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{
|
||||
opacity: isCodeEditorOpen ? 1 : 0,
|
||||
height: isCodeEditorOpen ? 'auto' : 0
|
||||
}}
|
||||
>
|
||||
<CodeEditor
|
||||
value={requestBody}
|
||||
onChange={(value) => setRequestBody(value ?? '')}
|
||||
language="json"
|
||||
height="400px"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-1">
|
||||
<ChatInputModal />
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
onPress={() =>
|
||||
setRequestBody(generateDefaultJson(data.request))
|
||||
}
|
||||
>
|
||||
填充示例请求体
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card
|
||||
shadow="sm"
|
||||
className="my-4 relative bg-opacity-50 backdrop-blur-md"
|
||||
>
|
||||
<PageLoading loading={isFetching} />
|
||||
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
||||
<span className="mr-2">响应</span>
|
||||
<Button
|
||||
color="warning"
|
||||
variant="flat"
|
||||
onPress={() => setIsResponseOpen(!isResponseOpen)}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{isResponseOpen ? '收起' : '展开'}
|
||||
</Button>
|
||||
<Button
|
||||
color="success"
|
||||
variant="flat"
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(responseContent)
|
||||
toast.success('响应内容已复制到剪贴板')
|
||||
}}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<motion.div
|
||||
className="overflow-y-auto text-sm"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{
|
||||
opacity: isResponseOpen ? 1 : 0,
|
||||
height: isResponseOpen ? 300 : 0
|
||||
}}
|
||||
>
|
||||
<pre>
|
||||
<code>
|
||||
{responseContent || (
|
||||
<div className="text-gray-400">暂无响应</div>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</motion.div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<div className="p-2 md:p-4 border border-default-50 dark:border-default-200 rounded-lg backdrop-blur-sm">
|
||||
<h2 className="text-xl font-semibold mb-2">请求体结构</h2>
|
||||
<DisplayStruct schema={parsedRequest} />
|
||||
<h2 className="text-xl font-semibold mt-4 mb-2">响应体结构</h2>
|
||||
<DisplayStruct schema={parsedResponse} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotApiDebug
|
||||
202
napcat.webui/src/components/onebot/api/display_struct.tsx
Normal file
202
napcat.webui/src/components/onebot/api/display_struct.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { motion } from 'motion/react'
|
||||
import React, { useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { TbSquareRoundedChevronRightFilled } from 'react-icons/tb'
|
||||
|
||||
import type { LiteralValue, ParsedSchema } from '@/utils/zod'
|
||||
|
||||
interface DisplayStructProps {
|
||||
schema: ParsedSchema | ParsedSchema[]
|
||||
}
|
||||
|
||||
const SchemaType = ({
|
||||
type,
|
||||
value
|
||||
}: {
|
||||
type: string
|
||||
value?: LiteralValue
|
||||
}) => {
|
||||
let name = type
|
||||
switch (type) {
|
||||
case 'union':
|
||||
name = '联合类型'
|
||||
break
|
||||
case 'value':
|
||||
name = '固定值'
|
||||
break
|
||||
}
|
||||
let chipColor: 'primary' | 'success' | 'danger' | 'warning' | 'secondary' =
|
||||
'primary'
|
||||
switch (type) {
|
||||
case 'enum':
|
||||
chipColor = 'warning'
|
||||
break
|
||||
case 'union':
|
||||
chipColor = 'secondary'
|
||||
break
|
||||
case 'array':
|
||||
chipColor = 'danger'
|
||||
break
|
||||
case 'object':
|
||||
chipColor = 'success'
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip size="sm" color={chipColor} variant="flat">
|
||||
{name}
|
||||
{type === 'value' && (
|
||||
<span className="px-1 rounded-full bg-primary-400 text-white ml-1">
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
const SchemaLabel: React.FC<{
|
||||
schema: ParsedSchema
|
||||
}> = ({ schema }) => (
|
||||
<>
|
||||
{Array.isArray(schema.type) ? (
|
||||
schema.type.map((type) => (
|
||||
<SchemaType key={type} type={type} value={schema?.value} />
|
||||
))
|
||||
) : (
|
||||
<SchemaType type={schema.type} value={schema?.value} />
|
||||
)}
|
||||
{schema.optional && (
|
||||
<Chip size="sm" color="default" variant="flat">
|
||||
可选
|
||||
</Chip>
|
||||
)}
|
||||
{schema.description && (
|
||||
<span className="text-xs text-default-400">{schema.description}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const SchemaContainer: React.FC<{
|
||||
schema: ParsedSchema
|
||||
children: React.ReactNode
|
||||
}> = ({ schema, children }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const toggleExpand = () => setExpanded(!expanded)
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div
|
||||
onClick={toggleExpand}
|
||||
className="md:cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ rotate: 0 }}
|
||||
animate={{ rotate: expanded ? 90 : 0 }}
|
||||
>
|
||||
<TbSquareRoundedChevronRightFilled />
|
||||
</motion.div>
|
||||
<Tooltip content="点击复制" placement="top" showArrow>
|
||||
<span
|
||||
className="border-b border-transparent border-dashed hover:border-primary-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(schema.name || '')
|
||||
toast.success('已复制')
|
||||
}}
|
||||
>
|
||||
{schema.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<SchemaLabel schema={schema} />
|
||||
</div>
|
||||
<motion.div
|
||||
className="ml-5 overflow-hidden"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: expanded ? 1 : 0, height: expanded ? 'auto' : 0 }}
|
||||
>
|
||||
<div className="h-2"></div>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RenderSchema: React.FC<{ schema: ParsedSchema }> = ({ schema }) => {
|
||||
if (schema.type === 'object') {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
{schema.children && schema.children.length > 0 ? (
|
||||
schema.children.map((child, i) => (
|
||||
<RenderSchema key={child.name || i} schema={child} />
|
||||
))
|
||||
) : (
|
||||
<div>{`{}`}</div>
|
||||
)}
|
||||
</SchemaContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (schema.type === 'array' || schema.type === 'union') {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
{schema.children?.map((child, i) => (
|
||||
<RenderSchema key={child.name || i} schema={child} />
|
||||
))}
|
||||
</SchemaContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (schema.type === 'enum' && Array.isArray(schema.enum)) {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
<div className="flex gap-1 items-center">
|
||||
{schema.enum?.map((value, i) => (
|
||||
<Chip
|
||||
key={value?.toString() || i}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="success"
|
||||
>
|
||||
{value?.toString()}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</SchemaContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-1 pl-5">
|
||||
<Tooltip content="点击复制" placement="top" showArrow>
|
||||
<span
|
||||
className="border-b border-transparent border-dashed hover:border-primary-400 md:cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(schema.name || '')
|
||||
toast.success('已复制')
|
||||
}}
|
||||
>
|
||||
{schema.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<SchemaLabel schema={schema} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DisplayStruct: React.FC<DisplayStructProps> = ({ schema }) => {
|
||||
return (
|
||||
<div className="p-4 bg-content2 rounded-lg bg-opacity-50">
|
||||
{Array.isArray(schema) ? (
|
||||
schema.map((s, i) => <RenderSchema key={s.name || i} schema={s} />)
|
||||
) : (
|
||||
<RenderSchema schema={schema} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DisplayStruct
|
||||
87
napcat.webui/src/components/onebot/api/nav_list.tsx
Normal file
87
napcat.webui/src/components/onebot/api/nav_list.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
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 type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api'
|
||||
|
||||
export interface OneBotApiNavListProps {
|
||||
data: OneBotHttpApi
|
||||
selectedApi: OneBotHttpApiPath
|
||||
onSelect: (apiName: OneBotHttpApiPath) => void
|
||||
openSideBar: boolean
|
||||
}
|
||||
|
||||
const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
const { data, selectedApi, onSelect, openSideBar } = props
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
return (
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'flex-shrink-0 absolute md:!top-0 md:bottom-0 left-0 !overflow-hidden md:relative md:w-auto z-20',
|
||||
openSideBar &&
|
||||
'bottom-8 z-10 bg-background bg-opacity-20 backdrop-blur-md top-14'
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
transition={{
|
||||
type: openSideBar ? 'spring' : 'tween',
|
||||
stiffness: 150,
|
||||
damping: 15
|
||||
}}
|
||||
animate={{ width: openSideBar ? '16rem' : '0rem' }}
|
||||
style={{ overflowY: openSideBar ? 'auto' : 'hidden' }}
|
||||
>
|
||||
<div className="w-64 h-full overflow-y-auto px-2 float-right">
|
||||
<Input
|
||||
className="sticky top-0 z-10 text-danger-600"
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-opacity-30 bg-danger-50 backdrop-blur-sm border border-danger-300 mb-2',
|
||||
input: 'bg-transparent !text-danger-400 !placeholder-danger-400'
|
||||
}}
|
||||
radius="full"
|
||||
placeholder="搜索 API"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
isClearable
|
||||
onClear={() => setSearchValue('')}
|
||||
/>
|
||||
{Object.entries(data).map(([apiName, api]) => (
|
||||
<Card
|
||||
key={apiName}
|
||||
shadow="none"
|
||||
className={clsx(
|
||||
'w-full border border-danger-100 rounded-lg mb-1 bg-opacity-30 backdrop-blur-sm text-danger-400',
|
||||
{
|
||||
hidden: !(
|
||||
apiName.includes(searchValue) ||
|
||||
api.description?.includes(searchValue)
|
||||
)
|
||||
},
|
||||
{
|
||||
'!bg-opacity-40 border border-danger-400 bg-danger-50 text-danger-600':
|
||||
apiName === selectedApi
|
||||
}
|
||||
)}
|
||||
isPressable
|
||||
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
|
||||
>
|
||||
<CardBody>
|
||||
<h2 className="font-ubuntu font-bold">{api.description}</h2>
|
||||
<div
|
||||
className={clsx('text-sm text-danger-200', {
|
||||
'!text-danger-400': apiName === selectedApi
|
||||
})}
|
||||
>
|
||||
{apiName}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotApiNavList
|
||||
Reference in New Issue
Block a user