mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-12 16:00:27 +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
|
||||
122
napcat.webui/src/components/onebot/display_card/message.tsx
Normal file
122
napcat.webui/src/components/onebot/display_card/message.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Avatar } from '@heroui/avatar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { isOB11GroupMessage } from '@/utils/onebot'
|
||||
|
||||
import type {
|
||||
OB11GroupMessage,
|
||||
OB11Message,
|
||||
OB11PrivateMessage
|
||||
} from '@/types/onebot'
|
||||
|
||||
import { renderMessageContent } from '../render_message'
|
||||
|
||||
export interface OneBotMessageProps {
|
||||
data: OB11Message
|
||||
}
|
||||
|
||||
export interface OneBotMessageGroupProps {
|
||||
data: OB11GroupMessage
|
||||
}
|
||||
|
||||
export interface OneBotMessagePrivateProps {
|
||||
data: OB11PrivateMessage
|
||||
}
|
||||
|
||||
const MessageContent: React.FC<{ data: OB11Message }> = ({ data }) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden flex-1">
|
||||
<div className="flex gap-2 items-center flex-shrink-0">
|
||||
<div className="font-bold">
|
||||
{isOB11GroupMessage(data) && data.sender.card && (
|
||||
<span className="mr-1">{data.sender.card}</span>
|
||||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
isOB11GroupMessage(data) &&
|
||||
data.sender.card &&
|
||||
'text-default-400 font-normal'
|
||||
)}
|
||||
>
|
||||
{data.sender.nickname}
|
||||
</span>
|
||||
</div>
|
||||
<div>({data.sender.user_id})</div>
|
||||
<div className="text-sm">消息ID: {data.message_id}</div>
|
||||
</div>
|
||||
<Popover showArrow triggerScaleOnOpen={false}>
|
||||
<PopoverTrigger>
|
||||
<div className="flex-1 break-all overflow-hidden whitespace-pre-wrap border border-default-100 p-2 rounded-md hover:bg-content2 md:cursor-pointer transition-background relative group">
|
||||
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 text-default-300">
|
||||
点击查看完整消息
|
||||
</div>
|
||||
{Array.isArray(data.message)
|
||||
? renderMessageContent(data.message, true)
|
||||
: data.raw_message}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div className="p-2">
|
||||
{Array.isArray(data.message)
|
||||
? renderMessageContent(data.message)
|
||||
: data.raw_message}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OneBotMessageGroup: React.FC<OneBotMessageGroupProps> = ({ data }) => {
|
||||
return (
|
||||
<div className="h-full overflow-hidden flex flex-col w-full">
|
||||
<div className="flex items-center p-1 flex-shrink-0">
|
||||
<Avatar
|
||||
src={`https://p.qlogo.cn/gh/${data.group_id}/${data.group_id}/640/`}
|
||||
alt="群头像"
|
||||
size="sm"
|
||||
className="flex-shrink-0 mr-2"
|
||||
/>
|
||||
<div>群 {data.group_id}</div>
|
||||
</div>
|
||||
<div className="flex items-start p-1 rounded-md h-full flex-1 border border-default-100">
|
||||
<Avatar
|
||||
src={`https://q1.qlogo.cn/g?b=qq&nk=${data.sender.user_id}&s=100`}
|
||||
alt="用户头像"
|
||||
size="md"
|
||||
className="flex-shrink-0 mr-2"
|
||||
/>
|
||||
<MessageContent data={data} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OneBotMessagePrivate: React.FC<OneBotMessagePrivateProps> = ({
|
||||
data
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-start p-2 rounded-md h-full flex-1">
|
||||
<Avatar
|
||||
src={`https://q1.qlogo.cn/g?b=qq&nk=${data.sender.user_id}&s=100`}
|
||||
alt="用户头像"
|
||||
size="md"
|
||||
className="flex-shrink-0 mr-2"
|
||||
/>
|
||||
<MessageContent data={data} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OneBotMessage: React.FC<OneBotMessageProps> = ({ data }) => {
|
||||
if (data.message_type === 'group') {
|
||||
return <OneBotMessageGroup data={data} />
|
||||
} else if (data.message_type === 'private') {
|
||||
return <OneBotMessagePrivate data={data} />
|
||||
} else {
|
||||
return <div>未知消息类型</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default OneBotMessage
|
||||
60
napcat.webui/src/components/onebot/display_card/meta.tsx
Normal file
60
napcat.webui/src/components/onebot/display_card/meta.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
|
||||
import { getLifecycleColor, getLifecycleName } from '@/utils/onebot'
|
||||
|
||||
import type {
|
||||
OB11Meta,
|
||||
OneBot11Heartbeat,
|
||||
OneBot11Lifecycle
|
||||
} from '@/types/onebot'
|
||||
|
||||
export interface OneBotDisplayMetaProps {
|
||||
data: OB11Meta
|
||||
}
|
||||
|
||||
export interface OneBotDisplayMetaHeartbeatProps {
|
||||
data: OneBot11Heartbeat
|
||||
}
|
||||
|
||||
export interface OneBotDisplayMetaLifecycleProps {
|
||||
data: OneBot11Lifecycle
|
||||
}
|
||||
|
||||
const OneBotDisplayMetaHeartbeat: React.FC<OneBotDisplayMetaHeartbeatProps> = ({
|
||||
data
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Chip>心跳</Chip>
|
||||
<Chip>间隔 {data.status.interval}ms</Chip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OneBotDisplayMetaLifecycle: React.FC<OneBotDisplayMetaLifecycleProps> = ({
|
||||
data
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Chip>生命周期</Chip>
|
||||
<Chip color={getLifecycleColor(data.sub_type)}>
|
||||
{getLifecycleName(data.sub_type)}
|
||||
</Chip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OneBotDisplayMeta: React.FC<OneBotDisplayMetaProps> = ({ data }) => {
|
||||
return (
|
||||
<div className="h-full flex items-center">
|
||||
{data.meta_event_type === 'lifecycle' && (
|
||||
<OneBotDisplayMetaLifecycle data={data} />
|
||||
)}
|
||||
{data.meta_event_type === 'heartbeat' && (
|
||||
<OneBotDisplayMetaHeartbeat data={data} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotDisplayMeta
|
||||
292
napcat.webui/src/components/onebot/display_card/notice.tsx
Normal file
292
napcat.webui/src/components/onebot/display_card/notice.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
|
||||
import { getNoticeTypeName } from '@/utils/onebot'
|
||||
|
||||
import {
|
||||
OB11Notice,
|
||||
OB11NoticeType,
|
||||
OneBot11FriendAdd,
|
||||
OneBot11FriendRecall,
|
||||
OneBot11GroupAdmin,
|
||||
OneBot11GroupBan,
|
||||
OneBot11GroupCard,
|
||||
OneBot11GroupDecrease,
|
||||
OneBot11GroupEssence,
|
||||
OneBot11GroupIncrease,
|
||||
OneBot11GroupMessageReaction,
|
||||
OneBot11GroupRecall,
|
||||
OneBot11GroupUpload,
|
||||
OneBot11Honor,
|
||||
OneBot11LuckyKing,
|
||||
OneBot11Poke
|
||||
} from '@/types/onebot'
|
||||
|
||||
export interface OneBotNoticeProps {
|
||||
data: OB11Notice
|
||||
}
|
||||
|
||||
export interface NoticeProps<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
const GroupUploadNotice: React.FC<NoticeProps<OneBot11GroupUpload>> = ({
|
||||
data
|
||||
}) => {
|
||||
const { group_id, user_id, file } = data
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>文件名: {file.name}</div>
|
||||
<div>文件大小: {file.size} 字节</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const GroupAdminNotice: React.FC<NoticeProps<OneBot11GroupAdmin>> = ({
|
||||
data
|
||||
}) => {
|
||||
const { group_id, user_id, sub_type } = data
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>变动类型: {sub_type === 'set' ? '设置管理员' : '取消管理员'}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const GroupDecreaseNotice: React.FC<NoticeProps<OneBot11GroupDecrease>> = ({
|
||||
data
|
||||
}) => {
|
||||
const { group_id, operator_id, user_id, sub_type } = data
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>操作者ID: {operator_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>原因: {sub_type}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const GroupIncreaseNotice: React.FC<NoticeProps<OneBot11GroupIncrease>> = ({
|
||||
data
|
||||
}) => {
|
||||
const { group_id, operator_id, user_id, sub_type } = data
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>操作者ID: {operator_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>增加类型: {sub_type}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const GroupBanNotice: React.FC<NoticeProps<OneBot11GroupBan>> = ({ data }) => {
|
||||
const { group_id, operator_id, user_id, sub_type, duration } = data
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>操作者ID: {operator_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>禁言类型: {sub_type}</div>
|
||||
<div>禁言时长: {duration} 秒</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const FriendAddNotice: React.FC<NoticeProps<OneBot11FriendAdd>> = ({
|
||||
data
|
||||
}) => {
|
||||
const { user_id } = data
|
||||
return (
|
||||
<>
|
||||
<div>用户ID: {user_id}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const GroupRecallNotice: React.FC<NoticeProps<OneBot11GroupRecall>> = ({
|
||||
data
|
||||
}) => {
|
||||
const { group_id, user_id, operator_id, message_id } = data
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>操作者ID: {operator_id}</div>
|
||||
<div>消息ID: {message_id}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const FriendRecallNotice: React.FC<NoticeProps<OneBot11FriendRecall>> = ({
|
||||
data
|
||||
}) => {
|
||||
const { user_id, message_id } = data
|
||||
return (
|
||||
<>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>消息ID: {message_id}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PokeNotice: React.FC<NoticeProps<OneBot11Poke>> = ({ data }) => {
|
||||
const { group_id, user_id, target_id } = data
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>目标ID: {target_id}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const LuckyKingNotice: React.FC<NoticeProps<OneBot11LuckyKing>> = ({
|
||||
data
|
||||
}) => {
|
||||
const { group_id, user_id, target_id } = data
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>目标ID: {target_id}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const HonorNotice: React.FC<NoticeProps<OneBot11Honor>> = ({ data }) => {
|
||||
const { group_id, user_id, honor_type } = data
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>荣誉类型: {honor_type}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const GroupMessageReactionNotice: React.FC<
|
||||
NoticeProps<OneBot11GroupMessageReaction>
|
||||
> = ({ data }) => {
|
||||
const { group_id, user_id, message_id, likes } = data
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>消息ID: {message_id}</div>
|
||||
<div>
|
||||
表情回应:
|
||||
{likes
|
||||
.map((like) => `表情ID: ${like.emoji_id}, 数量: ${like.count}`)
|
||||
.join(', ')}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const GroupEssenceNotice: React.FC<NoticeProps<OneBot11GroupEssence>> = ({
|
||||
data
|
||||
}) => {
|
||||
const { group_id, message_id, sender_id, operator_id, sub_type } = data
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>消息ID: {message_id}</div>
|
||||
<div>发送者ID: {sender_id}</div>
|
||||
<div>操作者ID: {operator_id}</div>
|
||||
<div>操作类型: {sub_type}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const GroupCardNotice: React.FC<NoticeProps<OneBot11GroupCard>> = ({
|
||||
data
|
||||
}) => {
|
||||
const { group_id, user_id, card_new, card_old } = data
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>新名片: {card_new}</div>
|
||||
<div>旧名片: {card_old}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const OneBotNotice: React.FC<OneBotNoticeProps> = ({ data }) => {
|
||||
let NoticeComponent: React.ReactNode
|
||||
switch (data.notice_type) {
|
||||
case OB11NoticeType.GroupUpload:
|
||||
NoticeComponent = <GroupUploadNotice data={data} />
|
||||
break
|
||||
case OB11NoticeType.GroupAdmin:
|
||||
NoticeComponent = <GroupAdminNotice data={data} />
|
||||
break
|
||||
case OB11NoticeType.GroupDecrease:
|
||||
NoticeComponent = <GroupDecreaseNotice data={data} />
|
||||
break
|
||||
case OB11NoticeType.GroupIncrease:
|
||||
NoticeComponent = (
|
||||
<GroupIncreaseNotice data={data as OneBot11GroupIncrease} />
|
||||
)
|
||||
break
|
||||
case OB11NoticeType.GroupBan:
|
||||
NoticeComponent = <GroupBanNotice data={data} />
|
||||
break
|
||||
case OB11NoticeType.FriendAdd:
|
||||
NoticeComponent = <FriendAddNotice data={data as OneBot11FriendAdd} />
|
||||
break
|
||||
case OB11NoticeType.GroupRecall:
|
||||
NoticeComponent = <GroupRecallNotice data={data as OneBot11GroupRecall} />
|
||||
break
|
||||
case OB11NoticeType.FriendRecall:
|
||||
NoticeComponent = (
|
||||
<FriendRecallNotice data={data as OneBot11FriendRecall} />
|
||||
)
|
||||
break
|
||||
case OB11NoticeType.Notify:
|
||||
switch (data.sub_type) {
|
||||
case 'poke':
|
||||
NoticeComponent = <PokeNotice data={data as OneBot11Poke} />
|
||||
break
|
||||
case 'lucky_king':
|
||||
NoticeComponent = <LuckyKingNotice data={data as OneBot11LuckyKing} />
|
||||
break
|
||||
case 'honor':
|
||||
NoticeComponent = <HonorNotice data={data as OneBot11Honor} />
|
||||
break
|
||||
}
|
||||
break
|
||||
case OB11NoticeType.GroupMsgEmojiLike:
|
||||
NoticeComponent = (
|
||||
<GroupMessageReactionNotice
|
||||
data={data as OneBot11GroupMessageReaction}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case OB11NoticeType.GroupEssence:
|
||||
NoticeComponent = (
|
||||
<GroupEssenceNotice data={data as OneBot11GroupEssence} />
|
||||
)
|
||||
break
|
||||
case OB11NoticeType.GroupCard:
|
||||
NoticeComponent = <GroupCardNotice data={data as OneBot11GroupCard} />
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Chip color="warning" variant="flat">
|
||||
通知
|
||||
</Chip>
|
||||
<Chip>{getNoticeTypeName(data.notice_type)}</Chip>
|
||||
{NoticeComponent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotNotice
|
||||
151
napcat.webui/src/components/onebot/display_card/render.tsx
Normal file
151
napcat.webui/src/components/onebot/display_card/render.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Snippet } from '@heroui/snippet'
|
||||
import { motion } from 'motion/react'
|
||||
import { IoCode } from 'react-icons/io5'
|
||||
|
||||
import OneBotDisplayMeta from '@/components/onebot/display_card/meta'
|
||||
|
||||
import { getEventName, isOB11Event } from '@/utils/onebot'
|
||||
import { timestampToDateString } from '@/utils/time'
|
||||
|
||||
import type {
|
||||
AllOB11WsResponse,
|
||||
OB11AllEvent,
|
||||
OB11Request
|
||||
} from '@/types/onebot'
|
||||
|
||||
import OneBotMessage from './message'
|
||||
import OneBotNotice from './notice'
|
||||
import OneBotDisplayResponse from './response'
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, scale: 0.8, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: { type: 'spring', stiffness: 300, damping: 20 }
|
||||
}
|
||||
}
|
||||
|
||||
function RequestComponent({ data: _ }: { data: OB11Request }) {
|
||||
return <div>Request消息,暂未适配</div>
|
||||
}
|
||||
|
||||
export interface OneBotItemRenderProps {
|
||||
data: AllOB11WsResponse[]
|
||||
index: number
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
export const getItemSize = (event: OB11AllEvent['post_type']) => {
|
||||
if (event === 'meta_event') {
|
||||
return 100
|
||||
}
|
||||
if (event === 'message') {
|
||||
return 180
|
||||
}
|
||||
if (event === 'request') {
|
||||
return 100
|
||||
}
|
||||
if (event === 'notice') {
|
||||
return 100
|
||||
}
|
||||
if (event === 'message_sent') {
|
||||
return 250
|
||||
}
|
||||
return 100
|
||||
}
|
||||
|
||||
const renderDetail = (data: AllOB11WsResponse) => {
|
||||
if (isOB11Event(data)) {
|
||||
switch (data.post_type) {
|
||||
case 'meta_event':
|
||||
return <OneBotDisplayMeta data={data} />
|
||||
case 'message':
|
||||
return <OneBotMessage data={data} />
|
||||
case 'request':
|
||||
return <RequestComponent data={data} />
|
||||
case 'notice':
|
||||
return <OneBotNotice data={data} />
|
||||
case 'message_sent':
|
||||
return <OneBotMessage data={data} />
|
||||
default:
|
||||
return <div>未知类型的消息</div>
|
||||
}
|
||||
}
|
||||
return <OneBotDisplayResponse data={data} />
|
||||
}
|
||||
|
||||
const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
|
||||
const msg = data[index]
|
||||
const isEvent = isOB11Event(msg)
|
||||
return (
|
||||
<div style={style} className="p-1 overflow-visible w-full h-full">
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="h-full px-2"
|
||||
>
|
||||
<Card className="w-full h-full py-2 bg-opacity-50 backdrop-blur-sm">
|
||||
<CardHeader className="py-0 text-default-500 flex-row gap-2">
|
||||
<div className="font-bold">
|
||||
{isEvent ? getEventName(msg.post_type) : '请求响应'}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{isEvent && timestampToDateString(msg.time)}
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Popover
|
||||
placement="left"
|
||||
showArrow
|
||||
classNames={{
|
||||
content: 'max-h-96 max-w-96 overflow-hidden p-0'
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
className="text-medium"
|
||||
>
|
||||
<IoCode />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Snippet
|
||||
hideSymbol
|
||||
tooltipProps={{
|
||||
content: '点击复制'
|
||||
}}
|
||||
classNames={{
|
||||
copyButton: 'self-start sticky top-0 right-0'
|
||||
}}
|
||||
className="bg-content1 h-full overflow-y-scroll items-start"
|
||||
>
|
||||
{JSON.stringify(msg, null, 2)
|
||||
.split('\n')
|
||||
.map((line, i) => (
|
||||
<span key={i} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</Snippet>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="py-0">{renderDetail(msg)}</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotItemRender
|
||||
75
napcat.webui/src/components/onebot/display_card/response.tsx
Normal file
75
napcat.webui/src/components/onebot/display_card/response.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Snippet } from '@heroui/snippet'
|
||||
|
||||
import { getResponseStatusColor, getResponseStatusText } from '@/utils/onebot'
|
||||
|
||||
import { RequestResponse } from '@/types/onebot'
|
||||
|
||||
export interface OneBotDisplayResponseProps {
|
||||
data: RequestResponse
|
||||
}
|
||||
|
||||
const OneBotDisplayResponse: React.FC<OneBotDisplayResponseProps> = ({
|
||||
data
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Chip color={getResponseStatusColor(data.status)} variant="flat">
|
||||
{getResponseStatusText(data.status)}
|
||||
</Chip>
|
||||
{data.data && (
|
||||
<Popover
|
||||
placement="right"
|
||||
showArrow
|
||||
classNames={{
|
||||
content: 'max-h-96 max-w-96 overflow-hidden p-0'
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
radius="full"
|
||||
className="text-medium"
|
||||
>
|
||||
查看数据
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Snippet
|
||||
hideSymbol
|
||||
tooltipProps={{
|
||||
content: '点击复制'
|
||||
}}
|
||||
classNames={{
|
||||
copyButton: 'self-start sticky top-0 right-0'
|
||||
}}
|
||||
className="bg-content1 h-full overflow-y-scroll items-start"
|
||||
>
|
||||
{JSON.stringify(data.data, null, 2)
|
||||
.split('\n')
|
||||
.map((line, i) => (
|
||||
<span key={i} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</Snippet>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{data.message && (
|
||||
<Chip className="pl-0.5" variant="flat">
|
||||
<Chip color="warning" size="sm" className="-ml-2 mr-1" variant="flat">
|
||||
返回消息
|
||||
</Chip>
|
||||
{data.message}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotDisplayResponse
|
||||
58
napcat.webui/src/components/onebot/filter_message_type.tsx
Normal file
58
napcat.webui/src/components/onebot/filter_message_type.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Select, SelectItem } from '@heroui/select'
|
||||
import { SharedSelection } from '@heroui/system'
|
||||
import type { Selection } from '@react-types/shared'
|
||||
|
||||
export interface FilterMessageTypeProps {
|
||||
filterTypes: Selection
|
||||
onSelectionChange: (keys: SharedSelection) => void
|
||||
}
|
||||
const items = [
|
||||
{ label: '元事件', value: 'meta_event' },
|
||||
{ label: '消息', value: 'message' },
|
||||
{ label: '请求', value: 'request' },
|
||||
{ label: '通知', value: 'notice' },
|
||||
{ label: '消息发送', value: 'message_sent' }
|
||||
]
|
||||
const FilterMessageType: React.FC<FilterMessageTypeProps> = (props) => {
|
||||
const { filterTypes, onSelectionChange } = props
|
||||
return (
|
||||
<Select
|
||||
selectedKeys={filterTypes}
|
||||
onSelectionChange={(selectedKeys) => {
|
||||
if (selectedKeys !== 'all' && selectedKeys?.size === 0) {
|
||||
selectedKeys = 'all'
|
||||
}
|
||||
onSelectionChange(selectedKeys)
|
||||
}}
|
||||
label="筛选消息类型"
|
||||
selectionMode="multiple"
|
||||
items={items}
|
||||
renderValue={(value) => {
|
||||
if (value.length === items.length) {
|
||||
return '全部'
|
||||
}
|
||||
return value.map((v) => v.data?.label).join(',')
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export const renderFilterMessageType = (
|
||||
filterTypes: Selection,
|
||||
onSelectionChange: (keys: SharedSelection) => void
|
||||
) => {
|
||||
return (
|
||||
<FilterMessageType
|
||||
filterTypes={filterTypes}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterMessageType
|
||||
72
napcat.webui/src/components/onebot/message_list.tsx
Normal file
72
napcat.webui/src/components/onebot/message_list.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { VariableSizeList } from 'react-window'
|
||||
|
||||
import OneBotItemRender, {
|
||||
getItemSize
|
||||
} from '@/components/onebot/display_card/render'
|
||||
|
||||
import { isOB11Event } from '@/utils/onebot'
|
||||
|
||||
import type { AllOB11WsResponse } from '@/types/onebot'
|
||||
|
||||
export interface OneBotMessageListProps {
|
||||
messages: AllOB11WsResponse[]
|
||||
}
|
||||
|
||||
const OneBotMessageList: React.FC<OneBotMessageListProps> = (props) => {
|
||||
const { messages } = props
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<VariableSizeList>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(400)
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (containerRef.current) {
|
||||
setContainerHeight(containerRef.current.offsetHeight)
|
||||
}
|
||||
})
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.resetAfterIndex(0, true)
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-hidden" ref={containerRef}>
|
||||
<VariableSizeList
|
||||
ref={listRef}
|
||||
itemCount={messages.length}
|
||||
width="100%"
|
||||
style={{
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
itemSize={(idx) => {
|
||||
const msg = messages[idx]
|
||||
if (isOB11Event(msg)) {
|
||||
const size = getItemSize(msg.post_type)
|
||||
return size
|
||||
} else {
|
||||
return 100
|
||||
}
|
||||
}}
|
||||
height={containerHeight}
|
||||
itemData={messages}
|
||||
itemKey={(index) => messages.length - index - 1}
|
||||
>
|
||||
{OneBotItemRender}
|
||||
</VariableSizeList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotMessageList
|
||||
164
napcat.webui/src/components/onebot/render_message.tsx
Normal file
164
napcat.webui/src/components/onebot/render_message.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Image } from '@heroui/image'
|
||||
import qface from 'qface'
|
||||
import { FaReply } from 'react-icons/fa6'
|
||||
|
||||
import { OB11Segment } from '@/types/onebot'
|
||||
|
||||
export const renderMessageContent = (
|
||||
segments: OB11Segment[],
|
||||
small = false
|
||||
): React.ReactElement[] => {
|
||||
return segments.map((segment, index) => {
|
||||
switch (segment.type) {
|
||||
case 'text':
|
||||
return <span key={index}>{segment.data.text}</span>
|
||||
case 'face':
|
||||
return (
|
||||
<Image
|
||||
removeWrapper
|
||||
classNames={{
|
||||
img: 'w-6 h-6 inline !text-[0px] m-0 -mt-1.5 !p-0'
|
||||
}}
|
||||
key={index}
|
||||
src={qface.getUrl(segment.data.id)}
|
||||
alt={`face-${segment.data.id}`}
|
||||
/>
|
||||
)
|
||||
case 'image':
|
||||
return (
|
||||
<Image
|
||||
classNames={{
|
||||
wrapper: 'block !text-[0px] !m-0 !p-0',
|
||||
img: 'block'
|
||||
}}
|
||||
radius="sm"
|
||||
className={
|
||||
small
|
||||
? 'max-h-16 object-cover'
|
||||
: 'max-w-64 max-h-96 h-auto object-cover'
|
||||
}
|
||||
key={index}
|
||||
src={segment.data.url || segment.data.file}
|
||||
alt="image"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)
|
||||
case 'record':
|
||||
return (
|
||||
<audio
|
||||
key={index}
|
||||
controls
|
||||
src={segment.data.url || segment.data.file}
|
||||
/>
|
||||
)
|
||||
case 'video':
|
||||
return (
|
||||
<video
|
||||
key={index}
|
||||
controls
|
||||
src={segment.data.url || segment.data.file}
|
||||
/>
|
||||
)
|
||||
case 'at':
|
||||
return (
|
||||
<span key={index} className="text-blue-500">
|
||||
@
|
||||
{segment.data.qq === 'all' ? (
|
||||
'所有人'
|
||||
) : (
|
||||
<span>
|
||||
{segment.data.name}({segment.data.qq})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
case 'rps':
|
||||
return <span key={index}>[猜拳]</span>
|
||||
case 'dice':
|
||||
return <span key={index}>[掷骰子]</span>
|
||||
case 'shake':
|
||||
return <span key={index}>[窗口抖动]</span>
|
||||
case 'poke':
|
||||
return (
|
||||
<span key={index}>
|
||||
[戳一戳: {segment.data.name || segment.data.id}]
|
||||
</span>
|
||||
)
|
||||
case 'anonymous':
|
||||
return <span key={index}>[匿名消息]</span>
|
||||
case 'share':
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={segment.data.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{segment.data.title}
|
||||
</a>
|
||||
)
|
||||
case 'contact':
|
||||
return (
|
||||
<span key={index}>
|
||||
[推荐{segment.data.type === 'qq' ? '好友' : '群'}: {segment.data.id}
|
||||
]
|
||||
</span>
|
||||
)
|
||||
case 'location':
|
||||
return <span key={index}>[位置: {segment.data.title || '未知'}]</span>
|
||||
case 'music':
|
||||
if (segment.data.type === 'custom') {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={segment.data.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{segment.data.title}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span key={index}>
|
||||
[音乐: {segment.data.type} - {segment.data.id}]
|
||||
</span>
|
||||
)
|
||||
case 'reply':
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-content3 py-1 px-2 rounded-md flex items-center gap-1"
|
||||
>
|
||||
<FaReply className="text-default-500" />
|
||||
回复消息ID: {segment.data.id}
|
||||
</div>
|
||||
)
|
||||
case 'forward':
|
||||
return <span key={index}>[合并转发: {segment.data.id}]</span>
|
||||
case 'node':
|
||||
return <span key={index}>[消息节点]</span>
|
||||
case 'xml':
|
||||
return <pre key={index}>{segment.data.data}</pre>
|
||||
case 'json':
|
||||
return (
|
||||
<pre key={index} className="break-all whitespace-break-spaces">
|
||||
{segment.data.data}
|
||||
</pre>
|
||||
)
|
||||
case 'file':
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={segment.data.file}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
[文件]
|
||||
</a>
|
||||
)
|
||||
default:
|
||||
return <span key={index}>[不支持的消息类型]</span>
|
||||
}
|
||||
})
|
||||
}
|
||||
92
napcat.webui/src/components/onebot/send_modal.tsx
Normal file
92
napcat.webui/src/components/onebot/send_modal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
useDisclosure
|
||||
} from '@heroui/modal'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import CodeEditor from '@/components/code_editor'
|
||||
import type { CodeEditorRef } from '@/components/code_editor'
|
||||
|
||||
export interface OneBotSendModalProps {
|
||||
sendMessage: (msg: string) => void
|
||||
}
|
||||
|
||||
const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
||||
const { sendMessage } = props
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure()
|
||||
const editorRef = useRef<CodeEditorRef | null>(null)
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(onClose: () => void) => {
|
||||
const msg = editorRef.current?.getValue()
|
||||
if (!msg) {
|
||||
toast.error('消息不能为空')
|
||||
return
|
||||
}
|
||||
try {
|
||||
sendMessage(msg)
|
||||
toast.success('消息发送成功')
|
||||
onClose()
|
||||
} catch (error) {
|
||||
toast.error('消息发送失败')
|
||||
}
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={onOpen} color="danger" radius="full" variant="flat">
|
||||
构造请求
|
||||
</Button>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
placement="top-center"
|
||||
size="5xl"
|
||||
scrollBehavior="outside"
|
||||
isDismissable={false}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
构造请求
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="h-96 dark:bg-[rgb(30,30,30)] p-2 rounded-md border border-default-100">
|
||||
<CodeEditor
|
||||
height="100%"
|
||||
defaultLanguage="json"
|
||||
defaultValue={`{
|
||||
"action": "get_group_list"
|
||||
}`}
|
||||
ref={editorRef}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="flat" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={() => handleSendMessage(onClose)}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default OneBotSendModal
|
||||
39
napcat.webui/src/components/onebot/ws_status.tsx
Normal file
39
napcat.webui/src/components/onebot/ws_status.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import clsx from 'clsx'
|
||||
import { ReadyState } from 'react-use-websocket'
|
||||
|
||||
export interface WSStatusProps {
|
||||
state: ReadyState
|
||||
}
|
||||
|
||||
function StatusTag({
|
||||
title,
|
||||
color
|
||||
}: {
|
||||
title: string
|
||||
color: 'success' | 'danger' | 'warning'
|
||||
}) {
|
||||
const textClassName = `text-${color} text-sm`
|
||||
const bgClassName = `bg-${color}`
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center gap-1 rounded-md px-2 col-span-2 md:col-span-1">
|
||||
<div className={clsx('w-4 h-4 rounded-full', bgClassName)}></div>
|
||||
<div className={textClassName}>{title}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WSStatus({ state }: WSStatusProps) {
|
||||
if (state === ReadyState.OPEN) {
|
||||
return <StatusTag title="已连接" color="success" />
|
||||
}
|
||||
if (state === ReadyState.CLOSED) {
|
||||
return <StatusTag title="已关闭" color="danger" />
|
||||
}
|
||||
if (state === ReadyState.CONNECTING) {
|
||||
return <StatusTag title="连接中" color="warning" />
|
||||
}
|
||||
if (state === ReadyState.CLOSING) {
|
||||
return <StatusTag title="关闭中" color="warning" />
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user