diff --git a/napcat.webui/src/App.tsx b/napcat.webui/src/App.tsx index 968ff6fe..9db7243d 100644 --- a/napcat.webui/src/App.tsx +++ b/napcat.webui/src/App.tsx @@ -16,6 +16,16 @@ 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 ( @@ -58,9 +68,21 @@ function AuthChecker({ children }: { children: React.ReactNode }) { function AppRoutes() { return ( - } path="/*" /> - } path="/qq_login" /> - } path="/web_login" /> + }> + } /> + } /> + } /> + } /> + }> + } /> + } /> + + } /> + } /> + } /> + + } /> + } /> ) } diff --git a/napcat.webui/src/components/rotating_text.tsx b/napcat.webui/src/components/rotating_text.tsx new file mode 100644 index 00000000..a50c35be --- /dev/null +++ b/napcat.webui/src/components/rotating_text.tsx @@ -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( + ( + { + 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(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 ( + + {texts[currentTextIndex]} + + + + + ) + } +) + +RotatingText.displayName = 'RotatingText' +export default RotatingText diff --git a/napcat.webui/src/components/system_info.tsx b/napcat.webui/src/components/system_info.tsx index b7de2388..db46de32 100644 --- a/napcat.webui/src/components/system_info.tsx +++ b/napcat.webui/src/components/system_info.tsx @@ -16,7 +16,6 @@ import { compareVersion } from '@/utils/version' import WebUIManager from '@/controllers/webui_manager' import { GithubRelease } from '@/types/github' -import packageJson from '../../package.json' import TailwindMarkdown from './tailwind_markdown' export interface SystemInfoItemProps { @@ -198,11 +197,6 @@ const SystemInfo: React.FC = (props) => {
- } - value={packageJson.version} - /> } @@ -216,6 +210,11 @@ const SystemInfo: React.FC = (props) => { ) } /> + } + value="Next" + /> } diff --git a/napcat.webui/src/components/xterm.tsx b/napcat.webui/src/components/xterm.tsx index 47e4ef37..617a8838 100644 --- a/napcat.webui/src/components/xterm.tsx +++ b/napcat.webui/src/components/xterm.tsx @@ -99,7 +99,7 @@ const XTerm = forwardRef((props, ref) => { if (theme === 'dark') { terminalRef.current.options.theme = { background: '#00000000', - black: '#000000', + black: '#ffffff', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510', diff --git a/napcat.webui/src/hooks/use-preload-images.ts b/napcat.webui/src/hooks/use-preload-images.ts new file mode 100644 index 00000000..8df3dc97 --- /dev/null +++ b/napcat.webui/src/hooks/use-preload-images.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from 'react' + +// 全局图片缓存 +const imageCache = new Map() + +export function usePreloadImages(urls: string[]) { + const [loadedUrls, setLoadedUrls] = useState>({}) + 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 = {} + 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 } +} diff --git a/napcat.webui/src/pages/dashboard/about.tsx b/napcat.webui/src/pages/dashboard/about.tsx index 652d16c9..4a56e2c1 100644 --- a/napcat.webui/src/pages/dashboard/about.tsx +++ b/napcat.webui/src/pages/dashboard/about.tsx @@ -1,12 +1,18 @@ -import { Chip } from '@heroui/chip' +import { Card, CardBody } from '@heroui/card' import { Image } from '@heroui/image' +import { Link } from '@heroui/link' import { Spinner } from '@heroui/spinner' 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 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 WebUIManager from '@/controllers/webui_manager' @@ -14,54 +20,181 @@ import WebUIManager from '@/controllers/webui_manager' function VersionInfo() { const { data, loading, error } = useRequest(WebUIManager.getPackageInfo) return ( -
- - NapCat - - } - > +
+
+
NapCat
{error ? ( error.message ) : loading ? ( ) : ( - data?.version + )} - +
) } 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 ( +
+ +
+ ) + } + + return ( + {alt} + ) + }, + [getImageUrl] + ) + return ( <> 关于 NapCat WebUI -
-
-
- +
+
+
+
- -
-

- NapCat Contributors -

- Contributors +
+ +
+

NapCat 是什么?

+

+ 基于TypeScript构建的Bot框架,通过相应的启动器或者框架,主动调用QQ + Node模块提供给客户端的接口,实现Bot的功能. +

+

魔法版介绍

+

+ 猫猫框架通过魔法的手段获得了 QQ 的发送消息、接收消息等接口。 + 为了方便使用,猫猫框架将通过一种名为 OneBot 的约定将你的 HTTP / + WebSocket 请求按照规范读取, + 再去调用猫猫框架所获得的QQ发送接口之类的接口。 +

+
+
+
+ + + + + + 官方社群1 + + + + + + + + + 官方社群2 + + + + + + + + + Telegram + + + + + + + + + 使用文档 + + +
+
+
+ {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' + )} +
+
diff --git a/napcat.webui/src/pages/index.tsx b/napcat.webui/src/pages/index.tsx index 8a94ec5a..88ebec7f 100644 --- a/napcat.webui/src/pages/index.tsx +++ b/napcat.webui/src/pages/index.tsx @@ -1,46 +1,35 @@ +import { Spinner } from '@heroui/spinner' 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 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() { const location = useLocation() return ( - - - - } path="/" /> - } path="/network" /> - } path="/config" /> - } path="/logs" /> - } path="/debug"> - } /> - } /> - - } path="/file_manager" /> - } path="/terminal" /> - } path="/about" /> - - - + + +
+ } + > + + + + + + ) }