mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-13 00:10:27 +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:
90
packages/napcat-webui-frontend/src/App.tsx
Normal file
90
packages/napcat-webui-frontend/src/App.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Suspense, lazy, useEffect } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Route, Routes, useNavigate } from 'react-router-dom';
|
||||
|
||||
import PageBackground from '@/components/page_background';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import Toaster from '@/components/toaster';
|
||||
|
||||
import DialogProvider from '@/contexts/dialog';
|
||||
import AudioProvider from '@/contexts/songs';
|
||||
|
||||
import useAuth from '@/hooks/auth';
|
||||
|
||||
import store from '@/store';
|
||||
|
||||
const WebLoginPage = lazy(() => import('@/pages/web_login'));
|
||||
const IndexPage = lazy(() => import('@/pages/index'));
|
||||
const QQLoginPage = lazy(() => import('@/pages/qq_login'));
|
||||
const DashboardIndexPage = lazy(() => import('@/pages/dashboard'));
|
||||
const AboutPage = lazy(() => import('@/pages/dashboard/about'));
|
||||
const ConfigPage = lazy(() => import('@/pages/dashboard/config'));
|
||||
const DebugPage = lazy(() => import('@/pages/dashboard/debug'));
|
||||
const HttpDebug = lazy(() => import('@/pages/dashboard/debug/http'));
|
||||
const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket'));
|
||||
const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'));
|
||||
const LogsPage = lazy(() => import('@/pages/dashboard/logs'));
|
||||
const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
|
||||
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
|
||||
|
||||
function App () {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<Provider store={store}>
|
||||
<PageBackground />
|
||||
<Toaster />
|
||||
<AudioProvider>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<AuthChecker>
|
||||
<AppRoutes />
|
||||
</AuthChecker>
|
||||
</Suspense>
|
||||
</AudioProvider>
|
||||
</Provider>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthChecker ({ children }: { children: React.ReactNode }) {
|
||||
const { isAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuth) {
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const token = search.get('token');
|
||||
let url = '/web_login';
|
||||
|
||||
if (token) {
|
||||
url += `?token=${token}`;
|
||||
}
|
||||
navigate(url, { replace: true });
|
||||
}
|
||||
}, [isAuth, navigate]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AppRoutes () {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path='/' element={<IndexPage />}>
|
||||
<Route index element={<DashboardIndexPage />} />
|
||||
<Route path='network' element={<NetworkPage />} />
|
||||
<Route path='config' element={<ConfigPage />} />
|
||||
<Route path='logs' element={<LogsPage />} />
|
||||
<Route path='debug' element={<DebugPage />}>
|
||||
<Route path='ws' element={<WSDebug />} />
|
||||
<Route path='http' element={<HttpDebug />} />
|
||||
</Route>
|
||||
<Route path='file_manager' element={<FileManagerPage />} />
|
||||
<Route path='terminal' element={<TerminalPage />} />
|
||||
<Route path='about' element={<AboutPage />} />
|
||||
</Route>
|
||||
<Route path='/qq_login' element={<QQLoginPage />} />
|
||||
<Route path='/web_login' element={<WebLoginPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
BIN
packages/napcat-webui-frontend/src/assets/images/bkg-color.png
Normal file
BIN
packages/napcat-webui-frontend/src/assets/images/bkg-color.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
BIN
packages/napcat-webui-frontend/src/assets/images/logo.png
Normal file
BIN
packages/napcat-webui-frontend/src/assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 684 KiB |
@@ -0,0 +1,36 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import React from 'react';
|
||||
import { ColorResult, SketchPicker } from 'react-color';
|
||||
|
||||
// 假定 heroui 提供的 Popover组件
|
||||
|
||||
interface ColorPickerProps {
|
||||
color: string
|
||||
onChange: (color: ColorResult) => void
|
||||
}
|
||||
|
||||
const ColorPicker: React.FC<ColorPickerProps> = ({ color, onChange }) => {
|
||||
const handleChange = (colorResult: ColorResult) => {
|
||||
onChange(colorResult);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover triggerScaleOnOpen={false}>
|
||||
<PopoverTrigger>
|
||||
<div
|
||||
className='w-36 h-8 rounded-md cursor-pointer border border-content4'
|
||||
style={{ background: color }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<SketchPicker
|
||||
color={color}
|
||||
onChange={handleChange}
|
||||
className='!bg-transparent !shadow-none'
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
425
packages/napcat-webui-frontend/src/components/audio_player.tsx
Normal file
425
packages/napcat-webui-frontend/src/components/audio_player.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Slider } from '@heroui/slider';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BiSolidSkipNextCircle,
|
||||
BiSolidSkipPreviousCircle,
|
||||
} from 'react-icons/bi';
|
||||
import {
|
||||
FaPause,
|
||||
FaPlay,
|
||||
FaRegHandPointRight,
|
||||
FaRepeat,
|
||||
FaShuffle,
|
||||
} from 'react-icons/fa6';
|
||||
import { TbRepeatOnce } from 'react-icons/tb';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import { PlayMode } from '@/const/enum';
|
||||
import key from '@/const/key';
|
||||
|
||||
import { VolumeHighIcon, VolumeLowIcon } from './icons';
|
||||
|
||||
export interface AudioPlayerProps
|
||||
extends React.AudioHTMLAttributes<HTMLAudioElement> {
|
||||
src: string
|
||||
title?: string
|
||||
artist?: string
|
||||
cover?: string
|
||||
pressNext?: () => void
|
||||
pressPrevious?: () => void
|
||||
onPlayEnd?: () => void
|
||||
onChangeMode?: (mode: PlayMode) => void
|
||||
mode?: PlayMode
|
||||
}
|
||||
|
||||
export default function AudioPlayer (props: AudioPlayerProps) {
|
||||
const {
|
||||
src,
|
||||
pressNext,
|
||||
pressPrevious,
|
||||
cover = 'https://nextui.org/images/album-cover.png',
|
||||
title = '未知',
|
||||
artist = '未知',
|
||||
onTimeUpdate,
|
||||
onLoadedData,
|
||||
onPlay,
|
||||
onPause,
|
||||
onPlayEnd,
|
||||
onChangeMode,
|
||||
autoPlay,
|
||||
mode = PlayMode.Loop,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(100);
|
||||
const [isCollapsed, setIsCollapsed] = useLocalStorage(
|
||||
key.isCollapsedMusicPlayer,
|
||||
false
|
||||
);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const startY = useRef(0);
|
||||
const startX = useRef(0);
|
||||
const [translateY, setTranslateY] = useState(0);
|
||||
const [translateX, setTranslateX] = useState(0);
|
||||
const isSmallScreen = useMediaQuery({ maxWidth: 767 });
|
||||
const isMediumUp = useMediaQuery({ minWidth: 768 });
|
||||
const shouldAdd = useRef(false);
|
||||
const currentProgress = (currentTime / duration) * 100;
|
||||
const [storageAutoPlay, setStorageAutoPlay] = useLocalStorage(
|
||||
key.autoPlay,
|
||||
true
|
||||
);
|
||||
|
||||
const handleTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
const audio = event.target as HTMLAudioElement;
|
||||
setCurrentTime(audio.currentTime);
|
||||
onTimeUpdate?.(event);
|
||||
};
|
||||
|
||||
const handleLoadedData = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
const audio = event.target as HTMLAudioElement;
|
||||
setDuration(audio.duration);
|
||||
onLoadedData?.(event);
|
||||
};
|
||||
|
||||
const handlePlay = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
setIsPlaying(true);
|
||||
setStorageAutoPlay(true);
|
||||
onPlay?.(e);
|
||||
};
|
||||
|
||||
const handlePause = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
setIsPlaying(false);
|
||||
onPause?.(e);
|
||||
};
|
||||
|
||||
const changeMode = () => {
|
||||
const modes = [PlayMode.Loop, PlayMode.Random, PlayMode.Single];
|
||||
const currentIndex = modes.findIndex((_mode) => _mode === mode);
|
||||
const nextIndex = currentIndex + 1;
|
||||
const nextMode = modes[nextIndex] || modes[0];
|
||||
onChangeMode?.(nextMode);
|
||||
};
|
||||
|
||||
const volumeChange = (value: number) => {
|
||||
setVolume(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.volume = volume / 100;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
startY.current = e.touches[0].clientY;
|
||||
startX.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
const deltaY = e.touches[0].clientY - startY.current;
|
||||
const deltaX = e.touches[0].clientX - startX.current;
|
||||
const container = cardRef.current;
|
||||
const header = cardRef.current?.querySelector('[data-header]');
|
||||
const headerHeight = header?.clientHeight || 20;
|
||||
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
|
||||
const _shouldAdd = isCollapsed && deltaY < 0;
|
||||
if (isSmallScreen) {
|
||||
shouldAdd.current = _shouldAdd;
|
||||
setTranslateY(_shouldAdd ? deltaY + addHeight : deltaY);
|
||||
} else {
|
||||
setTranslateX(deltaX);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (isSmallScreen) {
|
||||
const container = cardRef.current;
|
||||
const header = cardRef.current?.querySelector('[data-header]');
|
||||
const headerHeight = header?.clientHeight || 20;
|
||||
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
|
||||
const _translateY = translateY - (shouldAdd.current ? addHeight : 0);
|
||||
if (_translateY > 100) {
|
||||
setIsCollapsed(true);
|
||||
} else if (_translateY < -100) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
setTranslateY(0);
|
||||
} else {
|
||||
if (translateX > 100) {
|
||||
setIsCollapsed(true);
|
||||
} else if (translateX < -100) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
setTranslateX(0);
|
||||
}
|
||||
};
|
||||
|
||||
const dragTranslate = isSmallScreen
|
||||
? translateY
|
||||
? `translateY(${translateY}px)`
|
||||
: ''
|
||||
: translateX
|
||||
? `translateX(${translateX}px)`
|
||||
: '';
|
||||
const collapsedTranslate = isCollapsed
|
||||
? isSmallScreen
|
||||
? 'translateY(90%)'
|
||||
: 'translateX(96%)'
|
||||
: '';
|
||||
|
||||
const translateStyle = dragTranslate || collapsedTranslate;
|
||||
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed right-0 bottom-0 z-[52] w-full md:w-96',
|
||||
!translateX && !translateY && 'transition-transform',
|
||||
isCollapsed && 'md:hover:!translate-x-80'
|
||||
)}
|
||||
style={{
|
||||
transform: translateStyle,
|
||||
}}
|
||||
>
|
||||
<audio
|
||||
src={src}
|
||||
onLoadedData={handleLoadedData}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={onPlayEnd}
|
||||
autoPlay={autoPlay ?? storageAutoPlay}
|
||||
{...rest}
|
||||
controls={false}
|
||||
hidden
|
||||
ref={audioRef}
|
||||
/>
|
||||
|
||||
<Card
|
||||
ref={cardRef}
|
||||
className={clsx(
|
||||
'border-none bg-background/60 dark:bg-default-300/50 w-full max-w-full transform transition-transform backdrop-blur-md duration-300 overflow-visible',
|
||||
isSmallScreen ? 'rounded-t-3xl' : 'md:rounded-l-xl'
|
||||
)}
|
||||
classNames={{
|
||||
body: 'p-0',
|
||||
}}
|
||||
shadow='sm'
|
||||
radius='none'
|
||||
>
|
||||
{isMediumUp && (
|
||||
<Button
|
||||
isIconOnly
|
||||
className={clsx(
|
||||
'absolute data-[hover]:bg-foreground/10 text-lg z-50',
|
||||
isCollapsed
|
||||
? 'top-0 left-0 w-full h-full rounded-xl bg-opacity-0 hover:bg-opacity-30'
|
||||
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
|
||||
)}
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='sm'
|
||||
onPress={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<FaRegHandPointRight />
|
||||
</Button>
|
||||
)}
|
||||
{isSmallScreen && (
|
||||
<CardHeader
|
||||
data-header
|
||||
className='flex-row justify-center pt-4'
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<div className='w-24 h-2 rounded-full bg-content2-foreground shadow-sm' />
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardBody>
|
||||
<div className='grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0'>
|
||||
<div className='relative col-span-6 md:col-span-4 flex justify-center'>
|
||||
<Image
|
||||
alt='Album cover'
|
||||
className='object-cover'
|
||||
classNames={{
|
||||
wrapper: 'w-36 aspect-square md:w-24 flex',
|
||||
img: 'block w-full h-full',
|
||||
}}
|
||||
shadow='md'
|
||||
src={cover}
|
||||
width='100%'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col col-span-6 md:col-span-8'>
|
||||
<div className='flex flex-col gap-0'>
|
||||
<h1 className='font-medium truncate'>{title}</h1>
|
||||
<p className='text-xs text-foreground/80 truncate'>{artist}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<Slider
|
||||
aria-label='Music progress'
|
||||
classNames={{
|
||||
track: 'bg-default-500/30 border-none',
|
||||
thumb: 'w-2 h-2 after:w-1.5 after:h-1.5',
|
||||
filler: 'rounded-full',
|
||||
}}
|
||||
color='foreground'
|
||||
value={currentProgress || 0}
|
||||
defaultValue={0}
|
||||
size='sm'
|
||||
onChange={(value) => {
|
||||
value = Array.isArray(value) ? value[0] : value;
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.currentTime = (value / 100) * duration;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className='flex justify-between h-3'>
|
||||
<p className='text-xs'>
|
||||
{Math.floor(currentTime / 60)}:
|
||||
{Math.floor(currentTime % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}
|
||||
</p>
|
||||
<p className='text-xs text-foreground/50'>
|
||||
{Math.floor(duration / 60)}:
|
||||
{Math.floor(duration % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex w-full items-center justify-center'>
|
||||
<Tooltip
|
||||
content={
|
||||
mode === PlayMode.Loop
|
||||
? '列表循环'
|
||||
: mode === PlayMode.Random
|
||||
? '随机播放'
|
||||
: '单曲循环'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-lg md:text-medium'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='md'
|
||||
onPress={changeMode}
|
||||
>
|
||||
{mode === PlayMode.Loop && (
|
||||
<FaRepeat className='text-foreground/80' />
|
||||
)}
|
||||
{mode === PlayMode.Random && (
|
||||
<FaShuffle className='text-foreground/80' />
|
||||
)}
|
||||
{mode === PlayMode.Single && (
|
||||
<TbRepeatOnce className='text-foreground/80 text-xl' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content='上一首'>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='md'
|
||||
onPress={pressPrevious}
|
||||
>
|
||||
<BiSolidSkipPreviousCircle />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={isPlaying ? '暂停' : '播放'}>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-3xl md:text-3xl'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='lg'
|
||||
onPress={() => {
|
||||
if (isPlaying) {
|
||||
audioRef.current?.pause();
|
||||
setStorageAutoPlay(false);
|
||||
} else {
|
||||
audioRef.current?.play();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <FaPause /> : <FaPlay className='ml-1' />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content='下一首'>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='md'
|
||||
onPress={pressNext}
|
||||
>
|
||||
<BiSolidSkipNextCircle />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
placement='top'
|
||||
classNames={{
|
||||
content: 'bg-opacity-30 backdrop-blur-md',
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-xl md:text-xl'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='md'
|
||||
>
|
||||
<VolumeHighIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Slider
|
||||
orientation='vertical'
|
||||
showTooltip
|
||||
aria-label='Volume'
|
||||
className='h-40'
|
||||
color='primary'
|
||||
defaultValue={volume}
|
||||
onChange={(value) => {
|
||||
value = Array.isArray(value) ? value[0] : value;
|
||||
volumeChange(value);
|
||||
}}
|
||||
startContent={<VolumeHighIcon className='text-2xl' />}
|
||||
size='sm'
|
||||
endContent={<VolumeLowIcon className='text-2xl' />}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
} from '@heroui/dropdown';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { FaRegCircleQuestion } from 'react-icons/fa6';
|
||||
import { IoAddCircleOutline } from 'react-icons/io5';
|
||||
|
||||
import {
|
||||
HTTPClientIcon,
|
||||
HTTPServerIcon,
|
||||
PCIcon,
|
||||
PlusIcon,
|
||||
WebsocketIcon,
|
||||
} from '../icons';
|
||||
|
||||
export interface AddButtonProps {
|
||||
onOpen: (key: keyof OneBotConfig['network']) => void
|
||||
}
|
||||
|
||||
const AddButton: React.FC<AddButtonProps> = (props) => {
|
||||
const { onOpen } = props;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
classNames={{
|
||||
content: 'bg-opacity-30 backdrop-blur-md',
|
||||
}}
|
||||
placement='right'
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
color='primary'
|
||||
startContent={<IoAddCircleOutline className='text-2xl' />}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label='Create Network Config'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onAction={(key) => {
|
||||
onOpen(key as keyof OneBotConfig['network']);
|
||||
}}
|
||||
>
|
||||
<DropdownItem
|
||||
key='title'
|
||||
isReadOnly
|
||||
className='cursor-default hover:!bg-transparent'
|
||||
textValue='title'
|
||||
>
|
||||
<div className='flex items-center gap-2 justify-center'>
|
||||
<div className='w-5 h-5 -ml-3'>
|
||||
<PlusIcon />
|
||||
</div>
|
||||
<div className='text-primary-400'>新建网络配置</div>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='httpServers'
|
||||
textValue='httpServers'
|
||||
startContent={
|
||||
<div className='w-6 h-6'>
|
||||
<HTTPServerIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
HTTP服务器
|
||||
<Tooltip
|
||||
content='「由NapCat建立」一个HTTP服务器,你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址,你或者你的框架应该连接到这个地址。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='httpSseServers'
|
||||
textValue='httpSseServers'
|
||||
startContent={
|
||||
<div className='w-6 h-6'>
|
||||
<HTTPServerIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
HTTP SSE服务器
|
||||
<Tooltip
|
||||
content='「由NapCat建立」一个HTTP SSE服务器,你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址,你或者你的框架应该连接到这个地址。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='httpClients'
|
||||
textValue='httpClients'
|
||||
startContent={
|
||||
<div className='w-6 h-6'>
|
||||
<HTTPClientIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
HTTP客户端
|
||||
<Tooltip
|
||||
content='「由框架或者你自己建立」的一个用于「接收」NapCat向你发送请求的客户端,通常框架会提供一个HTTP地址。这个地址是你使用的框架提供的,NapCat会主动连接它。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='websocketServers'
|
||||
textValue='websocketServers'
|
||||
startContent={
|
||||
<div className='w-6 h-6'>
|
||||
<WebsocketIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
Websocket服务器
|
||||
<Tooltip
|
||||
content='「由NapCat建立」一个WebSocket服务器,你的框架应该连接到此服务器。NapCat会根据你配置的IP和端口等建立一个WebSocket地址,你或者你的框架应该连接到这个地址。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='websocketClients'
|
||||
textValue='websocketClients'
|
||||
startContent={
|
||||
<div className='w-6 h-6'>
|
||||
<PCIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
Websocket客户端
|
||||
<Tooltip
|
||||
content='「由框架或者你自己建立」的WebSocket,通常框架会「提供」一个ws地址,NapCat会主动连接它。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddButton;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import clsx from 'clsx';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
|
||||
export interface SaveButtonsProps {
|
||||
onSubmit: () => void
|
||||
reset: () => void
|
||||
refresh?: () => void
|
||||
isSubmitting: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const SaveButtons: React.FC<SaveButtonsProps> = ({
|
||||
onSubmit,
|
||||
reset,
|
||||
isSubmitting,
|
||||
refresh,
|
||||
className,
|
||||
}) => (
|
||||
<div
|
||||
className={clsx(
|
||||
'max-w-full mx-3 w-96 flex flex-col justify-center gap-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-2 mt-5'>
|
||||
<Button
|
||||
color='default'
|
||||
onPress={() => {
|
||||
reset();
|
||||
toast.success('重置成功');
|
||||
}}
|
||||
>
|
||||
取消更改
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
isLoading={isSubmitting}
|
||||
onPress={() => onSubmit()}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
{refresh && (
|
||||
<Button
|
||||
isIconOnly
|
||||
color='secondary'
|
||||
radius='full'
|
||||
variant='flat'
|
||||
onPress={() => refresh()}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SaveButtons;
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { FaMicrophone } from 'react-icons/fa6';
|
||||
import { IoMic } from 'react-icons/io5';
|
||||
import { MdEdit, MdUpload } from 'react-icons/md';
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
import { isURI } from '@/utils/url';
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot';
|
||||
|
||||
const AudioInsert = () => {
|
||||
const [audioUrl, setAudioUrl] = useState<string>('');
|
||||
const audioInputRef = useRef<HTMLInputElement>(null);
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
const showAudioSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'record',
|
||||
data: {
|
||||
file,
|
||||
},
|
||||
},
|
||||
];
|
||||
showStructuredMessage(messages);
|
||||
};
|
||||
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const [audioPreview, setAudioPreview] = useState<string | null>(null);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
|
||||
streamRef.current = stream;
|
||||
const recorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = recorder;
|
||||
recorder.start();
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
if (audioChunksRef.current.length > 0) {
|
||||
const audioBlob = new Blob(audioChunksRef.current, {
|
||||
type: 'audio/wav',
|
||||
});
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(audioBlob);
|
||||
reader.onloadend = () => {
|
||||
const base64Audio = reader.result as string;
|
||||
setAudioPreview(base64Audio);
|
||||
setShowPreview(true);
|
||||
};
|
||||
audioChunksRef.current = [];
|
||||
}
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
});
|
||||
recordingIntervalRef.current = setInterval(() => {
|
||||
setRecordingTime((prevTime) => prevTime + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
mediaRecorderRef.current?.stop();
|
||||
if (recordingIntervalRef.current) {
|
||||
clearInterval(recordingIntervalRef.current);
|
||||
recordingIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [isRecording]);
|
||||
|
||||
const startRecording = () => {
|
||||
setAudioPreview(null);
|
||||
setShowPreview(false);
|
||||
setRecordingTime(0);
|
||||
setIsRecording(true);
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
const handleShowPreview = () => {
|
||||
if (audioPreview) {
|
||||
showAudioSegment(audioPreview);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = time % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content='发送音频'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button color='primary' variant='flat' isIconOnly radius='full'>
|
||||
<IoMic className='text-xl' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-2 p-4'>
|
||||
<Tooltip content='上传音频'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
audioInputRef?.current?.click();
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content='输入音频地址'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger tooltip='输入音频地址'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-1 p-2'>
|
||||
<Input
|
||||
value={audioUrl}
|
||||
onChange={(e) => setAudioUrl(e.target.value)}
|
||||
placeholder='请输入音频地址'
|
||||
/>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
if (!isURI(audioUrl)) {
|
||||
toast.error('请输入正确的音频地址');
|
||||
return;
|
||||
}
|
||||
showAudioSegment(audioUrl);
|
||||
setAudioUrl('');
|
||||
}}
|
||||
>
|
||||
<FaMicrophone />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover>
|
||||
<Tooltip content='录制音频'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
>
|
||||
<IoMic />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-col gap-2 p-4'>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
color={isRecording ? 'primary' : 'primary'}
|
||||
variant='flat'
|
||||
onPress={isRecording ? stopRecording : startRecording}
|
||||
>
|
||||
{isRecording ? '停止录制' : '开始录制'}
|
||||
</Button>
|
||||
{showPreview && audioPreview && (
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={handleShowPreview}
|
||||
>
|
||||
查看消息
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(isRecording || audioPreview) && (
|
||||
<div className='flex gap-1 items-center'>
|
||||
<span
|
||||
className={clsx(
|
||||
'w-4 h-4 rounded-full',
|
||||
isRecording
|
||||
? 'animate-pulse bg-primary-400'
|
||||
: 'bg-success-400'
|
||||
)}
|
||||
/>
|
||||
<span>录制时长: {formatTime(recordingTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
{showPreview && audioPreview && (
|
||||
<audio controls src={audioPreview} />
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type='file'
|
||||
ref={audioInputRef}
|
||||
hidden
|
||||
accept='audio/*'
|
||||
className='hidden'
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result;
|
||||
showAudioSegment(dataURL as string);
|
||||
e.target.value = '';
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioInsert;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { BsDice3Fill } from 'react-icons/bs';
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
const DiceInsert = () => {
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
|
||||
return (
|
||||
<Tooltip content='发送骰子'>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
showStructuredMessage([
|
||||
{
|
||||
type: 'dice',
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<BsDice3Fill className='text-lg' />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiceInsert;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { data, getUrl } from 'qface';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MdEmojiEmotions } from 'react-icons/md';
|
||||
|
||||
import { EmojiValue } from '../formats/emoji_blot';
|
||||
|
||||
const emojis = data.map((item) => {
|
||||
return {
|
||||
alt: item.QDes,
|
||||
src: getUrl(item.QSid),
|
||||
id: item.QSid,
|
||||
} as EmojiValue;
|
||||
});
|
||||
|
||||
export interface EmojiPickerProps {
|
||||
onInsertEmoji: (emoji: EmojiValue) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const EmojiPicker = ({ onInsertEmoji, onOpenChange }: EmojiPickerProps) => {
|
||||
const [visibleEmojis, setVisibleEmojis] = useState<EmojiValue[]>([]);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (isPopoverOpen) {
|
||||
setVisibleEmojis([]); // Reset visible emojis
|
||||
requestAnimationFrame(() => loadEmojis()); // Start loading emojis
|
||||
}
|
||||
}, [isPopoverOpen]);
|
||||
|
||||
const loadEmojis = (index = 0, batchSize = 10) => {
|
||||
if (index < emojis.length) {
|
||||
setVisibleEmojis((prev) => [
|
||||
...prev,
|
||||
...emojis.slice(index, index + batchSize),
|
||||
]);
|
||||
requestAnimationFrame(() => loadEmojis(index + batchSize, batchSize));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Popover
|
||||
portalContainer={containerRef.current!}
|
||||
shouldCloseOnScroll={false}
|
||||
placement='right-start'
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v);
|
||||
setIsPopoverOpen(v);
|
||||
}}
|
||||
>
|
||||
<Tooltip content='插入表情'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button color='primary' variant='flat' isIconOnly radius='full'>
|
||||
<MdEmojiEmotions className='text-xl' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='grid grid-cols-8 gap-1 flex-wrap justify-start items-start overflow-y-auto max-w-full max-h-96 p-2'>
|
||||
{visibleEmojis.map((emoji) => (
|
||||
<Button
|
||||
key={emoji.id}
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => onInsertEmoji(emoji)}
|
||||
>
|
||||
<Image src={emoji.src} alt={emoji.alt} className='w-6 h-6' />
|
||||
</Button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPicker;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { FaFolder } from 'react-icons/fa6';
|
||||
import { LuFilePlus2 } from 'react-icons/lu';
|
||||
import { MdEdit, MdUpload } from 'react-icons/md';
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
import { isURI } from '@/utils/url';
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot';
|
||||
|
||||
const FileInsert = () => {
|
||||
const [fileUrl, setFileUrl] = useState<string>('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
const showFileSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'file',
|
||||
data: {
|
||||
file,
|
||||
},
|
||||
},
|
||||
];
|
||||
showStructuredMessage(messages);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content='发送文件'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button color='primary' variant='flat' isIconOnly radius='full'>
|
||||
<FaFolder className='text-lg' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-2 p-4'>
|
||||
<Tooltip content='上传文件'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
fileInputRef?.current?.click();
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content='输入文件地址'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger tooltip='输入文件地址'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-1 p-2'>
|
||||
<Input
|
||||
value={fileUrl}
|
||||
onChange={(e) => setFileUrl(e.target.value)}
|
||||
placeholder='请输入文件地址'
|
||||
/>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
if (!isURI(fileUrl)) {
|
||||
toast.error('请输入正确的文件地址');
|
||||
return;
|
||||
}
|
||||
showFileSegment(fileUrl);
|
||||
setFileUrl('');
|
||||
}}
|
||||
>
|
||||
<LuFilePlus2 />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type='file'
|
||||
ref={fileInputRef}
|
||||
hidden
|
||||
className='hidden'
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result;
|
||||
showFileSegment(dataURL as string);
|
||||
e.target.value = '';
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileInsert;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { MdAddPhotoAlternate, MdEdit, MdImage, MdUpload } from 'react-icons/md';
|
||||
|
||||
import { isURI } from '@/utils/url';
|
||||
|
||||
export interface ImageInsertProps {
|
||||
insertImage: (url: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
|
||||
const [imgUrl, setImgUrl] = useState<string>('');
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover onOpenChange={onOpenChange}>
|
||||
<Tooltip content='插入图片'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button color='primary' variant='flat' isIconOnly radius='full'>
|
||||
<MdImage className='text-xl' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-2 p-4'>
|
||||
<Tooltip content='上传图片'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
imageInputRef?.current?.click();
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content='输入图片地址'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger tooltip='输入图片地址'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-1 p-2'>
|
||||
<Input
|
||||
value={imgUrl}
|
||||
onChange={(e) => setImgUrl(e.target.value)}
|
||||
placeholder='请输入图片地址'
|
||||
/>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
if (!isURI(imgUrl)) {
|
||||
toast.error('请输入正确的图片地址');
|
||||
return;
|
||||
}
|
||||
insertImage(imgUrl);
|
||||
setImgUrl('');
|
||||
}}
|
||||
>
|
||||
<MdAddPhotoAlternate />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type='file'
|
||||
ref={imageInputRef}
|
||||
hidden
|
||||
accept='image/*'
|
||||
className='hidden'
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result;
|
||||
insertImage(dataURL as string);
|
||||
e.target.value = '';
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageInsert;
|
||||
@@ -0,0 +1,258 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Form } from '@heroui/form';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import type { SharedSelection } from '@heroui/system';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import type { Key } from '@react-types/shared';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMusicalNotes } from 'react-icons/io5';
|
||||
import { TbMusicPlus } from 'react-icons/tb';
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
import { isURI } from '@/utils/url';
|
||||
|
||||
import type {
|
||||
CustomMusicSegment,
|
||||
MusicSegment,
|
||||
OB11Segment,
|
||||
} from '@/types/onebot';
|
||||
|
||||
type MusicData = CustomMusicSegment['data'] | MusicSegment['data'];
|
||||
|
||||
const MusicInsert = () => {
|
||||
const [musicId, setMusicId] = useState<string>('');
|
||||
const [musicType, setMusicType] = useState<SharedSelection>(new Set(['163']));
|
||||
const [mode, setMode] = useState<Key>('default');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { control, handleSubmit, reset } = useForm<
|
||||
Omit<CustomMusicSegment['data'], 'type'>
|
||||
>({
|
||||
defaultValues: {
|
||||
url: '',
|
||||
audio: '',
|
||||
title: '',
|
||||
image: '',
|
||||
content: '',
|
||||
},
|
||||
});
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
|
||||
const showMusicSegment = (data: MusicData) => {
|
||||
const messages: OB11Segment[] = [];
|
||||
if (data.type === 'custom') {
|
||||
messages.push({
|
||||
type: 'music',
|
||||
data: {
|
||||
...data,
|
||||
type: 'custom',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
type: 'music',
|
||||
data,
|
||||
});
|
||||
}
|
||||
showStructuredMessage(messages);
|
||||
};
|
||||
|
||||
const onSubmit = (data: Omit<CustomMusicSegment['data'], 'type'>) => {
|
||||
showMusicSegment({
|
||||
type: 'custom',
|
||||
...data,
|
||||
});
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='overflow-visible'>
|
||||
<Popover
|
||||
placement='right-start'
|
||||
shouldCloseOnScroll={false}
|
||||
portalContainer={containerRef.current!}
|
||||
>
|
||||
<Tooltip content='发送音乐'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button color='primary' variant='flat' isIconOnly radius='full'>
|
||||
<IoMusicalNotes className='text-xl' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='gap-2 p-4'>
|
||||
<Tabs
|
||||
placement='top'
|
||||
className='w-96'
|
||||
fullWidth
|
||||
selectedKey={mode}
|
||||
onSelectionChange={(key) => {
|
||||
if (key !== null) setMode(key);
|
||||
}}
|
||||
>
|
||||
<Tab title='主流平台' key='default' className='flex flex-col gap-2'>
|
||||
<Select
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label='音乐平台'
|
||||
selectedKeys={musicType}
|
||||
label='音乐平台'
|
||||
placeholder='请选择音乐平台'
|
||||
items={[
|
||||
{
|
||||
name: 'QQ音乐',
|
||||
id: 'qq',
|
||||
},
|
||||
{
|
||||
name: '网易云音乐',
|
||||
id: '163',
|
||||
},
|
||||
{
|
||||
name: '虾米音乐',
|
||||
id: 'xm',
|
||||
},
|
||||
]}
|
||||
onSelectionChange={setMusicType}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
<Input
|
||||
value={musicId}
|
||||
onChange={(e) => setMusicId(e.target.value)}
|
||||
placeholder='请输入音乐ID'
|
||||
label='音乐ID'
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
if (!musicId) {
|
||||
toast.error('请输入音乐ID');
|
||||
return;
|
||||
}
|
||||
showMusicSegment({
|
||||
type: Array.from(
|
||||
musicType
|
||||
)[0] as MusicSegment['data']['type'],
|
||||
id: musicId,
|
||||
});
|
||||
setMusicId('');
|
||||
}}
|
||||
startContent={<TbMusicPlus />}
|
||||
>
|
||||
创建{Array.from(musicType)[0] === '163' ? '网易云' : 'QQ'}音乐
|
||||
</Button>
|
||||
</Tab>
|
||||
<Tab
|
||||
title='自定义音乐'
|
||||
key='custom'
|
||||
className='flex flex-col gap-2'
|
||||
>
|
||||
<Form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className='flex flex-col gap-2'
|
||||
validationBehavior='native'
|
||||
>
|
||||
<Controller
|
||||
name='url'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
isRequired
|
||||
validate={(v) => {
|
||||
return !isURI(v) ? '请输入正确的音乐URL' : null;
|
||||
}}
|
||||
size='sm'
|
||||
placeholder='请输入音乐URL'
|
||||
label='音乐URL'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='audio'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
isRequired
|
||||
validate={(v) => {
|
||||
return !isURI(v) ? '请输入正确的音频URL' : null;
|
||||
}}
|
||||
size='sm'
|
||||
placeholder='请输入音频URL'
|
||||
label='音频URL'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='title'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
isRequired
|
||||
size='sm'
|
||||
errorMessage='请输入音乐标题'
|
||||
placeholder='请输入音乐标题'
|
||||
label='音乐标题'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='image'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
size='sm'
|
||||
placeholder='请输入封面图片URL'
|
||||
label='封面图片URL'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='content'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
size='sm'
|
||||
placeholder='请输入音乐描述'
|
||||
label='音乐描述'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
radius='full'
|
||||
type='submit'
|
||||
startContent={<TbMusicPlus />}
|
||||
>
|
||||
创建自定义音乐
|
||||
</Button>
|
||||
</Form>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MusicInsert;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useState } from 'react';
|
||||
import { BsChatQuoteFill } from 'react-icons/bs';
|
||||
import { MdAdd } from 'react-icons/md';
|
||||
|
||||
export interface ReplyInsertProps {
|
||||
insertReply: (messageId: string) => void
|
||||
}
|
||||
|
||||
const ReplyInsert = ({ insertReply }: ReplyInsertProps) => {
|
||||
const [replyId, setReplyId] = useState<string>('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content='回复消息'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button color='primary' variant='flat' isIconOnly radius='full'>
|
||||
<BsChatQuoteFill className='text-lg' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-2 p-4'>
|
||||
<Input
|
||||
placeholder='输入消息 ID'
|
||||
value={replyId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/;
|
||||
if (isNumberReg.test(value)) {
|
||||
setReplyId(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
radius='full'
|
||||
isIconOnly
|
||||
onPress={() => {
|
||||
insertReply(replyId);
|
||||
setReplyId('');
|
||||
}}
|
||||
>
|
||||
<MdAdd />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplyInsert;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { LiaHandScissors } from 'react-icons/lia';
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
const RPSInsert = () => {
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
|
||||
return (
|
||||
<Tooltip content='发送猜拳'>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
showStructuredMessage([
|
||||
{
|
||||
type: 'rps',
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<LiaHandScissors className='text-2xl' />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default RPSInsert;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Snippet } from '@heroui/snippet';
|
||||
|
||||
import { OB11Segment } from '@/types/onebot';
|
||||
|
||||
export interface ShowStructedMessageProps {
|
||||
messages: OB11Segment[]
|
||||
}
|
||||
|
||||
const ShowStructedMessage = ({ messages }: ShowStructedMessageProps) => {
|
||||
return (
|
||||
<Snippet
|
||||
hideSymbol
|
||||
tooltipProps={{
|
||||
content: '点击复制',
|
||||
}}
|
||||
classNames={{
|
||||
copyButton: 'self-start sticky top-0 right-0',
|
||||
}}
|
||||
className='bg-content1 h-96 overflow-y-scroll items-start'
|
||||
>
|
||||
{JSON.stringify(messages, null, 2)
|
||||
.split('\n')
|
||||
.map((line, i) => (
|
||||
<span key={i} className='whitespace-pre-wrap break-all'>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</Snippet>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowStructedMessage;
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoVideocam } from 'react-icons/io5';
|
||||
import { MdEdit, MdUpload } from 'react-icons/md';
|
||||
import { TbVideoPlus } from 'react-icons/tb';
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
import { isURI } from '@/utils/url';
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot';
|
||||
|
||||
const VideoInsert = () => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
const showVideoSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'video',
|
||||
data: {
|
||||
file,
|
||||
},
|
||||
},
|
||||
];
|
||||
showStructuredMessage(messages);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content='发送视频'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button color='primary' variant='flat' isIconOnly radius='full'>
|
||||
<IoVideocam className='text-xl' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-2 p-4'>
|
||||
<Tooltip content='上传视频'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
videoInputRef?.current?.click();
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content='输入视频地址'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger tooltip='输入视频地址'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-1 p-2'>
|
||||
<Input
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
placeholder='请输入视频地址'
|
||||
/>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
if (!isURI(videoUrl)) {
|
||||
toast.error('请输入正确的视频地址');
|
||||
return;
|
||||
}
|
||||
showVideoSegment(videoUrl);
|
||||
setVideoUrl('');
|
||||
}}
|
||||
>
|
||||
<TbVideoPlus />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type='file'
|
||||
ref={videoInputRef}
|
||||
hidden
|
||||
accept='video/*'
|
||||
className='hidden'
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result;
|
||||
showVideoSegment(dataURL as string);
|
||||
e.target.value = '';
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoInsert;
|
||||
@@ -0,0 +1,41 @@
|
||||
import Quill from 'quill';
|
||||
|
||||
// eslint-disable-next-line
|
||||
const Embed = Quill.import('blots/embed') as any
|
||||
export interface EmojiValue {
|
||||
alt: string
|
||||
src: string
|
||||
id: string
|
||||
}
|
||||
class EmojiBlot extends Embed {
|
||||
static blotName: string = 'emoji';
|
||||
static tagName: string = 'img';
|
||||
static classNames: string[] = ['w-6', 'h-6'];
|
||||
|
||||
static create (value: HTMLImageElement) {
|
||||
const node = super.create(value);
|
||||
node.setAttribute('alt', value.alt);
|
||||
node.setAttribute('src', value.src);
|
||||
node.setAttribute('data-id', value.id);
|
||||
node.classList.add(...EmojiBlot.classNames);
|
||||
return node;
|
||||
}
|
||||
|
||||
static formats (node: HTMLImageElement): EmojiValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? '',
|
||||
id: node.getAttribute('data-id') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
static value (node: HTMLImageElement): EmojiValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? '',
|
||||
id: node.getAttribute('data-id') ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default EmojiBlot;
|
||||
@@ -0,0 +1,30 @@
|
||||
import Quill from 'quill';
|
||||
|
||||
// eslint-disable-next-line
|
||||
const Embed = Quill.import('blots/embed') as any
|
||||
export interface ImageValue {
|
||||
alt: string
|
||||
src: string
|
||||
}
|
||||
class ImageBlot extends Embed {
|
||||
static blotName = 'image';
|
||||
static tagName = 'img';
|
||||
static classNames: string[] = ['max-w-48', 'max-h-48', 'align-bottom'];
|
||||
|
||||
static create (value: ImageValue) {
|
||||
const node = super.create();
|
||||
node.setAttribute('alt', value.alt);
|
||||
node.setAttribute('src', value.src);
|
||||
node.classList.add(...ImageBlot.classNames);
|
||||
return node;
|
||||
}
|
||||
|
||||
static value (node: HTMLImageElement): ImageValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageBlot;
|
||||
@@ -0,0 +1,43 @@
|
||||
import Quill from 'quill';
|
||||
|
||||
// eslint-disable-next-line
|
||||
const BlockEmbed = Quill.import('blots/block/embed') as any
|
||||
export interface ReplyBlockValue {
|
||||
messageId: string
|
||||
}
|
||||
class ReplyBlock extends BlockEmbed {
|
||||
static blotName = 'reply';
|
||||
static tagName = 'div';
|
||||
static classNames = [
|
||||
'p-2',
|
||||
'select-none',
|
||||
'bg-default-100',
|
||||
'rounded-md',
|
||||
'pointer-events-none',
|
||||
];
|
||||
|
||||
static create (value: ReplyBlockValue) {
|
||||
const node = super.create();
|
||||
node.setAttribute('data-message-id', value.messageId);
|
||||
node.setAttribute('contenteditable', 'false');
|
||||
node.classList.add(...ReplyBlock.classNames);
|
||||
const innerDom = document.createElement('div');
|
||||
innerDom.classList.add('text-sm', 'text-default-500', 'relative');
|
||||
const svgContainer = document.createElement('div');
|
||||
svgContainer.classList.add('w-3', 'h-3', 'absolute', 'top-0', 'right-0');
|
||||
const svg = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M15.9082 12.3714H20.5982C20.5182 17.0414 19.5982 17.8114 16.7282 19.5114C16.3982 19.7114 16.2882 20.1314 16.4882 20.4714C16.6882 20.8014 17.1082 20.9114 17.4482 20.7114C20.8282 18.7114 22.0082 17.4914 22.0082 11.6714V6.28141C22.0082 4.57141 20.6182 3.19141 18.9182 3.19141H15.9182C14.1582 3.19141 12.8282 4.52141 12.8282 6.28141V9.28141C12.8182 11.0414 14.1482 12.3714 15.9082 12.3714Z" fill="#292D32"></path> <path d="M5.09 12.3714H9.78C9.7 17.0414 8.78 17.8114 5.91 19.5114C5.58 19.7114 5.47 20.1314 5.67 20.4714C5.87 20.8014 6.29 20.9114 6.63 20.7114C10.01 18.7114 11.19 17.4914 11.19 11.6714V6.28141C11.19 4.57141 9.8 3.19141 8.1 3.19141H5.1C3.33 3.19141 2 4.52141 2 6.28141V9.28141C2 11.0414 3.33 12.3714 5.09 12.3714Z" fill="#292D32"></path> </g></svg>';
|
||||
svgContainer.innerHTML = svg;
|
||||
innerDom.innerHTML = `消息ID:${value.messageId}`;
|
||||
innerDom.appendChild(svgContainer);
|
||||
node.appendChild(innerDom);
|
||||
return node;
|
||||
}
|
||||
|
||||
static value (node: HTMLElement): ReplyBlockValue {
|
||||
return {
|
||||
messageId: node.getAttribute('data-message-id') || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ReplyBlock;
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import type { Range } from 'quill';
|
||||
import 'quill/dist/quill.core.css';
|
||||
import { useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { useCustomQuill } from '@/hooks/use_custom_quill';
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
import { quillToMessage } from '@/utils/onebot';
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot';
|
||||
|
||||
import AudioInsert from './components/audio_insert';
|
||||
import DiceInsert from './components/dice_insert';
|
||||
import EmojiPicker from './components/emoji_picker';
|
||||
import FileInsert from './components/file_insert';
|
||||
import ImageInsert from './components/image_insert';
|
||||
import MusicInsert from './components/music_insert';
|
||||
import ReplyInsert from './components/reply_insert';
|
||||
import RPSInsert from './components/rps_insert';
|
||||
import VideoInsert from './components/video_insert';
|
||||
import EmojiBlot from './formats/emoji_blot';
|
||||
import type { EmojiValue } from './formats/emoji_blot';
|
||||
import ImageBlot from './formats/image_blot';
|
||||
import ReplyBlock from './formats/reply_blot';
|
||||
|
||||
const ChatInput = () => {
|
||||
const memorizedRange = useRef<Range | null>(null);
|
||||
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
const formats: string[] = ['image', 'emoji', 'reply'];
|
||||
const modules = {
|
||||
toolbar: '#toolbar',
|
||||
};
|
||||
const { quillRef, quill, Quill } = useCustomQuill({
|
||||
modules,
|
||||
formats,
|
||||
placeholder: '请输入消息',
|
||||
});
|
||||
|
||||
if (Quill && !quill) {
|
||||
Quill.register('formats/emoji', EmojiBlot);
|
||||
Quill.register('formats/image', ImageBlot, true);
|
||||
Quill.register('formats/reply', ReplyBlock);
|
||||
}
|
||||
|
||||
if (quill) {
|
||||
quill.on('selection-change', (range) => {
|
||||
if (range) {
|
||||
const editorContent = quill.getContents();
|
||||
const firstOp = editorContent.ops[0];
|
||||
|
||||
if (
|
||||
typeof firstOp?.insert !== 'string' &&
|
||||
firstOp?.insert?.reply &&
|
||||
range.index === 0 &&
|
||||
range.length !== quill.getLength()
|
||||
) {
|
||||
quill.setSelection(1, Quill.sources.SILENT);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
quill.on('text-change', () => {
|
||||
const editorContent = quill.getContents();
|
||||
const firstOp = editorContent.ops[0];
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT);
|
||||
}
|
||||
});
|
||||
|
||||
quill.on('editor-change', (eventName: string) => {
|
||||
if (eventName === 'text-change') {
|
||||
const editorContent = quill.getContents();
|
||||
const firstOp = editorContent.ops[0];
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
quill.root.addEventListener('compositionstart', () => {
|
||||
const editorContent = quill.getContents();
|
||||
const firstOp = editorContent.ops[0];
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
const selection = quill?.getSelection();
|
||||
if (selection) memorizedRange.current = selection;
|
||||
}
|
||||
};
|
||||
|
||||
const insertImage = (url: string) => {
|
||||
const selection = memorizedRange.current || quill?.getSelection();
|
||||
quill?.deleteText(selection?.index || 0, selection?.length || 0);
|
||||
quill?.insertEmbed(selection?.index || 0, 'image', {
|
||||
src: url,
|
||||
alt: '图片',
|
||||
});
|
||||
quill?.setSelection((selection?.index || 0) + 1, 0);
|
||||
};
|
||||
function insertReplyBlock (messageId: string) {
|
||||
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/;
|
||||
if (!isNumberReg.test(messageId)) {
|
||||
toast.error('请输入正确的消息ID');
|
||||
return;
|
||||
}
|
||||
const editorContent = quill?.getContents();
|
||||
const firstOp = editorContent?.ops[0];
|
||||
const currentSelection = quill?.getSelection();
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply
|
||||
) {
|
||||
const delta = quill?.getContents();
|
||||
if (delta) {
|
||||
delta.ops[0] = {
|
||||
insert: { reply: { messageId } },
|
||||
};
|
||||
quill?.setContents(delta, Quill.sources.USER);
|
||||
}
|
||||
} else {
|
||||
quill?.insertEmbed(0, 'reply', { messageId }, Quill.sources.USER);
|
||||
}
|
||||
quill?.setSelection((currentSelection?.index || 0) + 1, 0);
|
||||
quill?.blur();
|
||||
}
|
||||
const onInsertEmoji = (emoji: EmojiValue) => {
|
||||
const selection = memorizedRange.current || quill?.getSelection();
|
||||
quill?.deleteText(selection?.index || 0, selection?.length || 0);
|
||||
quill?.insertEmbed(selection?.index || 0, 'emoji', {
|
||||
alt: emoji.alt,
|
||||
src: emoji.src,
|
||||
id: emoji.id,
|
||||
});
|
||||
quill?.setSelection((selection?.index || 0) + 1, 0);
|
||||
};
|
||||
|
||||
const getChatMessage = () => {
|
||||
const delta = quill?.getContents();
|
||||
const ops =
|
||||
delta?.ops?.filter((op) => {
|
||||
return op.insert !== '\n';
|
||||
}) ?? [];
|
||||
const messages: OB11Segment[] = ops.map((op) => {
|
||||
return quillToMessage(op);
|
||||
});
|
||||
return messages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={quillRef}
|
||||
className='border border-default-200 rounded-md !mb-2 !text-base !h-64'
|
||||
/>
|
||||
<div id='toolbar' className='!border-none flex gap-2'>
|
||||
<ImageInsert insertImage={insertImage} onOpenChange={onOpenChange} />
|
||||
<EmojiPicker
|
||||
onInsertEmoji={onInsertEmoji}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
<ReplyInsert insertReply={insertReplyBlock} />
|
||||
<FileInsert />
|
||||
<AudioInsert />
|
||||
<VideoInsert />
|
||||
<MusicInsert />
|
||||
<DiceInsert />
|
||||
<RPSInsert />
|
||||
<Button
|
||||
color='primary'
|
||||
onPress={() => {
|
||||
const messages = getChatMessage();
|
||||
showStructuredMessage(messages);
|
||||
}}
|
||||
className='ml-auto'
|
||||
>
|
||||
获取JSON格式
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatInput;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
useDisclosure,
|
||||
} from '@heroui/modal';
|
||||
|
||||
import ChatInput from '.';
|
||||
|
||||
export default function ChatInputModal () {
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={onOpen} color='primary' radius='full' variant='flat'>
|
||||
构造聊天消息
|
||||
</Button>
|
||||
<Modal
|
||||
size='4xl'
|
||||
scrollBehavior='inside'
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className='flex flex-col gap-1'>
|
||||
构造消息
|
||||
</ModalHeader>
|
||||
<ModalBody className='overflow-y-auto'>
|
||||
<div className='overflow-y-auto'>
|
||||
<ChatInput />
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' onPress={onClose} variant='flat'>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Editor, { OnMount, loader } from '@monaco-editor/react';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
import monaco from '@/monaco';
|
||||
|
||||
loader.config({
|
||||
monaco,
|
||||
paths: {
|
||||
vs: '/webui/monaco-editor/min/vs',
|
||||
},
|
||||
});
|
||||
|
||||
loader.config({
|
||||
'vs/nls': {
|
||||
availableLanguages: { '*': 'zh-cn' },
|
||||
},
|
||||
});
|
||||
|
||||
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
|
||||
test?: string
|
||||
}
|
||||
|
||||
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor;
|
||||
|
||||
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>(
|
||||
(props, ref) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const handleEditorDidMount: OnMount = (editor, monaco) => {
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(editor);
|
||||
} else {
|
||||
(ref as React.RefObject<CodeEditorRef>).current = editor;
|
||||
}
|
||||
}
|
||||
if (props.onMount) {
|
||||
props.onMount(editor, monaco);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Editor
|
||||
{...props}
|
||||
onMount={handleEditorDidMount}
|
||||
theme={isDark ? 'vs-dark' : 'light'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default CodeEditor;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Button, ButtonGroup } from '@heroui/button';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { useState } from 'react';
|
||||
import { CgDebug } from 'react-icons/cg';
|
||||
import { FiEdit3 } from 'react-icons/fi';
|
||||
import { MdDeleteForever } from 'react-icons/md';
|
||||
|
||||
import DisplayCardContainer from './container';
|
||||
|
||||
type NetworkType = OneBotConfig['network'];
|
||||
|
||||
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
|
||||
label: string
|
||||
value: NetworkType[T][0][keyof NetworkType[T][0]]
|
||||
render?: (
|
||||
value: NetworkType[T][0][keyof NetworkType[T][0]]
|
||||
) => React.ReactNode
|
||||
}>;
|
||||
|
||||
export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
|
||||
data: NetworkType[T][0]
|
||||
showType?: boolean
|
||||
typeLabel: string
|
||||
fields: NetworkDisplayCardFields<T>
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const NetworkDisplayCard = <T extends keyof NetworkType>({
|
||||
data,
|
||||
showType,
|
||||
typeLabel,
|
||||
fields,
|
||||
onEdit,
|
||||
onEnable,
|
||||
onDelete,
|
||||
onEnableDebug,
|
||||
}: NetworkDisplayCardProps<T>) => {
|
||||
const { name, enable, debug } = data;
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const handleEnable = () => {
|
||||
setEditing(true);
|
||||
onEnable().finally(() => setEditing(false));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setEditing(true);
|
||||
onDelete().finally(() => setEditing(false));
|
||||
};
|
||||
|
||||
const handleEnableDebug = () => {
|
||||
setEditing(true);
|
||||
onEnableDebug().finally(() => setEditing(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
action={
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
isDisabled={editing}
|
||||
radius='sm'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
>
|
||||
<Button
|
||||
color='warning'
|
||||
startContent={<FiEdit3 size={16} />}
|
||||
onPress={onEdit}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color={debug ? 'secondary' : 'success'}
|
||||
variant='flat'
|
||||
startContent={
|
||||
<CgDebug
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
minWidth: '16px',
|
||||
minHeight: '16px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onPress={handleEnableDebug}
|
||||
>
|
||||
{debug ? '关闭调试' : '开启调试'}
|
||||
</Button>
|
||||
<Button
|
||||
className='bg-danger/20 text-danger hover:bg-danger/30 transition-colors'
|
||||
variant='flat'
|
||||
startContent={<MdDeleteForever size={16} />}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
}
|
||||
enableSwitch={
|
||||
<Switch
|
||||
isDisabled={editing}
|
||||
isSelected={enable}
|
||||
onChange={handleEnable}
|
||||
/>
|
||||
}
|
||||
tag={showType && typeLabel}
|
||||
title={name}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-1'>
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center gap-2 ${
|
||||
field.label === 'URL' ? 'col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
<span className='text-default-400'>{field.label}</span>
|
||||
{field.render
|
||||
? (
|
||||
field.render(field.value)
|
||||
)
|
||||
: (
|
||||
<span>{field.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DisplayCardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkDisplayCard;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { title } from '../primitives';
|
||||
|
||||
export interface ContainerProps {
|
||||
title: string
|
||||
tag?: React.ReactNode
|
||||
action: React.ReactNode
|
||||
enableSwitch: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export interface DisplayCardProps {
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const DisplayCardContainer: React.FC<ContainerProps> = ({
|
||||
title: _title,
|
||||
action,
|
||||
tag,
|
||||
enableSwitch,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<Card className='bg-opacity-50 backdrop-blur-sm'>
|
||||
<CardHeader className='pb-0 flex items-center'>
|
||||
{tag && (
|
||||
<div className='text-center text-default-400 mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-sm pointer-events-none bg-warning-100 dark:bg-warning-50 px-2 rounded-b'>
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
<h2
|
||||
className={clsx(
|
||||
title({
|
||||
color: 'foreground',
|
||||
size: 'xs',
|
||||
shadow: true,
|
||||
}),
|
||||
'truncate'
|
||||
)}
|
||||
>
|
||||
{_title}
|
||||
</h2>
|
||||
<div className='ml-auto'>{enableSwitch}</div>
|
||||
</CardHeader>
|
||||
<CardBody className='text-sm'>{children}</CardBody>
|
||||
<CardFooter>{action}</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisplayCardContainer;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface HTTPClientDisplayCardProps {
|
||||
data: OneBotConfig['network']['httpClients'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
|
||||
const { url, reportSelfMessage, messagePostFormat } = data;
|
||||
|
||||
const fields: NetworkDisplayCardFields<'httpClients'> = [
|
||||
{ label: 'URL', value: url },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: '上报自身消息',
|
||||
value: reportSelfMessage,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '是' : '否'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel='HTTP客户端'
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPClientDisplayCard;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface HTTPServerDisplayCardProps {
|
||||
data: OneBotConfig['network']['httpServers'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
|
||||
const { host, port, enableCors, enableWebsocket, messagePostFormat } = data;
|
||||
|
||||
const fields: NetworkDisplayCardFields<'httpServers'> = [
|
||||
{ label: '主机', value: host },
|
||||
{ label: '端口', value: port },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: 'CORS',
|
||||
value: enableCors,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'WS',
|
||||
value: enableWebsocket,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel='HTTP服务器'
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPServerDisplayCard;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface HTTPSSEServerDisplayCardProps {
|
||||
data: OneBotConfig['network']['httpSseServers'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (
|
||||
props
|
||||
) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
|
||||
const { host, port, enableCors, enableWebsocket, messagePostFormat } = data;
|
||||
|
||||
const fields: NetworkDisplayCardFields<'httpServers'> = [
|
||||
{ label: '主机', value: host },
|
||||
{ label: '端口', value: port },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: 'CORS',
|
||||
value: enableCors,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'WS',
|
||||
value: enableWebsocket,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel='HTTP服务器'
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPSSEServerDisplayCard;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface WebsocketClientDisplayCardProps {
|
||||
data: OneBotConfig['network']['websocketClients'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (
|
||||
props
|
||||
) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
|
||||
const {
|
||||
url,
|
||||
heartInterval,
|
||||
reconnectInterval,
|
||||
messagePostFormat,
|
||||
reportSelfMessage,
|
||||
} = data;
|
||||
|
||||
const fields: NetworkDisplayCardFields<'websocketClients'> = [
|
||||
{ label: 'URL', value: url },
|
||||
{ label: '重连间隔', value: `${reconnectInterval}ms` },
|
||||
{ label: '心跳间隔', value: `${heartInterval}ms` },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: '上报自身消息',
|
||||
value: reportSelfMessage,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '是' : '否'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel='Websocket客户端'
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsocketClientDisplayCard;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface WebsocketServerDisplayCardProps {
|
||||
data: OneBotConfig['network']['websocketServers'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (
|
||||
props
|
||||
) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
|
||||
const {
|
||||
host,
|
||||
port,
|
||||
heartInterval,
|
||||
messagePostFormat,
|
||||
reportSelfMessage,
|
||||
enableForcePushEvent,
|
||||
} = data;
|
||||
|
||||
const fields: NetworkDisplayCardFields<'websocketServers'> = [
|
||||
{ label: '主机', value: host },
|
||||
{ label: '端口', value: port },
|
||||
{ label: '心跳间隔', value: `${heartInterval}ms` },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: '上报自身消息',
|
||||
value: reportSelfMessage,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '是' : '否'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '强制推送事件',
|
||||
value: enableForcePushEvent,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '是' : '否'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel='Websocket服务器'
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsocketServerDisplayCard;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { title } from '@/components/primitives';
|
||||
|
||||
export interface NetworkItemDisplayProps {
|
||||
count: number
|
||||
label: string
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
||||
count,
|
||||
label,
|
||||
size = 'md',
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
className={clsx(
|
||||
'bg-opacity-60 shadow-sm md:rounded-3xl',
|
||||
size === 'md'
|
||||
? 'col-span-8 md:col-span-2 bg-primary-50 shadow-primary-100'
|
||||
: 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
|
||||
)}
|
||||
shadow='sm'
|
||||
>
|
||||
<CardBody className='items-center md:gap-1 p-1 md:p-2'>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-1',
|
||||
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
|
||||
title({
|
||||
color: size === 'md' ? 'pink' : 'yellow',
|
||||
size,
|
||||
})
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'whitespace-nowrap text-nowrap flex-shrink-0',
|
||||
size === 'md' ? 'text-sm md:text-base' : 'text-xs md:text-sm',
|
||||
title({
|
||||
color: size === 'md' ? 'pink' : 'yellow',
|
||||
shadow: true,
|
||||
size: 'xxs',
|
||||
})
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkItemDisplay;
|
||||
109
packages/napcat-webui-frontend/src/components/effect_card.tsx
Normal file
109
packages/napcat-webui-frontend/src/components/effect_card.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Card, CardProps } from '@heroui/card';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
export interface HoverEffectCardProps extends CardProps {
|
||||
children: React.ReactNode
|
||||
maxXRotation?: number
|
||||
maxYRotation?: number
|
||||
lightClassName?: string
|
||||
lightStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
maxXRotation = 5,
|
||||
maxYRotation = 5,
|
||||
className,
|
||||
style,
|
||||
lightClassName,
|
||||
lightStyle,
|
||||
} = props;
|
||||
const cardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const lightRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isShowLight, setIsShowLight] = React.useState(false);
|
||||
const [pos, setPos] = React.useState({
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...props}
|
||||
ref={cardRef}
|
||||
className={clsx(
|
||||
'relative overflow-hidden bg-opacity-50 backdrop-blur-lg',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
willChange: 'transform',
|
||||
transform:
|
||||
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)',
|
||||
...style,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (cardRef.current) {
|
||||
cardRef.current.style.transition = 'transform 0.3s ease-out';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsShowLight(false);
|
||||
if (cardRef.current) {
|
||||
cardRef.current.style.transition = 'transform 0.5s';
|
||||
cardRef.current.style.transform =
|
||||
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)';
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (cardRef.current) {
|
||||
setIsShowLight(true);
|
||||
|
||||
const { x, y } = cardRef.current.getBoundingClientRect();
|
||||
const { clientX, clientY } = e;
|
||||
|
||||
const offsetX = clientX - x;
|
||||
const offsetY = clientY - y;
|
||||
|
||||
const lightWidth = lightStyle?.width?.toString() || '100';
|
||||
const lightHeight = lightStyle?.height?.toString() || '100';
|
||||
const lightWidthNum = parseInt(lightWidth);
|
||||
const lightHeightNum = parseInt(lightHeight);
|
||||
|
||||
const left = offsetX - lightWidthNum / 2;
|
||||
const top = offsetY - lightHeightNum / 2;
|
||||
|
||||
setPos({
|
||||
left,
|
||||
top,
|
||||
});
|
||||
|
||||
cardRef.current.style.transition = 'transform 0.1s';
|
||||
|
||||
const rangeX = 400 / 2;
|
||||
const rangeY = 400 / 2;
|
||||
|
||||
const rotateX = ((offsetY - rangeY) / rangeY) * maxXRotation;
|
||||
const rotateY = -1 * ((offsetX - rangeX) / rangeX) * maxYRotation;
|
||||
|
||||
cardRef.current.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={lightRef}
|
||||
className={clsx(
|
||||
isShowLight ? 'opacity-100' : 'opacity-0',
|
||||
'absolute rounded-full blur-[150px] filter transition-opacity duration-300 dark:bg-[#2850ff] bg-[#ff4132] w-[100px] h-[100px]',
|
||||
lightClassName
|
||||
)}
|
||||
style={{
|
||||
...pos,
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoverEffectCard;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Code } from '@heroui/code';
|
||||
import { MdError } from 'react-icons/md';
|
||||
|
||||
export interface ErrorFallbackProps {
|
||||
error: Error
|
||||
resetErrorBoundary: () => void
|
||||
}
|
||||
function errorFallbackRender ({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
}: ErrorFallbackProps) {
|
||||
return (
|
||||
<div className='pt-32 flex flex-col justify-center items-center'>
|
||||
<div className='flex items-center'>
|
||||
<MdError className='mr-2' color='red' size={30} />
|
||||
<h1 className='text-2xl'>出错了</h1>
|
||||
</div>
|
||||
<div className='my-6 flex flex-col justify-center items-center'>
|
||||
<p className='mb-2'>错误信息</p>
|
||||
<Code>{error.message}</Code>
|
||||
</div>
|
||||
<Button color='primary' size='md' onPress={resetErrorBoundary}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default errorFallbackRender;
|
||||
166
packages/napcat-webui-frontend/src/components/file_icon.tsx
Normal file
166
packages/napcat-webui-frontend/src/components/file_icon.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
FaFile,
|
||||
FaFileAudio,
|
||||
FaFileCode,
|
||||
FaFileCsv,
|
||||
FaFileExcel,
|
||||
FaFileImage,
|
||||
FaFileLines,
|
||||
FaFilePdf,
|
||||
FaFilePowerpoint,
|
||||
FaFileVideo,
|
||||
FaFileWord,
|
||||
FaFileZipper,
|
||||
FaFolderClosed,
|
||||
} from 'react-icons/fa6';
|
||||
|
||||
export interface FileIconProps {
|
||||
name?: string
|
||||
isDirectory?: boolean
|
||||
}
|
||||
|
||||
const FileIcon = (props: FileIconProps) => {
|
||||
const { name, isDirectory = false } = props;
|
||||
if (isDirectory) {
|
||||
return <FaFolderClosed className='text-yellow-500' />;
|
||||
}
|
||||
|
||||
const ext = name?.split('.').pop() || '';
|
||||
if (ext) {
|
||||
switch (ext.toLowerCase()) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'svg':
|
||||
case 'bmp':
|
||||
case 'ico':
|
||||
case 'webp':
|
||||
case 'tiff':
|
||||
case 'tif':
|
||||
case 'heic':
|
||||
case 'heif':
|
||||
case 'avif':
|
||||
case 'apng':
|
||||
case 'flif':
|
||||
case 'ai':
|
||||
case 'psd':
|
||||
case 'xcf':
|
||||
case 'sketch':
|
||||
case 'fig':
|
||||
case 'xd':
|
||||
case 'svgz':
|
||||
return <FaFileImage className='text-green-500' />;
|
||||
case 'pdf':
|
||||
return <FaFilePdf className='text-red-500' />;
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return <FaFileWord className='text-blue-500' />;
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return <FaFileExcel className='text-green-500' />;
|
||||
case 'csv':
|
||||
return <FaFileCsv className='text-green-500' />;
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return <FaFilePowerpoint className='text-red-500' />;
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
case 'tar':
|
||||
case 'gz':
|
||||
case 'bz2':
|
||||
case 'xz':
|
||||
case 'lz':
|
||||
case 'lzma':
|
||||
case 'zst':
|
||||
case 'zstd':
|
||||
case 'z':
|
||||
case 'taz':
|
||||
case 'tz':
|
||||
case 'tzo':
|
||||
return <FaFileZipper className='text-green-500' />;
|
||||
case 'txt':
|
||||
return <FaFileLines className='text-gray-500' />;
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'flac':
|
||||
return <FaFileAudio className='text-green-500' />;
|
||||
case 'mp4':
|
||||
case 'avi':
|
||||
case 'mov':
|
||||
case 'wmv':
|
||||
return <FaFileVideo className='text-red-500' />;
|
||||
case 'html':
|
||||
case 'css':
|
||||
case 'js':
|
||||
case 'ts':
|
||||
case 'jsx':
|
||||
case 'tsx':
|
||||
case 'json':
|
||||
case 'xml':
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
case 'md':
|
||||
case 'sh':
|
||||
case 'py':
|
||||
case 'java':
|
||||
case 'c':
|
||||
case 'cpp':
|
||||
case 'cs':
|
||||
case 'go':
|
||||
case 'php':
|
||||
case 'rb':
|
||||
case 'pl':
|
||||
case 'swift':
|
||||
case 'kt':
|
||||
case 'rs':
|
||||
case 'sql':
|
||||
case 'r':
|
||||
case 'scala':
|
||||
case 'groovy':
|
||||
case 'dart':
|
||||
case 'lua':
|
||||
case 'perl':
|
||||
case 'h':
|
||||
case 'm':
|
||||
case 'mm':
|
||||
case 'makefile':
|
||||
case 'cmake':
|
||||
case 'dockerfile':
|
||||
case 'gradle':
|
||||
case 'properties':
|
||||
case 'ini':
|
||||
case 'conf':
|
||||
case 'env':
|
||||
case 'bat':
|
||||
case 'cmd':
|
||||
case 'ps1':
|
||||
case 'psm1':
|
||||
case 'psd1':
|
||||
case 'ps1xml':
|
||||
case 'psc1':
|
||||
case 'pssc':
|
||||
case 'nuspec':
|
||||
case 'resx':
|
||||
case 'resw':
|
||||
case 'csproj':
|
||||
case 'vbproj':
|
||||
case 'vcxproj':
|
||||
case 'fsproj':
|
||||
case 'sln':
|
||||
case 'suo':
|
||||
case 'user':
|
||||
case 'userosscache':
|
||||
case 'sln.docstates':
|
||||
case 'dll':
|
||||
return <FaFileCode className='text-blue-500' />;
|
||||
default:
|
||||
return <FaFile className='text-gray-500' />;
|
||||
}
|
||||
}
|
||||
|
||||
return <FaFile className='text-gray-500' />;
|
||||
};
|
||||
|
||||
export default FileIcon;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Button, ButtonGroup } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@heroui/modal';
|
||||
|
||||
interface CreateFileModalProps {
|
||||
isOpen: boolean
|
||||
fileType: 'file' | 'directory'
|
||||
newFileName: string
|
||||
onTypeChange: (type: 'file' | 'directory') => void
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onClose: () => void
|
||||
onCreate: () => void
|
||||
}
|
||||
|
||||
export default function CreateFileModal ({
|
||||
isOpen,
|
||||
fileType,
|
||||
newFileName,
|
||||
onTypeChange,
|
||||
onNameChange,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: CreateFileModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>新建</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<ButtonGroup color='primary'>
|
||||
<Button
|
||||
variant={fileType === 'file' ? 'solid' : 'flat'}
|
||||
onPress={() => onTypeChange('file')}
|
||||
>
|
||||
文件
|
||||
</Button>
|
||||
<Button
|
||||
variant={fileType === 'directory' ? 'solid' : 'flat'}
|
||||
onPress={() => onTypeChange('directory')}
|
||||
>
|
||||
目录
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Input label='名称' value={newFileName} onChange={onNameChange} />
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onCreate}>
|
||||
创建
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Code } from '@heroui/code';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@heroui/modal';
|
||||
|
||||
import CodeEditor from '@/components/code_editor';
|
||||
|
||||
interface FileEditModalProps {
|
||||
isOpen: boolean
|
||||
file: { path: string; content: string } | null
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
onContentChange: (newContent?: string) => void
|
||||
}
|
||||
|
||||
export default function FileEditModal ({
|
||||
isOpen,
|
||||
file,
|
||||
onClose,
|
||||
onSave,
|
||||
onContentChange,
|
||||
}: FileEditModalProps) {
|
||||
// 根据文件后缀返回对应语言
|
||||
const getLanguage = (filePath: string) => {
|
||||
if (filePath.endsWith('.js')) return 'javascript';
|
||||
if (filePath.endsWith('.ts')) return 'typescript';
|
||||
if (filePath.endsWith('.tsx')) return 'tsx';
|
||||
if (filePath.endsWith('.jsx')) return 'jsx';
|
||||
if (filePath.endsWith('.vue')) return 'vue';
|
||||
if (filePath.endsWith('.svelte')) return 'svelte';
|
||||
if (filePath.endsWith('.json')) return 'json';
|
||||
if (filePath.endsWith('.html')) return 'html';
|
||||
if (filePath.endsWith('.css')) return 'css';
|
||||
if (filePath.endsWith('.scss')) return 'scss';
|
||||
if (filePath.endsWith('.less')) return 'less';
|
||||
if (filePath.endsWith('.md')) return 'markdown';
|
||||
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml';
|
||||
if (filePath.endsWith('.xml')) return 'xml';
|
||||
if (filePath.endsWith('.sql')) return 'sql';
|
||||
if (filePath.endsWith('.sh')) return 'shell';
|
||||
if (filePath.endsWith('.bat')) return 'bat';
|
||||
if (filePath.endsWith('.php')) return 'php';
|
||||
if (filePath.endsWith('.java')) return 'java';
|
||||
if (filePath.endsWith('.c')) return 'c';
|
||||
if (filePath.endsWith('.cpp')) return 'cpp';
|
||||
if (filePath.endsWith('.h')) return 'h';
|
||||
if (filePath.endsWith('.hpp')) return 'hpp';
|
||||
if (filePath.endsWith('.go')) return 'go';
|
||||
if (filePath.endsWith('.py')) return 'python';
|
||||
if (filePath.endsWith('.rb')) return 'ruby';
|
||||
if (filePath.endsWith('.cs')) return 'csharp';
|
||||
if (filePath.endsWith('.swift')) return 'swift';
|
||||
if (filePath.endsWith('.vb')) return 'vb';
|
||||
if (filePath.endsWith('.lua')) return 'lua';
|
||||
if (filePath.endsWith('.pl')) return 'perl';
|
||||
if (filePath.endsWith('.r')) return 'r';
|
||||
return 'plaintext';
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal size='full' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader className='flex items-center gap-2 bg-content2 bg-opacity-50'>
|
||||
<span>编辑文件</span>
|
||||
<Code className='text-xs'>{file?.path}</Code>
|
||||
</ModalHeader>
|
||||
<ModalBody className='p-0'>
|
||||
<div className='h-full'>
|
||||
<CodeEditor
|
||||
height='100%'
|
||||
value={file?.content || ''}
|
||||
onChange={onContentChange}
|
||||
options={{ wordWrap: 'on' }}
|
||||
language={file?.path ? getLanguage(file.path) : 'plaintext'}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@heroui/modal';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useRequest } from 'ahooks';
|
||||
import path from 'path-browserify';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
isOpen: boolean
|
||||
filePath: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const videoExts = ['.mp4', '.webm'];
|
||||
export const audioExts = ['.mp3', '.wav'];
|
||||
|
||||
export const supportedPreviewExts = [...videoExts, ...audioExts];
|
||||
|
||||
export default function FilePreviewModal ({
|
||||
isOpen,
|
||||
filePath,
|
||||
onClose,
|
||||
}: FilePreviewModalProps) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const { data, loading, error, run } = useRequest(
|
||||
async () => FileManager.downloadToURL(filePath),
|
||||
{
|
||||
refreshDeps: [filePath],
|
||||
manual: true,
|
||||
refreshDepsAction: () => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!filePath || !supportedPreviewExts.includes(ext)) {
|
||||
return;
|
||||
}
|
||||
run();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filePath) {
|
||||
run();
|
||||
}
|
||||
}, [filePath]);
|
||||
|
||||
let contentElement = null;
|
||||
if (!supportedPreviewExts.includes(ext)) {
|
||||
contentElement = <div>暂不支持预览此文件类型</div>;
|
||||
} else if (error) {
|
||||
contentElement = <div>读取文件失败</div>;
|
||||
} else if (loading || !data) {
|
||||
contentElement = (
|
||||
<div className='flex justify-center items-center h-full'>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
} else if (videoExts.includes(ext)) {
|
||||
contentElement = <video src={data} controls className='max-w-full' />;
|
||||
} else if (audioExts.includes(ext)) {
|
||||
contentElement = <audio src={data} controls className='w-full' />;
|
||||
} else {
|
||||
contentElement = (
|
||||
<div className='flex justify-center items-center h-full'>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
|
||||
<ModalContent>
|
||||
<ModalHeader>文件预览</ModalHeader>
|
||||
<ModalBody className='flex justify-center items-center'>
|
||||
{contentElement}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { Button, ButtonGroup } from '@heroui/button';
|
||||
import { Pagination } from '@heroui/pagination';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import {
|
||||
type Selection,
|
||||
type SortDescriptor,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@heroui/table';
|
||||
import path from 'path-browserify';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BiRename } from 'react-icons/bi';
|
||||
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi';
|
||||
import { PhotoSlider } from 'react-photo-view';
|
||||
|
||||
import FileIcon from '@/components/file_icon';
|
||||
|
||||
import type { FileInfo } from '@/controllers/file_manager';
|
||||
|
||||
import { supportedPreviewExts } from './file_preview_modal';
|
||||
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button';
|
||||
|
||||
export interface FileTableProps {
|
||||
files: FileInfo[]
|
||||
currentPath: string
|
||||
loading: boolean
|
||||
sortDescriptor: SortDescriptor
|
||||
onSortChange: (descriptor: SortDescriptor) => void
|
||||
selectedFiles: Selection
|
||||
onSelectionChange: (selected: Selection) => void
|
||||
onDirectoryClick: (dirPath: string) => void
|
||||
onEdit: (filePath: string) => void
|
||||
onPreview: (filePath: string) => void
|
||||
onRenameRequest: (name: string) => void
|
||||
onMoveRequest: (name: string) => void
|
||||
onCopyPath: (fileName: string) => void
|
||||
onDelete: (filePath: string) => void
|
||||
onDownload: (filePath: string) => void
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function FileTable ({
|
||||
files,
|
||||
currentPath,
|
||||
loading,
|
||||
sortDescriptor,
|
||||
onSortChange,
|
||||
selectedFiles,
|
||||
onSelectionChange,
|
||||
onDirectoryClick,
|
||||
onEdit,
|
||||
onPreview,
|
||||
onRenameRequest,
|
||||
onMoveRequest,
|
||||
onCopyPath,
|
||||
onDelete,
|
||||
onDownload,
|
||||
}: FileTableProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pages = Math.ceil(files.length / PAGE_SIZE) || 1;
|
||||
const start = (page - 1) * PAGE_SIZE;
|
||||
const end = start + PAGE_SIZE;
|
||||
const displayFiles = files.slice(start, end);
|
||||
const [showImage, setShowImage] = useState(false);
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([]);
|
||||
|
||||
const addPreviewImage = useCallback((image: PreviewImage) => {
|
||||
setPreviewImages((prev) => {
|
||||
const exists = prev.some((p) => p.key === image.key);
|
||||
if (exists) return prev;
|
||||
return [...prev, image];
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewImages([]);
|
||||
setPreviewIndex(0);
|
||||
setShowImage(false);
|
||||
setPage(1);
|
||||
}, [currentPath]);
|
||||
|
||||
const onPreviewImage = (name: string, images: PreviewImage[]) => {
|
||||
const index = images.findIndex((image) => image.key === name);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
setPreviewIndex(index);
|
||||
setShowImage(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PhotoSlider
|
||||
images={previewImages}
|
||||
visible={showImage}
|
||||
onClose={() => setShowImage(false)}
|
||||
index={previewIndex}
|
||||
onIndexChange={setPreviewIndex}
|
||||
/>
|
||||
<Table
|
||||
aria-label='文件列表'
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={onSortChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
defaultSelectedKeys={[]}
|
||||
selectedKeys={selectedFiles}
|
||||
selectionMode='multiple'
|
||||
bottomContent={
|
||||
<div className='flex w-full justify-center'>
|
||||
<Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color='primary'
|
||||
page={page}
|
||||
total={pages}
|
||||
onChange={(page) => setPage(page)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn key='name' allowsSorting>
|
||||
名称
|
||||
</TableColumn>
|
||||
<TableColumn key='type' allowsSorting>
|
||||
类型
|
||||
</TableColumn>
|
||||
<TableColumn key='size' allowsSorting>
|
||||
大小
|
||||
</TableColumn>
|
||||
<TableColumn key='mtime' allowsSorting>
|
||||
修改时间
|
||||
</TableColumn>
|
||||
<TableColumn key='actions'>操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
isLoading={loading}
|
||||
loadingContent={
|
||||
<div className='flex justify-center items-center h-full'>
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{displayFiles.map((file: FileInfo) => {
|
||||
const filePath = path.join(currentPath, file.name);
|
||||
const ext = path.extname(file.name).toLowerCase();
|
||||
const previewable = supportedPreviewExts.includes(ext);
|
||||
const images = previewImages;
|
||||
return (
|
||||
<TableRow key={file.name}>
|
||||
<TableCell>
|
||||
{imageExts.includes(ext)
|
||||
? (
|
||||
<ImageNameButton
|
||||
name={file.name}
|
||||
filePath={filePath}
|
||||
onPreview={() => onPreviewImage(file.name, images)}
|
||||
onAddPreview={addPreviewImage}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
variant='light'
|
||||
onPress={() =>
|
||||
file.isDirectory
|
||||
? onDirectoryClick(file.name)
|
||||
: previewable
|
||||
? onPreview(filePath)
|
||||
: onEdit(filePath)}
|
||||
className='text-left justify-start'
|
||||
startContent={
|
||||
<FileIcon
|
||||
name={file.name}
|
||||
isDirectory={file.isDirectory}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{file.name}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
||||
<TableCell>
|
||||
{isNaN(file.size) || file.isDirectory
|
||||
? '-'
|
||||
: `${file.size} 字节`}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup size='sm'>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() => onRenameRequest(file.name)}
|
||||
>
|
||||
<BiRename />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() => onMoveRequest(file.name)}
|
||||
>
|
||||
<FiMove />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() => onCopyPath(file.name)}
|
||||
>
|
||||
<FiCopy />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() => onDownload(filePath)}
|
||||
>
|
||||
<FiDownload />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() => onDelete(filePath)}
|
||||
>
|
||||
<FiTrash2 />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useRequest } from 'ahooks';
|
||||
import path from 'path-browserify';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
|
||||
import FileIcon from '../file_icon';
|
||||
|
||||
export interface PreviewImage {
|
||||
key: string
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp'];
|
||||
|
||||
export interface ImageNameButtonProps {
|
||||
name: string
|
||||
filePath: string
|
||||
onPreview: () => void
|
||||
onAddPreview: (image: PreviewImage) => void
|
||||
}
|
||||
|
||||
export default function ImageNameButton ({
|
||||
name,
|
||||
filePath,
|
||||
onPreview,
|
||||
onAddPreview,
|
||||
}: ImageNameButtonProps) {
|
||||
const { data, loading, error, run } = useRequest(
|
||||
async () => FileManager.downloadToURL(filePath),
|
||||
{
|
||||
refreshDeps: [filePath],
|
||||
manual: true,
|
||||
refreshDepsAction: () => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!filePath || !imageExts.includes(ext)) {
|
||||
return;
|
||||
}
|
||||
run();
|
||||
},
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
onAddPreview({
|
||||
key: name,
|
||||
src: data,
|
||||
alt: name,
|
||||
});
|
||||
}
|
||||
}, [data, name, onAddPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filePath) {
|
||||
run();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='light'
|
||||
className='text-left justify-start'
|
||||
onPress={onPreview}
|
||||
startContent={
|
||||
error
|
||||
? (
|
||||
<FileIcon name={name} isDirectory={false} />
|
||||
)
|
||||
: loading || !data
|
||||
? (
|
||||
<Spinner size='sm' />
|
||||
)
|
||||
: (
|
||||
<Image
|
||||
src={data}
|
||||
alt={name}
|
||||
className='w-8 h-8 flex-shrink-0'
|
||||
classNames={{
|
||||
wrapper: 'w-8 h-8 flex-shrink-0',
|
||||
}}
|
||||
radius='sm'
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@heroui/modal';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import clsx from 'clsx';
|
||||
import path from 'path-browserify';
|
||||
import { useState } from 'react';
|
||||
import { IoAdd, IoRemove } from 'react-icons/io5';
|
||||
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
|
||||
interface MoveModalProps {
|
||||
isOpen: boolean;
|
||||
moveTargetPath: string;
|
||||
selectionInfo: string;
|
||||
onClose: () => void;
|
||||
onMove: () => void;
|
||||
onSelect: (dir: string) => void; // 新增回调
|
||||
}
|
||||
|
||||
// 将 DirectoryTree 改为递归组件
|
||||
// 新增 selectedPath 属性,用于标识当前选中的目录
|
||||
function DirectoryTree ({
|
||||
basePath,
|
||||
onSelect,
|
||||
selectedPath,
|
||||
}: {
|
||||
basePath: string;
|
||||
onSelect: (dir: string) => void;
|
||||
selectedPath?: string;
|
||||
}) {
|
||||
const [dirs, setDirs] = useState<string[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// 新增loading状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchDirectories = async () => {
|
||||
try {
|
||||
// 直接使用 basePath 调用接口,移除 process.platform 判断
|
||||
const list = await FileManager.listDirectories(basePath);
|
||||
setDirs(list.map((item) => item.name));
|
||||
} catch (_error) {
|
||||
// ...error handling...
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (!expanded) {
|
||||
setExpanded(true);
|
||||
setLoading(true);
|
||||
await fetchDirectories();
|
||||
setLoading(false);
|
||||
} else {
|
||||
setExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
onSelect(basePath);
|
||||
handleToggle();
|
||||
};
|
||||
|
||||
// 计算显示的名称
|
||||
const getDisplayName = () => {
|
||||
if (basePath === '/') return '/';
|
||||
if (/^[A-Z]:$/i.test(basePath)) return basePath;
|
||||
return path.basename(basePath);
|
||||
};
|
||||
|
||||
// 更新 Button 的 variant 逻辑
|
||||
const isSeleted = selectedPath === basePath;
|
||||
const variant = isSeleted
|
||||
? 'solid'
|
||||
: selectedPath && path.dirname(selectedPath) === basePath
|
||||
? 'flat'
|
||||
: 'light';
|
||||
|
||||
return (
|
||||
<div className='ml-4'>
|
||||
<Button
|
||||
onPress={handleClick}
|
||||
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md'
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant={variant}
|
||||
startContent={
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-md',
|
||||
isSeleted ? 'bg-primary-600' : 'bg-primary-50'
|
||||
)}
|
||||
>
|
||||
{expanded ? <IoRemove /> : <IoAdd />}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{getDisplayName()}
|
||||
</Button>
|
||||
{expanded && (
|
||||
<div>
|
||||
{loading
|
||||
? (
|
||||
<div className='flex py-1 px-8'>
|
||||
<Spinner size='sm' color='primary' />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
dirs.map((dirName) => {
|
||||
const childPath =
|
||||
basePath === '/' && /^[A-Z]:$/i.test(dirName)
|
||||
? dirName
|
||||
: path.join(basePath, dirName);
|
||||
return (
|
||||
<DirectoryTree
|
||||
key={childPath}
|
||||
basePath={childPath}
|
||||
onSelect={onSelect}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MoveModal ({
|
||||
isOpen,
|
||||
moveTargetPath,
|
||||
selectionInfo,
|
||||
onClose,
|
||||
onMove,
|
||||
onSelect,
|
||||
}: MoveModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>选择目标目录</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='rounded-md p-2 border border-default-300 overflow-auto max-h-60'>
|
||||
<DirectoryTree
|
||||
basePath='/'
|
||||
onSelect={onSelect}
|
||||
selectedPath={moveTargetPath}
|
||||
/>
|
||||
</div>
|
||||
<p className='text-sm text-default-500 mt-2'>
|
||||
当前选择:{moveTargetPath || '未选择'}
|
||||
</p>
|
||||
<p className='text-sm text-default-500'>移动项:{selectionInfo}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onMove}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@heroui/modal';
|
||||
|
||||
interface RenameModalProps {
|
||||
isOpen: boolean
|
||||
newFileName: string
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onClose: () => void
|
||||
onRename: () => void
|
||||
}
|
||||
|
||||
export default function RenameModal ({
|
||||
isOpen,
|
||||
newFileName,
|
||||
onNameChange,
|
||||
onClose,
|
||||
onRename,
|
||||
}: RenameModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>重命名</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input label='新名称' value={newFileName} onChange={onNameChange} />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onRename}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface IconWrapperProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const IconWrapper = ({ children, className }: IconWrapperProps) => (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'flex items-center rounded-small justify-center w-7 h-7'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default IconWrapper;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ChevronRightIcon } from '../icons';
|
||||
|
||||
const ItemCounter = ({ number }: { number: number }) => (
|
||||
<div className='flex items-center gap-1 text-default-400'>
|
||||
<span className='text-small'>{number}</span>
|
||||
<ChevronRightIcon className='text-xl' />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ItemCounter;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getReleaseTime } from '@/utils/time';
|
||||
|
||||
import type { GithubRelease as GithubReleaseType } from '@/types/github';
|
||||
|
||||
export interface GithubReleaseProps {
|
||||
releaseData: GithubReleaseType
|
||||
}
|
||||
const GithubRelease: React.FC<GithubReleaseProps> = (props) => {
|
||||
const { releaseData } = props;
|
||||
const [releaseTime, setReleaseTime] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (releaseData) {
|
||||
const timer = setInterval(() => {
|
||||
const time = getReleaseTime(releaseData.published_at);
|
||||
|
||||
setReleaseTime(time);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [releaseData]);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span>Releases</span>
|
||||
<div className='px-2 py-1 rounded-small bg-default-100 bg-opacity-50 backdrop-blur-sm group-data-[hover=true]:bg-default-200'>
|
||||
<span className='text-tiny text-default-600'>{releaseData.name}</span>
|
||||
<div className='flex gap-2 text-tiny'>
|
||||
<span className='text-default-500'>{releaseTime}</span>
|
||||
<span className='text-success'>Latest</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GithubRelease;
|
||||
78
packages/napcat-webui-frontend/src/components/hitokoto.tsx
Normal file
78
packages/napcat-webui-frontend/src/components/hitokoto.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useRequest } from 'ahooks';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoCopy, IoRefresh } from 'react-icons/io5';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import PageLoading from './page_loading';
|
||||
|
||||
export default function Hitokoto () {
|
||||
const {
|
||||
data: dataOri,
|
||||
error,
|
||||
loading,
|
||||
run,
|
||||
} = useRequest(() => request.get<IHitokoto>('https://hitokoto.152710.xyz/'), {
|
||||
pollingInterval: 10000,
|
||||
throttleWait: 1000,
|
||||
});
|
||||
const data = dataOri?.data;
|
||||
const onCopy = () => {
|
||||
try {
|
||||
const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`;
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('复制成功');
|
||||
} catch (_error) {
|
||||
toast.error('复制失败, 请手动复制');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className='relative'>
|
||||
{loading && <PageLoading />}
|
||||
{error
|
||||
? (
|
||||
<div className='text-primary-400'>一言加载失败:{error.message}</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div>{data?.hitokoto}</div>
|
||||
<div className='text-right'>
|
||||
—— <span className='text-default-400'>{data?.from}</span>{' '}
|
||||
{data?.from_who}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Tooltip content='刷新' placement='top'>
|
||||
<Button
|
||||
onPress={run}
|
||||
size='sm'
|
||||
isLoading={loading}
|
||||
isIconOnly
|
||||
radius='full'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
>
|
||||
<IoRefresh />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content='复制' placement='top'>
|
||||
<Button
|
||||
onPress={onCopy}
|
||||
size='sm'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
color='success'
|
||||
variant='flat'
|
||||
>
|
||||
<IoCopy />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { motion, useMotionValue, useSpring } from 'motion/react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
const springValues = {
|
||||
damping: 30,
|
||||
stiffness: 100,
|
||||
mass: 2,
|
||||
};
|
||||
|
||||
export interface HoverTiltedCardProps {
|
||||
imageSrc: string
|
||||
altText?: string
|
||||
captionText?: string
|
||||
containerHeight?: string
|
||||
containerWidth?: string
|
||||
imageHeight?: string
|
||||
imageWidth?: string
|
||||
scaleOnHover?: number
|
||||
rotateAmplitude?: number
|
||||
showTooltip?: boolean
|
||||
overlayContent?: React.ReactNode
|
||||
displayOverlayContent?: boolean
|
||||
}
|
||||
|
||||
export default function HoverTiltedCard ({
|
||||
imageSrc,
|
||||
altText = 'NapCat',
|
||||
captionText = 'NapCat',
|
||||
containerHeight = '200px',
|
||||
containerWidth = '100%',
|
||||
imageHeight = '200px',
|
||||
imageWidth = '200px',
|
||||
scaleOnHover = 1.1,
|
||||
rotateAmplitude = 14,
|
||||
showTooltip = false,
|
||||
overlayContent = (
|
||||
<div className='text-center mt-6 px-4 py-0.5 shadow-lg rounded-full bg-primary-600 text-default-100 bg-opacity-80'>
|
||||
NapCat
|
||||
</div>
|
||||
),
|
||||
displayOverlayContent = true,
|
||||
}: HoverTiltedCardProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
const rotateX = useSpring(useMotionValue(0), springValues);
|
||||
const rotateY = useSpring(useMotionValue(0), springValues);
|
||||
const scale = useSpring(1, springValues);
|
||||
const opacity = useSpring(0);
|
||||
const rotateFigcaption = useSpring(0, {
|
||||
stiffness: 350,
|
||||
damping: 30,
|
||||
mass: 1,
|
||||
});
|
||||
|
||||
const [lastY, setLastY] = useState(0);
|
||||
|
||||
function handleMouse (e: React.MouseEvent) {
|
||||
if (!ref.current) return;
|
||||
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const offsetX = e.clientX - rect.left - rect.width / 2;
|
||||
const offsetY = e.clientY - rect.top - rect.height / 2;
|
||||
|
||||
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude;
|
||||
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude;
|
||||
|
||||
rotateX.set(rotationX);
|
||||
rotateY.set(rotationY);
|
||||
|
||||
x.set(e.clientX - rect.left);
|
||||
y.set(e.clientY - rect.top);
|
||||
|
||||
const velocityY = offsetY - lastY;
|
||||
rotateFigcaption.set(-velocityY * 0.6);
|
||||
setLastY(offsetY);
|
||||
}
|
||||
|
||||
function handleMouseEnter () {
|
||||
scale.set(scaleOnHover);
|
||||
opacity.set(1);
|
||||
}
|
||||
|
||||
function handleMouseLeave () {
|
||||
opacity.set(0);
|
||||
scale.set(1);
|
||||
rotateX.set(0);
|
||||
rotateY.set(0);
|
||||
rotateFigcaption.set(0);
|
||||
}
|
||||
|
||||
return (
|
||||
<figure
|
||||
ref={ref}
|
||||
className='relative w-full h-full [perspective:800px] flex flex-col items-center justify-center'
|
||||
style={{
|
||||
height: containerHeight,
|
||||
width: containerWidth,
|
||||
}}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<motion.div
|
||||
className='relative [transform-style:preserve-3d]'
|
||||
style={{
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
rotateX,
|
||||
rotateY,
|
||||
scale,
|
||||
}}
|
||||
>
|
||||
<motion.img
|
||||
src={imageSrc}
|
||||
alt={altText}
|
||||
className='absolute top-0 left-0 object-cover rounded-md will-change-transform [transform:translateZ(0)] pointer-events-none select-none'
|
||||
style={{
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
}}
|
||||
/>
|
||||
|
||||
{displayOverlayContent && overlayContent && (
|
||||
<motion.div className='absolute top-0 left-0 right-0 z-10 flex justify-center will-change-transform [transform:translateZ(30px)]'>
|
||||
{overlayContent}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{showTooltip && (
|
||||
<motion.figcaption
|
||||
className='pointer-events-none absolute left-0 top-0 rounded-md bg-white px-2 py-1 text-sm text-default-900 opacity-0 z-10 hidden sm:block'
|
||||
style={{
|
||||
x,
|
||||
y,
|
||||
opacity,
|
||||
rotate: rotateFigcaption,
|
||||
}}
|
||||
>
|
||||
{captionText}
|
||||
</motion.figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
1967
packages/napcat-webui-frontend/src/components/icons.tsx
Normal file
1967
packages/napcat-webui-frontend/src/components/icons.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export interface FileInputProps {
|
||||
onChange: (file: File) => Promise<void> | void;
|
||||
onDelete?: () => Promise<void> | void;
|
||||
label?: string;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
const FileInput: React.FC<FileInputProps> = ({
|
||||
onChange,
|
||||
onDelete,
|
||||
label,
|
||||
accept,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
return (
|
||||
<div className='flex items-end gap-2'>
|
||||
<div className='flex-grow'>
|
||||
<Input
|
||||
isDisabled={isLoading}
|
||||
ref={inputRef}
|
||||
label={label}
|
||||
type='file'
|
||||
placeholder='选择文件'
|
||||
accept={accept}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
await onChange(file);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
isDisabled={isLoading}
|
||||
onPress={async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (onDelete) await onDelete();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
color='primary'
|
||||
variant='flat'
|
||||
size='sm'
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileInput;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Input } from '@heroui/input';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export interface ImageInputProps {
|
||||
onChange: (base64: string) => void
|
||||
value: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<div className='flex items-end gap-2'>
|
||||
<div className='w-5 h-5 flex-shrink-0'>
|
||||
<Image
|
||||
src={value}
|
||||
alt={label}
|
||||
className='w-5 h-5 flex-shrink-0 rounded-none'
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
label={label}
|
||||
type='file'
|
||||
placeholder='选择图片'
|
||||
accept='image/*'
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const base64 = reader.result as string;
|
||||
onChange(base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onPress={() => {
|
||||
onChange('');
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}}
|
||||
color='primary'
|
||||
variant='flat'
|
||||
size='sm'
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageInput;
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import type { Selection } from '@react-types/shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { colorizeLogLevel } from '@/utils/terminal';
|
||||
|
||||
import PageLoading from '../page_loading';
|
||||
import XTerm from '../xterm';
|
||||
import type { XTermRef } from '../xterm';
|
||||
import LogLevelSelect from './log_level_select';
|
||||
|
||||
export interface HistoryLogsProps {
|
||||
list: string[]
|
||||
onSelect: (name: string) => void
|
||||
selectedLog?: string
|
||||
refreshList: () => void
|
||||
refreshLog: () => void
|
||||
listLoading?: boolean
|
||||
logLoading?: boolean
|
||||
listError?: Error
|
||||
logContent?: string
|
||||
}
|
||||
const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
|
||||
const {
|
||||
list,
|
||||
onSelect,
|
||||
selectedLog,
|
||||
refreshList,
|
||||
refreshLog,
|
||||
listLoading,
|
||||
logContent,
|
||||
listError,
|
||||
logLoading,
|
||||
} = props;
|
||||
const Xterm = useRef<XTermRef>(null);
|
||||
|
||||
const [logLevel, setLogLevel] = useState<Selection>(
|
||||
new Set(['info', 'warn', 'error'])
|
||||
);
|
||||
|
||||
const logToColored = (log: string) => {
|
||||
const logs = log
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const colored = colorizeLogLevel(line);
|
||||
return colored;
|
||||
})
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true;
|
||||
}
|
||||
return logLevel.has(log.level);
|
||||
})
|
||||
.map((log) => log.content)
|
||||
.join('\r\n');
|
||||
return logs;
|
||||
};
|
||||
|
||||
const onDownloadLog = () => {
|
||||
if (!logContent) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([logContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedLog}.log`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!Xterm.current || !logContent) {
|
||||
return;
|
||||
}
|
||||
Xterm.current.clear();
|
||||
const _logContent = logToColored(logContent);
|
||||
Xterm.current.write(_logContent + '\r\nnapcat@webui:~$ ');
|
||||
}, [logContent, logLevel]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>历史日志 - NapCat WebUI</title>
|
||||
<Card className='max-w-full h-full bg-opacity-50 backdrop-blur-sm'>
|
||||
<CardHeader className='flex-row justify-start gap-3'>
|
||||
<Select
|
||||
label='选择日志'
|
||||
size='sm'
|
||||
isLoading={listLoading}
|
||||
errorMessage={listError?.message}
|
||||
classNames={{
|
||||
trigger:
|
||||
'hover:!bg-content3 bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60',
|
||||
}}
|
||||
placeholder='选择日志'
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onSelect(value);
|
||||
}}
|
||||
selectedKeys={[selectedLog || '']}
|
||||
items={list.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
}))}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
<LogLevelSelect
|
||||
selectedKeys={logLevel}
|
||||
onSelectionChange={setLogLevel}
|
||||
/>
|
||||
<Button className='flex-shrink-0' onPress={onDownloadLog}>
|
||||
下载日志
|
||||
</Button>
|
||||
<Button onPress={refreshList}>刷新列表</Button>
|
||||
<Button onPress={refreshLog}>刷新日志</Button>
|
||||
</CardHeader>
|
||||
<CardBody className='relative'>
|
||||
<PageLoading loading={logLoading} />
|
||||
<XTerm className='w-full h-full' ref={Xterm} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryLogs;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { SharedSelection } from '@heroui/system';
|
||||
import type { Selection } from '@react-types/shared';
|
||||
|
||||
import { LogLevel } from '@/const/enum';
|
||||
|
||||
export interface LogLevelSelectProps {
|
||||
selectedKeys: Selection
|
||||
onSelectionChange: (keys: SharedSelection) => void
|
||||
}
|
||||
const logLevelColor: {
|
||||
[key in LogLevel]:
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'primary'
|
||||
} = {
|
||||
[LogLevel.DEBUG]: 'default',
|
||||
[LogLevel.INFO]: 'primary',
|
||||
[LogLevel.WARN]: 'warning',
|
||||
[LogLevel.ERROR]: 'primary',
|
||||
[LogLevel.FATAL]: 'primary',
|
||||
};
|
||||
const LogLevelSelect = (props: LogLevelSelectProps) => {
|
||||
const { selectedKeys, onSelectionChange } = props;
|
||||
return (
|
||||
<Select
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={(selectedKeys) => {
|
||||
if (selectedKeys !== 'all' && selectedKeys?.size === 0) {
|
||||
selectedKeys = 'all';
|
||||
}
|
||||
onSelectionChange(selectedKeys);
|
||||
}}
|
||||
label='日志级别'
|
||||
selectionMode='multiple'
|
||||
aria-label='Log Level'
|
||||
classNames={{
|
||||
label: 'mb-2',
|
||||
trigger: 'bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60',
|
||||
popoverContent: 'bg-opacity-50 backdrop-blur-sm',
|
||||
}}
|
||||
size='sm'
|
||||
items={[
|
||||
{ label: 'Debug', value: LogLevel.DEBUG },
|
||||
{ label: 'Info', value: LogLevel.INFO },
|
||||
{ label: 'Warn', value: LogLevel.WARN },
|
||||
{ label: 'Error', value: LogLevel.ERROR },
|
||||
{ label: 'Fatal', value: LogLevel.FATAL },
|
||||
]}
|
||||
renderValue={(value) => {
|
||||
if (value.length === 5) {
|
||||
return (
|
||||
<Chip size='sm' color='primary' variant='flat'>
|
||||
全部
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='flex gap-2'>
|
||||
{value.map((v) => (
|
||||
<Chip
|
||||
size='sm'
|
||||
key={v.key}
|
||||
color={logLevelColor[v.data?.value as LogLevel]}
|
||||
variant='flat'
|
||||
>
|
||||
{v.data?.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogLevelSelect;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import type { Selection } from '@react-types/shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoDownloadOutline } from 'react-icons/io5';
|
||||
|
||||
import { colorizeLogLevelWithTag } from '@/utils/terminal';
|
||||
|
||||
import WebUIManager, { Log } from '@/controllers/webui_manager';
|
||||
|
||||
import type { XTermRef } from '../xterm';
|
||||
import XTerm from '../xterm';
|
||||
import LogLevelSelect from './log_level_select';
|
||||
|
||||
const RealTimeLogs = () => {
|
||||
const Xterm = useRef<XTermRef>(null);
|
||||
const [logLevel, setLogLevel] = useState<Selection>(
|
||||
new Set(['info', 'warn', 'error'])
|
||||
);
|
||||
const [dataArr, setDataArr] = useState<Log[]>([]);
|
||||
|
||||
const onDownloadLog = () => {
|
||||
const logContent = dataArr
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true;
|
||||
}
|
||||
return logLevel.has(log.level);
|
||||
})
|
||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||
.join('\r\n');
|
||||
const blob = new Blob([logContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'napcat.log';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const writeStream = () => {
|
||||
try {
|
||||
const _data = dataArr
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true;
|
||||
}
|
||||
return logLevel.has(log.level);
|
||||
})
|
||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||
.join('\r\n');
|
||||
Xterm.current?.clear();
|
||||
Xterm.current?.write(_data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('获取实时日志失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
writeStream();
|
||||
}, [logLevel, dataArr]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscribeLogs = () => {
|
||||
try {
|
||||
const source = WebUIManager.getRealTimeLogs((data) => {
|
||||
setDataArr((prev) => {
|
||||
const newData = [...prev, ...data];
|
||||
if (newData.length > 1000) {
|
||||
newData.splice(0, newData.length - 1000);
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
source.close();
|
||||
};
|
||||
} catch (_error) {
|
||||
toast.error('获取实时日志失败');
|
||||
}
|
||||
};
|
||||
|
||||
const close = subscribeLogs();
|
||||
return () => {
|
||||
console.log('close');
|
||||
close?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>实时日志 - NapCat WebUI</title>
|
||||
<div className='flex items-center gap-2'>
|
||||
<LogLevelSelect
|
||||
selectedKeys={logLevel}
|
||||
onSelectionChange={setLogLevel}
|
||||
/>
|
||||
<Button
|
||||
className='flex-shrink-0'
|
||||
onPress={onDownloadLog}
|
||||
startContent={<IoDownloadOutline className='text-lg' />}
|
||||
>
|
||||
下载日志
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex-1 h-full overflow-hidden'>
|
||||
<XTerm ref={Xterm} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealTimeLogs;
|
||||
97
packages/napcat-webui-frontend/src/components/modal.tsx
Normal file
97
packages/napcat-webui-frontend/src/components/modal.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import {
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Modal as NextUIModal,
|
||||
useDisclosure,
|
||||
} from '@heroui/modal';
|
||||
import React from 'react';
|
||||
|
||||
export interface ModalProps {
|
||||
content: React.ReactNode
|
||||
title?: React.ReactNode
|
||||
size?: React.ComponentProps<typeof NextUIModal>['size']
|
||||
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior']
|
||||
onClose?: () => void
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
backdrop?: 'opaque' | 'blur' | 'transparent'
|
||||
showCancel?: boolean
|
||||
dismissible?: boolean
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
const {
|
||||
backdrop = 'blur',
|
||||
title,
|
||||
content,
|
||||
showCancel = true,
|
||||
dismissible,
|
||||
confirmText = '确定',
|
||||
cancelText = '取消',
|
||||
onClose,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
...rest
|
||||
} = props;
|
||||
const { onClose: onNativeClose } = useDisclosure();
|
||||
|
||||
return (
|
||||
<NextUIModal
|
||||
defaultOpen
|
||||
backdrop={backdrop}
|
||||
isDismissable={dismissible}
|
||||
onClose={() => {
|
||||
onClose?.();
|
||||
onNativeClose();
|
||||
}}
|
||||
classNames={{
|
||||
backdrop: 'z-[99]',
|
||||
wrapper: 'z-[99]',
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<ModalContent>
|
||||
{(nativeClose) => (
|
||||
<>
|
||||
{title && (
|
||||
<ModalHeader className='flex flex-col gap-1'>{title}</ModalHeader>
|
||||
)}
|
||||
<ModalBody className='break-all'>{content}</ModalBody>
|
||||
<ModalFooter>
|
||||
{showCancel && (
|
||||
<Button
|
||||
color='primary'
|
||||
variant='light'
|
||||
onPress={() => {
|
||||
onCancel?.();
|
||||
nativeClose();
|
||||
}}
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color='primary'
|
||||
onPress={() => {
|
||||
onConfirm?.();
|
||||
nativeClose();
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</NextUIModal>
|
||||
);
|
||||
});
|
||||
|
||||
Modal.displayName = 'Modal';
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Listbox, ListboxItem } from '@heroui/listbox';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { MdError } from 'react-icons/md';
|
||||
|
||||
import IconWrapper from '@/components/github_info/icon_wrapper';
|
||||
import ItemCounter from '@/components/github_info/item_counter';
|
||||
import GithubRelease from '@/components/github_info/release';
|
||||
import {
|
||||
BookIcon,
|
||||
BugIcon,
|
||||
PullRequestIcon,
|
||||
StarIcon,
|
||||
TagIcon,
|
||||
UsersIcon,
|
||||
WatchersIcon,
|
||||
} from '@/components/icons';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
import { openUrl } from '@/utils/url';
|
||||
|
||||
import type {
|
||||
GirhubRepo,
|
||||
GithubContributor,
|
||||
GithubPullRequest,
|
||||
GithubRelease as GithubReleaseType,
|
||||
} from '@/types/github';
|
||||
|
||||
function displayData (data: number, loading: boolean, error?: Error) {
|
||||
if (error) {
|
||||
return <MdError className='text-primary-400' />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Spinner size='sm' />;
|
||||
}
|
||||
|
||||
return <ItemCounter number={data} />;
|
||||
}
|
||||
|
||||
export default function NapCatRepoInfo () {
|
||||
// repo info
|
||||
const {
|
||||
data: repoOriData,
|
||||
error: repoError,
|
||||
loading: repoLoading,
|
||||
} = useRequest(() =>
|
||||
request.get<GirhubRepo>('https://api.github.com/repos/NapNeko/NapCatQQ')
|
||||
);
|
||||
|
||||
// release info
|
||||
const {
|
||||
data: releaseOriData,
|
||||
error: releaseError,
|
||||
loading: releaseLoading,
|
||||
} = useRequest(() =>
|
||||
request.get<GithubReleaseType[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
|
||||
)
|
||||
);
|
||||
|
||||
// pr info
|
||||
const {
|
||||
data: prData,
|
||||
error: prError,
|
||||
loading: prLoading,
|
||||
} = useRequest(() =>
|
||||
request.get<GithubPullRequest[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/pulls'
|
||||
)
|
||||
);
|
||||
|
||||
// contributors info
|
||||
const {
|
||||
data: contributorsData,
|
||||
error: contributorsError,
|
||||
loading: contributorsLoading,
|
||||
} = useRequest(() =>
|
||||
request.get<GithubContributor[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/contributors'
|
||||
)
|
||||
);
|
||||
|
||||
const repoData = repoOriData?.data;
|
||||
const releaseData = releaseOriData?.data?.[0];
|
||||
const prCount = prData?.data?.length || 0;
|
||||
const contributorsCount = contributorsData?.data?.length || 0;
|
||||
|
||||
const releaseCount = releaseOriData?.data?.length || 0;
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
aria-label='NapCat Repo Info'
|
||||
className='p-0 gap-0 divide-y divide-default-300/50 dark:divide-default-100/80 bg-content1 max-w-[300px] overflow-visible shadow-small rounded-medium bg-opacity-50 backdrop-blur-sm'
|
||||
itemClasses={{
|
||||
base: 'px-3 first:rounded-t-medium last:rounded-b-medium rounded-none gap-3 h-12 data-[hover=true]:bg-default-100/80',
|
||||
}}
|
||||
onAction={(key: React.Key) => {
|
||||
switch (key) {
|
||||
case 'releases':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/releases', true);
|
||||
break;
|
||||
case 'contributors':
|
||||
openUrl(
|
||||
'https://github.com/NapNeko/NapCatQQ/graphs/contributors',
|
||||
true
|
||||
);
|
||||
break;
|
||||
case 'license':
|
||||
openUrl(
|
||||
'https://github.com/NapNeko/NapCatQQ/blob/main/LICENSE',
|
||||
true
|
||||
);
|
||||
break;
|
||||
case 'watchers':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/watchers', true);
|
||||
break;
|
||||
case 'star':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/stargazers', true);
|
||||
break;
|
||||
case 'issues':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/issues', true);
|
||||
break;
|
||||
case 'pull_requests':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/pulls', true);
|
||||
break;
|
||||
default:
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ', true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListboxItem
|
||||
key='star'
|
||||
endContent={displayData(
|
||||
repoData?.stargazers_count ?? 0,
|
||||
false,
|
||||
repoError
|
||||
)}
|
||||
startContent={
|
||||
<IconWrapper className='bg-success/10 text-success'>
|
||||
<StarIcon className='text-lg' />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Star
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key='issues'
|
||||
endContent={displayData(
|
||||
repoData?.open_issues_count ?? 0,
|
||||
false,
|
||||
repoError
|
||||
)}
|
||||
startContent={
|
||||
<IconWrapper className='bg-success/10 text-success'>
|
||||
<BugIcon className='text-lg' />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Issues
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key='pull_requests'
|
||||
endContent={displayData(prCount, prLoading, prError)}
|
||||
startContent={
|
||||
<IconWrapper className='bg-primary/10 text-primary'>
|
||||
<PullRequestIcon className='text-lg' />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Pull Requests
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key='releases'
|
||||
className='group h-auto py-3'
|
||||
endContent={
|
||||
releaseError
|
||||
? (
|
||||
<MdError className='text-primary-400' />
|
||||
)
|
||||
: releaseLoading
|
||||
? (
|
||||
<Spinner size='sm' />
|
||||
)
|
||||
: (
|
||||
<ItemCounter number={releaseCount} />
|
||||
)
|
||||
}
|
||||
startContent={
|
||||
<IconWrapper className='bg-primary/10 text-primary'>
|
||||
<TagIcon className='text-lg' />
|
||||
</IconWrapper>
|
||||
}
|
||||
textValue='Releases'
|
||||
>
|
||||
{releaseData && <GithubRelease releaseData={releaseData} />}
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key='contributors'
|
||||
endContent={displayData(
|
||||
contributorsCount,
|
||||
contributorsLoading,
|
||||
contributorsError
|
||||
)}
|
||||
startContent={
|
||||
<IconWrapper className='bg-warning/10 text-warning'>
|
||||
<UsersIcon />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Contributors
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key='watchers'
|
||||
endContent={displayData(
|
||||
repoData?.watchers_count ?? 0,
|
||||
repoLoading,
|
||||
repoError
|
||||
)}
|
||||
startContent={
|
||||
<IconWrapper className='bg-default/50 text-foreground'>
|
||||
<WatchersIcon />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Watchers
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key='license'
|
||||
endContent={
|
||||
<span className='text-small text-default-400'>
|
||||
{repoData?.license?.name ?? 'unknown'}
|
||||
</span>
|
||||
}
|
||||
startContent={
|
||||
<IconWrapper className='bg-primary/10 text-primary dark:text-primary-500'>
|
||||
<BookIcon />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
License
|
||||
</ListboxItem>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { ModalBody, ModalFooter } from '@heroui/modal';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { ReactElement, useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import type {
|
||||
DefaultValues,
|
||||
Path,
|
||||
PathValue,
|
||||
SubmitHandler,
|
||||
} from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import SwitchCard from '../switch_card';
|
||||
|
||||
export type FieldTypes = 'input' | 'select' | 'switch';
|
||||
|
||||
type NetworkConfigType = OneBotConfig['network'];
|
||||
|
||||
export interface Field<T extends keyof OneBotConfig['network']> {
|
||||
name: keyof NetworkConfigType[T][0];
|
||||
label: string;
|
||||
type: FieldTypes;
|
||||
options?: Array<{ key: string; value: string; }>;
|
||||
placeholder?: string;
|
||||
isRequired?: boolean;
|
||||
isDisabled?: boolean;
|
||||
description?: string;
|
||||
colSpan?: 1 | 2;
|
||||
}
|
||||
|
||||
export interface GenericFormProps<T extends keyof NetworkConfigType> {
|
||||
data?: NetworkConfigType[T][0];
|
||||
defaultValues: DefaultValues<NetworkConfigType[T][0]>;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: NetworkConfigType[T][0]) => Promise<void>;
|
||||
fields: Array<Field<T>>;
|
||||
}
|
||||
|
||||
const GenericForm = <T extends keyof NetworkConfigType> ({
|
||||
data,
|
||||
defaultValues,
|
||||
onClose,
|
||||
onSubmit,
|
||||
fields,
|
||||
}: GenericFormProps<T>): ReactElement => {
|
||||
const { control, handleSubmit, formState, setValue, reset } = useForm<
|
||||
NetworkConfigType[T][0]
|
||||
>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const submitAction: SubmitHandler<NetworkConfigType[T][0]> = async (data) => {
|
||||
await onSubmit(data);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const _onSubmit = handleSubmit(submitAction, (e) => {
|
||||
const errors = Object.values(e);
|
||||
if (errors.length > 0) {
|
||||
toast.error(errors[0]?.message as string);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const keys = Object.keys(data) as Path<NetworkConfig[T][0]>[];
|
||||
for (const key of keys) {
|
||||
const value = data[key] as PathValue<
|
||||
NetworkConfig[T][0],
|
||||
Path<NetworkConfig[T][0]>
|
||||
>;
|
||||
setValue(key, value);
|
||||
}
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
}, [data, reset, setValue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalBody>
|
||||
<div className='grid grid-cols-2 gap-y-4 gap-x-2 w-full'>
|
||||
{fields.map((field) => (
|
||||
<div
|
||||
key={field.name as string}
|
||||
className={field.colSpan === 1 ? 'col-span-1' : 'col-span-2'}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name={field.name as Path<NetworkConfig[T][0]>}
|
||||
rules={
|
||||
field.isRequired
|
||||
? {
|
||||
required: `请填写${field.label}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
render={({ field: controllerField }) => {
|
||||
switch (field.type) {
|
||||
case 'input':
|
||||
return (
|
||||
<Input
|
||||
value={controllerField.value as string}
|
||||
onValueChange={(value) => controllerField.onChange(value)}
|
||||
ref={controllerField.ref}
|
||||
isRequired={field.isRequired}
|
||||
isDisabled={field.isDisabled}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
{...controllerField}
|
||||
ref={controllerField.ref}
|
||||
isRequired={field.isRequired}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
selectedKeys={[controllerField.value as string]}
|
||||
value={controllerField.value.toString()}
|
||||
>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option.key} value={option.value}>
|
||||
{option.value}
|
||||
</SelectItem>
|
||||
)) || <></>}
|
||||
</Select>
|
||||
);
|
||||
case 'switch':
|
||||
return (
|
||||
<SwitchCard
|
||||
{...controllerField}
|
||||
value={controllerField.value as boolean}
|
||||
description={field.description}
|
||||
label={field.label}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
color='primary'
|
||||
isDisabled={formState.isSubmitting}
|
||||
variant='light'
|
||||
onPress={onClose}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
isLoading={formState.isSubmitting}
|
||||
onPress={() => _onSubmit()}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenericForm;
|
||||
export function random_token (length: number) {
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()-_=+[]{}|;:,.<>?';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface HTTPClientFormProps {
|
||||
data?: OneBotConfig['network']['httpClients'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (data: OneBotConfig['network']['httpClients'][0]) => Promise<void>
|
||||
}
|
||||
|
||||
type HTTPClientFormType = OneBotConfig['network']['httpClients'];
|
||||
|
||||
const HTTPClientForm: React.FC<HTTPClientFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const defaultValues: HTTPClientFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
url: 'http://localhost:8080',
|
||||
reportSelfMessage: false,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
};
|
||||
|
||||
const fields: Field<'httpClients'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data,
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL',
|
||||
type: 'input',
|
||||
placeholder: '请输入URL',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'reportSelfMessage',
|
||||
label: '上报自身消息',
|
||||
type: 'switch',
|
||||
description: '是否上报自身消息',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' },
|
||||
],
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPClientForm;
|
||||
@@ -0,0 +1,110 @@
|
||||
import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface HTTPServerFormProps {
|
||||
data?: OneBotConfig['network']['httpServers'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise<void>
|
||||
}
|
||||
|
||||
type HTTPServerFormType = OneBotConfig['network']['httpServers'];
|
||||
|
||||
const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const defaultValues: HTTPServerFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
enableCors: true,
|
||||
enableWebsocket: true,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
};
|
||||
|
||||
const fields: Field<'httpServers'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data,
|
||||
},
|
||||
{
|
||||
name: 'host',
|
||||
label: 'Host',
|
||||
type: 'input',
|
||||
placeholder: '请输入主机地址',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
label: 'Port',
|
||||
type: 'input',
|
||||
placeholder: '请输入端口',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'enableCors',
|
||||
label: '启用CORS',
|
||||
type: 'switch',
|
||||
description: '是否启用CORS跨域',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'enableWebsocket',
|
||||
label: '启用Websocket',
|
||||
type: 'switch',
|
||||
description: '是否启用Websocket',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPServerForm;
|
||||
@@ -0,0 +1,120 @@
|
||||
import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface HTTPServerSSEFormProps {
|
||||
data?: OneBotConfig['network']['httpSseServers'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (
|
||||
data: OneBotConfig['network']['httpSseServers'][0]
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
type HTTPServerSSEFormType = OneBotConfig['network']['httpSseServers'];
|
||||
|
||||
const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const defaultValues: HTTPServerSSEFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
enableCors: true,
|
||||
enableWebsocket: true,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
reportSelfMessage: false,
|
||||
};
|
||||
|
||||
const fields: Field<'httpSseServers'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data,
|
||||
},
|
||||
{
|
||||
name: 'host',
|
||||
label: 'Host',
|
||||
type: 'input',
|
||||
placeholder: '请输入主机地址',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
label: 'Port',
|
||||
type: 'input',
|
||||
placeholder: '请输入端口',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'enableCors',
|
||||
label: '启用CORS',
|
||||
type: 'switch',
|
||||
description: '是否启用CORS跨域',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'enableWebsocket',
|
||||
label: '启用Websocket',
|
||||
type: 'switch',
|
||||
description: '是否启用Websocket',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token',
|
||||
},
|
||||
{
|
||||
name: 'reportSelfMessage',
|
||||
label: '上报自身消息',
|
||||
type: 'switch',
|
||||
description: '是否上报自身消息',
|
||||
colSpan: 1,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPServerSSEForm;
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Modal, ModalContent, ModalHeader } from '@heroui/modal';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import useConfig from '@/hooks/use-config';
|
||||
|
||||
import HTTPClientForm from './http_client';
|
||||
import HTTPServerForm from './http_server';
|
||||
import HTTPServerSSEForm from './http_sse';
|
||||
import WebsocketClientForm from './ws_client';
|
||||
import WebsocketServerForm from './ws_server';
|
||||
|
||||
const modalTitle = {
|
||||
httpServers: 'HTTP Server',
|
||||
httpClients: 'HTTP Client',
|
||||
httpSseServers: 'HTTP SSE Server',
|
||||
websocketServers: 'Websocket Server',
|
||||
websocketClients: 'Websocket Client',
|
||||
};
|
||||
|
||||
export interface NetworkFormModalProps<
|
||||
T extends keyof OneBotConfig['network']
|
||||
> {
|
||||
isOpen: boolean;
|
||||
field: T;
|
||||
data?: OneBotConfig['network'][T][0];
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
|
||||
props: NetworkFormModalProps<T>
|
||||
) => {
|
||||
const { isOpen, onOpenChange, field, data } = props;
|
||||
const { createNetworkConfig, updateNetworkConfig } = useConfig();
|
||||
const isCreate = !data;
|
||||
|
||||
const onSubmit = async (data: OneBotConfig['network'][typeof field][0]) => {
|
||||
try {
|
||||
if (isCreate) {
|
||||
await createNetworkConfig(field, data);
|
||||
} else {
|
||||
await updateNetworkConfig(field, data);
|
||||
}
|
||||
toast.success('保存配置成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
|
||||
toast.error(`保存配置失败: ${msg}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFormComponent = (onClose: () => void) => {
|
||||
switch (field) {
|
||||
case 'httpServers':
|
||||
return (
|
||||
<HTTPServerForm
|
||||
data={data as OneBotConfig['network']['httpServers'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
case 'httpClients':
|
||||
return (
|
||||
<HTTPClientForm
|
||||
data={data as OneBotConfig['network']['httpClients'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
case 'websocketServers':
|
||||
return (
|
||||
<WebsocketServerForm
|
||||
data={data as OneBotConfig['network']['websocketServers'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
case 'websocketClients':
|
||||
return (
|
||||
<WebsocketClientForm
|
||||
data={data as OneBotConfig['network']['websocketClients'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
case 'httpSseServers':
|
||||
return (
|
||||
<HTTPServerSSEForm
|
||||
data={data as OneBotConfig['network']['httpSseServers'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
backdrop='blur'
|
||||
isDismissable={false}
|
||||
isOpen={isOpen}
|
||||
size='lg'
|
||||
scrollBehavior='outside'
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className='flex flex-col gap-1'>
|
||||
{modalTitle[field]}
|
||||
</ModalHeader>
|
||||
{renderFormComponent(onClose)}
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkFormModal;
|
||||
@@ -0,0 +1,115 @@
|
||||
import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface WebsocketClientFormProps {
|
||||
data?: OneBotConfig['network']['websocketClients'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (
|
||||
data: OneBotConfig['network']['websocketClients'][0]
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
type WebsocketClientFormType = OneBotConfig['network']['websocketClients'];
|
||||
|
||||
const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const defaultValues: WebsocketClientFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
url: 'ws://localhost:8082',
|
||||
reportSelfMessage: false,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
heartInterval: 30000,
|
||||
reconnectInterval: 30000,
|
||||
};
|
||||
|
||||
const fields: Field<'websocketClients'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data,
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL',
|
||||
type: 'input',
|
||||
placeholder: '请输入URL',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'reportSelfMessage',
|
||||
label: '上报自身消息',
|
||||
type: 'switch',
|
||||
description: '是否上报自身消息',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' },
|
||||
],
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token',
|
||||
},
|
||||
{
|
||||
name: 'heartInterval',
|
||||
label: '心跳间隔',
|
||||
type: 'input',
|
||||
placeholder: '请输入心跳间隔',
|
||||
isRequired: true,
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'reconnectInterval',
|
||||
label: '重连间隔',
|
||||
type: 'input',
|
||||
placeholder: '请输入重连间隔',
|
||||
isRequired: true,
|
||||
colSpan: 1,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsocketClientForm;
|
||||
@@ -0,0 +1,122 @@
|
||||
import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface WebsocketServerFormProps {
|
||||
data?: OneBotConfig['network']['websocketServers'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (
|
||||
data: OneBotConfig['network']['websocketServers'][0]
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
type WebsocketServerFormType = OneBotConfig['network']['websocketServers'];
|
||||
|
||||
const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const defaultValues: WebsocketServerFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
host: '127.0.0.1',
|
||||
port: 3001,
|
||||
reportSelfMessage: false,
|
||||
enableForcePushEvent: true,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
heartInterval: 30000,
|
||||
};
|
||||
|
||||
const fields: Field<'websocketServers'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data,
|
||||
},
|
||||
{
|
||||
name: 'host',
|
||||
label: 'Host',
|
||||
type: 'input',
|
||||
placeholder: '请输入主机地址',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
label: 'Port',
|
||||
type: 'input',
|
||||
placeholder: '请输入端口',
|
||||
isRequired: true,
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' },
|
||||
],
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'reportSelfMessage',
|
||||
label: '上报自身消息',
|
||||
type: 'switch',
|
||||
description: '是否上报自身消息',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'enableForcePushEvent',
|
||||
label: '强制推送事件',
|
||||
type: 'switch',
|
||||
description: '是否强制推送事件',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token',
|
||||
},
|
||||
{
|
||||
name: 'heartInterval',
|
||||
label: '心跳间隔',
|
||||
type: 'input',
|
||||
placeholder: '请输入心跳间隔',
|
||||
isRequired: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsocketServerForm;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Image } from '@heroui/image';
|
||||
|
||||
import bkg_color from '@/assets/images/bkg-color.png';
|
||||
|
||||
const PageBackground = () => {
|
||||
return (
|
||||
<>
|
||||
<div className='fixed w-full h-full -z-[0] flex justify-end opacity-80'>
|
||||
<Image
|
||||
className='overflow-hidden object-contain -top-42 h-[160%] -right-[30%] -rotate-45 pointer-events-none select-none -z-10 relative'
|
||||
src={bkg_color}
|
||||
/>
|
||||
</div>
|
||||
<div className='fixed w-full h-full overflow-hidden -z-[0] hue-rotate-90 flex justify-start opacity-80'>
|
||||
<Image
|
||||
className='relative -top-92 h-[180%] object-contain pointer-events-none rotate-90 select-none -z-10 top-44'
|
||||
src={bkg_color}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageBackground;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface PageLoadingProps {
|
||||
loading?: boolean
|
||||
}
|
||||
const PageLoading: React.FC<PageLoadingProps> = ({ loading }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute top-0 left-0 w-full h-full bg-zinc-500 bg-opacity-10 z-50 flex justify-center items-center backdrop-blur',
|
||||
{
|
||||
hidden: !loading,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageLoading;
|
||||
58
packages/napcat-webui-frontend/src/components/primitives.ts
Normal file
58
packages/napcat-webui-frontend/src/components/primitives.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
export const title = tv({
|
||||
base: 'tracking-tight inline font-semibold',
|
||||
variants: {
|
||||
color: {
|
||||
violet: 'from-[#FF1CF7] to-[#b249f8]',
|
||||
yellow: 'from-[#FF705B] to-[#FFB457]',
|
||||
blue: 'from-[#5EA2EF] to-[#0072F5]',
|
||||
cyan: 'from-[#00b7fa] to-[#01cfea]',
|
||||
green: 'from-[#6FEE8D] to-[#17c964]',
|
||||
pink: 'from-[#FF72E1] to-[#F54C7A]',
|
||||
foreground: 'from-[#FFFFFF] to-[#4B4B4B]',
|
||||
},
|
||||
size: {
|
||||
xxs: 'text-medium lg:text-medium',
|
||||
xs: 'text-xl lg:text-xl',
|
||||
sm: 'text-3xl lg:text-4xl',
|
||||
md: 'text-[2.3rem] lg:text-5xl leading-9',
|
||||
lg: 'text-4xl lg:text-6xl',
|
||||
},
|
||||
fullWidth: {
|
||||
true: 'w-full block',
|
||||
},
|
||||
shadow: {
|
||||
true: 'drop-shadow-md',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
color: [
|
||||
'violet',
|
||||
'yellow',
|
||||
'blue',
|
||||
'cyan',
|
||||
'green',
|
||||
'pink',
|
||||
'foreground',
|
||||
],
|
||||
class: 'bg-clip-text text-transparent bg-gradient-to-b',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const subtitle = tv({
|
||||
base: 'w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full',
|
||||
variants: {
|
||||
fullWidth: {
|
||||
true: '!w-full',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
fullWidth: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Image } from '@heroui/image';
|
||||
import clsx from 'clsx';
|
||||
import { BsTencentQq } from 'react-icons/bs';
|
||||
|
||||
import { SelfInfo } from '@/types/user';
|
||||
|
||||
import PageLoading from './page_loading';
|
||||
|
||||
export interface QQInfoCardProps {
|
||||
data?: SelfInfo
|
||||
error?: Error
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
return (
|
||||
<Card
|
||||
className='relative bg-primary-100 bg-opacity-60 overflow-hidden flex-shrink-0 shadow-md shadow-primary-300 dark:shadow-primary-50'
|
||||
shadow='none'
|
||||
radius='lg'
|
||||
>
|
||||
<PageLoading loading={loading} />
|
||||
{error
|
||||
? (
|
||||
<CardBody className='items-center gap-1 justify-center'>
|
||||
<div className='flex-1 text-content1-foreground'>Error</div>
|
||||
<div className='whitespace-nowrap text-nowrap flex-shrink-0'>
|
||||
{error.message}
|
||||
</div>
|
||||
</CardBody>
|
||||
)
|
||||
: (
|
||||
<CardBody className='flex-row items-center gap-2 overflow-hidden relative'>
|
||||
<div className='absolute right-0 bottom-0 text-5xl text-primary-400'>
|
||||
<BsTencentQq />
|
||||
</div>
|
||||
<div className='relative flex-shrink-0 z-10'>
|
||||
<Image
|
||||
src={
|
||||
data?.avatarUrl ??
|
||||
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=1`
|
||||
}
|
||||
className='shadow-md rounded-full w-12 aspect-square'
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'w-4 h-4 rounded-full absolute right-0.5 bottom-0 border-2 border-primary-100 z-10',
|
||||
data?.online ? 'bg-green-500' : 'bg-gray-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-col justify-center'>
|
||||
<div className='text-lg truncate'>{data?.nick}</div>
|
||||
<div className='text-primary-500 text-sm'>{data?.uin}</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default QQInfoCard;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
interface QrCodeLoginProps {
|
||||
qrcode: string
|
||||
}
|
||||
|
||||
const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode }) => {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden'>
|
||||
{!qrcode && (
|
||||
<div className='absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center'>
|
||||
<Spinner color='primary' />
|
||||
</div>
|
||||
)}
|
||||
<QRCodeSVG size={180} value={qrcode} />
|
||||
</div>
|
||||
<div className='mt-5 text-center'>请使用QQ或者TIM扫描上方二维码</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QrCodeLogin;
|
||||
129
packages/napcat-webui-frontend/src/components/quick_login.tsx
Normal file
129
packages/napcat-webui-frontend/src/components/quick_login.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Avatar } from '@heroui/avatar';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
|
||||
import { isQQQuickNewItem } from '@/utils/qq';
|
||||
|
||||
export interface QQItem {
|
||||
uin: string
|
||||
}
|
||||
|
||||
interface QuickLoginProps {
|
||||
qqList: (QQItem | LoginListItem)[]
|
||||
refresh: boolean
|
||||
isLoading: boolean
|
||||
selectedQQ: string
|
||||
onUpdateQQList: () => void
|
||||
handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement>
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
const QuickLogin: React.FC<QuickLoginProps> = ({
|
||||
qqList,
|
||||
refresh,
|
||||
isLoading,
|
||||
selectedQQ,
|
||||
onUpdateQQList,
|
||||
handleSelectionChange,
|
||||
onSubmit,
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex flex-col gap-8'>
|
||||
<div className='flex justify-center'>
|
||||
<Image
|
||||
className='shadow-lg'
|
||||
height={100}
|
||||
radius='full'
|
||||
src={`https://q1.qlogo.cn/g?b=qq&nk=${selectedQQ || '0'}&s=100`}
|
||||
width={100}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Select
|
||||
classNames={{
|
||||
popoverContent: 'bg-opacity-50 backdrop-blur',
|
||||
}}
|
||||
aria-label='QQ Login'
|
||||
isDisabled={refresh}
|
||||
items={qqList}
|
||||
placeholder='请选择QQ'
|
||||
renderValue={(items) => {
|
||||
return items.map((item) => (
|
||||
<div key={item.key} className='flex items-center gap-2'>
|
||||
<Avatar
|
||||
alt={item.key?.toString()}
|
||||
className='flex-shrink-0'
|
||||
size='sm'
|
||||
src={
|
||||
isQQQuickNewItem(item.data)
|
||||
? item.data?.faceUrl
|
||||
: `https://q1.qlogo.cn/g?b=qq&nk=${item.key}&s=1`
|
||||
}
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
{isQQQuickNewItem(item.data)
|
||||
? `${item.data.nickName}(${item.key?.toString()})`
|
||||
: item.key?.toString()}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
}}
|
||||
selectedKeys={[selectedQQ]}
|
||||
size='lg'
|
||||
onChange={handleSelectionChange}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.uin} textValue={item.uin}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Avatar
|
||||
alt={item.uin}
|
||||
className='flex-shrink-0'
|
||||
size='sm'
|
||||
src={
|
||||
isQQQuickNewItem(item)
|
||||
? item.faceUrl
|
||||
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
|
||||
}
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
{isQQQuickNewItem(item)
|
||||
? `${item.nickName}(${item.uin})`
|
||||
: item.uin}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='flex-grow-0 flex-shrink-0'
|
||||
color='secondary'
|
||||
isLoading={refresh}
|
||||
radius='full'
|
||||
size='lg'
|
||||
variant='light'
|
||||
onPress={onUpdateQQList}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex justify-center mt-5'>
|
||||
<Button
|
||||
className='w-64 max-w-full'
|
||||
color='primary'
|
||||
isLoading={isLoading}
|
||||
radius='full'
|
||||
size='lg'
|
||||
variant='shadow'
|
||||
onPress={onSubmit}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickLogin;
|
||||
265
packages/napcat-webui-frontend/src/components/rotating_text.tsx
Normal file
265
packages/napcat-webui-frontend/src/components/rotating_text.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
AnimatePresence,
|
||||
HTMLMotionProps,
|
||||
TargetAndTransition,
|
||||
Transition,
|
||||
motion,
|
||||
} from 'motion/react';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
function cn (...classes: (string | undefined | null | boolean)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export interface RotatingTextRef {
|
||||
next: () => void
|
||||
previous: () => void
|
||||
jumpTo: (index: number) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export interface RotatingTextProps
|
||||
extends Omit<
|
||||
HTMLMotionProps<'span'>,
|
||||
'children' | 'transition' | 'initial' | 'animate' | 'exit'
|
||||
> {
|
||||
texts: string[]
|
||||
transition?: Transition
|
||||
initial?: TargetAndTransition
|
||||
animate?: TargetAndTransition
|
||||
exit?: TargetAndTransition
|
||||
animatePresenceMode?: 'sync' | 'wait'
|
||||
animatePresenceInitial?: boolean
|
||||
rotationInterval?: number
|
||||
staggerDuration?: number
|
||||
staggerFrom?: 'first' | 'last' | 'center' | 'random' | number
|
||||
loop?: boolean
|
||||
auto?: boolean
|
||||
splitBy?: string
|
||||
onNext?: (index: number) => void
|
||||
mainClassName?: string
|
||||
splitLevelClassName?: string
|
||||
elementLevelClassName?: string
|
||||
}
|
||||
|
||||
const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
|
||||
(
|
||||
{
|
||||
texts,
|
||||
transition = { type: 'spring', damping: 25, stiffness: 300 },
|
||||
initial = { y: '100%', opacity: 0 },
|
||||
animate = { y: 0, opacity: 1 },
|
||||
exit = { y: '-120%', opacity: 0 },
|
||||
animatePresenceMode = 'wait',
|
||||
animatePresenceInitial = false,
|
||||
rotationInterval = 2000,
|
||||
staggerDuration = 0,
|
||||
staggerFrom = 'first',
|
||||
loop = true,
|
||||
auto = true,
|
||||
splitBy = 'characters',
|
||||
onNext,
|
||||
mainClassName,
|
||||
splitLevelClassName,
|
||||
elementLevelClassName,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [currentTextIndex, setCurrentTextIndex] = useState<number>(0);
|
||||
|
||||
const splitIntoCharacters = (text: string): string[] => {
|
||||
return Array.from(text);
|
||||
};
|
||||
|
||||
const elements = useMemo(() => {
|
||||
const currentText: string = texts[currentTextIndex];
|
||||
if (splitBy === 'characters') {
|
||||
const words = currentText.split(' ');
|
||||
return words.map((word, i) => ({
|
||||
characters: splitIntoCharacters(word),
|
||||
needsSpace: i !== words.length - 1,
|
||||
}));
|
||||
}
|
||||
if (splitBy === 'words') {
|
||||
return currentText.split(' ').map((word, i, arr) => ({
|
||||
characters: [word],
|
||||
needsSpace: i !== arr.length - 1,
|
||||
}));
|
||||
}
|
||||
if (splitBy === 'lines') {
|
||||
return currentText.split('\n').map((line, i, arr) => ({
|
||||
characters: [line],
|
||||
needsSpace: i !== arr.length - 1,
|
||||
}));
|
||||
}
|
||||
|
||||
return currentText.split(splitBy).map((part, i, arr) => ({
|
||||
characters: [part],
|
||||
needsSpace: i !== arr.length - 1,
|
||||
}));
|
||||
}, [texts, currentTextIndex, splitBy]);
|
||||
|
||||
const getStaggerDelay = useCallback(
|
||||
(index: number, totalChars: number): number => {
|
||||
const total = totalChars;
|
||||
if (staggerFrom === 'first') return index * staggerDuration;
|
||||
if (staggerFrom === 'last') return (total - 1 - index) * staggerDuration;
|
||||
if (staggerFrom === 'center') {
|
||||
const center = Math.floor(total / 2);
|
||||
return Math.abs(center - index) * staggerDuration;
|
||||
}
|
||||
if (staggerFrom === 'random') {
|
||||
const randomIndex = Math.floor(Math.random() * total);
|
||||
return Math.abs(randomIndex - index) * staggerDuration;
|
||||
}
|
||||
return Math.abs((staggerFrom as number) - index) * staggerDuration;
|
||||
},
|
||||
[staggerFrom, staggerDuration]
|
||||
);
|
||||
|
||||
const handleIndexChange = useCallback(
|
||||
(newIndex: number) => {
|
||||
setCurrentTextIndex(newIndex);
|
||||
if (onNext) onNext(newIndex);
|
||||
},
|
||||
[onNext]
|
||||
);
|
||||
|
||||
const next = useCallback(() => {
|
||||
const nextIndex =
|
||||
currentTextIndex === texts.length - 1
|
||||
? loop
|
||||
? 0
|
||||
: currentTextIndex
|
||||
: currentTextIndex + 1;
|
||||
if (nextIndex !== currentTextIndex) {
|
||||
handleIndexChange(nextIndex);
|
||||
}
|
||||
}, [currentTextIndex, texts.length, loop, handleIndexChange]);
|
||||
|
||||
const previous = useCallback(() => {
|
||||
const prevIndex =
|
||||
currentTextIndex === 0
|
||||
? loop
|
||||
? texts.length - 1
|
||||
: currentTextIndex
|
||||
: currentTextIndex - 1;
|
||||
if (prevIndex !== currentTextIndex) {
|
||||
handleIndexChange(prevIndex);
|
||||
}
|
||||
}, [currentTextIndex, texts.length, loop, handleIndexChange]);
|
||||
|
||||
const jumpTo = useCallback(
|
||||
(index: number) => {
|
||||
const validIndex = Math.max(0, Math.min(index, texts.length - 1));
|
||||
if (validIndex !== currentTextIndex) {
|
||||
handleIndexChange(validIndex);
|
||||
}
|
||||
},
|
||||
[texts.length, currentTextIndex, handleIndexChange]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (currentTextIndex !== 0) {
|
||||
handleIndexChange(0);
|
||||
}
|
||||
}, [currentTextIndex, handleIndexChange]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
next,
|
||||
previous,
|
||||
jumpTo,
|
||||
reset,
|
||||
}),
|
||||
[next, previous, jumpTo, reset]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auto) return;
|
||||
const intervalId = setInterval(next, rotationInterval);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [next, rotationInterval, auto]);
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className={cn(
|
||||
'flex flex-wrap whitespace-pre-wrap relative',
|
||||
mainClassName
|
||||
)}
|
||||
{...rest}
|
||||
layout
|
||||
transition={transition}
|
||||
>
|
||||
<span className='sr-only'>{texts[currentTextIndex]}</span>
|
||||
<AnimatePresence
|
||||
mode={animatePresenceMode}
|
||||
initial={animatePresenceInitial}
|
||||
>
|
||||
<motion.div
|
||||
key={currentTextIndex}
|
||||
className={cn(
|
||||
splitBy === 'lines'
|
||||
? 'flex flex-col w-full'
|
||||
: 'flex flex-wrap whitespace-pre-wrap relative'
|
||||
)}
|
||||
layout
|
||||
aria-hidden='true'
|
||||
initial={initial as HTMLMotionProps<'div'>['initial']}
|
||||
animate={animate as HTMLMotionProps<'div'>['animate']}
|
||||
exit={exit as HTMLMotionProps<'div'>['exit']}
|
||||
>
|
||||
{elements.map((wordObj, wordIndex, array) => {
|
||||
const previousCharsCount = array
|
||||
.slice(0, wordIndex)
|
||||
.reduce((sum, word) => sum + word.characters.length, 0);
|
||||
return (
|
||||
<span
|
||||
key={wordIndex}
|
||||
className={cn('inline-flex', splitLevelClassName)}
|
||||
>
|
||||
{wordObj.characters.map((char, charIndex) => (
|
||||
<motion.span
|
||||
key={charIndex}
|
||||
initial={initial as HTMLMotionProps<'span'>['initial']}
|
||||
animate={animate as HTMLMotionProps<'span'>['animate']}
|
||||
exit={exit as HTMLMotionProps<'span'>['exit']}
|
||||
transition={{
|
||||
...transition,
|
||||
delay: getStaggerDelay(
|
||||
previousCharsCount + charIndex,
|
||||
array.reduce(
|
||||
(sum, word) => sum + word.characters.length,
|
||||
0
|
||||
)
|
||||
),
|
||||
}}
|
||||
className={cn('inline-block', elementLevelClassName)}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
{wordObj.needsSpace && (
|
||||
<span className='whitespace-pre'> </span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
RotatingText.displayName = 'RotatingText';
|
||||
export default RotatingText;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Image } from '@heroui/image';
|
||||
import clsx from 'clsx';
|
||||
import { motion } from 'motion/react';
|
||||
import React from 'react';
|
||||
import { IoMdLogOut } from 'react-icons/io';
|
||||
import { MdDarkMode, MdLightMode } from 'react-icons/md';
|
||||
|
||||
import useAuth from '@/hooks/auth';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import type { MenuItem } from '@/config/site';
|
||||
|
||||
import Menus from './menus';
|
||||
|
||||
interface SideBarProps {
|
||||
open: boolean
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
const SideBar: React.FC<SideBarProps> = (props) => {
|
||||
const { open, items } = props;
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const { revokeAuth } = useAuth();
|
||||
const dialog = useDialog();
|
||||
const onRevokeAuth = () => {
|
||||
dialog.confirm({
|
||||
title: '退出登录',
|
||||
content: '确定要退出登录吗?',
|
||||
onConfirm: revokeAuth,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'overflow-hidden fixed top-0 left-0 h-full z-50 bg-background md:bg-transparent md:static shadow-md md:shadow-none rounded-r-md md:rounded-none'
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: open ? '16rem' : 0 }}
|
||||
transition={{
|
||||
type: open ? 'spring' : 'tween',
|
||||
stiffness: 150,
|
||||
damping: open ? 15 : 10,
|
||||
}}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<motion.div className='w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right'>
|
||||
<div className='flex justify-center items-center my-2 gap-2'>
|
||||
<Image radius='none' height={40} src={logo} className='mb-2' />
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center font-bold',
|
||||
'!text-2xl shiny-text'
|
||||
)}
|
||||
>
|
||||
NapCat
|
||||
</div>
|
||||
</div>
|
||||
<div className='overflow-y-auto flex flex-col flex-1 px-4'>
|
||||
<Menus items={items} />
|
||||
<div className='mt-auto mb-10 md:mb-0'>
|
||||
<Button
|
||||
className='w-full'
|
||||
color='primary'
|
||||
radius='full'
|
||||
variant='light'
|
||||
onPress={toggleTheme}
|
||||
startContent={
|
||||
!isDark ? <MdLightMode size={16} /> : <MdDarkMode size={16} />
|
||||
}
|
||||
>
|
||||
切换主题
|
||||
</Button>
|
||||
<Button
|
||||
className='w-full mb-2'
|
||||
color='primary'
|
||||
radius='full'
|
||||
variant='light'
|
||||
onPress={onRevokeAuth}
|
||||
startContent={<IoMdLogOut size={16} />}
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBar;
|
||||
162
packages/napcat-webui-frontend/src/components/sidebar/menus.tsx
Normal file
162
packages/napcat-webui-frontend/src/components/sidebar/menus.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Image } from '@heroui/image';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import key from '@/const/key';
|
||||
|
||||
import type { MenuItem } from '@/config/site';
|
||||
|
||||
const renderItems = (items: MenuItem[], children = false) => {
|
||||
return items?.map((item) => {
|
||||
const navigate = useNavigate();
|
||||
const locate = useLocation();
|
||||
const [open, setOpen] = React.useState(!!item.autoOpen);
|
||||
const canOpen = React.useMemo(
|
||||
() => item.items && item.items.length > 0,
|
||||
[item.items]
|
||||
);
|
||||
const [b64img] = useLocalStorage(key.backgroundImage, '');
|
||||
const [customIcons] = useLocalStorage<Record<string, string>>(
|
||||
key.customIcons,
|
||||
{}
|
||||
);
|
||||
const isActive = React.useMemo(() => {
|
||||
if (item.href) {
|
||||
return !!matchPath(item.href, locate.pathname);
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [item.href, locate.pathname]);
|
||||
|
||||
const goTo = (href: string) => {
|
||||
navigate(href);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (item.items) {
|
||||
const shouldOpen = item.items.some(
|
||||
(item) => item?.href && !!matchPath(item.href, locate.pathname)
|
||||
);
|
||||
|
||||
if (shouldOpen) setOpen(true);
|
||||
}
|
||||
}, [item.items, locate.pathname]);
|
||||
const panelRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div key={item.href + item.label}>
|
||||
<Button
|
||||
className={clsx(
|
||||
'flex items-center w-full text-left justify-start dark:text-white',
|
||||
// children && 'rounded-l-lg',
|
||||
isActive && 'bg-opacity-60',
|
||||
b64img && 'backdrop-blur-md text-white'
|
||||
)}
|
||||
color='primary'
|
||||
endContent={
|
||||
canOpen
|
||||
? (
|
||||
<div
|
||||
className={clsx(
|
||||
'ml-auto relative w-3 h-3 transition-transform',
|
||||
open && 'transform rotate-180',
|
||||
isActive
|
||||
? 'text-primary-500'
|
||||
: 'text-primary-200 dark:text-white',
|
||||
'before:rounded-full',
|
||||
'before:content-[""]',
|
||||
'before:block',
|
||||
'before:absolute',
|
||||
'before:w-3',
|
||||
'before:h-[4.5px]',
|
||||
'before:bg-current',
|
||||
'before:top-1/2',
|
||||
'before:-left-[3px]',
|
||||
'before:transform',
|
||||
'before:-translate-y-1/2',
|
||||
'before:rotate-45',
|
||||
'after:rounded-full',
|
||||
'after:content-[""]',
|
||||
'after:block',
|
||||
'after:absolute',
|
||||
'after:w-3',
|
||||
'after:h-[4.5px]',
|
||||
'after:bg-current',
|
||||
'after:top-1/2',
|
||||
'after:left-[3px]',
|
||||
'after:transform',
|
||||
'after:-translate-y-1/2',
|
||||
'after:-rotate-45'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-3 h-1.5 rounded-full ml-auto shadow-lg',
|
||||
isActive
|
||||
? 'bg-primary-500 animate-spinner-ease-spin'
|
||||
: 'bg-primary-200 dark:bg-white'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
radius='full'
|
||||
startContent={
|
||||
customIcons[item.label]
|
||||
? (
|
||||
<Image
|
||||
radius='none'
|
||||
src={customIcons[item.label]}
|
||||
alt={item.label}
|
||||
className='w-5 h-5'
|
||||
/>
|
||||
)
|
||||
: (
|
||||
item.icon
|
||||
)
|
||||
}
|
||||
variant={isActive ? (children ? 'solid' : 'shadow') : 'light'}
|
||||
onPress={() => {
|
||||
if (item.href) {
|
||||
if (!isActive) {
|
||||
goTo(item.href);
|
||||
}
|
||||
} else if (canOpen) {
|
||||
setOpen(!open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className='ml-4 overflow-hidden transition-all duration-300'
|
||||
style={{
|
||||
height: open ? panelRef.current?.scrollHeight : 0,
|
||||
}}
|
||||
>
|
||||
{item.items && renderItems(item.items, true)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
interface MenusProps {
|
||||
items: MenuItem[]
|
||||
}
|
||||
const Menus: React.FC<MenusProps> = (props) => {
|
||||
const { items } = props;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col justify-content-center flex-1 gap-2'>
|
||||
{renderItems(items)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menus;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Switch } from '@heroui/switch';
|
||||
import clsx from 'clsx';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
export interface SwitchCardProps {
|
||||
label?: string
|
||||
description?: string
|
||||
value?: boolean
|
||||
onValueChange?: (value: boolean) => void
|
||||
name?: string
|
||||
onBlur?: React.FocusEventHandler
|
||||
disabled?: boolean
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>
|
||||
}
|
||||
|
||||
const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
|
||||
(props, ref) => {
|
||||
const { label, description, value, onValueChange, disabled } = props;
|
||||
const selectString = value ? 'true' : 'false';
|
||||
|
||||
return (
|
||||
<Switch
|
||||
classNames={{
|
||||
base: clsx(
|
||||
'inline-flex flex-row-reverse w-full max-w-md bg-content1 hover:bg-content2 items-center',
|
||||
'justify-between cursor-pointer rounded-lg gap-2 p-3 border-2 border-transparent',
|
||||
'data-[selected=true]:border-primary bg-opacity-50 backdrop-blur-sm'
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
ref={ref}
|
||||
isDisabled={disabled}
|
||||
isSelected={value}
|
||||
value={selectString}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<p className='text-medium'>{label}</p>
|
||||
<p className='text-tiny text-default-400'>{description}</p>
|
||||
</div>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SwitchCard.displayName = 'SwitchCard';
|
||||
|
||||
export default SwitchCard;
|
||||
282
packages/napcat-webui-frontend/src/components/system_info.tsx
Normal file
282
packages/napcat-webui-frontend/src/components/system_info.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useEffect } from 'react';
|
||||
import { BsStars } from 'react-icons/bs';
|
||||
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6';
|
||||
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
|
||||
import { RiMacFill } from 'react-icons/ri';
|
||||
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
import { compareVersion } from '@/utils/version';
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import { GithubRelease } from '@/types/github';
|
||||
|
||||
import TailwindMarkdown from './tailwind_markdown';
|
||||
|
||||
export interface SystemInfoItemProps {
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
value?: React.ReactNode
|
||||
endContent?: React.ReactNode
|
||||
}
|
||||
|
||||
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
title,
|
||||
value = '--',
|
||||
icon,
|
||||
endContent,
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex text-sm gap-1 p-2 items-center shadow-sm shadow-primary-100 dark:shadow-primary-100 rounded text-primary-400'>
|
||||
{icon}
|
||||
<div className='w-24'>{title}</div>
|
||||
<div className='text-primary-200'>{value}</div>
|
||||
<div className='ml-auto'>{endContent}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface NewVersionTipProps {
|
||||
currentVersion?: string
|
||||
}
|
||||
|
||||
const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
const { currentVersion } = props;
|
||||
const dialog = useDialog();
|
||||
const { data: releaseData, error } = useRequest(() =>
|
||||
request.get<GithubRelease[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
|
||||
)
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Tooltip content='检查新版本失败'>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
color='primary'
|
||||
variant='shadow'
|
||||
className='!w-5 !h-5 !min-w-0 text-small shadow-md'
|
||||
onPress={() => {
|
||||
dialog.alert({
|
||||
title: '检查新版本失败',
|
||||
content: error.message,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaInfo />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const latestVersion = releaseData?.data?.[0]?.tag_name;
|
||||
|
||||
if (!latestVersion || !currentVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (compareVersion(latestVersion, currentVersion) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const middleVersions: GithubRelease[] = [];
|
||||
|
||||
for (let i = 0; i < releaseData.data.length; i++) {
|
||||
const versionInfo = releaseData.data[i];
|
||||
if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
|
||||
middleVersions.push(versionInfo);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const AISummaryComponent = () => {
|
||||
const {
|
||||
data: aiSummaryData,
|
||||
loading: aiSummaryLoading,
|
||||
error: aiSummaryError,
|
||||
run: runAiSummary,
|
||||
} = useRequest(
|
||||
(version) =>
|
||||
request.get<ServerResponse<string | null>>(
|
||||
`https://release.nc.152710.xyz/?version=${version}`,
|
||||
{
|
||||
timeout: 30000,
|
||||
}
|
||||
),
|
||||
{
|
||||
manual: true,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
runAiSummary(currentVersion);
|
||||
}, [currentVersion, runAiSummary]);
|
||||
|
||||
if (aiSummaryLoading) {
|
||||
return (
|
||||
<div className='flex justify-center py-1'>
|
||||
<Spinner size='sm' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (aiSummaryError) {
|
||||
return <div className='text-center text-primary-500'>AI 摘要获取失败</div>;
|
||||
}
|
||||
return <span className='text-default-700'>{aiSummaryData?.data.data}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content='有新版本可用'>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
color='primary'
|
||||
variant='shadow'
|
||||
className='!w-5 !h-5 !min-w-0 text-small shadow-md'
|
||||
onPress={() => {
|
||||
dialog.confirm({
|
||||
title: '有新版本可用',
|
||||
content: (
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm space-x-2'>
|
||||
<span>当前版本</span>
|
||||
<Chip color='primary' variant='flat'>
|
||||
v{currentVersion}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className='text-sm space-x-2'>
|
||||
<span>最新版本</span>
|
||||
<Chip color='primary'>{latestVersion}</Chip>
|
||||
</div>
|
||||
<div className='p-2 rounded-md bg-content2 text-sm'>
|
||||
<div className='text-primary-400 font-bold flex items-center gap-1 mb-1'>
|
||||
<BsStars />
|
||||
<span>AI总结</span>
|
||||
</div>
|
||||
<AISummaryComponent />
|
||||
</div>
|
||||
<div className='text-sm space-y-2 !mt-4'>
|
||||
{middleVersions.map((versionInfo) => (
|
||||
<div
|
||||
key={versionInfo.tag_name}
|
||||
className='p-4 bg-content1 rounded-md shadow-small'
|
||||
>
|
||||
<TailwindMarkdown content={versionInfo.body} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
scrollBehavior: 'inside',
|
||||
size: '3xl',
|
||||
confirmText: '前往下载',
|
||||
onConfirm () {
|
||||
window.open(
|
||||
'https://github.com/NapNeko/NapCatQQ/releases',
|
||||
'_blank',
|
||||
'noopener'
|
||||
);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaInfo />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const NapCatVersion = () => {
|
||||
const {
|
||||
data: packageData,
|
||||
loading: packageLoading,
|
||||
error: packageError,
|
||||
} = useRequest(WebUIManager.getPackageInfo);
|
||||
|
||||
const currentVersion = packageData?.version;
|
||||
|
||||
return (
|
||||
<SystemInfoItem
|
||||
title='NapCat 版本'
|
||||
icon={<IoLogoOctocat className='text-xl' />}
|
||||
value={
|
||||
packageError
|
||||
? (
|
||||
`错误:${packageError.message}`
|
||||
)
|
||||
: packageLoading
|
||||
? (
|
||||
<Spinner size='sm' />
|
||||
)
|
||||
: (
|
||||
currentVersion
|
||||
)
|
||||
}
|
||||
endContent={<NewVersionTip currentVersion={currentVersion} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export interface SystemInfoProps {
|
||||
archInfo?: string
|
||||
}
|
||||
const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||
const { archInfo } = props;
|
||||
const {
|
||||
data: qqVersionData,
|
||||
loading: qqVersionLoading,
|
||||
error: qqVersionError,
|
||||
} = useRequest(WebUIManager.getQQVersion);
|
||||
return (
|
||||
<Card className='bg-opacity-60 shadow-sm shadow-primary-100 dark:shadow-primary-100 overflow-visible flex-1'>
|
||||
<CardHeader className='pb-0 items-center gap-1 text-primary-500 font-extrabold'>
|
||||
<FaCircleInfo className='text-lg' />
|
||||
<span>系统信息</span>
|
||||
</CardHeader>
|
||||
<CardBody className='flex-1'>
|
||||
<div className='flex flex-col justify-between h-full'>
|
||||
<NapCatVersion />
|
||||
<SystemInfoItem
|
||||
title='QQ 版本'
|
||||
icon={<FaQq className='text-lg' />}
|
||||
value={
|
||||
qqVersionError
|
||||
? (
|
||||
`错误:${qqVersionError.message}`
|
||||
)
|
||||
: qqVersionLoading
|
||||
? (
|
||||
<Spinner size='sm' />
|
||||
)
|
||||
: (
|
||||
qqVersionData
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SystemInfoItem
|
||||
title='WebUI 版本'
|
||||
icon={<IoLogoChrome className='text-xl' />}
|
||||
value='Next'
|
||||
/>
|
||||
<SystemInfoItem
|
||||
title='系统版本'
|
||||
icon={<RiMacFill className='text-xl' />}
|
||||
value={archInfo}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemInfo;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Image } from '@heroui/image';
|
||||
import clsx from 'clsx';
|
||||
import { BiSolidMemoryCard } from 'react-icons/bi';
|
||||
import { GiCpu } from 'react-icons/gi';
|
||||
|
||||
import bkg from '@/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png';
|
||||
|
||||
import UsagePie from './usage_pie';
|
||||
|
||||
export interface SystemStatusItemProps {
|
||||
title: string
|
||||
value?: string | number
|
||||
size?: 'md' | 'lg'
|
||||
unit?: string
|
||||
}
|
||||
|
||||
const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
|
||||
title,
|
||||
value = '-',
|
||||
size = 'md',
|
||||
unit,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'shadow-sm shadow-primary-100 p-2 rounded-md text-sm bg-content1 bg-opacity-30',
|
||||
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between'
|
||||
)}
|
||||
>
|
||||
<div className='w-24'>{title}</div>
|
||||
<div className='text-default-400'>
|
||||
{value}
|
||||
{unit}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface SystemStatusDisplayProps {
|
||||
data?: SystemStatus
|
||||
}
|
||||
|
||||
const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
|
||||
const memoryUsage = {
|
||||
system: 0,
|
||||
qq: 0,
|
||||
};
|
||||
if (data) {
|
||||
const system = Number(data.memory.total) || 1;
|
||||
const systemUsage = Number(data.memory.usage.system);
|
||||
const qqUsage = Number(data.memory.usage.qq);
|
||||
memoryUsage.system = (systemUsage / system) * 100;
|
||||
memoryUsage.qq = (qqUsage / system) * 100;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='bg-opacity-60 shadow-sm shadow-primary-100 col-span-1 lg:col-span-2 relative overflow-hidden'>
|
||||
<div className='absolute h-full right-0 top-0'>
|
||||
<Image
|
||||
src={bkg}
|
||||
alt='background'
|
||||
className='select-none pointer-events-none !opacity-30 w-full h-full'
|
||||
classNames={{
|
||||
wrapper: 'w-full h-full',
|
||||
img: 'object-contain w-full h-full',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CardBody className='overflow-visible md:flex-row gap-4 items-center justify-stretch z-10'>
|
||||
<div className='flex-1 w-full md:max-w-96'>
|
||||
<h2 className='text-lg font-semibold flex items-center gap-1 text-primary-400'>
|
||||
<GiCpu className='text-xl' />
|
||||
<span>CPU</span>
|
||||
</h2>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<SystemStatusItem title='型号' value={data?.cpu.model} size='lg' />
|
||||
<SystemStatusItem title='内核数' value={data?.cpu.core} />
|
||||
<SystemStatusItem title='主频' value={data?.cpu.speed} unit='GHz' />
|
||||
<SystemStatusItem
|
||||
title='使用率'
|
||||
value={data?.cpu.usage.system}
|
||||
unit='%'
|
||||
/>
|
||||
<SystemStatusItem
|
||||
title='QQ主线程'
|
||||
value={data?.cpu.usage.qq}
|
||||
unit='%'
|
||||
/>
|
||||
</div>
|
||||
<h2 className='text-lg font-semibold flex items-center gap-1 text-primary-400 mt-2'>
|
||||
<BiSolidMemoryCard className='text-xl' />
|
||||
<span>内存</span>
|
||||
</h2>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<SystemStatusItem
|
||||
title='总量'
|
||||
value={data?.memory.total}
|
||||
size='lg'
|
||||
unit='MB'
|
||||
/>
|
||||
<SystemStatusItem
|
||||
title='使用量'
|
||||
value={data?.memory.usage.system}
|
||||
unit='MB'
|
||||
/>
|
||||
<SystemStatusItem
|
||||
title='QQ主线程'
|
||||
value={data?.memory.usage.qq}
|
||||
unit='MB'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-row md:flex-col gap-2 flex-shrink-0 w-full justify-center md:w-40 min-h-40 mt-4 md:mt-0 md:mx-auto'>
|
||||
<UsagePie
|
||||
systemUsage={Number(data?.cpu.usage.system) || 0}
|
||||
processUsage={Number(data?.cpu.usage.qq) || 0}
|
||||
title='CPU占用'
|
||||
/>
|
||||
<UsagePie
|
||||
systemUsage={memoryUsage.system}
|
||||
processUsage={memoryUsage.qq}
|
||||
title='内存占用'
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export default SystemStatusDisplay;
|
||||
89
packages/napcat-webui-frontend/src/components/tabs/index.tsx
Normal file
89
packages/napcat-webui-frontend/src/components/tabs/index.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import clsx from 'clsx';
|
||||
import { type ReactNode, createContext, forwardRef, useContext } from 'react';
|
||||
|
||||
export interface TabsContextValue {
|
||||
activeKey: string
|
||||
onChange: (key: string) => void
|
||||
}
|
||||
|
||||
const TabsContext = createContext<TabsContextValue>({
|
||||
activeKey: '',
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
export interface TabsProps {
|
||||
activeKey: string
|
||||
onChange: (key: string) => void
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Tabs ({ activeKey, onChange, children, className }: TabsProps) {
|
||||
return (
|
||||
<TabsContext.Provider value={{ activeKey, onChange }}>
|
||||
<div className={clsx('flex flex-col gap-2', className)}>{children}</div>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabListProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TabList ({ children, className }: TabListProps) {
|
||||
return (
|
||||
<div className={clsx('flex items-center gap-1', className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabProps extends React.ButtonHTMLAttributes<HTMLDivElement> {
|
||||
value: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
export const Tab = forwardRef<HTMLDivElement, TabProps>(
|
||||
({ className, isSelected, value, ...props }, ref) => {
|
||||
const { onChange } = useContext(TabsContext);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
onChange(value);
|
||||
props.onClick?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role='tab'
|
||||
aria-selected={isSelected}
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
'px-2 py-1 flex items-center gap-1 text-sm font-medium border-b-2 transition-colors',
|
||||
isSelected
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent hover:border-default',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Tab.displayName = 'Tab';
|
||||
|
||||
export interface TabPanelProps {
|
||||
value: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TabPanel ({ value, children, className }: TabPanelProps) {
|
||||
const { activeKey } = useContext(TabsContext);
|
||||
|
||||
if (value !== activeKey) return null;
|
||||
|
||||
return <div className={clsx('flex-1', className)}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { Tab } from '@/components/tabs';
|
||||
import type { TabProps } from '@/components/tabs';
|
||||
|
||||
interface SortableTabProps extends TabProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
export function SortableTab ({ id, ...props }: SortableTabProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
position: 'relative' as const,
|
||||
touchAction: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<Tab
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
||||
return (
|
||||
<Markdown
|
||||
className='prose prose-sm sm:prose lg:prose-lg xl:prose-xl'
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ node: _node, ...props }) => (
|
||||
<h1 className='text-2xl font-bold' {...props} />
|
||||
),
|
||||
h2: ({ node: _node, ...props }) => (
|
||||
<h2 className='text-xl font-bold' {...props} />
|
||||
),
|
||||
h3: ({ node: _node, ...props }) => (
|
||||
<h3 className='text-lg font-bold' {...props} />
|
||||
),
|
||||
p: ({ node: _node, ...props }) => <p className='m-0' {...props} />,
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a
|
||||
className='text-primary-500 inline-block hover:underline'
|
||||
target='_blank'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ node: _node, ...props }) => (
|
||||
<ul className='list-disc list-inside' {...props} />
|
||||
),
|
||||
ol: ({ node: _node, ...props }) => (
|
||||
<ol className='list-decimal list-inside' {...props} />
|
||||
),
|
||||
blockquote: ({ node: _node, ...props }) => (
|
||||
<blockquote
|
||||
className='border-l-4 border-default-300 pl-4 italic'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({ node: _node, ...props }) => (
|
||||
<code className='bg-default-100 p-1 rounded text-xs' {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default TailwindMarkdown;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import TerminalManager from '@/controllers/terminal_manager';
|
||||
|
||||
import XTerm, { XTermRef } from '../xterm';
|
||||
|
||||
interface TerminalInstanceProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
export function TerminalInstance ({ id }: TerminalInstanceProps) {
|
||||
const termRef = useRef<XTermRef>(null);
|
||||
const connected = useRef(false);
|
||||
|
||||
const handleData = (data: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.data) {
|
||||
termRef.current?.write(parsed.data);
|
||||
}
|
||||
} catch (_e) {
|
||||
termRef.current?.write(data);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (connected.current) {
|
||||
TerminalManager.disconnectTerminal(id, handleData);
|
||||
}
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const handleInput = (data: string) => {
|
||||
TerminalManager.sendInput(id, data);
|
||||
};
|
||||
|
||||
const handleResize = (cols: number, rows: number) => {
|
||||
if (!connected.current) {
|
||||
connected.current = true;
|
||||
console.log('instance', rows, cols);
|
||||
TerminalManager.connectTerminal(id, handleData, { rows, cols });
|
||||
} else {
|
||||
TerminalManager.sendResize(id, cols, rows);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<XTerm
|
||||
ref={termRef}
|
||||
onInput={handleInput}
|
||||
onResize={handleResize} // 使用 fitAddon 改变后触发的 resize 回调
|
||||
className='w-full h-full'
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { SwitchProps, useSwitch } from '@heroui/switch';
|
||||
import { VisuallyHidden } from '@react-aria/visually-hidden';
|
||||
import clsx from 'clsx';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
|
||||
import { MoonFilledIcon, SunFilledIcon } from '@/components/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
export interface ThemeSwitchProps {
|
||||
className?: string
|
||||
classNames?: SwitchProps['classNames']
|
||||
}
|
||||
|
||||
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
|
||||
className,
|
||||
classNames,
|
||||
}) => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const onChange = toggleTheme;
|
||||
|
||||
const {
|
||||
Component,
|
||||
slots,
|
||||
isSelected,
|
||||
getBaseProps,
|
||||
getInputProps,
|
||||
getWrapperProps,
|
||||
} = useSwitch({
|
||||
isSelected: theme === 'light',
|
||||
onChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, [isMounted]);
|
||||
|
||||
// Prevent Hydration Mismatch
|
||||
if (!isMounted) return <div className='w-6 h-6' />;
|
||||
|
||||
return (
|
||||
<Component
|
||||
aria-label={isSelected ? 'Switch to dark mode' : 'Switch to light mode'}
|
||||
{...getBaseProps({
|
||||
className: clsx(
|
||||
'px-px transition-opacity hover:opacity-80 cursor-pointer',
|
||||
className,
|
||||
classNames?.base
|
||||
),
|
||||
})}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<input {...getInputProps()} />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
{...getWrapperProps()}
|
||||
className={slots.wrapper({
|
||||
class: clsx(
|
||||
[
|
||||
'w-auto h-auto',
|
||||
'bg-transparent',
|
||||
'rounded-lg',
|
||||
'flex items-center justify-center',
|
||||
'group-data-[selected=true]:bg-transparent',
|
||||
'!text-default-500',
|
||||
'pt-px',
|
||||
'px-0',
|
||||
'mx-0',
|
||||
],
|
||||
classNames?.wrapper
|
||||
),
|
||||
})}
|
||||
>
|
||||
{isSelected
|
||||
? (
|
||||
<MoonFilledIcon size={22} />
|
||||
)
|
||||
: (
|
||||
<SunFilledIcon size={22} />
|
||||
)}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
21
packages/napcat-webui-frontend/src/components/toaster.tsx
Normal file
21
packages/napcat-webui-frontend/src/components/toaster.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Toaster as HotToaster } from 'react-hot-toast';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
export const Toaster = () => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<HotToaster
|
||||
toastOptions={{
|
||||
style: {
|
||||
borderRadius: '20px',
|
||||
background: isDark ? '#333' : '#fff',
|
||||
color: isDark ? '#fff' : '#333',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toaster;
|
||||
@@ -0,0 +1,12 @@
|
||||
export default function UnderConstruction () {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center h-full pt-4'>
|
||||
<div className='flex flex-col items-center justify-center space-y-4'>
|
||||
<div className='text-6xl font-bold text-gray-500'>🚧</div>
|
||||
<div className='text-2xl font-bold text-gray-500'>
|
||||
Under Construction
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
packages/napcat-webui-frontend/src/components/usage_pie.tsx
Normal file
143
packages/napcat-webui-frontend/src/components/usage_pie.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import * as echarts from 'echarts';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
interface UsagePieProps {
|
||||
systemUsage: number
|
||||
processUsage: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
const defaultOption: echarts.EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '<center>{b}<br/><b>{d}%</b></center>',
|
||||
borderRadius: 10,
|
||||
extraCssText: 'backdrop-filter: blur(10px);',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '系统占用',
|
||||
type: 'pie',
|
||||
radius: ['70%', '90%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'center',
|
||||
formatter: '系统占用',
|
||||
fontSize: 14,
|
||||
},
|
||||
itemStyle: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: 100,
|
||||
name: '系统总量',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const UsagePie: React.FC<UsagePieProps> = ({
|
||||
systemUsage,
|
||||
processUsage,
|
||||
title,
|
||||
}) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (chartRef.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
const option = defaultOption;
|
||||
chartInstance.current.setOption(option);
|
||||
const observer = new ResizeObserver(() => {
|
||||
chartInstance.current?.resize();
|
||||
});
|
||||
observer.observe(chartRef.current);
|
||||
return () => {
|
||||
chartInstance.current?.dispose();
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.setOption({
|
||||
series: [
|
||||
{
|
||||
label: {
|
||||
formatter: title,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.setOption({
|
||||
darkMode: theme === 'dark',
|
||||
tooltip: {
|
||||
backgroundColor:
|
||||
theme === 'dark'
|
||||
? 'rgba(0, 0, 0, 0.8)'
|
||||
: 'rgba(255, 255, 255, 0.8)',
|
||||
textStyle: {
|
||||
color: theme === 'dark' ? '#fff' : '#333',
|
||||
},
|
||||
},
|
||||
color:
|
||||
theme === 'dark'
|
||||
? ['#D33FF0', '#EF8664', '#E25180']
|
||||
: ['#D33FF0', '#EA7D9B', '#FFC107'],
|
||||
series: [
|
||||
{
|
||||
itemStyle: {
|
||||
borderColor: theme === 'dark' ? '#333' : '#F0A9A7',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.setOption({
|
||||
series: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: processUsage,
|
||||
name: 'QQ占用',
|
||||
},
|
||||
{
|
||||
value: systemUsage - processUsage,
|
||||
name: '其他进程占用',
|
||||
},
|
||||
{
|
||||
value: 100 - systemUsage,
|
||||
name: '剩余系统总量',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}, [systemUsage, processUsage]);
|
||||
|
||||
return <div ref={chartRef} className='w-36 h-36 flex-shrink-0' />;
|
||||
};
|
||||
|
||||
export default UsagePie;
|
||||
192
packages/napcat-webui-frontend/src/components/xterm.tsx
Normal file
192
packages/napcat-webui-frontend/src/components/xterm.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { CanvasAddon } from '@xterm/addon-canvas';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
// import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
export type XTermRef = {
|
||||
write: (
|
||||
...args: Parameters<Terminal['write']>
|
||||
) => ReturnType<Terminal['write']>
|
||||
writeAsync: (data: Parameters<Terminal['write']>[0]) => Promise<void>
|
||||
writeln: (
|
||||
...args: Parameters<Terminal['writeln']>
|
||||
) => ReturnType<Terminal['writeln']>
|
||||
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
|
||||
clear: () => void
|
||||
terminalRef: React.RefObject<Terminal | null>
|
||||
};
|
||||
|
||||
export interface XTermProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
|
||||
onInput?: (data: string) => void
|
||||
onKey?: (key: string, event: KeyboardEvent) => void
|
||||
onResize?: (cols: number, rows: number) => void // 新增属性
|
||||
}
|
||||
|
||||
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
const domRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const { className, onInput, onKey, onResize, ...rest } = props;
|
||||
const { theme } = useTheme();
|
||||
useEffect(() => {
|
||||
const terminal = new Terminal({
|
||||
allowTransparency: true,
|
||||
fontFamily:
|
||||
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
|
||||
cursorInactiveStyle: 'outline',
|
||||
drawBoldTextInBrightColors: false,
|
||||
fontSize: 14,
|
||||
lineHeight: 1.2,
|
||||
});
|
||||
terminalRef.current = terminal;
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(
|
||||
new WebLinksAddon((event, uri) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
window.open(uri, '_blank');
|
||||
}
|
||||
})
|
||||
);
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(domRef.current!);
|
||||
|
||||
terminal.loadAddon(new CanvasAddon());
|
||||
terminal.onData((data) => {
|
||||
if (onInput) {
|
||||
onInput(data);
|
||||
}
|
||||
});
|
||||
|
||||
terminal.onKey((event) => {
|
||||
if (onKey) {
|
||||
onKey(event.key, event.domEvent);
|
||||
}
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
// 获取当前终端尺寸
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
if (onResize) {
|
||||
onResize(cols, rows);
|
||||
}
|
||||
});
|
||||
|
||||
// 字体加载完成后重新调整终端大小
|
||||
document.fonts.ready.then(() => {
|
||||
fitAddon.fit();
|
||||
|
||||
resizeObserver.observe(domRef.current!);
|
||||
});
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
setTimeout(() => {
|
||||
terminal.dispose();
|
||||
}, 0);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
if (theme === 'dark') {
|
||||
terminalRef.current.options.theme = {
|
||||
background: '#00000000',
|
||||
black: '#ffffff',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#e5e5e5',
|
||||
foreground: '#cccccc',
|
||||
selectionBackground: '#3a3d41',
|
||||
cursor: '#ffffff',
|
||||
};
|
||||
} else {
|
||||
terminalRef.current.options.theme = {
|
||||
background: '#ffffff00',
|
||||
black: '#000000',
|
||||
red: '#aa3731',
|
||||
green: '#448c27',
|
||||
yellow: '#cb9000',
|
||||
blue: '#325cc0',
|
||||
cyan: '#0083b2',
|
||||
white: '#7f7f7f',
|
||||
brightBlack: '#777777',
|
||||
brightRed: '#f05050',
|
||||
brightGreen: '#60cb00',
|
||||
brightYellow: '#ffbc5d',
|
||||
brightBlue: '#007acc',
|
||||
brightCyan: '#00aacb',
|
||||
brightWhite: '#b0b0b0',
|
||||
foreground: '#000000',
|
||||
selectionBackground: '#bfdbfe',
|
||||
cursor: '#007acc',
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
write: (...args) => {
|
||||
return terminalRef.current?.write(...args);
|
||||
},
|
||||
writeAsync: async (data) => {
|
||||
return new Promise((resolve) => {
|
||||
terminalRef.current?.write(data, resolve);
|
||||
});
|
||||
},
|
||||
writeln: (...args) => {
|
||||
return terminalRef.current?.writeln(...args);
|
||||
},
|
||||
writelnAsync: async (data) => {
|
||||
return new Promise((resolve) => {
|
||||
terminalRef.current?.writeln(data, resolve);
|
||||
});
|
||||
},
|
||||
clear: () => {
|
||||
terminalRef.current?.clear();
|
||||
},
|
||||
terminalRef,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'p-2 rounded-md shadow-sm border border-default-200 w-full h-full overflow-hidden bg-opacity-50 backdrop-blur-sm',
|
||||
theme === 'dark' ? 'bg-black' : 'bg-white',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
ref={domRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default XTerm;
|
||||
112
packages/napcat-webui-frontend/src/config/site.tsx
Normal file
112
packages/napcat-webui-frontend/src/config/site.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
BugIcon2,
|
||||
FileIcon,
|
||||
InfoIcon,
|
||||
LogIcon,
|
||||
RouteIcon,
|
||||
SettingsIcon,
|
||||
SignalTowerIcon,
|
||||
TerminalIcon,
|
||||
} from '@/components/icons';
|
||||
|
||||
export type SiteConfig = typeof siteConfig;
|
||||
export interface MenuItem {
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
autoOpen?: boolean
|
||||
href?: string
|
||||
items?: MenuItem[]
|
||||
customIcon?: string
|
||||
}
|
||||
|
||||
export const siteConfig = {
|
||||
name: 'NapCat WebUI',
|
||||
description: 'NapCat WebUI.',
|
||||
navItems: [
|
||||
{
|
||||
label: '基础信息',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<RouteIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/',
|
||||
},
|
||||
{
|
||||
label: '网络配置',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<SignalTowerIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/network',
|
||||
},
|
||||
{
|
||||
label: '其他配置',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<SettingsIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/config',
|
||||
},
|
||||
{
|
||||
label: '猫猫日志',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<LogIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/logs',
|
||||
},
|
||||
{
|
||||
label: '接口调试',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<BugIcon2 />
|
||||
</div>
|
||||
),
|
||||
items: [
|
||||
{
|
||||
label: 'HTTP',
|
||||
href: '/debug/http',
|
||||
},
|
||||
{
|
||||
label: 'Websocket',
|
||||
href: '/debug/ws',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '文件管理',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<FileIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/file_manager',
|
||||
},
|
||||
{
|
||||
label: '系统终端',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<TerminalIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/terminal',
|
||||
},
|
||||
{
|
||||
label: '关于我们',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<InfoIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/about',
|
||||
},
|
||||
] as MenuItem[],
|
||||
links: {
|
||||
github: 'https://github.com/NapNeko/NapCatQQ',
|
||||
docs: 'https://napcat.napneko.icu/',
|
||||
},
|
||||
};
|
||||
13
packages/napcat-webui-frontend/src/const/enum.ts
Normal file
13
packages/napcat-webui-frontend/src/const/enum.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
FATAL = 'fatal',
|
||||
}
|
||||
|
||||
export enum PlayMode {
|
||||
Loop = 'loop',
|
||||
Random = 'random',
|
||||
Single = 'single',
|
||||
}
|
||||
15
packages/napcat-webui-frontend/src/const/key.ts
Normal file
15
packages/napcat-webui-frontend/src/const/key.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
enum key {
|
||||
token = 'token',
|
||||
theme = 'theme',
|
||||
backgroundImage = 'background-image',
|
||||
musicID = 'music-id',
|
||||
showMusicTip = 'show-music-tip',
|
||||
autoPlay = 'auto-play',
|
||||
customIcons = 'custom-icons',
|
||||
isCollapsedMusicPlayer = 'is-collapsed-music-player',
|
||||
sideBarOpen = 'side-bar-open',
|
||||
httpDebugConfig = 'http-debug-config',
|
||||
wsDebugConfig = 'ws-debug-config',
|
||||
}
|
||||
|
||||
export default key;
|
||||
744
packages/napcat-webui-frontend/src/const/ob_api/group.ts
Normal file
744
packages/napcat-webui-frontend/src/const/ob_api/group.ts
Normal file
@@ -0,0 +1,744 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import messageNodeSchema from './message/node';
|
||||
import { baseResponseSchema, commonResponseDataSchema } from './response';
|
||||
|
||||
const oneBotHttpApiGroup = {
|
||||
'/set_group_kick': {
|
||||
description: '群踢人',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
user_id: z.union([z.string(), z.number()]).describe('QQ 号'),
|
||||
reject_add_request: z.boolean().describe('拒绝此人的加群请求'),
|
||||
}),
|
||||
response: baseResponseSchema,
|
||||
},
|
||||
'/set_group_ban': {
|
||||
description: '群禁言',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
user_id: z.union([z.string(), z.number()]).describe('QQ 号'),
|
||||
duration: z.number(),
|
||||
}),
|
||||
response: baseResponseSchema,
|
||||
},
|
||||
'/get_group_system_msg': {
|
||||
description: '获取群系统消息',
|
||||
request: z.object({}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({
|
||||
InvitedRequest: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
request_id: z.string().describe('请求 ID'),
|
||||
invitor_uin: z.string().describe('邀请人 QQ 号'),
|
||||
invitor_nick: z.string().describe('邀请人昵称'),
|
||||
group_id: z.string().describe('群号'),
|
||||
message: z.string().describe('入群回答'),
|
||||
group_name: z.string().describe('群名称'),
|
||||
checked: z.boolean().describe('是否已处理'),
|
||||
actor: z.string().describe('处理人 QQ 号'),
|
||||
})
|
||||
.describe('邀请入群请求')
|
||||
)
|
||||
.describe('邀请入群请求列表'),
|
||||
join_requests: z.array(
|
||||
z.object({
|
||||
request_id: z.string().describe('请求 ID'),
|
||||
requester_uin: z.string().describe('请求人 QQ 号'),
|
||||
requester_nick: z.string().describe('请求人昵称'),
|
||||
group_id: z.string().describe('群号'),
|
||||
message: z.string().describe('入群回答'),
|
||||
group_name: z.string().describe('群名称'),
|
||||
checked: z.boolean().describe('是否已处理'),
|
||||
actor: z.string().describe('处理人 QQ 号'),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/get_essence_msg_list': {
|
||||
description: '获取精华消息',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
msg_seq: z.number().describe('消息序号'),
|
||||
msg_random: z.number().describe('消息随机数'),
|
||||
sender_id: z.number().describe('发送人 QQ 号'),
|
||||
sender_nick: z.string().describe('发送人昵称'),
|
||||
operator_id: z.number().describe('操作人 QQ 号'),
|
||||
operator_nick: z.string().describe('操作人昵称'),
|
||||
message_id: z.string().describe('消息 ID'),
|
||||
operator_time: z.string().describe('操作时间'),
|
||||
content: z.array(messageNodeSchema),
|
||||
})
|
||||
.describe('精华消息')
|
||||
)
|
||||
.describe('精华消息列表'),
|
||||
}),
|
||||
},
|
||||
'/set_group_whole_ban': {
|
||||
description: '全员禁言',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
enable: z.boolean().describe('是否开启'),
|
||||
}),
|
||||
response: baseResponseSchema,
|
||||
},
|
||||
'/set_group_portrait': {
|
||||
description: '设置群头像',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
file: z.string().describe('图片文件路径,服务器本地路径或远程 URL'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: commonResponseDataSchema,
|
||||
}),
|
||||
},
|
||||
'/set_group_admin': {
|
||||
description: '设置群管理',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
user_id: z.union([z.string(), z.number()]).describe('QQ 号'),
|
||||
enable: z.boolean().describe('是否设置为管理员'),
|
||||
}),
|
||||
response: baseResponseSchema,
|
||||
},
|
||||
'/set_essence_msg': {
|
||||
description: '设置群精华消息',
|
||||
request: z.object({
|
||||
message_id: z.union([z.string(), z.number()]).describe('消息 ID'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({
|
||||
errCode: z.number().describe('错误码'),
|
||||
errMsg: z.string().describe('错误信息'),
|
||||
result: z
|
||||
.object({
|
||||
wording: z.string().describe('?'),
|
||||
digestUin: z.string().describe('?QQ号'),
|
||||
digestTime: z.number().describe('设置时间?'),
|
||||
msg: z
|
||||
.object({
|
||||
groupCode: z.string().describe('群号'),
|
||||
msgSeq: z.number().describe('消息序号'),
|
||||
msgRandom: z.number().describe('消息随机数'),
|
||||
msgContent: z.array(messageNodeSchema).describe('消息内容'),
|
||||
textSize: z.string().describe('文本大小'),
|
||||
picSize: z.string().describe('图片大小'),
|
||||
videoSize: z.string().describe('视频大小'),
|
||||
senderUin: z.string().describe('发送人 QQ 号'),
|
||||
senderTime: z.number().describe('发送时间'),
|
||||
addDigestUin: z.string().describe('添加精华消息人 QQ 号'),
|
||||
addDigestTime: z.number().describe('添加精华消息时间'),
|
||||
startTime: z.number().describe('开始时间'),
|
||||
latestMsgSeq: z.number().describe('最新消息序号'),
|
||||
opType: z.number().describe('操作类型'),
|
||||
})
|
||||
.describe('消息内容'),
|
||||
errorCode: z.number().describe('错误码'),
|
||||
})
|
||||
.describe('结果'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/set_group_card': {
|
||||
description: '设置群成员名片',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
user_id: z.union([z.string(), z.number()]).describe('QQ 号'),
|
||||
card: z.string().describe('名片'),
|
||||
}),
|
||||
response: baseResponseSchema,
|
||||
},
|
||||
'/delete_essence_msg': {
|
||||
description: '删除群精华消息',
|
||||
request: z.object({
|
||||
message_id: z.union([z.string(), z.number()]).describe('消息 ID'),
|
||||
}),
|
||||
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({
|
||||
errCode: z.number().describe('错误码'),
|
||||
errMsg: z.string().describe('错误信息'),
|
||||
result: z
|
||||
.object({
|
||||
wording: z.string().describe('?'),
|
||||
digestUin: z.string().describe('?QQ号'),
|
||||
digestTime: z.number().describe('设置时间?'),
|
||||
msg: z.object({
|
||||
groupCode: z.string().describe('群号'),
|
||||
msgSeq: z.number().describe('消息序号'),
|
||||
msgRandom: z.number().describe('消息随机数'),
|
||||
msgContent: z.array(messageNodeSchema).describe('消息内容'),
|
||||
textSize: z.string().describe('文本大小'),
|
||||
picSize: z.string().describe('图片大小'),
|
||||
videoSize: z.string().describe('视频大小'),
|
||||
senderUin: z.string().describe('发送人 QQ 号'),
|
||||
senderTime: z.number().describe('发送时间'),
|
||||
addDigestUin: z.string().describe('添加精华消息人 QQ 号'),
|
||||
addDigestTime: z.number().describe('添加精华消息时间'),
|
||||
startTime: z.number().describe('开始时间'),
|
||||
latestMsgSeq: z.number().describe('最新消息序号'),
|
||||
opType: z.number().describe('操作类型'),
|
||||
}),
|
||||
errorCode: z.number().describe('错误码'),
|
||||
})
|
||||
.describe('结果'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/set_group_name': {
|
||||
description: '设置群名称',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
group_name: z.string().describe('群名称'),
|
||||
}),
|
||||
response: baseResponseSchema,
|
||||
},
|
||||
'/set_group_leave': {
|
||||
description: '退出群聊',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
}),
|
||||
response: baseResponseSchema,
|
||||
},
|
||||
'/_send_group_notice': {
|
||||
description: '发送群公告',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
content: z.string().describe('公告内容'),
|
||||
image: z.string().optional().describe('图片地址'),
|
||||
}),
|
||||
response: baseResponseSchema,
|
||||
},
|
||||
'/_get_group_notice': {
|
||||
description: '获取群公告',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.array(
|
||||
z.object({
|
||||
notice_id: z.string().describe('公告 ID'),
|
||||
sender_id: z.number().describe('发送人 QQ 号'),
|
||||
publish_time: z.number().describe('发布时间'),
|
||||
message: z.object({
|
||||
text: z.string().describe('文本内容'),
|
||||
image: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().describe('图片 ID'),
|
||||
height: z.string().describe('高度'),
|
||||
width: z.string().describe('宽度'),
|
||||
})
|
||||
.describe('图片信息')
|
||||
)
|
||||
.describe('图片内容列表'),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
},
|
||||
'/set_group_special_title': {
|
||||
description: '设置群成员专属头衔',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
user_id: z.union([z.string(), z.number()]).describe('QQ 号'),
|
||||
special_title: z.string().describe('专属头衔内容'),
|
||||
}),
|
||||
response: baseResponseSchema,
|
||||
},
|
||||
'/upload_group_file': {
|
||||
description: '上传群文件',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
file: z.string().describe('文件路径'),
|
||||
name: z.string().describe('文件名'),
|
||||
folder_id: z.string().describe('文件夹 ID'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: commonResponseDataSchema,
|
||||
}),
|
||||
},
|
||||
'/set_group_add_request': {
|
||||
description: '处理加群请求',
|
||||
request: z.object({
|
||||
flag: z.string().describe('请求ID'),
|
||||
approve: z.boolean().describe('是否同意'),
|
||||
reason: z.string().optional().describe('拒绝理由'),
|
||||
}),
|
||||
response: baseResponseSchema,
|
||||
},
|
||||
'/get_group_info': {
|
||||
description: '获取群信息',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({}),
|
||||
}),
|
||||
},
|
||||
'/get_group_info_ex': {
|
||||
description: '获取群信息扩展',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z
|
||||
.object({
|
||||
groupCode: z.string().describe('群号'),
|
||||
resultCode: z.number().describe('结果码'),
|
||||
extInfo: z
|
||||
.object({
|
||||
groupInfoExtSeq: z.number().describe('群信息序列号'),
|
||||
reserve: z.number().describe('?'),
|
||||
luckyWordId: z.string().describe('幸运字符ID'),
|
||||
lightCharNum: z.number().describe('?'),
|
||||
luckyWord: z.string().describe('幸运字符'),
|
||||
starId: z.number().describe('?'),
|
||||
essentialMsgSwitch: z.number().describe('精华消息开关'),
|
||||
todoSeq: z.number().describe('?'),
|
||||
blacklistExpireTime: z.number().describe('黑名单过期时间'),
|
||||
isLimitGroupRtc: z.number().describe('是否限制群视频通话'),
|
||||
companyId: z.number().describe('公司ID'),
|
||||
hasGroupCustomPortrait: z.number().describe('是否有群自定义头像'),
|
||||
bindGuildId: z.string().describe('绑定频道ID?'),
|
||||
groupOwnerId: z
|
||||
.object({
|
||||
memberUin: z.string().describe('群主QQ号'),
|
||||
memberUid: z.string().describe('群主ID'),
|
||||
memberQid: z.string().describe('群主QID'),
|
||||
})
|
||||
.describe('群主信息'),
|
||||
essentialMsgPrivilege: z.number().describe('精华消息权限'),
|
||||
msgEventSeq: z.string().describe('消息事件序列号'),
|
||||
inviteRobotSwitch: z.number().describe('邀请机器人开关'),
|
||||
gangUpId: z.string().describe('?'),
|
||||
qqMusicMedalSwitch: z.number().describe('QQ音乐勋章开关'),
|
||||
showPlayTogetherSwitch: z.number().describe('显示一起玩开关'),
|
||||
groupFlagPro1: z.string()?.describe('群标识1'),
|
||||
groupBindGuildIds: z
|
||||
.object({
|
||||
guildIds: z.array(z.string()),
|
||||
})
|
||||
.describe('绑定频道ID列表?'),
|
||||
viewedMsgDisappearTime: z.string().describe('消息消失时间'),
|
||||
groupExtFlameData: z.object({
|
||||
switchState: z.number().describe('开关状态'),
|
||||
state: z.number().describe('状态'),
|
||||
dayNums: z.array(z.number()).describe('天数列表'),
|
||||
version: z.number().describe('版本号'),
|
||||
updateTime: z.string().describe('更新时间'),
|
||||
isDisplayDayNum: z.boolean().describe('是否显示天数'),
|
||||
}),
|
||||
groupBindGuildSwitch: z.number().describe('绑定频道开关'),
|
||||
groupAioBindGuildId: z.string().describe('AIO绑定频道ID'),
|
||||
groupExcludeGuildIds: z
|
||||
.object({
|
||||
guildIds: z.array(z.string()).describe('排除频道ID'),
|
||||
})
|
||||
.describe('排除频道ID列表?'),
|
||||
fullGroupExpansionSwitch: z.number().describe('全员群扩容开关'),
|
||||
fullGroupExpansionSeq: z.string().describe('全员群扩容序列号'),
|
||||
inviteRobotMemberSwitch: z
|
||||
.number()
|
||||
.describe('邀请机器人成员开关'),
|
||||
inviteRobotMemberExamine: z
|
||||
.number()
|
||||
.describe('邀请机器人成员审核'),
|
||||
groupSquareSwitch: z.number().describe('群广场开关'),
|
||||
})
|
||||
.describe('扩展信息'),
|
||||
})
|
||||
.describe('结果'),
|
||||
}),
|
||||
},
|
||||
'/create_group_file_folder': {
|
||||
description: '创建群文件夹',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
folder_name: z.string().describe('文件夹名称'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z
|
||||
.object({
|
||||
result: z
|
||||
.object({
|
||||
retCode: z.number().describe('结果码'),
|
||||
retMsg: z.string().describe('结果信息'),
|
||||
clientWording: z.string().describe('客户端提示'),
|
||||
})
|
||||
.describe('结果'),
|
||||
groupItem: z
|
||||
.object({
|
||||
peerId: z.string().describe('?'),
|
||||
type: z.string().describe('类型'),
|
||||
folderInfo: z
|
||||
.object({
|
||||
folderId: z.string().describe('文件夹 ID'),
|
||||
parentFolderId: z.string().describe('父文件夹 ID'),
|
||||
folderName: z.string().describe('文件夹名称'),
|
||||
createTime: z.number().describe('创建时间'),
|
||||
modifyTime: z.number().describe('修改时间'),
|
||||
createUin: z.string().describe('创建人 QQ 号'),
|
||||
creatorName: z.string().describe('创建人昵称'),
|
||||
totalFileCount: z.string().describe('文件总数'),
|
||||
modifyUin: z.string().describe('修改人 QQ 号'),
|
||||
modifyName: z.string().describe('修改人昵称'),
|
||||
usedSpace: z.string().describe('已使用空间'),
|
||||
})
|
||||
.describe('文件夹信息'),
|
||||
})
|
||||
.describe('群文件夹信息'),
|
||||
})
|
||||
.describe('数据'),
|
||||
}),
|
||||
},
|
||||
'/delete_group_file': {
|
||||
description: '删除群文件',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
file_id: z.string().describe('文件 ID'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z
|
||||
.object({
|
||||
result: z.number().describe('结果码'),
|
||||
errMsg: z.string().describe('错误信息'),
|
||||
transGroupFileResult: z
|
||||
.object({
|
||||
result: z
|
||||
.object({
|
||||
retCode: z.number().describe('结果码'),
|
||||
retMsg: z.string().describe('结果信息'),
|
||||
clientWording: z.string().describe('客户端提示'),
|
||||
})
|
||||
.describe('结果'),
|
||||
successFileIdList: z
|
||||
.array(z.string())
|
||||
.describe('成功文件 ID 列表'),
|
||||
failFileIdList: z.array(z.string()).describe('失败文件 ID 列表'),
|
||||
})
|
||||
.describe('删除群文件结果'),
|
||||
})
|
||||
.describe('结果'),
|
||||
}),
|
||||
},
|
||||
'/delete_group_folder': {
|
||||
description: '删除群文件夹',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
folder_id: z.string().describe('文件夹 ID'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({
|
||||
retCode: z.number().describe('结果码'),
|
||||
retMsg: z.string().describe('结果信息'),
|
||||
clientWording: z.string().describe('客户端提示'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/get_group_file_system_info': {
|
||||
description: '获取群文件系统信息',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({
|
||||
file_count: z.number().describe('文件总数'),
|
||||
limit_count: z.number().describe('文件总数限制'),
|
||||
used_space: z.number().describe('已使用空间'),
|
||||
total_space: z.number().describe('总空间'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/get_group_root_files': {
|
||||
description: '获取群根目录文件列表',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.array(
|
||||
z.object({
|
||||
files: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
group_id: z.number().describe('群号'),
|
||||
file_id: z.string().describe('文件 ID'),
|
||||
file_name: z.string().describe('文件名'),
|
||||
busid: z.number().describe('?'),
|
||||
size: z.number().describe('文件大小'),
|
||||
upload_time: z.number().describe('上传时间'),
|
||||
dead_time: z.number().describe('过期时间'),
|
||||
modify_time: z.number().describe('修改时间'),
|
||||
download_times: z.number().describe('下载次数'),
|
||||
uploader: z.number().describe('上传人 QQ 号'),
|
||||
uploader_name: z.string().describe('上传人昵称'),
|
||||
})
|
||||
.describe('文件信息')
|
||||
)
|
||||
.describe('文件列表'),
|
||||
folders: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
group_id: z.number().describe('群号'),
|
||||
folder_id: z.string().describe('文件夹 ID'),
|
||||
folder: z.string().describe('文件夹?'),
|
||||
folder_name: z.string().describe('文件夹名称'),
|
||||
create_time: z.string().describe('创建时间'),
|
||||
creator: z.string().describe('创建人 QQ 号'),
|
||||
creator_name: z.string().describe('创建人昵称'),
|
||||
total_file_count: z.string().describe('文件总数'),
|
||||
})
|
||||
.describe('文件夹信息')
|
||||
)
|
||||
.describe('文件夹列表'),
|
||||
})
|
||||
),
|
||||
}),
|
||||
},
|
||||
'/get_group_files_by_folder': {
|
||||
description: '获取群子目录文件列表',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
folder_id: z.string().describe('文件夹 ID'),
|
||||
file_count: z.number().describe('文件数量'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({
|
||||
files: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
group_id: z.number().describe('群号'),
|
||||
file_id: z.string().describe('文件 ID'),
|
||||
file_name: z.string().describe('文件名'),
|
||||
busid: z.number().describe('?'),
|
||||
size: z.number().describe('文件大小'),
|
||||
upload_time: z.number().describe('上传时间'),
|
||||
dead_time: z.number().describe('过期时间'),
|
||||
modify_time: z.number().describe('修改时间'),
|
||||
download_times: z.number().describe('下载次数'),
|
||||
uploader: z.number().describe('上传人 QQ 号'),
|
||||
uploader_name: z.string().describe('上传人昵称'),
|
||||
})
|
||||
.describe('文件信息')
|
||||
)
|
||||
.describe('文件列表'),
|
||||
folders: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
group_id: z.number().describe('群号'),
|
||||
folder_id: z.string().describe('文件夹 ID'),
|
||||
folder: z.string().describe('文件夹?'),
|
||||
folder_name: z.string().describe('文件夹名称'),
|
||||
create_time: z.string().describe('创建时间'),
|
||||
creator: z.string().describe('创建人 QQ 号'),
|
||||
creator_name: z.string().describe('创建人昵称'),
|
||||
total_file_count: z.string().describe('文件总数'),
|
||||
})
|
||||
.describe('文件夹信息')
|
||||
)
|
||||
.describe('文件夹列表'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/get_group_file_url': {
|
||||
description: '获取群文件下载链接',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
file_id: z.string().describe('文件 ID'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({
|
||||
url: z.string().describe('下载链接'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/get_group_list': {
|
||||
description: '获取群列表',
|
||||
request: z.object({
|
||||
next_token: z.string().optional().describe('下一页标识'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.array(z.object({})),
|
||||
}),
|
||||
},
|
||||
'/get_group_member_info': {
|
||||
description: '获取群成员信息',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
user_id: z.union([z.string(), z.number()]).describe('QQ 号'),
|
||||
no_cache: z.boolean().describe('是否不使用缓存'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({}),
|
||||
}),
|
||||
},
|
||||
'/get_group_member_list': {
|
||||
description: '获取群成员列表',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
no_cache: z.boolean().describe('是否不使用缓存'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.array(z.object({})),
|
||||
}),
|
||||
},
|
||||
'/get_group_honor_info': {
|
||||
description: '获取群荣誉',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z
|
||||
.object({
|
||||
group_id: z.number().describe('群号'),
|
||||
current_talkative: z
|
||||
.object({
|
||||
user_id: z.number().describe('QQ 号'),
|
||||
avatar: z.string().describe('头像 URL'),
|
||||
nickname: z.string().describe('昵称'),
|
||||
day_count: z.number().describe('天数'),
|
||||
description: z.string().describe('描述'),
|
||||
})
|
||||
.describe('当前龙王'),
|
||||
talkative_list: z
|
||||
.array(
|
||||
z.object({
|
||||
user_id: z.number().describe('QQ 号'),
|
||||
avatar: z.string().describe('头像 URL'),
|
||||
nickname: z.string().describe('昵称'),
|
||||
day_count: z.number().describe('天数'),
|
||||
description: z.string().describe('描述'),
|
||||
})
|
||||
)
|
||||
.describe('龙王榜'),
|
||||
performer_list: z
|
||||
.array(
|
||||
z.object({
|
||||
user_id: z.number().describe('QQ 号'),
|
||||
avatar: z.string().describe('头像 URL'),
|
||||
nickname: z.string().describe('昵称'),
|
||||
description: z.string().describe('描述'),
|
||||
})
|
||||
)
|
||||
.describe('?'),
|
||||
legend_list: z.array(z.string()).describe('?'),
|
||||
emotion_list: z.array(z.string()).describe('?'),
|
||||
strong_newbie_list: z.array(z.string()).describe('?'),
|
||||
})
|
||||
.describe('群荣誉信息'),
|
||||
}),
|
||||
},
|
||||
'/get_group_at_all_remain': {
|
||||
description: '获取群 @全体成员 剩余次数',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({
|
||||
can_at_all: z.boolean().describe('是否可以 @全体成员'),
|
||||
remain_at_all_count_for_group: z.number().describe('剩余次数(group?)'),
|
||||
remain_at_all_count_for_uin: z.number().describe('剩余次数(qq?)'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/get_group_ignored_notifies': {
|
||||
description: '获取群过滤系统消息',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({
|
||||
join_requests: z
|
||||
.array(
|
||||
z.object({
|
||||
request_id: z.string().describe('请求 ID'),
|
||||
requester_uin: z.string().describe('请求人 QQ 号'),
|
||||
requester_nick: z.string().describe('请求人昵称'),
|
||||
group_id: z.string().describe('群号'),
|
||||
group_name: z.string().describe('群名称'),
|
||||
checked: z.boolean().describe('是否已处理'),
|
||||
actor: z.string().describe('处理人 QQ 号'),
|
||||
})
|
||||
)
|
||||
.describe('入群请求列表'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/set_group_sign': {
|
||||
description: '设置群打卡',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
}),
|
||||
response: baseResponseSchema,
|
||||
},
|
||||
'/send_group_sign': {
|
||||
description: '发送群打卡',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
}),
|
||||
response: baseResponseSchema,
|
||||
},
|
||||
'/get_ai_characters': {
|
||||
description: '获取AI语音人物',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
chat_type: z.union([z.string(), z.number()]).describe('聊天类型'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.array(
|
||||
z.object({
|
||||
type: z.string().describe('类型'),
|
||||
characters: z.array(
|
||||
z
|
||||
.object({
|
||||
character_id: z.string().describe('人物 ID'),
|
||||
character_name: z.string().describe('人物名称'),
|
||||
preview_url: z.string().describe('预览音频地址'),
|
||||
})
|
||||
.describe('人物信息')
|
||||
),
|
||||
})
|
||||
),
|
||||
}),
|
||||
},
|
||||
'/send_group_ai_record': {
|
||||
description: '发送群AI语音',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号'),
|
||||
character: z.string().describe('人物ID'),
|
||||
text: z.string().describe('文本内容'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({
|
||||
message_id: z.string().describe('消息 ID'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/get_ai_record': {
|
||||
description: '获取AI语音',
|
||||
request: z.object({
|
||||
group_id: z.string().describe('群号'),
|
||||
character: z.string().describe('人物ID'),
|
||||
text: z.string().describe('文本内容'),
|
||||
}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.string(),
|
||||
}),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default oneBotHttpApiGroup;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user