mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
refactor: 整体重构 (#1381)
* feat: pnpm new * Refactor build and release workflows, update dependencies Switch build scripts and workflows from npm to pnpm, update build and artifact paths, and simplify release workflow by removing version detection and changelog steps. Add new dependencies (silk-wasm, express, ws, node-pty-prebuilt-multiarch), update exports in package.json files, and add vite config for napcat-framework. Also, rename manifest.json for framework package and fix static asset copying in shell build config.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Snippet } from '@heroui/snippet';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { motion } from 'motion/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoLink, IoSend } from 'react-icons/io5';
|
||||
import { PiCatDuotone } from 'react-icons/pi';
|
||||
|
||||
import key from '@/const/key';
|
||||
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 currentURL = new URL(window.location.origin);
|
||||
currentURL.port = '3000';
|
||||
const defaultHttpUrl = currentURL.href;
|
||||
const [httpConfig, setHttpConfig] = useLocalStorage(key.httpDebugConfig, {
|
||||
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 responseRef = useRef<HTMLDivElement>(null);
|
||||
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);
|
||||
const requestURL = new URL(httpConfig.url);
|
||||
requestURL.pathname = path;
|
||||
request
|
||||
.post(requestURL.href, 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);
|
||||
setIsResponseOpen(true);
|
||||
responseRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
toast.dismiss(r);
|
||||
});
|
||||
} catch (_error) {
|
||||
toast.error('请求体 JSON 格式错误');
|
||||
setIsFetching(false);
|
||||
toast.dismiss(r);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setRequestBody(generateDefaultJson(data.request));
|
||||
setResponseContent('');
|
||||
}, [path]);
|
||||
|
||||
return (
|
||||
<section className='p-4 pt-14 rounded-lg shadow-md'>
|
||||
<h1 className='text-2xl font-bold mb-4 flex items-center gap-1 text-primary-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' />}
|
||||
tooltipProps={{
|
||||
content: '点击复制地址',
|
||||
}}
|
||||
>
|
||||
{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='primary'
|
||||
size='lg'
|
||||
radius='full'
|
||||
isIconOnly
|
||||
isDisabled={isFetching}
|
||||
>
|
||||
<IoSend />
|
||||
</Button>
|
||||
</div>
|
||||
<Card
|
||||
shadow='sm'
|
||||
className='my-4 bg-opacity-50 backdrop-blur-md overflow-visible'
|
||||
>
|
||||
<CardHeader className='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
|
||||
ref={responseRef}
|
||||
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-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>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default OneBotApiDebug;
|
||||
@@ -0,0 +1,208 @@
|
||||
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' | 'primary' | 'warning' | 'secondary' =
|
||||
'primary';
|
||||
switch (type) {
|
||||
case 'enum':
|
||||
chipColor = 'warning';
|
||||
break;
|
||||
case 'union':
|
||||
chipColor = 'secondary';
|
||||
break;
|
||||
case 'array':
|
||||
chipColor = 'primary';
|
||||
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' />
|
||||
{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;
|
||||
@@ -0,0 +1,86 @@
|
||||
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(
|
||||
'h-[calc(100vh-3.5rem)] left-0 !overflow-hidden md:w-auto z-20 top-[3.3rem] md:top-[3rem] absolute md:sticky md:float-start',
|
||||
openSideBar && 'bg-background bg-opacity-20 backdrop-blur-md'
|
||||
)}
|
||||
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 pt-2 pb-10 md:pb-0'>
|
||||
<Input
|
||||
className='sticky top-0 z-10 text-primary-600'
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-opacity-30 bg-primary-50 backdrop-blur-sm border border-primary-300 mb-2',
|
||||
input: 'bg-transparent !text-primary-400 !placeholder-primary-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-primary-100 rounded-lg mb-1 bg-opacity-30 backdrop-blur-sm text-primary-400',
|
||||
{
|
||||
hidden: !(
|
||||
apiName.includes(searchValue) ||
|
||||
api.description?.includes(searchValue)
|
||||
),
|
||||
},
|
||||
{
|
||||
'!bg-opacity-40 border border-primary-400 bg-primary-50 text-primary-600':
|
||||
apiName === selectedApi,
|
||||
}
|
||||
)}
|
||||
isPressable
|
||||
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
|
||||
>
|
||||
<CardBody>
|
||||
<h2 className='font-bold'>{api.description}</h2>
|
||||
<div
|
||||
className={clsx('text-sm text-primary-200', {
|
||||
'!text-primary-400': apiName === selectedApi,
|
||||
})}
|
||||
>
|
||||
{apiName}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OneBotApiNavList;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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' as const, 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='primary'
|
||||
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;
|
||||
@@ -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='primary'
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,166 @@
|
||||
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>;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
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 ChatInputModal from '@/components/chat_input/modal';
|
||||
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='primary' 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>
|
||||
<ChatInputModal />
|
||||
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onPress={() => handleSendMessage(onClose)}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default OneBotSendModal;
|
||||
@@ -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' | 'primary' | '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 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='primary' />;
|
||||
}
|
||||
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