mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
feat: 新版webui
This commit is contained in:
101
napcat.webui/src/pages/dashboard/about.tsx
Normal file
101
napcat.webui/src/pages/dashboard/about.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Link } from '@heroui/link'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRequest } from 'ahooks'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { BietiaopIcon, GithubIcon, WebUIIcon } from '@/components/icons'
|
||||
import NapCatRepoInfo from '@/components/napcat_repo_info'
|
||||
import { title } from '@/components/primitives'
|
||||
|
||||
import logo from '@/assets/images/logo.png'
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
|
||||
import packageJson from '../../../package.json'
|
||||
|
||||
function VersionInfo() {
|
||||
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<Chip
|
||||
startContent={
|
||||
<Chip color="danger" size="sm" className="-ml-0.5 select-none">
|
||||
WebUI
|
||||
</Chip>
|
||||
}
|
||||
>
|
||||
{packageJson.version}
|
||||
</Chip>
|
||||
<Chip
|
||||
startContent={
|
||||
<Chip color="warning" size="sm" className="-ml-0.5 select-none">
|
||||
NapCat
|
||||
</Chip>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
error.message
|
||||
) : loading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
data?.version
|
||||
)}
|
||||
</Chip>
|
||||
<Tooltip content="查看WebUI源码" placement="bottom" showArrow>
|
||||
<Link isExternal href="https://github.com/bietiaop/NextNapCatWebUI">
|
||||
<GithubIcon className="text-default-900 hover:text-default-600 w-8 h-8 hover:drop-shadow-lg transition-all" />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<>
|
||||
<title>关于 NapCat WebUI</title>
|
||||
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
|
||||
<div className="max-w-full w-[1000px] px-5 flex flex-col items-center">
|
||||
<div className="flex flex-col md:flex-row items-center">
|
||||
<Image
|
||||
alt="logo"
|
||||
className="flex-shrink-0 w-52 md:w-48 mr-2"
|
||||
src={logo}
|
||||
/>
|
||||
<div className="flex -mt-9 md:mt-0">
|
||||
<WebUIIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex opacity-60 items-center gap-2 mb-2 font-ubuntu">
|
||||
Created By
|
||||
<div className="flex scale-80 -ml-5 -mr-5">
|
||||
<BietiaopIcon />
|
||||
</div>
|
||||
</div>
|
||||
<VersionInfo />
|
||||
<div className="mb-6 flex flex-col items-center gap-4">
|
||||
<p
|
||||
className={clsx(
|
||||
title({
|
||||
color: 'cyan',
|
||||
shadow: true
|
||||
}),
|
||||
'!text-3xl'
|
||||
)}
|
||||
>
|
||||
NapCat Contributors
|
||||
</p>
|
||||
<Image
|
||||
className="w-[600px] max-w-full pointer-events-none select-none"
|
||||
src="https://contrib.rocks/image?repo=bietiaop/NapCatQQ"
|
||||
alt="Contributors"
|
||||
/>
|
||||
</div>
|
||||
<NapCatRepoInfo />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
135
napcat.webui/src/pages/dashboard/config/index.tsx
Normal file
135
napcat.webui/src/pages/dashboard/config/index.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Tab, Tabs } from '@heroui/tabs'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
import key from '@/const/key'
|
||||
|
||||
import useConfig from '@/hooks/use-config'
|
||||
import useMusic from '@/hooks/use-music'
|
||||
|
||||
import OneBotConfigCard from './onebot'
|
||||
import WebUIConfigCard from './webui'
|
||||
|
||||
export default function ConfigPage() {
|
||||
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
|
||||
const {
|
||||
control: onebotControl,
|
||||
handleSubmit: handleOnebotSubmit,
|
||||
formState: { isSubmitting: isOnebotSubmitting },
|
||||
setValue: setOnebotValue
|
||||
} = useForm<IConfig['onebot']>({
|
||||
defaultValues: {
|
||||
musicSignUrl: '',
|
||||
enableLocalFile2Url: false,
|
||||
parseMultMsg: false
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
control: webuiControl,
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting: isWebuiSubmitting },
|
||||
setValue: setWebuiValue
|
||||
} = useForm<IConfig['webui']>({
|
||||
defaultValues: {
|
||||
background: '',
|
||||
musicListID: '',
|
||||
customIcons: {}
|
||||
}
|
||||
})
|
||||
|
||||
const isMediumUp = useMediaQuery({ minWidth: 768 })
|
||||
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
|
||||
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
|
||||
key.customIcons,
|
||||
{}
|
||||
)
|
||||
const { setListId, listId } = useMusic()
|
||||
const resetOneBot = () => {
|
||||
setOnebotValue('musicSignUrl', config.musicSignUrl)
|
||||
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
|
||||
setOnebotValue('parseMultMsg', config.parseMultMsg)
|
||||
}
|
||||
|
||||
const resetWebUI = () => {
|
||||
setWebuiValue('musicListID', listId)
|
||||
setWebuiValue('customIcons', customIcons)
|
||||
setWebuiValue('background', b64img)
|
||||
}
|
||||
|
||||
const onOneBotSubmit = handleOnebotSubmit((data) => {
|
||||
try {
|
||||
saveConfigWithoutNetwork(data)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
const onWebuiSubmit = handleWebuiSubmit((data) => {
|
||||
try {
|
||||
setListId(data.musicListID)
|
||||
setCustomIcons(data.customIcons)
|
||||
setB64img(data.background)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
const onRefresh = async () => {
|
||||
try {
|
||||
await refreshConfig()
|
||||
toast.success('刷新成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`刷新失败: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
resetOneBot()
|
||||
resetWebUI()
|
||||
}, [config])
|
||||
|
||||
return (
|
||||
<section className="w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10">
|
||||
<Tabs
|
||||
aria-label="config tab"
|
||||
fullWidth
|
||||
className="w-full"
|
||||
isVertical={isMediumUp}
|
||||
classNames={{
|
||||
tabList: 'sticky flex top-14 bg-opacity-50 backdrop-blur-sm',
|
||||
panel: 'w-full',
|
||||
base: 'md:!w-auto flex-grow-0 flex-shrink-0 mr-0',
|
||||
cursor: 'bg-opacity-60 backdrop-blur-sm'
|
||||
}}
|
||||
>
|
||||
<Tab title="OneBot配置" key="onebot">
|
||||
<OneBotConfigCard
|
||||
isSubmitting={isOnebotSubmitting}
|
||||
onRefresh={onRefresh}
|
||||
onSubmit={onOneBotSubmit}
|
||||
control={onebotControl}
|
||||
reset={resetOneBot}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab title="WebUI配置" key="webui">
|
||||
<WebUIConfigCard
|
||||
isSubmitting={isWebuiSubmitting}
|
||||
onRefresh={onRefresh}
|
||||
onSubmit={onWebuiSubmit}
|
||||
control={webuiControl}
|
||||
reset={resetWebUI}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
70
napcat.webui/src/pages/dashboard/config/onebot.tsx
Normal file
70
napcat.webui/src/pages/dashboard/config/onebot.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import type { Control } from 'react-hook-form'
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons'
|
||||
import SwitchCard from '@/components/switch_card'
|
||||
|
||||
export interface OneBotConfigCardProps {
|
||||
control: Control<IConfig['onebot']>
|
||||
onSubmit: () => void
|
||||
reset: () => void
|
||||
isSubmitting: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
const OneBotConfigCard: React.FC<OneBotConfigCardProps> = (props) => {
|
||||
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
|
||||
return (
|
||||
<>
|
||||
<title>OneBot配置 - NapCat WebUI</title>
|
||||
<Card className="bg-opacity-50 backdrop-blur-sm">
|
||||
<CardBody className="items-center py-5">
|
||||
<div className="w-96 max-w-full flex flex-col gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="musicSignUrl"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="音乐签名地址"
|
||||
placeholder="请输入音乐签名地址"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enableLocalFile2Url"
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description="启用本地文件到URL"
|
||||
label="启用本地文件到URL"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parseMultMsg"
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description="启用上报解析合并消息"
|
||||
label="启用上报解析合并消息"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotConfigCard
|
||||
71
napcat.webui/src/pages/dashboard/config/webui.tsx
Normal file
71
napcat.webui/src/pages/dashboard/config/webui.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import type { Control } from 'react-hook-form'
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons'
|
||||
import ImageInput from '@/components/input/image_input'
|
||||
|
||||
import { siteConfig } from '@/config/site'
|
||||
|
||||
export interface WebUIConfigCardProps {
|
||||
control: Control<IConfig['webui']>
|
||||
onSubmit: () => void
|
||||
reset: () => void
|
||||
isSubmitting: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
const WebUIConfigCard: React.FC<WebUIConfigCardProps> = (props) => {
|
||||
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
|
||||
return (
|
||||
<>
|
||||
<title>WebUI配置 - NapCat WebUI</title>
|
||||
<Card className="bg-opacity-50 backdrop-blur-sm">
|
||||
<CardBody className="items-center py-5">
|
||||
<div className="w-96 max-w-full flex flex-col gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="musicListID"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="网易云音乐歌单ID(网页内音乐播放器)"
|
||||
placeholder="请输入歌单ID"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-shrink-0 w-full">背景图</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="background"
|
||||
render={({ field }) => <ImageInput {...field} />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>自定义图标</div>
|
||||
{siteConfig.navItems.map((item) => (
|
||||
<Controller
|
||||
key={item.label}
|
||||
control={control}
|
||||
name={`customIcons.${item.label}`}
|
||||
render={({ field }) => (
|
||||
<ImageInput {...field} label={item.label} />
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebUIConfigCard
|
||||
68
napcat.webui/src/pages/dashboard/debug/http/index.tsx
Normal file
68
napcat.webui/src/pages/dashboard/debug/http/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import clsx from 'clsx'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { TbSquareRoundedChevronLeftFilled } from 'react-icons/tb'
|
||||
|
||||
import oneBotHttpApi from '@/const/ob_api'
|
||||
import type { OneBotHttpApi } from '@/const/ob_api'
|
||||
|
||||
import OneBotApiDebug from '@/components/onebot/api/debug'
|
||||
import OneBotApiNavList from '@/components/onebot/api/nav_list'
|
||||
|
||||
export default function HttpDebug() {
|
||||
const [selectedApi, setSelectedApi] =
|
||||
useState<keyof OneBotHttpApi>('/set_qq_profile')
|
||||
const data = oneBotHttpApi[selectedApi]
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [openSideBar, setOpenSideBar] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
contentRef?.current?.scrollTo?.({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}, [selectedApi])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>HTTP调试 - NapCat WebUI</title>
|
||||
<div className="w-full h-[calc(100%-3.6rem)] flex items-stretch">
|
||||
<OneBotApiNavList
|
||||
data={oneBotHttpApi}
|
||||
selectedApi={selectedApi}
|
||||
onSelect={setSelectedApi}
|
||||
openSideBar={openSideBar}
|
||||
/>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="flex-1 h-full overflow-x-hidden relative"
|
||||
>
|
||||
<motion.div
|
||||
className="sticky top-0 z-20 md:!ml-4"
|
||||
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
|
||||
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
radius="md"
|
||||
variant="shadow"
|
||||
size="sm"
|
||||
onPress={() => setOpenSideBar(!openSideBar)}
|
||||
>
|
||||
<TbSquareRoundedChevronLeftFilled
|
||||
size={24}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
openSideBar ? '' : 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</motion.div>
|
||||
<OneBotApiDebug path={selectedApi} data={data} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
5
napcat.webui/src/pages/dashboard/debug/index.tsx
Normal file
5
napcat.webui/src/pages/dashboard/debug/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
export default function DebugPage() {
|
||||
return <Outlet />
|
||||
}
|
||||
92
napcat.webui/src/pages/dashboard/debug/websocket/index.tsx
Normal file
92
napcat.webui/src/pages/dashboard/debug/websocket/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { useCallback, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import ChatInputModal from '@/components/chat_input/modal'
|
||||
import OneBotMessageList from '@/components/onebot/message_list'
|
||||
import OneBotSendModal from '@/components/onebot/send_modal'
|
||||
import WSStatus from '@/components/onebot/ws_status'
|
||||
|
||||
import { useWebSocketDebug } from '@/hooks/use-websocket-debug'
|
||||
|
||||
export default function WSDebug() {
|
||||
const url = new URL(window.location.origin).href
|
||||
const defaultWsUrl = url.replace('http', 'ws').replace(':6099', ':3000')
|
||||
const [socketConfig, setSocketConfig] = useState({
|
||||
url: defaultWsUrl,
|
||||
token: ''
|
||||
})
|
||||
const [inputUrl, setInputUrl] = useState(socketConfig.url)
|
||||
const [inputToken, setInputToken] = useState(socketConfig.token)
|
||||
|
||||
const { sendMessage, readyState, FilterMessagesType, filteredMessages } =
|
||||
useWebSocketDebug(socketConfig.url, socketConfig.token)
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
if (!inputUrl.startsWith('ws://') && !inputUrl.startsWith('wss://')) {
|
||||
toast.error('WebSocket URL 不合法')
|
||||
return
|
||||
}
|
||||
setSocketConfig({
|
||||
url: inputUrl,
|
||||
token: inputToken
|
||||
})
|
||||
}, [inputUrl, inputToken])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>Websocket调试 - NapCat WebUI</title>
|
||||
<div className="h-[calc(100vh-4rem)] overflow-hidden flex flex-col">
|
||||
<Card className="mx-2 mt-2 flex-shrink-0 bg-opacity-50 backdrop-blur-sm">
|
||||
<CardBody className="gap-2">
|
||||
<div className="grid gap-2 items-center md:grid-cols-5">
|
||||
<Input
|
||||
className="col-span-2"
|
||||
label="WebSocket URL"
|
||||
type="text"
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
placeholder="输入 WebSocket URL"
|
||||
/>
|
||||
<Input
|
||||
className="col-span-2"
|
||||
label="Token"
|
||||
type="text"
|
||||
value={inputToken}
|
||||
onChange={(e) => setInputToken(e.target.value)}
|
||||
placeholder="输入 Token"
|
||||
/>
|
||||
<div className="flex-shrink-0 flex gap-2 col-span-2 md:col-span-1">
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={handleConnect}
|
||||
size="lg"
|
||||
radius="full"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 border border-default-100 bg-content1 bg-opacity-50 rounded-md dark:bg-[rgb(30,30,30)]">
|
||||
<div className="grid gap-2 md:grid-cols-5 items-center md:w-fit">
|
||||
<WSStatus state={readyState} />
|
||||
<div className="md:w-64 max-w-full col-span-2">
|
||||
{FilterMessagesType}
|
||||
</div>
|
||||
<OneBotSendModal sendMessage={sendMessage} />
|
||||
<ChatInputModal />
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<OneBotMessageList messages={filteredMessages} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
118
napcat.webui/src/pages/dashboard/index.tsx
Normal file
118
napcat.webui/src/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { useRequest } from 'ahooks'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import NetworkItemDisplay from '@/components/display_network_item'
|
||||
import Hitokoto from '@/components/hitokoto'
|
||||
import QQInfoCard from '@/components/qq_info_card'
|
||||
import SystemInfo from '@/components/system_info'
|
||||
import SystemStatusDisplay from '@/components/system_status_display'
|
||||
|
||||
import useConfig from '@/hooks/use-config'
|
||||
|
||||
import QQManager from '@/controllers/qq_manager'
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
|
||||
const Networks: React.FC = () => {
|
||||
const { config, refreshConfig } = useConfig()
|
||||
const allNetWorkConfigLength =
|
||||
config.network.httpClients.length +
|
||||
config.network.websocketClients.length +
|
||||
config.network.websocketServers.length +
|
||||
config.network.httpServers.length
|
||||
|
||||
useEffect(() => {
|
||||
refreshConfig()
|
||||
}, [])
|
||||
return (
|
||||
<div className="grid grid-cols-8 md:grid-cols-3 lg:grid-cols-6 gap-y-2 gap-x-1 md:gap-y-4 md:gap-x-4 py-5">
|
||||
<NetworkItemDisplay count={allNetWorkConfigLength} label="网络配置" />
|
||||
<NetworkItemDisplay
|
||||
count={config.network.httpServers.length}
|
||||
label="HTTP服务器"
|
||||
size="sm"
|
||||
/>
|
||||
<NetworkItemDisplay
|
||||
count={config.network.httpClients.length}
|
||||
label="HTTP客户端"
|
||||
size="sm"
|
||||
/>
|
||||
<NetworkItemDisplay
|
||||
count={config.network.websocketServers.length}
|
||||
label="WS服务器"
|
||||
size="sm"
|
||||
/>
|
||||
<NetworkItemDisplay
|
||||
count={config.network.websocketClients.length}
|
||||
label="WS客户端"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const QQInfo: React.FC = () => {
|
||||
const { data, loading, error } = useRequest(QQManager.getQQLoginInfo)
|
||||
return <QQInfoCard data={data} error={error} loading={loading} />
|
||||
}
|
||||
|
||||
export interface SystemStatusCardProps {
|
||||
setArchInfo: (arch: string | undefined) => void
|
||||
}
|
||||
const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>()
|
||||
const isSetted = useRef(false)
|
||||
const getStatus = useCallback(() => {
|
||||
try {
|
||||
const event = WebUIManager.getSystemStatus(setSystemStatus)
|
||||
return event
|
||||
} catch (error) {
|
||||
toast.error('获取系统状态失败')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const close = getStatus()
|
||||
return () => {
|
||||
close?.close()
|
||||
}
|
||||
}, [getStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (systemStatus?.arch && !isSetted.current) {
|
||||
setArchInfo(systemStatus.arch)
|
||||
isSetted.current = true
|
||||
}
|
||||
}, [systemStatus, setArchInfo])
|
||||
|
||||
return <SystemStatusDisplay data={systemStatus} />
|
||||
}
|
||||
|
||||
const DashboardIndexPage: React.FC = () => {
|
||||
const [archInfo, setArchInfo] = useState<string>()
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>基础信息 - NapCat WebUI</title>
|
||||
<section className="w-full p-2 md:p-4 md:max-w-[1000px] mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch">
|
||||
<div className="flex flex-col gap-2">
|
||||
<QQInfo />
|
||||
<SystemInfo archInfo={archInfo} />
|
||||
</div>
|
||||
<SystemStatusCard setArchInfo={setArchInfo} />
|
||||
</div>
|
||||
<Networks />
|
||||
<Card className="bg-opacity-60 shadow-sm shadow-danger-50">
|
||||
<CardBody>
|
||||
<Hitokoto />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DashboardIndexPage
|
||||
79
napcat.webui/src/pages/dashboard/logs.tsx
Normal file
79
napcat.webui/src/pages/dashboard/logs.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Tab, Tabs } from '@heroui/tabs'
|
||||
import { useRequest } from 'ahooks'
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import HistoryLogs from '@/components/log_com/history'
|
||||
import RealTimeLogs from '@/components/log_com/realtime'
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
|
||||
export default function LogsPage() {
|
||||
const {
|
||||
data: logList,
|
||||
loading: logListLoading,
|
||||
error: logListError,
|
||||
refresh: refreshLogList
|
||||
} = useRequest(WebUIManager.getLogList)
|
||||
const [selectedLog, setSelectedLog] = useState<string | null>(null)
|
||||
const [logContent, setLogContent] = useState<string | null>(null)
|
||||
const [logLoading, setLogLoading] = useState<boolean>(false)
|
||||
const onLogSelect = (name: string) => {
|
||||
setSelectedLog(name)
|
||||
}
|
||||
const onLoadLog = async () => {
|
||||
if (!selectedLog) {
|
||||
return
|
||||
}
|
||||
setLogLoading(true)
|
||||
try {
|
||||
const result = await WebUIManager.getLogContent(selectedLog)
|
||||
setLogContent(result)
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`加载日志失败: ${msg}`)
|
||||
} finally {
|
||||
setLogLoading(false)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (logList && logList.length > 0) {
|
||||
setSelectedLog(logList[0])
|
||||
}
|
||||
}, [logList])
|
||||
useEffect(() => {
|
||||
if (selectedLog) {
|
||||
onLoadLog()
|
||||
}
|
||||
}, [selectedLog])
|
||||
return (
|
||||
<div className="h-[calc(100vh_-_8rem)] flex flex-col gap-4 items-center pt-4 px-2">
|
||||
<Tabs
|
||||
aria-label="Logs"
|
||||
classNames={{
|
||||
panel: 'w-full flex-1 h-full py-0 flex flex-col gap-4',
|
||||
base: 'flex-shrink-0 !h-fit',
|
||||
tabList: 'bg-opacity-50 backdrop-blur-sm',
|
||||
cursor: 'bg-opacity-60 backdrop-blur-sm'
|
||||
}}
|
||||
>
|
||||
<Tab title="实时日志">
|
||||
<RealTimeLogs />
|
||||
</Tab>
|
||||
<Tab title="历史日志">
|
||||
<HistoryLogs
|
||||
list={logList || []}
|
||||
onSelect={onLogSelect}
|
||||
selectedLog={selectedLog || undefined}
|
||||
refreshList={refreshLogList}
|
||||
refreshLog={onLoadLog}
|
||||
listLoading={logListLoading}
|
||||
logLoading={logLoading}
|
||||
listError={logListError}
|
||||
logContent={logContent || undefined}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
367
napcat.webui/src/pages/dashboard/network.tsx
Normal file
367
napcat.webui/src/pages/dashboard/network.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { useDisclosure } from '@heroui/modal'
|
||||
import { Tab, Tabs } from '@heroui/tabs'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoMdRefresh } from 'react-icons/io'
|
||||
|
||||
import AddButton from '@/components/button/add_button'
|
||||
import HTTPClientDisplayCard from '@/components/display_card/http_client'
|
||||
import HTTPServerDisplayCard from '@/components/display_card/http_server'
|
||||
import WebsocketClientDisplayCard from '@/components/display_card/ws_client'
|
||||
import WebsocketServerDisplayCard from '@/components/display_card/ws_server'
|
||||
import NetworkFormModal from '@/components/network_edit/modal'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
|
||||
import useConfig from '@/hooks/use-config'
|
||||
import useDialog from '@/hooks/use-dialog'
|
||||
|
||||
export interface SectionProps {
|
||||
title: string
|
||||
color?:
|
||||
| 'violet'
|
||||
| 'yellow'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'green'
|
||||
| 'pink'
|
||||
| 'foreground'
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export interface EmptySectionProps {
|
||||
isEmpty: boolean
|
||||
}
|
||||
|
||||
const EmptySection: React.FC<EmptySectionProps> = ({ isEmpty }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx('text-default-400', {
|
||||
hidden: !isEmpty
|
||||
})}
|
||||
>
|
||||
暂时还没有配置项哈
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NetworkPage() {
|
||||
const {
|
||||
config,
|
||||
refreshConfig,
|
||||
deleteNetworkConfig,
|
||||
enableNetworkConfig,
|
||||
enableDebugNetworkConfig
|
||||
} = useConfig()
|
||||
const [activeField, setActiveField] =
|
||||
useState<keyof OneBotConfig['network']>('httpServers')
|
||||
const [activeName, setActiveName] = useState<string>('')
|
||||
const {
|
||||
network: { httpServers, httpClients, websocketServers, websocketClients }
|
||||
} = config
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure()
|
||||
const dialog = useDialog()
|
||||
const activeData = useMemo(() => {
|
||||
const findData = config.network[activeField].find(
|
||||
(item) => item.name === activeName
|
||||
)
|
||||
|
||||
return findData
|
||||
}, [activeField, activeName, config])
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await refreshConfig()
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
|
||||
toast.error(`获取配置失败: ${msg}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickCreate = (key: keyof OneBotConfig['network']) => {
|
||||
setActiveField(key)
|
||||
setActiveName('')
|
||||
onOpen()
|
||||
}
|
||||
|
||||
const onDelete = async (
|
||||
field: keyof OneBotConfig['network'],
|
||||
name: string
|
||||
) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
dialog.confirm({
|
||||
title: '删除配置',
|
||||
content: `确定要删除配置「${name}」吗?`,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteNetworkConfig(field, name)
|
||||
toast.success('删除配置成功')
|
||||
resolve()
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
|
||||
toast.error(`删除配置失败: ${msg}`)
|
||||
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const onEnable = async (
|
||||
field: keyof OneBotConfig['network'],
|
||||
name: string
|
||||
) => {
|
||||
try {
|
||||
await enableNetworkConfig(field, name)
|
||||
toast.success('更新配置成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
|
||||
toast.error(`更新配置失败: ${msg}`)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const onEnableDebug = async (
|
||||
field: keyof OneBotConfig['network'],
|
||||
name: string
|
||||
) => {
|
||||
try {
|
||||
await enableDebugNetworkConfig(field, name)
|
||||
toast.success('更新配置成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
|
||||
toast.error(`更新配置失败: ${msg}`)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const onEdit = (field: keyof OneBotConfig['network'], name: string) => {
|
||||
setActiveField(field)
|
||||
setActiveName(name)
|
||||
onOpen()
|
||||
}
|
||||
|
||||
const renderCard = <T extends keyof OneBotConfig['network']>(
|
||||
type: T,
|
||||
item: OneBotConfig['network'][T][0],
|
||||
showType = false
|
||||
) => {
|
||||
switch (type) {
|
||||
case 'httpServers':
|
||||
return (
|
||||
<HTTPServerDisplayCard
|
||||
key={item.name}
|
||||
showType={showType}
|
||||
data={item as OneBotConfig['network']['httpServers'][0]}
|
||||
onDelete={async () => {
|
||||
await onDelete('httpServers', item.name)
|
||||
}}
|
||||
onEdit={() => {
|
||||
onEdit('httpServers', item.name)
|
||||
}}
|
||||
onEnable={async () => {
|
||||
await onEnable('httpServers', item.name)
|
||||
}}
|
||||
onEnableDebug={async () => {
|
||||
await onEnableDebug('httpServers', item.name)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
case 'httpClients':
|
||||
return (
|
||||
<HTTPClientDisplayCard
|
||||
key={item.name}
|
||||
showType={showType}
|
||||
data={item as OneBotConfig['network']['httpClients'][0]}
|
||||
onDelete={async () => {
|
||||
await onDelete('httpClients', item.name)
|
||||
}}
|
||||
onEdit={() => {
|
||||
onEdit('httpClients', item.name)
|
||||
}}
|
||||
onEnable={async () => {
|
||||
await onEnable('httpClients', item.name)
|
||||
}}
|
||||
onEnableDebug={async () => {
|
||||
await onEnableDebug('httpClients', item.name)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
case 'websocketServers':
|
||||
return (
|
||||
<WebsocketServerDisplayCard
|
||||
key={item.name}
|
||||
showType={showType}
|
||||
data={item as OneBotConfig['network']['websocketServers'][0]}
|
||||
onDelete={async () => {
|
||||
await onDelete('websocketServers', item.name)
|
||||
}}
|
||||
onEdit={() => {
|
||||
onEdit('websocketServers', item.name)
|
||||
}}
|
||||
onEnable={async () => {
|
||||
await onEnable('websocketServers', item.name)
|
||||
}}
|
||||
onEnableDebug={async () => {
|
||||
await onEnableDebug('websocketServers', item.name)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
case 'websocketClients':
|
||||
return (
|
||||
<WebsocketClientDisplayCard
|
||||
key={item.name}
|
||||
showType={showType}
|
||||
data={item as OneBotConfig['network']['websocketClients'][0]}
|
||||
onDelete={async () => {
|
||||
await onDelete('websocketClients', item.name)
|
||||
}}
|
||||
onEdit={() => {
|
||||
onEdit('websocketClients', item.name)
|
||||
}}
|
||||
onEnable={async () => {
|
||||
await onEnable('websocketClients', item.name)
|
||||
}}
|
||||
onEnableDebug={async () => {
|
||||
await onEnableDebug('websocketClients', item.name)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'all',
|
||||
title: '全部',
|
||||
items: [
|
||||
...httpServers,
|
||||
...httpClients,
|
||||
...websocketServers,
|
||||
...websocketClients
|
||||
]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((item) => {
|
||||
if (httpServers.find((i) => i.name === item.name)) {
|
||||
return renderCard(
|
||||
'httpServers',
|
||||
item as OneBotConfig['network']['httpServers'][0],
|
||||
true
|
||||
)
|
||||
}
|
||||
if (httpClients.find((i) => i.name === item.name)) {
|
||||
return renderCard(
|
||||
'httpClients',
|
||||
item as OneBotConfig['network']['httpClients'][0],
|
||||
true
|
||||
)
|
||||
}
|
||||
if (websocketServers.find((i) => i.name === item.name)) {
|
||||
return renderCard(
|
||||
'websocketServers',
|
||||
item as OneBotConfig['network']['websocketServers'][0],
|
||||
true
|
||||
)
|
||||
}
|
||||
if (websocketClients.find((i) => i.name === item.name)) {
|
||||
return renderCard(
|
||||
'websocketClients',
|
||||
item as OneBotConfig['network']['websocketClients'][0],
|
||||
true
|
||||
)
|
||||
}
|
||||
return null
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'httpServers',
|
||||
title: 'HTTP服务器',
|
||||
items: httpServers.map((item) => renderCard('httpServers', item))
|
||||
},
|
||||
{
|
||||
key: 'httpClients',
|
||||
title: 'HTTP客户端',
|
||||
items: httpClients.map((item) => renderCard('httpClients', item))
|
||||
},
|
||||
{
|
||||
key: 'websocketServers',
|
||||
title: 'Websocket服务器',
|
||||
items: websocketServers.map((item) =>
|
||||
renderCard('websocketServers', item)
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'websocketClients',
|
||||
title: 'Websocket客户端',
|
||||
items: websocketClients.map((item) =>
|
||||
renderCard('websocketClients', item)
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>网络配置 - NapCat WebUI</title>
|
||||
<div className="p-2 md:p-4 relative">
|
||||
<NetworkFormModal
|
||||
data={activeData}
|
||||
field={activeField}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
<PageLoading loading={loading} />
|
||||
<div className="flex mb-6 items-center gap-4">
|
||||
<AddButton onOpen={handleClickCreate} />
|
||||
<Button
|
||||
isIconOnly
|
||||
color="primary"
|
||||
radius="full"
|
||||
variant="flat"
|
||||
onPress={refresh}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs
|
||||
aria-label="Network Configs"
|
||||
className="max-w-full"
|
||||
items={tabs}
|
||||
classNames={{
|
||||
tabList: 'bg-opacity-50 backdrop-blur-sm',
|
||||
cursor: 'bg-opacity-60 backdrop-blur-sm'
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<Tab key={item.key} title={item.title}>
|
||||
<EmptySection isEmpty={!item.items.length} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
|
||||
{item.items}
|
||||
</div>
|
||||
</Tab>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user