mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c9e91a9b94 | ||
![]() |
43fb62c5bd | ||
![]() |
cb8727d487 | ||
![]() |
a94e03e2fd | ||
![]() |
425c3c6432 | ||
![]() |
89b9610016 | ||
![]() |
62fe88f868 | ||
![]() |
11a7f5fade | ||
![]() |
fbde997f7c | ||
![]() |
26734a35ef | ||
![]() |
715c4ac534 |
@@ -4,7 +4,7 @@
|
|||||||
"name": "NapCatQQ",
|
"name": "NapCatQQ",
|
||||||
"slug": "NapCat.Framework",
|
"slug": "NapCat.Framework",
|
||||||
"description": "高性能的 OneBot 11 协议实现",
|
"description": "高性能的 OneBot 11 协议实现",
|
||||||
"version": "4.5.1",
|
"version": "4.5.4",
|
||||||
"icon": "./logo.png",
|
"icon": "./logo.png",
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
|
@@ -16,6 +16,16 @@ import store from '@/store'
|
|||||||
const WebLoginPage = lazy(() => import('@/pages/web_login'))
|
const WebLoginPage = lazy(() => import('@/pages/web_login'))
|
||||||
const IndexPage = lazy(() => import('@/pages/index'))
|
const IndexPage = lazy(() => import('@/pages/index'))
|
||||||
const QQLoginPage = lazy(() => import('@/pages/qq_login'))
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -58,9 +68,21 @@ function AuthChecker({ children }: { children: React.ReactNode }) {
|
|||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<IndexPage />} path="/*" />
|
<Route path="/" element={<IndexPage />}>
|
||||||
<Route element={<QQLoginPage />} path="/qq_login" />
|
<Route index element={<DashboardIndexPage />} />
|
||||||
<Route element={<WebLoginPage />} path="/web_login" />
|
<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>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -9,6 +9,7 @@ import {
|
|||||||
import { Spinner } from '@heroui/spinner'
|
import { Spinner } from '@heroui/spinner'
|
||||||
import { useRequest } from 'ahooks'
|
import { useRequest } from 'ahooks'
|
||||||
import path from 'path-browserify'
|
import path from 'path-browserify'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
import FileManager from '@/controllers/file_manager'
|
import FileManager from '@/controllers/file_manager'
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export default function FilePreviewModal({
|
|||||||
async () => FileManager.downloadToURL(filePath),
|
async () => FileManager.downloadToURL(filePath),
|
||||||
{
|
{
|
||||||
refreshDeps: [filePath],
|
refreshDeps: [filePath],
|
||||||
|
manual: true,
|
||||||
refreshDepsAction: () => {
|
refreshDepsAction: () => {
|
||||||
const ext = path.extname(filePath).toLowerCase()
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
if (!filePath || !supportedPreviewExts.includes(ext)) {
|
if (!filePath || !supportedPreviewExts.includes(ext)) {
|
||||||
@@ -66,6 +68,12 @@ export default function FilePreviewModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filePath) {
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside" size="3xl">
|
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside" size="3xl">
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
|
@@ -33,6 +33,7 @@ export default function ImageNameButton({
|
|||||||
async () => FileManager.downloadToURL(filePath),
|
async () => FileManager.downloadToURL(filePath),
|
||||||
{
|
{
|
||||||
refreshDeps: [filePath],
|
refreshDeps: [filePath],
|
||||||
|
manual: true,
|
||||||
refreshDepsAction: () => {
|
refreshDepsAction: () => {
|
||||||
const ext = path.extname(filePath).toLowerCase()
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
if (!filePath || !imageExts.includes(ext)) {
|
if (!filePath || !imageExts.includes(ext)) {
|
||||||
@@ -52,6 +53,11 @@ export default function ImageNameButton({
|
|||||||
}
|
}
|
||||||
}, [data, name, onAddPreview])
|
}, [data, name, onAddPreview])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filePath) {
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
|
@@ -20,7 +20,7 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
|
|||||||
enable: false,
|
enable: false,
|
||||||
name: '',
|
name: '',
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3001,
|
||||||
reportSelfMessage: false,
|
reportSelfMessage: false,
|
||||||
enableForcePushEvent: true,
|
enableForcePushEvent: true,
|
||||||
messagePostFormat: 'array',
|
messagePostFormat: 'array',
|
||||||
|
265
napcat.webui/src/components/rotating_text.tsx
Normal file
265
napcat.webui/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
|
@@ -16,7 +16,6 @@ import { compareVersion } from '@/utils/version'
|
|||||||
import WebUIManager from '@/controllers/webui_manager'
|
import WebUIManager from '@/controllers/webui_manager'
|
||||||
import { GithubRelease } from '@/types/github'
|
import { GithubRelease } from '@/types/github'
|
||||||
|
|
||||||
import packageJson from '../../package.json'
|
|
||||||
import TailwindMarkdown from './tailwind_markdown'
|
import TailwindMarkdown from './tailwind_markdown'
|
||||||
|
|
||||||
export interface SystemInfoItemProps {
|
export interface SystemInfoItemProps {
|
||||||
@@ -198,11 +197,6 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
|||||||
<CardBody className="flex-1">
|
<CardBody className="flex-1">
|
||||||
<div className="flex flex-col justify-between h-full">
|
<div className="flex flex-col justify-between h-full">
|
||||||
<NapCatVersion />
|
<NapCatVersion />
|
||||||
<SystemInfoItem
|
|
||||||
title="WebUI 版本"
|
|
||||||
icon={<IoLogoChrome className="text-xl" />}
|
|
||||||
value={packageJson.version}
|
|
||||||
/>
|
|
||||||
<SystemInfoItem
|
<SystemInfoItem
|
||||||
title="QQ 版本"
|
title="QQ 版本"
|
||||||
icon={<FaQq className="text-lg" />}
|
icon={<FaQq className="text-lg" />}
|
||||||
@@ -216,6 +210,11 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SystemInfoItem
|
||||||
|
title="WebUI 版本"
|
||||||
|
icon={<IoLogoChrome className="text-xl" />}
|
||||||
|
value="Next"
|
||||||
|
/>
|
||||||
<SystemInfoItem
|
<SystemInfoItem
|
||||||
title="系统版本"
|
title="系统版本"
|
||||||
icon={<RiMacFill className="text-xl" />}
|
icon={<RiMacFill className="text-xl" />}
|
||||||
|
@@ -99,7 +99,7 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
|||||||
if (theme === 'dark') {
|
if (theme === 'dark') {
|
||||||
terminalRef.current.options.theme = {
|
terminalRef.current.options.theme = {
|
||||||
background: '#00000000',
|
background: '#00000000',
|
||||||
black: '#000000',
|
black: '#ffffff',
|
||||||
red: '#cd3131',
|
red: '#cd3131',
|
||||||
green: '#0dbc79',
|
green: '#0dbc79',
|
||||||
yellow: '#e5e510',
|
yellow: '#e5e510',
|
||||||
|
69
napcat.webui/src/hooks/use-preload-images.ts
Normal file
69
napcat.webui/src/hooks/use-preload-images.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// 全局图片缓存
|
||||||
|
const imageCache = new Map<string, HTMLImageElement>()
|
||||||
|
|
||||||
|
export function usePreloadImages(urls: string[]) {
|
||||||
|
const [loadedUrls, setLoadedUrls] = useState<Record<string, boolean>>({})
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const isMounted = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMounted.current = true
|
||||||
|
|
||||||
|
// 检查是否所有图片都已缓存
|
||||||
|
const allCached = urls.every((url) => imageCache.has(url))
|
||||||
|
if (allCached) {
|
||||||
|
setLoadedUrls(urls.reduce((acc, url) => ({ ...acc, [url]: true }), {}))
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
const loadedImages: Record<string, boolean> = {}
|
||||||
|
let pendingCount = urls.length
|
||||||
|
|
||||||
|
urls.forEach((url) => {
|
||||||
|
// 如果已经缓存,直接标记为已加载
|
||||||
|
if (imageCache.has(url)) {
|
||||||
|
loadedImages[url] = true
|
||||||
|
pendingCount--
|
||||||
|
if (pendingCount === 0) {
|
||||||
|
setLoadedUrls(loadedImages)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
if (!isMounted.current) return
|
||||||
|
loadedImages[url] = true
|
||||||
|
imageCache.set(url, img)
|
||||||
|
pendingCount--
|
||||||
|
|
||||||
|
if (pendingCount === 0) {
|
||||||
|
setLoadedUrls(loadedImages)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
if (!isMounted.current) return
|
||||||
|
loadedImages[url] = false
|
||||||
|
pendingCount--
|
||||||
|
|
||||||
|
if (pendingCount === 0) {
|
||||||
|
setLoadedUrls(loadedImages)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted.current = false
|
||||||
|
}
|
||||||
|
}, [urls])
|
||||||
|
|
||||||
|
return { loadedUrls, isLoading }
|
||||||
|
}
|
@@ -1,12 +1,18 @@
|
|||||||
import { Chip } from '@heroui/chip'
|
import { Card, CardBody } from '@heroui/card'
|
||||||
import { Image } from '@heroui/image'
|
import { Image } from '@heroui/image'
|
||||||
|
import { Link } from '@heroui/link'
|
||||||
import { Spinner } from '@heroui/spinner'
|
import { Spinner } from '@heroui/spinner'
|
||||||
import { useRequest } from 'ahooks'
|
import { useRequest } from 'ahooks'
|
||||||
import clsx from 'clsx'
|
import { useMemo } from 'react'
|
||||||
|
import { BsTelegram, BsTencentQq } from 'react-icons/bs'
|
||||||
|
import { IoDocument } from 'react-icons/io5'
|
||||||
|
|
||||||
import HoverTiltedCard from '@/components/hover_titled_card'
|
import HoverTiltedCard from '@/components/hover_titled_card'
|
||||||
import NapCatRepoInfo from '@/components/napcat_repo_info'
|
import NapCatRepoInfo from '@/components/napcat_repo_info'
|
||||||
import { title } from '@/components/primitives'
|
import RotatingText from '@/components/rotating_text'
|
||||||
|
|
||||||
|
import { usePreloadImages } from '@/hooks/use-preload-images'
|
||||||
|
import { useTheme } from '@/hooks/use-theme'
|
||||||
|
|
||||||
import logo from '@/assets/images/logo.png'
|
import logo from '@/assets/images/logo.png'
|
||||||
import WebUIManager from '@/controllers/webui_manager'
|
import WebUIManager from '@/controllers/webui_manager'
|
||||||
@@ -14,54 +20,181 @@ import WebUIManager from '@/controllers/webui_manager'
|
|||||||
function VersionInfo() {
|
function VersionInfo() {
|
||||||
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
|
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 mb-5">
|
<div className="flex items-center gap-2 text-2xl font-bold">
|
||||||
<Chip
|
<div className="flex items-center gap-2">
|
||||||
startContent={
|
<div className="text-danger-500 drop-shadow-md">NapCat</div>
|
||||||
<Chip color="warning" size="sm" className="-ml-0.5 select-none">
|
|
||||||
NapCat
|
|
||||||
</Chip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error ? (
|
{error ? (
|
||||||
error.message
|
error.message
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
) : (
|
) : (
|
||||||
data?.version
|
<RotatingText
|
||||||
|
texts={['WebUI', data?.version ?? '']}
|
||||||
|
mainClassName="overflow-hidden flex items-center bg-danger-500 px-2 rounded-lg text-default-50 shadow-md"
|
||||||
|
staggerFrom={'last'}
|
||||||
|
initial={{ y: '100%' }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: '-120%' }}
|
||||||
|
staggerDuration={0.025}
|
||||||
|
splitLevelClassName="overflow-hidden"
|
||||||
|
transition={{ type: 'spring', damping: 30, stiffness: 400 }}
|
||||||
|
rotationInterval={2000}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Chip>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
|
||||||
|
const imageUrls = useMemo(
|
||||||
|
() => [
|
||||||
|
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
|
||||||
|
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=dark',
|
||||||
|
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
|
||||||
|
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=dark'
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const { loadedUrls, isLoading } = usePreloadImages(imageUrls)
|
||||||
|
|
||||||
|
const getImageUrl = useMemo(
|
||||||
|
() => (baseUrl: string) => {
|
||||||
|
const theme = isDark ? 'dark' : 'light'
|
||||||
|
const fullUrl = baseUrl.replace(
|
||||||
|
/color_scheme=(?:light|dark)/,
|
||||||
|
`color_scheme=${theme}`
|
||||||
|
)
|
||||||
|
return isLoading ? null : loadedUrls[fullUrl] ? fullUrl : null
|
||||||
|
},
|
||||||
|
[isDark, isLoading, loadedUrls]
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderImage = useMemo(
|
||||||
|
() => (baseUrl: string, alt: string) => {
|
||||||
|
const imageUrl = getImageUrl(baseUrl)
|
||||||
|
|
||||||
|
if (!imageUrl) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 h-32 flex items-center justify-center bg-default-100 rounded-lg">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
className="flex-1 pointer-events-none select-none"
|
||||||
|
src={imageUrl}
|
||||||
|
alt={alt}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[getImageUrl]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>关于 NapCat WebUI</title>
|
<title>关于 NapCat WebUI</title>
|
||||||
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
|
<section className="max-w-7xl py-8 md:py-10 px-5 mx-auto space-y-10">
|
||||||
<div className="max-w-full w-[1000px] px-5 flex flex-col items-center">
|
<div className="w-full flex flex-col md:flex-row gap-4">
|
||||||
<div className="flex flex-col md:flex-row items-center mb-6">
|
<div className="flex flex-col md:flex-row items-center">
|
||||||
<HoverTiltedCard imageSrc={logo} />
|
<HoverTiltedCard imageSrc={logo} overlayContent="" />
|
||||||
</div>
|
</div>
|
||||||
<VersionInfo />
|
<div className="flex-1 flex flex-col gap-2 py-2">
|
||||||
<div className="mb-6 flex flex-col items-center gap-4">
|
<VersionInfo />
|
||||||
<p
|
<div className="space-y-1">
|
||||||
className={clsx(
|
<p className="font-bold text-danger-400">NapCat 是什么?</p>
|
||||||
title({
|
<p className="text-default-800">
|
||||||
color: 'cyan',
|
基于TypeScript构建的Bot框架,通过相应的启动器或者框架,主动调用QQ
|
||||||
shadow: true
|
Node模块提供给客户端的接口,实现Bot的功能.
|
||||||
}),
|
</p>
|
||||||
'!text-3xl'
|
<p className="font-bold text-danger-400">魔法版介绍</p>
|
||||||
)}
|
<p className="text-default-800">
|
||||||
>
|
猫猫框架通过魔法的手段获得了 QQ 的发送消息、接收消息等接口。
|
||||||
NapCat Contributors
|
为了方便使用,猫猫框架将通过一种名为 OneBot 的约定将你的 HTTP /
|
||||||
</p>
|
WebSocket 请求按照规范读取,
|
||||||
<Image
|
再去调用猫猫框架所获得的QQ发送接口之类的接口。
|
||||||
className="w-[600px] max-w-full pointer-events-none select-none"
|
</p>
|
||||||
src="https://contrib.rocks/image?repo=bietiaop/NapCatQQ"
|
</div>
|
||||||
alt="Contributors"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-2 flex-wrap justify-around">
|
||||||
|
<Card
|
||||||
|
as={Link}
|
||||||
|
shadow="sm"
|
||||||
|
isPressable
|
||||||
|
isExternal
|
||||||
|
href="https://qm.qq.com/q/F9cgs1N3Mc"
|
||||||
|
>
|
||||||
|
<CardBody className="flex-row items-center gap-2">
|
||||||
|
<span className="p-2 rounded-small bg-primary-50">
|
||||||
|
<BsTencentQq size={16} />
|
||||||
|
</span>
|
||||||
|
<span>官方社群1</span>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
as={Link}
|
||||||
|
shadow="sm"
|
||||||
|
isPressable
|
||||||
|
isExternal
|
||||||
|
href="https://qm.qq.com/q/hSt0u9PVn"
|
||||||
|
>
|
||||||
|
<CardBody className="flex-row items-center gap-2">
|
||||||
|
<span className="p-2 rounded-small bg-primary-50">
|
||||||
|
<BsTencentQq size={16} />
|
||||||
|
</span>
|
||||||
|
<span>官方社群2</span>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
as={Link}
|
||||||
|
shadow="sm"
|
||||||
|
isPressable
|
||||||
|
isExternal
|
||||||
|
href="https://t.me/MelodicMoonlight"
|
||||||
|
>
|
||||||
|
<CardBody className="flex-row items-center gap-2">
|
||||||
|
<span className="p-2 rounded-small bg-primary-50">
|
||||||
|
<BsTelegram size={16} />
|
||||||
|
</span>
|
||||||
|
<span>Telegram</span>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
as={Link}
|
||||||
|
shadow="sm"
|
||||||
|
isPressable
|
||||||
|
isExternal
|
||||||
|
href="https://napcat.napneko.icu/"
|
||||||
|
>
|
||||||
|
<CardBody className="flex-row items-center gap-2">
|
||||||
|
<span className="p-2 rounded-small bg-primary-50">
|
||||||
|
<IoDocument size={16} />
|
||||||
|
</span>
|
||||||
|
<span>使用文档</span>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col md:flex-row md:items-start gap-4">
|
||||||
|
<div className="w-full flex flex-col gap-4">
|
||||||
|
{renderImage(
|
||||||
|
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
|
||||||
|
'Contributors'
|
||||||
|
)}
|
||||||
|
{renderImage(
|
||||||
|
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
|
||||||
|
'Activity Trends'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<NapCatRepoInfo />
|
<NapCatRepoInfo />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@@ -15,7 +15,7 @@ import { useWebSocketDebug } from '@/hooks/use-websocket-debug'
|
|||||||
|
|
||||||
export default function WSDebug() {
|
export default function WSDebug() {
|
||||||
const url = new URL(window.location.origin)
|
const url = new URL(window.location.origin)
|
||||||
url.port = '3000'
|
url.port = '3001'
|
||||||
url.protocol = 'ws:'
|
url.protocol = 'ws:'
|
||||||
const defaultWsUrl = url.href
|
const defaultWsUrl = url.href
|
||||||
const [socketConfig, setSocketConfig] = useLocalStorage(key.wsDebugConfig, {
|
const [socketConfig, setSocketConfig] = useLocalStorage(key.wsDebugConfig, {
|
||||||
|
@@ -1,46 +1,35 @@
|
|||||||
|
import { Spinner } from '@heroui/spinner'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
import { Suspense } from 'react'
|
||||||
|
import { Outlet, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import DefaultLayout from '@/layouts/default'
|
import DefaultLayout from '@/layouts/default'
|
||||||
|
|
||||||
import DashboardIndexPage from './dashboard'
|
|
||||||
import AboutPage from './dashboard/about'
|
|
||||||
import ConfigPage from './dashboard/config'
|
|
||||||
import DebugPage from './dashboard/debug'
|
|
||||||
import HttpDebug from './dashboard/debug/http'
|
|
||||||
import WSDebug from './dashboard/debug/websocket'
|
|
||||||
import FileManagerPage from './dashboard/file_manager'
|
|
||||||
import LogsPage from './dashboard/logs'
|
|
||||||
import NetworkPage from './dashboard/network'
|
|
||||||
import TerminalPage from './dashboard/terminal'
|
|
||||||
|
|
||||||
export default function IndexPage() {
|
export default function IndexPage() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
return (
|
return (
|
||||||
<DefaultLayout>
|
<DefaultLayout>
|
||||||
<AnimatePresence mode="wait">
|
<Suspense
|
||||||
<motion.div
|
fallback={
|
||||||
key={location.pathname}
|
<div className="flex justify-center px-10">
|
||||||
initial={{ opacity: 0, y: 50 }}
|
<Spinner />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</div>
|
||||||
exit={{ opacity: 0, y: -50 }}
|
}
|
||||||
transition={{ duration: 0.3 }}
|
>
|
||||||
>
|
<AnimatePresence mode="wait">
|
||||||
<Routes location={location} key={location.pathname}>
|
<motion.div
|
||||||
<Route element={<DashboardIndexPage />} path="/" />
|
key={location.pathname}
|
||||||
<Route element={<NetworkPage />} path="/network" />
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<Route element={<ConfigPage />} path="/config" />
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<Route element={<LogsPage />} path="/logs" />
|
transition={{
|
||||||
<Route element={<DebugPage />} path="/debug">
|
type: 'tween',
|
||||||
<Route path="ws" element={<WSDebug />} />
|
ease: 'easeInOut'
|
||||||
<Route path="http" element={<HttpDebug />} />
|
}}
|
||||||
</Route>
|
>
|
||||||
<Route element={<FileManagerPage />} path="/file_manager" />
|
<Outlet />
|
||||||
<Route element={<TerminalPage />} path="/terminal" />
|
</motion.div>
|
||||||
<Route element={<AboutPage />} path="/about" />
|
</AnimatePresence>
|
||||||
</Routes>
|
</Suspense>
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
"name": "napcat",
|
"name": "napcat",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "4.5.1",
|
"version": "4.5.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
||||||
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
||||||
|
@@ -2,73 +2,73 @@ import path from 'node:path';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import type { NapCatCore } from '@/core';
|
import type { NapCatCore } from '@/core';
|
||||||
import json5 from 'json5';
|
import json5 from 'json5';
|
||||||
|
import Ajv, { AnySchema, ValidateFunction } from 'ajv';
|
||||||
|
|
||||||
export abstract class ConfigBase<T> {
|
export abstract class ConfigBase<T> {
|
||||||
name: string;
|
name: string;
|
||||||
core: NapCatCore;
|
core: NapCatCore;
|
||||||
configPath: string;
|
configPath: string;
|
||||||
configData: T = {} as T;
|
configData: T = {} as T;
|
||||||
|
ajv: Ajv;
|
||||||
|
validate: ValidateFunction<T>;
|
||||||
|
|
||||||
protected constructor(name: string, core: NapCatCore, configPath: string, copy_default: boolean = true) {
|
protected constructor(name: string, core: NapCatCore, configPath: string, ConfigSchema: AnySchema) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.core = core;
|
this.core = core;
|
||||||
this.configPath = configPath;
|
this.configPath = configPath;
|
||||||
|
this.ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||||
|
this.validate = this.ajv.compile<T>(ConfigSchema);
|
||||||
fs.mkdirSync(this.configPath, { recursive: true });
|
fs.mkdirSync(this.configPath, { recursive: true });
|
||||||
this.read(copy_default);
|
this.read();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getKeys(): string[] | null {
|
getConfigPath(pathName?: string): string {
|
||||||
// 决定 key 在json配置文件中的顺序
|
const filename = pathName ? `${this.name}_${pathName}.json` : `${this.name}.json`;
|
||||||
return null;
|
return path.join(this.configPath, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
getConfigPath(pathName: string | undefined): string {
|
read(): T {
|
||||||
if (!pathName) {
|
|
||||||
const filename = `${this.name}.json`;
|
|
||||||
const mainPath = this.core.context.pathWrapper.binaryPath;
|
|
||||||
return path.join(mainPath, 'config', filename);
|
|
||||||
} else {
|
|
||||||
const filename = `${this.name}_${pathName}.json`;
|
|
||||||
return path.join(this.configPath, filename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
read(copy_default: boolean = true): T {
|
|
||||||
|
|
||||||
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||||
if (!fs.existsSync(configPath) && copy_default) {
|
const defaultConfigPath = this.getConfigPath();
|
||||||
try {
|
if (!fs.existsSync(configPath)) {
|
||||||
fs.writeFileSync(configPath, fs.readFileSync(this.getConfigPath(undefined), 'utf-8'));
|
if (fs.existsSync(defaultConfigPath)) {
|
||||||
this.core.context.logger.log('[Core] [Config] 配置文件创建成功!\n');
|
this.configData = this.loadConfig(defaultConfigPath);
|
||||||
} catch (e: unknown) {
|
|
||||||
this.core.context.logger.logError('[Core] [Config] 创建配置文件时发生错误:', (e as Error).message);
|
|
||||||
}
|
}
|
||||||
} else if (!fs.existsSync(configPath) && !copy_default) {
|
this.save();
|
||||||
fs.writeFileSync(configPath, '{}');
|
return this.configData;
|
||||||
}
|
}
|
||||||
|
return this.loadConfig(configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfig(configPath: string): T {
|
||||||
try {
|
try {
|
||||||
this.configData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
|
let newConfigData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||||
|
this.validate(newConfigData);
|
||||||
|
this.configData = newConfigData;
|
||||||
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
|
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
|
||||||
return this.configData;
|
return this.configData;
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof SyntaxError) {
|
this.handleError(e, '读取配置文件时发生错误');
|
||||||
this.core.context.logger.logError('[Core] [Config] 配置文件格式错误,请检查配置文件:', e.message);
|
|
||||||
} else {
|
|
||||||
this.core.context.logger.logError('[Core] [Config] 读取配置文件时发生错误:', (e as Error).message);
|
|
||||||
}
|
|
||||||
return {} as T;
|
return {} as T;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
save(newConfigData: T = this.configData): void {
|
||||||
save(newConfigData: T = this.configData) {
|
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||||
const selfInfo = this.core.selfInfo;
|
this.validate(newConfigData);
|
||||||
this.configData = newConfigData;
|
this.configData = newConfigData;
|
||||||
const configPath = this.getConfigPath(selfInfo.uin);
|
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(configPath, JSON.stringify(newConfigData, this.getKeys(), 2));
|
fs.writeFileSync(configPath, JSON.stringify(this.configData, null, 2));
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
this.core.context.logger.logError(`保存配置文件 ${configPath} 时发生错误:`, (e as Error).message);
|
this.handleError(e, `保存配置文件 ${configPath} 时发生错误:`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private handleError(e: unknown, message: string): void {
|
||||||
|
if (e instanceof SyntaxError) {
|
||||||
|
this.core.context.logger.logError(`[Core] [Config] 操作配置文件格式错误,请检查配置文件:`, e.message);
|
||||||
|
} else {
|
||||||
|
this.core.context.logger.logError(`[Core] [Config] ${message}:`, (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1 +1 @@
|
|||||||
export const napCatVersion = '4.5.1';
|
export const napCatVersion = '4.5.4';
|
||||||
|
@@ -1,11 +1,21 @@
|
|||||||
import { ConfigBase } from '@/common/config-base';
|
import { ConfigBase } from '@/common/config-base';
|
||||||
import napCatDefaultConfig from '@/core/external/napcat.json';
|
|
||||||
import { NapCatCore } from '@/core';
|
import { NapCatCore } from '@/core';
|
||||||
|
import { Type, Static } from '@sinclair/typebox';
|
||||||
|
import { AnySchema } from 'ajv';
|
||||||
|
|
||||||
export type NapCatConfig = typeof napCatDefaultConfig;
|
export const NapcatConfigSchema = Type.Object({
|
||||||
|
fileLog: Type.Boolean({ default: false }),
|
||||||
|
consoleLog: Type.Boolean({ default: true }),
|
||||||
|
fileLogLevel: Type.String({ default: 'debug' }),
|
||||||
|
consoleLogLevel: Type.String({ default: 'info' }),
|
||||||
|
packetBackend: Type.String({ default: 'auto' }),
|
||||||
|
packetServer: Type.String({ default: '' })
|
||||||
|
});
|
||||||
|
|
||||||
export class NapCatConfigLoader extends ConfigBase<NapCatConfig> {
|
export type NapcatConfig = Static<typeof NapcatConfigSchema>;
|
||||||
constructor(core: NapCatCore, configPath: string) {
|
|
||||||
super('napcat', core, configPath);
|
export class NapCatConfigLoader extends ConfigBase<NapcatConfig> {
|
||||||
|
constructor(core: NapCatCore, configPath: string, schema: AnySchema) {
|
||||||
|
super('napcat', core, configPath, schema);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -25,7 +25,7 @@ import fs from 'node:fs';
|
|||||||
import { hostname, systemName, systemVersion } from '@/common/system';
|
import { hostname, systemName, systemVersion } from '@/common/system';
|
||||||
import { NTEventWrapper } from '@/common/event';
|
import { NTEventWrapper } from '@/common/event';
|
||||||
import { KickedOffLineInfo, SelfInfo, SelfStatusInfo } from '@/core/types';
|
import { KickedOffLineInfo, SelfInfo, SelfStatusInfo } from '@/core/types';
|
||||||
import { NapCatConfigLoader } from '@/core/helper/config';
|
import { NapCatConfigLoader, NapcatConfigSchema } from '@/core/helper/config';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/core/listeners';
|
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/core/listeners';
|
||||||
import { proxiedListenerOf } from '@/common/proxy-handler';
|
import { proxiedListenerOf } from '@/common/proxy-handler';
|
||||||
@@ -99,7 +99,7 @@ export class NapCatCore {
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
|
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
|
||||||
this.eventWrapper = new NTEventWrapper(context.session);
|
this.eventWrapper = new NTEventWrapper(context.session);
|
||||||
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath);
|
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath,NapcatConfigSchema);
|
||||||
this.apis = {
|
this.apis = {
|
||||||
FileApi: new NTQQFileApi(this.context, this),
|
FileApi: new NTQQFileApi(this.context, this),
|
||||||
SystemApi: new NTQQSystemApi(this.context, this),
|
SystemApi: new NTQQSystemApi(this.context, this),
|
||||||
|
@@ -857,7 +857,7 @@ export class OneBotMsgApi {
|
|||||||
return parsedElement;
|
return parsedElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [];
|
return;
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
|
@@ -78,7 +78,7 @@ const NetworkConfigSchema = Type.Object({
|
|||||||
plugins: Type.Array(PluginConfigSchema, { default: [] })
|
plugins: Type.Array(PluginConfigSchema, { default: [] })
|
||||||
}, { default: {} });
|
}, { default: {} });
|
||||||
|
|
||||||
const OneBotConfigSchema = Type.Object({
|
export const OneBotConfigSchema = Type.Object({
|
||||||
network: NetworkConfigSchema,
|
network: NetworkConfigSchema,
|
||||||
musicSignUrl: Type.String({ default: '' }),
|
musicSignUrl: Type.String({ default: '' }),
|
||||||
enableLocalFile2Url: Type.Boolean({ default: false }),
|
enableLocalFile2Url: Type.Boolean({ default: false }),
|
||||||
|
@@ -1,9 +1,10 @@
|
|||||||
import { ConfigBase } from '@/common/config-base';
|
import { ConfigBase } from '@/common/config-base';
|
||||||
import { NapCatCore } from '@/core';
|
import { NapCatCore } from '@/core';
|
||||||
import { OneBotConfig } from './config';
|
import { OneBotConfig } from './config';
|
||||||
|
import { AnySchema } from 'ajv';
|
||||||
|
|
||||||
export class OB11ConfigLoader extends ConfigBase<OneBotConfig> {
|
export class OB11ConfigLoader extends ConfigBase<OneBotConfig> {
|
||||||
constructor(core: NapCatCore, configPath: string) {
|
constructor(core: NapCatCore, configPath: string, schema: AnySchema) {
|
||||||
super('onebot11', core, configPath, false);
|
super('onebot11', core, configPath, schema);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -44,8 +44,8 @@ import { LRUCache } from '@/common/lru-cache';
|
|||||||
import { BotOfflineEvent } from './event/notice/BotOfflineEvent';
|
import { BotOfflineEvent } from './event/notice/BotOfflineEvent';
|
||||||
import {
|
import {
|
||||||
NetworkAdapterConfig,
|
NetworkAdapterConfig,
|
||||||
loadConfig,
|
|
||||||
OneBotConfig,
|
OneBotConfig,
|
||||||
|
OneBotConfigSchema,
|
||||||
} from './config/config';
|
} from './config/config';
|
||||||
import { OB11Message } from './types';
|
import { OB11Message } from './types';
|
||||||
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
|
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
|
||||||
@@ -66,9 +66,7 @@ export class NapCatOneBot11Adapter {
|
|||||||
constructor(core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
|
constructor(core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
|
||||||
this.core = core;
|
this.core = core;
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath);
|
this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath, OneBotConfigSchema);
|
||||||
this.configLoader.save(this.configLoader.configData);
|
|
||||||
this.configLoader.save(loadConfig(this.configLoader.configData));
|
|
||||||
this.apis = {
|
this.apis = {
|
||||||
GroupApi: new OneBotGroupApi(this, core),
|
GroupApi: new OneBotGroupApi(this, core),
|
||||||
UserApi: new OneBotUserApi(this, core),
|
UserApi: new OneBotUserApi(this, core),
|
||||||
@@ -176,9 +174,6 @@ export class NapCatOneBot11Adapter {
|
|||||||
WebUiDataRuntime.setQQLoginStatus(true);
|
WebUiDataRuntime.setQQLoginStatus(true);
|
||||||
WebUiDataRuntime.setOnOB11ConfigChanged(async (newConfig) => {
|
WebUiDataRuntime.setOnOB11ConfigChanged(async (newConfig) => {
|
||||||
const prev = this.configLoader.configData;
|
const prev = this.configLoader.configData;
|
||||||
// 保证默认配置
|
|
||||||
newConfig = loadConfig(newConfig);
|
|
||||||
|
|
||||||
this.configLoader.save(newConfig);
|
this.configLoader.save(newConfig);
|
||||||
//this.context.logger.log(`OneBot11 配置更改:${JSON.stringify(prev)} -> ${JSON.stringify(newConfig)}`);
|
//this.context.logger.log(`OneBot11 配置更改:${JSON.stringify(prev)} -> ${JSON.stringify(newConfig)}`);
|
||||||
await this.reloadNetwork(prev, newConfig);
|
await this.reloadNetwork(prev, newConfig);
|
||||||
|
@@ -15,7 +15,7 @@ import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
|
|||||||
import json5 from 'json5';
|
import json5 from 'json5';
|
||||||
|
|
||||||
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
|
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
|
||||||
wsServer: WebSocketServer;
|
wsServer?: WebSocketServer;
|
||||||
wsClients: WebSocket[] = [];
|
wsClients: WebSocket[] = [];
|
||||||
wsClientsMutex = new Mutex();
|
wsClientsMutex = new Mutex();
|
||||||
private heartbeatIntervalId: NodeJS.Timeout | null = null;
|
private heartbeatIntervalId: NodeJS.Timeout | null = null;
|
||||||
@@ -30,7 +30,11 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
|||||||
host: this.config.host === '0.0.0.0' ? '' : this.config.host,
|
host: this.config.host === '0.0.0.0' ? '' : this.config.host,
|
||||||
maxPayload: 1024 * 1024 * 1024,
|
maxPayload: 1024 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
this.wsServer.on('connection', async (wsClient, wsReq) => {
|
this.createServer(this.wsServer);
|
||||||
|
|
||||||
|
}
|
||||||
|
createServer(newServer: WebSocketServer) {
|
||||||
|
newServer.on('connection', async (wsClient, wsReq) => {
|
||||||
if (!this.isEnable) {
|
if (!this.isEnable) {
|
||||||
wsClient.close();
|
wsClient.close();
|
||||||
return;
|
return;
|
||||||
@@ -40,7 +44,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
|||||||
const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url;
|
const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url;
|
||||||
const isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
|
const isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
|
||||||
if (!isApiConnect) {
|
if (!isApiConnect) {
|
||||||
this.connectEvent(core, wsClient);
|
this.connectEvent(this.core, wsClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
wsClient.on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Client Error:', err.message));
|
wsClient.on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Client Error:', err.message));
|
||||||
@@ -74,7 +78,6 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
|||||||
});
|
});
|
||||||
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
|
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
connectEvent(core: NapCatCore, wsClient: WebSocket) {
|
connectEvent(core: NapCatCore, wsClient: WebSocket) {
|
||||||
try {
|
try {
|
||||||
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient);
|
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient);
|
||||||
@@ -96,7 +99,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
|||||||
this.logger.logError('[OneBot] [WebSocket Server] Cannot open a opened WebSocket server');
|
this.logger.logError('[OneBot] [WebSocket Server] Cannot open a opened WebSocket server');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const addressInfo = this.wsServer.address();
|
const addressInfo = this.wsServer?.address();
|
||||||
this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port);
|
this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port);
|
||||||
|
|
||||||
this.isEnable = true;
|
this.isEnable = true;
|
||||||
@@ -108,7 +111,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
|||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
this.isEnable = false;
|
this.isEnable = false;
|
||||||
this.wsServer.close((err) => {
|
this.wsServer?.close((err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
this.logger.logError('[OneBot] [WebSocket Server] Error closing server:', err.message);
|
this.logger.logError('[OneBot] [WebSocket Server] Error closing server:', err.message);
|
||||||
} else {
|
} else {
|
||||||
@@ -205,6 +208,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
|||||||
host: newConfig.host === '0.0.0.0' ? '' : newConfig.host,
|
host: newConfig.host === '0.0.0.0' ? '' : newConfig.host,
|
||||||
maxPayload: 1024 * 1024 * 1024,
|
maxPayload: 1024 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
this.createServer(this.wsServer);
|
||||||
if (newConfig.enable) {
|
if (newConfig.enable) {
|
||||||
this.open();
|
this.open();
|
||||||
}
|
}
|
||||||
|
@@ -268,7 +268,11 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
|
|||||||
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
|
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
|
||||||
export const DownloadHandler: RequestHandler = async (req, res) => {
|
export const DownloadHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const filePath = normalizePath(req.query['path'] as string);
|
const filePath = normalizePath( req.query[ 'path' ] as string );
|
||||||
|
if (!filePath) {
|
||||||
|
return sendError( res, '参数错误' );
|
||||||
|
}
|
||||||
|
|
||||||
const stat = await fsProm.stat(filePath);
|
const stat = await fsProm.stat(filePath);
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/octet-stream');
|
res.setHeader('Content-Type', 'application/octet-stream');
|
||||||
|
Reference in New Issue
Block a user