style: 调整样式

This commit is contained in:
bietiaop
2025-02-04 17:58:38 +08:00
parent 26734a35ef
commit fbde997f7c
7 changed files with 557 additions and 80 deletions

View File

@@ -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>
) )
} }

View 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

View File

@@ -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" />}

View File

@@ -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',

View 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 }
}

View File

@@ -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>

View File

@@ -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>
) )
} }