mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-01-03 00:19:05 +08:00
Remove music player and related context/hooks
Deleted the audio player component, songs context, and use-music hook, along with all related code and configuration. Updated affected components and pages to remove music player dependencies and UI. Also improved sidebar, background, and about page UI, and refactored site config icons to use react-icons.
This commit is contained in:
parent
176af14915
commit
4fcbdc4d89
@ -7,7 +7,6 @@ import PageLoading from '@/components/page_loading';
|
||||
import Toaster from '@/components/toaster';
|
||||
|
||||
import DialogProvider from '@/contexts/dialog';
|
||||
import AudioProvider from '@/contexts/songs';
|
||||
|
||||
import useAuth from '@/hooks/auth';
|
||||
|
||||
@ -33,13 +32,11 @@ function App () {
|
||||
<Provider store={store}>
|
||||
<PageBackground />
|
||||
<Toaster />
|
||||
<AudioProvider>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<AuthChecker>
|
||||
<AppRoutes />
|
||||
</AuthChecker>
|
||||
</Suspense>
|
||||
</AudioProvider>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<AuthChecker>
|
||||
<AppRoutes />
|
||||
</AuthChecker>
|
||||
</Suspense>
|
||||
</Provider>
|
||||
</DialogProvider>
|
||||
);
|
||||
|
||||
@ -1,425 +0,0 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Slider } from '@heroui/slider';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BiSolidSkipNextCircle,
|
||||
BiSolidSkipPreviousCircle,
|
||||
} from 'react-icons/bi';
|
||||
import {
|
||||
FaPause,
|
||||
FaPlay,
|
||||
FaRegHandPointRight,
|
||||
FaRepeat,
|
||||
FaShuffle,
|
||||
} from 'react-icons/fa6';
|
||||
import { TbRepeatOnce } from 'react-icons/tb';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import { PlayMode } from '@/const/enum';
|
||||
import key from '@/const/key';
|
||||
|
||||
import { VolumeHighIcon, VolumeLowIcon } from './icons';
|
||||
|
||||
export interface AudioPlayerProps
|
||||
extends React.AudioHTMLAttributes<HTMLAudioElement> {
|
||||
src: string
|
||||
title?: string
|
||||
artist?: string
|
||||
cover?: string
|
||||
pressNext?: () => void
|
||||
pressPrevious?: () => void
|
||||
onPlayEnd?: () => void
|
||||
onChangeMode?: (mode: PlayMode) => void
|
||||
mode?: PlayMode
|
||||
}
|
||||
|
||||
export default function AudioPlayer (props: AudioPlayerProps) {
|
||||
const {
|
||||
src,
|
||||
pressNext,
|
||||
pressPrevious,
|
||||
cover = 'https://nextui.org/images/album-cover.png',
|
||||
title = '未知',
|
||||
artist = '未知',
|
||||
onTimeUpdate,
|
||||
onLoadedData,
|
||||
onPlay,
|
||||
onPause,
|
||||
onPlayEnd,
|
||||
onChangeMode,
|
||||
autoPlay,
|
||||
mode = PlayMode.Loop,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(100);
|
||||
const [isCollapsed, setIsCollapsed] = useLocalStorage(
|
||||
key.isCollapsedMusicPlayer,
|
||||
false
|
||||
);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const startY = useRef(0);
|
||||
const startX = useRef(0);
|
||||
const [translateY, setTranslateY] = useState(0);
|
||||
const [translateX, setTranslateX] = useState(0);
|
||||
const isSmallScreen = useMediaQuery({ maxWidth: 767 });
|
||||
const isMediumUp = useMediaQuery({ minWidth: 768 });
|
||||
const shouldAdd = useRef(false);
|
||||
const currentProgress = (currentTime / duration) * 100;
|
||||
const [storageAutoPlay, setStorageAutoPlay] = useLocalStorage(
|
||||
key.autoPlay,
|
||||
true
|
||||
);
|
||||
|
||||
const handleTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
const audio = event.target as HTMLAudioElement;
|
||||
setCurrentTime(audio.currentTime);
|
||||
onTimeUpdate?.(event);
|
||||
};
|
||||
|
||||
const handleLoadedData = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
const audio = event.target as HTMLAudioElement;
|
||||
setDuration(audio.duration);
|
||||
onLoadedData?.(event);
|
||||
};
|
||||
|
||||
const handlePlay = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
setIsPlaying(true);
|
||||
setStorageAutoPlay(true);
|
||||
onPlay?.(e);
|
||||
};
|
||||
|
||||
const handlePause = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
setIsPlaying(false);
|
||||
onPause?.(e);
|
||||
};
|
||||
|
||||
const changeMode = () => {
|
||||
const modes = [PlayMode.Loop, PlayMode.Random, PlayMode.Single];
|
||||
const currentIndex = modes.findIndex((_mode) => _mode === mode);
|
||||
const nextIndex = currentIndex + 1;
|
||||
const nextMode = modes[nextIndex] || modes[0];
|
||||
onChangeMode?.(nextMode);
|
||||
};
|
||||
|
||||
const volumeChange = (value: number) => {
|
||||
setVolume(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.volume = volume / 100;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
startY.current = e.touches[0].clientY;
|
||||
startX.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
const deltaY = e.touches[0].clientY - startY.current;
|
||||
const deltaX = e.touches[0].clientX - startX.current;
|
||||
const container = cardRef.current;
|
||||
const header = cardRef.current?.querySelector('[data-header]');
|
||||
const headerHeight = header?.clientHeight || 20;
|
||||
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
|
||||
const _shouldAdd = isCollapsed && deltaY < 0;
|
||||
if (isSmallScreen) {
|
||||
shouldAdd.current = _shouldAdd;
|
||||
setTranslateY(_shouldAdd ? deltaY + addHeight : deltaY);
|
||||
} else {
|
||||
setTranslateX(deltaX);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (isSmallScreen) {
|
||||
const container = cardRef.current;
|
||||
const header = cardRef.current?.querySelector('[data-header]');
|
||||
const headerHeight = header?.clientHeight || 20;
|
||||
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
|
||||
const _translateY = translateY - (shouldAdd.current ? addHeight : 0);
|
||||
if (_translateY > 100) {
|
||||
setIsCollapsed(true);
|
||||
} else if (_translateY < -100) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
setTranslateY(0);
|
||||
} else {
|
||||
if (translateX > 100) {
|
||||
setIsCollapsed(true);
|
||||
} else if (translateX < -100) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
setTranslateX(0);
|
||||
}
|
||||
};
|
||||
|
||||
const dragTranslate = isSmallScreen
|
||||
? translateY
|
||||
? `translateY(${translateY}px)`
|
||||
: ''
|
||||
: translateX
|
||||
? `translateX(${translateX}px)`
|
||||
: '';
|
||||
const collapsedTranslate = isCollapsed
|
||||
? isSmallScreen
|
||||
? 'translateY(90%)'
|
||||
: 'translateX(96%)'
|
||||
: '';
|
||||
|
||||
const translateStyle = dragTranslate || collapsedTranslate;
|
||||
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed right-0 bottom-0 z-[52] w-full md:w-96',
|
||||
!translateX && !translateY && 'transition-transform',
|
||||
isCollapsed && 'md:hover:!translate-x-80'
|
||||
)}
|
||||
style={{
|
||||
transform: translateStyle,
|
||||
}}
|
||||
>
|
||||
<audio
|
||||
src={src}
|
||||
onLoadedData={handleLoadedData}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={onPlayEnd}
|
||||
autoPlay={autoPlay ?? storageAutoPlay}
|
||||
{...rest}
|
||||
controls={false}
|
||||
hidden
|
||||
ref={audioRef}
|
||||
/>
|
||||
|
||||
<Card
|
||||
ref={cardRef}
|
||||
className={clsx(
|
||||
'border-none bg-background/60 dark:bg-default-300/50 w-full max-w-full transform transition-transform backdrop-blur-md duration-300 overflow-visible',
|
||||
isSmallScreen ? 'rounded-t-3xl' : 'md:rounded-l-xl'
|
||||
)}
|
||||
classNames={{
|
||||
body: 'p-0',
|
||||
}}
|
||||
shadow='sm'
|
||||
radius='none'
|
||||
>
|
||||
{isMediumUp && (
|
||||
<Button
|
||||
isIconOnly
|
||||
className={clsx(
|
||||
'absolute data-[hover]:bg-foreground/10 text-lg z-50',
|
||||
isCollapsed
|
||||
? 'top-0 left-0 w-full h-full rounded-xl bg-opacity-0 hover:bg-opacity-30'
|
||||
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
|
||||
)}
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='sm'
|
||||
onPress={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<FaRegHandPointRight />
|
||||
</Button>
|
||||
)}
|
||||
{isSmallScreen && (
|
||||
<CardHeader
|
||||
data-header
|
||||
className='flex-row justify-center pt-4'
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<div className='w-24 h-2 rounded-full bg-content2-foreground shadow-sm' />
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardBody>
|
||||
<div className='grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0'>
|
||||
<div className='relative col-span-6 md:col-span-4 flex justify-center'>
|
||||
<Image
|
||||
alt='Album cover'
|
||||
className='object-cover'
|
||||
classNames={{
|
||||
wrapper: 'w-36 aspect-square md:w-24 flex',
|
||||
img: 'block w-full h-full',
|
||||
}}
|
||||
shadow='md'
|
||||
src={cover}
|
||||
width='100%'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col col-span-6 md:col-span-8'>
|
||||
<div className='flex flex-col gap-0'>
|
||||
<h1 className='font-medium truncate'>{title}</h1>
|
||||
<p className='text-xs text-foreground/80 truncate'>{artist}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<Slider
|
||||
aria-label='Music progress'
|
||||
classNames={{
|
||||
track: 'bg-default-500/30 border-none',
|
||||
thumb: 'w-2 h-2 after:w-1.5 after:h-1.5',
|
||||
filler: 'rounded-full',
|
||||
}}
|
||||
color='foreground'
|
||||
value={currentProgress || 0}
|
||||
defaultValue={0}
|
||||
size='sm'
|
||||
onChange={(value) => {
|
||||
value = Array.isArray(value) ? value[0] : value;
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.currentTime = (value / 100) * duration;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className='flex justify-between h-3'>
|
||||
<p className='text-xs'>
|
||||
{Math.floor(currentTime / 60)}:
|
||||
{Math.floor(currentTime % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}
|
||||
</p>
|
||||
<p className='text-xs text-foreground/50'>
|
||||
{Math.floor(duration / 60)}:
|
||||
{Math.floor(duration % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex w-full items-center justify-center'>
|
||||
<Tooltip
|
||||
content={
|
||||
mode === PlayMode.Loop
|
||||
? '列表循环'
|
||||
: mode === PlayMode.Random
|
||||
? '随机播放'
|
||||
: '单曲循环'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-lg md:text-medium'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='md'
|
||||
onPress={changeMode}
|
||||
>
|
||||
{mode === PlayMode.Loop && (
|
||||
<FaRepeat className='text-foreground/80' />
|
||||
)}
|
||||
{mode === PlayMode.Random && (
|
||||
<FaShuffle className='text-foreground/80' />
|
||||
)}
|
||||
{mode === PlayMode.Single && (
|
||||
<TbRepeatOnce className='text-foreground/80 text-xl' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content='上一首'>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='md'
|
||||
onPress={pressPrevious}
|
||||
>
|
||||
<BiSolidSkipPreviousCircle />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={isPlaying ? '暂停' : '播放'}>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-3xl md:text-3xl'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='lg'
|
||||
onPress={() => {
|
||||
if (isPlaying) {
|
||||
audioRef.current?.pause();
|
||||
setStorageAutoPlay(false);
|
||||
} else {
|
||||
audioRef.current?.play();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <FaPause /> : <FaPlay className='ml-1' />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content='下一首'>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='md'
|
||||
onPress={pressNext}
|
||||
>
|
||||
<BiSolidSkipNextCircle />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
placement='top'
|
||||
classNames={{
|
||||
content: 'bg-opacity-30 backdrop-blur-md',
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-xl md:text-xl'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='md'
|
||||
>
|
||||
<VolumeHighIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Slider
|
||||
orientation='vertical'
|
||||
showTooltip
|
||||
aria-label='Volume'
|
||||
className='h-40'
|
||||
color='primary'
|
||||
defaultValue={volume}
|
||||
onChange={(value) => {
|
||||
value = Array.isArray(value) ? value[0] : value;
|
||||
volumeChange(value);
|
||||
}}
|
||||
startContent={<VolumeHighIcon className='text-2xl' />}
|
||||
size='sm'
|
||||
endContent={<VolumeLowIcon className='text-2xl' />}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -94,7 +94,7 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
|
||||
ref={lightRef}
|
||||
className={clsx(
|
||||
isShowLight ? 'opacity-100' : 'opacity-0',
|
||||
'absolute rounded-full blur-[150px] filter transition-opacity duration-300 dark:bg-[#2850ff] bg-[#ff4132] w-[100px] h-[100px]',
|
||||
'absolute rounded-full blur-[100px] filter transition-opacity duration-300 bg-gradient-to-r from-primary-400 to-secondary-400 w-[150px] h-[150px]',
|
||||
lightClassName
|
||||
)}
|
||||
style={{
|
||||
|
||||
@ -1,23 +1,37 @@
|
||||
import { Image } from '@heroui/image';
|
||||
|
||||
import bkg_color from '@/assets/images/bkg-color.png';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const PageBackground = () => {
|
||||
return (
|
||||
<>
|
||||
<div className='fixed w-full h-full -z-[0] flex justify-end opacity-80'>
|
||||
<Image
|
||||
className='overflow-hidden object-contain -top-42 h-[160%] -right-[30%] -rotate-45 pointer-events-none select-none -z-10 relative'
|
||||
src={bkg_color}
|
||||
/>
|
||||
</div>
|
||||
<div className='fixed w-full h-full overflow-hidden -z-[0] hue-rotate-90 flex justify-start opacity-80'>
|
||||
<Image
|
||||
className='relative -top-92 h-[180%] object-contain pointer-events-none rotate-90 select-none -z-10 top-44'
|
||||
src={bkg_color}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<div className='fixed inset-0 w-full h-full -z-10 overflow-hidden bg-gradient-to-br from-indigo-50 via-white to-pink-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900'>
|
||||
{/* 动态呼吸光斑 - ACG风格 */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
rotate: [0, 90, 0],
|
||||
opacity: [0.3, 0.5, 0.3]
|
||||
}}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
|
||||
className='absolute top-[-10%] left-[-10%] w-[500px] h-[500px] rounded-full bg-primary-200/40 blur-[100px]'
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
x: [0, 100, 0],
|
||||
opacity: [0.3, 0.6, 0.3]
|
||||
}}
|
||||
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut", delay: 2 }}
|
||||
className='absolute top-[20%] right-[-10%] w-[400px] h-[400px] rounded-full bg-secondary-200/40 blur-[90px]'
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
y: [0, -50, 0],
|
||||
opacity: [0.2, 0.4, 0.2]
|
||||
}}
|
||||
transition={{ duration: 12, repeat: Infinity, ease: "easeInOut", delay: 5 }}
|
||||
className='absolute bottom-[-10%] left-[20%] w-[600px] h-[600px] rounded-full bg-pink-200/30 blur-[110px]'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Image } from '@heroui/image';
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import React from 'react';
|
||||
@ -10,15 +9,13 @@ import useAuth from '@/hooks/auth';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import type { MenuItem } from '@/config/site';
|
||||
|
||||
import Menus from './menus';
|
||||
|
||||
interface SideBarProps {
|
||||
open: boolean
|
||||
items: MenuItem[]
|
||||
onClose?: () => void
|
||||
open: boolean;
|
||||
items: MenuItem[];
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const SideBar: React.FC<SideBarProps> = (props) => {
|
||||
@ -61,40 +58,33 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
||||
}}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<motion.div className='w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right'>
|
||||
<div className='flex justify-center items-center my-2 gap-2'>
|
||||
<Image radius='none' height={40} src={logo} className='mb-2' />
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center font-bold',
|
||||
'!text-2xl shiny-text'
|
||||
)}
|
||||
>
|
||||
<motion.div className='w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right p-4'>
|
||||
<div className='flex items-center justify-start gap-3 px-2 my-8 ml-2'>
|
||||
<div className="h-5 w-1 bg-primary rounded-full shadow-sm" />
|
||||
<div className="text-xl font-bold text-default-900 dark:text-white tracking-wide select-none">
|
||||
NapCat
|
||||
</div>
|
||||
</div>
|
||||
<div className='overflow-y-auto flex flex-col flex-1 px-4'>
|
||||
<div className='overflow-y-auto flex flex-col flex-1 px-2'>
|
||||
<Menus items={items} />
|
||||
<div className='mt-auto mb-10 md:mb-0'>
|
||||
<div className='mt-auto mb-10 md:mb-0 space-y-3 px-2'>
|
||||
<Button
|
||||
className='w-full'
|
||||
color='primary'
|
||||
className='w-full bg-primary-50/50 hover:bg-primary-100/80 text-primary-600 font-medium shadow-sm hover:shadow-md transition-all duration-300 backdrop-blur-sm'
|
||||
radius='full'
|
||||
variant='light'
|
||||
variant='flat'
|
||||
onPress={toggleTheme}
|
||||
startContent={
|
||||
!isDark ? <MdLightMode size={16} /> : <MdDarkMode size={16} />
|
||||
!isDark ? <MdLightMode size={18} /> : <MdDarkMode size={18} />
|
||||
}
|
||||
>
|
||||
切换主题
|
||||
</Button>
|
||||
<Button
|
||||
className='w-full mb-2'
|
||||
color='primary'
|
||||
className='w-full mb-2 bg-danger-50/50 hover:bg-danger-100/80 text-danger-500 font-medium shadow-sm hover:shadow-md transition-all duration-300 backdrop-blur-sm'
|
||||
radius='full'
|
||||
variant='light'
|
||||
variant='flat'
|
||||
onPress={onRevokeAuth}
|
||||
startContent={<IoMdLogOut size={16} />}
|
||||
startContent={<IoMdLogOut size={18} />}
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
|
||||
@ -50,12 +50,13 @@ const renderItems = (items: MenuItem[], children = false) => {
|
||||
<div key={item.href + item.label}>
|
||||
<Button
|
||||
className={clsx(
|
||||
'flex items-center w-full text-left justify-start dark:text-white',
|
||||
// children && 'rounded-l-lg',
|
||||
isActive && 'bg-opacity-60',
|
||||
'flex items-center w-full text-left justify-start dark:text-white transition-all duration-300',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-400 shadow-none font-semibold translate-x-1'
|
||||
: 'hover:bg-default-100 hover:translate-x-1',
|
||||
b64img && 'backdrop-blur-md text-white'
|
||||
)}
|
||||
color='primary'
|
||||
color={isActive ? 'primary' : 'default'}
|
||||
endContent={
|
||||
canOpen
|
||||
? (
|
||||
@ -104,7 +105,6 @@ const renderItems = (items: MenuItem[], children = false) => {
|
||||
/>
|
||||
)
|
||||
}
|
||||
radius='full'
|
||||
startContent={
|
||||
customIcons[item.label]
|
||||
? (
|
||||
@ -147,7 +147,7 @@ const renderItems = (items: MenuItem[], children = false) => {
|
||||
};
|
||||
|
||||
interface MenusProps {
|
||||
items: MenuItem[]
|
||||
items: MenuItem[];
|
||||
}
|
||||
const Menus: React.FC<MenusProps> = (props) => {
|
||||
const { items } = props;
|
||||
|
||||
@ -1,107 +1,72 @@
|
||||
import {
|
||||
BugIcon2,
|
||||
FileIcon,
|
||||
InfoIcon,
|
||||
LogIcon,
|
||||
RouteIcon,
|
||||
SettingsIcon,
|
||||
SignalTowerIcon,
|
||||
TerminalIcon,
|
||||
} from '@/components/icons';
|
||||
LuActivity,
|
||||
LuFileText,
|
||||
LuFolderOpen,
|
||||
LuInfo,
|
||||
LuLayoutDashboard,
|
||||
LuSettings,
|
||||
LuSignal,
|
||||
LuTerminal,
|
||||
LuZap,
|
||||
} from 'react-icons/lu';
|
||||
|
||||
export type SiteConfig = typeof siteConfig;
|
||||
export interface MenuItem {
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
autoOpen?: boolean
|
||||
href?: string
|
||||
items?: MenuItem[]
|
||||
customIcon?: string
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
autoOpen?: boolean;
|
||||
href?: string;
|
||||
items?: MenuItem[];
|
||||
customIcon?: string;
|
||||
}
|
||||
|
||||
export const siteConfig = {
|
||||
name: 'NapCat WebUI',
|
||||
name: 'NapCat',
|
||||
description: 'NapCat WebUI.',
|
||||
navItems: [
|
||||
{
|
||||
label: '基础信息',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<RouteIcon />
|
||||
</div>
|
||||
),
|
||||
icon: <LuLayoutDashboard className='w-5 h-5' />,
|
||||
href: '/',
|
||||
},
|
||||
{
|
||||
label: '网络配置',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<SignalTowerIcon />
|
||||
</div>
|
||||
),
|
||||
icon: <LuSignal className='w-5 h-5' />,
|
||||
href: '/network',
|
||||
},
|
||||
{
|
||||
label: '其他配置',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<SettingsIcon />
|
||||
</div>
|
||||
),
|
||||
icon: <LuSettings className='w-5 h-5' />,
|
||||
href: '/config',
|
||||
},
|
||||
{
|
||||
label: '猫猫日志',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<LogIcon />
|
||||
</div>
|
||||
),
|
||||
icon: <LuFileText className='w-5 h-5' />,
|
||||
href: '/logs',
|
||||
},
|
||||
{
|
||||
label: '接口调试',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<BugIcon2 />
|
||||
</div>
|
||||
),
|
||||
items: [
|
||||
{
|
||||
label: 'HTTP',
|
||||
href: '/debug/http',
|
||||
},
|
||||
{
|
||||
label: 'Websocket',
|
||||
href: '/debug/ws',
|
||||
},
|
||||
],
|
||||
icon: <LuActivity className='w-5 h-5' />,
|
||||
href: '/debug/http',
|
||||
},
|
||||
{
|
||||
label: '实时调试',
|
||||
icon: <LuZap className='w-5 h-5' />,
|
||||
href: '/debug/ws',
|
||||
},
|
||||
{
|
||||
label: '文件管理',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<FileIcon />
|
||||
</div>
|
||||
),
|
||||
icon: <LuFolderOpen className='w-5 h-5' />,
|
||||
href: '/file_manager',
|
||||
},
|
||||
{
|
||||
label: '系统终端',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<TerminalIcon />
|
||||
</div>
|
||||
),
|
||||
icon: <LuTerminal className='w-5 h-5' />,
|
||||
href: '/terminal',
|
||||
},
|
||||
{
|
||||
label: '关于我们',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<InfoIcon />
|
||||
</div>
|
||||
),
|
||||
icon: <LuInfo className='w-5 h-5' />,
|
||||
href: '/about',
|
||||
},
|
||||
] as MenuItem[],
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
// Songs Context
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { createContext, useEffect, useState } from 'react';
|
||||
|
||||
import { PlayMode } from '@/const/enum';
|
||||
import key from '@/const/key';
|
||||
|
||||
import AudioPlayer from '@/components/audio_player';
|
||||
|
||||
import { get163MusicListSongs, getNextMusic } from '@/utils/music';
|
||||
|
||||
import type { FinalMusic } from '@/types/music';
|
||||
|
||||
export interface MusicContextProps {
|
||||
setListId: (id: string) => void
|
||||
listId: string
|
||||
onNext: () => void
|
||||
onPrevious: () => void
|
||||
}
|
||||
|
||||
export interface MusicProviderProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const AudioContext = createContext<MusicContextProps>({
|
||||
setListId: () => {},
|
||||
listId: '5438670983',
|
||||
onNext: () => {},
|
||||
onPrevious: () => {},
|
||||
});
|
||||
|
||||
const AudioProvider: React.FC<MusicProviderProps> = ({ children }) => {
|
||||
const [listId, setListId] = useLocalStorage(key.musicID, '5438670983');
|
||||
const [musicList, setMusicList] = useState<FinalMusic[]>([]);
|
||||
const [musicId, setMusicId] = useState<number>(0);
|
||||
const [playMode, setPlayMode] = useState<PlayMode>(PlayMode.Loop);
|
||||
const music = musicList.find((music) => music.id === musicId);
|
||||
const [token] = useLocalStorage(key.token, '');
|
||||
const onNext = () => {
|
||||
const nextID = getNextMusic(musicList, musicId, playMode);
|
||||
setMusicId(nextID);
|
||||
};
|
||||
const onPrevious = () => {
|
||||
const index = musicList.findIndex((music) => music.id === musicId);
|
||||
if (index === 0) {
|
||||
setMusicId(musicList[musicList.length - 1].id);
|
||||
} else {
|
||||
setMusicId(musicList[index - 1].id);
|
||||
}
|
||||
};
|
||||
const onPlayEnd = () => {
|
||||
const nextID = getNextMusic(musicList, musicId, playMode);
|
||||
setMusicId(nextID);
|
||||
};
|
||||
const changeMode = (mode: PlayMode) => {
|
||||
setPlayMode(mode);
|
||||
};
|
||||
const fetchMusicList = async (id: string) => {
|
||||
const res = await get163MusicListSongs(id);
|
||||
setMusicList(res);
|
||||
setMusicId(res[0].id);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (listId && token) fetchMusicList(listId);
|
||||
}, [listId, token]);
|
||||
return (
|
||||
<AudioContext.Provider
|
||||
value={{
|
||||
setListId,
|
||||
listId,
|
||||
onNext,
|
||||
onPrevious,
|
||||
}}
|
||||
>
|
||||
<AudioPlayer
|
||||
title={music?.title}
|
||||
src={music?.url || ''}
|
||||
artist={music?.artist}
|
||||
cover={music?.cover}
|
||||
mode={playMode}
|
||||
pressNext={onNext}
|
||||
pressPrevious={onPrevious}
|
||||
onPlayEnd={onPlayEnd}
|
||||
onChangeMode={changeMode}
|
||||
/>
|
||||
{children}
|
||||
</AudioContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioProvider;
|
||||
@ -1,11 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AudioContext } from '@/contexts/songs';
|
||||
|
||||
const useMusic = () => {
|
||||
const music = React.useContext(AudioContext);
|
||||
|
||||
return music;
|
||||
};
|
||||
|
||||
export default useMusic;
|
||||
@ -79,10 +79,11 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
}, [location.pathname]);
|
||||
return (
|
||||
<div
|
||||
className='h-screen relative flex bg-primary-50 dark:bg-black items-stretch'
|
||||
className='h-screen relative flex items-stretch overflow-hidden'
|
||||
style={{
|
||||
backgroundImage: `url(${b64img})`,
|
||||
backgroundImage: b64img ? `url(${b64img})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<SideBar
|
||||
@ -90,14 +91,19 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
open={openSideBar}
|
||||
onClose={() => setOpenSideBar(false)}
|
||||
/>
|
||||
<div
|
||||
<motion.div
|
||||
layout
|
||||
ref={contentRef}
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className={clsx(
|
||||
'overflow-y-auto flex-1 rounded-md m-1 bg-content1 pb-10 md:pb-0',
|
||||
openSideBar ? 'ml-0' : 'ml-1',
|
||||
!b64img && 'shadow-inner',
|
||||
b64img && '!bg-opacity-50 backdrop-blur-none dark:bg-background',
|
||||
'overflow-x-hidden'
|
||||
'flex-1 overflow-y-auto',
|
||||
'bg-white/60 dark:bg-black/40 backdrop-blur-xl',
|
||||
'shadow-[0_8px_32px_0_rgba(31,38,135,0.07)]',
|
||||
'transition-all duration-300 ease-in-out',
|
||||
openSideBar ? 'm-3 ml-0 rounded-3xl border border-white/40 dark:border-white/10' : 'm-0 rounded-none',
|
||||
'pb-10 md:pb-0'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@ -109,15 +115,12 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
'z-30 m-2 mb-0 sticky top-2 left-0'
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
<div
|
||||
className={clsx(
|
||||
'mr-1 ease-in-out ml-0 md:relative z-50 md:z-auto',
|
||||
openSideBar && 'pl-2 absolute',
|
||||
openSideBar && 'pl-2',
|
||||
'md:!ml-0 md:pl-0'
|
||||
)}
|
||||
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
|
||||
initial={{ marginLeft: 0 }}
|
||||
animate={{ marginLeft: openSideBar ? '15rem' : 0 }}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
@ -127,7 +130,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
>
|
||||
{openSideBar ? <MdMenuOpen size={24} /> : <MdMenu size={24} />}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
<Breadcrumbs isDisabled size='lg'>
|
||||
{title?.map((item, index) => (
|
||||
<BreadcrumbItem key={index}>
|
||||
@ -149,7 +152,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
<ErrorBoundary fallbackRender={errorFallbackRender}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,19 +1,20 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Divider } from '@heroui/divider';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Link } from '@heroui/link';
|
||||
import { Skeleton } from '@heroui/skeleton';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useRequest } from 'ahooks';
|
||||
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 RotatingText from '@/components/rotating_text';
|
||||
|
||||
import { usePreloadImages } from '@/hooks/use-preload-images';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import {
|
||||
BsCodeSlash,
|
||||
BsCpu,
|
||||
BsGithub,
|
||||
BsGlobe,
|
||||
BsPlugin,
|
||||
BsTelegram,
|
||||
BsTencentQq
|
||||
} from 'react-icons/bs';
|
||||
import { IoDocument, IoRocketSharp } from 'react-icons/io5';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
@ -22,184 +23,168 @@ function VersionInfo () {
|
||||
const { data, loading, error } = useRequest(WebUIManager.GetNapCatVersion);
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex items-center gap-2 text-2xl font-bold'>
|
||||
<div className='text-primary-500 drop-shadow-md'>NapCat</div>
|
||||
{error
|
||||
? (
|
||||
error.message
|
||||
)
|
||||
: loading
|
||||
? (
|
||||
<Spinner size='sm' />
|
||||
)
|
||||
: (
|
||||
<RotatingText
|
||||
texts={['WebUI', data?.version ?? '']}
|
||||
mainClassName='overflow-hidden flex items-center bg-primary-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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{error ? (
|
||||
<Chip color="danger" variant="flat" size="sm">{error.message}</Chip>
|
||||
) : loading ? (
|
||||
<Spinner size='sm' color="default" />
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip size="sm" color="default" variant="flat" className="text-default-500">WebUI v0.0.6</Chip>
|
||||
<Chip size="sm" color="primary" variant="flat">Core {data?.version}</Chip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
const features = [
|
||||
{
|
||||
icon: <IoRocketSharp size={20} />,
|
||||
title: '高性能架构',
|
||||
desc: 'Node.js + Native 混合架构,资源占用低,响应速度快。',
|
||||
className: 'bg-primary-50 text-primary'
|
||||
},
|
||||
[isDark, isLoading, loadedUrls]
|
||||
);
|
||||
|
||||
const renderImage = useMemo(
|
||||
() => (baseUrl: string, alt: string) => {
|
||||
const imageUrl = getImageUrl(baseUrl);
|
||||
|
||||
if (!imageUrl) {
|
||||
return <Skeleton className='h-16 rounded-lg' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
className='flex-1 pointer-events-none select-none rounded-none'
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
/>
|
||||
);
|
||||
{
|
||||
icon: <BsGlobe size={20} />,
|
||||
title: '全平台支持',
|
||||
desc: '适配 Windows、Linux 及 Docker 环境。',
|
||||
className: 'bg-success-50 text-success'
|
||||
},
|
||||
[getImageUrl]
|
||||
);
|
||||
{
|
||||
icon: <BsCodeSlash size={20} />,
|
||||
title: 'OneBot 11',
|
||||
desc: '深度集成标准协议,兼容现有生态。',
|
||||
className: 'bg-warning-50 text-warning'
|
||||
},
|
||||
{
|
||||
icon: <BsPlugin size={20} />,
|
||||
title: '极易扩展',
|
||||
desc: '提供丰富的 API 接口与 WebHook 支持。',
|
||||
className: 'bg-secondary-50 text-secondary'
|
||||
}
|
||||
];
|
||||
|
||||
const links = [
|
||||
{ icon: <BsGithub />, name: 'GitHub', href: 'https://github.com/NapNeko/NapCatQQ' },
|
||||
{ icon: <BsTelegram />, name: 'Telegram', href: 'https://t.me/napcatqq' },
|
||||
{ icon: <BsTencentQq />, name: 'QQ 群 1', href: 'https://qm.qq.com/q/F9cgs1N3Mc' },
|
||||
{ icon: <BsTencentQq />, name: 'QQ 群 2', href: 'https://qm.qq.com/q/hSt0u9PVn' },
|
||||
{ icon: <IoDocument />, name: '文档', href: 'https://napcat.napneko.icu/' },
|
||||
];
|
||||
|
||||
const cardStyle = "bg-default/40 backdrop-blur-lg border-none shadow-none";
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>关于 NapCat WebUI</title>
|
||||
<section className='max-w-7xl py-8 md:py-10 px-5 mx-auto space-y-10'>
|
||||
<div className='w-full flex flex-col md:flex-row gap-4'>
|
||||
<div className='flex flex-col md:flex-row items-center'>
|
||||
<HoverTiltedCard imageSrc={logo} overlayContent='' />
|
||||
</div>
|
||||
<div className='flex-1 flex flex-col gap-2 py-2'>
|
||||
<VersionInfo />
|
||||
<div className='space-y-1'>
|
||||
<p className='font-bold text-primary-400'>NapCat 是什么?</p>
|
||||
<p className='text-default-800'>
|
||||
基于TypeScript构建的Bot框架,通过相应的启动器或者框架,主动调用QQ
|
||||
Node模块提供给客户端的接口,实现Bot的功能.
|
||||
<div className='flex flex-col h-full w-full gap-6 p-2 md:p-6'>
|
||||
<title>关于 - NapCat WebUI</title>
|
||||
|
||||
{/* 头部标题区 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-3 text-default-900">
|
||||
<Image src={logo} alt="NapCat Logo" width={32} height={32} />
|
||||
关于 NapCat
|
||||
</h1>
|
||||
<div className="flex items-center gap-4 text-small text-default-500">
|
||||
<p>现代化、轻量级的 QQ 机器人框架</p>
|
||||
<Divider orientation="vertical" className="h-4" />
|
||||
<VersionInfo />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider className="opacity-50" />
|
||||
|
||||
{/* 主内容区:双栏布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-grow">
|
||||
|
||||
{/* 左侧:介绍与特性 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card shadow="sm" className={cardStyle}>
|
||||
<CardHeader className="pb-0 pt-4 px-4 flex-col items-start">
|
||||
<h2 className="text-lg font-bold">项目简介</h2>
|
||||
</CardHeader>
|
||||
<CardBody className="py-4 text-default-600 leading-relaxed space-y-2">
|
||||
<p>
|
||||
NapCat (瞌睡猫) 是一个致力于打破 QQ 机器人开发壁垒的开源项目。我们利用 NTQQ 的底层能力,
|
||||
构建了一个无需 GUI 即可在服务器端稳定运行的 Headless 框架。
|
||||
</p>
|
||||
<p className='font-bold text-primary-400'>魔法版介绍</p>
|
||||
<p className='text-default-800'>
|
||||
猫猫框架通过魔法的手段获得了 QQ 的发送消息、接收消息等接口。
|
||||
为了方便使用,猫猫框架将通过一种名为 OneBot 的约定将你的 HTTP /
|
||||
WebSocket 请求按照规范读取,
|
||||
再去调用猫猫框架所获得的QQ发送接口之类的接口。
|
||||
<p>
|
||||
无论是个人开发者还是企业用户,NapCat 都能提供开箱即用的 OneBot 11 协议支持,
|
||||
助您快速将创意转化为现实。
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{features.map((item, index) => (
|
||||
<Card key={index} shadow="sm" className={cardStyle}>
|
||||
<CardBody className="flex flex-row items-start gap-4 p-4">
|
||||
<div className={`p-3 rounded-lg ${item.className}`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-default-900">{item.title}</h3>
|
||||
<p className="text-small text-default-500 mt-1">{item.desc}</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</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 text-primary-500'>
|
||||
<BsTencentQq size={16} />
|
||||
</span>
|
||||
<span>官方社群1</span>
|
||||
|
||||
{/* 右侧:信息与链接 */}
|
||||
<div className="space-y-6">
|
||||
<Card shadow="sm" className={cardStyle}>
|
||||
<CardHeader className="pb-0 pt-4 px-4">
|
||||
<h2 className="text-lg font-bold">相关资源</h2>
|
||||
</CardHeader>
|
||||
<CardBody className="py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{links.map((link, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
isExternal
|
||||
href={link.href}
|
||||
className="flex items-center justify-between p-3 rounded-xl hover:bg-default-100/50 transition-colors text-default-600"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
{link.icon}
|
||||
{link.name}
|
||||
</span>
|
||||
<span className="text-tiny text-default-400">跳转 →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</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 text-primary-500'>
|
||||
<BsTencentQq size={16} />
|
||||
</span>
|
||||
<span>官方社群2</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
as={Link}
|
||||
shadow='sm'
|
||||
isPressable
|
||||
isExternal
|
||||
href='https://t.me/napcatqq'
|
||||
>
|
||||
<CardBody className='flex-row items-center gap-2'>
|
||||
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
|
||||
<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 text-primary-500'>
|
||||
<IoDocument size={16} />
|
||||
</span>
|
||||
<span>使用文档</span>
|
||||
<Card shadow="sm" className={cardStyle}>
|
||||
<CardHeader className="pb-0 pt-4 px-4">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<BsCpu /> 技术栈
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardBody className="py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['TypeScript', 'React', 'Vite', 'Node.js', 'Electron', 'HeroUI'].map((tech) => (
|
||||
<Chip key={tech} size="sm" variant="flat" className="bg-default-100/50 text-default-600">
|
||||
{tech}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<NapCatRepoInfo />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
{/* 底部版权 - 移出 grid 布局 */}
|
||||
<div className="w-full text-center text-tiny text-default-400 py-4 mt-auto flex flex-col items-center gap-1">
|
||||
<p className="flex items-center justify-center gap-1">
|
||||
Made with <span className="text-danger">❤️</span> by NapCat Team
|
||||
</p>
|
||||
<p>MIT License © {new Date().getFullYear()}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
import { Input } from '@heroui/input';
|
||||
import { Button } from '@heroui/button';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
@ -11,8 +10,6 @@ import SaveButtons from '@/components/button/save_buttons';
|
||||
import FileInput from '@/components/input/file_input';
|
||||
import ImageInput from '@/components/input/image_input';
|
||||
|
||||
import useMusic from '@/hooks/use-music';
|
||||
|
||||
import { siteConfig } from '@/config/site';
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
@ -43,7 +40,6 @@ const WebUIConfigCard = () => {
|
||||
} = useForm<IConfig['webui']>({
|
||||
defaultValues: {
|
||||
background: '',
|
||||
musicListID: '',
|
||||
customIcons: {},
|
||||
},
|
||||
});
|
||||
@ -53,7 +49,6 @@ const WebUIConfigCard = () => {
|
||||
key.customIcons,
|
||||
{}
|
||||
);
|
||||
const { setListId, listId } = useMusic();
|
||||
const [registrationOptions, setRegistrationOptions] = useState<any>(null);
|
||||
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||
|
||||
@ -75,14 +70,12 @@ const WebUIConfigCard = () => {
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setWebuiValue('musicListID', listId);
|
||||
setWebuiValue('customIcons', customIcons);
|
||||
setWebuiValue('background', b64img);
|
||||
};
|
||||
|
||||
const onSubmit = handleWebuiSubmit((data) => {
|
||||
try {
|
||||
setListId(data.musicListID);
|
||||
setCustomIcons(data.customIcons);
|
||||
setB64img(data.background);
|
||||
toast.success('保存成功');
|
||||
@ -94,7 +87,7 @@ const WebUIConfigCard = () => {
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [listId, customIcons, b64img]);
|
||||
}, [customIcons, b64img]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -130,20 +123,6 @@ const WebUIConfigCard = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full'>WebUI音乐播放器</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name='musicListID'
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label='网易云音乐歌单ID(网页内音乐播放器)'
|
||||
placeholder='请输入歌单ID'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full'>背景图</div>
|
||||
<Controller
|
||||
|
||||
@ -6,6 +6,8 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
|
||||
import HoverEffectCard from '@/components/effect_card';
|
||||
import { title } from '@/components/primitives';
|
||||
import QrCodeLogin from '@/components/qr_code_login';
|
||||
@ -13,9 +15,9 @@ import QuickLogin from '@/components/quick_login';
|
||||
import type { QQItem } from '@/components/quick_login';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import PureLayout from '@/layouts/pure';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
export default function QQLoginPage () {
|
||||
const navigate = useNavigate();
|
||||
@ -112,7 +114,12 @@ export default function QQLoginPage () {
|
||||
<>
|
||||
<title>QQ登录 - NapCat WebUI</title>
|
||||
<PureLayout>
|
||||
<div className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.5, type: 'spring', stiffness: 120, damping: 20 }}
|
||||
className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'
|
||||
>
|
||||
<HoverEffectCard
|
||||
className='items-center gap-4 pt-0 pb-6 bg-default-50'
|
||||
maxXRotation={3}
|
||||
@ -169,7 +176,7 @@ export default function QQLoginPage () {
|
||||
</Button>
|
||||
</CardBody>
|
||||
</HoverEffectCard>
|
||||
</div>
|
||||
</motion.div>
|
||||
</PureLayout>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -8,15 +8,17 @@ import { toast } from 'react-hot-toast';
|
||||
import { IoKeyOutline } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
|
||||
import key from '@/const/key';
|
||||
|
||||
import HoverEffectCard from '@/components/effect_card';
|
||||
import { title } from '@/components/primitives';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import PureLayout from '@/layouts/pure';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
export default function WebLoginPage () {
|
||||
const urlSearchParams = new URLSearchParams(window.location.search);
|
||||
@ -150,7 +152,12 @@ export default function WebLoginPage () {
|
||||
<>
|
||||
<title>WebUI登录 - NapCat WebUI</title>
|
||||
<PureLayout>
|
||||
<div className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.5, type: "spring", stiffness: 120, damping: 20 }}
|
||||
className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'
|
||||
>
|
||||
<HoverEffectCard
|
||||
className='items-center gap-4 pt-0 pb-6 bg-default-50'
|
||||
maxXRotation={3}
|
||||
@ -257,7 +264,7 @@ export default function WebLoginPage () {
|
||||
</Button>
|
||||
</CardBody>
|
||||
</HoverEffectCard>
|
||||
</div>
|
||||
</motion.div>
|
||||
</PureLayout>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -6,15 +6,45 @@
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'Aa偷吃可爱长大的',
|
||||
PingFang SC,
|
||||
Helvetica Neue,
|
||||
Microsoft YaHei,
|
||||
'Quicksand',
|
||||
'Nunito',
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
'PingFang SC',
|
||||
'Microsoft YaHei',
|
||||
sans-serif !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-smooth: always;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
:root {
|
||||
--heroui-primary: 217.2 91.2% 59.8%; /* 自然的现代蓝 */
|
||||
--heroui-primary-foreground: 210 40% 98%;
|
||||
--heroui-radius: 0.75rem;
|
||||
--text-primary: 222.2 47.4% 11.2%;
|
||||
--text-secondary: 215.4 16.3% 46.9%;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: hsl(var(--text-primary));
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dark h1, .dark h2, .dark h3, .dark h4, .dark h5, .dark h6 {
|
||||
color: hsl(210 40% 98%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--heroui-primary: 217.2 91.2% 59.8%;
|
||||
--heroui-primary-foreground: 210 40% 98%;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@ -34,23 +64,29 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #ffcdba;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
-webkit-border-radius: 2em;
|
||||
-moz-border-radius: 2em;
|
||||
border-radius: 2em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(147, 147, 153, 0.5);
|
||||
-webkit-border-radius: 2em;
|
||||
-moz-border-radius: 2em;
|
||||
border-radius: 2em;
|
||||
background-color: rgba(255, 182, 193, 0.4); /* 浅粉色滚动条 */
|
||||
border-radius: 3px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 127, 172, 0.6);
|
||||
}
|
||||
|
||||
.monaco-editor {
|
||||
|
||||
@ -1,122 +0,0 @@
|
||||
import { PlayMode } from '@/const/enum';
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import type {
|
||||
FinalMusic,
|
||||
Music163ListResponse,
|
||||
Music163URLResponse,
|
||||
} from '@/types/music';
|
||||
|
||||
/**
|
||||
* 获取网易云音乐歌单
|
||||
* @param id 歌单id
|
||||
* @returns 歌单信息
|
||||
*/
|
||||
export const get163MusicList = async (id: string) => {
|
||||
const res = await WebUIManager.proxy<Music163ListResponse>(
|
||||
'https://wavesgame.top/playlist/track/all?id=' + id
|
||||
);
|
||||
// const res = await request.get<Music163ListResponse>(
|
||||
// `https://wavesgame.top/playlist/track/all?id=${id}`
|
||||
// )
|
||||
if (res?.data?.code !== 200) {
|
||||
throw new Error('获取歌曲列表失败');
|
||||
}
|
||||
return res.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取歌曲地址
|
||||
* @param ids 歌曲id
|
||||
* @returns 歌曲地址
|
||||
*/
|
||||
export const getSongsURL = async (ids: number[]) => {
|
||||
const _ids = ids.reduce((prev, cur, index) => {
|
||||
const groupIndex = Math.floor(index / 10);
|
||||
if (!prev[groupIndex]) {
|
||||
prev[groupIndex] = [];
|
||||
}
|
||||
prev[groupIndex].push(cur);
|
||||
return prev;
|
||||
}, [] as number[][]);
|
||||
const res = await Promise.all(
|
||||
_ids.map(async (id) => {
|
||||
const res = await WebUIManager.proxy<Music163URLResponse>(
|
||||
`https://wavesgame.top/song/url?id=${id.join(',')}`
|
||||
);
|
||||
if (res?.data?.code !== 200) {
|
||||
throw new Error('获取歌曲地址失败');
|
||||
}
|
||||
return res.data.data;
|
||||
})
|
||||
);
|
||||
const result = res.reduce((prev, cur) => {
|
||||
return prev.concat(...cur);
|
||||
}, []);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取网易云音乐歌单歌曲
|
||||
* @param id 歌单id
|
||||
* @returns 歌曲信息
|
||||
*/
|
||||
export const get163MusicListSongs = async (id: string) => {
|
||||
const listRes = await get163MusicList(id);
|
||||
const songs = listRes.songs.map((song) => song.id);
|
||||
const songsRes = await getSongsURL(songs);
|
||||
const finalMusic: FinalMusic[] = [];
|
||||
for (let i = 0; i < listRes.songs.length; i++) {
|
||||
const song = listRes.songs[i];
|
||||
const music = songsRes.find((s) => s.id === song.id);
|
||||
const songURL = music?.url;
|
||||
if (songURL) {
|
||||
finalMusic.push({
|
||||
id: song.id,
|
||||
url: songURL.replace(/http:\/\//, '//').replace(/https:\/\//, '//'),
|
||||
title: song.name,
|
||||
artist: song.ar.map((p) => p.name).join('/'),
|
||||
cover: song.al.picUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
return finalMusic;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取随机音乐
|
||||
* @param ids 歌曲id
|
||||
* @param currentId 当前音乐id
|
||||
* @returns 随机音乐id
|
||||
*/
|
||||
export const getRandomMusic = (ids: number[], currentId: number): number => {
|
||||
const randomIndex = Math.floor(Math.random() * ids.length);
|
||||
const randomId = ids[randomIndex];
|
||||
if (randomId === currentId) {
|
||||
return getRandomMusic(ids, currentId);
|
||||
}
|
||||
return randomId;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取下一首音乐id
|
||||
* @param ids 歌曲id
|
||||
* @param currentId 当前音乐ID
|
||||
* @param mode 播放模式
|
||||
*/
|
||||
export const getNextMusic = (
|
||||
musics: FinalMusic[],
|
||||
currentId: number,
|
||||
mode: PlayMode
|
||||
): number => {
|
||||
const ids = musics.map((music) => music.id);
|
||||
if (mode === PlayMode.Loop) {
|
||||
const currentIndex = ids.findIndex((id) => id === currentId);
|
||||
const nextIndex = currentIndex + 1;
|
||||
return ids[nextIndex] || ids[0];
|
||||
}
|
||||
if (mode === PlayMode.Random) {
|
||||
return getRandomMusic(ids, currentId);
|
||||
}
|
||||
return currentId;
|
||||
};
|
||||
@ -25,18 +25,32 @@ export default {
|
||||
light: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#f31260',
|
||||
DEFAULT: '#FF7FAC', // 樱花粉
|
||||
foreground: '#fff',
|
||||
50: '#fee7ef',
|
||||
100: '#fdd0df',
|
||||
200: '#faa0bf',
|
||||
300: '#f871a0',
|
||||
400: '#f54180',
|
||||
500: '#f31260',
|
||||
600: '#c20e4d',
|
||||
700: '#920b3a',
|
||||
800: '#610726',
|
||||
900: '#310413',
|
||||
50: '#FFF0F5',
|
||||
100: '#FFE4E9',
|
||||
200: '#FFCDD9',
|
||||
300: '#FF9EB5',
|
||||
400: '#FF7FAC',
|
||||
500: '#F33B7C',
|
||||
600: '#C92462',
|
||||
700: '#991B4B',
|
||||
800: '#691233',
|
||||
900: '#380A1B',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: '#88C0D0', // 冰霜蓝
|
||||
foreground: '#fff',
|
||||
50: '#F0F9FC',
|
||||
100: '#D7F0F8',
|
||||
200: '#AEE1F2',
|
||||
300: '#88C0D0',
|
||||
400: '#5E9FBF',
|
||||
500: '#4C8DAE',
|
||||
600: '#3A708C',
|
||||
700: '#2A546A',
|
||||
800: '#1A3748',
|
||||
900: '#0B1B26',
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: '#DB3694',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user